Compare commits

...

281 Commits

Author SHA1 Message Date
Vincent Koc
99de26761d fix(telegram): use dm thread hook session for plugin commands 2026-03-09 11:03:05 -07:00
Vincent Koc
0225e4c110 docs: format contributing whitespace 2026-03-09 11:02:34 -07:00
Pejman Pour-Moezzi
162232ae2f fix(acp): propagate setSessionMode gateway errors to client (#41185)
* fix(acp): propagate setSessionMode gateway errors to client

* fix: add changelog entry for ACP setSessionMode propagation (#41185) (thanks @pejmanjohn)

---------

Co-authored-by: Pejman Pour-Moezzi <481729+pejmanjohn@users.noreply.github.com>
Co-authored-by: Onur <onur@textcortex.com>
2026-03-09 10:58:05 -07:00
Pejman Pour-Moezzi
ae824ab269 fix(acp): map error states to end_turn instead of unconditional refusal (#41187)
* fix(acp): map error states to end_turn instead of unconditional refusal

* fix: map ACP error stop reason to end_turn (#41187) (thanks @pejmanjohn)

---------

Co-authored-by: Pejman Pour-Moezzi <481729+pejmanjohn@users.noreply.github.com>
Co-authored-by: Onur <onur@textcortex.com>
2026-03-09 10:58:05 -07:00
Radek Sienkiewicz
4808bf526e Update CONTRIBUTING.md 2026-03-09 10:58:05 -07:00
Robin Waslander
75c71eb18e Add Robin Waslander to maintainers 2026-03-09 10:58:05 -07:00
Radek Sienkiewicz
e5af902dda Update CONTRIBUTING.md 2026-03-09 10:58:05 -07:00
xaeon2026
2209cc5832 Allow ACP sessions.patch lineage fields on ACP session keys (#40995)
Merged via squash.

Prepared head SHA: c1191edc08
Co-authored-by: xaeon2026 <264572156+xaeon2026@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 10:58:05 -07:00
Charles Dusek
fce77e2d45 fix(agents): bound compaction retry wait and drain embedded runs on restart (#40324)
Merged via squash.

Prepared head SHA: cfd99562d6
Co-authored-by: cgdusek <38732970+cgdusek@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-09 10:58:05 -07:00
Daniel Reis
d0df6f3a4c test(context-engine): add bundle chunk isolation tests for registry (#40460)
Merged via squash.

Prepared head SHA: 44622abfbc
Co-authored-by: dsantoreis <220753637+dsantoreis@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-09 10:58:05 -07:00
Joshua Lelon Mitchell
e5ef7cbdab fix(swiftformat): exclude HostEnvSecurityPolicy.generated.swift from formatters (#39969) 2026-03-09 10:58:04 -07:00
opriz
d59eb6db5b fix(kimi-coding): fix kimi tool format: use native Anthropic tool schema instead of OpenAI … (openclaw#40008)
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: opriz <51957849+opriz@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-09 10:58:04 -07:00
Radek Sienkiewicz
6fac513119 fix(ui): preserve control-ui auth across refresh (#40892)
Merged via squash.

Prepared head SHA: f9b2375892
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-03-09 10:58:04 -07:00
Peter Steinberger
57b90adbf2 build: sync plugin versions for 2026.3.9 2026-03-09 10:58:04 -07:00
Peter Steinberger
0cf4c46004 fix: stabilize launchd paths and appcast secret scan 2026-03-09 10:58:04 -07:00
Peter Steinberger
1695b9203c build: bump unreleased version to 2026.3.9 2026-03-09 10:58:04 -07:00
Peter Steinberger
7cc3cc0687 fix(onboard): avoid persisting talk fallback on fresh setup 2026-03-09 10:58:04 -07:00
Peter Steinberger
0413f9576e fix(launchd): harden macOS launchagent install permissions 2026-03-09 10:58:04 -07:00
Peter Steinberger
559b38d507 test: narrow gateway loop signal harness 2026-03-09 10:58:04 -07:00
Peter Steinberger
2ca87d06f7 chore: prepare 2026.3.8 npm release 2026-03-09 10:58:04 -07:00
Peter Steinberger
09f678599d fix(update): re-enable launchd service before updater bootstrap 2026-03-09 10:58:04 -07:00
Peter Steinberger
2b8828ea46 test: fix windows runtime and restart loop harnesses 2026-03-09 10:58:04 -07:00
Peter Steinberger
dbabaa0fb2 chore: update appcast for 2026.3.8-beta.1 2026-03-09 10:58:04 -07:00
Peter Steinberger
daf0ade96b chore: prepare 2026.3.8-beta.1 release 2026-03-09 10:58:04 -07:00
Peter Steinberger
6c11b4378a fix: normalize windows runtime shim executables 2026-03-09 10:58:04 -07:00
Peter Steinberger
ddc3a3fc71 test: fix Windows fake runtime bin fixtures 2026-03-09 10:58:04 -07:00
Peter Steinberger
56218dcc21 test: fix Node 24+ test runner and subagent registry mocks 2026-03-09 10:58:04 -07:00
Peter Steinberger
38068de8e9 docs: move 2026.3.8 entries back to unreleased 2026-03-09 10:58:04 -07:00
Peter Steinberger
83b453f48a chore: refresh secrets baseline 2026-03-09 10:58:04 -07:00
Peter Steinberger
98d52062e7 build: sync pnpm lockfile 2026-03-09 10:58:04 -07:00
Peter Steinberger
64910240b9 docs: reorder 2026.3.8 changelog by impact 2026-03-09 10:58:04 -07:00
Peter Steinberger
bd4d7f6137 refactor: flatten supervisor marker hints 2026-03-09 10:58:04 -07:00
Peter Steinberger
1b8f800487 refactor: split cron startup catch-up flow 2026-03-09 10:58:04 -07:00
Peter Steinberger
8cb688c44d refactor: extract telegram polling session 2026-03-09 10:58:04 -07:00
Peter Steinberger
9bde7ef39f build: update app deps except carbon 2026-03-09 10:58:04 -07:00
Peter Steinberger
3c4377651e fix: stagger missed cron jobs on restart (#18925) (thanks @rexlunae) 2026-03-09 10:58:04 -07:00
rexlunae
41a39085d3 fix(cron): stagger missed jobs on restart to prevent gateway overload
When the gateway restarts with many overdue cron jobs, they are now
executed with staggered delays to prevent overwhelming the gateway.

- Add missedJobStaggerMs config (default 5s between jobs)
- Add maxMissedJobsPerRestart limit (default 5 jobs immediately)
- Prioritize most overdue jobs by sorting by nextRunAtMs
- Reschedule deferred jobs to fire gradually via normal timer

Fixes #18892
2026-03-09 10:58:04 -07:00
Peter Steinberger
2220a58ff7 fix: abort telegram getupdates on shutdown (#23950) (thanks @Gkinthecodeland) 2026-03-09 10:58:04 -07:00
George Kalogirou
e383257552 fix(telegram): use manual signal forwarding to avoid cross-realm AbortSignal
AbortSignal.any() fails in Node.js when signals come from different module
contexts (grammY's internal signal vs local AbortController), producing:
"The signals[0] argument must be an instance of AbortSignal. Received an
instance of AbortSignal".

Replace with manual event forwarding that works across all realms.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:58:04 -07:00
George Kalogirou
fa6436eaf3 fix(telegram): abort in-flight getUpdates fetch on shutdown
When the gateway receives SIGTERM, runner.stop() stops the grammY polling
loop but does not abort the in-flight getUpdates HTTP request. That request
hangs for up to 30 seconds (the Telegram API timeout). If a new gateway
instance starts polling during that window, Telegram returns a 409 Conflict
error, causing message loss and requiring exponential backoff recovery.

This is especially problematic with service managers (launchd, systemd)
that restart the process immediately after SIGTERM.

Wire an AbortController into the fetch layer so every Telegram API request
(especially the long-polling getUpdates) aborts immediately on shutdown:

- bot.ts: Accept optional fetchAbortSignal in TelegramBotOptions; wrap
  the grammY fetch with AbortSignal.any() to merge the shutdown signal.
- monitor.ts: Create a per-iteration AbortController, pass its signal to
  createTelegramBot, and abort it from the SIGTERM handler, force-restart
  path, and finally block.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:58:04 -07:00
Peter Steinberger
1809b92f15 fix(skills): pin validated download roots 2026-03-09 10:58:04 -07:00
Peter Steinberger
4006e388e7 fix(node-host): bind bun and deno approval scripts 2026-03-09 10:58:04 -07:00
Peter Steinberger
b22d596e59 fix: detect launchd supervision via xpc service name (#20555) (thanks @dimat) 2026-03-09 10:58:04 -07:00
dimatu
38f192a29c fix(gateway): detect launchd supervision via XPC_SERVICE_NAME
On macOS, launchd sets XPC_SERVICE_NAME on managed processes but does
not set LAUNCH_JOB_LABEL or LAUNCH_JOB_NAME. Without checking
XPC_SERVICE_NAME, isLikelySupervisedProcess() returns false for
launchd-managed gateways, causing restartGatewayProcessWithFreshPid()
to fork a detached child instead of returning "supervised". The
detached child holds the gateway lock while launchd simultaneously
respawns the original process (KeepAlive=true), leading to an infinite
lock-timeout / restart loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:58:04 -07:00
merlin
95ed5781f6 fix: release gateway lock on restart failure + reply to Codex reviews
- Release gateway lock when in-process restart fails, so daemon
  restart/stop can still manage the process (Codex P2)
- P1 (env mismatch) already addressed: best-effort by design, documented
  in JSDoc
2026-03-09 10:58:04 -07:00
merlin
d275df82a2 fix: move config pre-flight before onNotLoaded in runServiceRestart (Codex P2)
The config check was positioned after onNotLoaded, which could send
SIGUSR1 to an unmanaged process before config was validated.
2026-03-09 10:58:04 -07:00
merlin
32f6501dbd fix: address bot review feedback on #35862
- Remove dead 'return false' in runServiceStart (Greptile)
- Include stack trace in run-loop crash guard error log (Greptile)
- Only catch startup errors on subsequent restarts, not initial start (Codex P1)
- Add JSDoc note about env var false positive edge case (Codex P1)
2026-03-09 10:58:03 -07:00
merlin
ce44b366db test: add runServiceStart config pre-flight tests (#35862)
Address Greptile review: add test coverage for runServiceStart path.
The error message copy-paste issue was already fixed in the DRY refactor
(uses params.serviceNoun instead of hardcoded 'restart').
2026-03-09 10:58:03 -07:00
merlin
2cb063b72e fix(gateway): catch startup failure in run loop to prevent process exit (#35862)
When an in-process restart (SIGUSR1) triggers a config-triggered restart
and the new config is invalid, params.start() throws and the while loop
exits, killing the process. On macOS this loses TCC permissions.

Wrap params.start() in try/catch: on failure, set server=null, log the
error, and wait for the next SIGUSR1 instead of crashing.
2026-03-09 10:58:03 -07:00
merlin
5d82c2cc89 fix(gateway): validate config before restart to prevent crash + macOS permission loss (#35862)
When 'openclaw gateway restart' is run with an invalid config, the new
process crashes on startup due to config validation failure. On macOS,
this causes Full Disk Access (TCC) permissions to be lost because the
respawned process has a different PID.

Add getConfigValidationError() helper and pre-flight config validation
in both runServiceRestart() and runServiceStart(). If config is invalid,
abort with a clear error message instead of crashing.

The config watcher's hot-reload path already had this guard
(handleInvalidSnapshot), but the CLI restart/start commands did not.

AI-assisted (OpenClaw agent, fully tested)
2026-03-09 10:58:03 -07:00
Peter Steinberger
78a1644a8a fix(msteams): enforce sender allowlists with route allowlists 2026-03-09 10:58:03 -07:00
Peter Steinberger
d6b26e22d5 test(cron): cover owner-only tool availability 2026-03-09 10:58:03 -07:00
Peter Steinberger
0d607942d5 fix(cron): restore owner-only tools for isolated runs 2026-03-09 10:58:03 -07:00
Peter Steinberger
cc919d3856 fix(browser): enforce redirect-hop SSRF checks 2026-03-09 10:58:03 -07:00
Peter Steinberger
8948ed8e33 fix: add changelog for restart timeout recovery (#40380) (thanks @dsantoreis) 2026-03-09 10:58:03 -07:00
DevMac
648213653d test(secrets): skip ACL-dependent runtime snapshot tests on windows 2026-03-09 10:58:03 -07:00
Daniel dos Santos Reis
cec7006abb fix(gateway): exit non-zero on restart shutdown timeout
When a config-change restart hits the force-exit timeout, exit with
code 1 instead of 0 so launchd/systemd treats it as a failure and
triggers a clean process restart. Stop-timeout stays at exit(0)
since graceful stops should not cause supervisor recovery.

Closes #36822
2026-03-09 10:58:03 -07:00
scoootscooob
2d8c8e7f26 fix(daemon): also enable LaunchAgent in repairLaunchAgentBootstrap
The repair/recovery path had the same missing `enable` guard as
`restartLaunchAgent`.  If launchd persists a "disabled" state after a
previous `bootout`, the `bootstrap` call in `repairLaunchAgentBootstrap`
fails silently, leaving the gateway unloaded in the recovery flow.

Add the same `enable` guard before `bootstrap` that was already applied
to `installLaunchAgent` and (in this PR) `restartLaunchAgent`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:58:03 -07:00
scoootscooob
e2c8c27c8c fix(daemon): enable LaunchAgent before bootstrap on restart
restartLaunchAgent was missing the launchctl enable call that
installLaunchAgent already performs. launchd can persist a "disabled"
state after bootout, causing bootstrap to silently fail and leaving the
gateway unloaded until a manual reinstall.

Fixes #39211

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:58:03 -07:00
Peter Steinberger
dad107630e test: fix windows secrets runtime ci 2026-03-09 10:58:03 -07:00
GazeKingNuWu
0d597ab800 fix: clear plugin discovery cache after plugin installation (openclaw#39752)
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: GazeKingNuWu <264914544+GazeKingNuWu@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-09 10:58:03 -07:00
Ayaan Zaidi
03bc43a503 Fix cron text announce delivery for Telegram targets (#40575)
Merged via squash.

Prepared head SHA: 54b1513c78
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-09 10:58:03 -07:00
Bronko
8e506634b1 fix(matrix): restore robust DM routing without the memberCount heuristic (#19736)
* fix(matrix): remove memberCount heuristic from DM detection

The memberCount === 2 check in isDirectMessage() misclassifies 2-person
group rooms (admin channels, monitoring rooms) as DMs, routing them to
the main session instead of their room-specific session.

Matrix already distinguishes DMs from groups at the protocol level via
m.direct account data and is_direct member state flags. Both are already
checked by client.dms.isDm() and hasDirectFlag(). The memberCount
heuristic only adds false positives for 2-person groups.

Move resolveMemberCount() below the protocol-level checks so it is only
reached for rooms not matched by m.direct or is_direct. This narrows its
role to diagnostic logging for confirmed group rooms.

Refs: #19739

* fix(matrix): add conservative fallback for broken DM flags

Some homeservers (notably Continuwuity) have broken m.direct account
data or never set is_direct on invite events. With the memberCount
heuristic removed, these DMs are no longer detected.

Add a conservative fallback that requires two signals before classifying
as DM: memberCount === 2 AND no explicit m.room.name. Group rooms almost
always have explicit names; DMs almost never do.

Error handling distinguishes M_NOT_FOUND (missing state event, expected
for unnamed rooms) from network/auth errors. Non-404 errors fall through
to group classification rather than guessing.

This is independently revertable — removing this commit restores pure
protocol-based detection without any heuristic fallback.

* fix(matrix): add parentPeer for DM room binding support

Add parentPeer to DM routes so conversations are bindable by room ID
while preserving DM trust semantics (secure 1:1, no group restrictions).

Suggested by @KirillShchetinin.

* fix(matrix): override DM detection for explicitly configured rooms

Builds on @robertcorreiro's config-driven approach from #9106.

Move resolveMatrixRoomConfig() before the DM check. If a room matches
a non-wildcard config entry (matchSource === "direct") and was
classified as DM, override the classification to group. This gives users
a deterministic escape hatch for misclassified rooms.

Wildcards are excluded from the override to avoid breaking DM routing
when a "*" catch-all exists. roomConfig is gated behind isRoom so DMs
never inherit group settings (skills, systemPrompt, autoReply).

This commit is independently droppable if the scope is too broad.

* test(matrix): add DM detection and config override tests

- 15 unit tests for direct.ts: all detection paths, priority order,
  M_NOT_FOUND vs network error handling, edge cases (whitespace names,
  API failures)
- 8 unit tests for rooms.ts: matchSource classification, wildcard
  safety for DM override, direct match priority over wildcard

* Changelog: note matrix DM routing follow-up

* fix(matrix): preserve DM fallback and room bindings

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-09 10:58:03 -07:00
Ayaan Zaidi
27d2ac8460 fix: dedupe inbound Telegram DM replies per agent (#40519)
Merged via squash.

Prepared head SHA: 6e235e7d1f
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-09 10:58:03 -07:00
Peter Steinberger
fbc8c19e52 build(protocol): sync generated swift models 2026-03-09 10:58:03 -07:00
Peter Steinberger
a7e5c7c18b fix(media): accept reader read result type 2026-03-09 10:58:03 -07:00
Peter Steinberger
def4b221d9 fix(agents): re-expose configured tools under restrictive profiles 2026-03-09 10:58:03 -07:00
Tak Hoffman
40542aba96 chore(acpx): move runtime test fixtures to test-utils (openclaw#40548)
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini
2026-03-09 10:58:03 -07:00
Ayaan Zaidi
f34b0e6c42 test: fix android talk config contract fixture 2026-03-09 10:58:03 -07:00
Kyle
84064d08d3 fix(plugin-sdk): remove remaining bundled plugin src imports (openclaw#39638)
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Kyle <3477429+kyledh@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-09 10:58:03 -07:00
Kesku
022923be15 alphabetize web search providers (#40259)
Merged via squash.

Prepared head SHA: be6350e5ae
Co-authored-by: kesku <62210496+kesku@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-09 10:58:03 -07:00
Mariano
a95cce9f2f ACP: add optional ingress provenance receipts (#40473)
Merged via squash.

Prepared head SHA: b63e46dd94
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 10:58:03 -07:00
Tyson Cung
81bd382e6c fix(telegram): add download timeout to prevent polling loop hang (#40098)
Merged via squash.

Prepared head SHA: abdfa1a35f
Co-authored-by: tysoncung <45380903+tysoncung@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-09 10:58:03 -07:00
yuweuii
5b28093b01 fix(models): use 1M context for openai-codex gpt-5.4 (#37876)
Merged via squash.

Prepared head SHA: c41020779e
Co-authored-by: yuweuii <82372187+yuweuii@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-09 10:58:03 -07:00
Radek Sienkiewicz
f1449a5590 docs(changelog): correct Control UI contributor credit (#40420)
Merged via squash.

Prepared head SHA: e4295fe18b
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-03-09 10:58:03 -07:00
Vincent Koc
3fe81fdb96 fix(tests): correct security check failure 2026-03-09 10:58:03 -07:00
Vincent Koc
ccd6043240 Docker: improve build cache reuse (#40351)
* Docker: improve build cache reuse

* Tests: cover Docker build cache layout

* Docker: fix sandbox cache mount continuations

* Docker: document qr-import manifest scope

* Docker: narrow e2e install inputs

* CI: cache Docker builds in workflows

* CI: route sandbox smoke through setup script

* CI: keep sandbox smoke on script path
2026-03-09 10:58:03 -07:00
Radek Sienkiewicz
f3d6a3018a gateway: fix global Control UI 404s for symlinked wrappers and bundled package roots (#40385)
Merged via squash.

Prepared head SHA: 567b3ed684
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-03-09 10:58:02 -07:00
Peter Steinberger
df31d0e8b6 chore(docs): drop refactor cleanup tracker 2026-03-09 10:58:02 -07:00
Peter Steinberger
65623e1559 refactor(models): split provider discovery helpers 2026-03-09 10:58:02 -07:00
Peter Steinberger
b0973880b4 refactor(models): split models.json planning from writes 2026-03-09 10:58:02 -07:00
Peter Steinberger
98aa2a8cf0 refactor(agents): extract provider model normalization 2026-03-09 10:58:02 -07:00
Peter Steinberger
f7eccaee4a refactor(models): extract list row builders 2026-03-09 10:58:02 -07:00
Peter Steinberger
4b694d565d refactor: harden browser runtime profile handling 2026-03-09 10:58:02 -07:00
bbblending
7875fb6c27 fix(config): refresh runtime snapshot from disk after write. Fixes #37175 (#37313)
Merged via squash.

Prepared head SHA: 69e1861abf
Co-authored-by: bbblending <122739024+bbblending@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-09 10:58:02 -07:00
Peter Steinberger
43451ebab7 refactor: harden browser relay CDP flows 2026-03-09 10:58:02 -07:00
Vincent Koc
017b389549 telegram: align sent hooks with command session 2026-03-09 09:36:00 -07:00
Vincent Koc
0504fb35fe Merge branch 'main' into vincentkoc-code/telegram-message-sent-parity 2026-03-08 16:40:02 -07:00
Peter Steinberger
d47aa6bae8 docs(changelog): remove rebase marker 2026-03-08 23:39:03 +00:00
Peter Steinberger
661af2acd3 fix(agents): bootstrap runtime plugins before context-engine resolution 2026-03-08 23:38:38 +00:00
Peter Steinberger
936ac22ec2 refactor: share channel config adapter base 2026-03-08 23:38:24 +00:00
Peter Steinberger
bf601db3fc test: dedupe brave llm-context rejection cases 2026-03-08 23:38:24 +00:00
Peter Steinberger
5845b5bfba refactor: share multi-account config schema fragments 2026-03-08 23:38:24 +00:00
Peter Steinberger
52a253f18c refactor: reuse broadcast route key construction 2026-03-08 23:38:24 +00:00
Peter Steinberger
3f2f007c9a refactor: extract gateway port diagnostics helper 2026-03-08 23:38:24 +00:00
Peter Steinberger
32a6eae576 refactor: share gateway argv parsing 2026-03-08 23:38:24 +00:00
Peter Steinberger
8d7778d1d6 refactor: dedupe plugin runtime stores 2026-03-08 23:38:24 +00:00
Peter Steinberger
3e70109cb2 docs: add refactor cluster backlog 2026-03-08 23:38:24 +00:00
0xsline
024857050a fix: normalize openai-codex gpt-5.4 transport overrides 2026-03-08 23:35:21 +00:00
Doruk Ardahan
3da8882a02 test(models): refresh list assertions after main sync 2026-03-08 23:30:58 +00:00
Doruk Ardahan
b2b99f0325 fix(models): keep --all aligned with synthetic catalog rows 2026-03-08 23:30:58 +00:00
Vincent Koc
a3dc4b5a57 fix(tui): improve color contrast for light-background terminals (#40345)
* fix(tui): improve colour contrast for light-background terminals (#38636)

Detect light terminal backgrounds via COLORFGBG and apply a WCAG
AA-compliant light palette. Adds OPENCLAW_THEME=light|dark env var
override for terminals without auto-detection.

Uses proper sRGB linearisation and WCAG 2.1 contrast ratios to pick
whichever text palette (dark or light) has higher contrast against
the detected background colour.

Co-authored-by: ademczuk <ademczuk@users.noreply.github.com>

* Update CHANGELOG.md

---------

Co-authored-by: ademczuk <andrew.demczuk@gmail.com>
Co-authored-by: ademczuk <ademczuk@users.noreply.github.com>
2026-03-08 16:17:28 -07:00
Vincent Koc
211f68f8ad docs(changelog): move post-2026.3.8 entries to unreleased (#40342)
* docs(changelog): move post-2026.3.8 entries to unreleased

* Update CHANGELOG.md
2026-03-08 16:11:53 -07:00
Vincent Koc
3f3f66a5f7 Docker: trim runtime image payload (#40307)
* Docker: shrink runtime image payload

* Docker: add runtime pnpm opt-in

* Docker: collapse helper entrypoint chmod layers

* Docker: restore bundled pnpm runtime

* Update CHANGELOG.md
2026-03-08 16:07:04 -07:00
langdon
bd1fe4d8b4 fix(run-openclaw-podman): add SELinux :Z mount option on enforcing/permissive hosts (#39449)
* fix(run-openclaw-podman): add SELinux :Z mount option on Linux with enforcing/permissive SELinux

* fix(quadlet): add SELinux :Z label to openclaw.container.in volume mount

* fix(podman): add SELinux :Z mount option for Fedora/RHEL hosts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: sallyom <somalley@redhat.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:53:09 -04:00
Vincent Koc
3ea3a1c0ca Update CHANGELOG.md 2026-03-08 15:35:13 -07:00
Vincent Koc
da6592b681 Update CHANGELOG.md 2026-03-08 15:34:56 -07:00
Mariano
abb8f63107 iOS: auto-load the scoped gateway canvas with safe fallback (#40282)
Merged via squash.

- mb-server validation: `swift test --package-path apps/shared/OpenClawKit --filter GatewayNodeSessionTests`
- mb-server validation: `pnpm build`
- Scope note: top-level `RootTabs` shell change was intentionally removed from this PR before merge
2026-03-08 22:47:39 +01:00
Mariano
e806c479f5 Gateway/iOS: replay queued foreground actions safely after resume (#40281)
Merged via squash.

- Local validation: `pnpm exec vitest run --config vitest.gateway.config.ts src/gateway/server-methods/nodes.invoke-wake.test.ts`
- Local validation: `pnpm build`
- mb-server validation: `pnpm exec vitest run --config vitest.gateway.config.ts src/gateway/server-methods/nodes.invoke-wake.test.ts`
- mb-server validation: `pnpm build`
- mb-server validation: `pnpm protocol:check`
2026-03-08 22:46:54 +01:00
Tyler Yust
38543d8196 fix(cron): consolidate announce delivery, fire-and-forget trigger, and minimal prompt mode (#40204)
* fix(cron): consolidate announce delivery and detach manual runs

* fix: queue detached cron runs (#40204)
2026-03-08 14:46:33 -07:00
langdon
7dfd77abeb fix(setup-podman): cd to TMPDIR before podman load to avoid cwd permission error (#39435)
* fix(setup-podman): cd to TMPDIR before podman load to avoid inherited cwd permission error

* fix(podman): safe cwd in run_as_user to prevent chdir errors

Co-Authored-By: Claude Opus 4.6  <noreply@anthropic.com>
Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: sallyom <somalley@redhat.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 17:32:08 -04:00
Gustavo Madeira Santana
5889a2e98e fix(plugin-sdk): lazily load legacy root alias 2026-03-08 17:13:46 -04:00
Gustavo Madeira Santana
09acbe6528 fix: harden backup verify path validation 2026-03-08 16:53:44 -04:00
Nimrod Gutman
64dd23eade fix(ci): refresh detect-secrets baseline 2026-03-08 22:44:05 +02:00
Nimrod Gutman
dadd7f99cd fix(ci): scope secrets scan to branch changes 2026-03-08 22:21:49 +02:00
shichangs
0ecfd37b44 feat: add local backup CLI (#40163)
Merged via squash.

Prepared head SHA: ed46625ae2
Co-authored-by: shichangs <46870204+shichangs@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-08 16:21:20 -04:00
Peter Steinberger
a075baba84 refactor(browser): scope CDP sessions and harden stale target recovery 2026-03-08 19:52:33 +00:00
Nimrod Gutman
a6131438ea fix(macos): improve tailscale gateway discovery (#40167)
Sanitized test tailnet hostnames and re-ran the targeted macOS gateway discovery test suite before merge.
2026-03-08 21:49:42 +02:00
Nimrod Gutman
92726d9863 docs(changelog): credit macos remote token author 2026-03-08 21:28:17 +02:00
Nimrod Gutman
3d3e8fe78c fix(macos): preserve unsupported remote gateway tokens 2026-03-08 21:28:17 +02:00
Charles Dusek
3b7a72bffb tests: document remote token persistence across mode toggle 2026-03-08 21:28:17 +02:00
Charles Dusek
37e0b01684 macos: add mode-toggle remote token sync coverage 2026-03-08 21:28:17 +02:00
Charles Dusek
bd0e6a6efd macos: clarify remote token placeholder text 2026-03-08 21:28:17 +02:00
Charles Dusek
6b338dd283 macos: add remote gateway token field for remote mode 2026-03-08 21:28:17 +02:00
Peter Steinberger
9d467d1620 docs: add WSL2 + Windows remote Chrome CDP troubleshooting (#39407) (thanks @Owlock) 2026-03-08 19:21:42 +00:00
Peter Steinberger
d3111fbbcb fix: make browser relay bind address configurable (#39364) (thanks @mvanhorn) 2026-03-08 19:15:21 +00:00
Matt Van Horn
e883d0b556 fix(browser): add IP validation, fix upgrade handler for non-loopback bind
- Zod schema: validate relayBindHost with ipv4/ipv6 instead of bare string
- Upgrade handler: allow non-loopback connections when bindHost is explicitly
  non-loopback (e.g. 0.0.0.0 for WSL2), keeping loopback-only default
- Test: verify actual bind address via relay.bindHost instead of just checking
  reachability on 127.0.0.1 which passes regardless
- Expose bindHost on ChromeExtensionRelayServer type for inspection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:15:21 +00:00
Matt Van Horn
436ae8a07c fix(infra): make browser relay bind address configurable
Add browser.relayBindHost config option so the Chrome extension relay
server can bind to a non-loopback address (e.g. 0.0.0.0 for WSL2).
Defaults to 127.0.0.1 when unset, preserving current behavior.

Closes #39214

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:15:21 +00:00
Vincent Koc
817fa5462b telegram: bridge direct delivery message hooks 2026-03-08 12:14:19 -07:00
Peter Steinberger
0692f71c6f fix: wait for extension relay tab reconnects (#32461) (thanks @AaronWander) 2026-03-08 19:11:58 +00:00
AaronWander
bcb0d1b8b4 fix(browser): wait for extension tabs after relay drop (#32331) 2026-03-08 19:11:58 +00:00
Peter Steinberger
dcdce83da7 fix: normalize wildcard remote CDP websocket URLs (#17760) (thanks @joeharouni) 2026-03-08 19:07:23 +00:00
Joe Harouni
dfa3605bee fix(browser): rewrite 0.0.0.0 and [::] wildcard addresses in CDP WebSocket URLs
Containerized browsers (e.g. browserless in Docker) report
`ws://0.0.0.0:<internal-port>` in their `/json/version` response.
`normalizeCdpWsUrl` rewrites loopback WS hosts to the external
CDP host:port, but `0.0.0.0` and `[::]` were not treated as
addresses needing rewriting, causing OpenClaw to try connecting
to `ws://0.0.0.0:3000` literally — which always fails.

Fixes #17752

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:07:23 +00:00
Josh Lehman
4bfa800cc7 fix: share context engine registry across bundled chunks (#40115)
Merged via squash.

Prepared head SHA: 6af4820b7d
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-08 11:56:01 -07:00
Peter Steinberger
9914b48c57 fix: preserve loopback ws cdp tab ops (#31085) (thanks @shrey150) 2026-03-08 18:48:51 +00:00
Shrey Pandya
4d904e7b7d style(browser): fix oxfmt formatting in config.ts
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:48:10 +00:00
Shrey Pandya
7b58507224 chore: remove vendor-specific references from code comments 2026-03-08 18:48:10 +00:00
Shrey Pandya
c1f6edf48b fix(browser): preserve wss:// cdpUrl in legacy default profile resolution 2026-03-08 18:48:10 +00:00
shrey150
8b2f40f5f6 fix(browser): update existing tests for ws/wss protocol support
Two pre-existing tests still expected ws:// URLs to be rejected by
parseHttpUrl, which now accepts them. Switch the invalid-protocol
fixture to ftp:// and tighten the assertion to match the full
"must be http(s) or ws(s)" error message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:48:10 +00:00
shrey150
f9c220e261 test+docs: comprehensive coverage and generic framing
- Add 12 new tests covering: isWebSocketUrl detection, parseHttpUrl WSS
  acceptance/rejection, direct WS target creation with query params,
  SSRF enforcement on WS URLs, WS reachability probing bypasses HTTP
- Reframe docs section as generic "Direct WebSocket CDP providers" with
  Browserbase as one example — any WSS-based provider works
- Update security tips to mention WSS alongside HTTPS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:48:10 +00:00
shrey150
75602014db feat(browser): support direct WebSocket CDP URLs for Browserbase
Browserbase uses direct WebSocket connections (wss://) rather than the
standard HTTP-based /json/version CDP discovery flow used by Browserless.
This change teaches the browser tool to accept ws:// and wss:// URLs as
cdpUrl values: when a WebSocket URL is detected, OpenClaw connects
directly instead of attempting HTTP discovery.

Changes:
- config.ts: accept ws:// and wss:// in cdpUrl validation
- cdp.helpers.ts: add isWebSocketUrl() helper
- cdp.ts: skip /json/version when cdpUrl is already a WebSocket URL
- chrome.ts: probe WSS endpoints via WebSocket handshake instead of HTTP
- cdp.test.ts: add test for direct WebSocket target creation
- docs/tools/browser.md: update Browserbase section with correct URL
  format and notes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:48:10 +00:00
Shrey Pandya
3cf75f760c docs: simplify Browserbase section, drop pricing details
Restore platform-level feature description (CAPTCHA solving, stealth
mode, proxies) without plan-specific pricing gating. Keep free tier
note brief.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:48:10 +00:00
Shrey Pandya
ae39a152d8 docs: fact-check Browserbase section against official docs
- Fix CAPTCHA/stealth/proxy claims: these are Developer plan+ only,
  not available on free tier
- Fix free tier limits: 1 browser hour, 15-min session duration
  (not "60 minutes of monthly usage")
- Add link to pricing page for paid plan details
- Simplify structure to match Browserless section format
- Remove sub-headings to match Browserless section style

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:48:10 +00:00
Shrey Pandya
efa1204183 docs: restore direct wss://connect.browserbase.com URL
Browserbase exposes a direct WebSocket connect endpoint that
auto-creates a session, similar to how Browserless works. Simplified
the section to use this static URL pattern instead of requiring
manual session creation via the API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:48:10 +00:00
Shrey Pandya
9a4610c641 docs: fix Browserbase section to match official docs
Browserbase requires creating a session via their API to get a CDP
connect URL, unlike Browserless which uses a static endpoint. Updated
to show the correct curl-based session creation flow, removed
unverified static WebSocket URL, and added the 5-minute connect
timeout note from official docs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:48:10 +00:00
Shrey Pandya
c0a988f692 docs: fix duplicate heading lint error
Rename "Configuration" sub-heading to "Profile setup" to avoid
MD024/no-duplicate-heading conflict with the existing top-level
"Configuration" heading.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:48:10 +00:00
Shrey Pandya
641e1bacb4 docs: add Browserbase as hosted remote CDP option
Add Browserbase documentation section alongside the existing Browserless
section in the browser docs. Includes signup instructions, CDP connection
configuration, and environment variable setup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:48:10 +00:00
Shrey Pandya
0252bdc837 Revert "docs: add Browserbase as hosted remote CDP option"
This reverts commit c469657c97848c7a3e1e5135bf4ce735d07d6614.
2026-03-08 18:48:10 +00:00
Shrey Pandya
885199dcaa docs: add Browserbase as hosted remote CDP option
Add Browserbase documentation section alongside the existing Browserless
section in the browser docs. Includes signup instructions, CDP connection
configuration, and environment variable setup for both English and Chinese
(zh-CN) translations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:48:10 +00:00
Peter Steinberger
3ada30e670 fix: restore gate after rebase 2026-03-08 18:40:15 +00:00
Peter Steinberger
c5095153b0 refactor: extract qmd process runner 2026-03-08 18:40:15 +00:00
Peter Steinberger
68775745d2 fix: restore acp session meta narrowing 2026-03-08 18:40:15 +00:00
Peter Steinberger
f399a818ef refactor: extract ios watch reply coordinator 2026-03-08 18:40:15 +00:00
Peter Steinberger
6bd5735519 refactor: split doctor config analysis helpers 2026-03-08 18:40:15 +00:00
Peter Steinberger
11be305609 refactor: neutralize context engine runtime bridge 2026-03-08 18:40:15 +00:00
Peter Steinberger
f6cb77134c refactor: centralize acp session resolution guards 2026-03-08 18:40:14 +00:00
Peter Steinberger
25d0aa7296 refactor: simplify plugin sdk compatibility aliases 2026-03-08 18:40:14 +00:00
Peter Steinberger
dd7470730d test: isolate git commit resolution fallbacks 2026-03-08 18:40:14 +00:00
Peter Steinberger
c70151e873 test: isolate legacy plugin-sdk root import check 2026-03-08 18:40:14 +00:00
Peter Steinberger
a007bed375 test: isolate plugin loader from mocked module cache 2026-03-08 18:40:14 +00:00
Peter Steinberger
fa580e33c1 refactor: split android talk voice resolution 2026-03-08 18:40:14 +00:00
Peter Steinberger
371c53b282 test: expand talk config contract fixtures 2026-03-08 18:40:14 +00:00
Peter Steinberger
cee2f3e8b4 refactor: dedupe android talk config parsing 2026-03-08 18:40:14 +00:00
Peter Steinberger
2ed644f5d3 fix: require talk resolved payload 2026-03-08 18:40:14 +00:00
Mariano
404b1527e6 fix(acp): persist spawned child session history (#40137)
Merged via squash.

Prepared head SHA: 62de5d5669
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-08 19:37:00 +01:00
Peter Steinberger
72ebaf97c3 test: add implicit provider matrix coverage 2026-03-08 18:26:36 +00:00
Peter Steinberger
8ab762c005 test: standardize hermetic provider env snapshots 2026-03-08 18:26:36 +00:00
Peter Steinberger
d307a7ca1a refactor: extract bundled extension manifest parser 2026-03-08 18:26:36 +00:00
Peter Steinberger
52bc809143 refactor: extract provider stream wrappers 2026-03-08 18:26:36 +00:00
Peter Steinberger
6094035054 refactor: extract static provider builders 2026-03-08 18:26:36 +00:00
Peter Steinberger
f493b03202 refactor: validate bundled extension release metadata 2026-03-08 18:26:36 +00:00
Peter Steinberger
e53d840fed refactor: extract openai stream wrappers 2026-03-08 18:26:36 +00:00
Peter Steinberger
f66bd105a4 refactor: decompose implicit provider resolution 2026-03-08 18:26:36 +00:00
Peter Steinberger
ef2541ceb3 refactor: centralize transcript provider quirks 2026-03-08 18:26:35 +00:00
Peter Steinberger
8a18e2598f refactor: split models registry loading from persistence 2026-03-08 18:26:35 +00:00
Peter Steinberger
749eb4efea refactor: thread config runtime env through models config 2026-03-08 18:26:35 +00:00
Peter Steinberger
64d4d9aabb refactor: move bundled extension gap allowlists into manifests 2026-03-08 18:26:35 +00:00
Peter Steinberger
e5c06dd64a refactor: use model compat for anthropic tool payload normalization 2026-03-08 18:26:35 +00:00
Vincent Koc
efcca3d2ea Tests: format daemon lifecycle CLI coverage 2026-03-08 11:22:41 -07:00
Vincent Koc
0b452a5665 CLI: set local gateway mode in setup 2026-03-08 11:17:29 -07:00
Vincent Koc
4c71176c9f Chore: refresh detect-secrets baseline for Feishu docs 2026-03-08 11:16:03 -07:00
Vincent Koc
c5bba6628e Chore: refresh detect-secrets baseline after final scan 2026-03-08 11:16:03 -07:00
Vincent Koc
3b68d3fded Chore: refresh detect-secrets baseline after docs line changes 2026-03-08 11:16:03 -07:00
Vincent Koc
7856f5730c Web search: allowlist Perplexity auth source type name 2026-03-08 11:16:03 -07:00
Vincent Koc
aebfce7a36 Chore: refresh detect-secrets baseline 2026-03-08 11:16:03 -07:00
Vincent Koc
e19b3679d1 Chore: widen xxxxx detect-secrets allowlist 2026-03-08 11:16:03 -07:00
Vincent Koc
d23d36a2f9 Tests: lower entropy git commit fixtures 2026-03-08 11:16:03 -07:00
Vincent Koc
2ae58542a0 Fixtures: normalize talk config API key placeholder 2026-03-08 11:16:03 -07:00
Vincent Koc
55465d86d9 Docs: use placeholder OpenRouter key in web tool docs 2026-03-08 11:16:03 -07:00
Vincent Koc
615466bdf4 Docs: use placeholder OpenRouter key in Perplexity guide 2026-03-08 11:16:03 -07:00
Vincent Koc
6f4de3cc23 Web search: rename Perplexity auth source helper 2026-03-08 11:16:03 -07:00
Vincent Koc
f19761cefa Tests: reduce web search secret-scan noise 2026-03-08 11:16:03 -07:00
Vincent Koc
5387faa718 CI: satisfy provider merge fixture typing 2026-03-08 11:15:48 -07:00
Tak Hoffman
bdf9739e59 Add too-many-prs override label handling 2026-03-08 13:13:53 -05:00
Rémi
2970d72554 docs: update Brave Search API docs for Feb 2026 plan restructuring (#40111)
Merged via squash.

Prepared head SHA: c651f07855
Co-authored-by: remusao <1299873+remusao@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-08 14:06:21 -04:00
Tak Hoffman
74624e619d fix: prefer bundled channel plugins over npm duplicates (#40094)
* fix: prefer bundled channel plugins over npm duplicates

* fix: tighten bundled plugin review follow-ups

* fix: address check gate follow-ups

* docs: add changelog for bundled plugin install fix

* fix: align lifecycle test formatting with CI oxfmt
2026-03-08 13:00:24 -05:00
yuweuii
6c9b49a10b fix(sessions): clear stale contextTokens on model switch (#38044)
Merged via squash.

Prepared head SHA: bac2df4b7f
Co-authored-by: yuweuii <82372187+yuweuii@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-08 10:59:16 -07:00
GitBuck
caf1b84822 feat: allow compaction model override via config (#38753)
Merged via squash.

Prepared head SHA: a3d6d6c845
Co-authored-by: starbuck100 <25417736+starbuck100@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-08 10:47:34 -07:00
Vincent Koc
b6520d7172 CI: scope CodeQL JavaScript analysis 2026-03-08 10:29:56 -07:00
Ayaan Zaidi
d4ab731746 fix(telegram): use message previews in DMs 2026-03-08 21:59:43 +05:30
Peter Steinberger
95dff166cb refactor: fold implicit provider injection into resolver 2026-03-08 16:22:52 +00:00
Peter Steinberger
1ec1f0f1f2 refactor: scope prep push results to env artifacts 2026-03-08 16:22:52 +00:00
Peter Steinberger
bce9d93fb5 fix: publish models.json atomically 2026-03-08 16:22:52 +00:00
Peter Steinberger
bec3c0b71d refactor: reuse one models.json read per write 2026-03-08 16:22:52 +00:00
Peter Steinberger
b41bcb08a2 refactor: expand provider capability registry 2026-03-08 16:22:52 +00:00
Peter Steinberger
75e1521660 refactor: extract pure models config merge helpers 2026-03-08 16:22:52 +00:00
Peter Steinberger
79c5c660bb fix: treat model api drift as baseUrl refresh 2026-03-08 16:22:52 +00:00
Peter Steinberger
fa00b1d0ca refactor: dedupe prep branch push flow 2026-03-08 16:22:52 +00:00
Peter Steinberger
032778fb2e refactor: avoid checkout during prep head verification 2026-03-08 16:22:52 +00:00
Peter Steinberger
16a5f0b006 refactor: split talk gateway config loaders 2026-03-08 16:22:48 +00:00
Peter Steinberger
dc5645d459 test: add talk config contract fixtures 2026-03-08 16:22:48 +00:00
Peter Steinberger
8d3d742c6a refactor: require canonical talk resolved payload 2026-03-08 16:22:48 +00:00
Peter Steinberger
87640f9a61 fix: align talk config secret schemas 2026-03-08 16:22:48 +00:00
Peter Steinberger
b7ad8fd661 fix: fail closed talk provider selection 2026-03-08 16:22:48 +00:00
Altay
ca5e352c53 CLI: include commit hash in --version output (#39712)
* CLI: include commit hash in --version output

* fix(version): harden commit SHA resolution and keep output consistent

* CLI: keep install checks compatible with commit-tagged version output

* fix(cli): include commit hash in root version fast path

* test(cli): allow null commit-hash mocks

* Installer: share version parser across install scripts

* Installer: avoid sourcing helpers from stdin cwd

* CLI: note commit-tagged version output

* CLI: anchor commit hash resolution to module root

* CLI: harden commit hash resolution

* CLI: fix commit hash lookup edge cases

* CLI: prefer live git metadata in dev builds

* CLI: keep git lookup inside package root

* Infra: tolerate invalid moduleUrl hints

* CLI: cache baked commit metadata fallbacks

* CLI: align changelog attribution with prep gate

* CLI: restore changelog contributor credit

---------

Co-authored-by: echoVic <echovic@163.com>
Co-authored-by: echoVic <echoVic@users.noreply.github.com>
2026-03-08 19:10:48 +03:00
Hermione
c942655451 fix(hooks): use resolveAgentIdFromSessionKey in runBeforeReset (#39875)
Merged via squash.

Prepared head SHA: 00a2b241df
Co-authored-by: rbutera <6047293+rbutera@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-08 19:07:28 +03:00
Tak Hoffman
fa83010b17 fix(plugins): ship Feishu bundled runtime dependency (#39990)
* fix: ship feishu bundled runtime dependency

* test: align feishu bundled dependency specs
2026-03-08 10:36:41 -05:00
darkamenosa
67b2e81360 Zalo: fix provider lifecycle restarts (#39892)
* Zalo: fix provider lifecycle restarts

* Zalo: add typing indicators, smart webhook cleanup, and API type fixes

* fix review

* add allow list test secrect

* Zalo: bound webhook cleanup during shutdown

* Zalo: bound typing chat action timeout

* Zalo: use plugin-safe abort helper import
2026-03-08 22:33:18 +07:00
Ayaan Zaidi
28e46d04e5 fix(web-search): restore OpenRouter compatibility for Perplexity (#39937) (#39937) 2026-03-08 20:37:54 +05:30
Tak Hoffman
d9e8e8ac15 fix: resolve live config paths in status and gateway metadata (#39952)
* fix: resolve live config paths in status and gateway metadata

* fix: resolve remaining runtime config path references

* test: cover gateway config.set config path response
2026-03-08 09:59:32 -05:00
Peter Steinberger
da3cccb212 test: decouple ios talk parsing coverage 2026-03-08 14:58:29 +00:00
Peter Steinberger
e8ad80afc7 test: cover invalid talk config inputs 2026-03-08 14:58:29 +00:00
Peter Steinberger
b4c8950417 refactor: centralize talk silence timeout defaults 2026-03-08 14:58:29 +00:00
Peter Steinberger
4e2290b841 refactor: add canonical talk config payload 2026-03-08 14:58:29 +00:00
Peter Steinberger
4f482d2a2b refactor: share Apple talk config parsing 2026-03-08 14:58:29 +00:00
Peter Steinberger
eba9dcc67a Refactor release hardening follow-ups (#39959)
* build: fail fast on stale host-env swift policy

* build: sync generated host env swift policy

* build: guard bundled extension root dependency gaps

* refactor: centralize provider capability quirks

* test: table-drive provider regression coverage

* fix: block merge when prep branch has unpushed commits

* refactor: simplify models config merge preservation
2026-03-08 14:49:58 +00:00
Tak Hoffman
27558806b5 docs: clarify bot review conversation ownership (#39942)
* docs: clarify bot review conversations
2026-03-08 09:39:39 -05:00
Peter Steinberger
0af3118d08 fix: harden talk silence timeout parsing (#39607) (thanks @danodoesdesign)
Co-authored-by: dano does design <dano.does.design@gmail.com>
2026-03-08 14:30:25 +00:00
dano does design
6ff7e8f42e talk: add configurable silence timeout 2026-03-08 14:30:25 +00:00
Varun Chopra
097c588a6b transcript-policy: use named Set for anthropic signature-excluded providers 2026-03-08 14:16:21 +00:00
Varun Chopra
2bf53c2cb6 transcript-policy: don't preserve thinking signatures for kimi-coding (#39798) 2026-03-08 14:16:21 +00:00
Peter Steinberger
e2c07f8a47 fix: land mac universal release defaults (#33891) (thanks @cgdusek) 2026-03-08 14:14:36 +00:00
Charles Dusek
1a364cd066 Docs: clarify notarization handoff in mac release flow 2026-03-08 14:14:36 +00:00
Charles Dusek
9ce79bba34 Docs: mark basic mac dist example as non-notarized 2026-03-08 14:14:36 +00:00
Charles Dusek
047f4acacf Docs: clarify release build arch defaults for mac packaging 2026-03-08 14:14:36 +00:00
Charles Dusek
64760614aa macOS: default release app builds to universal binaries 2026-03-08 14:14:36 +00:00
GeekCheyun
76e4b8277f fix(issue-39839): address tool-call extra params parsing for kimi anthropic-messages 2026-03-08 14:14:06 +00:00
Peter Steinberger
6dadfaa18c docs: use alphabetical provider ordering 2026-03-08 14:10:36 +00:00
Peter Steinberger
d5b305b250 fix: follow up #39321 and #38445 landings 2026-03-08 13:58:13 +00:00
Peter Steinberger
ba2d580c4e docs: note /landpr merge process 2026-03-08 13:57:50 +00:00
Peter Steinberger
acac7e3132 fix: land Brave llm-context gaps (#33383) (thanks @thirumaleshp) 2026-03-08 13:57:12 +00:00
Thirumalesh
8a1015f1aa feat: add Brave Search LLM Context API mode for web_search
Add support for Brave's LLM Context API endpoint (/res/v1/llm/context)
as an optional mode for the web_search tool. When configured with
tools.web.search.brave.mode set to llm-context, the tool returns
pre-extracted page content optimized for LLM grounding instead of
standard URL/snippet results.

The llm-context cache key excludes count and ui_lang parameters that
the LLM Context API does not accept, preventing unnecessary cache
misses.

Closes #14992

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:57:12 +00:00
Ayane
38f4ac5e3c fix(feishu): restore @larksuiteoapi/node-sdk in root dependencies
The bundled Feishu extension fails to load after npm global install because
`@larksuiteoapi/node-sdk` was removed from the root package.json in
e1503349c ("scope extension runtime deps to plugin manifests").

Bundled extensions shipped inside the npm package resolve modules through
the root node_modules tree.  Since `.gitignore` excludes nested
`node_modules/` directories, the extension-level `node_modules/` is
never published, so the module is unreachable at runtime.

Other bundled channel dependencies (e.g. `@discordjs/voice`,
`@slack/bolt`) remain in the root manifest for the same reason.

Re-add the entry — matching the version already declared in
`extensions/feishu/package.json` — so that both global npm installs and
the bundled extension path can locate the SDK.

Closes #39733
2026-03-08 13:56:46 +00:00
Peter Steinberger
d91d24e41d refactor: tighten codex inline api fallback follow-up 2026-03-08 13:54:21 +00:00
Dmitri
d2347ed825 macOS: set speech recognition taskHint for Talk Mode mic capture
Add taskHint = .dictation to Talk Mode's SFSpeechAudioBufferRecognitionRequest,
matching what Voice Wake already sets. Without this hint the recognizer may not
properly initialize audio capture, causing Talk Mode to appear unresponsive.

Co-Authored-By: dmiv <dmiv@users.noreply.github.com>
2026-03-08 13:52:08 +00:00
justinhuangcode
6e086a5b3b chore: update secrets baseline line numbers 2026-03-08 13:51:37 +00:00
justinhuangcode
c9f2d6b761 fix(agents): let forward-compat resolve api when inline model omits it
When a user configures `models.providers.openai-codex` with a models
array but omits the `api` field, `buildInlineProviderModels` produces
an entry with `api: undefined`.  The inline-match early return then
hands this incomplete model straight to the caller, skipping the
forward-compat resolver that would supply the correct
`openai-codex-responses` api — causing a crash loop.

Let the inline match fall through to forward-compat when `api` is
absent so the resolver chain can fill it in.

Fixes #39682
2026-03-08 13:51:37 +00:00
Kros Dai
e9d51d874b Models: fix codex follow-up CI issues 2026-03-08 13:48:13 +00:00
Kros Dai
ec75643a09 Models: scope implicit codex baseUrl override 2026-03-08 13:48:13 +00:00
Kros Dai
374001c4a0 fix: add implicit openai-codex provider snapshot 2026-03-08 13:48:13 +00:00
Felix Hellström
58ae5582f4 macOS: fix VoiceWakeOverlayController exclusivity violation #39275 2026-03-08 13:47:27 +00:00
Peter Steinberger
eebee84093 fix(models): discover Vercel AI Gateway catalog 2026-03-08 13:44:10 +00:00
Peter Steinberger
386b811ddd test(cron): relax concurrent start race timeout 2026-03-08 13:44:10 +00:00
Peter Steinberger
f66cc886d3 test(agents): normalize live model not-found skips 2026-03-08 13:44:10 +00:00
daymade
f930fcbd3f Add regression test and CHANGELOG entry
- Add test ensuring launchd path never returns "failed" status
- Add CHANGELOG.md entry documenting the fix with issue/PR references
- Reference ThrottleInterval evolution (#27650#29078 → current 1s)
2026-03-08 13:42:50 +00:00
daymade
03aea082d0 chore: condense inline comments per code review
Remove redundant rationale from test body (test names already convey it)
and trim the production comment to what/consequence/link (mechanism
details live in #39760).
2026-03-08 13:42:50 +00:00
daymade
5f45e76d61 fix(darwin): remove self-kickstart from launchd gateway restart; rely on KeepAlive
When the gateway needs a config-triggered restart under launchd, calling
`launchctl kickstart -k` from within the service itself races with
launchd's async bootout state machine:

1. `kickstart -k` initiates a launchd bootout → SIGTERM to self
2. Gateway ignores SIGTERM during shutdown → process doesn't exit
3. 2s `spawnSync` timeout kills the launchctl child, but launchd
   continues the bootout asynchronously
4. Fallback `launchctl bootstrap` fails with EIO (service mid-bootout)
5. In-process restart runs on the same PID that launchd will SIGKILL
6. LaunchAgent is permanently unloaded — no auto-restart

Fix: on darwin/launchd, skip `triggerOpenClawRestart()` entirely.
The caller already calls `exitProcess(0)` for supervised mode, and
`KeepAlive=true` (always set in the plist template) restarts the
service within ~1 second.

The schtasks (Windows) path is unchanged — Windows doesn't have an
equivalent KeepAlive mechanism.
2026-03-08 13:42:50 +00:00
Peter Steinberger
53fb317e7f fix(macos): clean swiftformat pass and sendable warning 2026-03-08 13:22:46 +00:00
Ayaan Zaidi
eb0758e172 docs(changelog): note Android Play policy cutovers 2026-03-08 16:25:49 +05:30
Ayaan Zaidi
04b4b48077 fix(android): persist legacy location mode migration 2026-03-08 16:25:49 +05:30
Ayaan Zaidi
709e11ea70 build(android): bump release version code 2026-03-08 16:25:49 +05:30
Ayaan Zaidi
46145fde19 fix(android): remove mic and screen foreground services 2026-03-08 16:25:49 +05:30
Ayaan Zaidi
1230cefe25 fix(android): remove background location mode 2026-03-08 16:25:49 +05:30
Ayaan Zaidi
0f9566b0b5 fix(android): remove self-update install flow 2026-03-08 16:25:49 +05:30
arceus77-7
492fe679a7 feat(tui): infer workspace agent when launching TUI (#39591)
Merged via squash.

Prepared head SHA: 23533e24c4
Co-authored-by: arceus77-7 <261276524+arceus77-7@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-08 13:31:11 +03:00
Altay
f4c4856254 docs(changelog): add #39377 failover note (#39704) 2026-03-08 13:09:26 +03:00
gambletan
8a20f51460 fix: add rate limit patterns for 'too many tokens' and 'tokens per day' (#39377)
Merged via squash.

Prepared head SHA: 132a457286
Co-authored-by: gambletan <266203672+gambletan@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-08 13:03:33 +03:00
Farhoud Cheraghi
aedf3ee68f fix(skills): expand skill-creator description to cover edit/audit/review triggers (#39158)
Merged via squash.

Prepared head SHA: 13997c1ee5
Co-authored-by: haynzz <1236319+haynzz@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-08 12:13:00 +03:00
J. Campbell
b38f371630 fix: add @tloncorp/api to pnpm onlyBuiltDependencies allowlist (#39027)
Merged via squash.

Prepared head SHA: e149350260
Co-authored-by: apexfork <363026+apexfork@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-08 12:07:10 +03:00
gambletan
e5fdfec9dc fix(config): accept "openclaw" as browser profile driver in Zod schema (#39374)
Merged via squash.

Prepared head SHA: 0eba5ab939
Co-authored-by: gambletan <266203672+gambletan@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-08 12:04:49 +03:00
Altay
f73778e9b2 fix: remove redundant root strip-ansi dependency (#39652) 2026-03-08 12:04:46 +03:00
Nutchanon (Ben) Ninyawee
c1b914026d fix: add missing strip-ansi dep for pi-coding-agent (#38999)
Merged via squash.

Prepared head SHA: dd03a6aaaf
Co-authored-by: ninyawee <8089231+ninyawee@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-08 12:00:17 +03:00
Daniel Hnyk
9425209602 fix(mattermost): pass payload.replyToId as root_id for threaded replies (#27744)
Merged via squash.

Prepared head SHA: e029079872
Co-authored-by: hnykda <2741256+hnykda@users.noreply.github.com>
Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com>
Reviewed-by: @mukhtharcm
2026-03-08 14:13:13 +05:30
Ayaan Zaidi
4db634964b chore(secrets): sync appcast baseline 2026-03-08 13:29:26 +05:30
Ayaan Zaidi
6477da623f chore(secrets): sync detect-secrets baseline 2026-03-08 13:25:01 +05:30
Ayaan Zaidi
d3c3d0e730 style(android): update app icon 2026-03-08 13:25:01 +05:30
Peter Lee
92648f9ba9 fix(agents): broaden 402 temporary-limit detection and allow billing cooldown probe (#38533)
Merged via squash.

Prepared head SHA: 282b9186c6
Co-authored-by: xialonglee <22994703+xialonglee@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-08 10:27:01 +03:00
Peter Steinberger
d15b6af77b fix: land contributor PR #39516 from @Imhermes1
macOS app/chat/browser/cron/permissions fixes.

Co-authored-by: ImHermes1 <lukeforn@gmail.com>
2026-03-08 06:11:20 +00:00
Peter Steinberger
05217845a7 build: bump version to 2026.3.8 2026-03-08 05:59:04 +00:00
Peter Steinberger
389647157d build: update stable appcast release URL 2026-03-08 05:53:19 +00:00
Ayaan Zaidi
c217237a36 style(daemon-cli): format lifecycle test 2026-03-08 11:22:57 +05:30
802 changed files with 30910 additions and 8894 deletions

View File

@@ -41,3 +41,5 @@ pattern = grep -q 'N[O]DE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache' ~/.bash
pattern = env: \{ MISTRAL_API_K[E]Y: "sk-\.\.\." \},
pattern = "ap[i]Key": "xxxxx",
pattern = ap[i]Key: "A[I]za\.\.\.",
# Sparkle appcast signatures are release metadata, not credentials.
pattern = sparkle:edSignature="[A-Za-z0-9+/=]+"

View File

@@ -0,0 +1,18 @@
name: openclaw-codeql-javascript-typescript
paths:
- src
- extensions
- ui/src
- skills
paths-ignore:
- apps
- dist
- docs
- "**/node_modules"
- "**/coverage"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.e2e.test.ts"
- "**/*.e2e.test.tsx"

View File

@@ -87,6 +87,13 @@ What you personally verified (not just CI), and how:
- Edge cases checked:
- What you did **not** verify:
## Review Conversations
- [ ] I replied to or resolved every bot review conversation I addressed in this PR.
- [ ] I left unresolved only the conversations that still need reviewer or maintainer judgment.
If a bot review conversation is addressed by this PR, resolve that conversation yourself. Do not leave bot review conversation cleanup for maintainers.
## Compatibility / Migration
- Backward compatible? (`Yes/No`)

View File

@@ -261,6 +261,8 @@ jobs:
};
const triggerLabel = "trigger-response";
const activePrLimitLabel = "r: too-many-prs";
const activePrLimitOverrideLabel = "r: too-many-prs-override";
const target = context.payload.issue ?? context.payload.pull_request;
if (!target) {
return;
@@ -448,6 +450,10 @@ jobs:
return;
}
if (pullRequest && labelSet.has(activePrLimitOverrideLabel)) {
labelSet.delete(activePrLimitLabel);
}
const rule = rules.find((item) => labelSet.has(item.label));
if (!rule) {
return;

View File

@@ -267,6 +267,12 @@ jobs:
with:
submodules: false
- name: Ensure secrets base commit
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:

View File

@@ -28,6 +28,7 @@ jobs:
needs_swift_tools: false
needs_manual_build: false
needs_autobuild: false
config_file: ./.github/codeql/codeql-javascript-typescript.yml
- language: actions
runs_on: blacksmith-16vcpu-ubuntu-2404
needs_node: false
@@ -36,6 +37,7 @@ jobs:
needs_swift_tools: false
needs_manual_build: false
needs_autobuild: false
config_file: ""
- language: python
runs_on: blacksmith-16vcpu-ubuntu-2404
needs_node: false
@@ -44,6 +46,7 @@ jobs:
needs_swift_tools: false
needs_manual_build: false
needs_autobuild: false
config_file: ""
- language: java-kotlin
runs_on: blacksmith-16vcpu-ubuntu-2404
needs_node: false
@@ -52,6 +55,7 @@ jobs:
needs_swift_tools: false
needs_manual_build: true
needs_autobuild: false
config_file: ""
- language: swift
runs_on: macos-latest
needs_node: false
@@ -60,6 +64,7 @@ jobs:
needs_swift_tools: true
needs_manual_build: true
needs_autobuild: false
config_file: ""
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -95,6 +100,7 @@ jobs:
with:
languages: ${{ matrix.language }}
queries: security-and-quality
config-file: ${{ matrix.config_file || '' }}
- name: Autobuild
if: matrix.needs_autobuild

View File

@@ -109,6 +109,8 @@ jobs:
labels: ${{ steps.labels.outputs.value }}
provenance: false
push: true
cache-from: type=gha,scope=docker-release-amd64
cache-to: type=gha,mode=max,scope=docker-release-amd64
- name: Build and push amd64 slim image
id: build-slim
@@ -122,6 +124,8 @@ jobs:
labels: ${{ steps.labels.outputs.value }}
provenance: false
push: true
cache-from: type=gha,scope=docker-release-amd64
cache-to: type=gha,mode=max,scope=docker-release-amd64
# Build arm64 images (default + slim share the build stage cache)
build-arm64:
@@ -210,6 +214,8 @@ jobs:
labels: ${{ steps.labels.outputs.value }}
provenance: false
push: true
cache-from: type=gha,scope=docker-release-arm64
cache-to: type=gha,mode=max,scope=docker-release-arm64
- name: Build and push arm64 slim image
id: build-slim
@@ -223,6 +229,8 @@ jobs:
labels: ${{ steps.labels.outputs.value }}
provenance: false
push: true
cache-from: type=gha,scope=docker-release-arm64
cache-to: type=gha,mode=max,scope=docker-release-arm64
# Create multi-platform manifests
create-manifest:

View File

@@ -213,6 +213,7 @@ jobs:
}
const activePrLimitLabel = "r: too-many-prs";
const activePrLimitOverrideLabel = "r: too-many-prs-override";
const activePrLimit = 10;
const labelColor = "B60205";
const labelDescription = `Author has more than ${activePrLimit} active PRs in this repo`;
@@ -221,12 +222,37 @@ jobs:
return;
}
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
per_page: 100,
});
const labelNames = new Set(
(pullRequest.labels ?? [])
currentLabels
.map((label) => (typeof label === "string" ? label : label?.name))
.filter((name) => typeof name === "string"),
);
if (labelNames.has(activePrLimitOverrideLabel)) {
if (labelNames.has(activePrLimitLabel)) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
name: activePrLimitLabel,
});
} catch (error) {
if (error?.status !== 404) {
throw error;
}
}
}
return;
}
const ensureLabelExists = async () => {
try {
await github.rest.issues.getLabel({

View File

@@ -66,9 +66,13 @@ repos:
- --exclude-lines
- 'env: \{ MISTRAL_API_K[E]Y: "sk-\.\.\." \},'
- --exclude-lines
- '"ap[i]Key": "xxxxx",'
- '"ap[i]Key": "xxxxx"(,)?'
- --exclude-lines
- 'ap[i]Key: "A[I]za\.\.\.",'
- --exclude-lines
- '"ap[i]Key": "(resolved|normalized|legacy)-key"(,)?'
- --exclude-lines
- 'sparkle:edSignature="[A-Za-z0-9+/=]+"'
# Shell script linting
- repo: https://github.com/koalaman/shellcheck-precommit
rev: v0.11.0

View File

@@ -151,8 +151,10 @@
"export CUSTOM_API_K[E]Y=\"your-key\"",
"grep -q 'N[O]DE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache' ~/.bashrc \\|\\| cat >> ~/.bashrc <<'EOF'",
"env: \\{ MISTRAL_API_K[E]Y: \"sk-\\.\\.\\.\" \\},",
"\"ap[i]Key\": \"xxxxx\",",
"ap[i]Key: \"A[I]za\\.\\.\\.\","
"\"ap[i]Key\": \"xxxxx\"(,)?",
"ap[i]Key: \"A[I]za\\.\\.\\.\",",
"\"ap[i]Key\": \"(resolved|normalized|legacy)-key\"(,)?",
"sparkle:edSignature=\"[A-Za-z0-9+/=]+\""
]
},
{
@@ -179,29 +181,6 @@
"line_number": 15
}
],
"appcast.xml": [
{
"type": "Base64 High Entropy String",
"filename": "appcast.xml",
"hashed_secret": "abb0380989460de3f211d60628b439de7ebcd482",
"is_verified": false,
"line_number": 364
},
{
"type": "Base64 High Entropy String",
"filename": "appcast.xml",
"hashed_secret": "6e1ba26139ac4e73427e68a7eec2abf96bcf1fd4",
"is_verified": false,
"line_number": 583
},
{
"type": "Base64 High Entropy String",
"filename": "appcast.xml",
"hashed_secret": "c0baa9660a8d3b11874c63a535d8369f4a8fa8fa",
"is_verified": false,
"line_number": 722
}
],
"apps/android/app/src/test/java/ai/openclaw/android/node/AppUpdateHandlerTest.kt": [
{
"type": "Hex High Entropy String",
@@ -226,7 +205,7 @@
"filename": "apps/macos/Sources/OpenClawProtocol/GatewayModels.swift",
"hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd",
"is_verified": false,
"line_number": 1749
"line_number": 1763
}
],
"apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift": [
@@ -251,7 +230,7 @@
"filename": "apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift",
"hashed_secret": "19dad5cecb110281417d1db56b60e1b006d55bb4",
"is_verified": false,
"line_number": 66
"line_number": 81
}
],
"apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift": [
@@ -287,7 +266,7 @@
"filename": "apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift",
"hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd",
"is_verified": false,
"line_number": 1749
"line_number": 1763
}
],
"docs/.i18n/zh-CN.tm.jsonl": [
@@ -9619,14 +9598,14 @@
"filename": "docs/channels/feishu.md",
"hashed_secret": "b60d121b438a380c343d5ec3c2037564b82ffef3",
"is_verified": false,
"line_number": 189
"line_number": 187
},
{
"type": "Secret Keyword",
"filename": "docs/channels/feishu.md",
"hashed_secret": "186154712b2d5f6791d85b9a0987b98fa231779c",
"is_verified": false,
"line_number": 501
"line_number": 499
}
],
"docs/channels/irc.md": [
@@ -9795,63 +9774,63 @@
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e",
"is_verified": false,
"line_number": 1611
"line_number": 1614
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "bde4db9b4c3be4049adc3b9a69851d7c35119770",
"is_verified": false,
"line_number": 1627
"line_number": 1630
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "7f8aaf142ce0552c260f2e546dda43ddd7c9aef3",
"is_verified": false,
"line_number": 1812
"line_number": 1817
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27",
"is_verified": false,
"line_number": 1985
"line_number": 1990
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0",
"is_verified": false,
"line_number": 2041
"line_number": 2046
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
"is_verified": false,
"line_number": 2273
"line_number": 2278
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6",
"is_verified": false,
"line_number": 2401
"line_number": 2408
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281",
"is_verified": false,
"line_number": 2654
"line_number": 2661
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25",
"is_verified": false,
"line_number": 2656
"line_number": 2663
}
],
"docs/gateway/configuration.md": [
@@ -9945,7 +9924,7 @@
"filename": "docs/help/faq.md",
"hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6",
"is_verified": false,
"line_number": 2489
"line_number": 2490
}
],
"docs/install/macos-vm.md": [
@@ -9972,7 +9951,7 @@
"filename": "docs/perplexity.md",
"hashed_secret": "6b26c117c66a0c030e239eef595c1e18865132a8",
"is_verified": false,
"line_number": 29
"line_number": 43
}
],
"docs/plugins/voice-call.md": [
@@ -10033,14 +10012,14 @@
"filename": "docs/providers/minimax.md",
"hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0",
"is_verified": false,
"line_number": 70
"line_number": 69
},
{
"type": "Secret Keyword",
"filename": "docs/providers/minimax.md",
"hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209",
"is_verified": false,
"line_number": 149
"line_number": 148
}
],
"docs/providers/moonshot.md": [
@@ -10198,21 +10177,21 @@
"filename": "docs/tools/web.md",
"hashed_secret": "6b26c117c66a0c030e239eef595c1e18865132a8",
"is_verified": false,
"line_number": 90
"line_number": 135
},
{
"type": "Secret Keyword",
"filename": "docs/tools/web.md",
"hashed_secret": "491d458f895b9213facb2ee9375b1b044eaea3ac",
"is_verified": false,
"line_number": 179
"line_number": 228
},
{
"type": "Secret Keyword",
"filename": "docs/tools/web.md",
"hashed_secret": "674397e2c0c2faaa85961c708d2a96a7cc7af217",
"is_verified": false,
"line_number": 277
"line_number": 332
}
],
"docs/tts.md": [
@@ -10255,14 +10234,14 @@
"filename": "docs/zh-CN/channels/feishu.md",
"hashed_secret": "b60d121b438a380c343d5ec3c2037564b82ffef3",
"is_verified": false,
"line_number": 195
"line_number": 191
},
{
"type": "Secret Keyword",
"filename": "docs/zh-CN/channels/feishu.md",
"hashed_secret": "186154712b2d5f6791d85b9a0987b98fa231779c",
"is_verified": false,
"line_number": 509
"line_number": 505
}
],
"docs/zh-CN/channels/line.md": [
@@ -11481,7 +11460,7 @@
"filename": "src/agents/models-config.e2e-harness.ts",
"hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d",
"is_verified": false,
"line_number": 130
"line_number": 157
}
],
"src/agents/models-config.fills-missing-provider-apikey-from-env-var.e2e.test.ts": [
@@ -11515,14 +11494,14 @@
"filename": "src/agents/models-config.providers.nvidia.test.ts",
"hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f",
"is_verified": false,
"line_number": 13
"line_number": 14
},
{
"type": "Secret Keyword",
"filename": "src/agents/models-config.providers.nvidia.test.ts",
"hashed_secret": "be1a7be9d4d5af417882b267f4db6dddc08507bd",
"is_verified": false,
"line_number": 22
"line_number": 23
}
],
"src/agents/models-config.providers.ollama.e2e.test.ts": [
@@ -11583,7 +11562,7 @@
"filename": "src/agents/pi-embedded-runner/model.ts",
"hashed_secret": "e774aaeac31c6272107ba89080295e277050fa7c",
"is_verified": false,
"line_number": 267
"line_number": 279
}
],
"src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts": [
@@ -11680,7 +11659,7 @@
"filename": "src/agents/tools/web-search.ts",
"hashed_secret": "dfba7aade0868074c2861c98e2a9a92f3178a51b",
"is_verified": false,
"line_number": 254
"line_number": 292
}
],
"src/agents/tools/web-tools.enabled-defaults.e2e.test.ts": [
@@ -11746,7 +11725,7 @@
"filename": "src/auto-reply/status.test.ts",
"hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f",
"is_verified": false,
"line_number": 36
"line_number": 37
}
],
"src/browser/bridge-server.auth.test.ts": [
@@ -11764,14 +11743,14 @@
"filename": "src/browser/browser-utils.test.ts",
"hashed_secret": "4e126c049580d66ca1549fa534d95a7263f27f46",
"is_verified": false,
"line_number": 43
"line_number": 47
},
{
"type": "Basic Auth Credentials",
"filename": "src/browser/browser-utils.test.ts",
"hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684",
"is_verified": false,
"line_number": 164
"line_number": 171
}
],
"src/browser/cdp.test.ts": [
@@ -11780,7 +11759,7 @@
"filename": "src/browser/cdp.test.ts",
"hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684",
"is_verified": false,
"line_number": 243
"line_number": 318
}
],
"src/channels/plugins/plugins-channel.test.ts": [
@@ -12100,21 +12079,21 @@
"filename": "src/config/config.env-vars.test.ts",
"hashed_secret": "a24ef9c1a27cac44823571ceef2e8262718eee36",
"is_verified": false,
"line_number": 13
"line_number": 17
},
{
"type": "Secret Keyword",
"filename": "src/config/config.env-vars.test.ts",
"hashed_secret": "29d5f92e9ee44d4854d6dfaeefc3dc27d779fdf3",
"is_verified": false,
"line_number": 19
"line_number": 23
},
{
"type": "Secret Keyword",
"filename": "src/config/config.env-vars.test.ts",
"hashed_secret": "1672b6a1e7956c6a70f45d699aa42a351b1f8b80",
"is_verified": false,
"line_number": 27
"line_number": 31
}
],
"src/config/config.irc.test.ts": [
@@ -12335,14 +12314,14 @@
"filename": "src/config/schema.help.ts",
"hashed_secret": "9f4cda226d3868676ac7f86f59e4190eb94bd208",
"is_verified": false,
"line_number": 649
"line_number": 653
},
{
"type": "Secret Keyword",
"filename": "src/config/schema.help.ts",
"hashed_secret": "01822c8bbf6a8b136944b14182cb885100ec2eae",
"is_verified": false,
"line_number": 680
"line_number": 686
}
],
"src/config/schema.irc.ts": [
@@ -12381,14 +12360,14 @@
"filename": "src/config/schema.labels.ts",
"hashed_secret": "e73c9fcad85cd4eecc74181ec4bdb31064d68439",
"is_verified": false,
"line_number": 216
"line_number": 217
},
{
"type": "Secret Keyword",
"filename": "src/config/schema.labels.ts",
"hashed_secret": "2eda7cd978f39eebec3bf03e4410a40e14167fff",
"is_verified": false,
"line_number": 324
"line_number": 326
}
],
"src/config/slack-http-config.test.ts": [
@@ -12932,14 +12911,14 @@
"filename": "src/telegram/monitor.test.ts",
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
"is_verified": false,
"line_number": 450
"line_number": 497
},
{
"type": "Secret Keyword",
"filename": "src/telegram/monitor.test.ts",
"hashed_secret": "5934c4d4a4fa5d66ddb3d3fc0bba84996c17a5b7",
"is_verified": false,
"line_number": 641
"line_number": 688
}
],
"src/telegram/webhook.test.ts": [
@@ -13034,5 +13013,5 @@
}
]
},
"generated_at": "2026-03-08T05:05:36Z"
"generated_at": "2026-03-09T08:37:13Z"
}

View File

@@ -48,4 +48,4 @@
--allman false
# Exclusions
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/MoltbotProtocol
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/MoltbotProtocol,apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift

View File

@@ -19,6 +19,8 @@ excluded:
- "*.playground"
# Generated (protocol-gen-swift.ts)
- apps/macos/Sources/MoltbotProtocol/GatewayModels.swift
# Generated (generate-host-env-security-policy-swift.mjs)
- apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift
analyzer_rules:
- unused_declaration

View File

@@ -6,6 +6,7 @@
- GitHub comment footgun: never use `gh issue/pr comment -b "..."` when body contains backticks or shell chars. Always use single-quoted heredoc (`-F - <<'EOF'`) so no command substitution/escaping corruption.
- GitHub linking footgun: dont wrap issue/PR refs like `#24643` in backticks when you want auto-linking. Use plain `#24643` (optionally add full URL).
- PR landing comments: always make commit SHAs clickable with full commit links (both landed SHA + source SHA when present).
- PR review conversations: if a bot leaves review conversations on your PR, address them and resolve those conversations yourself once fixed. Leave a conversation unresolved only when reviewer or maintainer judgment is still needed; do not leave bot-conversation cleanup to maintainers.
- GitHub searching footgun: don't limit yourself to the first 500 issues or PRs when wanting to search all. Unless you're supposed to look at the most recent, keep going until you've reached the last page in the search
- Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries.
@@ -28,6 +29,7 @@
- Docs are hosted on Mintlify (docs.openclaw.ai).
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
- When working with documentation, read the mintlify skill.
- For docs, UI copy, and picker lists, order services/providers alphabetically unless the section is explicitly describing runtime behavior (for example auto-detection or execution order).
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
- Doc headings and anchors: avoid em dashes and apostrophes in headings because they break Mintlify anchor links.
- When Peter asks for links, reply with full `https://docs.openclaw.ai/...` URLs (not root-relative).
@@ -113,6 +115,7 @@
**Full maintainer PR workflow (optional):** If you want the repo's end-to-end maintainer workflow (triage order, quality bar, rebase rules, commit/changelog conventions, co-contributor policy, and the `review-pr` > `prepare-pr` > `merge-pr` pipeline), see `.agents/skills/PR_WORKFLOW.md`. Maintainers may use other workflows; when a maintainer specifies a workflow, follow that. If no workflow is specified, default to PR_WORKFLOW.
- `/landpr` lives in the global Codex prompts (`~/.codex/prompts/landpr.md`); when landing or merging any PR, always follow that `/landpr` process.
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
- Group related changes; avoid bundling unrelated refactors.

View File

@@ -2,6 +2,88 @@
Docs: https://docs.openclaw.ai
## Unreleased
### Changes
### Breaking
### Fixes
- macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes.
- Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark.
- Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz.
- Context engine/tests: add bundled-registry regression coverage for cross-chunk resolution, plugin-sdk re-exports, and concurrent chunk registration. (#40460) thanks @dsantoreis.
- Agents/embedded runner: bound compaction retry waiting and drain embedded runs during SIGUSR1 restart so session lanes recover instead of staying blocked behind compaction. (#40324) thanks @cgdusek.
- ACP/sessions.patch: allow `spawnedBy` and `spawnDepth` lineage fields on ACP session keys so `sessions_spawn` with `runtime: "acp"` no longer fails during child-session setup. Fixes #40971. (#40995) thanks @xaeon2026.
- ACP/stop reason mapping: resolve gateway chat `state: "error"` completions as ACP `end_turn` instead of `refusal` so transient backend failures are not surfaced as deliberate refusals. (#41187) thanks @pejmanjohn.
- ACP/setSessionMode: propagate gateway `sessions.patch` failures back to ACP clients so rejected mode changes no longer return silent success. (#41185) thanks @pejmanjohn.
## 2026.3.8
### Changes
- CLI/backup: add `openclaw backup create` and `openclaw backup verify` for local state archives, including `--only-config`, `--no-include-workspace`, manifest/payload validation, and backup guidance in destructive flows. (#40163) thanks @shichangs.
- macOS/onboarding: add a remote gateway token field for remote mode, preserve existing non-plaintext `gateway.remote.token` config values until explicitly replaced, and warn when the loaded token shape cannot be used directly from the macOS app. (#40187, supersedes #34614) Thanks @cgdusek.
- Talk mode: add top-level `talk.silenceTimeoutMs` config so Talk waits a configurable amount of silence before auto-sending the current transcript, while keeping each platform's existing default pause window when unset. (#39607) Thanks @danodoesdesign. Fixes #17147.
- TUI: infer the active agent from the current workspace when launched inside a configured agent workspace, while preserving explicit `agent:` session targets. (#39591) thanks @arceus77-7.
- Tools/Brave web search: add opt-in `tools.web.search.brave.mode: "llm-context"` so `web_search` can call Brave's LLM Context endpoint and return extracted grounding snippets with source metadata, plus config/docs/test coverage. (#33383) Thanks @thirumaleshp.
- CLI/install: include the short git commit hash in `openclaw --version` output when metadata is available, and keep installer version checks compatible with the decorated format. (#39712) thanks @sourman.
- CLI/backup: improve archive naming for date sorting, add config-only backup mode, and harden backup planning, publication, and verification edge cases. (#40163) Thanks @gumadeiras.
- ACP/Provenance: add optional ACP ingress provenance metadata and visible receipt injection (`openclaw acp --provenance off|meta|meta+receipt`) so OpenClaw agents can retain and report ACP-origin context with session trace IDs. (#40473) thanks @mbelinky.
- Tools/web search: alphabetize provider ordering across runtime selection, onboarding/configure pickers, and config metadata, so provider lists stay neutral and multi-key auto-detect now prefers Grok before Kimi. (#40259) thanks @kesku.
- Docs/Web search: restore $5/month free-credit details, replace defunct "Data for Search"/"Data for AI" plan names with current "Search" plan, and note legacy subscription validity in Brave setup docs. Follows up on #26860. (#40111) Thanks @remusao.
- Extensions/ACPX tests: move the shared runtime fixture helper from `src/runtime-internals/` to `src/test-utils/` so the test-only helper no longer looks like shipped runtime code.
### Fixes
- Update/macOS launchd restart: re-enable disabled LaunchAgent services before updater bootstrap so `openclaw update` can recover from a disabled gateway service instead of leaving the restart step stuck.
- macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1.
- Android/Play distribution: remove self-update, background location, `screen.record`, and background mic capture from the Android app, narrow the foreground service to `dataSync` only, and clean up the legacy `location.enabledMode=always` preference migration. (#39660) Thanks @obviyus.
- Telegram/DM routing: dedupe inbound Telegram DMs per agent instead of per session key so the same DM cannot trigger duplicate replies when both `agent:main:main` and `agent:main:telegram:direct:<id>` resolve for one agent. Fixes #40005. Supersedes #40116. (#40519) thanks @obviyus.
- Cron/Telegram announce delivery: route text-only announce jobs through the real outbound adapters after finalizing descendant output so plain Telegram targets no longer report `delivered: true` when no message actually reached Telegram. (#40575) thanks @obviyus.
- Matrix/DM routing: add safer fallback detection for broken `m.direct` homeservers, honor explicit room bindings over DM classification, and preserve room-bound agent selection for Matrix DM rooms. (#19736) Thanks @derbronko.
- Feishu/plugin onboarding: clear the short-lived plugin discovery cache before reloading the registry after installing a channel plugin, so onboarding no longer re-prompts to download Feishu immediately after a successful install. Fixes #39642. (#39752) Thanks @GazeKingNuWu.
- Plugins/channel onboarding: prefer bundled channel plugins over duplicate npm-installed copies during onboarding and release-channel sync, preventing bundled plugins from being shadowed by npm installs with the same plugin ID. (#40092)
- Config/runtime snapshots: keep secrets-runtime-resolved config and auth-profile snapshots intact after config writes so follow-up reads still see file-backed secret values while picking up the persisted config update. (#37313) thanks @bbblending.
- Gateway/Control UI: resolve bundled dashboard assets through symlinked global wrappers and auto-detected package roots, while keeping configured and custom roots on the strict hardlink boundary. (#40385) Thanks @LarytheLord.
- Browser/extension relay: add `browser.relayBindHost` so the Chrome relay can bind to an explicit non-loopback address for WSL2 and other cross-namespace setups, while preserving loopback-only defaults. (#39364) Thanks @mvanhorn.
- Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for `/json/*` tab operations so local `ws://` / `wss://` profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150.
- Browser/CDP: rewrite wildcard `ws://0.0.0.0` and `ws://[::]` debugger URLs from remote `/json/version` responses back to the external CDP host/port, fixing Browserless-style container endpoints. (#17760) Thanks @joeharouni.
- Browser/extension relay: wait briefly for a previously attached Chrome tab to reappear after transient relay drops before failing with `tab not found`, reducing noisy reconnect flakes. (#32461) Thanks @AaronWander.
- macOS/Tailscale gateway discovery: keep Tailscale Serve probing alive when other remote gateways are already discovered, prefer direct transport for resolved `.ts.net` and Tailscale Serve gateways, and set `TERM=dumb` for GUI-launched Tailscale CLI discovery. (#40167) thanks @ngutman.
- TUI/theme: detect light terminal backgrounds via `COLORFGBG` and pick a WCAG AA-compliant light palette, with `OPENCLAW_THEME=light|dark` override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc.
- Agents/openai-codex: normalize `gpt-5.4` fallback transport back to `openai-codex-responses` on `chatgpt.com/backend-api` when config drifts to the generic OpenAI responses endpoint. (#38736) Thanks @0xsline.
- Models/openai-codex GPT-5.4 forward-compat: use the GPT-5.4 1,050,000-token context window and 128,000 max tokens for `openai-codex/gpt-5.4` instead of inheriting stale legacy Codex limits in resolver fallbacks and model listing. (#37876) thanks @yuweuii.
- Tools/web search: restore Perplexity OpenRouter/Sonar compatibility for legacy `OPENROUTER_API_KEY`, `sk-or-...`, and explicit `perplexity.baseUrl` / `model` setups while keeping direct Perplexity keys on the native Search API path. (#39937) Thanks @obviyus.
- Agents/failover: detect Amazon Bedrock `Too many tokens per day` quota errors as rate limits across fallback, cron retry, and memory embeddings while keeping context-window `too many tokens per request` errors out of the rate-limit lane. (#39377) Thanks @gambletan.
- Mattermost replies: keep `root_id` pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda.
- Telegram/DM partial streaming: keep DM preview lanes on real message edits instead of native draft materialization so final replies no longer flash a second duplicate copy before collapsing back to one.
- macOS overlays: fix VoiceWake, Talk, and Notify overlay exclusivity crashes by removing shared `inout` visibility mutation from `OverlayPanelFactory.present`, and add a repeated Talk overlay smoke test. (#39275, #39321) Thanks @fellanH.
- macOS Talk Mode: set the speech recognition request `taskHint` to `.dictation` for mic capture, and add regression coverage for the request defaults. (#38445) Thanks @dmiv.
- macOS release packaging: default `scripts/package-mac-app.sh` to universal binaries for `BUILD_CONFIG=release`, and clarify that `scripts/package-mac-dist.sh` already produces the release zip + DMG. (#33891) Thanks @cgdusek.
- Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera.
- Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii.
- ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky.
- Docs/browser: add a layered WSL2 + Windows remote Chrome CDP troubleshooting guide, including Control UI origin pitfalls and extension-relay bind-address guidance. (#39407) Thanks @Owlock.
- Context engine registry/bundled builds: share the registry state through a `globalThis` singleton so duplicated bundled module copies can resolve engines registered by each other at runtime, with regression coverage for duplicate-module imports. (#40115) thanks @jalehman.
- Podman/setup: fix `cannot chdir: Permission denied` in `run_as_user` when `setup-podman.sh` is invoked from a directory the target user cannot access, by wrapping user-switch calls in a subshell that cd's to `/tmp` with `/` fallback. (#39435) Thanks @langdon and @jlcbk.
- Podman/SELinux: auto-detect SELinux enforcing/permissive mode and add `:Z` relabel to bind mounts in `run-openclaw-podman.sh` and the Quadlet template, fixing `EACCES` on Fedora/RHEL hosts. Supports `OPENCLAW_BIND_MOUNT_OPTIONS` override. (#39449) Thanks @langdon and @githubbzxs.
- Agents/context-engine plugins: bootstrap runtime plugins once at embedded-run, compaction, and subagent boundaries so plugin-provided context engines and hooks load from the active workspace before runtime resolution. (#40232)
- Docs/Changelog: correct the contributor credit for the bundled Control UI global-install fix to @LarytheLord. (#40420) Thanks @velvet-shark.
- Telegram/media downloads: time out only stalled body reads so polling recovers from hung file downloads without aborting slow downloads that are still streaming data. (#40098) thanks @tysoncung.
- Docker/runtime image: prune dev dependencies, strip build-only dist metadata for smaller Docker images. (#40307) Thanks @vincentkoc.
- Gateway/restart timeout recovery: exit non-zero when restart-triggered shutdown drains time out so launchd/systemd restart the gateway instead of treating the failed restart as a clean stop. Landed from contributor PR #40380 by @dsantoreis. Thanks @dsantoreis.
- Gateway/config restart guard: validate config before service start/restart and keep post-SIGUSR1 startup failures from crashing the gateway process, reducing invalid-config restart loops and macOS permission loss. Landed from contributor PR #38699 by @lml2468. Thanks @lml2468.
- Gateway/launchd respawn detection: treat `XPC_SERVICE_NAME` as a launchd supervision hint so macOS restarts exit cleanly under launchd instead of attempting detached self-respawn. Landed from contributor PR #20555 by @dimat. Thanks @dimat.
- Telegram/poll restart cleanup: abort the in-flight Telegram API fetch when shutdown or forced polling restarts stop a runner, preventing stale `getUpdates` long polls from colliding with the replacement runner. Landed from contributor PR #23950 by @Gkinthecodeland. Thanks @Gkinthecodeland.
- Cron/restart catch-up staggering: limit immediate missed-job replay on startup and reschedule the deferred remainder from the post-catchup clock so restart bursts do not starve the gateway or silently skip overdue recurring jobs. Landed from contributor PR #18925 by @rexlunae. Thanks @rexlunae.
- Cron/owner-only tools: pass trusted isolated cron runs into the embedded agent with owner context so `cron`/`gateway` tooling remains available after the owner-auth hardening narrowed direct-message ownership inference.
- Browser/SSRF: block private-network intermediate redirect hops in strict browser navigation flows and fail closed when remote tab-open paths cannot inspect redirect chains. Thanks @zpbrent.
- MS Teams/authz: keep `groupPolicy: "allowlist"` enforcing sender allowlists even when a team/channel route allowlist is configured, so route matches no longer widen group access to every sender in that route. Thanks @zpbrent.
- Security/system.run: bind approved `bun` and `deno run` script operands to on-disk file snapshots so post-approval script rewrites are denied before execution.
- Skills/download installs: pin the validated per-skill tools root before writing downloaded archives, so rebinding the lexical tools path cannot redirect download writes outside the intended tools directory. Thanks @tdjackey.
## 2026.3.7
### Changes
@@ -33,6 +115,7 @@ Docs: https://docs.openclaw.ai
- Mattermost/model picker: add Telegram-style interactive provider/model browsing for `/oc_model` and `/oc_models`, fix picker callback updates, and emit a normal confirmation reply when a model is selected. (#38767) thanks @mukhtharcm.
- Docker/multi-stage build: restructure Dockerfile as a multi-stage build to produce a minimal runtime image without build tools, source code, or Bun; add `OPENCLAW_VARIANT=slim` build arg for a bookworm-slim variant. (#38479) Thanks @sallyom.
- Google/Gemini 3.1 Flash-Lite: add first-class `google/gemini-3.1-flash-lite-preview` support across model-id normalization, default aliases, media-understanding image lookups, Google Gemini CLI forward-compat fallback, and docs.
- Agents/compaction model override: allow `agents.defaults.compaction.model` to route compaction summarization through a different model than the main session, and document the override across config help/reference surfaces. (#38753) thanks @starbuck100.
### Breaking
@@ -41,6 +124,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Models/MiniMax: stop advertising removed `MiniMax-M2.5-Lightning` in built-in provider catalogs, onboarding metadata, and docs; keep the supported fast-tier model as `MiniMax-M2.5-highspeed`.
- Models/Vercel AI Gateway: synthesize the built-in `vercel-ai-gateway` provider from `AI_GATEWAY_API_KEY` and auto-discover the live `/v1/models` catalog so `/models vercel-ai-gateway` exposes current refs including `openai/gpt-5.4`.
- Security/Config: fail closed when `loadConfig()` hits validation or read errors so invalid configs cannot silently fall back to permissive runtime defaults. (#9040) Thanks @joetomasone.
- Memory/Hybrid search: preserve negative FTS5 BM25 relevance ordering in `bm25RankToScore()` so stronger keyword matches rank above weaker ones instead of collapsing or reversing scores. (#33757) Thanks @lsdcc01.
- LINE/`requireMention` group gating: align inbound and reply-stage LINE group policy resolution across raw, `group:`, and `room:` keys (including account-scoped group config), preserve plugin-backed reply-stage fallback behavior, and add regression coverage for prefixed-only group/room config plus reply-stage policy resolution. (#35847) Thanks @kirisame-wang.
@@ -84,6 +168,7 @@ Docs: https://docs.openclaw.ai
- Agents/compaction safeguard pre-check: skip embedded compaction before entering the Pi SDK when a session has no real conversation messages, avoiding unnecessary LLM API calls on idle sessions. (#36451) thanks @Sid-Qin.
- Config/schema cache key stability: build merged schema cache keys with incremental hashing to avoid large single-string serialization and prevent `RangeError: Invalid string length` on high-cardinality plugin/channel metadata. (#36603) Thanks @powermaster888.
- iMessage/cron completion announces: strip leaked inline reply tags (for example `[[reply_to:6100]]`) from user-visible completion text so announcement deliveries do not expose threading metadata. (#24600) Thanks @vincentkoc.
- Cron/manual run enqueue flow: queue `cron.run` requests behind the cron execution lane, return immediate `{ ok: true, enqueued: true, runId }` acknowledgements, preserve `{ ok: true, ran: false, reason }` skip responses for already-running and not-due jobs, and document the asynchronous completion flow. (#40204)
- Control UI/iMessage duplicate reply routing: keep internal webchat turns on dispatcher delivery (instead of origin-channel reroute) so Control UI chats do not duplicate replies into iMessage, while preserving webchat-provider relayed routing for external surfaces. Fixes #33483. Thanks @alicexmolt.
- Sessions/daily reset transcript archival: archive prior transcript files during stale-session scheduled/daily resets by capturing the previous session entry before rollover, preventing orphaned transcript files on disk. (#35493) Thanks @byungsker.
- Feishu/group slash command detection: normalize group mention wrappers before command-authorization probing so mention-prefixed commands (for example `@Bot/model` and `@Bot /reset`) are recognized as gateway commands instead of being forwarded to the agent. (#35994) Thanks @liuxiaopai-ai.
@@ -353,6 +438,8 @@ Docs: https://docs.openclaw.ai
- Discord/config schema parity: add `channels.discord.agentComponents` to the strict Zod config schema so valid `agentComponents.enabled` settings (root and account-scoped) no longer fail with unrecognized-key validation errors. Landed from contributor PR #39378 by @gambletan. Thanks @gambletan and @thewilloftheshadow.
- ACPX/MCP session bootstrap: inject configured MCP servers into ACP `session/new` and `session/load` for acpx-backed sessions, restoring Canva and other external MCP tools. Landed from contributor PR #39337. Thanks @goodspeed-apps.
- Control UI/Telegram sender labels: preserve inbound sender labels in sanitized chat history so dashboard user-message groups split correctly and show real group-member names instead of `You`. (#39414) Thanks @obviyus.
- Agents/failover 402 recovery: keep temporary spend-limit `402` payloads retryable, preserve explicit insufficient-credit billing detection even in long provider payloads, and allow throttled billing-cooldown probes so single-provider setups can recover instead of staying locked out. (#38533) Thanks @xialonglee.
- Browser/config schema: accept `browser.profiles.*.driver: "openclaw"` while preserving legacy `"clawd"` compatibility in validated config. (#39374; based on #35621) Thanks @gambletan and @ingyukoh.
## 2026.3.2
@@ -710,6 +797,8 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/macOS restart: remove self-issued `launchctl kickstart -k` from launchd supervised restart path to prevent race with launchd's async bootout state machine that permanently unloads the LaunchAgent. With `ThrottleInterval=1` (current default), `exit(0)` + `KeepAlive=true` restarts the service within ~1s without the race condition. (#39760) Landed from contributor PR #39763 by @daymade. Thanks @daymade.
- Plugin SDK/bundled subpath contracts: add regression coverage for newly routed bundled-plugin SDK exports so BlueBubbles, Mattermost, Nextcloud Talk, and Twitch subpath symbols stay pinned during future plugin-sdk cleanup. (#39638)
- Exec/system.run env sanitization: block dangerous override-only env pivots such as `GIT_SSH_COMMAND`, editor/pager hooks, and `GIT_CONFIG_` / `NPM_CONFIG_` override prefixes so allowlisted tools cannot smuggle helper command execution through subprocess environment overrides. Thanks @tdjackey and @SnailSploit for reporting.
- Network/fetch guard redirect auth stripping: switch cross-origin redirect handling in `fetchWithSsrFGuard` from a narrow sensitive-header denylist to a safe-header allowlist so custom auth headers like `X-Api-Key` and `Private-Token` no longer leak on origin changes. Thanks @Rickidevs for reporting.
- Security/Sandbox media reads: eliminate sandbox media TOCTOU symlink-retarget escapes by enforcing root-scoped boundary-safe reads at attachment/image load time and consolidating shared safe-read helpers across sandbox media callsites. This ships in the next npm release. Thanks @tdjackey for reporting.

View File

@@ -57,9 +57,21 @@ Welcome to the lobster tank! 🦞
- GitHub: [@joshavant](https://github.com/joshavant) · X: [@joshavant](https://x.com/joshavant)
- **Jonathan Taylor** - ACP subsystem, Gateway features/bugs, Gog/Mog/Sog CLI's, SEDMAT
- Github [@visionik](https://github.com/visionik) · X: [@visionik](https://x.com/visionik)
- GitHub [@visionik](https://github.com/visionik) · X: [@visionik](https://x.com/visionik)
- **Josh Lehman** - Compaction, Tlon/Urbit subsystem
- Github [@jalehman](https://github.com/jalehman) · X: [@jlehman\_](https://x.com/jlehman_)
- GitHub [@jalehman](https://github.com/jalehman) · X: [@jlehman\_](https://x.com/jlehman_)
- **Radek Sienkiewicz** - Control UI + WebChat correctness
- GitHub [@velvet-shark](https://github.com/velvet-shark) · X: [@velvet_shark](https://twitter.com/velvet_shark)
- **Muhammed Mukhthar** - Mattermost, CLI
- GitHub [@mukhtharcm](https://github.com/mukhtharcm) · X: [@mukhtharcm](https://x.com/mukhtharcm)
- **Altay** - Agents, CLI, error handling
- GitHub [@altaywtf](https://github.com/altaywtf) · X: [@altaywtf](https://x.com/altaywtf)
- **Robin Waslander** - Security, PR triage, bug fixes
- GitHub: [@hydro13](https://github.com/hydro13) · X: [@Robin_waslander](https://x.com/Robin_waslander)
## How to Contribute
@@ -74,8 +86,19 @@ Welcome to the lobster tank! 🦞
- Ensure CI checks pass
- Keep PRs focused (one thing per PR; do not mix unrelated concerns)
- Describe what & why
- Reply to or resolve bot review conversations you addressed before asking for review again
- **Include screenshots** — one showing the problem/before, one showing the fix/after (for UI or visual changes)
## Review Conversations Are Author-Owned
If a review bot leaves review conversations on your PR, you are expected to handle the follow-through:
- Resolve the conversation yourself once the code or explanation fully addresses the bot's concern
- Reply and leave it open only when you need maintainer or reviewer judgment
- Do not leave "fixed" bot review conversations for maintainers to clean up for you
This applies to both human-authored and AI-assisted PRs.
## Control UI Decorators
The Control UI uses Lit with **legacy** decorators (current Rollup parsing does not support
@@ -101,8 +124,9 @@ Please include in your PR:
- [ ] Note the degree of testing (untested / lightly tested / fully tested)
- [ ] Include prompts or session logs if possible (super helpful!)
- [ ] Confirm you understand what the code does
- [ ] Resolve or reply to bot review conversations after you address them
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for.
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for. If you are using an LLM coding agent, instruct it to resolve bot review conversations it has addressed instead of leaving them for maintainers.
## Current Focus & Roadmap 🗺

View File

@@ -1,3 +1,5 @@
# syntax=docker/dockerfile:1.7
# Opt-in extension dependencies at build time (space-separated directory names).
# Example: docker build --build-arg OPENCLAW_EXTENSIONS="diagnostics-otel matrix" .
#
@@ -48,16 +50,25 @@ WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY ui/package.json ./ui/package.json
COPY patches ./patches
COPY scripts ./scripts
COPY --from=ext-deps /out/ ./extensions/
# Reduce OOM risk on low-memory hosts during dependency installation.
# Docker builds on small VMs may otherwise fail with "Killed" (exit 137).
RUN NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile
COPY . .
# Normalize extension paths now so runtime COPY preserves safe modes
# without adding a second full extensions layer.
RUN for dir in /app/extensions /app/.agent /app/.agents; do \
if [ -d "$dir" ]; then \
find "$dir" -type d -exec chmod 755 {} +; \
find "$dir" -type f -exec chmod 644 {} +; \
fi; \
done
# A2UI bundle may fail under QEMU cross-compilation (e.g. building amd64
# on Apple Silicon). CI builds natively per-arch so this is a no-op there.
# Stub it so local cross-arch builds still succeed.
@@ -67,11 +78,17 @@ RUN pnpm canvas:a2ui:bundle || \
echo "/* A2UI bundle unavailable in this build */" > src/canvas-host/a2ui/a2ui.bundle.js && \
echo "stub" > src/canvas-host/a2ui/.bundle.hash && \
rm -rf vendor/a2ui apps/shared/OpenClawKit/Tools/CanvasA2UI)
RUN pnpm build
RUN pnpm build:docker
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
ENV OPENCLAW_PREFER_PNPM=1
RUN pnpm ui:build
# Prune dev dependencies and strip build-only metadata before copying
# runtime assets into the final image.
FROM build AS runtime-assets
RUN CI=true pnpm prune --prod && \
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete
# ── Runtime base images ─────────────────────────────────────────
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS base-default
ARG OPENCLAW_NODE_BOOKWORM_DIGEST
@@ -102,36 +119,39 @@ WORKDIR /app
# Install system utilities present in bookworm but missing in bookworm-slim.
# On the full bookworm image these are already installed (apt-get is a no-op).
RUN apt-get update && \
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
procps hostname curl git openssl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
procps hostname curl git openssl
RUN chown node:node /app
COPY --from=build --chown=node:node /app/dist ./dist
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/package.json .
COPY --from=build --chown=node:node /app/openclaw.mjs .
COPY --from=build --chown=node:node /app/extensions ./extensions
COPY --from=build --chown=node:node /app/skills ./skills
COPY --from=build --chown=node:node /app/docs ./docs
COPY --from=runtime-assets --chown=node:node /app/dist ./dist
COPY --from=runtime-assets --chown=node:node /app/node_modules ./node_modules
COPY --from=runtime-assets --chown=node:node /app/package.json .
COPY --from=runtime-assets --chown=node:node /app/openclaw.mjs .
COPY --from=runtime-assets --chown=node:node /app/extensions ./extensions
COPY --from=runtime-assets --chown=node:node /app/skills ./skills
COPY --from=runtime-assets --chown=node:node /app/docs ./docs
# Docker live-test runners invoke `pnpm` inside the runtime image.
# Activate the exact pinned package manager now so the container does not
# rely on a first-run network fetch or missing shims under the non-root user.
RUN corepack enable && \
corepack prepare "$(node -p "require('./package.json').packageManager")" --activate
# Keep pnpm available in the runtime image for container-local workflows.
# Use a shared Corepack home so the non-root `node` user does not need a
# first-run network fetch when invoking pnpm.
ENV COREPACK_HOME=/usr/local/share/corepack
RUN install -d -m 0755 "$COREPACK_HOME" && \
corepack enable && \
corepack prepare "$(node -p "require('./package.json').packageManager")" --activate && \
chmod -R a+rX "$COREPACK_HOME"
# Install additional system packages needed by your skills or extensions.
# Example: docker build --build-arg OPENCLAW_DOCKER_APT_PACKAGES="python3 wget" .
ARG OPENCLAW_DOCKER_APT_PACKAGES=""
RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES; \
fi
# Optionally install Chromium and Xvfb for browser automation.
@@ -139,15 +159,15 @@ RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
# Adds ~300MB but eliminates the 60-90s Playwright install on every container start.
# Must run after node_modules COPY so playwright-core is available.
ARG OPENCLAW_INSTALL_BROWSER=""
RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
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/*; \
chown -R node:node /home/node/.cache/ms-playwright; \
fi
# Optionally install Docker CLI for sandbox container management.
@@ -156,7 +176,9 @@ RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \
# Required for agents.defaults.sandbox to function in Docker deployments.
ARG OPENCLAW_INSTALL_DOCKER_CLI=""
ARG OPENCLAW_DOCKER_GPG_FINGERPRINT="9DC858229FC7DD38854AE2D88D81803C0EBFCD88"
RUN if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates curl gnupg && \
@@ -177,20 +199,9 @@ RUN if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \
"$(dpkg --print-architecture)" > /etc/apt/sources.list.d/docker.list && \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
docker-ce-cli docker-compose-plugin && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
docker-ce-cli docker-compose-plugin; \
fi
# Normalize extension paths so plugin safety checks do not reject
# world-writable directories inherited from source file modes.
RUN for dir in /app/extensions /app/.agent /app/.agents; do \
if [ -d "$dir" ]; then \
find "$dir" -type d -exec chmod 755 {} +; \
find "$dir" -type f -exec chmod 644 {} +; \
fi; \
done
# Expose the CLI binary without requiring npm global writes as non-root.
RUN ln -sf /app/openclaw.mjs /usr/local/bin/openclaw \
&& chmod 755 /app/openclaw.mjs

View File

@@ -1,8 +1,12 @@
# syntax=docker/dockerfile:1.7
FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
@@ -10,8 +14,7 @@ RUN apt-get update \
git \
jq \
python3 \
ripgrep \
&& rm -rf /var/lib/apt/lists/*
ripgrep
RUN useradd --create-home --shell /bin/bash sandbox
USER sandbox

View File

@@ -1,8 +1,12 @@
# syntax=docker/dockerfile:1.7
FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
@@ -17,11 +21,9 @@ RUN apt-get update \
socat \
websockify \
x11vnc \
xvfb \
&& rm -rf /var/lib/apt/lists/*
xvfb
COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser
RUN chmod +x /usr/local/bin/openclaw-sandbox-browser
COPY --chmod=755 scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser
RUN useradd --create-home --shell /bin/bash sandbox
USER sandbox

View File

@@ -1,3 +1,5 @@
# syntax=docker/dockerfile:1.7
ARG BASE_IMAGE=openclaw-sandbox:bookworm-slim
FROM ${BASE_IMAGE}
@@ -19,9 +21,10 @@ ENV HOMEBREW_CELLAR=${BREW_INSTALL_DIR}/Cellar
ENV HOMEBREW_REPOSITORY=${BREW_INSTALL_DIR}/Homebrew
ENV PATH=${BUN_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/sbin:${PATH}
RUN apt-get update \
&& apt-get install -y --no-install-recommends ${PACKAGES} \
&& rm -rf /var/lib/apt/lists/*
RUN --mount=type=cache,id=openclaw-sandbox-common-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-sandbox-common-apt-lists,target=/var/lib/apt,sharing=locked \
apt-get update \
&& apt-get install -y --no-install-recommends ${PACKAGES}
RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi
@@ -42,4 +45,3 @@ fi
# Default is sandbox, but allow BASE_IMAGE overrides to select another final user.
USER ${FINAL_USER}

View File

@@ -2,6 +2,80 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>OpenClaw</title>
<item>
<title>2026.3.8-beta.1</title>
<pubDate>Mon, 09 Mar 2026 07:19:57 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026030801</sparkle:version>
<sparkle:shortVersionString>2026.3.8-beta.1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.3.8-beta.1</h2>
<h3>Changes</h3>
<ul>
<li>CLI/backup: add <code>openclaw backup create</code> and <code>openclaw backup verify</code> for local state archives, including <code>--only-config</code>, <code>--no-include-workspace</code>, manifest/payload validation, and backup guidance in destructive flows. (#40163) thanks @shichangs.</li>
<li>macOS/onboarding: add a remote gateway token field for remote mode, preserve existing non-plaintext <code>gateway.remote.token</code> config values until explicitly replaced, and warn when the loaded token shape cannot be used directly from the macOS app. (#40187, supersedes #34614) Thanks @cgdusek.</li>
<li>Talk mode: add top-level <code>talk.silenceTimeoutMs</code> config so Talk waits a configurable amount of silence before auto-sending the current transcript, while keeping each platform's existing default pause window when unset. (#39607) Thanks @danodoesdesign. Fixes #17147.</li>
<li>TUI: infer the active agent from the current workspace when launched inside a configured agent workspace, while preserving explicit <code>agent:</code> session targets. (#39591) thanks @arceus77-7.</li>
<li>Tools/Brave web search: add opt-in <code>tools.web.search.brave.mode: "llm-context"</code> so <code>web_search</code> can call Brave's LLM Context endpoint and return extracted grounding snippets with source metadata, plus config/docs/test coverage. (#33383) Thanks @thirumaleshp.</li>
<li>CLI/install: include the short git commit hash in <code>openclaw --version</code> output when metadata is available, and keep installer version checks compatible with the decorated format. (#39712) thanks @sourman.</li>
<li>CLI/backup: improve archive naming for date sorting, add config-only backup mode, and harden backup planning, publication, and verification edge cases. (#40163) Thanks @gumadeiras.</li>
<li>ACP/Provenance: add optional ACP ingress provenance metadata and visible receipt injection (<code>openclaw acp --provenance off|meta|meta+receipt</code>) so OpenClaw agents can retain and report ACP-origin context with session trace IDs. (#40473) thanks @mbelinky.</li>
<li>Tools/web search: alphabetize provider ordering across runtime selection, onboarding/configure pickers, and config metadata, so provider lists stay neutral and multi-key auto-detect now prefers Grok before Kimi. (#40259) thanks @kesku.</li>
<li>Docs/Web search: restore $5/month free-credit details, replace defunct "Data for Search"/"Data for AI" plan names with current "Search" plan, and note legacy subscription validity in Brave setup docs. Follows up on #26860. (#40111) Thanks @remusao.</li>
<li>Extensions/ACPX tests: move the shared runtime fixture helper from <code>src/runtime-internals/</code> to <code>src/test-utils/</code> so the test-only helper no longer looks like shipped runtime code.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1.</li>
<li>Android/Play distribution: remove self-update, background location, <code>screen.record</code>, and background mic capture from the Android app, narrow the foreground service to <code>dataSync</code> only, and clean up the legacy <code>location.enabledMode=always</code> preference migration. (#39660) Thanks @obviyus.</li>
<li>Telegram/DM routing: dedupe inbound Telegram DMs per agent instead of per session key so the same DM cannot trigger duplicate replies when both <code>agent:main:main</code> and <code>agent:main:telegram:direct:<id></code> resolve for one agent. Fixes #40005. Supersedes #40116. (#40519) thanks @obviyus.</li>
<li>Cron/Telegram announce delivery: route text-only announce jobs through the real outbound adapters after finalizing descendant output so plain Telegram targets no longer report <code>delivered: true</code> when no message actually reached Telegram. (#40575) thanks @obviyus.</li>
<li>Matrix/DM routing: add safer fallback detection for broken <code>m.direct</code> homeservers, honor explicit room bindings over DM classification, and preserve room-bound agent selection for Matrix DM rooms. (#19736) Thanks @derbronko.</li>
<li>Feishu/plugin onboarding: clear the short-lived plugin discovery cache before reloading the registry after installing a channel plugin, so onboarding no longer re-prompts to download Feishu immediately after a successful install. Fixes #39642. (#39752) Thanks @GazeKingNuWu.</li>
<li>Plugins/channel onboarding: prefer bundled channel plugins over duplicate npm-installed copies during onboarding and release-channel sync, preventing bundled plugins from being shadowed by npm installs with the same plugin ID. (#40092)</li>
<li>Config/runtime snapshots: keep secrets-runtime-resolved config and auth-profile snapshots intact after config writes so follow-up reads still see file-backed secret values while picking up the persisted config update. (#37313) thanks @bbblending.</li>
<li>Gateway/Control UI: resolve bundled dashboard assets through symlinked global wrappers and auto-detected package roots, while keeping configured and custom roots on the strict hardlink boundary. (#40385) Thanks @LarytheLord.</li>
<li>Browser/extension relay: add <code>browser.relayBindHost</code> so the Chrome relay can bind to an explicit non-loopback address for WSL2 and other cross-namespace setups, while preserving loopback-only defaults. (#39364) Thanks @mvanhorn.</li>
<li>Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for <code>/json/*</code> tab operations so local <code>ws://</code> / <code>wss://</code> profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150.</li>
<li>Browser/CDP: rewrite wildcard <code>ws://0.0.0.0</code> and <code>ws://[::]</code> debugger URLs from remote <code>/json/version</code> responses back to the external CDP host/port, fixing Browserless-style container endpoints. (#17760) Thanks @joeharouni.</li>
<li>Browser/extension relay: wait briefly for a previously attached Chrome tab to reappear after transient relay drops before failing with <code>tab not found</code>, reducing noisy reconnect flakes. (#32461) Thanks @AaronWander.</li>
<li>macOS/Tailscale gateway discovery: keep Tailscale Serve probing alive when other remote gateways are already discovered, prefer direct transport for resolved <code>.ts.net</code> and Tailscale Serve gateways, and set <code>TERM=dumb</code> for GUI-launched Tailscale CLI discovery. (#40167) thanks @ngutman.</li>
<li>TUI/theme: detect light terminal backgrounds via <code>COLORFGBG</code> and pick a WCAG AA-compliant light palette, with <code>OPENCLAW_THEME=light|dark</code> override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc.</li>
<li>Agents/openai-codex: normalize <code>gpt-5.4</code> fallback transport back to <code>openai-codex-responses</code> on <code>chatgpt.com/backend-api</code> when config drifts to the generic OpenAI responses endpoint. (#38736) Thanks @0xsline.</li>
<li>Models/openai-codex GPT-5.4 forward-compat: use the GPT-5.4 1,050,000-token context window and 128,000 max tokens for <code>openai-codex/gpt-5.4</code> instead of inheriting stale legacy Codex limits in resolver fallbacks and model listing. (#37876) thanks @yuweuii.</li>
<li>Tools/web search: restore Perplexity OpenRouter/Sonar compatibility for legacy <code>OPENROUTER_API_KEY</code>, <code>sk-or-...</code>, and explicit <code>perplexity.baseUrl</code> / <code>model</code> setups while keeping direct Perplexity keys on the native Search API path. (#39937) Thanks @obviyus.</li>
<li>Agents/failover: detect Amazon Bedrock <code>Too many tokens per day</code> quota errors as rate limits across fallback, cron retry, and memory embeddings while keeping context-window <code>too many tokens per request</code> errors out of the rate-limit lane. (#39377) Thanks @gambletan.</li>
<li>Mattermost replies: keep <code>root_id</code> pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda.</li>
<li>Telegram/DM partial streaming: keep DM preview lanes on real message edits instead of native draft materialization so final replies no longer flash a second duplicate copy before collapsing back to one.</li>
<li>macOS overlays: fix VoiceWake, Talk, and Notify overlay exclusivity crashes by removing shared <code>inout</code> visibility mutation from <code>OverlayPanelFactory.present</code>, and add a repeated Talk overlay smoke test. (#39275, #39321) Thanks @fellanH.</li>
<li>macOS Talk Mode: set the speech recognition request <code>taskHint</code> to <code>.dictation</code> for mic capture, and add regression coverage for the request defaults. (#38445) Thanks @dmiv.</li>
<li>macOS release packaging: default <code>scripts/package-mac-app.sh</code> to universal binaries for <code>BUILD_CONFIG=release</code>, and clarify that <code>scripts/package-mac-dist.sh</code> already produces the release zip + DMG. (#33891) Thanks @cgdusek.</li>
<li>Hooks/session-memory: keep <code>/new</code> and <code>/reset</code> memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera.</li>
<li>Sessions/model switch: clear stale cached <code>contextTokens</code> when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii.</li>
<li>ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky.</li>
<li>Docs/browser: add a layered WSL2 + Windows remote Chrome CDP troubleshooting guide, including Control UI origin pitfalls and extension-relay bind-address guidance. (#39407) Thanks @Owlock.</li>
<li>Context engine registry/bundled builds: share the registry state through a <code>globalThis</code> singleton so duplicated bundled module copies can resolve engines registered by each other at runtime, with regression coverage for duplicate-module imports. (#40115) thanks @jalehman.</li>
<li>Podman/setup: fix <code>cannot chdir: Permission denied</code> in <code>run_as_user</code> when <code>setup-podman.sh</code> is invoked from a directory the target user cannot access, by wrapping user-switch calls in a subshell that cd's to <code>/tmp</code> with <code>/</code> fallback. (#39435) Thanks @langdon and @jlcbk.</li>
<li>Podman/SELinux: auto-detect SELinux enforcing/permissive mode and add <code>:Z</code> relabel to bind mounts in <code>run-openclaw-podman.sh</code> and the Quadlet template, fixing <code>EACCES</code> on Fedora/RHEL hosts. Supports <code>OPENCLAW_BIND_MOUNT_OPTIONS</code> override. (#39449) Thanks @langdon and @githubbzxs.</li>
<li>Agents/context-engine plugins: bootstrap runtime plugins once at embedded-run, compaction, and subagent boundaries so plugin-provided context engines and hooks load from the active workspace before runtime resolution. (#40232)</li>
<li>Docs/Changelog: correct the contributor credit for the bundled Control UI global-install fix to @LarytheLord. (#40420) Thanks @velvet-shark.</li>
<li>Telegram/media downloads: time out only stalled body reads so polling recovers from hung file downloads without aborting slow downloads that are still streaming data. (#40098) thanks @tysoncung.</li>
<li>Docker/runtime image: prune dev dependencies, strip build-only dist metadata for smaller Docker images. (#40307) Thanks @vincentkoc.</li>
<li>Gateway/restart timeout recovery: exit non-zero when restart-triggered shutdown drains time out so launchd/systemd restart the gateway instead of treating the failed restart as a clean stop. Landed from contributor PR #40380 by @dsantoreis. Thanks @dsantoreis.</li>
<li>Gateway/config restart guard: validate config before service start/restart and keep post-SIGUSR1 startup failures from crashing the gateway process, reducing invalid-config restart loops and macOS permission loss. Landed from contributor PR #38699 by @lml2468. Thanks @lml2468.</li>
<li>Gateway/launchd respawn detection: treat <code>XPC_SERVICE_NAME</code> as a launchd supervision hint so macOS restarts exit cleanly under launchd instead of attempting detached self-respawn. Landed from contributor PR #20555 by @dimat. Thanks @dimat.</li>
<li>Telegram/poll restart cleanup: abort the in-flight Telegram API fetch when shutdown or forced polling restarts stop a runner, preventing stale <code>getUpdates</code> long polls from colliding with the replacement runner. Landed from contributor PR #23950 by @Gkinthecodeland. Thanks @Gkinthecodeland.</li>
<li>Cron/restart catch-up staggering: limit immediate missed-job replay on startup and reschedule the deferred remainder from the post-catchup clock so restart bursts do not starve the gateway or silently skip overdue recurring jobs. Landed from contributor PR #18925 by @rexlunae. Thanks @rexlunae.</li>
<li>Cron/owner-only tools: pass trusted isolated cron runs into the embedded agent with owner context so <code>cron</code>/<code>gateway</code> tooling remains available after the owner-auth hardening narrowed direct-message ownership inference.</li>
<li>Browser/SSRF: block private-network intermediate redirect hops in strict browser navigation flows and fail closed when remote tab-open paths cannot inspect redirect chains. Thanks @zpbrent.</li>
<li>MS Teams/authz: keep <code>groupPolicy: "allowlist"</code> enforcing sender allowlists even when a team/channel route allowlist is configured, so route matches no longer widen group access to every sender in that route. Thanks @zpbrent.</li>
<li>Security/system.run: bind approved <code>bun</code> and <code>deno run</code> script operands to on-disk file snapshots so post-approval script rewrites are denied before execution.</li>
<li>Skills/download installs: pin the validated per-skill tools root before writing downloaded archives, so rebinding the lexical tools path cannot redirect download writes outside the intended tools directory. Thanks @tdjackey.</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.3.8-beta.1/OpenClaw-2026.3.8-beta.1.zip" length="23407015" type="application/octet-stream" sparkle:edSignature="KCqhSmu4b0tHf55RqcQOHorsc55CgBI5BUmK/NTizxNq04INn/7QvsamHYQou9DbB2IW6B2nawBC4nn4au5yDA=="/>
</item>
<item>
<title>2026.3.7</title>
<pubDate>Sun, 08 Mar 2026 04:42:35 +0000</pubDate>
@@ -38,6 +112,7 @@
<li>iOS/App Store Connect release prep: align iOS bundle identifiers under <code>ai.openclaw.client</code>, refresh Watch app icons, add Fastlane metadata/screenshot automation, and support Keychain-backed ASC auth for uploads. (#38936) Thanks @ngutman.</li>
<li>Mattermost/model picker: add Telegram-style interactive provider/model browsing for <code>/oc_model</code> and <code>/oc_models</code>, fix picker callback updates, and emit a normal confirmation reply when a model is selected. (#38767) thanks @mukhtharcm.</li>
<li>Docker/multi-stage build: restructure Dockerfile as a multi-stage build to produce a minimal runtime image without build tools, source code, or Bun; add <code>OPENCLAW_VARIANT=slim</code> build arg for a bookworm-slim variant. (#38479) Thanks @sallyom.</li>
<li>Google/Gemini 3.1 Flash-Lite: add first-class <code>google/gemini-3.1-flash-lite-preview</code> support across model-id normalization, default aliases, media-understanding image lookups, Google Gemini CLI forward-compat fallback, and docs.</li>
</ul>
<h3>Breaking</h3>
<ul>
@@ -361,7 +436,7 @@
</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.3.7-beta.1/OpenClaw-2026.3.7.zip" length="23263836" type="application/octet-stream" sparkle:edSignature="IjhLPw9QKZukMDV0rU/06N2D0cu7WyNBPfZ+UL58KbTo7av7s3syYAGAmr+zhvfkMoqvtrQfN9prnvnNIKLNCQ=="/>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.7/OpenClaw-2026.3.7.zip" length="23263833" type="application/octet-stream" sparkle:edSignature="SO0zedZMzrvSDltLkuaSVQTWFPPPe1iu/enS4TGGb5EGckhqRCmNJWMKNID5lKwFC8vefTbfG9JTlSrZedP4Bg=="/>
</item>
<item>
<title>2026.3.2</title>
@@ -583,144 +658,5 @@
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.2/OpenClaw-2026.3.2.zip" length="23181513" type="application/octet-stream" sparkle:edSignature="THMgkcoMgz2vv5zse3Po3K7l3Or2RhBKurXZIi8iYVXN76yJy1YXAY6kXi6ovD+dbYn68JKYDIKA1Ya78bO7BQ=="/>
<!-- pragma: allowlist secret -->
</item>
<item>
<title>2026.3.1</title>
<pubDate>Mon, 02 Mar 2026 04:40:59 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026030190</sparkle:version>
<sparkle:shortVersionString>2026.3.1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.3.1</h2>
<h3>Changes</h3>
<ul>
<li>Agents/Thinking defaults: set <code>adaptive</code> as the default thinking level for Anthropic Claude 4.6 models (including Bedrock Claude 4.6 refs) while keeping other reasoning-capable models at <code>low</code> unless explicitly configured.</li>
<li>Gateway/Container probes: add built-in HTTP liveness/readiness endpoints (<code>/health</code>, <code>/healthz</code>, <code>/ready</code>, <code>/readyz</code>) for Docker/Kubernetes health checks, with fallback routing so existing handlers on those paths are not shadowed. (#31272) Thanks @vincentkoc.</li>
<li>Android/Nodes: add <code>camera.list</code>, <code>device.permissions</code>, <code>device.health</code>, and <code>notifications.actions</code> (<code>open</code>/<code>dismiss</code>/<code>reply</code>) on Android nodes, plus first-class node-tool actions for the new device/notification commands. (#28260) Thanks @obviyus.</li>
<li>Discord/Thread bindings: replace fixed TTL lifecycle with inactivity (<code>idleHours</code>, default 24h) plus optional hard <code>maxAgeHours</code> lifecycle controls, and add <code>/session idle</code> + <code>/session max-age</code> commands for focused thread-bound sessions. (#27845) Thanks @osolmaz.</li>
<li>Telegram/DM topics: add per-DM <code>direct</code> + topic config (allowlists, <code>dmPolicy</code>, <code>skills</code>, <code>systemPrompt</code>, <code>requireTopic</code>), route DM topics as distinct inbound/outbound sessions, and enforce topic-aware authorization/debounce for messages, callbacks, commands, and reactions. Landed from contributor PR #30579 by @kesor. Thanks @kesor.</li>
<li>Web UI/Cron i18n: localize cron page labels, filters, form help text, and validation/error messaging in English and zh-CN. (#29315) Thanks @BUGKillerKing.</li>
<li>OpenAI/Streaming transport: make <code>openai</code> Responses WebSocket-first by default (<code>transport: "auto"</code> with SSE fallback), add shared OpenAI WS stream/connection runtime wiring with per-session cleanup, and preserve server-side compaction payload mutation (<code>store</code> + <code>context_management</code>) on the WS path.</li>
<li>Android/Gateway capability refresh: add live Android capability integration coverage and node canvas capability refresh wiring, plus runtime hardening for A2UI readiness retries, scoped canvas URL normalization, debug diagnostics JSON, and JavaScript MIME delivery. (#28388) Thanks @obviyus.</li>
<li>Android/Nodes parity: add <code>system.notify</code>, <code>photos.latest</code>, <code>contacts.search</code>/<code>contacts.add</code>, <code>calendar.events</code>/<code>calendar.add</code>, and <code>motion.activity</code>/<code>motion.pedometer</code>, with motion sensor-aware command gating and improved activity sampling reliability. (#29398) Thanks @obviyus.</li>
<li>CLI/Config: add <code>openclaw config file</code> to print the active config file path resolved from <code>OPENCLAW_CONFIG_PATH</code> or the default location. (#26256) thanks @cyb1278588254.</li>
<li>Feishu/Docx tables + uploads: add <code>feishu_doc</code> actions for Docx table creation/cell writing (<code>create_table</code>, <code>write_table_cells</code>, <code>create_table_with_values</code>) and image/file uploads (<code>upload_image</code>, <code>upload_file</code>) with stricter create/upload error handling for missing <code>document_id</code> and placeholder cleanup failures. (#20304) Thanks @xuhao1.</li>
<li>Feishu/Reactions: add inbound <code>im.message.reaction.created_v1</code> handling, route verified reactions through synthetic inbound turns, and harden verification with timeout + fail-closed filtering so non-bot or unverified reactions are dropped. (#16716) Thanks @schumilin.</li>
<li>Feishu/Chat tooling: add <code>feishu_chat</code> tool actions for chat info and member queries, with configurable enablement under <code>channels.feishu.tools.chat</code>. (#14674) Thanks @liuweifly.</li>
<li>Feishu/Doc permissions: support optional owner permission grant fields on <code>feishu_doc</code> create and report permission metadata only when the grant call succeeds, with regression coverage for success/failure/omitted-owner paths. (#28295) Thanks @zhoulongchao77.</li>
<li>Web UI/i18n: add German (<code>de</code>) locale support and auto-render language options from supported locale constants in Overview settings. (#28495) thanks @dsantoreis.</li>
<li>Tools/Diffs: add a new optional <code>diffs</code> plugin tool for read-only diff rendering from before/after text or unified patches, with gateway viewer URLs for canvas and PNG image output. Thanks @gumadeiras.</li>
<li>Memory/LanceDB: support custom OpenAI <code>baseUrl</code> and embedding dimensions for LanceDB memory. (#17874) Thanks @rish2jain and @vincentkoc.</li>
<li>ACP/ACPX streaming: pin ACPX plugin support to <code>0.1.15</code>, add configurable ACPX command/version probing, and streamline ACP stream delivery (<code>final_only</code> default + reduced tool-event noise) with matching runtime and test updates. (#30036) Thanks @osolmaz.</li>
<li>Shell env markers: set <code>OPENCLAW_SHELL</code> across shell-like runtimes (<code>exec</code>, <code>acp</code>, <code>acp-client</code>, <code>tui-local</code>) so shell startup/config rules can target OpenClaw contexts consistently, and document the markers in env/exec/acp/TUI docs. Thanks @vincentkoc.</li>
<li>Cron/Heartbeat light bootstrap context: add opt-in lightweight bootstrap mode for automation runs (<code>--light-context</code> for cron agent turns and <code>agents.*.heartbeat.lightContext</code> for heartbeat), keeping only <code>HEARTBEAT.md</code> for heartbeat runs and skipping bootstrap-file injection for cron lightweight runs. (#26064) Thanks @jose-velez.</li>
<li>OpenAI/WebSocket warm-up: add optional OpenAI Responses WebSocket warm-up (<code>response.create</code> with <code>generate:false</code>), enable it by default for <code>openai/*</code>, and expose <code>params.openaiWsWarmup</code> for per-model enable/disable control.</li>
<li>Agents/Subagents runtime events: replace ad-hoc subagent completion system-message handoff with typed internal completion events (<code>task_completion</code>) that are rendered consistently across direct and queued announce paths, with gateway/CLI plumbing for structured <code>internalEvents</code>.</li>
</ul>
<h3>Breaking</h3>
<ul>
<li><strong>BREAKING:</strong> Node exec approval payloads now require <code>systemRunPlan</code>. <code>host=node</code> approval requests without that plan are rejected.</li>
<li><strong>BREAKING:</strong> Node <code>system.run</code> execution now pins path-token commands to the canonical executable path (<code>realpath</code>) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example <code>tr</code>) must now accept canonical paths (for example <code>/usr/bin/tr</code>).</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Android/Nodes reliability: reject <code>facing=both</code> when <code>deviceId</code> is set to avoid mislabeled duplicate captures, allow notification <code>open</code>/<code>reply</code> on non-clearable entries while still gating dismiss, trigger listener rebind before notification actions, and scale invoke-result ack timeout to invoke budget for large clip payloads. (#28260) Thanks @obviyus.</li>
<li>Windows/Plugin install: avoid <code>spawn EINVAL</code> on Windows npm/npx invocations by resolving to <code>node</code> + npm CLI scripts instead of spawning <code>.cmd</code> directly. Landed from contributor PR #31147 by @codertony. Thanks @codertony.</li>
<li>LINE/Voice transcription: classify M4A voice media as <code>audio/mp4</code> (not <code>video/mp4</code>) by checking the MPEG-4 <code>ftyp</code> major brand (<code>M4A </code> / <code>M4B </code>), restoring voice transcription for LINE voice messages. Landed from contributor PR #31151 by @scoootscooob. Thanks @scoootscooob.</li>
<li>Slack/Announce target account routing: enable session-backed announce-target lookup for Slack so multi-account announces resolve the correct <code>accountId</code> instead of defaulting to bot-token context. Landed from contributor PR #31028 by @taw0002. Thanks @taw0002.</li>
<li>Android/Voice screen TTS: stream assistant speech via ElevenLabs WebSocket in Talk Mode, stop cleanly on speaker mute/barge-in, and ignore stale out-of-order stream events. (#29521) Thanks @gregmousseau.</li>
<li>Android/Photos permissions: declare Android 14+ selected-photo access permission (<code>READ_MEDIA_VISUAL_USER_SELECTED</code>) and align Android permission/settings paths with current minSdk behavior for more reliable permission state handling.</li>
<li>Web UI/Cron: include configured agent model defaults/fallbacks in cron model suggestions so scheduled-job model autocomplete reflects configured models. (#29709) Thanks @Sid-Qin.</li>
<li>Cron/Delivery: disable the agent messaging tool when <code>delivery.mode</code> is <code>"none"</code> so cron output is not sent to Telegram or other channels. (#21808) Thanks @lailoo.</li>
<li>CLI/Cron: clarify <code>cron list</code> output by renaming <code>Agent</code> to <code>Agent ID</code> and adding a <code>Model</code> column for isolated agent-turn jobs. (#26259) Thanks @openperf.</li>
<li>Feishu/Reply media attachments: send Feishu reply <code>mediaUrl</code>/<code>mediaUrls</code> payloads as attachments alongside text/streamed replies in the reply dispatcher, including legacy fallback when <code>mediaUrls</code> is empty. (#28959) Thanks @icesword0760.</li>
<li>Slack/User-token resolution: normalize Slack account user-token sourcing through resolved account metadata (<code>SLACK_USER_TOKEN</code> env + config) so monitor reads, Slack actions, directory lookups, onboarding allow-from resolution, and capabilities probing consistently use the effective user token. (#28103) Thanks @Glucksberg.</li>
<li>Feishu/Outbound session routing: stop assuming bare <code>oc_</code> identifiers are always group chats, honor explicit <code>dm:</code>/<code>group:</code> prefixes for <code>oc_</code> chat IDs, and default ambiguous bare <code>oc_</code> targets to direct routing to avoid DM session misclassification. (#10407) Thanks @Bermudarat.</li>
<li>Feishu/Group session routing: add configurable group session scopes (<code>group</code>, <code>group_sender</code>, <code>group_topic</code>, <code>group_topic_sender</code>) with legacy <code>topicSessionMode=enabled</code> compatibility so Feishu group conversations can isolate sessions by sender/topic as configured. (#17798) Thanks @yfge.</li>
<li>Feishu/Reply-in-thread routing: add <code>replyInThread</code> config (<code>disabled|enabled</code>) for group replies, propagate <code>reply_in_thread</code> across text/card/media/streaming sends, and align topic-scoped session routing so newly created reply threads stay on the same session root. (#27325) Thanks @kcinzgg.</li>
<li>Feishu/Probe status caching: cache successful <code>probeFeishu()</code> bot-info results for 10 minutes (bounded cache with per-account keying) to reduce repeated status/onboarding probe API calls, while bypassing cache for failures and exceptions. (#28907) Thanks @Glucksberg.</li>
<li>Feishu/Opus media send type: send <code>.opus</code> attachments with <code>msg_type: "audio"</code> (instead of <code>"media"</code>) so Feishu voice messages deliver correctly while <code>.mp4</code> remains <code>msg_type: "media"</code> and documents remain <code>msg_type: "file"</code>. (#28269) Thanks @Glucksberg.</li>
<li>Feishu/Mobile video media type: treat inbound <code>message_type: "media"</code> as video-equivalent for media key extraction, placeholder inference, and media download resolution so mobile-app video sends ingest correctly. (#25502) Thanks @4ier.</li>
<li>Feishu/Inbound sender fallback: fall back to <code>sender_id.user_id</code> when <code>sender_id.open_id</code> is missing on inbound events, and use ID-type-aware sender lookup so mobile-delivered messages keep stable sender identity/routing. (#26703) Thanks @NewdlDewdl.</li>
<li>Feishu/Reply context metadata: include inbound <code>parent_id</code> and <code>root_id</code> as <code>ReplyToId</code>/<code>RootMessageId</code> in inbound context, and parse interactive-card quote bodies into readable text when fetching replied messages. (#18529) Thanks @qiangu.</li>
<li>Feishu/Post embedded media: extract <code>media</code> tags from inbound rich-text (<code>post</code>) messages and download embedded video/audio files alongside existing embedded-image handling, with regression coverage. (#21786) Thanks @laopuhuluwa.</li>
<li>Feishu/Local media sends: propagate <code>mediaLocalRoots</code> through Feishu outbound media sending into <code>loadWebMedia</code> so local path attachments work with post-CVE local-root enforcement. (#27884) Thanks @joelnishanth.</li>
<li>Feishu/Group wildcard policy fallback: honor <code>channels.feishu.groups["*"]</code> when no explicit group match exists so unmatched groups inherit wildcard reply-policy settings instead of falling back to global defaults. (#29456) Thanks @WaynePika.</li>
<li>Feishu/Inbound media regression coverage: add explicit tests for message resource type mapping (<code>image</code> stays <code>image</code>, non-image maps to <code>file</code>) to prevent reintroducing unsupported Feishu <code>type=audio</code> fetches. (#16311, #8746) Thanks @Yaxuan42.</li>
<li>TTS/Voice bubbles: use opus output and enable <code>audioAsVoice</code> routing for Feishu and WhatsApp (in addition to Telegram) so supported channels receive voice-bubble playback instead of file-style audio attachments. (#27366) Thanks @smthfoxy.</li>
<li>Telegram/Reply media context: include replied media files in inbound context when replying to media, defer reply-media downloads to debounce flush, gate reply-media fetch behind DM authorization, and preserve replied media when non-vision sticker fallback runs (including cached-sticker paths). (#28488) Thanks @obviyus.</li>
<li>Android/Nodes notification wake flow: enable Android <code>system.notify</code> default allowlist, emit <code>notifications.changed</code> events for posted/removed notifications (excluding OpenClaw app-owned notifications), canonicalize notification session keys before enqueue/wake routing, and skip heartbeat wakes when consecutive notification summaries dedupe. (#29440) Thanks @obviyus.</li>
<li>Telegram/Voice fallback reply chunking: apply reply reference, quote text, and inline buttons only to the first fallback text chunk when voice delivery is blocked, preventing over-quoted multi-chunk replies. Landed from contributor PR #31067 by @xdanger. Thanks @xdanger.</li>
<li>Feishu/Multi-account + reply reliability: add <code>channels.feishu.defaultAccount</code> outbound routing support with schema validation, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as <code>msg_type: "file"</code>, and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #29610, #30432, #30331, and #29501. Thanks @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff.</li>
<li>Cron/Delivery: disable the agent messaging tool when <code>delivery.mode</code> is <code>"none"</code> so cron output is not sent to Telegram or other channels. (#21808) Thanks @lailoo.</li>
<li>Feishu/Inbound rich-text parsing: preserve <code>share_chat</code> payload summaries when available and add explicit parsing for rich-text <code>code</code>/<code>code_block</code>/<code>pre</code> tags so forwarded and code-heavy messages keep useful context in agent input. (#28591) Thanks @kevinWangSheng.</li>
<li>Feishu/Post markdown parsing: parse rich-text <code>post</code> payloads through a shared markdown-aware parser with locale-wrapper support, preserved mention/image metadata extraction, and inline/fenced code fidelity for agent input rendering. (#12755) Thanks @WilsonLiu95.</li>
<li>Telegram/Outbound chunking: route oversize splitting through the shared outbound pipeline (including subagents), retry Telegram sends when escaped HTML exceeds limits, and preserve boundary whitespace when retry re-splitting rendered chunks so plain-text/transcript fidelity is retained. (#29342, #27317; follow-up to #27461) Thanks @obviyus.</li>
<li>Slack/Native commands: register Slack native status as <code>/agentstatus</code> (Slack-reserved <code>/status</code>) so manifest slash command registration stays valid while text <code>/status</code> still works. Landed from contributor PR #29032 by @maloqab. Thanks @maloqab.</li>
<li>Android/Camera clip: remove <code>camera.clip</code> HTTP-upload fallback to base64 so clip transport is deterministic and fail-loud, and reject non-positive <code>maxWidth</code> values so invalid inputs fall back to the safe resize default. (#28229) Thanks @obviyus.</li>
<li>Android/Gateway canvas capability refresh: send <code>node.canvas.capability.refresh</code> with object <code>params</code> (<code>{}</code>) from Android node runtime so gateway object-schema validation accepts refresh retries and A2UI host recovery works after scoped capability expiry. (#28413) Thanks @obviyus.</li>
<li>Gateway/Control UI origins: honor <code>gateway.controlUi.allowedOrigins: ["*"]</code> wildcard entries (including trimmed values) and lock behavior with regression tests. Landed from contributor PR #31058 by @byungsker. Thanks @byungsker.</li>
<li>Web UI/Cron: include configured agent model defaults/fallbacks in cron model suggestions so scheduled-job model autocomplete reflects configured models. (#29709) Thanks @Sid-Qin.</li>
<li>Agents/Sessions list transcript paths: handle missing/non-string/relative <code>sessions.list.path</code> values and per-agent <code>{agentId}</code> templates when deriving <code>transcriptPath</code>, so cross-agent session listings resolve to concrete agent session files instead of workspace-relative paths. (#24775) Thanks @martinfrancois.</li>
<li>Gateway/Control UI CSP: allow required Google Fonts origins in Control UI CSP. (#29279) Thanks @Glucksberg and @vincentkoc.</li>
<li>CLI/Install: add an npm-link fallback to fix CLI startup <code>Permission denied</code> failures (<code>exit 127</code>) on affected installs. (#17151) Thanks @sskyu and @vincentkoc.</li>
<li>Onboarding/Custom providers: improve verification reliability for slower local endpoints (for example Ollama) during setup. (#27380) Thanks @Sid-Qin.</li>
<li>Plugins/NPM spec install: fix npm-spec plugin installs when <code>npm pack</code> output is empty by detecting newly created <code>.tgz</code> archives in the pack directory. (#21039) Thanks @graysurf and @vincentkoc.</li>
<li>Plugins/Install: clear stale install errors when an npm package is not found so follow-up install attempts report current state correctly. (#25073) Thanks @dalefrieswthat.</li>
<li>Security/Feishu webhook ingress: bound unauthenticated webhook rate-limit state with stale-window pruning and a hard key cap to prevent unbounded pre-auth memory growth from rotating source keys. (#26050) Thanks @bmendonca3.</li>
<li>Gateway/macOS supervised restart: actively <code>launchctl kickstart -k</code> during intentional supervised restarts to bypass LaunchAgent <code>ThrottleInterval</code> delays, and fall back to in-process restart when kickstart fails. Landed from contributor PR #29078 by @cathrynlavery. Thanks @cathrynlavery.</li>
<li>Daemon/macOS TLS certs: default LaunchAgent service env <code>NODE_EXTRA_CA_CERTS</code> to <code>/etc/ssl/cert.pem</code> (while preserving explicit overrides) so HTTPS clients no longer fail with local-issuer errors under launchd. (#27915) Thanks @Lukavyi.</li>
<li>Discord/Components wildcard handlers: use distinct internal registration sentinel IDs and parse those sentinels as wildcard keys so select/user/role/channel/mentionable/modal interactions are not dropped by raw customId dedupe paths. Landed from contributor PR #29459 by @Sid-Qin. Thanks @Sid-Qin.</li>
<li>Feishu/Reaction notifications: add <code>channels.feishu.reactionNotifications</code> (<code>off | own | all</code>, default <code>own</code>) so operators can disable reaction ingress or allow all verified reaction events (not only bot-authored message reactions). (#28529) Thanks @cowboy129.</li>
<li>Feishu/Typing backoff: re-throw Feishu typing add/remove rate-limit and quota errors (<code>429</code>, <code>99991400</code>, <code>99991403</code>) and detect SDK non-throwing backoff responses so the typing keepalive circuit breaker can stop retries instead of looping indefinitely. (#28494) Thanks @guoqunabc.</li>
<li>Feishu/Zalo runtime logging: replace direct <code>console.log/error</code> usage in Feishu typing-indicator paths and Zalo monitor paths with runtime-gated logger calls so verbosity controls are respected while preserving typing backoff behavior. (#18841) Thanks @Clawborn.</li>
<li>Feishu/Group sender allowlist fallback: add global <code>channels.feishu.groupSenderAllowFrom</code> sender authorization for group chats, with per-group <code>groups.<id>.allowFrom</code> precedence and regression coverage for allow/block/precedence behavior. (#29174) Thanks @1MoreBuild.</li>
<li>Feishu/Docx append/write ordering: insert converted Docx blocks sequentially (single-block creates) so Feishu append/write preserves markdown block order instead of returning shuffled sections in asynchronous batch inserts. (#26172, #26022) Thanks @echoVic.</li>
<li>Feishu/Docx convert fallback chunking: recursively split oversized markdown chunks (including long no-heading sections) when <code>document.convert</code> hits content limits, while keeping fenced-code-aware split boundaries whenever possible. (#14402) Thanks @lml2468.</li>
<li>Feishu/API quota controls: add <code>typingIndicator</code> and <code>resolveSenderNames</code> config flags (top-level and per-account) so operators can disable typing reactions and sender-name lookup requests while keeping default behavior unchanged. (#10513) Thanks @BigUncle.</li>
<li>Feishu/System preview prompt leakage: stop enqueuing inbound Feishu message previews as system events so user preview text is not injected into later turns as trusted <code>System:</code> context. Landed from contributor PR #31209 by @stakeswky. Thanks @stakeswky.</li>
<li>Feishu/Typing replay suppression: skip typing indicators for stale replayed inbound messages after compaction using message-age checks with second/millisecond timestamp normalization, preventing old-message reaction floods while preserving typing for fresh messages. Landed from contributor PR #30709 by @arkyu2077. Thanks @arkyu2077.</li>
<li>Sessions/Internal routing: preserve established external <code>lastTo</code>/<code>lastChannel</code> routes for internal/non-deliverable turns, with added coverage for no-fallback internal routing behavior. Landed from contributor PR #30941 by @graysurf. Thanks @graysurf.</li>
<li>Control UI/Debug log layout: render Debug Event Log payloads at full width to prevent payload JSON from being squeezed into a narrow side column. Landed from contributor PR #30978 by @stozo04. Thanks @stozo04.</li>
<li>Auto-reply/NO_REPLY: strip <code>NO_REPLY</code> token from mixed-content messages instead of leaking raw control text to end users. Landed from contributor PR #31080 by @scoootscooob. Thanks @scoootscooob.</li>
<li>Install/npm: fix npm global install deprecation warnings. (#28318) Thanks @vincentkoc.</li>
<li>Update/Global npm: fallback to <code>--omit=optional</code> when global <code>npm update</code> fails so optional dependency install failures no longer abort update flows. (#24896) Thanks @xinhuagu and @vincentkoc.</li>
<li>Inbound metadata/Multi-account routing: include <code>account_id</code> in trusted inbound metadata so multi-account channel sessions can reliably disambiguate the receiving account in prompt context. Landed from contributor PR #30984 by @Stxle2. Thanks @Stxle2.</li>
<li>Model directives/Auth profiles: split <code>/model</code> profile suffixes at the first <code>@</code> after the last slash so email-based auth profile IDs (for example OAuth profile IDs) resolve correctly. Landed from contributor PR #30932 by @haosenwang1018. Thanks @haosenwang1018.</li>
<li>Cron/Delivery mode none: send explicit <code>delivery: { mode: "none" }</code> from cron editor for both add and update flows so previous announce delivery is actually cleared. Landed from contributor PR #31145 by @byungsker. Thanks @byungsker.</li>
<li>Cron editor viewport: make the sticky cron edit form independently scrollable with viewport-bounded height so lower fields/actions are reachable on shorter screens. Landed from contributor PR #31133 by @Sid-Qin. Thanks @Sid-Qin.</li>
<li>Agents/Thinking fallback: when providers reject unsupported thinking levels without enumerating alternatives, retry with <code>think=off</code> to avoid hard failure during model/provider fallback chains. Landed from contributor PR #31002 by @yfge. Thanks @yfge.</li>
<li>Ollama/Embedded runner base URL precedence: prioritize configured provider <code>baseUrl</code> over model defaults for embedded Ollama runs so Docker and remote-host setups avoid localhost fetch failures. (#30964) Thanks @stakeswky.</li>
<li>Agents/Failover reason classification: avoid false rate-limit classification from incidental <code>tpm</code> substrings by matching TPM as a standalone token/phrase and keeping auth-context errors on the auth path. Landed from contributor PR #31007 by @HOYALIM. Thanks @HOYALIM.</li>
<li>CLI/Cron: clarify <code>cron list</code> output by renaming <code>Agent</code> to <code>Agent ID</code> and adding a <code>Model</code> column for isolated agent-turn jobs. (#26259) Thanks @openperf.</li>
<li>Gateway/WS: close repeated post-handshake <code>unauthorized role:*</code> request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc.</li>
<li>Gateway/Auth: improve device-auth v2 migration diagnostics so operators get clearer guidance when legacy clients connect. (#28305) Thanks @vincentkoc.</li>
<li>CLI/Ollama config: allow <code>config set</code> for Ollama <code>apiKey</code> without predeclared provider config. (#29299) Thanks @vincentkoc.</li>
<li>Ollama/Autodiscovery: harden autodiscovery and warning behavior. (#29201) Thanks @marcodelpin and @vincentkoc.</li>
<li>Ollama/Context window: unify context window handling across discovery, merge, and OpenAI-compatible transport paths. (#29205) Thanks @Sid-Qin, @jimmielightner, and @vincentkoc.</li>
<li>Agents/Ollama: demote empty-discovery logging from <code>warn</code> to <code>debug</code> to reduce noisy warnings in normal edge-case discovery flows. (#26379) Thanks @byungsker.</li>
<li>fix(model): preserve reasoning in provider fallback resolution. (#29285) Fixes #25636. Thanks @vincentkoc.</li>
<li>Docker/Image permissions: normalize <code>/app/extensions</code>, <code>/app/.agent</code>, and <code>/app/.agents</code> to directory mode <code>755</code> and file mode <code>644</code> during image build so plugin discovery does not block inherited world-writable paths. (#30191) Fixes #30139. Thanks @edincampara.</li>
<li>OpenAI Responses/Compaction: rewrite and unify the OpenAI Responses store patches to treat empty <code>baseUrl</code> as non-direct, honor <code>compat.supportsStore=false</code>, and auto-inject server-side compaction <code>context_management</code> for compatible direct OpenAI models (with per-model opt-out/threshold overrides). Landed from contributor PRs #16930 (@OiPunk), #22441 (@EdwardWu7), and #25088 (@MoerAI). Thanks @OiPunk, @EdwardWu7, and @MoerAI.</li>
<li>Sandbox/Browser Docker: pass <code>OPENCLAW_BROWSER_NO_SANDBOX=1</code> to sandbox browser containers and bump sandbox browser security hash epoch so existing containers are recreated and pick up the env on upgrade. (#29879) Thanks @Lukavyi.</li>
<li>Usage normalization: clamp negative prompt/input token values to zero (including <code>prompt_tokens</code> alias inputs) so <code>/usage</code> and TUI usage displays cannot show nonsensical negative counts. Landed from contributor PR #31211 by @scoootscooob. Thanks @scoootscooob.</li>
<li>Secrets/Auth profiles: normalize inline SecretRef <code>token</code>/<code>key</code> values to canonical <code>tokenRef</code>/<code>keyRef</code> before persistence, and keep explicit <code>keyRef</code> precedence when inline refs are also present. Landed from contributor PR #31047 by @minupla. Thanks @minupla.</li>
<li>Tools/Edit workspace boundary errors: preserve the real <code>Path escapes workspace root</code> failure path instead of surfacing a misleading access/file-not-found error when editing outside workspace roots. Landed from contributor PR #31015 by @haosenwang1018. Thanks @haosenwang1018.</li>
<li>Browser/Open & navigate: accept <code>url</code> as an alias parameter for <code>open</code> and <code>navigate</code>. (#29260) Thanks @vincentkoc.</li>
<li>Codex/Usage window: label weekly usage window as <code>Week</code> instead of <code>Day</code>. (#26267) Thanks @Sid-Qin.</li>
<li>Signal/Sync message null-handling: treat <code>syncMessage</code> presence (including <code>null</code>) as sync envelope traffic so replayed sentTranscript payloads cannot bypass loop guards after daemon restart. Landed from contributor PR #31138 by @Sid-Qin. Thanks @Sid-Qin.</li>
<li>Infra/fs-safe: sanitize directory-read failures so raw <code>EISDIR</code> text never leaks to messaging surfaces, with regression tests for both root-scoped and direct safe reads. Landed from contributor PR #31205 by @polooooo. Thanks @polooooo.</li>
<li>Sandbox/mkdirp boundary checks: allow directory-safe boundary validation for existing in-boundary subdirectories, preventing false <code>cannot create directories</code> failures in sandbox write mode. (#30610) Thanks @glitch418x.</li>
<li>Security/Compaction audit: remove the post-compaction audit injection message. (#28507) Thanks @fuller-stack-dev and @vincentkoc.</li>
<li>Web tools/RFC2544 fake-IP compatibility: allow RFC2544 benchmark range (<code>198.18.0.0/15</code>) for trusted web-tool fetch endpoints so proxy fake-IP networking modes do not trigger false SSRF blocks. Landed from contributor PR #31176 by @sunkinux. Thanks @sunkinux.</li>
<li>Telegram/Voice fallback reply chunking: apply reply reference, quote text, and inline buttons only to the first fallback text chunk when voice delivery is blocked, preventing over-quoted multi-chunk replies. Landed from contributor PR #31067 by @xdanger. Thanks @xdanger.</li>
<li>Feishu/System preview prompt leakage: stop enqueuing inbound Feishu message previews as system events so user preview text is not injected into later turns as trusted <code>System:</code> context. Landed from contributor PR #31209 by @stakeswky. Thanks @stakeswky.</li>
<li>Feishu/Multi-account + reply reliability: add <code>channels.feishu.defaultAccount</code> outbound routing support with schema validation, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as <code>msg_type: "file"</code>, and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #29610, #30432, #30331, and #29501. Thanks @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff.</li>
<li>Feishu/Typing replay suppression: skip typing indicators for stale replayed inbound messages after compaction using message-age checks with second/millisecond timestamp normalization, preventing old-message reaction floods while preserving typing for fresh messages. Landed from contributor PR #30709 by @arkyu2077. Thanks @arkyu2077.</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.3.1/OpenClaw-2026.3.1.zip" length="12804155" type="application/octet-stream" sparkle:edSignature="TF1otD4Vk3pG0iViX7mvix5DQEgAsk4JkSFvH7opjf9aawV16f29SUa2wRmiCFU6HEgyNgnGI/078O+A27eXCA=="/>
<!-- pragma: allowlist secret -->
</item>
</channel>
</rss>

View File

@@ -211,7 +211,7 @@ What it does:
- Reads `node.describe` command list from the selected Android node.
- Invokes advertised non-interactive commands.
- Skips `screen.record` in this suite (Android requires interactive per-invocation screen-capture consent).
- Asserts command contracts (success or expected deterministic error for safe-invalid calls like `sms.send`, `notifications.actions`, `app.update`).
- Asserts command contracts (success or expected deterministic error for safe-invalid calls like `sms.send` and `notifications.actions`).
Common failure quick-fixes:

View File

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

View File

@@ -3,15 +3,12 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.SEND_SMS" />
@@ -25,7 +22,6 @@
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
@@ -47,7 +43,7 @@
<service
android:name=".NodeForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync|microphone|mediaProjection" />
android:foregroundServiceType="dataSync" />
<service
android:name=".node.DeviceNotificationListenerService"
android:label="@string/app_name"
@@ -76,9 +72,5 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".InstallResultReceiver"
android:exported="false" />
</application>
</manifest>

View File

@@ -1,33 +0,0 @@
package ai.openclaw.app
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.util.Log
class InstallResultReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
when (status) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
// System needs user confirmation — launch the confirmation activity
@Suppress("DEPRECATION")
val confirmIntent = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
if (confirmIntent != null) {
confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(confirmIntent)
Log.w("openclaw", "app.update: user confirmation requested, launching install dialog")
}
}
PackageInstaller.STATUS_SUCCESS -> {
Log.w("openclaw", "app.update: install SUCCESS")
}
else -> {
Log.e("openclaw", "app.update: install FAILED status=$status message=$message")
}
}
}
}

View File

@@ -3,12 +3,12 @@ package ai.openclaw.app
enum class LocationMode(val rawValue: String) {
Off("off"),
WhileUsing("whileUsing"),
Always("always"),
;
companion object {
fun fromRawValue(raw: String?): LocationMode {
val normalized = raw?.trim()?.lowercase()
if (normalized == "always") return WhileUsing
return entries.firstOrNull { it.rawValue.lowercase() == normalized } ?: Off
}
}

View File

@@ -18,18 +18,14 @@ import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModels()
private lateinit var permissionRequester: PermissionRequester
private lateinit var screenCaptureRequester: ScreenCaptureRequester
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
permissionRequester = PermissionRequester(this)
screenCaptureRequester = ScreenCaptureRequester(this)
viewModel.camera.attachLifecycleOwner(this)
viewModel.camera.attachPermissionRequester(permissionRequester)
viewModel.sms.attachPermissionRequester(permissionRequester)
viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester)
viewModel.screenRecorder.attachPermissionRequester(permissionRequester)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {

View File

@@ -6,7 +6,6 @@ import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.chat.OutgoingAttachment
import ai.openclaw.app.node.CameraCaptureManager
import ai.openclaw.app.node.CanvasController
import ai.openclaw.app.node.ScreenRecordManager
import ai.openclaw.app.node.SmsManager
import ai.openclaw.app.voice.VoiceConversationEntry
import kotlinx.coroutines.flow.StateFlow
@@ -20,7 +19,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val canvasRehydratePending: StateFlow<Boolean> = runtime.canvasRehydratePending
val canvasRehydrateErrorText: StateFlow<String?> = runtime.canvasRehydrateErrorText
val camera: CameraCaptureManager = runtime.camera
val screenRecorder: ScreenRecordManager = runtime.screenRecorder
val sms: SmsManager = runtime.sms
val gateways: StateFlow<List<GatewayEndpoint>> = runtime.gateways
@@ -38,7 +36,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
val screenRecordActive: StateFlow<Boolean> = runtime.screenRecordActive
val instanceId: StateFlow<String> = runtime.instanceId
val displayName: StateFlow<String> = runtime.displayName

View File

@@ -5,13 +5,10 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.app.PendingIntent
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -23,14 +20,13 @@ import kotlinx.coroutines.launch
class NodeForegroundService : Service() {
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var notificationJob: Job? = null
private var lastRequiresMic = false
private var didStartForeground = false
override fun onCreate() {
super.onCreate()
ensureChannel()
val initial = buildNotification(title = "OpenClaw Node", text = "Starting…")
startForegroundWithTypes(notification = initial, requiresMic = false)
startForegroundWithTypes(notification = initial)
val runtime = (application as NodeApp).runtime
notificationJob =
@@ -53,11 +49,8 @@ class NodeForegroundService : Service() {
}
val text = (server?.let { "$status · $it" } ?: status) + micSuffix
val requiresMic =
micEnabled && hasRecordAudioPermission()
startForegroundWithTypes(
notification = buildNotification(title = title, text = text),
requiresMic = requiresMic,
)
}
}
@@ -135,30 +128,15 @@ class NodeForegroundService : Service() {
mgr.notify(NOTIFICATION_ID, notification)
}
private fun startForegroundWithTypes(notification: Notification, requiresMic: Boolean) {
if (didStartForeground && requiresMic == lastRequiresMic) {
private fun startForegroundWithTypes(notification: Notification) {
if (didStartForeground) {
updateNotification(notification)
return
}
lastRequiresMic = requiresMic
val types =
if (requiresMic) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
} else {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
}
startForeground(NOTIFICATION_ID, notification, types)
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
didStartForeground = true
}
private fun hasRecordAudioPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
)
}
companion object {
private const val CHANNEL_ID = "connection"
private const val NOTIFICATION_ID = 1

View File

@@ -50,7 +50,6 @@ class NodeRuntime(context: Context) {
val canvas = CanvasController()
val camera = CameraCaptureManager(appContext)
val location = LocationCaptureManager(appContext)
val screenRecorder = ScreenRecordManager(appContext)
val sms = SmsManager(appContext)
private val json = Json { ignoreUnknownKeys = true }
@@ -77,17 +76,11 @@ class NodeRuntime(context: Context) {
identityStore = identityStore,
)
private val appUpdateHandler: AppUpdateHandler = AppUpdateHandler(
appContext = appContext,
connectedEndpoint = { connectedEndpoint },
)
private val locationHandler: LocationHandler = LocationHandler(
appContext = appContext,
location = location,
json = json,
isForeground = { _isForeground.value },
locationMode = { locationMode.value },
locationPreciseEnabled = { locationPreciseEnabled.value },
)
@@ -119,12 +112,6 @@ class NodeRuntime(context: Context) {
appContext = appContext,
)
private val screenHandler: ScreenHandler = ScreenHandler(
screenRecorder = screenRecorder,
setScreenRecordActive = { _screenRecordActive.value = it },
invokeErrorFromThrowable = { invokeErrorFromThrowable(it) },
)
private val smsHandlerImpl: SmsHandler = SmsHandler(
sms = sms,
)
@@ -159,11 +146,9 @@ class NodeRuntime(context: Context) {
contactsHandler = contactsHandler,
calendarHandler = calendarHandler,
motionHandler = motionHandler,
screenHandler = screenHandler,
smsHandler = smsHandlerImpl,
a2uiHandler = a2uiHandler,
debugHandler = debugHandler,
appUpdateHandler = appUpdateHandler,
isForeground = { _isForeground.value },
cameraEnabled = { cameraEnabled.value },
locationEnabled = { locationMode.value != LocationMode.Off },
@@ -206,9 +191,6 @@ class NodeRuntime(context: Context) {
private val _cameraFlashToken = MutableStateFlow(0L)
val cameraFlashToken: StateFlow<Long> = _cameraFlashToken.asStateFlow()
private val _screenRecordActive = MutableStateFlow(false)
val screenRecordActive: StateFlow<Boolean> = _screenRecordActive.asStateFlow()
private val _canvasA2uiHydrated = MutableStateFlow(false)
val canvasA2uiHydrated: StateFlow<Boolean> = _canvasA2uiHydrated.asStateFlow()
private val _canvasRehydratePending = MutableStateFlow(false)
@@ -623,6 +605,9 @@ class NodeRuntime(context: Context) {
fun setForeground(value: Boolean) {
_isForeground.value = value
if (!value) {
stopActiveVoiceSession()
}
}
fun setDisplayName(value: String) {
@@ -667,11 +652,7 @@ class NodeRuntime(context: Context) {
fun setVoiceScreenActive(active: Boolean) {
if (!active) {
// User left voice screen — stop mic and TTS
talkMode.ttsOnAllResponses = false
talkMode.stopTts()
micCapture.setMicEnabled(false)
prefs.setTalkEnabled(false)
stopActiveVoiceSession()
}
// Don't re-enable on active=true; mic toggle drives that
}
@@ -700,6 +681,14 @@ class NodeRuntime(context: Context) {
talkMode.setPlaybackEnabled(value)
}
private fun stopActiveVoiceSession() {
talkMode.ttsOnAllResponses = false
talkMode.stopTts()
micCapture.setMicEnabled(false)
prefs.setTalkEnabled(false)
externalAudioCaptureActive.value = false
}
fun refreshGatewayConnection() {
val endpoint =
connectedEndpoint ?: run {

View File

@@ -1,65 +0,0 @@
package ai.openclaw.app
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.media.projection.MediaProjectionManager
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
class ScreenCaptureRequester(private val activity: ComponentActivity) {
data class CaptureResult(val resultCode: Int, val data: Intent)
private val mutex = Mutex()
private var pending: CompletableDeferred<CaptureResult?>? = null
private val launcher: ActivityResultLauncher<Intent> =
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val p = pending
pending = null
val data = result.data
if (result.resultCode == Activity.RESULT_OK && data != null) {
p?.complete(CaptureResult(result.resultCode, data))
} else {
p?.complete(null)
}
}
suspend fun requestCapture(timeoutMs: Long = 20_000): CaptureResult? =
mutex.withLock {
val proceed = showRationaleDialog()
if (!proceed) return null
val mgr = activity.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
val intent = mgr.createScreenCaptureIntent()
val deferred = CompletableDeferred<CaptureResult?>()
pending = deferred
withContext(Dispatchers.Main) { launcher.launch(intent) }
withContext(Dispatchers.Default) { withTimeout(timeoutMs) { deferred.await() } }
}
private suspend fun showRationaleDialog(): Boolean =
withContext(Dispatchers.Main) {
suspendCancellableCoroutine { cont ->
AlertDialog.Builder(activity)
.setTitle("Screen recording required")
.setMessage("OpenClaw needs to record the screen for this command.")
.setPositiveButton("Continue") { _, _ -> cont.resume(true) }
.setNegativeButton("Not now") { _, _ -> cont.resume(false) }
.setOnCancelListener { cont.resume(false) }
.show()
}
}
}

View File

@@ -19,6 +19,7 @@ class SecurePrefs(context: Context) {
companion object {
val defaultWakeWords: List<String> = listOf("openclaw", "claude")
private const val displayNameKey = "node.displayName"
private const val locationModeKey = "location.enabledMode"
private const val voiceWakeModeKey = "voiceWake.mode"
private const val plainPrefsName = "openclaw.node"
private const val securePrefsName = "openclaw.node.secure"
@@ -46,8 +47,7 @@ class SecurePrefs(context: Context) {
private val _cameraEnabled = MutableStateFlow(plainPrefs.getBoolean("camera.enabled", true))
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
private val _locationMode =
MutableStateFlow(LocationMode.fromRawValue(plainPrefs.getString("location.enabledMode", "off")))
private val _locationMode = MutableStateFlow(loadLocationMode())
val locationMode: StateFlow<LocationMode> = _locationMode
private val _locationPreciseEnabled =
@@ -120,7 +120,7 @@ class SecurePrefs(context: Context) {
}
fun setLocationMode(mode: LocationMode) {
plainPrefs.edit { putString("location.enabledMode", mode.rawValue) }
plainPrefs.edit { putString(locationModeKey, mode.rawValue) }
_locationMode.value = mode
}
@@ -290,6 +290,15 @@ class SecurePrefs(context: Context) {
return resolved
}
private fun loadLocationMode(): LocationMode {
val raw = plainPrefs.getString(locationModeKey, "off")
val resolved = LocationMode.fromRawValue(raw)
if (raw?.trim()?.lowercase() == "always") {
plainPrefs.edit { putString(locationModeKey, resolved.rawValue) }
}
return resolved
}
private fun loadWakeWords(): List<String> {
val raw = plainPrefs.getString("voiceWake.triggerWords", null)?.trim()
if (raw.isNullOrEmpty()) return defaultWakeWords

View File

@@ -1,295 +0,0 @@
package ai.openclaw.app.node
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import ai.openclaw.app.InstallResultReceiver
import ai.openclaw.app.MainActivity
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.GatewaySession
import java.io.File
import java.net.URI
import java.security.MessageDigest
import java.util.Locale
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
private val SHA256_HEX = Regex("^[a-fA-F0-9]{64}$")
internal data class AppUpdateRequest(
val url: String,
val expectedSha256: String,
)
internal fun parseAppUpdateRequest(paramsJson: String?, connectedHost: String?): AppUpdateRequest {
val params =
try {
paramsJson?.let { Json.parseToJsonElement(it).jsonObject }
} catch (_: Throwable) {
throw IllegalArgumentException("params must be valid JSON")
} ?: throw IllegalArgumentException("missing 'url' parameter")
val urlRaw =
params["url"]?.jsonPrimitive?.content?.trim().orEmpty()
.ifEmpty { throw IllegalArgumentException("missing 'url' parameter") }
val sha256Raw =
params["sha256"]?.jsonPrimitive?.content?.trim().orEmpty()
.ifEmpty { throw IllegalArgumentException("missing 'sha256' parameter") }
if (!SHA256_HEX.matches(sha256Raw)) {
throw IllegalArgumentException("invalid 'sha256' parameter (expected 64 hex chars)")
}
val uri =
try {
URI(urlRaw)
} catch (_: Throwable) {
throw IllegalArgumentException("invalid 'url' parameter")
}
val scheme = uri.scheme?.lowercase(Locale.US).orEmpty()
if (scheme != "https") {
throw IllegalArgumentException("url must use https")
}
if (!uri.userInfo.isNullOrBlank()) {
throw IllegalArgumentException("url must not include credentials")
}
val host = uri.host?.lowercase(Locale.US) ?: throw IllegalArgumentException("url host required")
val connectedHostNormalized = connectedHost?.trim()?.lowercase(Locale.US).orEmpty()
if (connectedHostNormalized.isNotEmpty() && host != connectedHostNormalized) {
throw IllegalArgumentException("url host must match connected gateway host")
}
return AppUpdateRequest(
url = uri.toASCIIString(),
expectedSha256 = sha256Raw.lowercase(Locale.US),
)
}
internal fun sha256Hex(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read < 0) break
if (read == 0) continue
digest.update(buffer, 0, read)
}
}
val out = StringBuilder(64)
for (byte in digest.digest()) {
out.append(String.format(Locale.US, "%02x", byte))
}
return out.toString()
}
class AppUpdateHandler(
private val appContext: Context,
private val connectedEndpoint: () -> GatewayEndpoint?,
) {
fun handleUpdate(paramsJson: String?): GatewaySession.InvokeResult {
try {
val updateRequest =
try {
parseAppUpdateRequest(paramsJson, connectedEndpoint()?.host)
} catch (err: IllegalArgumentException) {
return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: ${err.message ?: "invalid app.update params"}",
)
}
val url = updateRequest.url
val expectedSha256 = updateRequest.expectedSha256
android.util.Log.w("openclaw", "app.update: downloading from $url")
val notifId = 9001
val channelId = "app_update"
val notifManager = appContext.getSystemService(android.content.Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
// Create notification channel (required for Android 8+)
val channel = android.app.NotificationChannel(channelId, "App Updates", android.app.NotificationManager.IMPORTANCE_LOW)
notifManager.createNotificationChannel(channel)
// PendingIntent to open the app when notification is tapped
val launchIntent = Intent(appContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val launchPi = PendingIntent.getActivity(appContext, 0, launchIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
// Launch download async so the invoke returns immediately
CoroutineScope(Dispatchers.IO).launch {
try {
val cacheDir = java.io.File(appContext.cacheDir, "updates")
cacheDir.mkdirs()
val file = java.io.File(cacheDir, "update.apk")
if (file.exists()) file.delete()
// Show initial progress notification
fun buildProgressNotif(progress: Int, max: Int, text: String): android.app.Notification {
return android.app.Notification.Builder(appContext, channelId)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setContentTitle("OpenClaw Update")
.setContentText(text)
.setProgress(max, progress, max == 0)
.setContentIntent(launchPi)
.setOngoing(true)
.build()
}
notifManager.notify(notifId, buildProgressNotif(0, 0, "Connecting..."))
val client = okhttp3.OkHttpClient.Builder()
.connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(300, java.util.concurrent.TimeUnit.SECONDS)
.build()
val request = okhttp3.Request.Builder().url(url).build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
notifManager.cancel(notifId)
notifManager.notify(notifId, android.app.Notification.Builder(appContext, channelId)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setContentTitle("Update Failed")
.setContentIntent(launchPi)
.setContentText("HTTP ${response.code}")
.build())
return@launch
}
val contentLength = response.body?.contentLength() ?: -1L
val body = response.body ?: run {
notifManager.cancel(notifId)
return@launch
}
// Download with progress tracking
var totalBytes = 0L
var lastNotifUpdate = 0L
body.byteStream().use { input ->
file.outputStream().use { output ->
val buffer = ByteArray(8192)
while (true) {
val bytesRead = input.read(buffer)
if (bytesRead == -1) break
output.write(buffer, 0, bytesRead)
totalBytes += bytesRead
// Update notification at most every 500ms
val now = System.currentTimeMillis()
if (now - lastNotifUpdate > 500) {
lastNotifUpdate = now
if (contentLength > 0) {
val pct = ((totalBytes * 100) / contentLength).toInt()
val mb = String.format(Locale.US, "%.1f", totalBytes / 1048576.0)
val totalMb = String.format(Locale.US, "%.1f", contentLength / 1048576.0)
notifManager.notify(notifId, buildProgressNotif(pct, 100, "$mb / $totalMb MB ($pct%)"))
} else {
val mb = String.format(Locale.US, "%.1f", totalBytes / 1048576.0)
notifManager.notify(notifId, buildProgressNotif(0, 0, "${mb} MB downloaded"))
}
}
}
}
}
android.util.Log.w("openclaw", "app.update: downloaded ${file.length()} bytes")
val actualSha256 = sha256Hex(file)
if (actualSha256 != expectedSha256) {
android.util.Log.e(
"openclaw",
"app.update: sha256 mismatch expected=$expectedSha256 actual=$actualSha256",
)
file.delete()
notifManager.cancel(notifId)
notifManager.notify(
notifId,
android.app.Notification.Builder(appContext, channelId)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setContentTitle("Update Failed")
.setContentIntent(launchPi)
.setContentText("SHA-256 mismatch")
.build(),
)
return@launch
}
// Verify file is a valid APK (basic check: ZIP magic bytes)
val magic = file.inputStream().use { it.read().toByte() to it.read().toByte() }
if (magic.first != 0x50.toByte() || magic.second != 0x4B.toByte()) {
android.util.Log.e("openclaw", "app.update: invalid APK (bad magic: ${magic.first}, ${magic.second})")
file.delete()
notifManager.cancel(notifId)
notifManager.notify(notifId, android.app.Notification.Builder(appContext, channelId)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setContentTitle("Update Failed")
.setContentIntent(launchPi)
.setContentText("Downloaded file is not a valid APK")
.build())
return@launch
}
// Use PackageInstaller session API — works from background on API 34+
// The system handles showing the install confirmation dialog
notifManager.cancel(notifId)
notifManager.notify(
notifId,
android.app.Notification.Builder(appContext, channelId)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentTitle("Installing Update...")
.setContentIntent(launchPi)
.setContentText("${String.format(Locale.US, "%.1f", totalBytes / 1048576.0)} MB downloaded")
.build(),
)
val installer = appContext.packageManager.packageInstaller
val params = android.content.pm.PackageInstaller.SessionParams(
android.content.pm.PackageInstaller.SessionParams.MODE_FULL_INSTALL
)
params.setSize(file.length())
val sessionId = installer.createSession(params)
val session = installer.openSession(sessionId)
session.openWrite("openclaw-update.apk", 0, file.length()).use { out ->
file.inputStream().use { inp -> inp.copyTo(out) }
session.fsync(out)
}
// Commit with FLAG_MUTABLE PendingIntent — system requires mutable for PackageInstaller status
val callbackIntent = android.content.Intent(appContext, InstallResultReceiver::class.java)
val pi = android.app.PendingIntent.getBroadcast(
appContext, sessionId, callbackIntent,
android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_MUTABLE
)
session.commit(pi.intentSender)
android.util.Log.w("openclaw", "app.update: PackageInstaller session committed, waiting for user confirmation")
} catch (err: Throwable) {
android.util.Log.e("openclaw", "app.update: async error", err)
notifManager.cancel(notifId)
notifManager.notify(notifId, android.app.Notification.Builder(appContext, channelId)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setContentTitle("Update Failed")
.setContentIntent(launchPi)
.setContentText(err.message ?: "Unknown error")
.build())
}
}
// Return immediately — download happens in background
return GatewaySession.InvokeResult.ok(buildJsonObject {
put("status", "downloading")
put("url", url)
put("sha256", expectedSha256)
}.toString())
} catch (err: Throwable) {
android.util.Log.e("openclaw", "app.update: error", err)
return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = err.message ?: "update failed")
}
}
}

View File

@@ -170,13 +170,6 @@ class DeviceHandler(
promptableWhenDenied = true,
),
)
put(
"backgroundLocation",
permissionStateJson(
granted = hasPermission(Manifest.permission.ACCESS_BACKGROUND_LOCATION),
promptableWhenDenied = true,
),
)
put(
"sms",
permissionStateJson(
@@ -226,14 +219,6 @@ class DeviceHandler(
promptableWhenDenied = true,
),
)
// Screen capture on Android is interactive per-capture consent, not a sticky app permission.
put(
"screenCapture",
permissionStateJson(
granted = false,
promptableWhenDenied = true,
),
)
},
)
}.toString()

View File

@@ -11,7 +11,6 @@ import ai.openclaw.app.protocol.OpenClawLocationCommand
import ai.openclaw.app.protocol.OpenClawMotionCommand
import ai.openclaw.app.protocol.OpenClawNotificationsCommand
import ai.openclaw.app.protocol.OpenClawPhotosCommand
import ai.openclaw.app.protocol.OpenClawScreenCommand
import ai.openclaw.app.protocol.OpenClawSmsCommand
import ai.openclaw.app.protocol.OpenClawSystemCommand
@@ -59,11 +58,9 @@ object InvokeCommandRegistry {
val capabilityManifest: List<NodeCapabilitySpec> =
listOf(
NodeCapabilitySpec(name = OpenClawCapability.Canvas.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.Screen.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.Device.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.Notifications.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.System.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.AppUpdate.rawValue),
NodeCapabilitySpec(
name = OpenClawCapability.Camera.rawValue,
availability = NodeCapabilityAvailability.CameraEnabled,
@@ -123,10 +120,6 @@ object InvokeCommandRegistry {
name = OpenClawCanvasA2UICommand.Reset.rawValue,
requiresForeground = true,
),
InvokeCommandSpec(
name = OpenClawScreenCommand.Record.rawValue,
requiresForeground = true,
),
InvokeCommandSpec(
name = OpenClawSystemCommand.Notify.rawValue,
),
@@ -202,7 +195,6 @@ object InvokeCommandRegistry {
name = "debug.ed25519",
availability = InvokeCommandAvailability.DebugBuild,
),
InvokeCommandSpec(name = "app.update"),
)
private val byNameInternal: Map<String, InvokeCommandSpec> = all.associateBy { it.name }

View File

@@ -10,7 +10,6 @@ import ai.openclaw.app.protocol.OpenClawDeviceCommand
import ai.openclaw.app.protocol.OpenClawLocationCommand
import ai.openclaw.app.protocol.OpenClawMotionCommand
import ai.openclaw.app.protocol.OpenClawNotificationsCommand
import ai.openclaw.app.protocol.OpenClawScreenCommand
import ai.openclaw.app.protocol.OpenClawSmsCommand
import ai.openclaw.app.protocol.OpenClawSystemCommand
@@ -25,11 +24,9 @@ class InvokeDispatcher(
private val contactsHandler: ContactsHandler,
private val calendarHandler: CalendarHandler,
private val motionHandler: MotionHandler,
private val screenHandler: ScreenHandler,
private val smsHandler: SmsHandler,
private val a2uiHandler: A2UIHandler,
private val debugHandler: DebugHandler,
private val appUpdateHandler: AppUpdateHandler,
private val isForeground: () -> Boolean,
private val cameraEnabled: () -> Boolean,
private val locationEnabled: () -> Boolean,
@@ -161,19 +158,12 @@ class InvokeDispatcher(
OpenClawMotionCommand.Activity.rawValue -> motionHandler.handleMotionActivity(paramsJson)
OpenClawMotionCommand.Pedometer.rawValue -> motionHandler.handleMotionPedometer(paramsJson)
// Screen command
OpenClawScreenCommand.Record.rawValue -> screenHandler.handleScreenRecord(paramsJson)
// SMS command
OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson)
// Debug commands
"debug.ed25519" -> debugHandler.handleEd25519()
"debug.logs" -> debugHandler.handleLogs()
// App update
"app.update" -> appUpdateHandler.handleUpdate(paramsJson)
else -> GatewaySession.InvokeResult.error(code = "INVALID_REQUEST", message = "INVALID_REQUEST: unknown command")
}
}

View File

@@ -5,7 +5,6 @@ import android.content.Context
import android.content.pm.PackageManager
import android.location.LocationManager
import androidx.core.content.ContextCompat
import ai.openclaw.app.LocationMode
import ai.openclaw.app.gateway.GatewaySession
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.serialization.json.Json
@@ -17,7 +16,6 @@ class LocationHandler(
private val location: LocationCaptureManager,
private val json: Json,
private val isForeground: () -> Boolean,
private val locationMode: () -> LocationMode,
private val locationPreciseEnabled: () -> Boolean,
) {
fun hasFineLocationPermission(): Boolean {
@@ -34,19 +32,11 @@ class LocationHandler(
)
}
fun hasBackgroundLocationPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
PackageManager.PERMISSION_GRANTED
)
}
suspend fun handleLocationGet(paramsJson: String?): GatewaySession.InvokeResult {
val mode = locationMode()
if (!isForeground() && mode != LocationMode.Always) {
if (!isForeground()) {
return GatewaySession.InvokeResult.error(
code = "LOCATION_BACKGROUND_UNAVAILABLE",
message = "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always",
message = "LOCATION_BACKGROUND_UNAVAILABLE: location requires OpenClaw to stay open",
)
}
if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) {
@@ -55,12 +45,6 @@ class LocationHandler(
message = "LOCATION_PERMISSION_REQUIRED: grant Location permission",
)
}
if (!isForeground() && mode == LocationMode.Always && !hasBackgroundLocationPermission()) {
return GatewaySession.InvokeResult.error(
code = "LOCATION_PERMISSION_REQUIRED",
message = "LOCATION_PERMISSION_REQUIRED: enable Always in system Settings",
)
}
val (maxAgeMs, timeoutMs, desiredAccuracy) = parseLocationParams(paramsJson)
val preciseEnabled = locationPreciseEnabled()
val accuracy =

View File

@@ -1,25 +0,0 @@
package ai.openclaw.app.node
import ai.openclaw.app.gateway.GatewaySession
class ScreenHandler(
private val screenRecorder: ScreenRecordManager,
private val setScreenRecordActive: (Boolean) -> Unit,
private val invokeErrorFromThrowable: (Throwable) -> Pair<String, String>,
) {
suspend fun handleScreenRecord(paramsJson: String?): GatewaySession.InvokeResult {
setScreenRecordActive(true)
try {
val res =
try {
screenRecorder.record(paramsJson)
} catch (err: Throwable) {
val (code, message) = invokeErrorFromThrowable(err)
return GatewaySession.InvokeResult.error(code = code, message = message)
}
return GatewaySession.InvokeResult.ok(res.payloadJson)
} finally {
setScreenRecordActive(false)
}
}
}

View File

@@ -1,165 +0,0 @@
package ai.openclaw.app.node
import android.content.Context
import android.hardware.display.DisplayManager
import android.media.MediaRecorder
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.util.Base64
import ai.openclaw.app.ScreenCaptureRequester
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import java.io.File
import kotlin.math.roundToInt
class ScreenRecordManager(private val context: Context) {
data class Payload(val payloadJson: String)
@Volatile private var screenCaptureRequester: ScreenCaptureRequester? = null
@Volatile private var permissionRequester: ai.openclaw.app.PermissionRequester? = null
fun attachScreenCaptureRequester(requester: ScreenCaptureRequester) {
screenCaptureRequester = requester
}
fun attachPermissionRequester(requester: ai.openclaw.app.PermissionRequester) {
permissionRequester = requester
}
suspend fun record(paramsJson: String?): Payload =
withContext(Dispatchers.Default) {
val requester =
screenCaptureRequester
?: throw IllegalStateException(
"SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission",
)
val params = parseJsonParamsObject(paramsJson)
val durationMs = (parseDurationMs(params) ?: 10_000).coerceIn(250, 60_000)
val fps = (parseFps(params) ?: 10.0).coerceIn(1.0, 60.0)
val fpsInt = fps.roundToInt().coerceIn(1, 60)
val screenIndex = parseScreenIndex(params)
val includeAudio = parseIncludeAudio(params) ?: true
val format = parseString(params, key = "format")
if (format != null && format.lowercase() != "mp4") {
throw IllegalArgumentException("INVALID_REQUEST: screen format must be mp4")
}
if (screenIndex != null && screenIndex != 0) {
throw IllegalArgumentException("INVALID_REQUEST: screenIndex must be 0 on Android")
}
val capture = requester.requestCapture()
?: throw IllegalStateException(
"SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission",
)
val mgr =
context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
val projection = mgr.getMediaProjection(capture.resultCode, capture.data)
?: throw IllegalStateException("UNAVAILABLE: screen capture unavailable")
val metrics = context.resources.displayMetrics
val width = metrics.widthPixels
val height = metrics.heightPixels
val densityDpi = metrics.densityDpi
val file = File.createTempFile("openclaw-screen-", ".mp4")
if (includeAudio) ensureMicPermission()
val recorder = createMediaRecorder()
var virtualDisplay: android.hardware.display.VirtualDisplay? = null
try {
if (includeAudio) {
recorder.setAudioSource(MediaRecorder.AudioSource.MIC)
}
recorder.setVideoSource(MediaRecorder.VideoSource.SURFACE)
recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
recorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264)
if (includeAudio) {
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
recorder.setAudioChannels(1)
recorder.setAudioSamplingRate(44_100)
recorder.setAudioEncodingBitRate(96_000)
}
recorder.setVideoSize(width, height)
recorder.setVideoFrameRate(fpsInt)
recorder.setVideoEncodingBitRate(estimateBitrate(width, height, fpsInt))
recorder.setOutputFile(file.absolutePath)
recorder.prepare()
val surface = recorder.surface
virtualDisplay =
projection.createVirtualDisplay(
"openclaw-screen",
width,
height,
densityDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
surface,
null,
null,
)
recorder.start()
delay(durationMs.toLong())
} finally {
try {
recorder.stop()
} catch (_: Throwable) {
// ignore
}
recorder.reset()
recorder.release()
virtualDisplay?.release()
projection.stop()
}
val bytes = withContext(Dispatchers.IO) { file.readBytes() }
file.delete()
val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
Payload(
"""{"format":"mp4","base64":"$base64","durationMs":$durationMs,"fps":$fpsInt,"screenIndex":0,"hasAudio":$includeAudio}""",
)
}
private fun createMediaRecorder(): MediaRecorder = MediaRecorder(context)
private suspend fun ensureMicPermission() {
val granted =
androidx.core.content.ContextCompat.checkSelfPermission(
context,
android.Manifest.permission.RECORD_AUDIO,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (granted) return
val requester =
permissionRequester
?: throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
val results = requester.requestIfMissing(listOf(android.Manifest.permission.RECORD_AUDIO))
if (results[android.Manifest.permission.RECORD_AUDIO] != true) {
throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
}
}
private fun parseDurationMs(params: JsonObject?): Int? =
parseJsonInt(params, "durationMs")
private fun parseFps(params: JsonObject?): Double? =
parseJsonDouble(params, "fps")
private fun parseScreenIndex(params: JsonObject?): Int? =
parseJsonInt(params, "screenIndex")
private fun parseIncludeAudio(params: JsonObject?): Boolean? = parseJsonBooleanFlag(params, "includeAudio")
private fun parseString(params: JsonObject?, key: String): String? =
parseJsonString(params, key)
private fun estimateBitrate(width: Int, height: Int, fps: Int): Int {
val pixels = width.toLong() * height.toLong()
val raw = (pixels * fps.toLong() * 2L).toInt()
return raw.coerceIn(1_000_000, 12_000_000)
}
}

View File

@@ -3,14 +3,12 @@ package ai.openclaw.app.protocol
enum class OpenClawCapability(val rawValue: String) {
Canvas("canvas"),
Camera("camera"),
Screen("screen"),
Sms("sms"),
VoiceWake("voiceWake"),
Location("location"),
Device("device"),
Notifications("notifications"),
System("system"),
AppUpdate("appUpdate"),
Photos("photos"),
Contacts("contacts"),
Calendar("calendar"),
@@ -52,15 +50,6 @@ enum class OpenClawCameraCommand(val rawValue: String) {
}
}
enum class OpenClawScreenCommand(val rawValue: String) {
Record("screen.record"),
;
companion object {
const val NamespacePrefix: String = "screen."
}
}
enum class OpenClawSmsCommand(val rawValue: String) {
Send("sms.send"),
;

View File

@@ -80,7 +80,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -118,7 +117,6 @@ private enum class PermissionToggle {
private enum class SpecialAccessToggle {
NotificationListener,
AppUpdates,
}
private val onboardingBackgroundGradient =
@@ -274,10 +272,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
rememberSaveable {
mutableStateOf(isNotificationListenerEnabled(context))
}
var enableAppUpdates by
rememberSaveable {
mutableStateOf(canInstallUnknownApps(context))
}
var enableMicrophone by rememberSaveable { mutableStateOf(false) }
var enableCamera by rememberSaveable { mutableStateOf(false) }
var enablePhotos by rememberSaveable { mutableStateOf(false) }
@@ -342,7 +336,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
fun setSpecialAccessToggleEnabled(toggle: SpecialAccessToggle, enabled: Boolean) {
when (toggle) {
SpecialAccessToggle.NotificationListener -> enableNotificationListener = enabled
SpecialAccessToggle.AppUpdates -> enableAppUpdates = enabled
}
}
@@ -352,7 +345,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
enableLocation,
enableNotifications,
enableNotificationListener,
enableAppUpdates,
enableMicrophone,
enableCamera,
enablePhotos,
@@ -368,7 +360,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
if (enableLocation) enabled += "Location"
if (enableNotifications) enabled += "Notifications"
if (enableNotificationListener) enabled += "Notification listener"
if (enableAppUpdates) enabled += "App updates"
if (enableMicrophone) enabled += "Microphone"
if (enableCamera) enabled += "Camera"
if (enablePhotos) enabled += "Photos"
@@ -385,10 +376,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
openNotificationListenerSettings(context)
openedSpecialSetup = true
}
if (enableAppUpdates && !canInstallUnknownApps(context)) {
openUnknownAppSourcesSettings(context)
openedSpecialSetup = true
}
if (openedSpecialSetup) {
return@proceed
}
@@ -431,7 +418,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
val grantedNow =
when (toggle) {
SpecialAccessToggle.NotificationListener -> isNotificationListenerEnabled(context)
SpecialAccessToggle.AppUpdates -> canInstallUnknownApps(context)
}
if (grantedNow) {
setSpecialAccessToggleEnabled(toggle, true)
@@ -441,7 +427,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
pendingSpecialAccessToggle = toggle
when (toggle) {
SpecialAccessToggle.NotificationListener -> openNotificationListenerSettings(context)
SpecialAccessToggle.AppUpdates -> openUnknownAppSourcesSettings(context)
}
}
@@ -459,13 +444,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
)
pendingSpecialAccessToggle = null
}
SpecialAccessToggle.AppUpdates -> {
setSpecialAccessToggleEnabled(
SpecialAccessToggle.AppUpdates,
canInstallUnknownApps(context),
)
pendingSpecialAccessToggle = null
}
null -> Unit
}
}
@@ -606,7 +584,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
enableLocation = enableLocation,
enableNotifications = enableNotifications,
enableNotificationListener = enableNotificationListener,
enableAppUpdates = enableAppUpdates,
enableMicrophone = enableMicrophone,
enableCamera = enableCamera,
enablePhotos = enablePhotos,
@@ -649,9 +626,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
onNotificationListenerChange = { checked ->
requestSpecialAccessToggle(SpecialAccessToggle.NotificationListener, checked)
},
onAppUpdatesChange = { checked ->
requestSpecialAccessToggle(SpecialAccessToggle.AppUpdates, checked)
},
onMicrophoneChange = { checked ->
requestPermissionToggle(
PermissionToggle.Microphone,
@@ -1337,7 +1311,6 @@ private fun PermissionsStep(
enableLocation: Boolean,
enableNotifications: Boolean,
enableNotificationListener: Boolean,
enableAppUpdates: Boolean,
enableMicrophone: Boolean,
enableCamera: Boolean,
enablePhotos: Boolean,
@@ -1353,7 +1326,6 @@ private fun PermissionsStep(
onLocationChange: (Boolean) -> Unit,
onNotificationsChange: (Boolean) -> Unit,
onNotificationListenerChange: (Boolean) -> Unit,
onAppUpdatesChange: (Boolean) -> Unit,
onMicrophoneChange: (Boolean) -> Unit,
onCameraChange: (Boolean) -> Unit,
onPhotosChange: (Boolean) -> Unit,
@@ -1387,7 +1359,6 @@ private fun PermissionsStep(
isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
}
val notificationListenerGranted = isNotificationListenerEnabled(context)
val appUpdatesGranted = canInstallUnknownApps(context)
StepShell(title = "Permissions") {
Text(
@@ -1405,7 +1376,7 @@ private fun PermissionsStep(
InlineDivider()
PermissionToggleRow(
title = "Location",
subtitle = "location.get (while app is open unless set to Always later)",
subtitle = "location.get (while app is open)",
checked = enableLocation,
granted = locationGranted,
onCheckedChange = onLocationChange,
@@ -1429,17 +1400,9 @@ private fun PermissionsStep(
onCheckedChange = onNotificationListenerChange,
)
InlineDivider()
PermissionToggleRow(
title = "App updates",
subtitle = "app.update install confirmation (opens Android Settings)",
checked = enableAppUpdates,
granted = appUpdatesGranted,
onCheckedChange = onAppUpdatesChange,
)
InlineDivider()
PermissionToggleRow(
title = "Microphone",
subtitle = "Voice tab transcription",
subtitle = "Foreground Voice tab transcription",
checked = enableMicrophone,
granted = isPermissionGranted(context, Manifest.permission.RECORD_AUDIO),
onCheckedChange = onMicrophoneChange,
@@ -1635,10 +1598,6 @@ private fun isNotificationListenerEnabled(context: Context): Boolean {
return DeviceNotificationListenerService.isAccessEnabled(context)
}
private fun canInstallUnknownApps(context: Context): Boolean {
return context.packageManager.canRequestPackageInstalls()
}
private fun openNotificationListenerSettings(context: Context) {
val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching {
@@ -1648,19 +1607,6 @@ private fun openNotificationListenerSettings(context: Context) {
}
}
private fun openUnknownAppSourcesSettings(context: Context) {
val intent =
Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
"package:${context.packageName}".toUri(),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching {
context.startActivity(intent)
}.getOrElse {
openAppSettings(context)
}
}
private fun openAppSettings(context: Context) {
val intent =
Intent(

View File

@@ -62,7 +62,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -115,7 +114,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
viewModel.setCameraEnabled(cameraOk)
}
var pendingLocationMode by remember { mutableStateOf<LocationMode?>(null) }
var pendingLocationRequest by remember { mutableStateOf(false) }
var pendingPreciseToggle by remember { mutableStateOf(false) }
val locationPermissionLauncher =
@@ -123,8 +122,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
val fineOk = perms[Manifest.permission.ACCESS_FINE_LOCATION] == true
val coarseOk = perms[Manifest.permission.ACCESS_COARSE_LOCATION] == true
val granted = fineOk || coarseOk
val requestedMode = pendingLocationMode
pendingLocationMode = null
if (pendingPreciseToggle) {
pendingPreciseToggle = false
@@ -132,21 +129,9 @@ fun SettingsSheet(viewModel: MainViewModel) {
return@rememberLauncherForActivityResult
}
if (!granted) {
viewModel.setLocationMode(LocationMode.Off)
return@rememberLauncherForActivityResult
}
if (requestedMode != null) {
viewModel.setLocationMode(requestedMode)
if (requestedMode == LocationMode.Always) {
val backgroundOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (!backgroundOk) {
openAppSettings(context)
}
}
if (pendingLocationRequest) {
pendingLocationRequest = false
viewModel.setLocationMode(if (granted) LocationMode.WhileUsing else LocationMode.Off)
}
}
@@ -246,11 +231,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
motionPermissionGranted = granted
}
var appUpdateInstallEnabled by
remember {
mutableStateOf(canInstallUnknownApps(context))
}
var smsPermissionGranted by
remember {
mutableStateOf(
@@ -290,7 +270,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
!motionPermissionRequired ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
PackageManager.PERMISSION_GRANTED
appUpdateInstallEnabled = canInstallUnknownApps(context)
smsPermissionGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
PackageManager.PERMISSION_GRANTED
@@ -316,7 +295,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
}
fun requestLocationPermissions(targetMode: LocationMode) {
fun requestLocationPermissions() {
val fineOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
@@ -324,17 +303,9 @@ fun SettingsSheet(viewModel: MainViewModel) {
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (fineOk || coarseOk) {
viewModel.setLocationMode(targetMode)
if (targetMode == LocationMode.Always) {
val backgroundOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (!backgroundOk) {
openAppSettings(context)
}
}
viewModel.setLocationMode(LocationMode.WhileUsing)
} else {
pendingLocationMode = targetMode
pendingLocationRequest = true
locationPermissionLauncher.launch(
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
)
@@ -431,9 +402,9 @@ fun SettingsSheet(viewModel: MainViewModel) {
supportingContent = {
Text(
if (micPermissionGranted) {
"Granted. Use the Voice tab mic button to capture transcript."
"Granted. Use the Voice tab mic button to capture transcript while the app is open."
} else {
"Required for Voice tab transcription."
"Required for foreground Voice tab transcription."
},
style = mobileCallout,
)
@@ -460,7 +431,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
item {
Text(
"Voice wake and talk modes were removed. Voice now uses one mic on/off flow in the Voice tab.",
"Voice wake and talk modes were removed. Voice now uses one mic on/off flow in the Voice tab while the app is open.",
style = mobileCallout,
color = mobileTextSecondary,
)
@@ -759,41 +730,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
item { HorizontalDivider(color = mobileBorder) }
// System
item {
Text(
"SYSTEM",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
color = mobileAccent,
)
}
item {
ListItem(
modifier = Modifier.settingsRowModifier(),
colors = listItemColors,
headlineContent = { Text("Install App Updates", style = mobileHeadline) },
supportingContent = {
Text(
"Enable install access for `app.update` package installs.",
style = mobileCallout,
)
},
trailingContent = {
Button(
onClick = { openUnknownAppSourcesSettings(context) },
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) {
Text(
if (appUpdateInstallEnabled) "Manage" else "Enable",
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
)
}
},
)
}
item { HorizontalDivider(color = mobileBorder) }
// Location
item {
Text(
@@ -825,20 +761,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
trailingContent = {
RadioButton(
selected = locationMode == LocationMode.WhileUsing,
onClick = { requestLocationPermissions(LocationMode.WhileUsing) },
)
},
)
HorizontalDivider(color = mobileBorder)
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("Always", style = mobileHeadline) },
supportingContent = { Text("Allow background location (requires system permission).", style = mobileCallout) },
trailingContent = {
RadioButton(
selected = locationMode == LocationMode.Always,
onClick = { requestLocationPermissions(LocationMode.Always) },
onClick = { requestLocationPermissions() },
)
},
)
@@ -858,14 +781,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
)
}
}
item {
Text(
"Always may require Android Settings to allow background location.",
style = mobileCallout,
color = mobileTextSecondary,
)
}
item { HorizontalDivider(color = mobileBorder) }
// Screen
@@ -970,19 +885,6 @@ private fun openNotificationListenerSettings(context: Context) {
}
}
private fun openUnknownAppSourcesSettings(context: Context) {
val intent =
Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
"package:${context.packageName}".toUri(),
)
runCatching {
context.startActivity(intent)
}.getOrElse {
openAppSettings(context)
}
}
private fun hasNotificationsPermission(context: Context): Boolean {
if (Build.VERSION.SDK_INT < 33) return true
return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
@@ -993,10 +895,6 @@ private fun isNotificationListenerEnabled(context: Context): Boolean {
return DeviceNotificationListenerService.isAccessEnabled(context)
}
private fun canInstallUnknownApps(context: Context): Boolean {
return context.packageManager.canRequestPackageInstalls()
}
private fun hasMotionCapabilities(context: Context): Boolean {
val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false
return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null ||

View File

@@ -0,0 +1,5 @@
package ai.openclaw.app.voice
internal object TalkDefaults {
const val defaultSilenceTimeoutMs = 700L
}

View File

@@ -0,0 +1,161 @@
package ai.openclaw.app.voice
import ai.openclaw.app.normalizeMainKey
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
internal data class TalkProviderConfigSelection(
val provider: String,
val config: JsonObject,
val normalizedPayload: Boolean,
)
internal data class TalkModeGatewayConfigState(
val activeProvider: String,
val normalizedPayload: Boolean,
val missingResolvedPayload: Boolean,
val mainSessionKey: String,
val defaultVoiceId: String?,
val voiceAliases: Map<String, String>,
val defaultModelId: String,
val defaultOutputFormat: String,
val apiKey: String?,
val interruptOnSpeech: Boolean?,
val silenceTimeoutMs: Long,
)
internal object TalkModeGatewayConfigParser {
private const val defaultTalkProvider = "elevenlabs"
fun parse(
config: JsonObject?,
defaultProvider: String,
defaultModelIdFallback: String,
defaultOutputFormatFallback: String,
envVoice: String?,
sagVoice: String?,
envKey: String?,
): TalkModeGatewayConfigState {
val talk = config?.get("talk").asObjectOrNull()
val selection = selectTalkProviderConfig(talk)
val activeProvider = selection?.provider ?: defaultProvider
val activeConfig = selection?.config
val sessionCfg = config?.get("session").asObjectOrNull()
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
val voice = activeConfig?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val aliases =
activeConfig?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) ->
val id = value.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
normalizeTalkAliasKey(key).takeIf { it.isNotEmpty() }?.let { it to id }
}?.toMap().orEmpty()
val model = activeConfig?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val outputFormat =
activeConfig?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val key = activeConfig?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
val silenceTimeoutMs = resolvedSilenceTimeoutMs(talk)
return TalkModeGatewayConfigState(
activeProvider = activeProvider,
normalizedPayload = selection?.normalizedPayload == true,
missingResolvedPayload = talk != null && selection == null,
mainSessionKey = mainKey,
defaultVoiceId =
if (activeProvider == defaultProvider) {
voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
} else {
voice
},
voiceAliases = aliases,
defaultModelId = model ?: defaultModelIdFallback,
defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback,
apiKey = key ?: envKey?.takeIf { it.isNotEmpty() },
interruptOnSpeech = interrupt,
silenceTimeoutMs = silenceTimeoutMs,
)
}
fun fallback(
defaultProvider: String,
defaultModelIdFallback: String,
defaultOutputFormatFallback: String,
envVoice: String?,
sagVoice: String?,
envKey: String?,
): TalkModeGatewayConfigState =
TalkModeGatewayConfigState(
activeProvider = defaultProvider,
normalizedPayload = false,
missingResolvedPayload = false,
mainSessionKey = "main",
defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() },
voiceAliases = emptyMap(),
defaultModelId = defaultModelIdFallback,
defaultOutputFormat = defaultOutputFormatFallback,
apiKey = envKey?.takeIf { it.isNotEmpty() },
interruptOnSpeech = null,
silenceTimeoutMs = TalkDefaults.defaultSilenceTimeoutMs,
)
fun selectTalkProviderConfig(talk: JsonObject?): TalkProviderConfigSelection? {
if (talk == null) return null
selectResolvedTalkProviderConfig(talk)?.let { return it }
val rawProvider = talk["provider"].asStringOrNull()
val rawProviders = talk["providers"].asObjectOrNull()
val hasNormalizedPayload = rawProvider != null || rawProviders != null
if (hasNormalizedPayload) {
return null
}
return TalkProviderConfigSelection(
provider = defaultTalkProvider,
config = talk,
normalizedPayload = false,
)
}
fun resolvedSilenceTimeoutMs(talk: JsonObject?): Long {
val fallback = TalkDefaults.defaultSilenceTimeoutMs
val primitive = talk?.get("silenceTimeoutMs") as? JsonPrimitive ?: return fallback
if (primitive.isString) return fallback
val timeout = primitive.content.toDoubleOrNull() ?: return fallback
if (timeout <= 0 || timeout % 1.0 != 0.0 || timeout > Long.MAX_VALUE.toDouble()) {
return fallback
}
return timeout.toLong()
}
private fun selectResolvedTalkProviderConfig(talk: JsonObject): TalkProviderConfigSelection? {
val resolved = talk["resolved"].asObjectOrNull() ?: return null
val providerId = normalizeTalkProviderId(resolved["provider"].asStringOrNull()) ?: return null
return TalkProviderConfigSelection(
provider = providerId,
config = resolved["config"].asObjectOrNull() ?: buildJsonObject {},
normalizedPayload = true,
)
}
private fun normalizeTalkProviderId(raw: String?): String? {
val trimmed = raw?.trim()?.lowercase().orEmpty()
return trimmed.takeIf { it.isNotEmpty() }
}
}
private fun normalizeTalkAliasKey(value: String): String =
value.trim().lowercase()
private fun JsonElement?.asStringOrNull(): String? =
this?.let { element ->
element as? JsonPrimitive
}?.contentOrNull
private fun JsonElement?.asBooleanOrNull(): Boolean? {
val primitive = this as? JsonPrimitive ?: return null
return primitive.booleanOrNull
}
private fun JsonElement?.asObjectOrNull(): JsonObject? =
this as? JsonObject

View File

@@ -59,52 +59,11 @@ class TalkModeManager(
private const val tag = "TalkMode"
private const val defaultModelIdFallback = "eleven_v3"
private const val defaultOutputFormatFallback = "pcm_24000"
private const val defaultTalkProvider = "elevenlabs"
private const val silenceWindowMs = 500L
private const val defaultTalkProvider = "elevenlabs"
private const val listenWatchdogMs = 12_000L
private const val chatFinalWaitWithSubscribeMs = 45_000L
private const val chatFinalWaitWithoutSubscribeMs = 6_000L
private const val maxCachedRunCompletions = 128
internal data class TalkProviderConfigSelection(
val provider: String,
val config: JsonObject,
val normalizedPayload: Boolean,
)
private fun normalizeTalkProviderId(raw: String?): String? {
val trimmed = raw?.trim()?.lowercase().orEmpty()
return trimmed.takeIf { it.isNotEmpty() }
}
internal fun selectTalkProviderConfig(talk: JsonObject?): TalkProviderConfigSelection? {
if (talk == null) return null
val rawProvider = talk["provider"].asStringOrNull()
val rawProviders = talk["providers"].asObjectOrNull()
val hasNormalizedPayload = rawProvider != null || rawProviders != null
if (hasNormalizedPayload) {
val providers =
rawProviders?.entries?.mapNotNull { (key, value) ->
val providerId = normalizeTalkProviderId(key) ?: return@mapNotNull null
val providerConfig = value.asObjectOrNull() ?: return@mapNotNull null
providerId to providerConfig
}?.toMap().orEmpty()
val providerId =
normalizeTalkProviderId(rawProvider)
?: providers.keys.sorted().firstOrNull()
?: defaultTalkProvider
return TalkProviderConfigSelection(
provider = providerId,
config = providers[providerId] ?: buildJsonObject {},
normalizedPayload = true,
)
}
return TalkProviderConfigSelection(
provider = defaultTalkProvider,
config = talk,
normalizedPayload = false,
)
}
}
private val mainHandler = Handler(Looper.getMainLooper())
@@ -134,7 +93,7 @@ private const val defaultTalkProvider = "elevenlabs"
private var listeningMode = false
private var silenceJob: Job? = null
private val silenceWindowMs = 700L
private var silenceWindowMs = TalkDefaults.defaultSilenceTimeoutMs
private var lastTranscript: String = ""
private var lastHeardAtMs: Long? = null
private var lastSpokenText: String? = null
@@ -854,7 +813,7 @@ private const val defaultTalkProvider = "elevenlabs"
_lastAssistantText.value = cleaned
val requestedVoice = directive?.voiceId?.trim()?.takeIf { it.isNotEmpty() }
val resolvedVoice = resolveVoiceAlias(requestedVoice)
val resolvedVoice = TalkModeVoiceResolver.resolveVoiceAlias(requestedVoice, voiceAliases)
if (requestedVoice != null && resolvedVoice == null) {
Log.w(tag, "unknown voice alias: $requestedVoice")
}
@@ -877,12 +836,35 @@ private const val defaultTalkProvider = "elevenlabs"
apiKey?.trim()?.takeIf { it.isNotEmpty() }
?: System.getenv("ELEVENLABS_API_KEY")?.trim()
val preferredVoice = resolvedVoice ?: currentVoiceId ?: defaultVoiceId
val voiceId =
val resolvedPlaybackVoice =
if (!apiKey.isNullOrEmpty()) {
resolveVoiceId(preferredVoice, apiKey)
try {
TalkModeVoiceResolver.resolveVoiceId(
preferred = preferredVoice,
fallbackVoiceId = fallbackVoiceId,
defaultVoiceId = defaultVoiceId,
currentVoiceId = currentVoiceId,
voiceOverrideActive = voiceOverrideActive,
listVoices = { TalkModeVoiceResolver.listVoices(apiKey, json) },
)
} catch (err: Throwable) {
Log.w(tag, "list voices failed: ${err.message ?: err::class.simpleName}")
null
}
} else {
null
}
resolvedPlaybackVoice?.let { resolved ->
fallbackVoiceId = resolved.fallbackVoiceId
defaultVoiceId = resolved.defaultVoiceId
currentVoiceId = resolved.currentVoiceId
resolved.selectedVoiceName?.let { name ->
resolved.voiceId?.let { voiceId ->
Log.d(tag, "default voice selected $name ($voiceId)")
}
}
}
val voiceId = resolvedPlaybackVoice?.voiceId
_statusText.value = "Speaking…"
_isSpeaking.value = true
@@ -1393,60 +1375,64 @@ private const val defaultTalkProvider = "elevenlabs"
try {
val res = session.request("talk.config", """{"includeSecrets":true}""")
val root = json.parseToJsonElement(res).asObjectOrNull()
val config = root?.get("config").asObjectOrNull()
val talk = config?.get("talk").asObjectOrNull()
val selection = selectTalkProviderConfig(talk)
val activeProvider = selection?.provider ?: defaultTalkProvider
val activeConfig = selection?.config
val sessionCfg = config?.get("session").asObjectOrNull()
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
val voice = activeConfig?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val aliases =
activeConfig?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) ->
val id = value.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
normalizeAliasKey(key).takeIf { it.isNotEmpty() }?.let { it to id }
}?.toMap().orEmpty()
val model = activeConfig?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val outputFormat =
activeConfig?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val key = activeConfig?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
val parsed =
TalkModeGatewayConfigParser.parse(
config = root?.get("config").asObjectOrNull(),
defaultProvider = defaultTalkProvider,
defaultModelIdFallback = defaultModelIdFallback,
defaultOutputFormatFallback = defaultOutputFormatFallback,
envVoice = envVoice,
sagVoice = sagVoice,
envKey = envKey,
)
if (parsed.missingResolvedPayload) {
Log.w(tag, "talk config ignored: normalized payload missing talk.resolved")
}
if (!isCanonicalMainSessionKey(mainSessionKey)) {
mainSessionKey = mainKey
mainSessionKey = parsed.mainSessionKey
}
defaultVoiceId =
if (activeProvider == defaultTalkProvider) {
voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
} else {
voice
}
voiceAliases = aliases
defaultVoiceId = parsed.defaultVoiceId
voiceAliases = parsed.voiceAliases
if (!voiceOverrideActive) currentVoiceId = defaultVoiceId
defaultModelId = model ?: defaultModelIdFallback
defaultModelId = parsed.defaultModelId
if (!modelOverrideActive) currentModelId = defaultModelId
defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback
apiKey = key ?: envKey?.takeIf { it.isNotEmpty() }
Log.d(tag, "reloadConfig apiKey=${if (apiKey != null) "set" else "null"} voiceId=$defaultVoiceId")
if (interrupt != null) interruptOnSpeech = interrupt
activeProviderIsElevenLabs = activeProvider == defaultTalkProvider
defaultOutputFormat = parsed.defaultOutputFormat
apiKey = parsed.apiKey
silenceWindowMs = parsed.silenceTimeoutMs
Log.d(
tag,
"reloadConfig apiKey=${if (apiKey != null) "set" else "null"} voiceId=$defaultVoiceId silenceTimeoutMs=${parsed.silenceTimeoutMs}",
)
if (parsed.interruptOnSpeech != null) interruptOnSpeech = parsed.interruptOnSpeech
activeProviderIsElevenLabs = parsed.activeProvider == defaultTalkProvider
if (!activeProviderIsElevenLabs) {
// Clear ElevenLabs credentials so playAssistant won't attempt ElevenLabs calls
apiKey = null
defaultVoiceId = null
if (!voiceOverrideActive) currentVoiceId = null
Log.w(tag, "talk provider $activeProvider unsupported; using system voice fallback")
} else if (selection?.normalizedPayload == true) {
Log.w(tag, "talk provider ${parsed.activeProvider} unsupported; using system voice fallback")
} else if (parsed.normalizedPayload) {
Log.d(tag, "talk config provider=elevenlabs")
}
configLoaded = true
} catch (_: Throwable) {
defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
defaultModelId = defaultModelIdFallback
val fallback =
TalkModeGatewayConfigParser.fallback(
defaultProvider = defaultTalkProvider,
defaultModelIdFallback = defaultModelIdFallback,
defaultOutputFormatFallback = defaultOutputFormatFallback,
envVoice = envVoice,
sagVoice = sagVoice,
envKey = envKey,
)
silenceWindowMs = fallback.silenceTimeoutMs
defaultVoiceId = fallback.defaultVoiceId
defaultModelId = fallback.defaultModelId
if (!modelOverrideActive) currentModelId = defaultModelId
apiKey = envKey?.takeIf { it.isNotEmpty() }
voiceAliases = emptyMap()
defaultOutputFormat = defaultOutputFormatFallback
apiKey = fallback.apiKey
voiceAliases = fallback.voiceAliases
defaultOutputFormat = fallback.defaultOutputFormat
// Keep config load retryable after transient fetch failures.
configLoaded = false
}
@@ -1740,82 +1726,6 @@ private const val defaultTalkProvider = "elevenlabs"
}
}
private fun resolveVoiceAlias(value: String?): String? {
val trimmed = value?.trim().orEmpty()
if (trimmed.isEmpty()) return null
val normalized = normalizeAliasKey(trimmed)
voiceAliases[normalized]?.let { return it }
if (voiceAliases.values.any { it.equals(trimmed, ignoreCase = true) }) return trimmed
return if (isLikelyVoiceId(trimmed)) trimmed else null
}
private suspend fun resolveVoiceId(preferred: String?, apiKey: String): String? {
val trimmed = preferred?.trim().orEmpty()
if (trimmed.isNotEmpty()) {
val resolved = resolveVoiceAlias(trimmed)
// If it resolves as an alias, use the alias target.
// Otherwise treat it as a direct voice ID (e.g. "21m00Tcm4TlvDq8ikWAM").
return resolved ?: trimmed
}
fallbackVoiceId?.let { return it }
return try {
val voices = listVoices(apiKey)
val first = voices.firstOrNull() ?: return null
fallbackVoiceId = first.voiceId
if (defaultVoiceId.isNullOrBlank()) {
defaultVoiceId = first.voiceId
}
if (!voiceOverrideActive) {
currentVoiceId = first.voiceId
}
val name = first.name ?: "unknown"
Log.d(tag, "default voice selected $name (${first.voiceId})")
first.voiceId
} catch (err: Throwable) {
Log.w(tag, "list voices failed: ${err.message ?: err::class.simpleName}")
null
}
}
private suspend fun listVoices(apiKey: String): List<ElevenLabsVoice> {
return withContext(Dispatchers.IO) {
val url = URL("https://api.elevenlabs.io/v1/voices")
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "GET"
conn.connectTimeout = 15_000
conn.readTimeout = 15_000
conn.setRequestProperty("xi-api-key", apiKey)
val code = conn.responseCode
val stream = if (code >= 400) conn.errorStream else conn.inputStream
val data = stream.readBytes()
if (code >= 400) {
val message = data.toString(Charsets.UTF_8)
throw IllegalStateException("ElevenLabs voices failed: $code $message")
}
val root = json.parseToJsonElement(data.toString(Charsets.UTF_8)).asObjectOrNull()
val voices = (root?.get("voices") as? JsonArray) ?: JsonArray(emptyList())
voices.mapNotNull { entry ->
val obj = entry.asObjectOrNull() ?: return@mapNotNull null
val voiceId = obj["voice_id"].asStringOrNull() ?: return@mapNotNull null
val name = obj["name"].asStringOrNull()
ElevenLabsVoice(voiceId, name)
}
}
}
private fun isLikelyVoiceId(value: String): Boolean {
if (value.length < 10) return false
return value.all { it.isLetterOrDigit() || it == '-' || it == '_' }
}
private fun normalizeAliasKey(value: String): String =
value.trim().lowercase()
private data class ElevenLabsVoice(val voiceId: String, val name: String?)
private val listener =
object : RecognitionListener {
override fun onReadyForSpeech(params: Bundle?) {

View File

@@ -0,0 +1,118 @@
package ai.openclaw.app.voice
import java.net.HttpURLConnection
import java.net.URL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
internal data class ElevenLabsVoice(val voiceId: String, val name: String?)
internal data class TalkModeResolvedVoice(
val voiceId: String?,
val fallbackVoiceId: String?,
val defaultVoiceId: String?,
val currentVoiceId: String?,
val selectedVoiceName: String? = null,
)
internal object TalkModeVoiceResolver {
fun resolveVoiceAlias(value: String?, voiceAliases: Map<String, String>): String? {
val trimmed = value?.trim().orEmpty()
if (trimmed.isEmpty()) return null
val normalized = normalizeAliasKey(trimmed)
voiceAliases[normalized]?.let { return it }
if (voiceAliases.values.any { it.equals(trimmed, ignoreCase = true) }) return trimmed
return if (isLikelyVoiceId(trimmed)) trimmed else null
}
suspend fun resolveVoiceId(
preferred: String?,
fallbackVoiceId: String?,
defaultVoiceId: String?,
currentVoiceId: String?,
voiceOverrideActive: Boolean,
listVoices: suspend () -> List<ElevenLabsVoice>,
): TalkModeResolvedVoice {
val trimmed = preferred?.trim().orEmpty()
if (trimmed.isNotEmpty()) {
return TalkModeResolvedVoice(
voiceId = trimmed,
fallbackVoiceId = fallbackVoiceId,
defaultVoiceId = defaultVoiceId,
currentVoiceId = currentVoiceId,
)
}
if (!fallbackVoiceId.isNullOrBlank()) {
return TalkModeResolvedVoice(
voiceId = fallbackVoiceId,
fallbackVoiceId = fallbackVoiceId,
defaultVoiceId = defaultVoiceId,
currentVoiceId = currentVoiceId,
)
}
val first = listVoices().firstOrNull()
if (first == null) {
return TalkModeResolvedVoice(
voiceId = null,
fallbackVoiceId = fallbackVoiceId,
defaultVoiceId = defaultVoiceId,
currentVoiceId = currentVoiceId,
)
}
return TalkModeResolvedVoice(
voiceId = first.voiceId,
fallbackVoiceId = first.voiceId,
defaultVoiceId = if (defaultVoiceId.isNullOrBlank()) first.voiceId else defaultVoiceId,
currentVoiceId = if (voiceOverrideActive) currentVoiceId else first.voiceId,
selectedVoiceName = first.name,
)
}
suspend fun listVoices(apiKey: String, json: Json): List<ElevenLabsVoice> {
return withContext(Dispatchers.IO) {
val url = URL("https://api.elevenlabs.io/v1/voices")
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "GET"
conn.connectTimeout = 15_000
conn.readTimeout = 15_000
conn.setRequestProperty("xi-api-key", apiKey)
val code = conn.responseCode
val stream = if (code >= 400) conn.errorStream else conn.inputStream
val data = stream.readBytes()
if (code >= 400) {
val message = data.toString(Charsets.UTF_8)
throw IllegalStateException("ElevenLabs voices failed: $code $message")
}
val root = json.parseToJsonElement(data.toString(Charsets.UTF_8)).asObjectOrNull()
val voices = (root?.get("voices") as? JsonArray) ?: JsonArray(emptyList())
voices.mapNotNull { entry ->
val obj = entry.asObjectOrNull() ?: return@mapNotNull null
val voiceId = obj["voice_id"].asStringOrNull() ?: return@mapNotNull null
val name = obj["name"].asStringOrNull()
ElevenLabsVoice(voiceId, name)
}
}
}
private fun isLikelyVoiceId(value: String): Boolean {
if (value.length < 10) return false
return value.all { it.isLetterOrDigit() || it == '-' || it == '_' }
}
private fun normalizeAliasKey(value: String): String =
value.trim().lowercase()
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement?.asStringOrNull(): String? =
(this as? JsonPrimitive)?.takeIf { it.isString }?.content

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -1,3 +1,3 @@
<resources>
<color name="ic_launcher_background">#0A0A0A</color>
<color name="ic_launcher_background">#DD1A08</color>
</resources>

View File

@@ -0,0 +1,23 @@
package ai.openclaw.app
import android.content.Context
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class SecurePrefsTest {
@Test
fun loadLocationMode_migratesLegacyAlwaysValue() {
val context = RuntimeEnvironment.getApplication()
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
plainPrefs.edit().clear().putString("location.enabledMode", "always").commit()
val prefs = SecurePrefs(context)
assertEquals(LocationMode.WhileUsing, prefs.locationMode.value)
assertEquals("whileUsing", plainPrefs.getString("location.enabledMode", null))
}
}

View File

@@ -1,65 +0,0 @@
package ai.openclaw.app.node
import java.io.File
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
class AppUpdateHandlerTest {
@Test
fun parseAppUpdateRequest_acceptsHttpsWithMatchingHost() {
val req =
parseAppUpdateRequest(
paramsJson =
"""{"url":"https://gw.example.com/releases/openclaw.apk","sha256":"${"a".repeat(64)}"}""",
connectedHost = "gw.example.com",
)
assertEquals("https://gw.example.com/releases/openclaw.apk", req.url)
assertEquals("a".repeat(64), req.expectedSha256)
}
@Test
fun parseAppUpdateRequest_rejectsNonHttps() {
assertThrows(IllegalArgumentException::class.java) {
parseAppUpdateRequest(
paramsJson = """{"url":"http://gw.example.com/releases/openclaw.apk","sha256":"${"a".repeat(64)}"}""",
connectedHost = "gw.example.com",
)
}
}
@Test
fun parseAppUpdateRequest_rejectsHostMismatch() {
assertThrows(IllegalArgumentException::class.java) {
parseAppUpdateRequest(
paramsJson = """{"url":"https://evil.example.com/releases/openclaw.apk","sha256":"${"a".repeat(64)}"}""",
connectedHost = "gw.example.com",
)
}
}
@Test
fun parseAppUpdateRequest_rejectsInvalidSha256() {
assertThrows(IllegalArgumentException::class.java) {
parseAppUpdateRequest(
paramsJson = """{"url":"https://gw.example.com/releases/openclaw.apk","sha256":"bad"}""",
connectedHost = "gw.example.com",
)
}
}
@Test
fun sha256Hex_computesExpectedDigest() {
val tmp = File.createTempFile("openclaw-update-hash", ".bin")
try {
tmp.writeText("hello", Charsets.UTF_8)
assertEquals(
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", // pragma: allowlist secret
sha256Hex(tmp),
)
} finally {
tmp.delete()
}
}
}

View File

@@ -87,7 +87,6 @@ class DeviceHandlerTest {
"camera",
"microphone",
"location",
"backgroundLocation",
"sms",
"notificationListener",
"notifications",
@@ -95,7 +94,6 @@ class DeviceHandlerTest {
"contacts",
"calendar",
"motion",
"screenCapture",
)
for (key in expected) {
val state = permissions.getValue(key).jsonObject

View File

@@ -19,11 +19,9 @@ class InvokeCommandRegistryTest {
private val coreCapabilities =
setOf(
OpenClawCapability.Canvas.rawValue,
OpenClawCapability.Screen.rawValue,
OpenClawCapability.Device.rawValue,
OpenClawCapability.Notifications.rawValue,
OpenClawCapability.System.rawValue,
OpenClawCapability.AppUpdate.rawValue,
OpenClawCapability.Photos.rawValue,
OpenClawCapability.Contacts.rawValue,
OpenClawCapability.Calendar.rawValue,
@@ -52,7 +50,6 @@ class InvokeCommandRegistryTest {
OpenClawContactsCommand.Add.rawValue,
OpenClawCalendarCommand.Events.rawValue,
OpenClawCalendarCommand.Add.rawValue,
"app.update",
)
private val optionalCommands =

View File

@@ -24,14 +24,12 @@ class OpenClawProtocolConstantsTest {
fun capabilitiesUseStableStrings() {
assertEquals("canvas", OpenClawCapability.Canvas.rawValue)
assertEquals("camera", OpenClawCapability.Camera.rawValue)
assertEquals("screen", OpenClawCapability.Screen.rawValue)
assertEquals("voiceWake", OpenClawCapability.VoiceWake.rawValue)
assertEquals("location", OpenClawCapability.Location.rawValue)
assertEquals("sms", OpenClawCapability.Sms.rawValue)
assertEquals("device", OpenClawCapability.Device.rawValue)
assertEquals("notifications", OpenClawCapability.Notifications.rawValue)
assertEquals("system", OpenClawCapability.System.rawValue)
assertEquals("appUpdate", OpenClawCapability.AppUpdate.rawValue)
assertEquals("photos", OpenClawCapability.Photos.rawValue)
assertEquals("contacts", OpenClawCapability.Contacts.rawValue)
assertEquals("calendar", OpenClawCapability.Calendar.rawValue)
@@ -45,11 +43,6 @@ class OpenClawProtocolConstantsTest {
assertEquals("camera.clip", OpenClawCameraCommand.Clip.rawValue)
}
@Test
fun screenCommandsUseStableStrings() {
assertEquals("screen.record", OpenClawScreenCommand.Record.rawValue)
}
@Test
fun notificationsCommandsUseStableStrings() {
assertEquals("notifications.list", OpenClawNotificationsCommand.List.rawValue)

View File

@@ -0,0 +1,100 @@
package ai.openclaw.app.voice
import java.io.File
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
@Serializable
private data class TalkConfigContractFixture(
@SerialName("selectionCases") val selectionCases: List<SelectionCase>,
@SerialName("timeoutCases") val timeoutCases: List<TimeoutCase>,
) {
@Serializable
data class SelectionCase(
val id: String,
val defaultProvider: String,
val payloadValid: Boolean,
val expectedSelection: ExpectedSelection? = null,
val talk: JsonObject,
)
@Serializable
data class ExpectedSelection(
val provider: String,
val normalizedPayload: Boolean,
val voiceId: String? = null,
val apiKey: String? = null,
)
@Serializable
data class TimeoutCase(
val id: String,
val fallback: Long,
val expectedTimeoutMs: Long,
val talk: JsonObject,
)
}
class TalkModeConfigContractTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun selectionFixtures() {
for (fixture in loadFixtures().selectionCases) {
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(fixture.talk)
val expected = fixture.expectedSelection
if (expected == null) {
assertNull(fixture.id, selection)
continue
}
assertNotNull(fixture.id, selection)
assertEquals(fixture.id, expected.provider, selection?.provider)
assertEquals(fixture.id, expected.normalizedPayload, selection?.normalizedPayload)
assertEquals(
fixture.id,
expected.voiceId,
(selection?.config?.get("voiceId") as? JsonPrimitive)?.content,
)
assertEquals(
fixture.id,
expected.apiKey,
(selection?.config?.get("apiKey") as? JsonPrimitive)?.content,
)
assertEquals(fixture.id, true, fixture.payloadValid)
}
}
@Test
fun timeoutFixtures() {
for (fixture in loadFixtures().timeoutCases) {
val timeout = TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(fixture.talk)
assertEquals(fixture.id, fixture.expectedTimeoutMs, timeout)
assertEquals(fixture.id, TalkDefaults.defaultSilenceTimeoutMs, fixture.fallback)
}
}
private fun loadFixtures(): TalkConfigContractFixture {
val fixturePath = findFixtureFile()
return json.decodeFromString(File(fixturePath).readText())
}
private fun findFixtureFile(): String {
val startDir = System.getProperty("user.dir") ?: error("user.dir unavailable")
var current = File(startDir).absoluteFile
while (true) {
val candidate = File(current, "test-fixtures/talk-config-contract.json")
if (candidate.exists()) {
return candidate.absolutePath
}
current = current.parentFile ?: break
}
error("talk-config-contract.json not found from $startDir")
}
}

View File

@@ -13,6 +13,36 @@ import org.junit.Test
class TalkModeConfigParsingTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun prefersCanonicalResolvedTalkProviderPayload() {
val talk =
json.parseToJsonElement(
"""
{
"resolved": {
"provider": "elevenlabs",
"config": {
"voiceId": "voice-resolved"
}
},
"provider": "elevenlabs",
"providers": {
"elevenlabs": {
"voiceId": "voice-normalized"
}
}
}
""".trimIndent(),
)
.jsonObject
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
assertNotNull(selection)
assertEquals("elevenlabs", selection?.provider)
assertTrue(selection?.normalizedPayload == true)
assertEquals("voice-resolved", selection?.config?.get("voiceId")?.jsonPrimitive?.content)
}
@Test
fun prefersNormalizedTalkProviderPayload() {
val talk =
@@ -31,11 +61,52 @@ class TalkModeConfigParsingTest {
)
.jsonObject
val selection = TalkModeManager.selectTalkProviderConfig(talk)
assertNotNull(selection)
assertEquals("elevenlabs", selection?.provider)
assertTrue(selection?.normalizedPayload == true)
assertEquals("voice-normalized", selection?.config?.get("voiceId")?.jsonPrimitive?.content)
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
assertEquals(null, selection)
}
@Test
fun rejectsNormalizedTalkProviderPayloadWhenProviderMissingFromProviders() {
val talk =
json.parseToJsonElement(
"""
{
"provider": "acme",
"providers": {
"elevenlabs": {
"voiceId": "voice-normalized"
}
}
}
""".trimIndent(),
)
.jsonObject
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
assertEquals(null, selection)
}
@Test
fun rejectsNormalizedTalkProviderPayloadWhenProviderIsAmbiguous() {
val talk =
json.parseToJsonElement(
"""
{
"providers": {
"acme": {
"voiceId": "voice-acme"
},
"elevenlabs": {
"voiceId": "voice-normalized"
}
}
}
""".trimIndent(),
)
.jsonObject
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
assertEquals(null, selection)
}
@Test
@@ -47,11 +118,46 @@ class TalkModeConfigParsingTest {
put("apiKey", legacyApiKey) // pragma: allowlist secret
}
val selection = TalkModeManager.selectTalkProviderConfig(talk)
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
assertNotNull(selection)
assertEquals("elevenlabs", selection?.provider)
assertTrue(selection?.normalizedPayload == false)
assertEquals("voice-legacy", selection?.config?.get("voiceId")?.jsonPrimitive?.content)
assertEquals("legacy-key", selection?.config?.get("apiKey")?.jsonPrimitive?.content)
}
@Test
fun readsConfiguredSilenceTimeoutMs() {
val talk = buildJsonObject { put("silenceTimeoutMs", 1500) }
assertEquals(1500L, TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk))
}
@Test
fun defaultsSilenceTimeoutMsWhenMissing() {
assertEquals(
TalkDefaults.defaultSilenceTimeoutMs,
TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(null),
)
}
@Test
fun defaultsSilenceTimeoutMsWhenInvalid() {
val talk = buildJsonObject { put("silenceTimeoutMs", 0) }
assertEquals(
TalkDefaults.defaultSilenceTimeoutMs,
TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk),
)
}
@Test
fun defaultsSilenceTimeoutMsWhenString() {
val talk = buildJsonObject { put("silenceTimeoutMs", "1500") }
assertEquals(
TalkDefaults.defaultSilenceTimeoutMs,
TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk),
)
}
}

View File

@@ -0,0 +1,92 @@
package ai.openclaw.app.voice
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class TalkModeVoiceResolverTest {
@Test
fun resolvesVoiceAliasCaseInsensitively() {
val resolved =
TalkModeVoiceResolver.resolveVoiceAlias(
" Clawd ",
mapOf("clawd" to "voice-123"),
)
assertEquals("voice-123", resolved)
}
@Test
fun acceptsDirectVoiceIds() {
val resolved = TalkModeVoiceResolver.resolveVoiceAlias("21m00Tcm4TlvDq8ikWAM", emptyMap())
assertEquals("21m00Tcm4TlvDq8ikWAM", resolved)
}
@Test
fun rejectsUnknownAliases() {
val resolved = TalkModeVoiceResolver.resolveVoiceAlias("nickname", emptyMap())
assertNull(resolved)
}
@Test
fun reusesCachedFallbackVoiceBeforeFetchingCatalog() =
runBlocking {
var fetchCount = 0
val resolved =
TalkModeVoiceResolver.resolveVoiceId(
preferred = null,
fallbackVoiceId = "cached-voice",
defaultVoiceId = null,
currentVoiceId = null,
voiceOverrideActive = false,
listVoices = {
fetchCount += 1
emptyList()
},
)
assertEquals("cached-voice", resolved.voiceId)
assertEquals(0, fetchCount)
}
@Test
fun seedsDefaultVoiceFromCatalogWhenNeeded() =
runBlocking {
val resolved =
TalkModeVoiceResolver.resolveVoiceId(
preferred = null,
fallbackVoiceId = null,
defaultVoiceId = null,
currentVoiceId = null,
voiceOverrideActive = false,
listVoices = { listOf(ElevenLabsVoice("voice-1", "First")) },
)
assertEquals("voice-1", resolved.voiceId)
assertEquals("voice-1", resolved.fallbackVoiceId)
assertEquals("voice-1", resolved.defaultVoiceId)
assertEquals("voice-1", resolved.currentVoiceId)
assertEquals("First", resolved.selectedVoiceName)
}
@Test
fun preservesCurrentVoiceWhenOverrideIsActive() =
runBlocking {
val resolved =
TalkModeVoiceResolver.resolveVoiceId(
preferred = null,
fallbackVoiceId = null,
defaultVoiceId = null,
currentVoiceId = null,
voiceOverrideActive = true,
listVoices = { listOf(ElevenLabsVoice("voice-1", "First")) },
)
assertEquals("voice-1", resolved.voiceId)
assertNull(resolved.currentVoiceId)
}
}

View File

@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.7</string>
<string>2026.3.9</string>
<key>CFBundleVersion</key>
<string>20260307</string>
<string>20260308</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

View File

@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.7</string>
<string>2026.3.9</string>
<key>CFBundleVersion</key>
<string>20260307</string>
<string>20260308</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>

View File

@@ -23,7 +23,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.7</string>
<string>2026.3.9</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@@ -36,7 +36,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>20260307</string>
<string>20260308</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSAppTransportSecurity</key>

View File

@@ -1,9 +1,24 @@
import Foundation
import Network
import OpenClawKit
import os
enum A2UIReadyState {
case ready(String)
case hostNotConfigured
case hostUnavailable
}
extension NodeAppModel {
func resolveCanvasHostURL() async -> String? {
guard let raw = await self.gatewaySession.currentCanvasHostUrl() else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
if let host = base.host, LoopbackHost.isLoopback(host) {
return nil
}
return base.appendingPathComponent("__openclaw__/canvas/").absoluteString
}
func _test_resolveA2UIHostURL() async -> String? {
await self.resolveA2UIHostURL()
}
@@ -19,22 +34,14 @@ extension NodeAppModel {
}
func showA2UIOnConnectIfNeeded() async {
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
await MainActor.run {
self.lastAutoA2uiURL = nil
self.screen.showDefaultCanvas()
}
return
}
let current = self.screen.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
if current.isEmpty || current == self.lastAutoA2uiURL {
// Avoid navigating the WKWebView to an unreachable host: it leaves a persistent
// "could not connect to the server" overlay even when the gateway is connected.
if let url = URL(string: a2uiUrl),
if let canvasUrl = await self.resolveCanvasHostURLWithCapabilityRefresh(),
let url = URL(string: canvasUrl),
await Self.probeTCP(url: url, timeoutSeconds: 2.5)
{
self.screen.navigate(to: a2uiUrl)
self.lastAutoA2uiURL = a2uiUrl
self.screen.navigate(to: canvasUrl)
self.lastAutoA2uiURL = canvasUrl
} else {
self.lastAutoA2uiURL = nil
self.screen.showDefaultCanvas()
@@ -42,11 +49,46 @@ extension NodeAppModel {
}
}
func ensureA2UIReadyWithCapabilityRefresh(timeoutMs: Int = 5000) async -> A2UIReadyState {
guard let initialUrl = await self.resolveA2UIHostURLWithCapabilityRefresh() else {
return .hostNotConfigured
}
self.screen.navigate(to: initialUrl)
if await self.screen.waitForA2UIReady(timeoutMs: timeoutMs) {
return .ready(initialUrl)
}
// First render can fail when scoped capability rotates between reconnects.
guard await self.gatewaySession.refreshNodeCanvasCapability() else { return .hostUnavailable }
guard let refreshedUrl = await self.resolveA2UIHostURL() else { return .hostUnavailable }
self.screen.navigate(to: refreshedUrl)
if await self.screen.waitForA2UIReady(timeoutMs: timeoutMs) {
return .ready(refreshedUrl)
}
return .hostUnavailable
}
func showLocalCanvasOnDisconnect() {
self.lastAutoA2uiURL = nil
self.screen.showDefaultCanvas()
}
private func resolveA2UIHostURLWithCapabilityRefresh() async -> String? {
if let url = await self.resolveA2UIHostURL() {
return url
}
guard await self.gatewaySession.refreshNodeCanvasCapability() else { return nil }
return await self.resolveA2UIHostURL()
}
private func resolveCanvasHostURLWithCapabilityRefresh() async -> String? {
if let url = await self.resolveCanvasHostURL() {
return url
}
guard await self.gatewaySession.refreshNodeCanvasCapability() else { return nil }
return await self.resolveCanvasHostURL()
}
private static func probeTCP(url: URL, timeoutSeconds: Double) async -> Bool {
guard let host = url.host, !host.isEmpty else { return false }
let portInt = url.port ?? ((url.scheme ?? "").lowercased() == "wss" ? 443 : 80)

View File

@@ -57,6 +57,7 @@ final class NodeAppModel {
private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink")
private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake")
private let pendingActionLogger = Logger(subsystem: "ai.openclaw.ios", category: "PendingAction")
private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake")
private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply")
enum CameraHUDKind {
@@ -129,8 +130,8 @@ final class NodeAppModel {
private var backgroundReconnectSuppressed = false
private var backgroundReconnectLeaseUntil: Date?
private var lastSignificantLocationWakeAt: Date?
private var queuedWatchReplies: [WatchQuickReplyEvent] = []
private var seenWatchReplyIds = Set<String>()
@ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator()
private var pendingForegroundActionDrainInFlight = false
private var gatewayConnected = false
private var operatorConnected = false
@@ -330,6 +331,9 @@ final class NodeAppModel {
}
await self.talkMode.resumeAfterBackground(wasSuspended: suspended, wasKeptActive: keptActive)
}
Task { [weak self] in
await self?.resumePendingForegroundNodeActionsIfNeeded(trigger: "scene_active")
}
}
if phase == .active, self.reconnectAfterBackgroundArmed {
self.reconnectAfterBackgroundArmed = false
@@ -878,16 +882,17 @@ final class NodeAppModel {
let command = req.command
switch command {
case OpenClawCanvasA2UICommand.reset.rawValue:
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
switch await self.ensureA2UIReadyWithCapabilityRefresh(timeoutMs: 5000) {
case .ready:
break
case .hostNotConfigured:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .unavailable,
message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host"))
}
self.screen.navigate(to: a2uiUrl)
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
case .hostUnavailable:
return BridgeInvokeResponse(
id: req.id,
ok: false,
@@ -895,7 +900,6 @@ final class NodeAppModel {
code: .unavailable,
message: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable"))
}
let json = try await self.screen.eval(javaScript: """
(() => {
const host = globalThis.openclawA2UI;
@@ -904,6 +908,7 @@ final class NodeAppModel {
})()
""")
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
case OpenClawCanvasA2UICommand.push.rawValue, OpenClawCanvasA2UICommand.pushJSONL.rawValue:
let messages: [OpenClawKit.AnyCodable]
if command == OpenClawCanvasA2UICommand.pushJSONL.rawValue {
@@ -920,16 +925,17 @@ final class NodeAppModel {
}
}
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
switch await self.ensureA2UIReadyWithCapabilityRefresh(timeoutMs: 5000) {
case .ready:
break
case .hostNotConfigured:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .unavailable,
message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host"))
}
self.screen.navigate(to: a2uiUrl)
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
case .hostUnavailable:
return BridgeInvokeResponse(
id: req.id,
ok: false,
@@ -2099,6 +2105,22 @@ private extension NodeAppModel {
}
extension NodeAppModel {
private struct PendingForegroundNodeAction: Decodable {
var id: String
var command: String
var paramsJSON: String?
var enqueuedAtMs: Int?
}
private struct PendingForegroundNodeActionsResponse: Decodable {
var nodeId: String?
var actions: [PendingForegroundNodeAction]
}
private struct PendingForegroundNodeActionsAckRequest: Encodable {
var ids: [String]
}
private func refreshShareRouteFromGateway() async {
struct Params: Codable {
var includeGlobal: Bool
@@ -2196,40 +2218,102 @@ extension NodeAppModel {
func onNodeGatewayConnected() async {
await self.registerAPNsTokenIfNeeded()
await self.flushQueuedWatchRepliesIfConnected()
await self.resumePendingForegroundNodeActionsIfNeeded(trigger: "node_connected")
}
private func resumePendingForegroundNodeActionsIfNeeded(trigger: String) async {
guard !self.isBackgrounded else { return }
guard await self.isGatewayConnected() else { return }
guard !self.pendingForegroundActionDrainInFlight else { return }
self.pendingForegroundActionDrainInFlight = true
defer { self.pendingForegroundActionDrainInFlight = false }
do {
let payload = try await self.nodeGateway.request(
method: "node.pending.pull",
paramsJSON: "{}",
timeoutSeconds: 6)
let decoded = try JSONDecoder().decode(
PendingForegroundNodeActionsResponse.self,
from: payload)
guard !decoded.actions.isEmpty else { return }
self.pendingActionLogger.info(
"Pending actions pulled trigger=\(trigger, privacy: .public) "
+ "count=\(decoded.actions.count, privacy: .public)")
await self.applyPendingForegroundNodeActions(decoded.actions, trigger: trigger)
} catch {
// Best-effort only.
}
}
private func applyPendingForegroundNodeActions(
_ actions: [PendingForegroundNodeAction],
trigger: String) async
{
for action in actions {
guard !self.isBackgrounded else {
self.pendingActionLogger.info(
"Pending action replay paused trigger=\(trigger, privacy: .public): app backgrounded")
return
}
let req = BridgeInvokeRequest(
id: action.id,
command: action.command,
paramsJSON: action.paramsJSON)
let result = await self.handleInvoke(req)
self.pendingActionLogger.info(
"Pending action replay trigger=\(trigger, privacy: .public) "
+ "id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) "
+ "ok=\(result.ok, privacy: .public)")
guard result.ok else { return }
let acked = await self.ackPendingForegroundNodeAction(
id: action.id,
trigger: trigger,
command: action.command)
guard acked else { return }
}
}
private func ackPendingForegroundNodeAction(
id: String,
trigger: String,
command: String) async -> Bool
{
do {
let payload = try JSONEncoder().encode(PendingForegroundNodeActionsAckRequest(ids: [id]))
let paramsJSON = String(decoding: payload, as: UTF8.self)
_ = try await self.nodeGateway.request(
method: "node.pending.ack",
paramsJSON: paramsJSON,
timeoutSeconds: 6)
return true
} catch {
self.pendingActionLogger.error(
"Pending action ack failed trigger=\(trigger, privacy: .public) "
+ "id=\(id, privacy: .public) command=\(command, privacy: .public) "
+ "error=\(String(describing: error), privacy: .public)")
return false
}
}
private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async {
let replyId = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines)
let actionId = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines)
if replyId.isEmpty || actionId.isEmpty {
switch self.watchReplyCoordinator.ingest(event, isGatewayConnected: await self.isGatewayConnected()) {
case .dropMissingFields:
self.watchReplyLogger.info("watch reply dropped: missing replyId/actionId")
return
}
if self.seenWatchReplyIds.contains(replyId) {
case .deduped(let replyId):
self.watchReplyLogger.debug(
"watch reply deduped replyId=\(replyId, privacy: .public)")
return
}
self.seenWatchReplyIds.insert(replyId)
if await !self.isGatewayConnected() {
self.queuedWatchReplies.append(event)
case .queue(let replyId, let actionId):
self.watchReplyLogger.info(
"watch reply queued replyId=\(replyId, privacy: .public) action=\(actionId, privacy: .public)")
return
case .forward:
await self.forwardWatchReplyToAgent(event)
}
await self.forwardWatchReplyToAgent(event)
}
private func flushQueuedWatchRepliesIfConnected() async {
guard await self.isGatewayConnected() else { return }
guard !self.queuedWatchReplies.isEmpty else { return }
let pending = self.queuedWatchReplies
self.queuedWatchReplies.removeAll()
for event in pending {
for event in self.watchReplyCoordinator.drainIfConnected(await self.isGatewayConnected()) {
await self.forwardWatchReplyToAgent(event)
}
}
@@ -2259,7 +2343,7 @@ extension NodeAppModel {
"watch reply forwarding failed replyId=\(event.replyId) "
+ "error=\(error.localizedDescription)"
self.watchReplyLogger.error("\(failedMessage, privacy: .public)")
self.queuedWatchReplies.insert(event, at: 0)
self.watchReplyCoordinator.requeueFront(event)
}
}
@@ -2852,13 +2936,26 @@ extension NodeAppModel {
}
func _test_queuedWatchReplyCount() -> Int {
self.queuedWatchReplies.count
self.watchReplyCoordinator.queuedCount
}
func _test_setGatewayConnected(_ connected: Bool) {
self.gatewayConnected = connected
}
func _test_applyPendingForegroundNodeActions(
_ actions: [(id: String, command: String, paramsJSON: String?)]) async
{
let mapped = actions.map { action in
PendingForegroundNodeAction(
id: action.id,
command: action.command,
paramsJSON: action.paramsJSON,
enqueuedAtMs: nil)
}
await self.applyPendingForegroundNodeActions(mapped, trigger: "test")
}
static func _test_currentDeepLinkKey() -> String {
self.expectedDeepLinkKey()
}

View File

@@ -0,0 +1,46 @@
import Foundation
@MainActor
final class WatchReplyCoordinator {
enum Decision {
case dropMissingFields
case deduped(replyId: String)
case queue(replyId: String, actionId: String)
case forward
}
private var queuedReplies: [WatchQuickReplyEvent] = []
private var seenReplyIds = Set<String>()
func ingest(_ event: WatchQuickReplyEvent, isGatewayConnected: Bool) -> Decision {
let replyId = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines)
let actionId = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines)
if replyId.isEmpty || actionId.isEmpty {
return .dropMissingFields
}
if self.seenReplyIds.contains(replyId) {
return .deduped(replyId: replyId)
}
self.seenReplyIds.insert(replyId)
if !isGatewayConnected {
self.queuedReplies.append(event)
return .queue(replyId: replyId, actionId: actionId)
}
return .forward
}
func drainIfConnected(_ isGatewayConnected: Bool) -> [WatchQuickReplyEvent] {
guard isGatewayConnected, !self.queuedReplies.isEmpty else { return [] }
let pending = self.queuedReplies
self.queuedReplies.removeAll()
return pending
}
func requeueFront(_ event: WatchQuickReplyEvent) {
self.queuedReplies.insert(event, at: 0)
}
var queuedCount: Int {
self.queuedReplies.count
}
}

View File

@@ -0,0 +1,3 @@
enum TalkDefaults {
static let silenceTimeoutMs = 900
}

View File

@@ -0,0 +1,69 @@
import Foundation
import OpenClawKit
struct TalkModeGatewayConfigState {
let activeProvider: String
let normalizedPayload: Bool
let missingResolvedPayload: Bool
let defaultVoiceId: String?
let voiceAliases: [String: String]
let defaultModelId: String
let defaultOutputFormat: String?
let rawConfigApiKey: String?
let interruptOnSpeech: Bool?
let silenceTimeoutMs: Int
}
enum TalkModeGatewayConfigParser {
static func parse(
config: [String: Any],
defaultProvider: String,
defaultModelIdFallback: String,
defaultSilenceTimeoutMs: Int
) -> TalkModeGatewayConfigState {
let talk = TalkConfigParsing.bridgeFoundationDictionary(config["talk"] as? [String: Any])
let selection = TalkConfigParsing.selectProviderConfig(
talk,
defaultProvider: defaultProvider,
allowLegacyFallback: false)
let activeProvider = selection?.provider ?? defaultProvider
let activeConfig = selection?.config
let defaultVoiceId = activeConfig?["voiceId"]?.stringValue?
.trimmingCharacters(in: .whitespacesAndNewlines)
let voiceAliases: [String: String]
if let aliases = activeConfig?["voiceAliases"]?.dictionaryValue {
var resolved: [String: String] = [:]
for (key, value) in aliases {
guard let id = value.stringValue else { continue }
let normalizedKey = key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let trimmedId = id.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKey.isEmpty, !trimmedId.isEmpty else { continue }
resolved[normalizedKey] = trimmedId
}
voiceAliases = resolved
} else {
voiceAliases = [:]
}
let model = activeConfig?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
let defaultModelId = (model?.isEmpty == false) ? model! : defaultModelIdFallback
let defaultOutputFormat = activeConfig?["outputFormat"]?.stringValue?
.trimmingCharacters(in: .whitespacesAndNewlines)
let rawConfigApiKey = activeConfig?["apiKey"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
let interruptOnSpeech = talk?["interruptOnSpeech"]?.boolValue
let silenceTimeoutMs = TalkConfigParsing.resolvedSilenceTimeoutMs(
talk,
fallback: defaultSilenceTimeoutMs)
return TalkModeGatewayConfigState(
activeProvider: activeProvider,
normalizedPayload: selection?.normalizedPayload == true,
missingResolvedPayload: talk != nil && selection == nil,
defaultVoiceId: defaultVoiceId,
voiceAliases: voiceAliases,
defaultModelId: defaultModelId,
defaultOutputFormat: defaultOutputFormat,
rawConfigApiKey: rawConfigApiKey,
interruptOnSpeech: interruptOnSpeech,
silenceTimeoutMs: silenceTimeoutMs)
}
}

View File

@@ -34,6 +34,7 @@ final class TalkModeManager: NSObject {
private typealias SpeechRequest = SFSpeechAudioBufferRecognitionRequest
private static let defaultModelIdFallback = "eleven_v3"
private static let defaultTalkProvider = "elevenlabs"
private static let defaultSilenceTimeoutMs = TalkDefaults.silenceTimeoutMs
private static let redactedConfigSentinel = "__OPENCLAW_REDACTED__"
var isEnabled: Bool = false
var isListening: Bool = false
@@ -97,7 +98,7 @@ final class TalkModeManager: NSObject {
private var gateway: GatewayNodeSession?
private var gatewayConnected = false
private let silenceWindow: TimeInterval = 0.9
private var silenceWindow: TimeInterval = TimeInterval(TalkModeManager.defaultSilenceTimeoutMs) / 1000
private var lastAudioActivity: Date?
private var noiseFloorSamples: [Double] = []
private var noiseFloor: Double?
@@ -1969,38 +1970,6 @@ extension TalkModeManager {
return trimmed
}
struct TalkProviderConfigSelection {
let provider: String
let config: [String: Any]
}
private static func normalizedTalkProviderID(_ raw: String?) -> String? {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return trimmed.isEmpty ? nil : trimmed
}
static func selectTalkProviderConfig(_ talk: [String: Any]?) -> TalkProviderConfigSelection? {
guard let talk else { return nil }
let rawProvider = talk["provider"] as? String
let rawProviders = talk["providers"] as? [String: Any]
guard rawProvider != nil || rawProviders != nil else { return nil }
let providers = rawProviders ?? [:]
let normalizedProviders = providers.reduce(into: [String: [String: Any]]()) { acc, entry in
guard
let providerID = Self.normalizedTalkProviderID(entry.key),
let config = entry.value as? [String: Any]
else { return }
acc[providerID] = config
}
let providerID =
Self.normalizedTalkProviderID(rawProvider) ??
normalizedProviders.keys.min() ??
Self.defaultTalkProvider
return TalkProviderConfigSelection(
provider: providerID,
config: normalizedProviders[providerID] ?? [:])
}
func reloadConfig() async {
guard let gateway else { return }
self.pcmFormatUnavailable = false
@@ -2012,40 +1981,27 @@ extension TalkModeManager {
)
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
guard let config = json["config"] as? [String: Any] else { return }
let talk = config["talk"] as? [String: Any]
let selection = Self.selectTalkProviderConfig(talk)
if talk != nil, selection == nil {
let parsed = TalkModeGatewayConfigParser.parse(
config: config,
defaultProvider: Self.defaultTalkProvider,
defaultModelIdFallback: Self.defaultModelIdFallback,
defaultSilenceTimeoutMs: Self.defaultSilenceTimeoutMs)
if parsed.missingResolvedPayload {
GatewayDiagnostics.log(
"talk config ignored: legacy payload unsupported on iOS beta; expected talk.provider/providers")
}
let activeProvider = selection?.provider ?? Self.defaultTalkProvider
let activeConfig = selection?.config
self.defaultVoiceId = (activeConfig?["voiceId"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let aliases = activeConfig?["voiceAliases"] as? [String: Any] {
var resolved: [String: String] = [:]
for (key, value) in aliases {
guard let id = value as? String else { continue }
let normalizedKey = key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let trimmedId = id.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKey.isEmpty, !trimmedId.isEmpty else { continue }
resolved[normalizedKey] = trimmedId
}
self.voiceAliases = resolved
} else {
self.voiceAliases = [:]
"talk config ignored: normalized payload missing talk.resolved")
}
let activeProvider = parsed.activeProvider
self.defaultVoiceId = parsed.defaultVoiceId
self.voiceAliases = parsed.voiceAliases
if !self.voiceOverrideActive {
self.currentVoiceId = self.defaultVoiceId
}
let model = (activeConfig?["modelId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
self.defaultModelId = (model?.isEmpty == false) ? model : Self.defaultModelIdFallback
self.defaultModelId = parsed.defaultModelId
if !self.modelOverrideActive {
self.currentModelId = self.defaultModelId
}
self.defaultOutputFormat = (activeConfig?["outputFormat"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let rawConfigApiKey = (activeConfig?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
self.defaultOutputFormat = parsed.defaultOutputFormat
let rawConfigApiKey = parsed.rawConfigApiKey
let configApiKey = Self.normalizedTalkApiKey(rawConfigApiKey)
let localApiKey = Self.normalizedTalkApiKey(
GatewaySettingsStore.loadTalkProviderApiKey(provider: activeProvider))
@@ -2064,11 +2020,13 @@ extension TalkModeManager {
self.gatewayTalkDefaultModelId = self.defaultModelId
self.gatewayTalkApiKeyConfigured = (self.apiKey?.isEmpty == false)
self.gatewayTalkConfigLoaded = true
if let interrupt = talk?["interruptOnSpeech"] as? Bool {
if let interrupt = parsed.interruptOnSpeech {
self.interruptOnSpeech = interrupt
}
if selection != nil {
GatewayDiagnostics.log("talk config provider=\(activeProvider)")
self.silenceWindow = TimeInterval(parsed.silenceTimeoutMs) / 1000
if parsed.normalizedPayload || parsed.defaultVoiceId != nil || parsed.rawConfigApiKey != nil {
GatewayDiagnostics.log(
"talk config provider=\(activeProvider) silenceTimeoutMs=\(parsed.silenceTimeoutMs)")
}
} catch {
self.defaultModelId = Self.defaultModelIdFallback
@@ -2079,6 +2037,7 @@ extension TalkModeManager {
self.gatewayTalkDefaultModelId = nil
self.gatewayTalkApiKeyConfigured = false
self.gatewayTalkConfigLoaded = false
self.silenceWindow = TimeInterval(Self.defaultSilenceTimeoutMs) / 1000
}
}

View File

@@ -13,6 +13,7 @@ Sources/OpenClawApp.swift
Sources/Location/LocationService.swift
Sources/Model/NodeAppModel.swift
Sources/Model/NodeAppModel+Canvas.swift
Sources/Model/WatchReplyCoordinator.swift
Sources/RootCanvas.swift
Sources/RootTabs.swift
Sources/Screen/ScreenController.swift

View File

@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.7</string>
<string>2026.3.9</string>
<key>CFBundleVersion</key>
<string>20260307</string>
<string>20260308</string>
</dict>
</plist>

View File

@@ -0,0 +1,75 @@
import Foundation
import OpenClawKit
import Testing
private let iOSSilenceTimeoutMs = 900
@Suite struct TalkConfigParsingTests {
@Test func rejectsNormalizedTalkProviderPayloadWithoutResolved() {
let talk: [String: Any] = [
"provider": "elevenlabs",
"providers": [
"elevenlabs": [
"voiceId": "voice-normalized",
],
],
"voiceId": "voice-legacy",
]
let selection = TalkConfigParsing.selectProviderConfig(
TalkConfigParsing.bridgeFoundationDictionary(talk),
defaultProvider: "elevenlabs",
allowLegacyFallback: false)
#expect(selection == nil)
}
@Test func ignoresLegacyTalkFieldsWhenNormalizedPayloadMissing() {
let talk: [String: Any] = [
"voiceId": "voice-legacy",
"apiKey": "legacy-key", // pragma: allowlist secret
]
let selection = TalkConfigParsing.selectProviderConfig(
TalkConfigParsing.bridgeFoundationDictionary(talk),
defaultProvider: "elevenlabs",
allowLegacyFallback: false)
#expect(selection == nil)
}
@Test func readsConfiguredSilenceTimeoutMs() {
let talk: [String: Any] = [
"silenceTimeoutMs": 1500,
]
#expect(
TalkConfigParsing.resolvedSilenceTimeoutMs(
TalkConfigParsing.bridgeFoundationDictionary(talk),
fallback: iOSSilenceTimeoutMs) == 1500)
}
@Test func defaultsSilenceTimeoutMsWhenMissing() {
#expect(TalkConfigParsing.resolvedSilenceTimeoutMs(nil, fallback: iOSSilenceTimeoutMs) == iOSSilenceTimeoutMs)
}
@Test func defaultsSilenceTimeoutMsWhenInvalid() {
let talk: [String: Any] = [
"silenceTimeoutMs": 0,
]
#expect(
TalkConfigParsing.resolvedSilenceTimeoutMs(
TalkConfigParsing.bridgeFoundationDictionary(talk),
fallback: iOSSilenceTimeoutMs) == iOSSilenceTimeoutMs)
}
@Test func defaultsSilenceTimeoutMsWhenBool() {
let talk: [String: Any] = [
"silenceTimeoutMs": true,
]
#expect(
TalkConfigParsing.resolvedSilenceTimeoutMs(
TalkConfigParsing.bridgeFoundationDictionary(talk),
fallback: iOSSilenceTimeoutMs) == iOSSilenceTimeoutMs)
}
}

View File

@@ -179,6 +179,41 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
#expect(payload?["result"] as? String == "2")
}
@Test @MainActor func pendingForegroundActionsReplayCanvasNavigate() async throws {
let appModel = NodeAppModel()
let navigateParams = OpenClawCanvasNavigateParams(url: "http://example.com/")
let navData = try JSONEncoder().encode(navigateParams)
let navJSON = String(decoding: navData, as: UTF8.self)
await appModel._test_applyPendingForegroundNodeActions([
(
id: "pending-nav-1",
command: OpenClawCanvasCommand.navigate.rawValue,
paramsJSON: navJSON
),
])
#expect(appModel.screen.urlString == "http://example.com/")
}
@Test @MainActor func pendingForegroundActionsDoNotApplyWhileBackgrounded() async throws {
let appModel = NodeAppModel()
appModel.setScenePhase(.background)
let navigateParams = OpenClawCanvasNavigateParams(url: "http://example.com/")
let navData = try JSONEncoder().encode(navigateParams)
let navJSON = String(decoding: navData, as: UTF8.self)
await appModel._test_applyPendingForegroundNodeActions([
(
id: "pending-nav-bg",
command: OpenClawCanvasCommand.navigate.rawValue,
paramsJSON: navJSON
),
])
#expect(appModel.screen.urlString.isEmpty)
}
@Test @MainActor func handleInvokeA2UICommandsFailWhenHostMissing() async throws {
let appModel = NodeAppModel()

View File

@@ -3,33 +3,7 @@ import Testing
@testable import OpenClaw
@MainActor
@Suite struct TalkModeConfigParsingTests {
@Test func prefersNormalizedTalkProviderPayload() {
let talk: [String: Any] = [
"provider": "elevenlabs",
"providers": [
"elevenlabs": [
"voiceId": "voice-normalized",
],
],
"voiceId": "voice-legacy",
]
let selection = TalkModeManager.selectTalkProviderConfig(talk)
#expect(selection?.provider == "elevenlabs")
#expect(selection?.config["voiceId"] as? String == "voice-normalized")
}
@Test func ignoresLegacyTalkFieldsWhenNormalizedPayloadMissing() {
let talk: [String: Any] = [
"voiceId": "voice-legacy",
"apiKey": "legacy-key", // pragma: allowlist secret
]
let selection = TalkModeManager.selectTalkProviderConfig(talk)
#expect(selection == nil)
}
@Suite struct TalkModeManagerTests {
@Test func detectsPCMFormatRejectionFromElevenLabsError() {
let error = NSError(
domain: "ElevenLabsTTS",

View File

@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.7</string>
<string>2026.3.9</string>
<key>CFBundleVersion</key>
<string>20260307</string>
<string>20260308</string>
<key>WKCompanionAppBundleIdentifier</key>
<string>$(OPENCLAW_APP_BUNDLE_ID)</string>
<key>WKWatchKitApp</key>

View File

@@ -15,9 +15,9 @@
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.7</string>
<string>2026.3.9</string>
<key>CFBundleVersion</key>
<string>20260307</string>
<string>20260308</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>

View File

@@ -25,6 +25,15 @@ schemes:
test:
targets:
- OpenClawTests
- OpenClawLogicTests
OpenClawLogicTests:
shared: true
build:
targets:
OpenClawLogicTests: all
test:
targets:
- OpenClawLogicTests
targets:
OpenClaw:
@@ -98,8 +107,8 @@ targets:
- CFBundleURLName: ai.openclaw.ios
CFBundleURLSchemes:
- openclaw
CFBundleShortVersionString: "2026.3.7"
CFBundleVersion: "20260307"
CFBundleShortVersionString: "2026.3.9"
CFBundleVersion: "20260308"
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@@ -117,8 +126,11 @@ targets:
NSLocationWhenInUseUsageDescription: OpenClaw uses your location when you allow location sharing.
NSLocationAlwaysAndWhenInUseUsageDescription: OpenClaw can share your location in the background when you enable Always.
NSMicrophoneUsageDescription: OpenClaw needs microphone access for voice wake.
NSMotionUsageDescription: OpenClaw may use motion data to support device-aware interactions and automations.
NSPhotoLibraryUsageDescription: OpenClaw needs photo library access when you choose existing photos to share with your assistant.
NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for voice wake.
NSSupportsLiveActivities: true
ITSAppUsesNonExemptEncryption: false
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
@@ -156,8 +168,8 @@ targets:
path: ShareExtension/Info.plist
properties:
CFBundleDisplayName: OpenClaw Share
CFBundleShortVersionString: "2026.3.7"
CFBundleVersion: "20260307"
CFBundleShortVersionString: "2026.3.9"
CFBundleVersion: "20260308"
NSExtension:
NSExtensionPointIdentifier: com.apple.share-services
NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController"
@@ -193,8 +205,8 @@ targets:
path: ActivityWidget/Info.plist
properties:
CFBundleDisplayName: OpenClaw Activity
CFBundleShortVersionString: "2026.3.7"
CFBundleVersion: "20260307"
CFBundleShortVersionString: "2026.3.9"
CFBundleVersion: "20260308"
NSSupportsLiveActivities: true
NSExtension:
NSExtensionPointIdentifier: com.apple.widgetkit-extension
@@ -219,8 +231,8 @@ targets:
path: WatchApp/Info.plist
properties:
CFBundleDisplayName: OpenClaw
CFBundleShortVersionString: "2026.3.7"
CFBundleVersion: "20260307"
CFBundleShortVersionString: "2026.3.9"
CFBundleVersion: "20260308"
WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)"
WKWatchKitApp: true
@@ -244,8 +256,8 @@ targets:
path: WatchExtension/Info.plist
properties:
CFBundleDisplayName: OpenClaw
CFBundleShortVersionString: "2026.3.7"
CFBundleVersion: "20260307"
CFBundleShortVersionString: "2026.3.9"
CFBundleVersion: "20260308"
NSExtension:
NSExtensionAttributes:
WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
@@ -259,6 +271,8 @@ targets:
Release: Signing.xcconfig
sources:
- path: Tests
excludes:
- Logic
dependencies:
- target: OpenClaw
- package: Swabble
@@ -279,5 +293,31 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: OpenClawTests
CFBundleShortVersionString: "2026.3.7"
CFBundleVersion: "20260307"
CFBundleShortVersionString: "2026.3.9"
CFBundleVersion: "20260308"
OpenClawLogicTests:
type: bundle.unit-test
platform: iOS
configFiles:
Debug: Signing.xcconfig
Release: Signing.xcconfig
sources:
- path: Tests/Logic
dependencies:
- package: OpenClawKit
settings:
base:
CODE_SIGN_IDENTITY: "Apple Development"
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.logic-tests
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
info:
path: Tests/Info.plist
properties:
CFBundleDisplayName: OpenClawLogicTests
CFBundleShortVersionString: "2026.3.9"
CFBundleVersion: "20260308"

View File

@@ -4,40 +4,3 @@ import OpenClawKit
// Prefer the OpenClawKit wrapper to keep gateway request payloads consistent.
typealias AnyCodable = OpenClawKit.AnyCodable
typealias InstanceIdentity = OpenClawKit.InstanceIdentity
extension AnyCodable {
var stringValue: String? {
self.value as? String
}
var boolValue: Bool? {
self.value as? Bool
}
var intValue: Int? {
self.value as? Int
}
var doubleValue: Double? {
self.value as? Double
}
var dictionaryValue: [String: AnyCodable]? {
self.value as? [String: AnyCodable]
}
var arrayValue: [AnyCodable]? {
self.value as? [AnyCodable]
}
var foundationValue: Any {
switch self.value {
case let dict as [String: AnyCodable]:
dict.mapValues { $0.foundationValue }
case let array as [AnyCodable]:
array.map(\.foundationValue)
default:
self.value
}
}
}

View File

@@ -9,6 +9,7 @@ import SwiftUI
final class AppState {
private let isPreview: Bool
private var isInitializing = true
private var isApplyingRemoteTokenConfig = false
private var configWatcher: ConfigFileWatcher?
private var suppressVoiceWakeGlobalSync = false
private var voiceWakeGlobalSyncTask: Task<Void, Never>?
@@ -213,6 +214,18 @@ final class AppState {
didSet { self.syncGatewayConfigIfNeeded() }
}
var remoteToken: String {
didSet {
guard !self.isApplyingRemoteTokenConfig else { return }
self.remoteTokenDirty = true
self.remoteTokenUnsupported = false
self.syncGatewayConfigIfNeeded()
}
}
private(set) var remoteTokenDirty = false
private(set) var remoteTokenUnsupported = false
var remoteIdentity: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
}
@@ -281,6 +294,7 @@ final class AppState {
let configRoot = OpenClawConfigFile.loadDict()
let configRemoteUrl = GatewayRemoteConfig.resolveUrlString(root: configRoot)
let configRemoteToken = GatewayRemoteConfig.resolveTokenValue(root: configRoot)
let configRemoteTransport = GatewayRemoteConfig.resolveTransport(root: configRoot)
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
self.remoteTransport = configRemoteTransport
@@ -297,6 +311,9 @@ final class AppState {
self.remoteTarget = storedRemoteTarget
}
self.remoteUrl = configRemoteUrl ?? ""
self.remoteToken = configRemoteToken.textFieldValue
self.remoteTokenDirty = false
self.remoteTokenUnsupported = configRemoteToken.isUnsupportedNonString
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
@@ -374,13 +391,29 @@ final class AppState {
return false
}
private func applyRemoteTokenState(_ tokenValue: GatewayRemoteConfig.TokenValue) {
let nextToken = tokenValue.textFieldValue
let unsupported = tokenValue.isUnsupportedNonString
guard self.remoteToken != nextToken || self.remoteTokenDirty || self.remoteTokenUnsupported != unsupported
else {
return
}
self.isApplyingRemoteTokenConfig = true
self.remoteToken = nextToken
self.isApplyingRemoteTokenConfig = false
self.remoteTokenDirty = false
self.remoteTokenUnsupported = unsupported
}
private static func updatedRemoteGatewayConfig(
current: [String: Any],
transport: RemoteTransport,
remoteUrl: String,
remoteHost: String?,
remoteTarget: String,
remoteIdentity: String) -> (remote: [String: Any], changed: Bool)
remoteIdentity: String,
remoteToken: String,
remoteTokenDirty: Bool) -> (remote: [String: Any], changed: Bool)
{
var remote = current
var changed = false
@@ -417,6 +450,10 @@ final class AppState {
changed = Self.updateGatewayString(&remote, key: "sshIdentity", value: remoteIdentity) || changed
}
if remoteTokenDirty {
changed = Self.updateGatewayString(&remote, key: "token", value: remoteToken) || changed
}
return (remote, changed)
}
@@ -439,6 +476,7 @@ final class AppState {
let gateway = root["gateway"] as? [String: Any]
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let remoteUrl = GatewayRemoteConfig.resolveUrlString(root: root)
let remoteToken = GatewayRemoteConfig.resolveTokenValue(root: root)
let hasRemoteUrl = !(remoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true)
@@ -470,6 +508,7 @@ final class AppState {
if remoteUrlText != self.remoteUrl {
self.remoteUrl = remoteUrlText
}
self.applyRemoteTokenState(remoteToken)
let targetMode = desiredMode ?? self.connectionMode
if targetMode == .remote,
@@ -496,14 +535,20 @@ final class AppState {
}
}
private func syncGatewayConfigIfNeeded() {
guard !self.isPreview, !self.isInitializing else { return }
private static func syncedGatewayRoot(
currentRoot: [String: Any],
connectionMode: ConnectionMode,
remoteTransport: RemoteTransport,
remoteTarget: String,
remoteIdentity: String,
remoteUrl: String,
remoteToken: String,
remoteTokenDirty: Bool) -> (root: [String: Any], changed: Bool)
{
var root = currentRoot
var gateway = root["gateway"] as? [String: Any] ?? [:]
var changed = false
let connectionMode = self.connectionMode
let remoteTarget = self.remoteTarget
let remoteIdentity = self.remoteIdentity
let remoteTransport = self.remoteTransport
let remoteUrl = self.remoteUrl
let desiredMode: String? = switch connectionMode {
case .local:
"local"
@@ -512,49 +557,70 @@ final class AppState {
case .unconfigured:
nil
}
let remoteHost = connectionMode == .remote
? CommandResolver.parseSSHTarget(remoteTarget)?.host
: nil
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let desiredMode {
if currentMode != desiredMode {
gateway["mode"] = desiredMode
changed = true
}
} else if currentMode != nil {
gateway.removeValue(forKey: "mode")
changed = true
}
if connectionMode == .remote {
let remoteHost = CommandResolver.parseSSHTarget(remoteTarget)?.host
let currentRemote = gateway["remote"] as? [String: Any] ?? [:]
let updated = Self.updatedRemoteGatewayConfig(
current: currentRemote,
transport: remoteTransport,
remoteUrl: remoteUrl,
remoteHost: remoteHost,
remoteTarget: remoteTarget,
remoteIdentity: remoteIdentity,
remoteToken: remoteToken,
remoteTokenDirty: remoteTokenDirty)
if updated.changed {
gateway["remote"] = updated.remote
changed = true
}
}
guard changed else { return (currentRoot, false) }
if gateway.isEmpty {
root.removeValue(forKey: "gateway")
} else {
root["gateway"] = gateway
}
return (root, true)
}
private func syncGatewayConfigIfNeeded() {
guard !self.isPreview, !self.isInitializing else { return }
let connectionMode = self.connectionMode
let remoteTarget = self.remoteTarget
let remoteIdentity = self.remoteIdentity
let remoteTransport = self.remoteTransport
let remoteUrl = self.remoteUrl
let remoteToken = self.remoteToken
let remoteTokenDirty = self.remoteTokenDirty
Task { @MainActor in
// Keep app-only connection settings local to avoid overwriting remote gateway config.
var root = OpenClawConfigFile.loadDict()
var gateway = root["gateway"] as? [String: Any] ?? [:]
var changed = false
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let desiredMode {
if currentMode != desiredMode {
gateway["mode"] = desiredMode
changed = true
}
} else if currentMode != nil {
gateway.removeValue(forKey: "mode")
changed = true
}
if connectionMode == .remote {
let currentRemote = gateway["remote"] as? [String: Any] ?? [:]
let updated = Self.updatedRemoteGatewayConfig(
current: currentRemote,
transport: remoteTransport,
remoteUrl: remoteUrl,
remoteHost: remoteHost,
remoteTarget: remoteTarget,
remoteIdentity: remoteIdentity)
if updated.changed {
gateway["remote"] = updated.remote
changed = true
}
}
guard changed else { return }
if gateway.isEmpty {
root.removeValue(forKey: "gateway")
} else {
root["gateway"] = gateway
}
OpenClawConfigFile.saveDict(root)
let synced = Self.syncedGatewayRoot(
currentRoot: OpenClawConfigFile.loadDict(),
connectionMode: connectionMode,
remoteTransport: remoteTransport,
remoteTarget: remoteTarget,
remoteIdentity: remoteIdentity,
remoteUrl: remoteUrl,
remoteToken: remoteToken,
remoteTokenDirty: remoteTokenDirty)
guard synced.changed else { return }
OpenClawConfigFile.saveDict(synced.root)
}
}
@@ -697,6 +763,7 @@ extension AppState {
state.canvasEnabled = true
state.remoteTarget = "user@example.com"
state.remoteUrl = "wss://gateway.example.ts.net"
state.remoteToken = "example-token"
state.remoteIdentity = "~/.ssh/id_ed25519"
state.remoteProjectRoot = "~/Projects/openclaw"
state.remoteCliPath = ""
@@ -704,6 +771,53 @@ extension AppState {
}
}
#if DEBUG
@MainActor
extension AppState {
static func _testUpdatedRemoteGatewayConfig(
current: [String: Any],
transport: RemoteTransport,
remoteUrl: String,
remoteHost: String?,
remoteTarget: String,
remoteIdentity: String,
remoteToken: String,
remoteTokenDirty: Bool) -> [String: Any]
{
Self.updatedRemoteGatewayConfig(
current: current,
transport: transport,
remoteUrl: remoteUrl,
remoteHost: remoteHost,
remoteTarget: remoteTarget,
remoteIdentity: remoteIdentity,
remoteToken: remoteToken,
remoteTokenDirty: remoteTokenDirty).remote
}
static func _testSyncedGatewayRoot(
currentRoot: [String: Any],
connectionMode: ConnectionMode,
remoteTransport: RemoteTransport,
remoteTarget: String,
remoteIdentity: String,
remoteUrl: String,
remoteToken: String,
remoteTokenDirty: Bool) -> [String: Any]
{
Self.syncedGatewayRoot(
currentRoot: currentRoot,
connectionMode: connectionMode,
remoteTransport: remoteTransport,
remoteTarget: remoteTarget,
remoteIdentity: remoteIdentity,
remoteUrl: remoteUrl,
remoteToken: remoteToken,
remoteTokenDirty: remoteTokenDirty).root
}
}
#endif
@MainActor
enum AppStateStore {
static let shared = AppState()

View File

@@ -6,14 +6,14 @@ import OpenClawKit
import OSLog
actor CameraCaptureService {
struct CameraDeviceInfo: Encodable, Sendable {
struct CameraDeviceInfo: Encodable {
let id: String
let name: String
let position: String
let deviceType: String
}
enum CameraError: LocalizedError, Sendable {
enum CameraError: LocalizedError {
case cameraUnavailable
case microphoneUnavailable
case permissionDenied(kind: String)

View File

@@ -2,7 +2,7 @@ import Foundation
import OpenClawProtocol
enum ConfigStore {
struct Overrides: Sendable {
struct Overrides {
var isRemoteMode: (@Sendable () async -> Bool)?
var loadLocal: (@MainActor @Sendable () -> [String: Any])?
var saveLocal: (@MainActor @Sendable ([String: Any]) -> Void)?

View File

@@ -1,13 +1,13 @@
import Foundation
enum EffectiveConnectionModeSource: Sendable, Equatable {
enum EffectiveConnectionModeSource: Equatable {
case configMode
case configRemoteURL
case userDefaults
case onboarding
}
struct EffectiveConnectionMode: Sendable, Equatable {
struct EffectiveConnectionMode: Equatable {
let mode: AppState.ConnectionMode
let source: EffectiveConnectionModeSource
}

View File

@@ -14,7 +14,7 @@ struct ControlHeartbeatEvent: Codable {
let reason: String?
}
struct ControlAgentEvent: Codable, Sendable, Identifiable {
struct ControlAgentEvent: Codable, Identifiable {
var id: String {
"\(self.runId)-\(self.seq)"
}

View File

@@ -226,7 +226,7 @@ struct CronJob: Identifiable, Codable, Equatable {
}
}
struct CronEvent: Codable, Sendable {
struct CronEvent: Codable {
let jobId: String
let action: String
let runAtMs: Int?
@@ -237,7 +237,7 @@ struct CronEvent: Codable, Sendable {
let nextRunAtMs: Int?
}
struct CronRunLogEntry: Codable, Identifiable, Sendable {
struct CronRunLogEntry: Codable, Identifiable {
var id: String {
"\(self.jobId)-\(self.ts)"
}

View File

@@ -1,6 +1,6 @@
import Foundation
struct DevicePresentation: Sendable {
struct DevicePresentation {
let title: String
let symbol: String?
}

View File

@@ -7,7 +7,7 @@ actor DiagnosticsFileLog {
private let maxBytes: Int64 = 5 * 1024 * 1024
private let maxBackups = 5
struct Record: Codable, Sendable {
struct Record: Codable {
let ts: String
let pid: Int32
let category: String

View File

@@ -84,13 +84,13 @@ enum ExecAsk: String, CaseIterable, Codable, Identifiable {
}
}
enum ExecApprovalDecision: String, Codable, Sendable {
enum ExecApprovalDecision: String, Codable {
case allowOnce = "allow-once"
case allowAlways = "allow-always"
case deny
}
enum ExecAllowlistPatternValidationReason: String, Codable, Sendable, Equatable {
enum ExecAllowlistPatternValidationReason: String, Codable, Equatable {
case empty
case missingPathComponent
@@ -104,12 +104,12 @@ enum ExecAllowlistPatternValidationReason: String, Codable, Sendable, Equatable
}
}
enum ExecAllowlistPatternValidation: Sendable, Equatable {
enum ExecAllowlistPatternValidation: Equatable {
case valid(String)
case invalid(ExecAllowlistPatternValidationReason)
}
struct ExecAllowlistRejectedEntry: Sendable, Equatable {
struct ExecAllowlistRejectedEntry: Equatable {
let id: UUID
let pattern: String
let reason: ExecAllowlistPatternValidationReason
@@ -753,7 +753,7 @@ enum ExecApprovalHelpers {
}
}
struct ExecEventPayload: Codable, Sendable {
struct ExecEventPayload: Codable {
var sessionKey: String
var runId: String
var host: String

View File

@@ -11,7 +11,7 @@ final class ExecApprovalsGatewayPrompter {
private let logger = Logger(subsystem: "ai.openclaw", category: "exec-approvals.gateway")
private var task: Task<Void, Never>?
struct GatewayApprovalRequest: Codable, Sendable {
struct GatewayApprovalRequest: Codable {
var id: String
var request: ExecApprovalPromptRequest
var createdAtMs: Int

View File

@@ -5,7 +5,7 @@ import Foundation
import OpenClawKit
import OSLog
struct ExecApprovalPromptRequest: Codable, Sendable {
struct ExecApprovalPromptRequest: Codable {
var command: String
var cwd: String?
var host: String?

View File

@@ -1,6 +1,6 @@
import Foundation
struct ExecCommandResolution: Sendable {
struct ExecCommandResolution {
let rawExecutable: String
let resolvedPath: String?
let executableName: String

View File

@@ -6,7 +6,7 @@ import OSLog
private let gatewayConnectionLogger = Logger(subsystem: "ai.openclaw", category: "gateway.connection")
enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
enum GatewayAgentChannel: String, Codable, CaseIterable {
case last
case whatsapp
case telegram
@@ -33,7 +33,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
}
}
struct GatewayAgentInvocation: Sendable {
struct GatewayAgentInvocation {
var message: String
var sessionKey: String = "main"
var thinking: String?
@@ -53,7 +53,7 @@ actor GatewayConnection {
typealias Config = (url: URL, token: String?, password: String?)
enum Method: String, Sendable {
enum Method: String {
case agent
case status
case setHeartbeats = "set-heartbeats"
@@ -110,6 +110,44 @@ actor GatewayConnection {
private var subscribers: [UUID: AsyncStream<GatewayPush>.Continuation] = [:]
private var lastSnapshot: HelloOk?
private struct LossyDecodable<Value: Decodable>: Decodable {
let value: Value?
init(from decoder: Decoder) throws {
do {
self.value = try Value(from: decoder)
} catch {
self.value = nil
}
}
}
private struct LossyCronListResponse: Decodable {
let jobs: [LossyDecodable<CronJob>]
enum CodingKeys: String, CodingKey {
case jobs
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.jobs = try container.decodeIfPresent([LossyDecodable<CronJob>].self, forKey: .jobs) ?? []
}
}
private struct LossyCronRunsResponse: Decodable {
let entries: [LossyDecodable<CronRunLogEntry>]
enum CodingKeys: String, CodingKey {
case entries
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.entries = try container.decodeIfPresent([LossyDecodable<CronRunLogEntry>].self, forKey: .entries) ?? []
}
}
init(
configProvider: @escaping @Sendable () async throws -> Config = GatewayConnection.defaultConfigProvider,
sessionBox: WebSocketSessionBox? = nil)
@@ -390,9 +428,9 @@ actor GatewayConnection {
// MARK: - Typed gateway API
extension GatewayConnection {
struct ConfigGetSnapshot: Decodable, Sendable {
struct SnapshotConfig: Decodable, Sendable {
struct Session: Decodable, Sendable {
struct ConfigGetSnapshot: Decodable {
struct SnapshotConfig: Decodable {
struct Session: Decodable {
let mainKey: String?
let scope: String?
}
@@ -691,7 +729,7 @@ extension GatewayConnection {
// MARK: - Cron
struct CronSchedulerStatus: Decodable, Sendable {
struct CronSchedulerStatus: Decodable {
let enabled: Bool
let storePath: String
let jobs: Int
@@ -703,17 +741,17 @@ extension GatewayConnection {
}
func cronList(includeDisabled: Bool = true) async throws -> [CronJob] {
let res: CronListResponse = try await self.requestDecoded(
let data = try await self.requestRaw(
method: .cronList,
params: ["includeDisabled": AnyCodable(includeDisabled)])
return res.jobs
return try Self.decodeCronListResponse(data)
}
func cronRuns(jobId: String, limit: Int = 200) async throws -> [CronRunLogEntry] {
let res: CronRunsResponse = try await self.requestDecoded(
let data = try await self.requestRaw(
method: .cronRuns,
params: ["id": AnyCodable(jobId), "limit": AnyCodable(limit)])
return res.entries
return try Self.decodeCronRunsResponse(data)
}
func cronRun(jobId: String, force: Bool = true) async throws {
@@ -739,4 +777,24 @@ extension GatewayConnection {
func cronAdd(payload: [String: AnyCodable]) async throws {
try await self.requestVoid(method: .cronAdd, params: payload)
}
nonisolated static func decodeCronListResponse(_ data: Data) throws -> [CronJob] {
let decoded = try JSONDecoder().decode(LossyCronListResponse.self, from: data)
let jobs = decoded.jobs.compactMap(\.value)
let skipped = decoded.jobs.count - jobs.count
if skipped > 0 {
gatewayConnectionLogger.warning("cron.list skipped \(skipped, privacy: .public) malformed jobs")
}
return jobs
}
nonisolated static func decodeCronRunsResponse(_ data: Data) throws -> [CronRunLogEntry] {
let decoded = try JSONDecoder().decode(LossyCronRunsResponse.self, from: data)
let entries = decoded.entries.compactMap(\.value)
let skipped = decoded.entries.count - entries.count
if skipped > 0 {
gatewayConnectionLogger.warning("cron.runs skipped \(skipped, privacy: .public) malformed entries")
}
return entries
}
}

View File

@@ -6,11 +6,16 @@ enum GatewayDiscoverySelectionSupport {
gateway: GatewayDiscoveryModel.DiscoveredGateway,
state: AppState)
{
if state.remoteTransport == .direct {
state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
} else {
state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
let preferredTransport = self.preferredTransport(
for: gateway,
current: state.remoteTransport)
if preferredTransport != state.remoteTransport {
state.remoteTransport = preferredTransport
}
state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
OpenClawConfigFile.setRemoteGatewayUrl(
host: endpoint.host,
@@ -19,4 +24,30 @@ enum GatewayDiscoverySelectionSupport {
OpenClawConfigFile.clearRemoteGatewayUrl()
}
}
static func preferredTransport(
for gateway: GatewayDiscoveryModel.DiscoveredGateway,
current: AppState.RemoteTransport) -> AppState.RemoteTransport
{
if self.shouldPreferDirectTransport(for: gateway) {
return .direct
}
return current
}
static func shouldPreferDirectTransport(
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool
{
guard GatewayDiscoveryHelpers.directUrl(for: gateway) != nil else { return false }
if gateway.stableID.hasPrefix("tailscale-serve|") {
return true
}
guard let host = GatewayDiscoveryHelpers.resolvedServiceHost(for: gateway)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
else {
return false
}
return host.hasSuffix(".ts.net")
}
}

View File

@@ -2,7 +2,7 @@ import ConcurrencyExtras
import Foundation
import OSLog
enum GatewayEndpointState: Sendable, Equatable {
enum GatewayEndpointState: Equatable {
case ready(mode: AppState.ConnectionMode, url: URL, token: String?, password: String?)
case connecting(mode: AppState.ConnectionMode, detail: String)
case unavailable(mode: AppState.ConnectionMode, reason: String)
@@ -24,14 +24,14 @@ actor GatewayEndpointStore {
]
private static let remoteConnectingDetail = "Connecting to remote gateway…"
private static let staticLogger = Logger(subsystem: "ai.openclaw", category: "gateway-endpoint")
private enum EnvOverrideWarningKind: Sendable {
private enum EnvOverrideWarningKind {
case token
case password
}
private static let envOverrideWarnings = LockIsolated((token: false, password: false))
struct Deps: Sendable {
struct Deps {
let mode: @Sendable () async -> AppState.ConnectionMode
let token: @Sendable () -> String?
let password: @Sendable () -> String?
@@ -188,13 +188,7 @@ actor GatewayEndpointStore {
private static func resolveConfigToken(isRemote: Bool, root: [String: Any]) -> String? {
if isRemote {
if let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let token = remote["token"] as? String
{
return token.trimmingCharacters(in: .whitespacesAndNewlines)
}
return nil
return GatewayRemoteConfig.resolveTokenString(root: root)
}
if let gateway = root["gateway"] as? [String: Any],
@@ -614,6 +608,44 @@ actor GatewayEndpointStore {
}
extension GatewayEndpointStore {
static func localConfig() -> GatewayConnection.Config {
self.localConfig(
root: OpenClawConfigFile.loadDict(),
env: ProcessInfo.processInfo.environment,
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot(),
tailscaleIP: TailscaleService.fallbackTailnetIPv4())
}
static func localConfig(
root: [String: Any],
env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot?,
tailscaleIP: String?) -> GatewayConnection.Config
{
let port = GatewayEnvironment.gatewayPort()
let bind = self.resolveGatewayBindMode(root: root, env: env)
let customBindHost = self.resolveGatewayCustomBindHost(root: root)
let scheme = self.resolveGatewayScheme(root: root, env: env)
let host = self.resolveLocalGatewayHost(
bindMode: bind,
customBindHost: customBindHost,
tailscaleIP: tailscaleIP)
let token = self.resolveGatewayToken(
isRemote: false,
root: root,
env: env,
launchdSnapshot: launchdSnapshot)
let password = self.resolveGatewayPassword(
isRemote: false,
root: root,
env: env,
launchdSnapshot: launchdSnapshot)
return (
url: URL(string: "\(scheme)://\(host):\(port)")!,
token: token,
password: password)
}
private static func normalizeDashboardPath(_ rawPath: String?) -> String {
let trimmed = (rawPath ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "/" }
@@ -721,5 +753,18 @@ extension GatewayEndpointStore {
customBindHost: customBindHost,
tailscaleIP: tailscaleIP)
}
static func _testLocalConfig(
root: [String: Any],
env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot? = nil,
tailscaleIP: String? = nil) -> GatewayConnection.Config
{
self.localConfig(
root: root,
env: env,
launchdSnapshot: launchdSnapshot,
tailscaleIP: tailscaleIP)
}
}
#endif

View File

@@ -3,7 +3,7 @@ import OpenClawIPC
import OSLog
/// Lightweight SemVer helper (major.minor.patch only) for gateway compatibility checks.
struct Semver: Comparable, CustomStringConvertible, Sendable {
struct Semver: Comparable, CustomStringConvertible {
let major: Int
let minor: Int
let patch: Int

View File

@@ -2,6 +2,28 @@ import Foundation
import OpenClawKit
enum GatewayRemoteConfig {
enum TokenValue: Equatable {
case missing
case plaintext(String)
case unsupportedNonString
var textFieldValue: String {
switch self {
case let .plaintext(token):
token
case .missing, .unsupportedNonString:
""
}
}
var isUnsupportedNonString: Bool {
if case .unsupportedNonString = self {
return true
}
return false
}
}
static func resolveTransport(root: [String: Any]) -> AppState.RemoteTransport {
guard let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
@@ -24,6 +46,29 @@ enum GatewayRemoteConfig {
return trimmed.isEmpty ? nil : trimmed
}
static func resolveTokenValue(root: [String: Any]) -> TokenValue {
guard let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let tokenRaw = remote["token"]
else {
return .missing
}
guard let tokenString = tokenRaw as? String else {
return .unsupportedNonString
}
let trimmed = tokenString.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? .missing : .plaintext(trimmed)
}
static func resolveTokenString(root: [String: Any]) -> String? {
switch self.resolveTokenValue(root: root) {
case let .plaintext(token):
token
case .missing, .unsupportedNonString:
nil
}
}
static func resolveGatewayUrl(root: [String: Any]) -> URL? {
guard let raw = self.resolveUrlString(root: root) else { return nil }
return self.normalizeGatewayUrl(raw)

View File

@@ -149,6 +149,7 @@ struct GeneralSettings: View {
} else {
self.remoteDirectRow
}
self.remoteTokenRow
GatewayDiscoveryInlineList(
discovery: self.gatewayDiscovery,
@@ -291,6 +292,30 @@ struct GeneralSettings: View {
}
}
private var remoteTokenRow: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .center, spacing: 10) {
Text("Gateway token")
.font(.callout.weight(.semibold))
.frame(width: self.remoteLabelWidth, alignment: .leading)
SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
Text("Used when the remote gateway requires token auth.")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, self.remoteLabelWidth + 10)
if self.state.remoteTokenUnsupported {
Text(
"The current gateway.remote.token value is not plain text. OpenClaw for macOS cannot use it directly; enter a plaintext token here to replace it.")
.font(.caption)
.foregroundStyle(.orange)
.padding(.leading, self.remoteLabelWidth + 10)
}
}
}
private func remoteTestButton(disabled: Bool) -> some View {
Button {
Task { await self.testRemote() }
@@ -692,6 +717,7 @@ extension GeneralSettings {
state.remoteTransport = .ssh
state.remoteTarget = "user@host:2222"
state.remoteUrl = "wss://gateway.example.ts.net"
state.remoteToken = "example-token"
state.remoteIdentity = "/tmp/id_ed25519"
state.remoteProjectRoot = "/tmp/openclaw"
state.remoteCliPath = "/tmp/openclaw"

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