Compare commits

..

89 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
Vincent Koc
817fa5462b telegram: bridge direct delivery message hooks 2026-03-08 12:14:19 -07:00
717 changed files with 8440 additions and 35949 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
custom: ["https://github.com/sponsors/steipete"]

View File

@@ -76,37 +76,6 @@ body:
label: Install method
description: How OpenClaw was installed or launched.
placeholder: npm global / pnpm dev / docker / mac app
- type: input
id: model
attributes:
label: Model
description: Effective model under test.
placeholder: minimax/text-01 / openrouter/anthropic/claude-opus-4.1 / anthropic/claude-sonnet-4.5
validations:
required: true
- type: input
id: provider_chain
attributes:
label: Provider / routing chain
description: Effective request path through gateways, proxies, providers, or model routers.
placeholder: openclaw -> cloudflare-ai-gateway -> minimax
validations:
required: true
- type: input
id: config_location
attributes:
label: Config file / key location
description: Optional. Relevant config source or key path if this bug depends on overrides or custom provider setup. Redact secrets.
placeholder: ~/.openclaw/openclaw.json ; models.providers.cloudflare-ai-gateway.baseUrl ; ~/.openclaw/agents/<agentId>/agent/models.json
- type: textarea
id: provider_setup_details
attributes:
label: Additional provider/model setup details
description: Optional. Include redacted routing details, per-agent overrides, auth-profile interactions, env/config context, or anything else needed to explain the effective provider/model setup. Do not include API keys, tokens, or passwords.
placeholder: |
Default route is openclaw -> cloudflare-ai-gateway -> minimax.
Previous setup was openclaw -> cloudflare-ai-gateway -> openrouter -> minimax.
Relevant config lives in ~/.openclaw/openclaw.json under models.providers.minimax and models.providers.cloudflare-ai-gateway.
- type: textarea
id: logs
attributes:

View File

@@ -51,7 +51,6 @@ jobs:
},
{
label: "r: no-ci-pr",
close: true,
message:
"Please don't make PRs for test failures on main.\n\n" +
"The team is aware of those and will handle them directly on the codebase, not only fixing the tests but also investigating what the root cause is. Having to sift through test-fix-PRs (including some that have been out of date for weeks...) on top of that doesn't help. There are already way too many PRs for humans to manage; please don't make the flood worse.\n\n" +
@@ -393,7 +392,6 @@ jobs:
}
const invalidLabel = "invalid";
const spamLabel = "r: spam";
const dirtyLabel = "dirty";
const noisyPrMessage =
"Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch.";
@@ -430,21 +428,6 @@ jobs:
});
return;
}
if (labelSet.has(spamLabel)) {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
state: "closed",
});
await github.rest.issues.lock({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
lock_reason: "spam",
});
return;
}
if (labelSet.has(invalidLabel)) {
await github.rest.issues.update({
owner: context.repo.owner,
@@ -456,23 +439,6 @@ jobs:
}
}
if (issue && labelSet.has(spamLabel)) {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: "closed",
state_reason: "not_planned",
});
await github.rest.issues.lock({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
lock_reason: "spam",
});
return;
}
if (issue && labelSet.has(invalidLabel)) {
await github.rest.issues.update({
owner: context.repo.owner,

View File

@@ -302,6 +302,34 @@ jobs:
python -m pip install --upgrade pip
python -m pip install pre-commit
- name: Detect secrets
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "push" ]; then
echo "Running full detect-secrets scan on push."
pre-commit run --all-files detect-secrets
exit 0
fi
BASE="${{ github.event.pull_request.base.sha }}"
changed_files=()
if git rev-parse --verify "$BASE^{commit}" >/dev/null 2>&1; then
while IFS= read -r path; do
[ -n "$path" ] || continue
[ -f "$path" ] || continue
changed_files+=("$path")
done < <(git diff --name-only --diff-filter=ACMR "$BASE" HEAD)
fi
if [ "${#changed_files[@]}" -gt 0 ]; then
echo "Running detect-secrets on ${#changed_files[@]} changed file(s)."
pre-commit run detect-secrets --files "${changed_files[@]}"
else
echo "Falling back to full detect-secrets scan."
pre-commit run --all-files detect-secrets
fi
- name: Detect committed private keys
run: pre-commit run --all-files detect-private-key

View File

@@ -93,11 +93,7 @@ jobs:
- name: Setup Swift build tools
if: matrix.needs_swift_tools
run: |
sudo xcode-select -s /Applications/Xcode_26.1.app
xcodebuild -version
brew install xcodegen swiftlint swiftformat
swift --version
run: brew install xcodegen swiftlint swiftformat
- name: Initialize CodeQL
uses: github/codeql-action/init@v4

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

@@ -43,8 +43,6 @@ jobs:
- name: Set up Docker Builder
uses: useblacksmith/setup-docker-builder@v1
# Blacksmith can fall back to the local docker driver, which rejects gha
# cache export/import. Keep smoke builds driver-agnostic.
- name: Build root Dockerfile smoke image
uses: useblacksmith/build-push-action@v2
with:
@@ -54,6 +52,8 @@ jobs:
load: true
push: false
provenance: false
cache-from: type=gha,scope=install-smoke-root-dockerfile
cache-to: type=gha,mode=max,scope=install-smoke-root-dockerfile
- name: Run root Dockerfile CLI smoke
run: |
@@ -73,6 +73,8 @@ jobs:
load: true
push: false
provenance: false
cache-from: type=gha,scope=install-smoke-root-dockerfile-ext
cache-to: type=gha,mode=max,scope=install-smoke-root-dockerfile-ext
- name: Smoke test Dockerfile with extension build arg
run: |
@@ -87,6 +89,8 @@ jobs:
load: true
push: false
provenance: false
cache-from: type=gha,scope=install-smoke-installer-root
cache-to: type=gha,mode=max,scope=install-smoke-installer-root
- name: Build installer non-root image
if: github.event_name != 'pull_request'
@@ -98,6 +102,8 @@ jobs:
load: true
push: false
provenance: false
cache-from: type=gha,scope=install-smoke-installer-nonroot
cache-to: type=gha,mode=max,scope=install-smoke-installer-nonroot
- name: Run installer docker tests
env:

View File

@@ -1,79 +0,0 @@
name: OpenClaw NPM Release
on:
push:
tags:
- "v*"
concurrency:
group: openclaw-npm-release-${{ github.ref }}
cancel-in-progress: false
env:
NODE_VERSION: "22.x"
PNPM_VERSION: "10.23.0"
jobs:
publish_openclaw_npm:
# npm trusted publishing + provenance requires a GitHub-hosted runner.
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "false"
use-sticky-disk: "false"
- name: Validate release tag and package metadata
env:
RELEASE_SHA: ${{ github.sha }}
RELEASE_TAG: ${{ github.ref_name }}
RELEASE_MAIN_REF: origin/main
run: |
set -euo pipefail
# Fetch the full main ref so merge-base ancestry checks keep working
# for older tagged commits that are still contained in main.
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
pnpm release:openclaw:npm:check
- name: Ensure version is not already published
run: |
set -euo pipefail
PACKAGE_VERSION=$(node -p "require('./package.json').version")
if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
exit 1
fi
echo "Publishing openclaw@${PACKAGE_VERSION}"
- name: Check
run: pnpm check
- name: Build
run: pnpm build
- name: Verify release contents
run: pnpm release:check
- name: Publish
run: |
set -euo pipefail
PACKAGE_VERSION=$(node -p "require('./package.json').version")
if [[ "$PACKAGE_VERSION" == *-beta.* ]]; then
npm publish --access public --tag beta --provenance
else
npm publish --access public --provenance
fi

2
.gitignore vendored
View File

@@ -81,7 +81,6 @@ apps/ios/*.mobileprovision
# Local untracked files
.local/
docs/.local/
tmp/
IDENTITY.md
USER.md
.tgz
@@ -122,4 +121,3 @@ dist/protocol.schema.json
# Synthing
**/.stfolder/
.dev-state

View File

@@ -9,19 +9,7 @@ Input
- If ambiguous: ask.
Do (review-only)
Goal: produce a thorough review and a clear recommendation (READY FOR /landpr vs NEEDS WORK vs INVALID CLAIM). Do NOT merge, do NOT push, do NOT make changes in the repo as part of this command.
0. Truthfulness + reality gate (required for bug-fix claims)
- Do not trust the issue text or PR summary by default; verify in code and evidence.
- If the PR claims to fix a bug linked to an issue, confirm the bug exists now (repro steps, logs, failing test, or clear code-path proof).
- Prove root cause with exact location (`path/file.ts:line` + explanation of why behavior is wrong).
- Verify fix targets the same code path as the root cause.
- Require a regression test when feasible (fails before fix, passes after fix). If not feasible, require explicit justification + manual verification evidence.
- Hallucination/BS red flags (treat as BLOCKER until disproven):
- claimed behavior not present in repo,
- issue/PR says "fixes #..." but changed files do not touch implicated path,
- only docs/comments changed for a runtime bug claim,
- vague AI-generated rationale without concrete evidence.
Goal: produce a thorough review and a clear recommendation (READY for /landpr vs NEEDS WORK). Do NOT merge, do NOT push, do NOT make changes in the repo as part of this command.
1. Identify PR meta + context
@@ -68,7 +56,6 @@ Goal: produce a thorough review and a clear recommendation (READY FOR /landpr vs
- Any deprecations, docs, types, or lint rules we should adjust?
8. Key questions to answer explicitly
- Is the core claim substantiated by evidence, or is it likely invalid/hallucinated?
- Can we fix everything ourselves in a follow-up, or does the contributor need to update this PR?
- Any blocking concerns (must-fix before merge)?
- Is this PR ready to land, or does it need work?
@@ -78,32 +65,18 @@ Goal: produce a thorough review and a clear recommendation (READY FOR /landpr vs
A) TL;DR recommendation
- One of: READY FOR /landpr | NEEDS WORK | INVALID CLAIM (issue/bug not substantiated) | NEEDS DISCUSSION
- One of: READY FOR /landpr | NEEDS WORK | NEEDS DISCUSSION
- 13 sentence rationale.
B) Claim verification matrix (required)
- Fill this table:
| Field | Evidence |
| ----------------------------------------------- | -------- |
| Claimed problem | ... |
| Evidence observed (repro/log/test/code) | ... |
| Root cause location (`path:line`) | ... |
| Why this fix addresses that root cause | ... |
| Regression coverage (test name or manual proof) | ... |
- If any row is missing/weak, default to `NEEDS WORK` or `INVALID CLAIM`.
C) What changed
B) What changed
- Brief bullet summary of the diff/behavioral changes.
D) What's good
C) What's good
- Bullets: correctness, simplicity, tests, docs, ergonomics, etc.
E) Concerns / questions (actionable)
D) Concerns / questions (actionable)
- Numbered list.
- Mark each item as:
@@ -111,19 +84,17 @@ E) Concerns / questions (actionable)
- IMPORTANT (should fix before merge)
- NIT (optional)
- For each: point to the file/area and propose a concrete fix or alternative.
- If evidence for the core bug claim is missing, add a `BLOCKER` explicitly.
F) Tests
E) Tests
- What exists.
- What's missing (specific scenarios).
- State clearly whether there is a regression test for the claimed bug.
G) Follow-ups (optional)
F) Follow-ups (optional)
- Non-blocking refactors/tickets to open later.
H) Suggested PR comment (optional)
G) Suggested PR comment (optional)
- Offer: "Want me to draft a PR comment to the author?"
- If yes, provide a ready-to-paste comment summarizing the above, with clear asks.

View File

@@ -205,7 +205,7 @@
"filename": "apps/macos/Sources/OpenClawProtocol/GatewayModels.swift",
"hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd",
"is_verified": false,
"line_number": 1859
"line_number": 1763
}
],
"apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift": [
@@ -266,7 +266,7 @@
"filename": "apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift",
"hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd",
"is_verified": false,
"line_number": 1859
"line_number": 1763
}
],
"docs/.i18n/zh-CN.tm.jsonl": [
@@ -11659,7 +11659,7 @@
"filename": "src/agents/tools/web-search.ts",
"hashed_secret": "dfba7aade0868074c2861c98e2a9a92f3178a51b",
"is_verified": false,
"line_number": 291
"line_number": 292
}
],
"src/agents/tools/web-tools.enabled-defaults.e2e.test.ts": [
@@ -13013,5 +13013,5 @@
}
]
},
"generated_at": "2026-03-10T03:11:06Z"
"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/OpenClawProtocol,apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift
--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

@@ -18,7 +18,7 @@ excluded:
- coverage
- "*.playground"
# Generated (protocol-gen-swift.ts)
- apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
- apps/macos/Sources/MoltbotProtocol/GatewayModels.swift
# Generated (generate-host-env-security-policy-swift.mjs)
- apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift

View File

@@ -10,36 +10,6 @@
- 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.
## Auto-close labels (issues and PRs)
- If an issue/PR matches one of the reasons below, apply the label and let `.github/workflows/auto-response.yml` handle comment/close/lock.
- Do not manually close + manually comment for these reasons.
- Why: keeps wording consistent, preserves automation behavior (`state_reason`, locking), and keeps triage/reporting searchable by label.
- `r:*` labels can be used on both issues and PRs.
- `r: skill`: close with guidance to publish skills on Clawhub.
- `r: support`: close with redirect to Discord support + stuck FAQ.
- `r: no-ci-pr`: close test-fix-only PRs for failing `main` CI and post the standard explanation.
- `r: too-many-prs`: close when author exceeds active PR limit.
- `r: testflight`: close requests asking for TestFlight access/builds. OpenClaw does not provide TestFlight distribution yet, so use the standard response (“Not available, build from source.”) instead of ad-hoc replies.
- `r: third-party-extension`: close with guidance to ship as third-party plugin.
- `r: moltbook`: close + lock as off-topic (not affiliated).
- `r: spam`: close + lock as spam (`lock_reason: spam`).
- `invalid`: close invalid items (issues are closed as `not_planned`; PRs are closed).
- `dirty`: close PRs with too many unrelated/unexpected changes (PR-only label).
## PR truthfulness and bug-fix validation
- Never merge a bug-fix PR based only on issue text, PR text, or AI rationale.
- Before `/landpr`, run `/reviewpr` and require explicit evidence for bug-fix claims.
- Minimum merge gate for bug-fix PRs:
1. symptom evidence (repro/log/failing test),
2. verified root cause in code with file/line,
3. fix touches the implicated code path,
4. regression test (fail before/pass after) when feasible; if not feasible, include manual verification proof and why no test was added.
- If claim is unsubstantiated or likely hallucinated/BS: do not merge. Request evidence/changes, or close with `invalid` when appropriate.
- If linked issue appears wrong/outdated, correct triage first; do not merge speculative fixes.
## Project Structure & Module Organization
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).

View File

@@ -6,94 +6,18 @@ Docs: https://docs.openclaw.ai
### Changes
- Gateway/node pending work: add narrow in-memory pending-work queue primitives (`node.pending.enqueue` / `node.pending.drain`) and wake-helper reuse as a foundation for dormant-node work delivery. (#41409) Thanks @mbelinky.
- Git/runtime state: ignore the gateway-generated `.dev-state` file so local runtime state does not show up as untracked repo noise. (#41848) Thanks @smysle.
- ACP/sessions_spawn: add optional `resumeSessionId` for `runtime: "acp"` so spawned ACP sessions can resume an existing ACPX/Codex conversation instead of always starting fresh. (#41847) Thanks @pejmanjohn.
- Exec/child commands: mark child command environments with `OPENCLAW_CLI` so subprocesses can detect when they were launched from the OpenClaw CLI. (#41411) Thanks @vincentkoc.
- iOS/Home canvas: add a bundled welcome screen with a live agent overview that refreshes on connect, reconnect, and foreground return, and move the compact connection pill off the top-left canvas overlay. (#42456) Thanks @ngutman.
- iOS/Home canvas: replace floating controls with a docked toolbar, make the bundled home scaffold adapt to smaller phones, and open chat in the resolved main session instead of a synthetic `ios` session. (#42456) Thanks @ngutman.
- Discord/auto threads: add `autoArchiveDuration` channel config for auto-created threads so Discord thread archiving can stay at 1 hour, 1 day, 3 days, or 1 week instead of always using the 1-hour default. (#35065) Thanks @davidguttman.
- OpenCode/onboarding: add new OpenCode Go provider, treat Zen and Go as one OpenCode setup in the wizard/docs while keeping the runtime providers split, store one shared OpenCode key for both profiles, and stop overriding the built-in `opencode-go` catalog routing. (#42313) Thanks @ImLukeF and @vincentkoc.
- macOS/chat UI: add a chat model picker, persist explicit thinking-level selections across relaunch, and harden provider-aware session model sync for the shared chat composer. (#42314) Thanks @ImLukeF.
### Breaking
- Cron/doctor: tighten isolated cron delivery so cron jobs can no longer notify through ad hoc agent sends or fallback main-session summaries, and add `openclaw doctor --fix` migration for legacy cron storage and legacy notify/webhook delivery metadata. (#40998) Thanks @mbelinky.
### Fixes
- Agents/text sanitization: strip leaked model control tokens (`<|...|>` and full-width `<...>` variants) from user-facing assistant text, preventing GLM-5 and DeepSeek internal delimiters from reaching end users. (#42173) Thanks @imwyvern.
- Resolve web tool SecretRefs atomically at runtime. (#41599) Thanks @joshavant.
- Feishu/local image auto-convert: pass `mediaLocalRoots` through the `sendText` local-image shim so allowed local image paths upload as Feishu images again instead of falling back to raw path text. (#40623) Thanks @ayanesakura.
- ACP/ACPX plugin: bump the bundled `acpx` pin to `0.1.16` so plugin-local installs and strict version checks match the latest published CLI. (#41975) Thanks @dutifulbob.
- 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.
- Secret files: harden CLI and channel credential file reads against path-swap races by requiring direct regular files for `*File` secret inputs and rejecting symlink-backed secret files.
- Archive extraction: harden TAR and external `tar.bz2` installs against destination symlink and pre-existing child-symlink escapes by extracting into staging first and merging into the canonical destination with safe file opens.
- 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.
- Agents/embedded logs: add structured, sanitized lifecycle and failover observation events so overload and provider failures are easier to tail and filter. (#41336) thanks @altaywtf.
- iOS/gateway foreground recovery: reconnect immediately on foreground return after stale background sockets are torn down, so the app no longer stays disconnected until a later wake path happens. (#41384) Thanks @mbelinky.
- Cron/subagent followup: do not misclassify empty or `NO_REPLY` cron responses as interim acknowledgements that need a rerun, so deliberately silent cron jobs are no longer retried. (#41383) thanks @jackal092927.
- Auth/cooldowns: reset expired auth-profile cooldown error counters before computing the next backoff so stale on-disk counters do not re-escalate into long cooldown loops after expiry. (#41028) thanks @zerone0x.
- Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky.
- ACP/bridge mode: reject unsupported per-session MCP server setup and propagate rejected session-mode changes so IDE clients see explicit bridge limitations instead of silent success. (#41424) Thanks @mbelinky.
- ACP/session UX: replay stored user and assistant text on `loadSession`, expose Gateway-backed session controls and metadata, and emit approximate session usage updates so IDE clients restore context more faithfully. (#41425) Thanks @mbelinky.
- ACP/tool streaming: enrich `tool_call` and `tool_call_update` events with best-effort text content and file-location hints so IDE clients can follow bridge tool activity more naturally. (#41442) Thanks @mbelinky.
- ACP/runtime attachments: forward normalized inbound image attachments into ACP runtime turns so ACPX sessions can preserve image prompt content on the runtime path. (#41427) Thanks @mbelinky.
- ACP/regressions: add gateway RPC coverage for ACP lineage patching, ACPX runtime coverage for image prompt serialization, and an operator smoke-test procedure for live ACP spawn verification. (#41456) Thanks @mbelinky.
- Agents/billing recovery: probe single-provider billing cooldowns on the existing throttle so topping up credits can recover without a manual gateway restart. (#41422) thanks @altaywtf.
- ACP/follow-up hardening: make session restore and prompt completion degrade gracefully on transcript/update failures, enforce bounded tool-location traversal, and skip non-image ACPX turns the runtime cannot serialize. (#41464) Thanks @mbelinky.
- Agents/fallback observability: add structured, sanitized model-fallback decision and auth-profile failure-state events with correlated run IDs so cooldown probes and failover paths are easier to trace in logs. (#41337) thanks @altaywtf.
- Protocol/Swift model sync: regenerate pending node work Swift bindings after the landed `node.pending.*` schema additions so generated protocol artifacts are consistent again. (#41477) Thanks @mbelinky.
- Discord/reply chunking: resolve the effective `maxLinesPerMessage` config across live reply paths and preserve `chunkMode` in the fast send path so long Discord replies no longer split unexpectedly at the default 17-line limit. (#40133) thanks @rbutera.
- Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf.
- Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk.
- Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu.
- CLI/memory teardown: close cached memory search/index managers in the one-shot CLI shutdown path so watcher-backed memory caches no longer keep completed CLI runs alive after output finishes. (#40389) thanks @Julbarth.
- Tools/web search: treat Brave `llm-context` grounding snippets as plain strings so `web_search` no longer returns empty snippet arrays in LLM Context mode. (#41387) thanks @zheliu2.
- Telegram/exec approvals: reject `/approve` commands aimed at other bots, keep deterministic approval prompts visible when tool-result delivery fails, and stop resolved exact IDs from matching other pending approvals by prefix. (#37233) Thanks @huntharo.
- Control UI/Sessions: restore single-column session table collapse on narrow viewport or container widths by moving the responsive table override next to the base grid rule and enabling inline-size container queries. (#12175) Thanks @benjipeng.
- Telegram/final preview delivery: split active preview lifecycle from cleanup retention so missing archived preview edits avoid duplicate fallback sends without clearing the live preview or blocking later in-place finalization. (#41662) thanks @hougangdev.
- Cron/state errors: record `lastErrorReason` in cron job state and keep the gateway schema aligned with the full failover-reason set, including regression coverage for protocol conformance. (#14382) thanks @futuremind2026.
- Tools/web search: recover OpenRouter Perplexity citation extraction from `message.annotations` when chat-completions responses omit top-level citations. (#40881) Thanks @laurieluo.
- Security/external content: treat whitespace-delimited `EXTERNAL UNTRUSTED CONTENT` boundary markers like underscore-delimited variants so prompt wrappers cannot bypass marker sanitization. (#35983) Thanks @urianpaul94.
- Telegram/network env-proxy: apply configured transport policy to proxied HTTPS dispatchers as well as direct `NO_PROXY` bypasses, so resolver-scoped IPv4 fallback and network settings work consistently for env-proxied Telegram traffic. (#40740) Thanks @sircrumpet.
- Agents/memory flush: forward `memoryFlushWritePath` through `runEmbeddedPiAgent` so memory-triggered flush turns keep the append-only write guard without aborting before tool setup. Follows up on #38574. (#41761) Thanks @frankekn.
- CI/CodeQL Swift toolchain: select Xcode 26.1 before installing Swift build tools so the CodeQL Swift job uses Swift tools 6.2 on `macos-latest`. (#41787) thanks @BunsDev.
- Sandbox/subagents: pass the real configured workspace through `sessions_spawn` inheritance when a parent agent runs in a copied-workspace sandbox, so child `/agent` mounts point at the configured workspace instead of the parent sandbox copy. (#40757) Thanks @dsantoreis.
- Mattermost/plugin send actions: normalize direct `replyTo` fallback handling so threaded plugin sends trim blank IDs and reuse the correct reply target again. (#41176) Thanks @hnykda.
- MS Teams/allowlist resolution: use the General channel conversation ID as the resolved team key (with Graph GUID fallback) so Bot Framework runtime `channelData.team.id` matching works for team and team/channel allowlist entries. (#41838) Thanks @BradGroux.
- Mattermost/Markdown formatting: preserve first-line indentation when stripping bot mentions so nested list items and indented code blocks keep their structure, and render Mattermost tables natively by default instead of fenced-code fallback. (#18655) thanks @echo931.
- Agents/fallback cooldown probing: cap cooldown-bypass probing to one attempt per provider per fallback run so multi-model same-provider cooldown chains can continue to cross-provider fallbacks instead of repeatedly stalling on duplicate cooldown probes. (#41711) Thanks @cgdusek.
- Telegram/direct delivery: bridge direct delivery sends to internal `message:sent` hooks so internal hook listeners observe successful Telegram deliveries. (#40185) Thanks @vincentkoc.
- Plugins/global hook runner: harden singleton state handling so shared global hook runner reuse does not leak or corrupt runner state across executions. (#40184) Thanks @vincentkoc.
- Agents/fallback: recognize Poe `402 You've used up your points!` billing errors so configured model fallbacks trigger instead of surfacing the raw provider error. (#42278) Thanks @CryUshio.
- Telegram/outbound HTML sends: chunk long HTML-mode messages, preserve plain-text fallback and silent-delivery params across retries, and cut over to plain text when HTML chunk planning cannot safely preserve the full message. (#42240) thanks @obviyus.
- Agents/embedded overload logs: include the failing model and provider in error-path console output, with lifecycle regression coverage for the rendered and sanitized `consoleMessage`. (#41236) thanks @jiarung.
- Agents/failover: treat Gemini `MALFORMED_RESPONSE` stop reasons as retryable timeouts so preview-model enum drift falls back cleanly instead of crashing the run, without also reclassifying malformed function-call errors. (#42292) Thanks @jnMetaCode.
- Discord/Telegram outbound runtime config: thread runtime-resolved config through Discord and Telegram send paths so SecretRef-based credentials stay resolved during message delivery. (#42352) Thanks @joshavant.
- Secrets/SecretRef: reject exec SecretRef traversal ids across schema, runtime, and gateway. (#42370) Thanks @joshavant.
- Telegram/docs: clarify that `channels.telegram.groups` allowlists chats while `groupAllowFrom` allowlists users inside those chats, and point invalid negative chat IDs at the right config key. (#42451) Thanks @altaywtf.
- Models/Alibaba Cloud Model Studio: wire `MODELSTUDIO_API_KEY` through shared env auth, implicit provider discovery, and shell-env fallback so onboarding works outside the wizard too. (#40634) Thanks @pomelo-nwu.
- Subagents/authority: persist leaf vs orchestrator control scope at spawn time and route tool plus slash-command control through shared ownership checks, so leaf sessions cannot regain orchestration privileges after restore or flat-key lookups. Thanks @tdjackey.
- ACP/sessions_spawn: implicitly stream `mode="run"` ACP spawns to parent only for eligible subagent orchestrator sessions (heartbeat `target: "last"` with a usable session-local route), restoring parent progress relays without thread binding. (#42404) Thanks @davidguttman.
- Sessions/reset model recompute: clear stale runtime model, context-token, and system-prompt metadata before session resets recompute the replacement session, so resets pick up current defaults and explicit overrides instead of reusing old runtime model state. (#41173) thanks @PonyX-lab.
- Browser/Browserbase 429 handling: surface stable no-retry rate-limit guidance without buffering discarded HTTP 429 response bodies from remote browser services. (#40491) thanks @mvanhorn.
- Gateway/auth: allow one trusted device-token retry on shared-token mismatch with recovery hints to prevent reconnect churn during token drift. (#42507) Thanks @joshavant.
- Channels/allowlists: remove stale matcher caching so same-array allowlist edits and wildcard replacements take effect immediately, with regression coverage for in-place mutation cases.
- Gateway/auth: fail closed when local `gateway.auth.*` SecretRefs are configured but unavailable, instead of silently falling back to `gateway.remote.*` credentials in local mode. (#42672) Thanks @joshavant.
- Sandbox/fs bridge: pin staged writes to verified parent directories so temporary write files cannot materialize outside the allowed mount before atomic replace. Thanks @tdjackey.
- Commands/config writes: enforce `configWrites` against both the originating account and the targeted account scope for `/config` and config-backed `/allowlist` edits, blocking sibling-account mutations while preserving gateway `operator.admin` flows. Thanks @tdjackey for reporting.
- Security/system.run: fail closed for approval-backed interpreter/runtime commands when OpenClaw cannot bind exactly one concrete local file operand, while extending best-effort direct-file binding to additional runtime forms. Thanks @tdjackey for reporting.
- Gateway/session reset auth: split conversation `/new` and `/reset` handling away from the admin-only `sessions.reset` control-plane RPC so write-scoped gateway callers can no longer reach the privileged reset path through `agent`. Thanks @tdjackey for reporting.
- Telegram/final preview delivery followup: keep ambiguous missing-`message_id` finals only when a preview was already visible, while first-preview/no-id cases still fall back so Telegram users do not lose the final reply. (#41932) thanks @hougangdev.
- Agents/Azure OpenAI Responses: include the `azure-openai` provider in the Responses API store override so Azure OpenAI multi-turn cron jobs and embedded agent runs no longer fail with HTTP 400 "store is set to false". (#42934, fixes #42800) Thanks @ademczuk.
- Agents/context pruning: prune image-only tool results during soft-trim, align context-pruning coverage with the new tool-result contract, and extend historical image cleanup to the same screenshot-heavy session path. (#42212) Thanks @MoerAI.
## 2026.3.8
@@ -149,7 +73,6 @@ Docs: https://docs.openclaw.ai
- 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.
- Subagents/sandboxing: restrict leaf subagents to their own spawned runs and remove leaf `subagents` control access so sandboxed leaf workers can no longer steer sibling sessions. Thanks @tdjackey.
- 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.
@@ -160,10 +83,6 @@ Docs: https://docs.openclaw.ai
- 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.
- Control UI/Debug: replace the Manual RPC free-text method field with a sorted dropdown sourced from gateway-advertised methods, and stack the form vertically for narrower layouts. (#14967) thanks @rixau.
- Auth/profile resolution: log debug details when auto-discovered auth profiles fail during provider API-key resolution, so `--debug` output surfaces the real refresh/keychain/credential-store failure instead of only the generic missing-key message. (#41271) thanks @he-yufeng.
- ACP/cancel scoping: scope `chat.abort` and shared-session ACP event routing by `runId` so one session cannot cancel or consume another session's run when they share the same gateway session key. (#41331) Thanks @pejmanjohn.
- SecretRef/models: harden custom/provider secret persistence and reuse across models.json snapshots, merge behavior, runtime headers, and secret audits. (#42554) Thanks @joshavant.
## 2026.3.7
@@ -230,7 +149,6 @@ Docs: https://docs.openclaw.ai
- Onboarding/API key input hardening: strip non-Latin1 Unicode artifacts from normalized secret input (while preserving Latin-1 content and internal spaces) so malformed copied API keys cannot trigger HTTP header `ByteString` construction crashes; adds regression coverage for shared normalization and MiniMax auth header usage. (#24496) Thanks @fa6maalassaf.
- Kimi Coding/Anthropic tools compatibility: normalize `anthropic-messages` tool payloads to OpenAI-style `tools[].function` + compatible `tool_choice` when targeting Kimi Coding endpoints, restoring tool-call workflows that regressed after v2026.3.2. (#37038) Thanks @mochimochimochi-hub.
- Heartbeat/workspace-path guardrails: append explicit workspace `HEARTBEAT.md` path guidance (and `docs/heartbeat.md` avoidance) to heartbeat prompts so heartbeat runs target workspace checklists reliably across packaged install layouts. (#37037) Thanks @stofancy.
- Node/system.run approvals: bind approval prompts to the exact executed argv text and show shell payload only as a secondary preview, closing basename-spoofed wrapper approval mismatches. Thanks @tdjackey.
- Subagents/kill-complete announce race: when a late `subagent-complete` lifecycle event arrives after an earlier kill marker, clear stale kill suppression/cleanup flags and re-run announce cleanup so finished runs no longer get silently swallowed. (#37024) Thanks @cmfinlan.
- Agents/tool-result cleanup timeout hardening: on embedded runner teardown idle timeouts, clear pending tool-call state without persisting synthetic `missing tool result` entries, preventing timeout cleanups from poisoning follow-up turns; adds regression coverage for timeout clear-vs-flush behavior. (#37081) Thanks @Coyote-Den.
- Agents/openai-completions stream timeout hardening: ensure runtime undici global dispatchers use extended streaming body/header timeouts (including env-proxy dispatcher mode) before embedded runs, reducing forced mid-stream `terminated` failures on long generations; adds regression coverage for dispatcher selection and idempotent reconfiguration. (#9708) Thanks @scottchguard.
@@ -522,8 +440,6 @@ Docs: https://docs.openclaw.ai
- 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.
- Memory flush/bootstrap file protection: restrict memory-flush runs to append-only `read`/`write` tools and route host-side memory appends through root-enforced safe file handles so flush turns cannot overwrite bootstrap files via `exec` or unsafe raw rewrites. (#38574) Thanks @frankekn.
- Mattermost/DM media uploads: resolve bare 26-character Mattermost IDs user-first for direct messages so media sends no longer fail with `403 Forbidden` when targets are configured as unprefixed user IDs. (#29925) Thanks @teconomix.
## 2026.3.2

View File

@@ -73,9 +73,6 @@ Welcome to the lobster tank! 🦞
- **Robin Waslander** - Security, PR triage, bug fixes
- GitHub: [@hydro13](https://github.com/hydro13) · X: [@Robin_waslander](https://x.com/Robin_waslander)
- **Tengji (George) Zhang** - Chinese model APIs, cloud, pi
- GitHub: [@odysseus0](https://github.com/odysseus0) · X: [@odysseus0z](https://x.com/odysseus0z)
## How to Contribute
1. **Bugs & small fixes** → Open a PR!
@@ -86,7 +83,6 @@ Welcome to the lobster tank! 🦞
- Test locally with your OpenClaw instance
- Run tests: `pnpm build && pnpm check && pnpm test`
- If you have access to Codex, run `codex review --base origin/main` locally before opening or updating your PR. Treat this as the current highest standard of AI review, even if GitHub Codex review also runs.
- Ensure CI checks pass
- Keep PRs focused (one thing per PR; do not mix unrelated concerns)
- Describe what & why
@@ -100,8 +96,6 @@ If a review bot leaves review conversations on your PR, you are expected to hand
- 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
- If Codex leaves comments, address every relevant one or resolve it with a short explanation when it is not applicable to your change
- If GitHub Codex review does not trigger for some reason, run `codex review --base origin/main` locally anyway and treat that output as required review work
This applies to both human-authored and AI-assisted PRs.
@@ -130,7 +124,6 @@ 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
- [ ] If you have access to Codex, run `codex review --base origin/main` locally and address the findings before asking for review
- [ ] 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. If you are using an LLM coding agent, instruct it to resolve bot review conversations it has addressed instead of leaving them for maintainers.

View File

@@ -125,7 +125,6 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
- Any report whose only claim is that an operator-enabled `dangerous*`/`dangerously*` config option weakens defaults (these are explicit break-glass tradeoffs by design)
- Reports that depend on trusted operator-supplied configuration values to trigger availability impact (for example custom regex patterns). These may still be fixed as defense-in-depth hardening, but are not security-boundary bypasses.
- Reports whose only claim is heuristic/parity drift in command-risk detection (for example obfuscation-pattern checks) across exec surfaces, without a demonstrated trust-boundary bypass. These are hardening-only findings and are not vulnerabilities; triage may close them as `invalid`/`no-action` or track them separately as low/informational hardening.
- Reports whose only claim is that exec approvals do not semantically model every interpreter/runtime loader form, subcommand, flag combination, package script, or transitive module/config import. Exec approvals bind exact request context and best-effort direct local file operands; they are not a complete semantic model of everything a runtime may load.
- Exposed secrets that are third-party/user-controlled credentials (not OpenClaw-owned and not granting access to OpenClaw-operated infrastructure/services) without demonstrated OpenClaw impact
- Reports whose only claim is host-side exec when sandbox runtime is disabled/unavailable (documented default behavior in the trusted-operator model), without a boundary bypass.
- Reports whose only claim is that a platform-provided upload destination URL is untrusted (for example Microsoft Teams `fileConsent/invoke` `uploadInfo.uploadUrl`) without proving attacker control in an authenticated production flow.
@@ -166,7 +165,6 @@ OpenClaw separates routing from execution, but both remain inside the same opera
- **Gateway** is the control plane. If a caller passes Gateway auth, they are treated as a trusted operator for that Gateway.
- **Node** is an execution extension of the Gateway. Pairing a node grants operator-level remote capability on that node.
- **Exec approvals** (allowlist/ask UI) are operator guardrails to reduce accidental command execution, not a multi-tenant authorization boundary.
- Exec approvals bind exact command/cwd/env context and, when OpenClaw can identify one concrete local script/file operand, that file snapshot too. This is best-effort integrity hardening, not a complete semantic model of every interpreter/runtime loader path.
- Differences in command-risk warning heuristics between exec surfaces (`gateway`, `node`, `sandbox`) do not, by themselves, constitute a security-boundary bypass.
- For untrusted-user isolation, split by trust boundary: separate gateways and separate OS users/hosts per boundary.

View File

@@ -1,223 +0,0 @@
import SwiftUI
struct HomeToolbar: View {
var gateway: StatusPill.GatewayState
var voiceWakeEnabled: Bool
var activity: StatusPill.Activity?
var brighten: Bool
var talkButtonEnabled: Bool
var talkActive: Bool
var talkTint: Color
var onStatusTap: () -> Void
var onChatTap: () -> Void
var onTalkTap: () -> Void
var onSettingsTap: () -> Void
@Environment(\.colorSchemeContrast) private var contrast
var body: some View {
VStack(spacing: 0) {
Rectangle()
.fill(.white.opacity(self.contrast == .increased ? 0.46 : (self.brighten ? 0.18 : 0.12)))
.frame(height: self.contrast == .increased ? 1.0 : 0.6)
.allowsHitTesting(false)
HStack(spacing: 12) {
HomeToolbarStatusButton(
gateway: self.gateway,
voiceWakeEnabled: self.voiceWakeEnabled,
activity: self.activity,
brighten: self.brighten,
onTap: self.onStatusTap)
Spacer(minLength: 0)
HStack(spacing: 8) {
HomeToolbarActionButton(
systemImage: "text.bubble.fill",
accessibilityLabel: "Chat",
brighten: self.brighten,
action: self.onChatTap)
if self.talkButtonEnabled {
HomeToolbarActionButton(
systemImage: self.talkActive ? "waveform.circle.fill" : "waveform.circle",
accessibilityLabel: self.talkActive ? "Talk Mode On" : "Talk Mode Off",
brighten: self.brighten,
tint: self.talkTint,
isActive: self.talkActive,
action: self.onTalkTap)
}
HomeToolbarActionButton(
systemImage: "gearshape.fill",
accessibilityLabel: "Settings",
brighten: self.brighten,
action: self.onSettingsTap)
}
}
.padding(.horizontal, 12)
.padding(.top, 10)
.padding(.bottom, 8)
}
.frame(maxWidth: .infinity)
.background(.ultraThinMaterial)
.overlay(alignment: .top) {
LinearGradient(
colors: [
.white.opacity(self.brighten ? 0.10 : 0.06),
.clear,
],
startPoint: .top,
endPoint: .bottom)
.allowsHitTesting(false)
}
}
}
private struct HomeToolbarStatusButton: View {
@Environment(\.scenePhase) private var scenePhase
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.colorSchemeContrast) private var contrast
var gateway: StatusPill.GatewayState
var voiceWakeEnabled: Bool
var activity: StatusPill.Activity?
var brighten: Bool
var onTap: () -> Void
@State private var pulse: Bool = false
var body: some View {
Button(action: self.onTap) {
HStack(spacing: 8) {
HStack(spacing: 6) {
Circle()
.fill(self.gateway.color)
.frame(width: 8, height: 8)
.scaleEffect(
self.gateway == .connecting && !self.reduceMotion
? (self.pulse ? 1.15 : 0.85)
: 1.0
)
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
Text(self.gateway.title)
.font(.footnote.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
}
if let activity {
Image(systemName: activity.systemImage)
.font(.footnote.weight(.semibold))
.foregroundStyle(activity.tint ?? .primary)
.transition(.opacity.combined(with: .move(edge: .top)))
} else {
Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash")
.font(.footnote.weight(.semibold))
.foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color.black.opacity(self.brighten ? 0.12 : 0.18))
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(
.white.opacity(self.contrast == .increased ? 0.46 : (self.brighten ? 0.22 : 0.16)),
lineWidth: self.contrast == .increased ? 1.0 : 0.6)
}
}
}
.buttonStyle(.plain)
.accessibilityLabel("Connection Status")
.accessibilityValue(self.accessibilityValue)
.accessibilityHint(self.gateway == .connected ? "Double tap for gateway actions" : "Double tap to open settings")
.onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) }
.onDisappear { self.pulse = false }
.onChange(of: self.gateway) { _, newValue in
self.updatePulse(for: newValue, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion)
}
.onChange(of: self.scenePhase) { _, newValue in
self.updatePulse(for: self.gateway, scenePhase: newValue, reduceMotion: self.reduceMotion)
}
.onChange(of: self.reduceMotion) { _, newValue in
self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: newValue)
}
.animation(.easeInOut(duration: 0.18), value: self.activity?.title)
}
private var accessibilityValue: String {
if let activity {
return "\(self.gateway.title), \(activity.title)"
}
return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")"
}
private func updatePulse(for gateway: StatusPill.GatewayState, scenePhase: ScenePhase, reduceMotion: Bool) {
guard gateway == .connecting, scenePhase == .active, !reduceMotion else {
withAnimation(reduceMotion ? .none : .easeOut(duration: 0.2)) { self.pulse = false }
return
}
guard !self.pulse else { return }
withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) {
self.pulse = true
}
}
}
private struct HomeToolbarActionButton: View {
@Environment(\.colorSchemeContrast) private var contrast
let systemImage: String
let accessibilityLabel: String
let brighten: Bool
var tint: Color?
var isActive: Bool = false
let action: () -> Void
var body: some View {
Button(action: self.action) {
Image(systemName: self.systemImage)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(self.isActive ? (self.tint ?? .primary) : .primary)
.frame(width: 40, height: 40)
.background {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.black.opacity(self.brighten ? 0.12 : 0.18))
.overlay {
if let tint {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(
LinearGradient(
colors: [
tint.opacity(self.isActive ? 0.22 : 0.14),
tint.opacity(self.isActive ? 0.08 : 0.04),
.clear,
],
startPoint: .topLeading,
endPoint: .bottomTrailing))
.blendMode(.overlay)
}
}
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(
(self.tint ?? .white).opacity(
self.isActive
? 0.34
: (self.contrast == .increased ? 0.4 : (self.brighten ? 0.22 : 0.16))
),
lineWidth: self.contrast == .increased ? 1.0 : (self.isActive ? 0.8 : 0.6))
}
}
}
.buttonStyle(.plain)
.accessibilityLabel(self.accessibilityLabel)
}
}

View File

@@ -34,11 +34,18 @@ extension NodeAppModel {
}
func showA2UIOnConnectIfNeeded() async {
await MainActor.run {
// Keep the bundled home canvas as the default connected view.
// Agents can still explicitly present a remote or local canvas later.
self.lastAutoA2uiURL = nil
self.screen.showDefaultCanvas()
let current = self.screen.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
if current.isEmpty || current == self.lastAutoA2uiURL {
if let canvasUrl = await self.resolveCanvasHostURLWithCapabilityRefresh(),
let url = URL(string: canvasUrl),
await Self.probeTCP(url: url, timeoutSeconds: 2.5)
{
self.screen.navigate(to: canvasUrl)
self.lastAutoA2uiURL = canvasUrl
} else {
self.lastAutoA2uiURL = nil
self.screen.showDefaultCanvas()
}
}
}

View File

@@ -88,7 +88,6 @@ final class NodeAppModel {
var selectedAgentId: String?
var gatewayDefaultAgentId: String?
var gatewayAgents: [AgentSummary] = []
var homeCanvasRevision: Int = 0
var lastShareEventText: String = "No share events yet."
var openChatRequestID: Int = 0
private(set) var pendingAgentDeepLinkPrompt: AgentDeepLinkPrompt?
@@ -363,14 +362,7 @@ final class NodeAppModel {
await MainActor.run {
self.operatorConnected = false
self.gatewayConnected = false
// Foreground recovery must actively restart the saved gateway config.
// Disconnecting stale sockets alone can leave us idle if the old
// reconnect tasks were suppressed or otherwise got stuck in background.
self.gatewayStatusText = "Reconnecting…"
self.talkMode.updateGatewayConnected(false)
if let cfg = self.activeGatewayConnectConfig {
self.applyGatewayConnectConfig(cfg)
}
}
}
}
@@ -549,7 +541,6 @@ final class NodeAppModel {
self.seamColorHex = raw.isEmpty ? nil : raw
self.mainSessionBaseKey = mainKey
self.talkMode.updateMainSessionKey(self.mainSessionKey)
self.homeCanvasRevision &+= 1
}
} catch {
if let gatewayError = error as? GatewayResponseError {
@@ -576,19 +567,12 @@ final class NodeAppModel {
self.selectedAgentId = nil
}
self.talkMode.updateMainSessionKey(self.mainSessionKey)
self.homeCanvasRevision &+= 1
}
} catch {
// Best-effort only.
}
}
func refreshGatewayOverviewIfConnected() async {
guard await self.isOperatorConnected() else { return }
await self.refreshBrandingFromGateway()
await self.refreshAgentsFromGateway()
}
func setSelectedAgentId(_ agentId: String?) {
let trimmed = (agentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let stableID = (self.connectedGatewayID ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
@@ -599,7 +583,6 @@ final class NodeAppModel {
GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: stableID, agentId: self.selectedAgentId)
}
self.talkMode.updateMainSessionKey(self.mainSessionKey)
self.homeCanvasRevision &+= 1
if let relay = ShareGatewayRelaySettings.loadConfig() {
ShareGatewayRelaySettings.saveConfig(
ShareGatewayRelayConfig(
@@ -1639,9 +1622,11 @@ extension NodeAppModel {
}
var chatSessionKey: String {
// Keep chat aligned with the gateway's resolved main session key.
// A hardcoded "ios" base creates synthetic placeholder sessions in the chat UI.
self.mainSessionKey
let base = "ios"
let agentId = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if agentId.isEmpty || (!defaultId.isEmpty && agentId == defaultId) { return base }
return SessionKey.makeAgentSessionKey(agentId: agentId, baseKey: base)
}
var activeAgentName: String {
@@ -1757,7 +1742,6 @@ private extension NodeAppModel {
self.gatewayDefaultAgentId = nil
self.gatewayAgents = []
self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID)
self.homeCanvasRevision &+= 1
self.apnsLastRegisteredTokenHex = nil
}

View File

@@ -536,7 +536,7 @@ struct OnboardingWizardView: View {
Text(
"Approve this device on the gateway.\n"
+ "1) `openclaw devices approve` (or `openclaw devices approve <requestId>`)\n"
+ "2) `/pair approve` in your OpenClaw chat\n"
+ "2) `/pair approve` in Telegram\n"
+ "\(requestLine)\n"
+ "OpenClaw will also retry automatically when you return to this app.")
}

View File

@@ -1,6 +1,5 @@
import SwiftUI
import UIKit
import OpenClawProtocol
struct RootCanvas: View {
@Environment(NodeAppModel.self) private var appModel
@@ -138,33 +137,16 @@ struct RootCanvas: View {
.environment(self.gatewayController)
}
.onAppear { self.updateIdleTimer() }
.onAppear { self.updateHomeCanvasState() }
.onAppear { self.evaluateOnboardingPresentation(force: false) }
.onAppear { self.maybeAutoOpenSettings() }
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
.onChange(of: self.scenePhase) { _, newValue in
self.updateIdleTimer()
self.updateHomeCanvasState()
guard newValue == .active else { return }
Task {
await self.appModel.refreshGatewayOverviewIfConnected()
await MainActor.run {
self.updateHomeCanvasState()
}
}
}
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
.onAppear { self.maybeShowQuickSetup() }
.onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() }
.onAppear { self.updateCanvasDebugStatus() }
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayStatusText) { _, _ in
self.updateCanvasDebugStatus()
self.updateHomeCanvasState()
}
.onChange(of: self.appModel.gatewayServerName) { _, _ in
self.updateCanvasDebugStatus()
self.updateHomeCanvasState()
}
.onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
if newValue != nil {
self.showOnboarding = false
@@ -173,13 +155,7 @@ struct RootCanvas: View {
.onChange(of: self.onboardingRequestID) { _, _ in
self.evaluateOnboardingPresentation(force: true)
}
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in
self.updateCanvasDebugStatus()
self.updateHomeCanvasState()
}
.onChange(of: self.appModel.homeCanvasRevision) { _, _ in
self.updateHomeCanvasState()
}
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
if newValue != nil {
self.onboardingComplete = true
@@ -233,134 +209,6 @@ struct RootCanvas: View {
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
}
private func updateHomeCanvasState() {
let payload = self.makeHomeCanvasPayload()
guard let data = try? JSONEncoder().encode(payload),
let json = String(data: data, encoding: .utf8)
else {
self.appModel.screen.updateHomeCanvasState(json: nil)
return
}
self.appModel.screen.updateHomeCanvasState(json: json)
}
private func makeHomeCanvasPayload() -> HomeCanvasPayload {
let gatewayName = self.normalized(self.appModel.gatewayServerName)
let gatewayAddress = self.normalized(self.appModel.gatewayRemoteAddress)
let gatewayLabel = gatewayName ?? gatewayAddress ?? "Gateway"
let activeAgentID = self.resolveActiveAgentID()
let agents = self.homeCanvasAgents(activeAgentID: activeAgentID)
switch self.gatewayStatus {
case .connected:
return HomeCanvasPayload(
gatewayState: "connected",
eyebrow: "Connected to \(gatewayLabel)",
title: "Your agents are ready",
subtitle:
"This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.",
gatewayLabel: gatewayLabel,
activeAgentName: self.appModel.activeAgentName,
activeAgentBadge: agents.first(where: { $0.isActive })?.badge ?? "OC",
activeAgentCaption: "Selected on this phone",
agentCount: agents.count,
agents: Array(agents.prefix(6)),
footer: "The overview refreshes on reconnect and when the app returns to foreground.")
case .connecting:
return HomeCanvasPayload(
gatewayState: "connecting",
eyebrow: "Reconnecting",
title: "OpenClaw is syncing back up",
subtitle:
"The gateway session is coming back online. "
+ "Agent shortcuts should settle automatically in a moment.",
gatewayLabel: gatewayLabel,
activeAgentName: self.appModel.activeAgentName,
activeAgentBadge: "OC",
activeAgentCaption: "Gateway session in progress",
agentCount: agents.count,
agents: Array(agents.prefix(4)),
footer: "If the gateway is reachable, reconnect should complete without intervention.")
case .error, .disconnected:
return HomeCanvasPayload(
gatewayState: self.gatewayStatus == .error ? "error" : "offline",
eyebrow: "Welcome to OpenClaw",
title: "Your phone stays quiet until it is needed",
subtitle:
"Pair this device to your gateway to wake it only for real work, "
+ "keep a live agent overview handy, and avoid battery-draining background loops.",
gatewayLabel: gatewayLabel,
activeAgentName: "Main",
activeAgentBadge: "OC",
activeAgentCaption: "Connect to load your agents",
agentCount: agents.count,
agents: Array(agents.prefix(4)),
footer:
"When connected, the gateway can wake the phone with a silent push "
+ "instead of holding an always-on session.")
}
}
private func resolveActiveAgentID() -> String {
let selected = self.normalized(self.appModel.selectedAgentId) ?? ""
if !selected.isEmpty {
return selected
}
return self.resolveDefaultAgentID()
}
private func resolveDefaultAgentID() -> String {
self.normalized(self.appModel.gatewayDefaultAgentId) ?? ""
}
private func homeCanvasAgents(activeAgentID: String) -> [HomeCanvasAgentCard] {
let defaultAgentID = self.resolveDefaultAgentID()
let cards = self.appModel.gatewayAgents.map { agent -> HomeCanvasAgentCard in
let isActive = !activeAgentID.isEmpty && agent.id == activeAgentID
let isDefault = !defaultAgentID.isEmpty && agent.id == defaultAgentID
return HomeCanvasAgentCard(
id: agent.id,
name: self.homeCanvasName(for: agent),
badge: self.homeCanvasBadge(for: agent),
caption: isActive ? "Active on this phone" : (isDefault ? "Default agent" : "Ready"),
isActive: isActive)
}
return cards.sorted { lhs, rhs in
if lhs.isActive != rhs.isActive {
return lhs.isActive
}
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
}
}
private func homeCanvasName(for agent: AgentSummary) -> String {
self.normalized(agent.name) ?? agent.id
}
private func homeCanvasBadge(for agent: AgentSummary) -> String {
if let identity = agent.identity,
let emoji = identity["emoji"]?.value as? String,
let normalizedEmoji = self.normalized(emoji)
{
return normalizedEmoji
}
let words = self.homeCanvasName(for: agent)
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
.prefix(2)
let initials = words.compactMap { $0.first }.map(String.init).joined()
if !initials.isEmpty {
return initials.uppercased()
}
return "OC"
}
private func normalized(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private func evaluateOnboardingPresentation(force: Bool) {
if force {
self.onboardingAllowSkip = true
@@ -426,28 +274,6 @@ struct RootCanvas: View {
}
}
private struct HomeCanvasPayload: Codable {
var gatewayState: String
var eyebrow: String
var title: String
var subtitle: String
var gatewayLabel: String
var activeAgentName: String
var activeAgentBadge: String
var activeAgentCaption: String
var agentCount: Int
var agents: [HomeCanvasAgentCard]
var footer: String
}
private struct HomeCanvasAgentCard: Codable {
var id: String
var name: String
var badge: String
var caption: String
var isActive: Bool
}
private struct CanvasContent: View {
@Environment(NodeAppModel.self) private var appModel
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
@@ -475,33 +301,53 @@ private struct CanvasContent: View {
.transition(.opacity)
}
}
.safeAreaInset(edge: .bottom, spacing: 0) {
HomeToolbar(
gateway: self.gatewayStatus,
voiceWakeEnabled: self.voiceWakeEnabled,
activity: self.statusActivity,
brighten: self.brightenButtons,
talkButtonEnabled: self.talkButtonEnabled,
talkActive: self.talkActive,
talkTint: self.appModel.seamColor,
onStatusTap: {
if self.gatewayStatus == .connected {
self.showGatewayActions = true
} else {
.overlay(alignment: .topLeading) {
HStack(alignment: .top, spacing: 8) {
StatusPill(
gateway: self.gatewayStatus,
voiceWakeEnabled: self.voiceWakeEnabled,
activity: self.statusActivity,
brighten: self.brightenButtons,
onTap: {
if self.gatewayStatus == .connected {
self.showGatewayActions = true
} else {
self.openSettings()
}
})
.layoutPriority(1)
Spacer(minLength: 8)
HStack(spacing: 8) {
OverlayButton(systemImage: "text.bubble.fill", brighten: self.brightenButtons) {
self.openChat()
}
.accessibilityLabel("Chat")
if self.talkButtonEnabled {
// Keep Talk mode near status controls while freeing right-side screen real estate.
OverlayButton(
systemImage: self.talkActive ? "waveform.circle.fill" : "waveform.circle",
brighten: self.brightenButtons,
tint: self.appModel.seamColor,
isActive: self.talkActive)
{
let next = !self.talkActive
self.talkEnabled = next
self.appModel.setTalkEnabled(next)
}
.accessibilityLabel("Talk Mode")
}
OverlayButton(systemImage: "gearshape.fill", brighten: self.brightenButtons) {
self.openSettings()
}
},
onChatTap: {
self.openChat()
},
onTalkTap: {
let next = !self.talkActive
self.talkEnabled = next
self.appModel.setTalkEnabled(next)
},
onSettingsTap: {
self.openSettings()
})
.accessibilityLabel("Settings")
}
}
.padding(.horizontal, 10)
.safeAreaPadding(.top, 10)
}
.overlay(alignment: .topLeading) {
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
@@ -534,6 +380,63 @@ private struct CanvasContent: View {
}
}
private struct OverlayButton: View {
let systemImage: String
let brighten: Bool
var tint: Color?
var isActive: Bool = false
let action: () -> Void
var body: some View {
Button(action: self.action) {
Image(systemName: self.systemImage)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(self.isActive ? (self.tint ?? .primary) : .primary)
.padding(10)
.background {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(
LinearGradient(
colors: [
.white.opacity(self.brighten ? 0.26 : 0.18),
.white.opacity(self.brighten ? 0.08 : 0.04),
.clear,
],
startPoint: .topLeading,
endPoint: .bottomTrailing))
.blendMode(.overlay)
}
.overlay {
if let tint {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(
LinearGradient(
colors: [
tint.opacity(self.isActive ? 0.22 : 0.14),
tint.opacity(self.isActive ? 0.10 : 0.06),
.clear,
],
startPoint: .topLeading,
endPoint: .bottomTrailing))
.blendMode(.overlay)
}
}
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(
(self.tint ?? .white).opacity(self.isActive ? 0.34 : (self.brighten ? 0.24 : 0.18)),
lineWidth: self.isActive ? 0.7 : 0.5)
}
.shadow(color: .black.opacity(0.35), radius: 12, y: 6)
}
}
.buttonStyle(.plain)
}
}
private struct CameraFlashOverlay: View {
var nonce: Int

View File

@@ -20,7 +20,6 @@ final class ScreenController {
private var debugStatusEnabled: Bool = false
private var debugStatusTitle: String?
private var debugStatusSubtitle: String?
private var homeCanvasStateJSON: String?
init() {
self.reload()
@@ -95,26 +94,6 @@ final class ScreenController {
subtitle: self.debugStatusSubtitle)
}
func updateHomeCanvasState(json: String?) {
self.homeCanvasStateJSON = json
self.applyHomeCanvasStateIfNeeded()
}
func applyHomeCanvasStateIfNeeded() {
guard let webView = self.activeWebView else { return }
let payload = self.homeCanvasStateJSON ?? "null"
let js = """
(() => {
try {
const api = globalThis.__openclaw;
if (!api || typeof api.renderHome !== 'function') return;
api.renderHome(\(payload));
} catch (_) {}
})()
"""
webView.evaluateJavaScript(js) { _, _ in }
}
func waitForA2UIReady(timeoutMs: Int) async -> Bool {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: .milliseconds(timeoutMs))
@@ -212,7 +191,6 @@ final class ScreenController {
self.activeWebView = webView
self.reload()
self.applyDebugStatusIfNeeded()
self.applyHomeCanvasStateIfNeeded()
}
func detachWebView(_ webView: WKWebView) {

View File

@@ -7,7 +7,7 @@ struct ScreenTab: View {
var body: some View {
ZStack(alignment: .top) {
ScreenWebView(controller: self.appModel.screen)
.ignoresSafeArea(.container, edges: [.top, .leading, .trailing])
.ignoresSafeArea()
.overlay(alignment: .top) {
if let errorText = self.appModel.screen.errorText,
self.appModel.gatewayServerName == nil

View File

@@ -161,7 +161,6 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
func webView(_: WKWebView, didFinish _: WKNavigation?) {
self.controller?.errorText = nil
self.controller?.applyDebugStatusIfNeeded()
self.controller?.applyHomeCanvasStateIfNeeded()
}
func webView(_: WKWebView, didFail _: WKNavigation?, withError error: any Error) {

View File

@@ -65,10 +65,10 @@ struct SettingsTab: View {
DisclosureGroup(isExpanded: self.$gatewayExpanded) {
if !self.isGatewayConnected {
Text(
"1. Open a chat with your OpenClaw agent and send /pair\n"
"1. Open Telegram and message your bot: /pair\n"
+ "2. Copy the setup code it returns\n"
+ "3. Paste here and tap Connect\n"
+ "4. Back in that chat, run /pair approve")
+ "4. Back in Telegram, run /pair approve")
.font(.footnote)
.foregroundStyle(.secondary)
@@ -340,9 +340,9 @@ struct SettingsTab: View {
.foregroundStyle(.secondary)
}
self.featureToggle(
"Show Talk Control",
"Show Talk Button",
isOn: self.$talkButtonEnabled,
help: "Shows the Talk control in the main toolbar.")
help: "Shows the floating Talk button in the main interface.")
TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical)
.lineLimit(2 ... 6)
.textInputAutocapitalization(.sentences)
@@ -896,7 +896,7 @@ struct SettingsTab: View {
guard !trimmed.isEmpty else { return nil }
let lower = trimmed.lowercased()
if lower.contains("pairing required") {
return "Pairing required. Go back to your OpenClaw chat and run /pair approve, then tap Connect again."
return "Pairing required. Go back to Telegram and run /pair approve, then tap Connect again."
}
if lower.contains("device nonce required") || lower.contains("device nonce mismatch") {
return "Secure handshake failed. Make sure Tailscale is connected, then tap Connect again."

View File

@@ -38,7 +38,6 @@ struct StatusPill: View {
var gateway: GatewayState
var voiceWakeEnabled: Bool
var activity: Activity?
var compact: Bool = false
var brighten: Bool = false
var onTap: () -> Void
@@ -46,11 +45,11 @@ struct StatusPill: View {
var body: some View {
Button(action: self.onTap) {
HStack(spacing: self.compact ? 8 : 10) {
HStack(spacing: self.compact ? 6 : 8) {
HStack(spacing: 10) {
HStack(spacing: 8) {
Circle()
.fill(self.gateway.color)
.frame(width: self.compact ? 8 : 9, height: self.compact ? 8 : 9)
.frame(width: 9, height: 9)
.scaleEffect(
self.gateway == .connecting && !self.reduceMotion
? (self.pulse ? 1.15 : 0.85)
@@ -59,38 +58,34 @@ struct StatusPill: View {
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
Text(self.gateway.title)
.font((self.compact ? Font.footnote : Font.subheadline).weight(.semibold))
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
}
if let activity {
if !self.compact {
Divider()
.frame(height: 14)
.opacity(0.35)
}
Divider()
.frame(height: 14)
.opacity(0.35)
HStack(spacing: self.compact ? 4 : 6) {
if let activity {
HStack(spacing: 6) {
Image(systemName: activity.systemImage)
.font((self.compact ? Font.footnote : Font.subheadline).weight(.semibold))
.font(.subheadline.weight(.semibold))
.foregroundStyle(activity.tint ?? .primary)
if !self.compact {
Text(activity.title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
}
Text(activity.title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
}
.transition(.opacity.combined(with: .move(edge: .top)))
} else {
Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash")
.font((self.compact ? Font.footnote : Font.subheadline).weight(.semibold))
.font(.subheadline.weight(.semibold))
.foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary)
.accessibilityLabel(self.voiceWakeEnabled ? "Voice Wake enabled" : "Voice Wake disabled")
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.statusGlassCard(brighten: self.brighten, verticalPadding: self.compact ? 6 : 8)
.statusGlassCard(brighten: self.brighten, verticalPadding: 8)
}
.buttonStyle(.plain)
.accessibilityLabel("Connection Status")

View File

@@ -83,16 +83,16 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
#expect(json.contains("\"value\""))
}
@Test @MainActor func chatSessionKeyDefaultsToMainBase() {
@Test @MainActor func chatSessionKeyDefaultsToIOSBase() {
let appModel = NodeAppModel()
#expect(appModel.chatSessionKey == "main")
#expect(appModel.chatSessionKey == "ios")
}
@Test @MainActor func chatSessionKeyUsesAgentScopedKeyForNonDefaultAgent() {
let appModel = NodeAppModel()
appModel.gatewayDefaultAgentId = "main"
appModel.setSelectedAgentId("agent-123")
#expect(appModel.chatSessionKey == SessionKey.makeAgentSessionKey(agentId: "agent-123", baseKey: "main"))
#expect(appModel.chatSessionKey == SessionKey.makeAgentSessionKey(agentId: "agent-123", baseKey: "ios"))
#expect(appModel.mainSessionKey == "agent:agent-123:main")
}

View File

@@ -8,7 +8,6 @@ import QuartzCore
import SwiftUI
private let webChatSwiftLogger = Logger(subsystem: "ai.openclaw", category: "WebChatSwiftUI")
private let webChatThinkingLevelDefaultsKey = "openclaw.webchat.thinkingLevel"
private enum WebChatSwiftUILayout {
static let windowSize = NSSize(width: 500, height: 840)
@@ -22,21 +21,6 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
try await GatewayConnection.shared.chatHistory(sessionKey: sessionKey)
}
func listModels() async throws -> [OpenClawChatModelChoice] {
do {
let data = try await GatewayConnection.shared.request(
method: "models.list",
params: [:],
timeoutMs: 15000)
let result = try JSONDecoder().decode(ModelsListResult.self, from: data)
return result.models.map(Self.mapModelChoice)
} catch {
webChatSwiftLogger.warning(
"models.list failed; hiding model picker: \(error.localizedDescription, privacy: .public)")
return []
}
}
func abortRun(sessionKey: String, runId: String) async throws {
_ = try await GatewayConnection.shared.request(
method: "chat.abort",
@@ -62,28 +46,6 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
return try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: data)
}
func setSessionModel(sessionKey: String, model: String?) async throws {
var params: [String: AnyCodable] = [
"key": AnyCodable(sessionKey),
]
params["model"] = model.map(AnyCodable.init) ?? AnyCodable(NSNull())
_ = try await GatewayConnection.shared.request(
method: "sessions.patch",
params: params,
timeoutMs: 15000)
}
func setSessionThinking(sessionKey: String, thinkingLevel: String) async throws {
let params: [String: AnyCodable] = [
"key": AnyCodable(sessionKey),
"thinkingLevel": AnyCodable(thinkingLevel),
]
_ = try await GatewayConnection.shared.request(
method: "sessions.patch",
params: params,
timeoutMs: 15000)
}
func sendMessage(
sessionKey: String,
message: String,
@@ -171,14 +133,6 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
return .seqGap
}
}
private static func mapModelChoice(_ model: OpenClawProtocol.ModelChoice) -> OpenClawChatModelChoice {
OpenClawChatModelChoice(
modelID: model.id,
name: model.name,
provider: model.provider,
contextWindow: model.contextwindow)
}
}
// MARK: - Window controller
@@ -201,13 +155,7 @@ final class WebChatSwiftUIWindowController {
init(sessionKey: String, presentation: WebChatPresentation, transport: any OpenClawChatTransport) {
self.sessionKey = sessionKey
self.presentation = presentation
let vm = OpenClawChatViewModel(
sessionKey: sessionKey,
transport: transport,
initialThinkingLevel: Self.persistedThinkingLevel(),
onThinkingLevelChanged: { level in
UserDefaults.standard.set(level, forKey: webChatThinkingLevelDefaultsKey)
})
let vm = OpenClawChatViewModel(sessionKey: sessionKey, transport: transport)
let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex)
self.hosting = NSHostingController(rootView: OpenClawChatView(
viewModel: vm,
@@ -306,16 +254,6 @@ final class WebChatSwiftUIWindowController {
OverlayPanelFactory.clearGlobalEventMonitor(&self.dismissMonitor)
}
private static func persistedThinkingLevel() -> String? {
let stored = UserDefaults.standard.string(forKey: webChatThinkingLevelDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
guard let stored, ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive"].contains(stored) else {
return nil
}
return stored
}
private static func makeWindow(
for presentation: WebChatPresentation,
contentViewController: NSViewController) -> NSWindow

View File

@@ -950,102 +950,6 @@ public struct NodeEventParams: Codable, Sendable {
}
}
public struct NodePendingDrainParams: Codable, Sendable {
public let maxitems: Int?
public init(
maxitems: Int?)
{
self.maxitems = maxitems
}
private enum CodingKeys: String, CodingKey {
case maxitems = "maxItems"
}
}
public struct NodePendingDrainResult: Codable, Sendable {
public let nodeid: String
public let revision: Int
public let items: [[String: AnyCodable]]
public let hasmore: Bool
public init(
nodeid: String,
revision: Int,
items: [[String: AnyCodable]],
hasmore: Bool)
{
self.nodeid = nodeid
self.revision = revision
self.items = items
self.hasmore = hasmore
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
case revision
case items
case hasmore = "hasMore"
}
}
public struct NodePendingEnqueueParams: Codable, Sendable {
public let nodeid: String
public let type: String
public let priority: String?
public let expiresinms: Int?
public let wake: Bool?
public init(
nodeid: String,
type: String,
priority: String?,
expiresinms: Int?,
wake: Bool?)
{
self.nodeid = nodeid
self.type = type
self.priority = priority
self.expiresinms = expiresinms
self.wake = wake
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
case type
case priority
case expiresinms = "expiresInMs"
case wake
}
}
public struct NodePendingEnqueueResult: Codable, Sendable {
public let nodeid: String
public let revision: Int
public let queued: [String: AnyCodable]
public let waketriggered: Bool
public init(
nodeid: String,
revision: Int,
queued: [String: AnyCodable],
waketriggered: Bool)
{
self.nodeid = nodeid
self.revision = revision
self.queued = queued
self.waketriggered = waketriggered
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
case revision
case queued
case waketriggered = "wakeTriggered"
}
}
public struct NodeInvokeRequestEvent: Codable, Sendable {
public let id: String
public let nodeid: String
@@ -1337,8 +1241,6 @@ public struct SessionsPatchParams: Codable, Sendable {
public let model: AnyCodable?
public let spawnedby: AnyCodable?
public let spawndepth: AnyCodable?
public let subagentrole: AnyCodable?
public let subagentcontrolscope: AnyCodable?
public let sendpolicy: AnyCodable?
public let groupactivation: AnyCodable?
@@ -1357,8 +1259,6 @@ public struct SessionsPatchParams: Codable, Sendable {
model: AnyCodable?,
spawnedby: AnyCodable?,
spawndepth: AnyCodable?,
subagentrole: AnyCodable?,
subagentcontrolscope: AnyCodable?,
sendpolicy: AnyCodable?,
groupactivation: AnyCodable?)
{
@@ -1376,8 +1276,6 @@ public struct SessionsPatchParams: Codable, Sendable {
self.model = model
self.spawnedby = spawnedby
self.spawndepth = spawndepth
self.subagentrole = subagentrole
self.subagentcontrolscope = subagentcontrolscope
self.sendpolicy = sendpolicy
self.groupactivation = groupactivation
}
@@ -1397,8 +1295,6 @@ public struct SessionsPatchParams: Codable, Sendable {
case model
case spawnedby = "spawnedBy"
case spawndepth = "spawnDepth"
case subagentrole = "subagentRole"
case subagentcontrolscope = "subagentControlScope"
case sendpolicy = "sendPolicy"
case groupactivation = "groupActivation"
}
@@ -3054,7 +2950,7 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
public struct ExecApprovalRequestParams: Codable, Sendable {
public let id: String?
public let command: String?
public let command: String
public let commandargv: [String]?
public let systemrunplan: [String: AnyCodable]?
public let env: [String: AnyCodable]?
@@ -3075,7 +2971,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public init(
id: String?,
command: String?,
command: String,
commandargv: [String]?,
systemrunplan: [String: AnyCodable]?,
env: [String: AnyCodable]?,

View File

@@ -9,8 +9,6 @@ import UniformTypeIdentifiers
@MainActor
struct OpenClawChatComposer: View {
private static let menuThinkingLevels = ["off", "low", "medium", "high"]
@Bindable var viewModel: OpenClawChatViewModel
let style: OpenClawChatView.Style
let showsSessionSwitcher: Bool
@@ -29,15 +27,11 @@ struct OpenClawChatComposer: View {
if self.showsSessionSwitcher {
self.sessionPicker
}
if self.viewModel.showsModelPicker {
self.modelPicker
}
self.thinkingPicker
Spacer()
self.refreshButton
self.attachmentPicker
}
.padding(.horizontal, 10)
}
if self.showsAttachments, !self.viewModel.attachments.isEmpty {
@@ -89,19 +83,11 @@ struct OpenClawChatComposer: View {
}
private var thinkingPicker: some View {
Picker(
"Thinking",
selection: Binding(
get: { self.viewModel.thinkingLevel },
set: { next in self.viewModel.selectThinkingLevel(next) }))
{
Picker("Thinking", selection: self.$viewModel.thinkingLevel) {
Text("Off").tag("off")
Text("Low").tag("low")
Text("Medium").tag("medium")
Text("High").tag("high")
if !Self.menuThinkingLevels.contains(self.viewModel.thinkingLevel) {
Text(self.viewModel.thinkingLevel.capitalized).tag(self.viewModel.thinkingLevel)
}
}
.labelsHidden()
.pickerStyle(.menu)
@@ -109,25 +95,6 @@ struct OpenClawChatComposer: View {
.frame(maxWidth: 140, alignment: .leading)
}
private var modelPicker: some View {
Picker(
"Model",
selection: Binding(
get: { self.viewModel.modelSelectionID },
set: { next in self.viewModel.selectModel(next) }))
{
Text(self.viewModel.defaultModelLabel).tag(OpenClawChatViewModel.defaultModelSelectionID)
ForEach(self.viewModel.modelChoices) { model in
Text(model.displayLabel).tag(model.selectionID)
}
}
.labelsHidden()
.pickerStyle(.menu)
.controlSize(.small)
.frame(maxWidth: 240, alignment: .leading)
.help("Model")
}
private var sessionPicker: some View {
Picker(
"Session",

View File

@@ -1,36 +1,5 @@
import Foundation
public struct OpenClawChatModelChoice: Identifiable, Codable, Sendable, Hashable {
public var id: String { self.selectionID }
public let modelID: String
public let name: String
public let provider: String
public let contextWindow: Int?
public init(modelID: String, name: String, provider: String, contextWindow: Int?) {
self.modelID = modelID
self.name = name
self.provider = provider
self.contextWindow = contextWindow
}
/// Provider-qualified model ref used for picker identity and selection tags.
public var selectionID: String {
let trimmedProvider = self.provider.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedProvider.isEmpty else { return self.modelID }
let providerPrefix = "\(trimmedProvider)/"
if self.modelID.hasPrefix(providerPrefix) {
return self.modelID
}
return "\(trimmedProvider)/\(self.modelID)"
}
public var displayLabel: String {
self.selectionID
}
}
public struct OpenClawChatSessionsDefaults: Codable, Sendable {
public let model: String?
public let contextTokens: Int?
@@ -58,7 +27,6 @@ public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashabl
public let outputTokens: Int?
public let totalTokens: Int?
public let modelProvider: String?
public let model: String?
public let contextTokens: Int?
}

View File

@@ -10,7 +10,6 @@ public enum OpenClawChatTransportEvent: Sendable {
public protocol OpenClawChatTransport: Sendable {
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload
func listModels() async throws -> [OpenClawChatModelChoice]
func sendMessage(
sessionKey: String,
message: String,
@@ -20,8 +19,6 @@ public protocol OpenClawChatTransport: Sendable {
func abortRun(sessionKey: String, runId: String) async throws
func listSessions(limit: Int?) async throws -> OpenClawChatSessionsListResponse
func setSessionModel(sessionKey: String, model: String?) async throws
func setSessionThinking(sessionKey: String, thinkingLevel: String) async throws
func requestHealth(timeoutMs: Int) async throws -> Bool
func events() -> AsyncStream<OpenClawChatTransportEvent>
@@ -45,25 +42,4 @@ extension OpenClawChatTransport {
code: 0,
userInfo: [NSLocalizedDescriptionKey: "sessions.list not supported by this transport"])
}
public func listModels() async throws -> [OpenClawChatModelChoice] {
throw NSError(
domain: "OpenClawChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "models.list not supported by this transport"])
}
public func setSessionModel(sessionKey _: String, model _: String?) async throws {
throw NSError(
domain: "OpenClawChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "sessions.patch(model) not supported by this transport"])
}
public func setSessionThinking(sessionKey _: String, thinkingLevel _: String) async throws {
throw NSError(
domain: "OpenClawChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "sessions.patch(thinkingLevel) not supported by this transport"])
}
}

View File

@@ -15,13 +15,9 @@ private let chatUILogger = Logger(subsystem: "ai.openclaw", category: "OpenClawC
@MainActor
@Observable
public final class OpenClawChatViewModel {
public static let defaultModelSelectionID = "__default__"
public private(set) var messages: [OpenClawChatMessage] = []
public var input: String = ""
public private(set) var thinkingLevel: String
public private(set) var modelSelectionID: String = "__default__"
public private(set) var modelChoices: [OpenClawChatModelChoice] = []
public var thinkingLevel: String = "off"
public private(set) var isLoading = false
public private(set) var isSending = false
public private(set) var isAborting = false
@@ -36,9 +32,6 @@ public final class OpenClawChatViewModel {
public private(set) var pendingToolCalls: [OpenClawChatPendingToolCall] = []
public private(set) var sessions: [OpenClawChatSessionEntry] = []
private let transport: any OpenClawChatTransport
private var sessionDefaults: OpenClawChatSessionsDefaults?
private let prefersExplicitThinkingLevel: Bool
private let onThinkingLevelChanged: (@MainActor @Sendable (String) -> Void)?
@ObservationIgnored
private nonisolated(unsafe) var eventTask: Task<Void, Never>?
@@ -49,17 +42,6 @@ public final class OpenClawChatViewModel {
@ObservationIgnored
private nonisolated(unsafe) var pendingRunTimeoutTasks: [String: Task<Void, Never>] = [:]
private let pendingRunTimeoutMs: UInt64 = 120_000
// Session switches can overlap in-flight picker patches, so stale completions
// must compare against the latest request and latest desired value for that session.
private var nextModelSelectionRequestID: UInt64 = 0
private var latestModelSelectionRequestIDsBySession: [String: UInt64] = [:]
private var latestModelSelectionIDsBySession: [String: String] = [:]
private var lastSuccessfulModelSelectionIDsBySession: [String: String] = [:]
private var inFlightModelPatchCountsBySession: [String: Int] = [:]
private var modelPatchWaitersBySession: [String: [CheckedContinuation<Void, Never>]] = [:]
private var nextThinkingSelectionRequestID: UInt64 = 0
private var latestThinkingSelectionRequestIDsBySession: [String: UInt64] = [:]
private var latestThinkingLevelsBySession: [String: String] = [:]
private var pendingToolCallsById: [String: OpenClawChatPendingToolCall] = [:] {
didSet {
@@ -70,18 +52,9 @@ public final class OpenClawChatViewModel {
private var lastHealthPollAt: Date?
public init(
sessionKey: String,
transport: any OpenClawChatTransport,
initialThinkingLevel: String? = nil,
onThinkingLevelChanged: (@MainActor @Sendable (String) -> Void)? = nil)
{
public init(sessionKey: String, transport: any OpenClawChatTransport) {
self.sessionKey = sessionKey
self.transport = transport
let normalizedThinkingLevel = Self.normalizedThinkingLevel(initialThinkingLevel)
self.thinkingLevel = normalizedThinkingLevel ?? "off"
self.prefersExplicitThinkingLevel = normalizedThinkingLevel != nil
self.onThinkingLevelChanged = onThinkingLevelChanged
self.eventTask = Task { [weak self] in
guard let self else { return }
@@ -126,14 +99,6 @@ public final class OpenClawChatViewModel {
Task { await self.performSwitchSession(to: sessionKey) }
}
public func selectThinkingLevel(_ level: String) {
Task { await self.performSelectThinkingLevel(level) }
}
public func selectModel(_ selectionID: String) {
Task { await self.performSelectModel(selectionID) }
}
public var sessionChoices: [OpenClawChatSessionEntry] {
let now = Date().timeIntervalSince1970 * 1000
let cutoff = now - (24 * 60 * 60 * 1000)
@@ -169,17 +134,6 @@ public final class OpenClawChatViewModel {
return result
}
public var showsModelPicker: Bool {
!self.modelChoices.isEmpty
}
public var defaultModelLabel: String {
guard let defaultModelID = self.normalizedModelSelectionID(self.sessionDefaults?.model) else {
return "Default"
}
return "Default: \(self.modelLabel(for: defaultModelID))"
}
public func addAttachments(urls: [URL]) {
Task { await self.loadAttachments(urls: urls) }
}
@@ -220,14 +174,11 @@ public final class OpenClawChatViewModel {
previous: self.messages,
incoming: Self.decodeMessages(payload.messages ?? []))
self.sessionId = payload.sessionId
if !self.prefersExplicitThinkingLevel,
let level = Self.normalizedThinkingLevel(payload.thinkingLevel)
{
if let level = payload.thinkingLevel, !level.isEmpty {
self.thinkingLevel = level
}
await self.pollHealthIfNeeded(force: true)
await self.fetchSessions(limit: 50)
await self.fetchModels()
self.errorText = nil
} catch {
self.errorText = error.localizedDescription
@@ -369,7 +320,6 @@ public final class OpenClawChatViewModel {
guard !self.isSending else { return }
let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty || !self.attachments.isEmpty else { return }
let sessionKey = self.sessionKey
guard self.healthOK else {
self.errorText = "Gateway health not OK; cannot send"
@@ -380,7 +330,6 @@ public final class OpenClawChatViewModel {
self.errorText = nil
let runId = UUID().uuidString
let messageText = trimmed.isEmpty && !self.attachments.isEmpty ? "See attached." : trimmed
let thinkingLevel = self.thinkingLevel
self.pendingRuns.insert(runId)
self.armPendingRunTimeout(runId: runId)
self.pendingToolCallsById = [:]
@@ -433,11 +382,10 @@ public final class OpenClawChatViewModel {
self.attachments = []
do {
await self.waitForPendingModelPatches(in: sessionKey)
let response = try await self.transport.sendMessage(
sessionKey: sessionKey,
sessionKey: self.sessionKey,
message: messageText,
thinking: thinkingLevel,
thinking: self.thinkingLevel,
idempotencyKey: runId,
attachments: encodedAttachments)
if response.runId != runId {
@@ -474,17 +422,6 @@ public final class OpenClawChatViewModel {
do {
let res = try await self.transport.listSessions(limit: limit)
self.sessions = res.sessions
self.sessionDefaults = res.defaults
self.syncSelectedModel()
} catch {
// Best-effort.
}
}
private func fetchModels() async {
do {
self.modelChoices = try await self.transport.listModels()
self.syncSelectedModel()
} catch {
// Best-effort.
}
@@ -495,106 +432,9 @@ public final class OpenClawChatViewModel {
guard !next.isEmpty else { return }
guard next != self.sessionKey else { return }
self.sessionKey = next
self.modelSelectionID = Self.defaultModelSelectionID
await self.bootstrap()
}
private func performSelectThinkingLevel(_ level: String) async {
let next = Self.normalizedThinkingLevel(level) ?? "off"
guard next != self.thinkingLevel else { return }
let sessionKey = self.sessionKey
self.thinkingLevel = next
self.onThinkingLevelChanged?(next)
self.nextThinkingSelectionRequestID &+= 1
let requestID = self.nextThinkingSelectionRequestID
self.latestThinkingSelectionRequestIDsBySession[sessionKey] = requestID
self.latestThinkingLevelsBySession[sessionKey] = next
do {
try await self.transport.setSessionThinking(sessionKey: sessionKey, thinkingLevel: next)
guard requestID == self.latestThinkingSelectionRequestIDsBySession[sessionKey] else {
let latest = self.latestThinkingLevelsBySession[sessionKey] ?? next
guard latest != next else { return }
try? await self.transport.setSessionThinking(sessionKey: sessionKey, thinkingLevel: latest)
return
}
} catch {
guard sessionKey == self.sessionKey,
requestID == self.latestThinkingSelectionRequestIDsBySession[sessionKey]
else { return }
// Best-effort. Persisting the user's local preference matters more than a patch error here.
}
}
private func performSelectModel(_ selectionID: String) async {
let next = self.normalizedSelectionID(selectionID)
guard next != self.modelSelectionID else { return }
let sessionKey = self.sessionKey
let previous = self.modelSelectionID
let previousRequestID = self.latestModelSelectionRequestIDsBySession[sessionKey]
self.nextModelSelectionRequestID &+= 1
let requestID = self.nextModelSelectionRequestID
let nextModelRef = self.modelRef(forSelectionID: next)
self.latestModelSelectionRequestIDsBySession[sessionKey] = requestID
self.latestModelSelectionIDsBySession[sessionKey] = next
self.beginModelPatch(for: sessionKey)
self.modelSelectionID = next
self.errorText = nil
defer { self.endModelPatch(for: sessionKey) }
do {
try await self.transport.setSessionModel(
sessionKey: sessionKey,
model: nextModelRef)
guard requestID == self.latestModelSelectionRequestIDsBySession[sessionKey] else {
self.applySuccessfulModelSelection(next, sessionKey: sessionKey, syncSelection: false)
return
}
self.applySuccessfulModelSelection(next, sessionKey: sessionKey, syncSelection: true)
} catch {
guard requestID == self.latestModelSelectionRequestIDsBySession[sessionKey] else { return }
self.latestModelSelectionIDsBySession[sessionKey] = previous
if let previousRequestID {
self.latestModelSelectionRequestIDsBySession[sessionKey] = previousRequestID
} else {
self.latestModelSelectionRequestIDsBySession.removeValue(forKey: sessionKey)
}
if self.lastSuccessfulModelSelectionIDsBySession[sessionKey] == previous {
self.applySuccessfulModelSelection(previous, sessionKey: sessionKey, syncSelection: sessionKey == self.sessionKey)
}
guard sessionKey == self.sessionKey else { return }
self.modelSelectionID = previous
self.errorText = error.localizedDescription
chatUILogger.error("sessions.patch(model) failed \(error.localizedDescription, privacy: .public)")
}
}
private func beginModelPatch(for sessionKey: String) {
self.inFlightModelPatchCountsBySession[sessionKey, default: 0] += 1
}
private func endModelPatch(for sessionKey: String) {
let remaining = max(0, (self.inFlightModelPatchCountsBySession[sessionKey] ?? 0) - 1)
if remaining == 0 {
self.inFlightModelPatchCountsBySession.removeValue(forKey: sessionKey)
let waiters = self.modelPatchWaitersBySession.removeValue(forKey: sessionKey) ?? []
for waiter in waiters {
waiter.resume()
}
return
}
self.inFlightModelPatchCountsBySession[sessionKey] = remaining
}
private func waitForPendingModelPatches(in sessionKey: String) async {
guard (self.inFlightModelPatchCountsBySession[sessionKey] ?? 0) > 0 else { return }
await withCheckedContinuation { continuation in
self.modelPatchWaitersBySession[sessionKey, default: []].append(continuation)
}
}
private func placeholderSession(key: String) -> OpenClawChatSessionEntry {
OpenClawChatSessionEntry(
key: key,
@@ -613,159 +453,10 @@ public final class OpenClawChatViewModel {
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: nil,
model: nil,
contextTokens: nil)
}
private func syncSelectedModel() {
let currentSession = self.sessions.first(where: { $0.key == self.sessionKey })
let explicitModelID = self.normalizedModelSelectionID(
currentSession?.model,
provider: currentSession?.modelProvider)
if let explicitModelID {
self.lastSuccessfulModelSelectionIDsBySession[self.sessionKey] = explicitModelID
self.modelSelectionID = explicitModelID
return
}
self.lastSuccessfulModelSelectionIDsBySession[self.sessionKey] = Self.defaultModelSelectionID
self.modelSelectionID = Self.defaultModelSelectionID
}
private func normalizedSelectionID(_ selectionID: String) -> String {
let trimmed = selectionID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return Self.defaultModelSelectionID }
return trimmed
}
private func normalizedModelSelectionID(_ modelID: String?, provider: String? = nil) -> String? {
guard let modelID else { return nil }
let trimmed = modelID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if let provider = Self.normalizedProvider(provider) {
let providerQualified = Self.providerQualifiedModelSelectionID(modelID: trimmed, provider: provider)
if let match = self.modelChoices.first(where: {
$0.selectionID == providerQualified ||
($0.modelID == trimmed && Self.normalizedProvider($0.provider) == provider)
}) {
return match.selectionID
}
return providerQualified
}
if self.modelChoices.contains(where: { $0.selectionID == trimmed }) {
return trimmed
}
let matches = self.modelChoices.filter { $0.modelID == trimmed || $0.selectionID == trimmed }
if matches.count == 1 {
return matches[0].selectionID
}
return trimmed
}
private func modelRef(forSelectionID selectionID: String) -> String? {
let normalized = self.normalizedSelectionID(selectionID)
if normalized == Self.defaultModelSelectionID {
return nil
}
return normalized
}
private func modelLabel(for modelID: String) -> String {
self.modelChoices.first(where: { $0.selectionID == modelID || $0.modelID == modelID })?.displayLabel ??
modelID
}
private func applySuccessfulModelSelection(_ selectionID: String, sessionKey: String, syncSelection: Bool) {
self.lastSuccessfulModelSelectionIDsBySession[sessionKey] = selectionID
let resolved = self.resolvedSessionModelIdentity(forSelectionID: selectionID)
self.updateCurrentSessionModel(
modelID: resolved.modelID,
modelProvider: resolved.modelProvider,
sessionKey: sessionKey,
syncSelection: syncSelection)
}
private func resolvedSessionModelIdentity(forSelectionID selectionID: String) -> (modelID: String?, modelProvider: String?) {
guard let modelRef = self.modelRef(forSelectionID: selectionID) else {
return (nil, nil)
}
if let choice = self.modelChoices.first(where: { $0.selectionID == modelRef }) {
return (choice.modelID, Self.normalizedProvider(choice.provider))
}
return (modelRef, nil)
}
private static func normalizedProvider(_ provider: String?) -> String? {
let trimmed = provider?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let trimmed, !trimmed.isEmpty else { return nil }
return trimmed
}
private static func providerQualifiedModelSelectionID(modelID: String, provider: String) -> String {
let providerPrefix = "\(provider)/"
if modelID.hasPrefix(providerPrefix) {
return modelID
}
return "\(provider)/\(modelID)"
}
private func updateCurrentSessionModel(
modelID: String?,
modelProvider: String?,
sessionKey: String,
syncSelection: Bool)
{
if let index = self.sessions.firstIndex(where: { $0.key == sessionKey }) {
let current = self.sessions[index]
self.sessions[index] = OpenClawChatSessionEntry(
key: current.key,
kind: current.kind,
displayName: current.displayName,
surface: current.surface,
subject: current.subject,
room: current.room,
space: current.space,
updatedAt: current.updatedAt,
sessionId: current.sessionId,
systemSent: current.systemSent,
abortedLastRun: current.abortedLastRun,
thinkingLevel: current.thinkingLevel,
verboseLevel: current.verboseLevel,
inputTokens: current.inputTokens,
outputTokens: current.outputTokens,
totalTokens: current.totalTokens,
modelProvider: modelProvider,
model: modelID,
contextTokens: current.contextTokens)
} else {
let placeholder = self.placeholderSession(key: sessionKey)
self.sessions.append(
OpenClawChatSessionEntry(
key: placeholder.key,
kind: placeholder.kind,
displayName: placeholder.displayName,
surface: placeholder.surface,
subject: placeholder.subject,
room: placeholder.room,
space: placeholder.space,
updatedAt: placeholder.updatedAt,
sessionId: placeholder.sessionId,
systemSent: placeholder.systemSent,
abortedLastRun: placeholder.abortedLastRun,
thinkingLevel: placeholder.thinkingLevel,
verboseLevel: placeholder.verboseLevel,
inputTokens: placeholder.inputTokens,
outputTokens: placeholder.outputTokens,
totalTokens: placeholder.totalTokens,
modelProvider: modelProvider,
model: modelID,
contextTokens: placeholder.contextTokens))
}
if syncSelection {
self.syncSelectedModel()
}
}
private func handleTransportEvent(_ evt: OpenClawChatTransportEvent) {
switch evt {
case let .health(ok):
@@ -882,9 +573,7 @@ public final class OpenClawChatViewModel {
previous: self.messages,
incoming: Self.decodeMessages(payload.messages ?? []))
self.sessionId = payload.sessionId
if !self.prefersExplicitThinkingLevel,
let level = Self.normalizedThinkingLevel(payload.thinkingLevel)
{
if let level = payload.thinkingLevel, !level.isEmpty {
self.thinkingLevel = level
}
} catch {
@@ -993,13 +682,4 @@ public final class OpenClawChatViewModel {
nil
#endif
}
private static func normalizedThinkingLevel(_ level: String?) -> String? {
guard let level else { return nil }
let trimmed = level.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive"].contains(trimmed) else {
return nil
}
return trimmed
}
}

View File

@@ -131,41 +131,6 @@ private let defaultOperatorConnectScopes: [String] = [
"operator.pairing",
]
private enum GatewayConnectErrorCodes {
static let authTokenMismatch = "AUTH_TOKEN_MISMATCH"
static let authDeviceTokenMismatch = "AUTH_DEVICE_TOKEN_MISMATCH"
static let authTokenMissing = "AUTH_TOKEN_MISSING"
static let authPasswordMissing = "AUTH_PASSWORD_MISSING"
static let authPasswordMismatch = "AUTH_PASSWORD_MISMATCH"
static let authRateLimited = "AUTH_RATE_LIMITED"
static let pairingRequired = "PAIRING_REQUIRED"
static let controlUiDeviceIdentityRequired = "CONTROL_UI_DEVICE_IDENTITY_REQUIRED"
static let deviceIdentityRequired = "DEVICE_IDENTITY_REQUIRED"
}
private struct GatewayConnectAuthError: LocalizedError {
let message: String
let detailCode: String?
let canRetryWithDeviceToken: Bool
var errorDescription: String? { self.message }
var isNonRecoverable: Bool {
switch self.detailCode {
case GatewayConnectErrorCodes.authTokenMissing,
GatewayConnectErrorCodes.authPasswordMissing,
GatewayConnectErrorCodes.authPasswordMismatch,
GatewayConnectErrorCodes.authRateLimited,
GatewayConnectErrorCodes.pairingRequired,
GatewayConnectErrorCodes.controlUiDeviceIdentityRequired,
GatewayConnectErrorCodes.deviceIdentityRequired:
return true
default:
return false
}
}
}
public actor GatewayChannelActor {
private let logger = Logger(subsystem: "ai.openclaw", category: "gateway")
private var task: WebSocketTaskBox?
@@ -195,9 +160,6 @@ public actor GatewayChannelActor {
private var watchdogTask: Task<Void, Never>?
private var tickTask: Task<Void, Never>?
private var keepaliveTask: Task<Void, Never>?
private var pendingDeviceTokenRetry = false
private var deviceTokenRetryBudgetUsed = false
private var reconnectPausedForAuthFailure = false
private let defaultRequestTimeoutMs: Double = 15000
private let pushHandler: (@Sendable (GatewayPush) async -> Void)?
private let connectOptions: GatewayConnectOptions?
@@ -270,19 +232,10 @@ public actor GatewayChannelActor {
while self.shouldReconnect {
guard await self.sleepUnlessCancelled(nanoseconds: 30 * 1_000_000_000) else { return } // 30s cadence
guard self.shouldReconnect else { return }
if self.reconnectPausedForAuthFailure { continue }
if self.connected { continue }
do {
try await self.connect()
} catch {
if self.shouldPauseReconnectAfterAuthFailure(error) {
self.reconnectPausedForAuthFailure = true
self.logger.error(
"gateway watchdog reconnect paused for non-recoverable auth failure " +
"\(error.localizedDescription, privacy: .public)"
)
continue
}
let wrapped = self.wrap(error, context: "gateway watchdog reconnect")
self.logger.error("gateway watchdog reconnect failed \(wrapped.localizedDescription, privacy: .public)")
}
@@ -314,12 +267,7 @@ public actor GatewayChannelActor {
},
operation: { try await self.sendConnect() })
} catch {
let wrapped: Error
if let authError = error as? GatewayConnectAuthError {
wrapped = authError
} else {
wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
}
let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
self.connected = false
self.task?.cancel(with: .goingAway, reason: nil)
await self.disconnectHandler?("connect failed: \(wrapped.localizedDescription)")
@@ -333,7 +281,6 @@ public actor GatewayChannelActor {
}
self.listen()
self.connected = true
self.reconnectPausedForAuthFailure = false
self.backoffMs = 500
self.lastSeq = nil
self.startKeepalive()
@@ -424,18 +371,11 @@ public actor GatewayChannelActor {
(includeDeviceIdentity && identity != nil)
? DeviceAuthStore.loadToken(deviceId: identity!.deviceId, role: role)?.token
: nil
let shouldUseDeviceRetryToken =
includeDeviceIdentity && self.pendingDeviceTokenRetry &&
storedToken != nil && self.token != nil && self.isTrustedDeviceRetryEndpoint()
if shouldUseDeviceRetryToken {
self.pendingDeviceTokenRetry = false
}
// Keep shared credentials explicit when provided. Device token retry is attached
// only on a bounded second attempt after token mismatch.
let authToken = self.token ?? (includeDeviceIdentity ? storedToken : nil)
let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
// If we're not sending a device identity, a device token can't be validated server-side.
// In that mode we always use the shared gateway token/password.
let authToken = includeDeviceIdentity ? (storedToken ?? self.token) : self.token
let authSource: GatewayAuthSource
if authDeviceToken != nil || (self.token == nil && storedToken != nil) {
if storedToken != nil {
authSource = .deviceToken
} else if authToken != nil {
authSource = .sharedToken
@@ -446,12 +386,9 @@ public actor GatewayChannelActor {
}
self.lastAuthSource = authSource
self.logger.info("gateway connect auth=\(authSource.rawValue, privacy: .public)")
let canFallbackToShared = includeDeviceIdentity && storedToken != nil && self.token != nil
if let authToken {
var auth: [String: ProtoAnyCodable] = ["token": ProtoAnyCodable(authToken)]
if let authDeviceToken {
auth["deviceToken"] = ProtoAnyCodable(authDeviceToken)
}
params["auth"] = ProtoAnyCodable(auth)
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])
} else if let password = self.password {
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
}
@@ -489,24 +426,11 @@ public actor GatewayChannelActor {
do {
let response = try await self.waitForConnectResponse(reqId: reqId)
try await self.handleConnectResponse(response, identity: identity, role: role)
self.pendingDeviceTokenRetry = false
self.deviceTokenRetryBudgetUsed = false
} catch {
let shouldRetryWithDeviceToken = self.shouldRetryWithStoredDeviceToken(
error: error,
explicitGatewayToken: self.token,
storedToken: storedToken,
attemptedDeviceTokenRetry: authDeviceToken != nil)
if shouldRetryWithDeviceToken {
self.pendingDeviceTokenRetry = true
self.deviceTokenRetryBudgetUsed = true
self.backoffMs = min(self.backoffMs, 250)
} else if authDeviceToken != nil,
let identity,
self.shouldClearStoredDeviceTokenAfterRetry(error)
{
// Retry failed with an explicit device-token mismatch; clear stale local token.
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
if canFallbackToShared {
if let identity {
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
}
}
throw error
}
@@ -519,13 +443,7 @@ public actor GatewayChannelActor {
) async throws {
if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
let details = res.error?["details"]?.value as? [String: ProtoAnyCodable]
let detailCode = details?["code"]?.value as? String
let canRetryWithDeviceToken = details?["canRetryWithDeviceToken"]?.value as? Bool ?? false
throw GatewayConnectAuthError(
message: msg,
detailCode: detailCode,
canRetryWithDeviceToken: canRetryWithDeviceToken)
throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg])
}
guard let payload = res.payload else {
throw NSError(
@@ -698,91 +616,19 @@ public actor GatewayChannelActor {
private func scheduleReconnect() async {
guard self.shouldReconnect else { return }
guard !self.reconnectPausedForAuthFailure else { return }
let delay = self.backoffMs / 1000
self.backoffMs = min(self.backoffMs * 2, 30000)
guard await self.sleepUnlessCancelled(nanoseconds: UInt64(delay * 1_000_000_000)) else { return }
guard self.shouldReconnect else { return }
guard !self.reconnectPausedForAuthFailure else { return }
do {
try await self.connect()
} catch {
if self.shouldPauseReconnectAfterAuthFailure(error) {
self.reconnectPausedForAuthFailure = true
self.logger.error(
"gateway reconnect paused for non-recoverable auth failure " +
"\(error.localizedDescription, privacy: .public)"
)
return
}
let wrapped = self.wrap(error, context: "gateway reconnect")
self.logger.error("gateway reconnect failed \(wrapped.localizedDescription, privacy: .public)")
await self.scheduleReconnect()
}
}
private func shouldRetryWithStoredDeviceToken(
error: Error,
explicitGatewayToken: String?,
storedToken: String?,
attemptedDeviceTokenRetry: Bool
) -> Bool {
if self.deviceTokenRetryBudgetUsed {
return false
}
if attemptedDeviceTokenRetry {
return false
}
guard explicitGatewayToken != nil, storedToken != nil else {
return false
}
guard self.isTrustedDeviceRetryEndpoint() else {
return false
}
guard let authError = error as? GatewayConnectAuthError else {
return false
}
return authError.canRetryWithDeviceToken ||
authError.detailCode == GatewayConnectErrorCodes.authTokenMismatch
}
private func shouldPauseReconnectAfterAuthFailure(_ error: Error) -> Bool {
guard let authError = error as? GatewayConnectAuthError else {
return false
}
if authError.isNonRecoverable {
return true
}
if authError.detailCode == GatewayConnectErrorCodes.authTokenMismatch &&
self.deviceTokenRetryBudgetUsed && !self.pendingDeviceTokenRetry
{
return true
}
return false
}
private func shouldClearStoredDeviceTokenAfterRetry(_ error: Error) -> Bool {
guard let authError = error as? GatewayConnectAuthError else {
return false
}
return authError.detailCode == GatewayConnectErrorCodes.authDeviceTokenMismatch
}
private func isTrustedDeviceRetryEndpoint() -> Bool {
// This client currently treats loopback as the only trusted retry target.
// Unlike the Node gateway client, it does not yet expose a pinned TLS-fingerprint
// trust path for remote retry, so remote fallback remains disabled by default.
guard let host = self.url.host?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
!host.isEmpty
else {
return false
}
if host == "localhost" || host == "::1" || host == "127.0.0.1" || host.hasPrefix("127.") {
return true
}
return false
}
private nonisolated func sleepUnlessCancelled(nanoseconds: UInt64) async -> Bool {
do {
try await Task.sleep(nanoseconds: nanoseconds)
@@ -910,8 +756,7 @@ public actor GatewayChannelActor {
return (id: id, data: data)
} catch {
self.logger.error(
"gateway \(kind) encode failed \(method, privacy: .public) " +
"error=\(error.localizedDescription, privacy: .public)")
"gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
throw error
}
}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>OpenClaw</title>
<title>Canvas</title>
<script>
(() => {
try {
@@ -15,358 +15,99 @@
}
if (/android/i.test(navigator.userAgent || '')) {
document.documentElement.dataset.platform = 'android';
} else {
document.documentElement.dataset.platform = 'ios';
}
} catch (_) {}
})();
</script>
<style>
:root {
color-scheme: dark;
--bg: #06070b;
--panel: rgba(14, 17, 24, 0.74);
--panel-strong: rgba(18, 23, 32, 0.86);
--line: rgba(255, 255, 255, 0.1);
--line-strong: rgba(255, 255, 255, 0.18);
--text: rgba(255, 255, 255, 0.96);
--muted: rgba(222, 229, 239, 0.72);
--soft: rgba(222, 229, 239, 0.5);
--accent: #8ec5ff;
--accent-strong: #5b9dff;
--accent-warm: #ff9159;
--accent-rose: #ff5fa2;
--state: #7d8ca3;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
:root { color-scheme: dark; }
@media (prefers-reduced-motion: reduce) {
body::before, body::after { animation: none !important; }
}
html, body {
height: 100%;
margin: 0;
}
html,body { height:100%; margin:0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", system-ui, sans-serif;
background:
radial-gradient(900px 640px at 12% 10%, rgba(91, 157, 255, 0.36), rgba(0, 0, 0, 0) 58%),
radial-gradient(840px 600px at 88% 16%, rgba(255, 95, 162, 0.24), rgba(0, 0, 0, 0) 62%),
radial-gradient(960px 720px at 50% 100%, rgba(255, 145, 89, 0.18), rgba(0, 0, 0, 0) 60%),
linear-gradient(180deg, #090b11 0%, #05060a 100%);
color: var(--text);
radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.18), rgba(0,0,0,0) 55%),
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.14), rgba(0,0,0,0) 60%),
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.10), rgba(0,0,0,0) 60%),
#000;
overflow: hidden;
}
body::before,
body::after {
content: "";
position: fixed;
inset: -10%;
pointer-events: none;
:root[data-platform="android"] body {
background:
radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.62), rgba(0,0,0,0) 55%),
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.52), rgba(0,0,0,0) 60%),
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.48), rgba(0,0,0,0) 60%),
#0b1328;
}
body::before {
content:"";
position: fixed;
inset: -20%;
background:
repeating-linear-gradient(
90deg,
rgba(255, 255, 255, 0.025) 0,
rgba(255, 255, 255, 0.025) 1px,
transparent 1px,
transparent 52px
),
repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0.025) 0,
rgba(255, 255, 255, 0.025) 1px,
transparent 1px,
transparent 52px
);
opacity: 0.42;
transform: rotate(-7deg);
repeating-linear-gradient(0deg, rgba(255,255,255,0.03) 0, rgba(255,255,255,0.03) 1px,
transparent 1px, transparent 48px),
repeating-linear-gradient(90deg, rgba(255,255,255,0.03) 0, rgba(255,255,255,0.03) 1px,
transparent 1px, transparent 48px);
transform: translate3d(0,0,0) rotate(-7deg);
will-change: transform, opacity;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
opacity: 0.45;
pointer-events: none;
animation: openclaw-grid-drift 140s ease-in-out infinite alternate;
}
:root[data-platform="android"] body::before { opacity: 0.80; }
body::after {
content:"";
position: fixed;
inset: -35%;
background:
radial-gradient(700px 460px at 20% 18%, rgba(142, 197, 255, 0.18), rgba(0, 0, 0, 0) 62%),
radial-gradient(720px 520px at 84% 20%, rgba(255, 95, 162, 0.14), rgba(0, 0, 0, 0) 66%),
radial-gradient(860px 620px at 52% 88%, rgba(255, 145, 89, 0.14), rgba(0, 0, 0, 0) 64%);
radial-gradient(900px 700px at 30% 30%, rgba(42,113,255,0.16), rgba(0,0,0,0) 60%),
radial-gradient(800px 650px at 70% 35%, rgba(255,0,138,0.12), rgba(0,0,0,0) 62%),
radial-gradient(900px 800px at 55% 75%, rgba(0,209,255,0.10), rgba(0,0,0,0) 62%);
filter: blur(28px);
opacity: 0.95;
opacity: 0.52;
will-change: transform, opacity;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
transform: translate3d(0,0,0);
pointer-events: none;
animation: openclaw-glow-drift 110s ease-in-out infinite alternate;
}
:root[data-platform="android"] body::after { opacity: 0.85; }
@supports (mix-blend-mode: screen) {
body::after { mix-blend-mode: screen; }
}
@supports not (mix-blend-mode: screen) {
body::after { opacity: 0.70; }
}
@keyframes openclaw-grid-drift {
0% { transform: translate3d(-12px, 8px, 0) rotate(-7deg); opacity: 0.40; }
50% { transform: translate3d( 10px,-7px, 0) rotate(-6.6deg); opacity: 0.56; }
100% { transform: translate3d(-8px, 6px, 0) rotate(-7.2deg); opacity: 0.42; }
}
@keyframes openclaw-glow-drift {
0% { transform: translate3d(-18px, 12px, 0) scale(1.02); opacity: 0.40; }
50% { transform: translate3d( 14px,-10px, 0) scale(1.05); opacity: 0.52; }
100% { transform: translate3d(-10px, 8px, 0) scale(1.03); opacity: 0.43; }
}
body[data-state="connected"] { --state: #61d58b; }
body[data-state="connecting"] { --state: #ffd05f; }
body[data-state="error"] { --state: #ff6d6d; }
body[data-state="offline"] { --state: #95a3b9; }
canvas {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
display: block;
display:block;
width:100vw;
height:100vh;
touch-action: none;
z-index: 1;
}
#openclaw-home {
position: fixed;
inset: 0;
z-index: 2;
display: flex;
align-items: flex-start;
justify-content: center;
padding: calc(var(--safe-top) + 18px) 16px calc(var(--safe-bottom) + 18px);
box-sizing: border-box;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
.shell {
width: min(100%, 760px);
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 16px;
min-height: 100%;
box-sizing: border-box;
}
.hero {
position: relative;
overflow: hidden;
border-radius: 28px;
background: linear-gradient(180deg, rgba(18, 24, 34, 0.86), rgba(10, 13, 19, 0.94));
border: 1px solid var(--line);
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.42);
padding: 22px 22px 18px;
}
.hero::before {
content: "";
position: absolute;
inset: -30% auto auto -20%;
width: 240px;
height: 240px;
border-radius: 999px;
background: radial-gradient(circle, rgba(142, 197, 255, 0.18), rgba(0, 0, 0, 0));
pointer-events: none;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 8px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
color: var(--muted);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
max-width: 100%;
box-sizing: border-box;
}
.eyebrow-dot {
flex: 0 0 auto;
width: 9px;
height: 9px;
border-radius: 999px;
background: var(--state);
box-shadow: 0 0 18px color-mix(in srgb, var(--state) 68%, transparent);
}
#openclaw-home-eyebrow {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hero h1 {
margin: 18px 0 0;
font-size: clamp(32px, 7vw, 52px);
line-height: 0.98;
letter-spacing: -0.04em;
}
.hero p {
margin: 14px 0 0;
font-size: 16px;
line-height: 1.5;
color: var(--muted);
max-width: 32rem;
}
.hero-grid {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 12px;
margin-top: 22px;
}
.meta-card,
.agent-card {
border-radius: 22px;
background: var(--panel);
border: 1px solid var(--line);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.meta-card {
padding: 16px 16px 15px;
}
.meta-label {
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--soft);
}
.meta-value {
margin-top: 8px;
font-size: 24px;
font-weight: 700;
letter-spacing: -0.03em;
overflow-wrap: anywhere;
}
.meta-subtitle {
margin-top: 6px;
color: var(--muted);
font-size: 13px;
line-height: 1.4;
}
.agent-focus {
display: flex;
align-items: flex-start;
gap: 14px;
margin-top: 8px;
}
.agent-badge {
width: 56px;
height: 56px;
border-radius: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
:root[data-platform="android"] #openclaw-canvas {
background:
linear-gradient(135deg, rgba(142, 197, 255, 0.22), rgba(91, 157, 255, 0.1)),
rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.12);
font-size: 24px;
font-weight: 700;
radial-gradient(1100px 800px at 20% 15%, rgba(42, 113, 255, 0.78), rgba(0,0,0,0) 58%),
radial-gradient(900px 650px at 82% 28%, rgba(255, 0, 138, 0.66), rgba(0,0,0,0) 62%),
radial-gradient(1000px 900px at 60% 88%, rgba(0, 209, 255, 0.58), rgba(0,0,0,0) 62%),
#141c33;
}
.agent-focus .name {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.03em;
overflow-wrap: anywhere;
}
.agent-focus .caption {
margin-top: 4px;
font-size: 13px;
color: var(--muted);
}
.section {
padding: 16px 16px 14px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.section-title {
font-size: 14px;
font-weight: 700;
color: var(--muted);
}
.section-count {
font-size: 12px;
font-weight: 700;
color: var(--soft);
}
.agent-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.agent-card {
padding: 13px 13px 12px;
}
.agent-card.active {
background: var(--panel-strong);
border-color: var(--line-strong);
box-shadow: inset 0 0 0 1px rgba(142, 197, 255, 0.12);
}
.agent-row {
display: flex;
align-items: center;
gap: 10px;
}
.agent-row .badge {
width: 38px;
height: 38px;
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
font-size: 16px;
font-weight: 700;
}
.agent-row .name {
font-size: 15px;
font-weight: 700;
line-height: 1.2;
overflow-wrap: anywhere;
}
.agent-row .caption {
margin-top: 3px;
font-size: 12px;
color: var(--muted);
}
.empty-state {
padding: 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.03);
border: 1px dashed rgba(255, 255, 255, 0.12);
color: var(--muted);
font-size: 14px;
line-height: 1.45;
}
.footer-note {
margin-top: 12px;
color: var(--soft);
font-size: 12px;
line-height: 1.45;
}
#openclaw-status {
position: fixed;
inset: 0;
@@ -374,174 +115,41 @@
align-items: center;
justify-content: flex-start;
flex-direction: column;
padding-top: calc(var(--safe-top) + 18px);
padding-top: calc(20px + env(safe-area-inset-top, 0px));
pointer-events: none;
z-index: 3;
}
#openclaw-status .card {
text-align: center;
padding: 16px 18px;
border-radius: 14px;
background: rgba(18, 18, 22, 0.46);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.55);
background: rgba(18, 18, 22, 0.42);
border: 1px solid rgba(255,255,255,0.08);
box-shadow: 0 18px 60px rgba(0,0,0,0.55);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
}
#openclaw-status .title {
font: 600 20px -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", system-ui, sans-serif;
color: rgba(255, 255, 255, 0.92);
letter-spacing: 0.2px;
color: rgba(255,255,255,0.92);
text-shadow: 0 0 22px rgba(42, 113, 255, 0.35);
}
#openclaw-status .subtitle {
margin-top: 6px;
font: 500 12px -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
color: rgba(255, 255, 255, 0.58);
}
@media (max-width: 640px) {
#openclaw-home {
padding-left: 12px;
padding-right: 12px;
}
.hero {
border-radius: 24px;
padding: 18px 16px 16px;
}
.hero-grid,
.agent-grid {
grid-template-columns: 1fr;
}
.hero h1 {
font-size: 34px;
}
}
@media (max-height: 760px) {
#openclaw-home {
padding-top: calc(var(--safe-top) + 14px);
padding-bottom: calc(var(--safe-bottom) + 12px);
}
.shell {
gap: 12px;
}
.hero {
border-radius: 24px;
padding: 16px 15px 15px;
}
.hero h1 {
margin-top: 14px;
font-size: clamp(28px, 8vw, 38px);
}
.hero p {
margin-top: 10px;
font-size: 15px;
line-height: 1.42;
}
.hero-grid {
margin-top: 18px;
}
.meta-card {
padding: 14px 14px 13px;
}
.meta-value {
font-size: 22px;
}
.agent-badge {
width: 50px;
height: 50px;
border-radius: 16px;
font-size: 22px;
}
.agent-focus .name {
font-size: 20px;
}
.section {
padding: 14px 14px 12px;
}
.section-header {
margin-bottom: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
body::before,
body::after {
animation: none !important;
}
color: rgba(255,255,255,0.58);
}
</style>
</head>
<body data-state="offline">
<body>
<canvas id="openclaw-canvas"></canvas>
<div id="openclaw-home">
<div class="shell">
<div class="hero">
<div class="eyebrow">
<span class="eyebrow-dot"></span>
<span id="openclaw-home-eyebrow">Welcome to OpenClaw</span>
</div>
<h1 id="openclaw-home-title">Your phone stays quiet until it is needed</h1>
<p id="openclaw-home-subtitle">
Pair this device to your gateway to wake it only for real work, keep a live agent overview handy, and avoid battery-draining background loops.
</p>
<div class="hero-grid">
<div class="meta-card">
<div class="meta-label">Gateway</div>
<div class="meta-value" id="openclaw-home-gateway">Gateway</div>
<div class="meta-subtitle" id="openclaw-home-gateway-caption">Connect to load your agents</div>
</div>
<div class="meta-card">
<div class="meta-label">Active Agent</div>
<div class="agent-focus">
<div class="agent-badge" id="openclaw-home-active-badge">OC</div>
<div>
<div class="name" id="openclaw-home-active-name">Main</div>
<div class="caption" id="openclaw-home-active-caption">Connect to load your agents</div>
</div>
</div>
</div>
</div>
</div>
<div class="meta-card section">
<div class="section-header">
<div class="section-title">Live agents</div>
<div class="section-count" id="openclaw-home-agent-count">0 agents</div>
</div>
<div class="agent-grid" id="openclaw-home-agent-grid"></div>
<div class="footer-note" id="openclaw-home-footer">
When connected, the gateway can wake the phone with a silent push instead of holding an always-on session.
</div>
</div>
</div>
</div>
<div id="openclaw-status">
<div class="card">
<div class="title" id="openclaw-status-title">Ready</div>
<div class="subtitle" id="openclaw-status-subtitle">Waiting for agent</div>
</div>
</div>
<script>
(() => {
const canvas = document.getElementById('openclaw-canvas');
@@ -549,20 +157,6 @@
const statusEl = document.getElementById('openclaw-status');
const titleEl = document.getElementById('openclaw-status-title');
const subtitleEl = document.getElementById('openclaw-status-subtitle');
const home = {
root: document.getElementById('openclaw-home'),
eyebrow: document.getElementById('openclaw-home-eyebrow'),
title: document.getElementById('openclaw-home-title'),
subtitle: document.getElementById('openclaw-home-subtitle'),
gateway: document.getElementById('openclaw-home-gateway'),
gatewayCaption: document.getElementById('openclaw-home-gateway-caption'),
activeBadge: document.getElementById('openclaw-home-active-badge'),
activeName: document.getElementById('openclaw-home-active-name'),
activeCaption: document.getElementById('openclaw-home-active-caption'),
agentCount: document.getElementById('openclaw-home-agent-count'),
agentGrid: document.getElementById('openclaw-home-agent-grid'),
footer: document.getElementById('openclaw-home-footer')
};
const debugStatusEnabledByQuery = (() => {
try {
const params = new URLSearchParams(window.location.search);
@@ -578,114 +172,54 @@
function resize() {
const dpr = window.devicePixelRatio || 1;
const width = Math.max(1, Math.floor(window.innerWidth * dpr));
const height = Math.max(1, Math.floor(window.innerHeight * dpr));
canvas.width = width;
canvas.height = height;
const w = Math.max(1, Math.floor(window.innerWidth * dpr));
const h = Math.max(1, Math.floor(window.innerHeight * dpr));
canvas.width = w;
canvas.height = h;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function setDebugStatusEnabled(enabled) {
debugStatusEnabled = !!enabled;
if (!debugStatusEnabled) {
statusEl.style.display = 'none';
}
}
function setStatus(title, subtitle) {
if (!debugStatusEnabled) return;
if (!title && !subtitle) {
statusEl.style.display = 'none';
return;
}
statusEl.style.display = 'flex';
if (typeof title === 'string') titleEl.textContent = title;
if (typeof subtitle === 'string') subtitleEl.textContent = subtitle;
}
function clearChildren(node) {
while (node.firstChild) node.removeChild(node.firstChild);
}
function createAgentCard(agent) {
const card = document.createElement('div');
card.className = `agent-card${agent.isActive ? ' active' : ''}`;
const row = document.createElement('div');
row.className = 'agent-row';
const badge = document.createElement('div');
badge.className = 'badge';
badge.textContent = agent.badge || 'OC';
const text = document.createElement('div');
const name = document.createElement('div');
name.className = 'name';
name.textContent = agent.name || agent.id || 'Agent';
const caption = document.createElement('div');
caption.className = 'caption';
caption.textContent = agent.caption || 'Ready';
text.appendChild(name);
text.appendChild(caption);
row.appendChild(badge);
row.appendChild(text);
card.appendChild(row);
return card;
}
function renderHome(state) {
if (!state || typeof state !== 'object') return;
document.body.dataset.state = state.gatewayState || 'offline';
home.root.style.display = 'flex';
home.eyebrow.textContent = state.eyebrow || 'Welcome to OpenClaw';
home.title.textContent = state.title || 'OpenClaw';
home.subtitle.textContent = state.subtitle || '';
home.gateway.textContent = state.gatewayLabel || 'Gateway';
home.gatewayCaption.textContent = state.gatewayState === 'connected'
? `${state.agentCount || 0} agent${state.agentCount === 1 ? '' : 's'} available`
: (state.activeAgentCaption || 'Connect to load your agents');
home.activeBadge.textContent = state.activeAgentBadge || 'OC';
home.activeName.textContent = state.activeAgentName || 'Main';
home.activeCaption.textContent = state.activeAgentCaption || '';
home.agentCount.textContent = `${state.agentCount || 0} agent${state.agentCount === 1 ? '' : 's'}`;
home.footer.textContent = state.footer || '';
clearChildren(home.agentGrid);
const agents = Array.isArray(state.agents) ? state.agents : [];
if (!agents.length) {
const empty = document.createElement('div');
empty.className = 'empty-state';
empty.textContent = state.gatewayState === 'connected'
? 'Your gateway is online. Agents will appear here as soon as the current scope reports them.'
: 'Connect this phone to your gateway and the live agent overview will appear here.';
home.agentGrid.appendChild(empty);
return;
}
agents.forEach((agent) => {
home.agentGrid.appendChild(createAgentCard(agent));
});
}
window.addEventListener('resize', resize);
resize();
if (!debugStatusEnabled) {
const setDebugStatusEnabled = (enabled) => {
debugStatusEnabled = !!enabled;
if (!statusEl) return;
if (!debugStatusEnabled) {
statusEl.style.display = 'none';
}
};
if (statusEl && !debugStatusEnabled) {
statusEl.style.display = 'none';
}
window.__openclaw = {
const api = {
canvas,
ctx,
setDebugStatusEnabled,
setStatus,
renderHome
setStatus: (title, subtitle) => {
if (!statusEl || !debugStatusEnabled) return;
if (!title && !subtitle) {
statusEl.style.display = 'none';
return;
}
statusEl.style.display = 'flex';
if (titleEl && typeof title === 'string') titleEl.textContent = title;
if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
if (!debugStatusEnabled) {
clearTimeout(window.__statusTimeout);
window.__statusTimeout = setTimeout(() => {
statusEl.style.display = 'none';
}, 3000);
} else {
clearTimeout(window.__statusTimeout);
}
}
};
window.__openclaw = api;
})();
</script>
</body>
</html>

View File

@@ -950,102 +950,6 @@ public struct NodeEventParams: Codable, Sendable {
}
}
public struct NodePendingDrainParams: Codable, Sendable {
public let maxitems: Int?
public init(
maxitems: Int?)
{
self.maxitems = maxitems
}
private enum CodingKeys: String, CodingKey {
case maxitems = "maxItems"
}
}
public struct NodePendingDrainResult: Codable, Sendable {
public let nodeid: String
public let revision: Int
public let items: [[String: AnyCodable]]
public let hasmore: Bool
public init(
nodeid: String,
revision: Int,
items: [[String: AnyCodable]],
hasmore: Bool)
{
self.nodeid = nodeid
self.revision = revision
self.items = items
self.hasmore = hasmore
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
case revision
case items
case hasmore = "hasMore"
}
}
public struct NodePendingEnqueueParams: Codable, Sendable {
public let nodeid: String
public let type: String
public let priority: String?
public let expiresinms: Int?
public let wake: Bool?
public init(
nodeid: String,
type: String,
priority: String?,
expiresinms: Int?,
wake: Bool?)
{
self.nodeid = nodeid
self.type = type
self.priority = priority
self.expiresinms = expiresinms
self.wake = wake
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
case type
case priority
case expiresinms = "expiresInMs"
case wake
}
}
public struct NodePendingEnqueueResult: Codable, Sendable {
public let nodeid: String
public let revision: Int
public let queued: [String: AnyCodable]
public let waketriggered: Bool
public init(
nodeid: String,
revision: Int,
queued: [String: AnyCodable],
waketriggered: Bool)
{
self.nodeid = nodeid
self.revision = revision
self.queued = queued
self.waketriggered = waketriggered
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
case revision
case queued
case waketriggered = "wakeTriggered"
}
}
public struct NodeInvokeRequestEvent: Codable, Sendable {
public let id: String
public let nodeid: String
@@ -1337,8 +1241,6 @@ public struct SessionsPatchParams: Codable, Sendable {
public let model: AnyCodable?
public let spawnedby: AnyCodable?
public let spawndepth: AnyCodable?
public let subagentrole: AnyCodable?
public let subagentcontrolscope: AnyCodable?
public let sendpolicy: AnyCodable?
public let groupactivation: AnyCodable?
@@ -1357,8 +1259,6 @@ public struct SessionsPatchParams: Codable, Sendable {
model: AnyCodable?,
spawnedby: AnyCodable?,
spawndepth: AnyCodable?,
subagentrole: AnyCodable?,
subagentcontrolscope: AnyCodable?,
sendpolicy: AnyCodable?,
groupactivation: AnyCodable?)
{
@@ -1376,8 +1276,6 @@ public struct SessionsPatchParams: Codable, Sendable {
self.model = model
self.spawnedby = spawnedby
self.spawndepth = spawndepth
self.subagentrole = subagentrole
self.subagentcontrolscope = subagentcontrolscope
self.sendpolicy = sendpolicy
self.groupactivation = groupactivation
}
@@ -1397,8 +1295,6 @@ public struct SessionsPatchParams: Codable, Sendable {
case model
case spawnedby = "spawnedBy"
case spawndepth = "spawnDepth"
case subagentrole = "subagentRole"
case subagentcontrolscope = "subagentControlScope"
case sendpolicy = "sendPolicy"
case groupactivation = "groupActivation"
}
@@ -3054,7 +2950,7 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
public struct ExecApprovalRequestParams: Codable, Sendable {
public let id: String?
public let command: String?
public let command: String
public let commandargv: [String]?
public let systemrunplan: [String: AnyCodable]?
public let env: [String: AnyCodable]?
@@ -3075,7 +2971,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public init(
id: String?,
command: String?,
command: String,
commandargv: [String]?,
systemrunplan: [String: AnyCodable]?,
env: [String: AnyCodable]?,

View File

@@ -41,67 +41,17 @@ private func sessionEntry(key: String, updatedAt: Double) -> OpenClawChatSession
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: nil,
model: nil,
contextTokens: nil)
}
private func sessionEntry(
key: String,
updatedAt: Double,
model: String?,
modelProvider: String? = nil) -> OpenClawChatSessionEntry
{
OpenClawChatSessionEntry(
key: key,
kind: nil,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: updatedAt,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: modelProvider,
model: model,
contextTokens: nil)
}
private func modelChoice(id: String, name: String, provider: String = "anthropic") -> OpenClawChatModelChoice {
OpenClawChatModelChoice(modelID: id, name: name, provider: provider, contextWindow: nil)
}
private func makeViewModel(
sessionKey: String = "main",
historyResponses: [OpenClawChatHistoryPayload],
sessionsResponses: [OpenClawChatSessionsListResponse] = [],
modelResponses: [[OpenClawChatModelChoice]] = [],
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil,
initialThinkingLevel: String? = nil,
onThinkingLevelChanged: (@MainActor @Sendable (String) -> Void)? = nil) async
-> (TestChatTransport, OpenClawChatViewModel)
sessionsResponses: [OpenClawChatSessionsListResponse] = []) async -> (TestChatTransport, OpenClawChatViewModel)
{
let transport = TestChatTransport(
historyResponses: historyResponses,
sessionsResponses: sessionsResponses,
modelResponses: modelResponses,
setSessionModelHook: setSessionModelHook,
setSessionThinkingHook: setSessionThinkingHook)
let vm = await MainActor.run {
OpenClawChatViewModel(
sessionKey: sessionKey,
transport: transport,
initialThinkingLevel: initialThinkingLevel,
onThinkingLevelChanged: onThinkingLevelChanged)
}
let transport = TestChatTransport(historyResponses: historyResponses, sessionsResponses: sessionsResponses)
let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: sessionKey, transport: transport) }
return (transport, vm)
}
@@ -175,60 +125,27 @@ private func emitExternalFinal(
errorMessage: nil)))
}
@MainActor
private final class CallbackBox {
var values: [String] = []
}
private actor AsyncGate {
private var continuation: CheckedContinuation<Void, Never>?
func wait() async {
await withCheckedContinuation { continuation in
self.continuation = continuation
}
}
func open() {
self.continuation?.resume()
self.continuation = nil
}
}
private actor TestChatTransportState {
var historyCallCount: Int = 0
var sessionsCallCount: Int = 0
var modelsCallCount: Int = 0
var sentRunIds: [String] = []
var sentThinkingLevels: [String] = []
var abortedRunIds: [String] = []
var patchedModels: [String?] = []
var patchedThinkingLevels: [String] = []
}
private final class TestChatTransport: @unchecked Sendable, OpenClawChatTransport {
private let state = TestChatTransportState()
private let historyResponses: [OpenClawChatHistoryPayload]
private let sessionsResponses: [OpenClawChatSessionsListResponse]
private let modelResponses: [[OpenClawChatModelChoice]]
private let setSessionModelHook: (@Sendable (String?) async throws -> Void)?
private let setSessionThinkingHook: (@Sendable (String) async throws -> Void)?
private let stream: AsyncStream<OpenClawChatTransportEvent>
private let continuation: AsyncStream<OpenClawChatTransportEvent>.Continuation
init(
historyResponses: [OpenClawChatHistoryPayload],
sessionsResponses: [OpenClawChatSessionsListResponse] = [],
modelResponses: [[OpenClawChatModelChoice]] = [],
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil)
sessionsResponses: [OpenClawChatSessionsListResponse] = [])
{
self.historyResponses = historyResponses
self.sessionsResponses = sessionsResponses
self.modelResponses = modelResponses
self.setSessionModelHook = setSessionModelHook
self.setSessionThinkingHook = setSessionThinkingHook
var cont: AsyncStream<OpenClawChatTransportEvent>.Continuation!
self.stream = AsyncStream { c in
cont = c
@@ -258,12 +175,11 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
func sendMessage(
sessionKey _: String,
message _: String,
thinking: String,
thinking _: String,
idempotencyKey: String,
attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
{
await self.state.sentRunIdsAppend(idempotencyKey)
await self.state.sentThinkingLevelsAppend(thinking)
return OpenClawChatSendResponse(runId: idempotencyKey, status: "ok")
}
@@ -285,29 +201,6 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
sessions: [])
}
func listModels() async throws -> [OpenClawChatModelChoice] {
let idx = await self.state.modelsCallCount
await self.state.setModelsCallCount(idx + 1)
if idx < self.modelResponses.count {
return self.modelResponses[idx]
}
return self.modelResponses.last ?? []
}
func setSessionModel(sessionKey _: String, model: String?) async throws {
await self.state.patchedModelsAppend(model)
if let setSessionModelHook = self.setSessionModelHook {
try await setSessionModelHook(model)
}
}
func setSessionThinking(sessionKey _: String, thinkingLevel: String) async throws {
await self.state.patchedThinkingLevelsAppend(thinkingLevel)
if let setSessionThinkingHook = self.setSessionThinkingHook {
try await setSessionThinkingHook(thinkingLevel)
}
}
func requestHealth(timeoutMs _: Int) async throws -> Bool {
true
}
@@ -324,18 +217,6 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
func abortedRunIds() async -> [String] {
await self.state.abortedRunIds
}
func sentThinkingLevels() async -> [String] {
await self.state.sentThinkingLevels
}
func patchedModels() async -> [String?] {
await self.state.patchedModels
}
func patchedThinkingLevels() async -> [String] {
await self.state.patchedThinkingLevels
}
}
extension TestChatTransportState {
@@ -347,10 +228,6 @@ extension TestChatTransportState {
self.sessionsCallCount = v
}
fileprivate func setModelsCallCount(_ v: Int) {
self.modelsCallCount = v
}
fileprivate func sentRunIdsAppend(_ v: String) {
self.sentRunIds.append(v)
}
@@ -358,18 +235,6 @@ extension TestChatTransportState {
fileprivate func abortedRunIdsAppend(_ v: String) {
self.abortedRunIds.append(v)
}
fileprivate func sentThinkingLevelsAppend(_ v: String) {
self.sentThinkingLevels.append(v)
}
fileprivate func patchedModelsAppend(_ v: String?) {
self.patchedModels.append(v)
}
fileprivate func patchedThinkingLevelsAppend(_ v: String) {
self.patchedThinkingLevels.append(v)
}
}
@Suite struct ChatViewModelTests {
@@ -592,512 +457,6 @@ extension TestChatTransportState {
#expect(keys == ["main", "custom"])
}
@Test func bootstrapsModelSelectionFromSessionAndDefaults() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(model: "openai/gpt-4.1-mini", contextTokens: nil),
sessions: [
sessionEntry(key: "main", updatedAt: now, model: "anthropic/claude-opus-4-6"),
])
let models = [
modelChoice(id: "anthropic/claude-opus-4-6", name: "Claude Opus 4.6"),
modelChoice(id: "openai/gpt-4.1-mini", name: "GPT-4.1 mini", provider: "openai"),
]
let (_, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models])
try await loadAndWaitBootstrap(vm: vm)
#expect(await MainActor.run { vm.showsModelPicker })
#expect(await MainActor.run { vm.modelSelectionID } == "anthropic/claude-opus-4-6")
#expect(await MainActor.run { vm.defaultModelLabel } == "Default: openai/gpt-4.1-mini")
}
@Test func selectingDefaultModelPatchesNilAndUpdatesSelection() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(model: "openai/gpt-4.1-mini", contextTokens: nil),
sessions: [
sessionEntry(key: "main", updatedAt: now, model: "anthropic/claude-opus-4-6"),
])
let models = [
modelChoice(id: "anthropic/claude-opus-4-6", name: "Claude Opus 4.6"),
modelChoice(id: "openai/gpt-4.1-mini", name: "GPT-4.1 mini", provider: "openai"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models])
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run { vm.selectModel(OpenClawChatViewModel.defaultModelSelectionID) }
try await waitUntil("session model patched") {
let patched = await transport.patchedModels()
return patched == [nil]
}
#expect(await MainActor.run { vm.modelSelectionID } == OpenClawChatViewModel.defaultModelSelectionID)
}
@Test func selectingProviderQualifiedModelDisambiguatesDuplicateModelIDs() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(model: "openrouter/gpt-4.1-mini", contextTokens: nil),
sessions: [
sessionEntry(key: "main", updatedAt: now, model: "gpt-4.1-mini", modelProvider: "openrouter"),
])
let models = [
modelChoice(id: "gpt-4.1-mini", name: "GPT-4.1 mini", provider: "openai"),
modelChoice(id: "gpt-4.1-mini", name: "GPT-4.1 mini", provider: "openrouter"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models])
try await loadAndWaitBootstrap(vm: vm)
#expect(await MainActor.run { vm.modelSelectionID } == "openrouter/gpt-4.1-mini")
await MainActor.run { vm.selectModel("openai/gpt-4.1-mini") }
try await waitUntil("provider-qualified model patched") {
let patched = await transport.patchedModels()
return patched == ["openai/gpt-4.1-mini"]
}
}
@Test func slashModelIDsStayProviderQualifiedInSelectionAndPatch() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
])
let models = [
modelChoice(
id: "openai/gpt-5.4",
name: "GPT-5.4 via Vercel AI Gateway",
provider: "vercel-ai-gateway"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models])
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run { vm.selectModel("vercel-ai-gateway/openai/gpt-5.4") }
try await waitUntil("slash model patched with provider-qualified ref") {
let patched = await transport.patchedModels()
return patched == ["vercel-ai-gateway/openai/gpt-5.4"]
}
}
@Test func staleModelPatchCompletionsDoNotOverwriteNewerSelection() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
])
let models = [
modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai"),
modelChoice(id: "gpt-5.4-pro", name: "GPT-5.4 Pro", provider: "openai"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models],
setSessionModelHook: { model in
if model == "openai/gpt-5.4" {
try await Task.sleep(for: .milliseconds(200))
}
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run {
vm.selectModel("openai/gpt-5.4")
vm.selectModel("openai/gpt-5.4-pro")
}
try await waitUntil("two model patches complete") {
let patched = await transport.patchedModels()
return patched == ["openai/gpt-5.4", "openai/gpt-5.4-pro"]
}
#expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4-pro")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "openai/gpt-5.4-pro")
}
@Test func sendWaitsForInFlightModelPatchToFinish() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
])
let models = [
modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai"),
]
let gate = AsyncGate()
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models],
setSessionModelHook: { model in
if model == "openai/gpt-5.4" {
await gate.wait()
}
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run { vm.selectModel("openai/gpt-5.4") }
try await waitUntil("model patch started") {
let patched = await transport.patchedModels()
return patched == ["openai/gpt-5.4"]
}
await sendUserMessage(vm, text: "hello")
try await waitUntil("send entered waiting state") {
await MainActor.run { vm.isSending }
}
#expect(await transport.lastSentRunId() == nil)
await MainActor.run { vm.selectThinkingLevel("high") }
try await waitUntil("thinking level changed while send is blocked") {
await MainActor.run { vm.thinkingLevel == "high" }
}
await gate.open()
try await waitUntil("send released after model patch") {
await transport.lastSentRunId() != nil
}
#expect(await transport.sentThinkingLevels() == ["off"])
}
@Test func failedLatestModelSelectionDoesNotReplayAfterOlderCompletionFinishes() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
])
let models = [
modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai"),
modelChoice(id: "gpt-5.4-pro", name: "GPT-5.4 Pro", provider: "openai"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models],
setSessionModelHook: { model in
if model == "openai/gpt-5.4" {
try await Task.sleep(for: .milliseconds(200))
return
}
if model == "openai/gpt-5.4-pro" {
throw NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "boom"])
}
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run {
vm.selectModel("openai/gpt-5.4")
vm.selectModel("openai/gpt-5.4-pro")
}
try await waitUntil("older model completion wins after latest failure") {
await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model == "openai/gpt-5.4" }
}
#expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "openai/gpt-5.4")
#expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"])
}
@Test func failedLatestModelSelectionRestoresEarlierSuccessWithoutReplay() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
])
let models = [
modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai"),
modelChoice(id: "gpt-5.4-pro", name: "GPT-5.4 Pro", provider: "openai"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sessionsResponses: [sessions],
modelResponses: [models],
setSessionModelHook: { model in
if model == "openai/gpt-5.4" {
try await Task.sleep(for: .milliseconds(100))
return
}
if model == "openai/gpt-5.4-pro" {
try await Task.sleep(for: .milliseconds(200))
throw NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "boom"])
}
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run {
vm.selectModel("openai/gpt-5.4")
vm.selectModel("openai/gpt-5.4-pro")
}
try await waitUntil("latest failure restores prior successful model") {
await MainActor.run {
vm.modelSelectionID == "openai/gpt-5.4" &&
vm.sessions.first(where: { $0.key == "main" })?.model == "gpt-5.4" &&
vm.sessions.first(where: { $0.key == "main" })?.modelProvider == "openai"
}
}
#expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"])
}
@Test func switchingSessionsIgnoresLateModelPatchCompletionFromPreviousSession() async throws {
let now = Date().timeIntervalSince1970 * 1000
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 2,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
sessionEntry(key: "other", updatedAt: now - 1000, model: nil),
])
let models = [
modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionKey: "main", sessionId: "sess-main"),
historyPayload(sessionKey: "other", sessionId: "sess-other"),
],
sessionsResponses: [sessions, sessions],
modelResponses: [models, models],
setSessionModelHook: { model in
if model == "openai/gpt-5.4" {
try await Task.sleep(for: .milliseconds(200))
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
await MainActor.run { vm.selectModel("openai/gpt-5.4") }
await MainActor.run { vm.switchSession(to: "other") }
try await waitUntil("switched sessions") {
await MainActor.run { vm.sessionKey == "other" && vm.sessionId == "sess-other" }
}
try await waitUntil("late model patch finished") {
let patched = await transport.patchedModels()
return patched == ["openai/gpt-5.4"]
}
#expect(await MainActor.run { vm.modelSelectionID } == OpenClawChatViewModel.defaultModelSelectionID)
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "other" })?.model } == nil)
}
@Test func lateModelCompletionDoesNotReplayCurrentSessionSelectionIntoPreviousSession() async throws {
let now = Date().timeIntervalSince1970 * 1000
let initialSessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 2,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
sessionEntry(key: "other", updatedAt: now - 1000, model: nil),
])
let sessionsAfterOtherSelection = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 2,
defaults: nil,
sessions: [
sessionEntry(key: "main", updatedAt: now, model: nil),
sessionEntry(key: "other", updatedAt: now - 1000, model: "openai/gpt-5.4-pro"),
])
let models = [
modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai"),
modelChoice(id: "gpt-5.4-pro", name: "GPT-5.4 Pro", provider: "openai"),
]
let (transport, vm) = await makeViewModel(
historyResponses: [
historyPayload(sessionKey: "main", sessionId: "sess-main"),
historyPayload(sessionKey: "other", sessionId: "sess-other"),
historyPayload(sessionKey: "main", sessionId: "sess-main"),
],
sessionsResponses: [initialSessions, initialSessions, sessionsAfterOtherSelection],
modelResponses: [models, models, models],
setSessionModelHook: { model in
if model == "openai/gpt-5.4" {
try await Task.sleep(for: .milliseconds(200))
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
await MainActor.run { vm.selectModel("openai/gpt-5.4") }
await MainActor.run { vm.switchSession(to: "other") }
try await waitUntil("switched to other session") {
await MainActor.run { vm.sessionKey == "other" && vm.sessionId == "sess-other" }
}
await MainActor.run { vm.selectModel("openai/gpt-5.4-pro") }
try await waitUntil("both model patches issued") {
let patched = await transport.patchedModels()
return patched == ["openai/gpt-5.4", "openai/gpt-5.4-pro"]
}
await MainActor.run { vm.switchSession(to: "main") }
try await waitUntil("switched back to main session") {
await MainActor.run { vm.sessionKey == "main" && vm.sessionId == "sess-main" }
}
try await waitUntil("late model completion updates only the original session") {
await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model == "openai/gpt-5.4" }
}
#expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "openai/gpt-5.4")
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "other" })?.model } == "openai/gpt-5.4-pro")
#expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"])
}
@Test func explicitThinkingLevelWinsOverHistoryAndPersistsChanges() async throws {
let history = OpenClawChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [],
thinkingLevel: "off")
let callbackState = await MainActor.run { CallbackBox() }
let (transport, vm) = await makeViewModel(
historyResponses: [history],
initialThinkingLevel: "high",
onThinkingLevelChanged: { level in
callbackState.values.append(level)
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
#expect(await MainActor.run { vm.thinkingLevel } == "high")
await MainActor.run { vm.selectThinkingLevel("medium") }
try await waitUntil("thinking level patched") {
let patched = await transport.patchedThinkingLevels()
return patched == ["medium"]
}
#expect(await MainActor.run { vm.thinkingLevel } == "medium")
#expect(await MainActor.run { callbackState.values } == ["medium"])
}
@Test func serverProvidedThinkingLevelsOutsideMenuArePreservedForSend() async throws {
let history = OpenClawChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [],
thinkingLevel: "xhigh")
let (transport, vm) = await makeViewModel(historyResponses: [history])
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
#expect(await MainActor.run { vm.thinkingLevel } == "xhigh")
await sendUserMessage(vm, text: "hello")
try await waitUntil("send uses preserved thinking level") {
await transport.sentThinkingLevels() == ["xhigh"]
}
}
@Test func staleThinkingPatchCompletionReappliesLatestSelection() async throws {
let history = OpenClawChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [],
thinkingLevel: "off")
let (transport, vm) = await makeViewModel(
historyResponses: [history],
setSessionThinkingHook: { level in
if level == "medium" {
try await Task.sleep(for: .milliseconds(200))
}
})
try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main")
await MainActor.run {
vm.selectThinkingLevel("medium")
vm.selectThinkingLevel("high")
}
try await waitUntil("thinking patch replayed latest selection") {
let patched = await transport.patchedThinkingLevels()
return patched == ["medium", "high", "high"]
}
#expect(await MainActor.run { vm.thinkingLevel } == "high")
}
@Test func clearsStreamingOnExternalErrorEvent() async throws {
let sessionId = "sess-main"
let history = historyPayload(sessionId: sessionId)

View File

@@ -17,51 +17,6 @@ Key goals:
- Works with existing Gateway session store (list/resolve/reset).
- Safe defaults (isolated ACP session keys by default).
## Bridge Scope
`openclaw acp` is a Gateway-backed ACP bridge, not a full ACP-native editor
runtime. It is designed to route IDE prompts into an existing OpenClaw Gateway
session with predictable session mapping and basic streaming updates.
## Compatibility Matrix
| ACP area | Status | Notes |
| --------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. |
| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. |
| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key and replays stored user/assistant text history. Tool/system history is not reconstructed yet. |
| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. |
| Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. |
| Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. |
| Tool streaming | Partial | `tool_call` / `tool_call_update` events include raw I/O, text content, and best-effort file locations when Gateway tool args/results expose them. Embedded terminals and richer diff-native output are still not exposed. |
| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. |
| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. |
| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. |
| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. |
## Known Limitations
- `loadSession` replays stored user and assistant text history, but it does not
reconstruct historic tool calls, system notices, or richer ACP-native event
types.
- If multiple ACP clients share the same Gateway session key, event and cancel
routing are best-effort rather than strictly isolated per client. Prefer the
default isolated `acp:<uuid>` sessions when you need clean editor-local
turns.
- Gateway stop states are translated into ACP stop reasons, but that mapping is
less expressive than a fully ACP-native runtime.
- Initial session controls currently surface a focused subset of Gateway knobs:
thought level, tool verbosity, reasoning, usage detail, and elevated
actions. Model selection and exec-host controls are not yet exposed as ACP
config options.
- `session_info_update` and `usage_update` are derived from Gateway session
snapshots, not live ACP-native runtime accounting. Usage is approximate,
carries no cost data, and is only emitted when the Gateway marks total token
data as fresh.
- Tool follow-along data is best-effort. The bridge can surface file paths that
appear in known tool args/results, but it does not yet emit ACP terminals or
structured file diffs.
## How can I use this
Use ACP when an IDE or tooling speaks Agent Client Protocol and you want it to
@@ -226,11 +181,9 @@ updates. Terminal Gateway states map to ACP `done` with stop reasons:
## Compatibility
- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.15.x).
- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.13.x).
- Works with ACP clients that implement `initialize`, `newSession`,
`loadSession`, `prompt`, `cancel`, and `listSessions`.
- Bridge mode rejects per-session `mcpServers` instead of silently ignoring
them. Configure MCP at the Gateway or agent layer.
## Testing

View File

@@ -29,7 +29,6 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
- Webhook posting is per job via `delivery.mode = "webhook"` + `delivery.to = "<url>"`.
- Legacy fallback remains for stored jobs with `notify: true` when `cron.webhook` is set, migrate those jobs to webhook delivery mode.
- For upgrades, `openclaw doctor --fix` can normalize legacy cron store fields before the scheduler touches them.
## Quick start (actionable)
@@ -262,7 +261,6 @@ If `delivery.channel` or `delivery.to` is omitted, cron can fall back to the mai
Target format reminders:
- Slack/Discord/Mattermost (plugin) targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity.
Mattermost bare 26-char IDs are resolved **user-first** (DM if user exists, channel otherwise) — use `user:<id>` or `channel:<id>` for deterministic routing.
- Telegram topics should use the `:topic:` form (see below).
#### Telegram delivery targets (topics / forum threads)

View File

@@ -168,7 +168,6 @@ openclaw pairing approve discord <CODE>
<Note>
Token resolution is account-aware. Config token values win over env fallback. `DISCORD_BOT_TOKEN` is only used for the default account.
For advanced outbound calls (message tool/channel actions), an explicit per-call `token` is used for that call. Account policy/retry settings still come from the selected account in the active runtime snapshot.
</Note>
## Recommended: Set up a guild workspace
@@ -946,7 +945,7 @@ Default slash command settings:
Gateway auth for this handler uses the same shared credential resolution contract as other Gateway clients:
- env-first local auth (`OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD` then `gateway.auth.*`)
- in local mode, `gateway.remote.*` can be used as fallback only when `gateway.auth.*` is unset; configured-but-unresolved local SecretRefs fail closed
- in local mode, `gateway.remote.*` can be used as fallback when `gateway.auth.*` is unset
- remote-mode support via `gateway.remote.*` when applicable
- URL overrides are override-safe: CLI overrides do not reuse implicit credentials, and env overrides use env credentials only

View File

@@ -87,8 +87,6 @@ Token/secret files:
}
```
`tokenFile` and `secretFile` must point to regular files. Symlinks are rejected.
Multiple accounts:
```json5

View File

@@ -153,14 +153,7 @@ Use these target formats with `openclaw message send` or cron/webhooks:
- `user:<id>` for a DM
- `@username` for a DM (resolved via the Mattermost API)
Bare opaque IDs (like `64ifufp...`) are **ambiguous** in Mattermost (user ID vs channel ID).
OpenClaw resolves them **user-first**:
- If the ID exists as a user (`GET /api/v4/users/<id>` succeeds), OpenClaw sends a **DM** by resolving the direct channel via `/api/v4/channels/direct`.
- Otherwise the ID is treated as a **channel ID**.
If you need deterministic behavior, always use the explicit prefixes (`user:<id>` / `channel:<id>`).
Bare IDs are treated as channels.
## Reactions (message tool)

View File

@@ -115,7 +115,7 @@ Provider options:
- `channels.nextcloud-talk.enabled`: enable/disable channel startup.
- `channels.nextcloud-talk.baseUrl`: Nextcloud instance URL.
- `channels.nextcloud-talk.botSecret`: bot shared secret.
- `channels.nextcloud-talk.botSecretFile`: regular-file secret path. Symlinks are rejected.
- `channels.nextcloud-talk.botSecretFile`: secret file path.
- `channels.nextcloud-talk.apiUser`: API user for room lookups (DM detection).
- `channels.nextcloud-talk.apiPassword`: API/app password for room lookups.
- `channels.nextcloud-talk.apiPasswordFile`: API password file path.

View File

@@ -155,7 +155,6 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
`groupAllowFrom` is used for group sender filtering. If not set, Telegram falls back to `allowFrom`.
`groupAllowFrom` entries should be numeric Telegram user IDs (`telegram:` / `tg:` prefixes are normalized).
Do not put Telegram group or supergroup chat IDs in `groupAllowFrom`. Negative chat IDs belong under `channels.telegram.groups`.
Non-numeric entries are ignored for sender authorization.
Security boundary (`2026.2.25+`): group sender auth does **not** inherit DM pairing-store approvals.
Pairing stays DM-only. For groups, set `groupAllowFrom` or per-group/per-topic `allowFrom`.
@@ -178,31 +177,6 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
}
```
Example: allow only specific users inside one specific group:
```json5
{
channels: {
telegram: {
groups: {
"-1001234567890": {
requireMention: true,
allowFrom: ["8734062810", "745123456"],
},
},
},
},
}
```
<Warning>
Common mistake: `groupAllowFrom` is not a Telegram group allowlist.
- Put negative Telegram group or supergroup chat IDs like `-1001234567890` under `channels.telegram.groups`.
- Put Telegram user IDs like `8734062810` under `groupAllowFrom` when you want to limit which people inside an allowed group can trigger the bot.
- Use `groupAllowFrom: ["*"]` only when you want any member of an allowed group to be able to talk to the bot.
</Warning>
</Tab>
<Tab title="Mention behavior">
@@ -436,7 +410,6 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
- `channels.telegram.actions.sticker` (default: disabled)
Note: `edit` and `topic-create` are currently enabled by default and do not have separate `channels.telegram.actions.*` toggles.
Runtime sends use the active config/secrets snapshot (startup/reload), so action paths do not perform ad-hoc SecretRef re-resolution per send.
Reaction removal semantics: [/tools/reactions](/tools/reactions)
@@ -787,34 +760,6 @@ openclaw message poll --channel telegram --target -1001234567890:topic:42 \
- `channels.telegram.actions.poll=false` disables Telegram poll creation while leaving regular sends enabled
</Accordion>
<Accordion title="Exec approvals in Telegram">
Telegram supports exec approvals in approver DMs and can optionally post approval prompts in the originating chat or topic.
Config path:
- `channels.telegram.execApprovals.enabled`
- `channels.telegram.execApprovals.approvers`
- `channels.telegram.execApprovals.target` (`dm` | `channel` | `both`, default: `dm`)
- `agentFilter`, `sessionFilter`
Approvers must be numeric Telegram user IDs. When `enabled` is false or `approvers` is empty, Telegram does not act as an exec approval client. Approval requests fall back to other configured approval routes or the exec approval fallback policy.
Delivery rules:
- `target: "dm"` sends approval prompts only to configured approver DMs
- `target: "channel"` sends the prompt back to the originating Telegram chat/topic
- `target: "both"` sends to approver DMs and the originating chat/topic
Only configured approvers can approve or deny. Non-approvers cannot use `/approve` and cannot use Telegram approval buttons.
Channel delivery shows the command text in the chat, so only enable `channel` or `both` in trusted groups/topics. When the prompt lands in a forum topic, OpenClaw preserves the topic for both the approval prompt and the post-approval follow-up.
Inline approval buttons also depend on `channels.telegram.capabilities.inlineButtons` allowing the target surface (`dm`, `group`, or `all`).
Related docs: [Exec approvals](/tools/exec-approvals)
</Accordion>
</AccordionGroup>
## Troubleshooting
@@ -892,7 +837,7 @@ Primary reference:
- `channels.telegram.enabled`: enable/disable channel startup.
- `channels.telegram.botToken`: bot token (BotFather).
- `channels.telegram.tokenFile`: read token from a regular file path. Symlinks are rejected.
- `channels.telegram.tokenFile`: read token from file path.
- `channels.telegram.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
- `channels.telegram.allowFrom`: DM allowlist (numeric Telegram user IDs). `allowlist` requires at least one sender ID. `open` requires `"*"`. `openclaw doctor --fix` can resolve legacy `@username` entries to IDs and can recover allowlist entries from pairing-store files in allowlist migration flows.
- `channels.telegram.actions.poll`: enable or disable Telegram poll creation (default: enabled; still requires `sendMessage`).
@@ -914,16 +859,10 @@ Primary reference:
- `channels.telegram.groups.<id>.enabled`: disable the group when `false`.
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (group fields + topic-only `agentId`).
- `channels.telegram.groups.<id>.topics.<threadId>.agentId`: route this topic to a specific agent (overrides group-level and binding routing).
- `channels.telegram.groups.<id>.topics.<threadId>.groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`).
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
- top-level `bindings[]` with `type: "acp"` and canonical topic id `chatId:topic:topicId` in `match.peer.id`: persistent ACP topic binding fields (see [ACP Agents](/tools/acp-agents#channel-specific-settings)).
- `channels.telegram.direct.<id>.topics.<threadId>.agentId`: route DM topics to a specific agent (same behavior as forum topics).
- `channels.telegram.execApprovals.enabled`: enable Telegram as a chat-based exec approval client for this account.
- `channels.telegram.execApprovals.approvers`: Telegram user IDs allowed to approve or deny exec requests. Required when exec approvals are enabled.
- `channels.telegram.execApprovals.target`: `dm | channel | both` (default: `dm`). `channel` and `both` preserve the originating Telegram topic when present.
- `channels.telegram.execApprovals.agentFilter`: optional agent ID filter for forwarded approval prompts.
- `channels.telegram.execApprovals.sessionFilter`: optional session key filter (substring or regex) for forwarded approval prompts.
- `channels.telegram.accounts.<account>.execApprovals`: per-account override for Telegram exec approval routing and approver authorization.
- `channels.telegram.groups.<id>.topics.<threadId>.groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`).
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
- top-level `bindings[]` with `type: "acp"` and canonical topic id `chatId:topic:topicId` in `match.peer.id`: persistent ACP topic binding fields (see [ACP Agents](/tools/acp-agents#channel-specific-settings)).
- `channels.telegram.direct.<id>.topics.<threadId>.agentId`: route DM topics to a specific agent (same behavior as forum topics).
- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
- `channels.telegram.commands.nativeSkills`: enable/disable Telegram native skills commands.
@@ -953,9 +892,8 @@ Primary reference:
Telegram-specific high-signal fields:
- startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*` (`tokenFile` must point to a regular file; symlinks are rejected)
- startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*`
- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`, top-level `bindings[]` (`type: "acp"`)
- exec approvals: `execApprovals`, `accounts.*.execApprovals`
- command/menu: `commands.native`, `commands.nativeSkills`, `customCommands`
- threading/replies: `replyToMode`
- streaming: `streaming` (preview), `blockStreaming`

View File

@@ -179,7 +179,7 @@ Provider options:
- `channels.zalo.enabled`: enable/disable channel startup.
- `channels.zalo.botToken`: bot token from Zalo Bot Platform.
- `channels.zalo.tokenFile`: read token from a regular file path. Symlinks are rejected.
- `channels.zalo.tokenFile`: read token from file path.
- `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
- `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. The wizard will ask for numeric IDs.
- `channels.zalo.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
@@ -193,7 +193,7 @@ Provider options:
Multi-account options:
- `channels.zalo.accounts.<id>.botToken`: per-account token.
- `channels.zalo.accounts.<id>.tokenFile`: per-account regular token file. Symlinks are rejected.
- `channels.zalo.accounts.<id>.tokenFile`: per-account token file.
- `channels.zalo.accounts.<id>.name`: display name.
- `channels.zalo.accounts.<id>.enabled`: enable/disable account.
- `channels.zalo.accounts.<id>.dmPolicy`: per-account DM policy.

View File

@@ -13,49 +13,6 @@ Run the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) bridge t
This command speaks ACP over stdio for IDEs and forwards prompts to the Gateway
over WebSocket. It keeps ACP sessions mapped to Gateway session keys.
`openclaw acp` is a Gateway-backed ACP bridge, not a full ACP-native editor
runtime. It focuses on session routing, prompt delivery, and basic streaming
updates.
## Compatibility Matrix
| ACP area | Status | Notes |
| --------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. |
| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. |
| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key and replays stored user/assistant text history. Tool/system history is not reconstructed yet. |
| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. |
| Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. |
| Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. |
| Tool streaming | Partial | `tool_call` / `tool_call_update` events include raw I/O, text content, and best-effort file locations when Gateway tool args/results expose them. Embedded terminals and richer diff-native output are still not exposed. |
| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. |
| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. |
| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. |
| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. |
## Known Limitations
- `loadSession` replays stored user and assistant text history, but it does not
reconstruct historic tool calls, system notices, or richer ACP-native event
types.
- If multiple ACP clients share the same Gateway session key, event and cancel
routing are best-effort rather than strictly isolated per client. Prefer the
default isolated `acp:<uuid>` sessions when you need clean editor-local
turns.
- Gateway stop states are translated into ACP stop reasons, but that mapping is
less expressive than a fully ACP-native runtime.
- Initial session controls currently surface a focused subset of Gateway knobs:
thought level, tool verbosity, reasoning, usage detail, and elevated
actions. Model selection and exec-host controls are not yet exposed as ACP
config options.
- `session_info_update` and `usage_update` are derived from Gateway session
snapshots, not live ACP-native runtime accounting. Usage is approximate,
carries no cost data, and is only emitted when the Gateway marks total token
data as fresh.
- Tool follow-along data is best-effort. The bridge can surface file paths that
appear in known tool args/results, but it does not yet emit ACP terminals or
structured file diffs.
## Usage
```bash
@@ -139,10 +96,6 @@ Each ACP session maps to a single Gateway session key. One agent can have many
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
the key or label.
Per-session `mcpServers` are not supported in bridge mode. If an ACP client
sends them during `newSession` or `loadSession`, the bridge returns a clear
error instead of silently ignoring them.
## Use from `acpx` (Codex, Claude, other ACP clients)
If you want a coding agent such as Codex or Claude Code to talk to your
@@ -273,7 +226,7 @@ Security note:
- `--token` and `--password` can be visible in local process listings on some systems.
- Prefer `--token-file`/`--password-file` or environment variables (`OPENCLAW_GATEWAY_TOKEN`, `OPENCLAW_GATEWAY_PASSWORD`).
- Gateway auth resolution follows the shared contract used by other Gateway clients:
- local mode: env (`OPENCLAW_GATEWAY_*`) -> `gateway.auth.*` -> `gateway.remote.*` fallback only when `gateway.auth.*` is unset (configured-but-unresolved local SecretRefs fail closed)
- local mode: env (`OPENCLAW_GATEWAY_*`) -> `gateway.auth.*` -> `gateway.remote.*` fallback when `gateway.auth.*` is unset
- remote mode: `gateway.remote.*` with env/config fallback per remote precedence rules
- `--url` is override-safe and does not reuse implicit config/env credentials; pass explicit `--token`/`--password` (or file variants)
- ACP runtime backend child processes receive `OPENCLAW_SHELL=acp`, which can be used for context-specific shell/profile rules.

View File

@@ -30,12 +30,6 @@ Note: retention/pruning is controlled in config:
- `cron.sessionRetention` (default `24h`) prunes completed isolated run sessions.
- `cron.runLog.maxBytes` + `cron.runLog.keepLines` prune `~/.openclaw/cron/runs/<jobId>.jsonl`.
Upgrade note: if you have older cron jobs from before the current delivery/store format, run
`openclaw doctor --fix`. Doctor now normalizes legacy cron fields (`jobId`, `schedule.cron`,
top-level delivery fields, payload `provider` delivery aliases) and migrates simple
`notify: true` webhook fallback jobs to explicit webhook delivery when `cron.webhook` is
configured.
## Common edits
Update delivery settings without changing the message:

View File

@@ -92,40 +92,3 @@ Pass `--token` or `--password` explicitly. Missing explicit credentials is an er
- These commands require `operator.pairing` (or `operator.admin`) scope.
- `devices clear` is intentionally gated by `--yes`.
- If pairing scope is unavailable on local loopback (and no explicit `--url` is passed), list/approve can use a local pairing fallback.
## Token drift recovery checklist
Use this when Control UI or other clients keep failing with `AUTH_TOKEN_MISMATCH` or `AUTH_DEVICE_TOKEN_MISMATCH`.
1. Confirm current gateway token source:
```bash
openclaw config get gateway.auth.token
```
2. List paired devices and identify the affected device id:
```bash
openclaw devices list
```
3. Rotate operator token for the affected device:
```bash
openclaw devices rotate --device <deviceId> --role operator
```
4. If rotation is not enough, remove stale pairing and approve again:
```bash
openclaw devices remove <deviceId>
openclaw devices list
openclaw devices approve <requestId>
```
5. Retry client connection with the current shared token/password.
Related:
- [Dashboard auth troubleshooting](/web/dashboard#if-you-see-unauthorized-1008)
- [Gateway troubleshooting](/gateway/troubleshooting#dashboard-control-ui-connectivity)

View File

@@ -28,7 +28,6 @@ Notes:
- Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts.
- `--fix` (alias for `--repair`) writes a backup to `~/.openclaw/openclaw.json.bak` and drops unknown config keys, listing each removal.
- State integrity checks now detect orphan transcript files in the sessions directory and can archive them as `.deleted.<timestamp>` to reclaim space safely.
- Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime.
- Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing.
- If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`).

View File

@@ -337,7 +337,7 @@ Options:
- `--non-interactive`
- `--mode <local|remote>`
- `--flow <quickstart|advanced|manual>` (manual is an alias for advanced)
- `--auth-choice <setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|mistral-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|opencode-go|custom-api-key|skip>`
- `--auth-choice <setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|mistral-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|custom-api-key|skip>`
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
- `--token <token>` (non-interactive; used with `--auth-choice token`)
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
@@ -354,7 +354,6 @@ Options:
- `--zai-api-key <key>`
- `--minimax-api-key <key>`
- `--opencode-zen-api-key <key>`
- `--opencode-go-api-key <key>`
- `--custom-base-url <url>` (non-interactive; used with `--auth-choice custom-api-key`)
- `--custom-model-id <id>` (non-interactive; used with `--auth-choice custom-api-key`)
- `--custom-api-key <key>` (non-interactive; optional; used with `--auth-choice custom-api-key`; falls back to `CUSTOM_API_KEY` when omitted)
@@ -1019,7 +1018,7 @@ Subcommands:
Auth notes:
- `node` resolves gateway auth from env/config (no `--token`/`--password` flags): `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`, then `gateway.auth.*`. In local mode, node host intentionally ignores `gateway.remote.*`; in `gateway.mode=remote`, `gateway.remote.*` participates per remote precedence rules.
- `node` resolves gateway auth from env/config (no `--token`/`--password` flags): `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`, then `gateway.auth.*`, with remote-mode support via `gateway.remote.*`.
- Legacy `CLAWDBOT_GATEWAY_*` env vars are intentionally ignored for node-host auth resolution.
## Nodes

View File

@@ -64,8 +64,7 @@ Options:
- `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD` are checked first.
- Then local config fallback: `gateway.auth.token` / `gateway.auth.password`.
- In local mode, node host intentionally does not inherit `gateway.remote.token` / `gateway.remote.password`.
- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, node auth resolution fails closed (no remote fallback masking).
- In local mode, `gateway.remote.token` / `gateway.remote.password` are also eligible as fallback when `gateway.auth.*` is unset.
- In `gateway.mode=remote`, remote client fields (`gateway.remote.token` / `gateway.remote.password`) are also eligible per remote precedence rules.
- Legacy `CLAWDBOT_GATEWAY_*` env vars are ignored for node host auth resolution.

View File

@@ -86,13 +86,12 @@ OpenClaw ships with the piai catalog. These providers require **no**
}
```
### OpenCode
### OpenCode Zen
- Provider: `opencode`
- Auth: `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`)
- Zen runtime provider: `opencode`
- Go runtime provider: `opencode-go`
- Example models: `opencode/claude-opus-4-6`, `opencode-go/kimi-k2.5`
- CLI: `openclaw onboard --auth-choice opencode-zen` or `openclaw onboard --auth-choice opencode-go`
- Example model: `opencode/claude-opus-4-6`
- CLI: `openclaw onboard --auth-choice opencode-zen`
```json5
{
@@ -105,8 +104,8 @@ OpenClaw ships with the piai catalog. These providers require **no**
- Provider: `google`
- Auth: `GEMINI_API_KEY`
- Optional rotation: `GEMINI_API_KEYS`, `GEMINI_API_KEY_1`, `GEMINI_API_KEY_2`, `GOOGLE_API_KEY` fallback, and `OPENCLAW_LIVE_GEMINI_KEY` (single override)
- Example models: `google/gemini-3.1-pro-preview`, `google/gemini-3-flash-preview`
- Compatibility: legacy OpenClaw config using `google/gemini-3.1-flash-preview` is normalized to `google/gemini-3-flash-preview`
- Example models: `google/gemini-3.1-pro-preview`, `google/gemini-3-flash-preview`, `google/gemini-3.1-flash-lite-preview`
- Compatibility: legacy OpenClaw config using `google/gemini-3.1-flash-preview` is normalized to `google/gemini-3-flash-preview`, and bare `google/gemini-3.1-flash-lite` is normalized to `google/gemini-3.1-flash-lite-preview`
- CLI: `openclaw onboard --auth-choice gemini-api-key`
### Google Vertex, Antigravity, and Gemini CLI

View File

@@ -55,8 +55,8 @@ subscription** (OAuth) and **Anthropic** (API key or `claude setup-token`).
Model refs are normalized to lowercase. Provider aliases like `z.ai/*` normalize
to `zai/*`.
Provider configuration examples (including OpenCode) live in
[/gateway/configuration](/gateway/configuration#opencode).
Provider configuration examples (including OpenCode Zen) live in
[/gateway/configuration](/gateway/configuration#opencode-zen-multi-model-proxy).
## “Model is not allowed” (and why replies stop)

View File

@@ -103,10 +103,6 @@
"source": "/opencode",
"destination": "/providers/opencode"
},
{
"source": "/opencode-go",
"destination": "/providers/opencode-go"
},
{
"source": "/qianfan",
"destination": "/providers/qianfan"
@@ -1017,7 +1013,8 @@
"tools/browser",
"tools/browser-login",
"tools/chrome-extension",
"tools/browser-linux-troubleshooting"
"tools/browser-linux-troubleshooting",
"tools/browser-wsl2-windows-remote-cdp-troubleshooting"
]
},
{
@@ -1115,7 +1112,6 @@
"providers/nvidia",
"providers/ollama",
"providers/openai",
"providers/opencode-go",
"providers/opencode",
"providers/openrouter",
"providers/qianfan",

View File

@@ -203,7 +203,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
}
```
- Bot token: `channels.telegram.botToken` or `channels.telegram.tokenFile` (regular file only; symlinks rejected), with `TELEGRAM_BOT_TOKEN` as fallback for the default account.
- Bot token: `channels.telegram.botToken` or `channels.telegram.tokenFile`, with `TELEGRAM_BOT_TOKEN` as fallback for the default account.
- Optional `channels.telegram.defaultAccount` overrides default account selection when it matches a configured account id.
- In multi-account setups (2+ account ids), set an explicit default (`channels.telegram.defaultAccount` or `channels.telegram.accounts.default`) to avoid fallback routing; `openclaw doctor` warns when this is missing or invalid.
- `configWrites: false` blocks Telegram-initiated config writes (supergroup ID migrations, `/config set|unset`).
@@ -304,7 +304,6 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
```
- Token: `channels.discord.token`, with `DISCORD_BOT_TOKEN` as fallback for the default account.
- Direct outbound calls that provide an explicit Discord `token` use that token for the call; account retry/policy settings still come from the selected account in the active runtime snapshot.
- Optional `channels.discord.defaultAccount` overrides default account selection when it matches a configured account id.
- Use `user:<id>` (DM) or `channel:<id>` (guild channel) for delivery targets; bare numeric IDs are rejected.
- Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged name (no `#`). Prefer guild IDs.
@@ -748,7 +747,6 @@ Include your own number in `allowFrom` to enable self-chat mode (ignores native
- `bash: true` enables `! <cmd>` for host shell. Requires `tools.elevated.enabled` and sender in `tools.elevated.allowFrom.<channel>`.
- `config: true` enables `/config` (reads/writes `openclaw.json`). For gateway `chat.send` clients, persistent `/config set|unset` writes also require `operator.admin`; read-only `/config show` stays available to normal write-scoped operator clients.
- `channels.<provider>.configWrites` gates config mutations per channel (default: true).
- For multi-account channels, `channels.<provider>.accounts.<id>.configWrites` also gates writes that target that account (for example `/allowlist --config --account <id>` or `/config set channels.<provider>.accounts.<id>...`).
- `allowFrom` is per-provider. When set, it is the **only** authorization source (channel allowlists/pairing and `useAccessGroups` are ignored).
- `useAccessGroups: false` allows commands to bypass access-group policies when `allowFrom` is not set.
@@ -2079,7 +2077,7 @@ Use `cerebras/zai-glm-4.7` for Cerebras; `zai/glm-4.7` for Z.AI direct.
</Accordion>
<Accordion title="OpenCode">
<Accordion title="OpenCode Zen">
```json5
{
@@ -2092,7 +2090,7 @@ Use `cerebras/zai-glm-4.7` for Cerebras; `zai/glm-4.7` for Z.AI direct.
}
```
Set `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`). Use `opencode/...` refs for the Zen catalog or `opencode-go/...` refs for the Go catalog. Shortcut: `openclaw onboard --auth-choice opencode-zen` or `openclaw onboard --auth-choice opencode-go`.
Set `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`). Shortcut: `openclaw onboard --auth-choice opencode-zen`.
</Accordion>
@@ -2470,8 +2468,7 @@ See [Plugins](/tools/plugin).
- `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`.
- `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`: client-side break-glass override that allows plaintext `ws://` to trusted private-network IPs; default remains loopback-only for plaintext.
- `gateway.remote.token` / `.password` are remote-client credential fields. They do not configure gateway auth by themselves.
- Local gateway call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset.
- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking).
- Local gateway call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset.
- `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control.
- `allowRealIpFallback`: when `true`, the gateway accepts `X-Real-IP` if `X-Forwarded-For` is missing. Default `false` for fail-closed behavior.
- `gateway.tools.deny`: extra tool names blocked for HTTP `POST /tools/invoke` (extends default deny list).
@@ -2715,7 +2712,6 @@ Validation:
- `source: "env"` id pattern: `^[A-Z][A-Z0-9_]{0,127}$`
- `source: "file"` id: absolute JSON pointer (for example `"/providers/openai/apiKey"`)
- `source: "exec"` id pattern: `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$`
- `source: "exec"` ids must not contain `.` or `..` slash-delimited path segments (for example `a/../b` is rejected)
### Supported credential surface

View File

@@ -63,9 +63,8 @@ cat ~/.openclaw/openclaw.json
- Health check + restart prompt.
- Skills status summary (eligible/missing/blocked).
- Config normalization for legacy values.
- OpenCode provider override warnings (`models.providers.opencode` / `models.providers.opencode-go`).
- OpenCode Zen provider override warnings (`models.providers.opencode`).
- Legacy on-disk state migration (sessions/agent dir/WhatsApp auth).
- Legacy cron store migration (`jobId`, `schedule.cron`, top-level delivery/payload fields, payload `provider`, simple `notify: true` webhook fallback jobs).
- State integrity and permissions checks (sessions, transcripts, state dir).
- Config file permission checks (chmod 600) when running locally.
- Model auth health: checks OAuth expiry, can refresh expiring tokens, and reports auth-profile cooldown/disabled states.
@@ -134,12 +133,12 @@ Doctor warnings also include account-default guidance for multi-account channels
- If two or more `channels.<channel>.accounts` entries are configured without `channels.<channel>.defaultAccount` or `accounts.default`, doctor warns that fallback routing can pick an unexpected account.
- If `channels.<channel>.defaultAccount` is set to an unknown account ID, doctor warns and lists configured account IDs.
### 2b) OpenCode provider overrides
### 2b) OpenCode Zen provider overrides
If youve added `models.providers.opencode`, `opencode-zen`, or `opencode-go`
manually, it overrides the built-in OpenCode catalog from `@mariozechner/pi-ai`.
That can force models onto the wrong API or zero out costs. Doctor warns so you
can remove the override and restore per-model API routing + costs.
If youve added `models.providers.opencode` (or `opencode-zen`) manually, it
overrides the built-in OpenCode Zen catalog from `@mariozechner/pi-ai`. That can
force every model onto a single API or zero out costs. Doctor warns so you can
remove the override and restore per-model API routing + costs.
### 3) Legacy state migrations (disk layout)
@@ -159,25 +158,6 @@ the legacy sessions + agent dir on startup so history/auth/models land in the
per-agent path without a manual doctor run. WhatsApp auth is intentionally only
migrated via `openclaw doctor`.
### 3b) Legacy cron store migrations
Doctor also checks the cron job store (`~/.openclaw/cron/jobs.json` by default,
or `cron.store` when overridden) for old job shapes that the scheduler still
accepts for compatibility.
Current cron cleanups include:
- `jobId``id`
- `schedule.cron``schedule.expr`
- top-level payload fields (`message`, `model`, `thinking`, ...) → `payload`
- top-level delivery fields (`deliver`, `channel`, `to`, `provider`, ...) → `delivery`
- payload `provider` delivery aliases → explicit `delivery.channel`
- simple legacy `notify: true` webhook fallback jobs → explicit `delivery.mode="webhook"` with `delivery.to=cron.webhook`
Doctor only auto-migrates `notify: true` jobs when it can do so without
changing behavior. If a job combines legacy notify fallback with an existing
non-webhook delivery mode, doctor warns and leaves that job for manual review.
### 4) State integrity checks (session persistence, routing, and safety)
The state directory is the operational brainstem. If it vanishes, you lose

View File

@@ -206,12 +206,6 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
persisted by the client for future connects.
- Device tokens can be rotated/revoked via `device.token.rotate` and
`device.token.revoke` (requires `operator.pairing` scope).
- Auth failures include `error.details.code` plus recovery hints:
- `error.details.canRetryWithDeviceToken` (boolean)
- `error.details.recommendedNextStep` (`retry_with_device_token`, `update_auth_configuration`, `update_auth_credentials`, `wait_then_retry`, `review_auth_configuration`)
- Client behavior for `AUTH_TOKEN_MISMATCH`:
- Trusted clients may attempt one bounded retry with a cached per-device token.
- If that retry fails, clients should stop automatic reconnect loops and surface operator action guidance.
## Device identity + pairing
@@ -223,9 +217,8 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- **Local** connects include loopback and the gateway hosts own tailnet address
(so samehost tailnet binds can still autoapprove).
- All WS clients must include `device` identity during `connect` (operator + node).
Control UI can omit it only in these modes:
- `gateway.controlUi.allowInsecureAuth=true` for localhost-only insecure HTTP compatibility.
- `gateway.controlUi.dangerouslyDisableDeviceAuth=true` (break-glass, severe security downgrade).
Control UI can omit it **only** when `gateway.controlUi.dangerouslyDisableDeviceAuth`
is enabled for break-glass use.
- All connections must sign the server-provided `connect.challenge` nonce.
### Device auth migration diagnostics

View File

@@ -103,19 +103,18 @@ When the gateway is loopback-only, keep the URL at `ws://127.0.0.1:18789` and op
## Credential precedence
Gateway credential resolution follows one shared contract across call/probe/status paths and Discord exec-approval monitoring. Node-host uses the same base contract with one local-mode exception (it intentionally ignores `gateway.remote.*`):
Gateway credential resolution follows one shared contract across call/probe/status paths, Discord exec-approval monitoring, and node-host connections:
- Explicit credentials (`--token`, `--password`, or tool `gatewayToken`) always win on call paths that accept explicit auth.
- URL override safety:
- CLI URL overrides (`--url`) never reuse implicit config/env credentials.
- Env URL overrides (`OPENCLAW_GATEWAY_URL`) may use env credentials only (`OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`).
- Local mode defaults:
- token: `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token` -> `gateway.remote.token` (remote fallback applies only when local auth token input is unset)
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.auth.password` -> `gateway.remote.password` (remote fallback applies only when local auth password input is unset)
- token: `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token` -> `gateway.remote.token`
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.auth.password` -> `gateway.remote.password`
- Remote mode defaults:
- token: `gateway.remote.token` -> `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token`
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.remote.password` -> `gateway.auth.password`
- Node-host local-mode exception: `gateway.remote.token` / `gateway.remote.password` are ignored.
- Remote probe/status token checks are strict by default: they use `gateway.remote.token` only (no local token fallback) when targeting remote mode.
- Legacy `CLAWDBOT_GATEWAY_*` env vars are only used by compatibility call paths; probe/status/auth resolution uses `OPENCLAW_GATEWAY_*` only.
@@ -141,8 +140,7 @@ Short version: **keep the Gateway loopback-only** unless youre sure you need
set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` on the client process as break-glass.
- **Non-loopback binds** (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) must use auth tokens/passwords.
- `gateway.remote.token` / `.password` are client credential sources. They do **not** configure server auth by themselves.
- Local call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset.
- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking).
- Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset.
- `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`.
- **Tailscale Serve** can authenticate Control UI/WebSocket traffic via identity
headers when `gateway.auth.allowTailscale: true`; HTTP API endpoints still

View File

@@ -21,7 +21,6 @@ Secrets are resolved into an in-memory runtime snapshot.
- Startup fails fast when an effectively active SecretRef cannot be resolved.
- Reload uses atomic swap: full success, or keep the last-known-good snapshot.
- Runtime requests read from the active in-memory snapshot only.
- Outbound delivery paths also read from that active snapshot (for example Discord reply/thread delivery and Telegram action sends); they do not re-resolve SecretRefs on each send.
This keeps secret-provider outages off hot request paths.
@@ -39,15 +38,14 @@ Examples of inactive surfaces:
- Top-level channel credentials that no enabled account inherits.
- Disabled tool/feature surfaces.
- Web search provider-specific keys that are not selected by `tools.web.search.provider`.
In auto mode (provider unset), keys are consulted by precedence for provider auto-detection until one resolves.
After selection, non-selected provider keys are treated as inactive until selected.
- `gateway.remote.token` / `gateway.remote.password` SecretRefs are active if one of these is true:
In auto mode (provider unset), provider-specific keys are also active for provider auto-detection.
- `gateway.remote.token` / `gateway.remote.password` SecretRefs are active (when `gateway.remote.enabled` is not `false`) if one of these is true:
- `gateway.mode=remote`
- `gateway.remote.url` is configured
- `gateway.tailscale.mode` is `serve` or `funnel`
- In local mode without those remote surfaces:
- `gateway.remote.token` is active when token auth can win and no env/auth token is configured.
- `gateway.remote.password` is active only when password auth can win and no env/auth password is configured.
In local mode without those remote surfaces:
- `gateway.remote.token` is active when token auth can win and no env/auth token is configured.
- `gateway.remote.password` is active only when password auth can win and no env/auth password is configured.
- `gateway.auth.token` SecretRef is inactive for startup auth resolution when `OPENCLAW_GATEWAY_TOKEN` (or `CLAWDBOT_GATEWAY_TOKEN`) is set, because env token input wins for that runtime.
## Gateway auth surface diagnostics
@@ -114,7 +112,6 @@ Validation:
- `provider` must match `^[a-z][a-z0-9_-]{0,63}$`
- `id` must match `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$`
- `id` must not contain `.` or `..` as slash-delimited path segments (for example `a/../b` is rejected)
## Provider config
@@ -323,7 +320,6 @@ Activation contract:
- Success swaps the snapshot atomically.
- Startup failure aborts gateway startup.
- Runtime reload failure keeps the last-known-good snapshot.
- Providing an explicit per-call channel token to an outbound helper/tool call does not trigger SecretRef activation; activation points remain startup, reload, and explicit `secrets.reload`.
## Degraded and recovered signals

View File

@@ -104,7 +104,6 @@ Treat Gateway and node as one operator trust domain, with different roles:
- A caller authenticated to the Gateway is trusted at Gateway scope. After pairing, node actions are trusted operator actions on that node.
- `sessionKey` is routing/context selection, not per-user auth.
- Exec approvals (allowlist + ask) are guardrails for operator intent, not hostile multi-tenant isolation.
- Exec approvals bind exact request context and best-effort direct local file operands; they do not semantically model every runtime/interpreter loader path. Use sandboxing and host isolation for strong boundaries.
If you need hostile-user isolation, split trust boundaries by OS user/host and run separate gateways.
@@ -200,7 +199,7 @@ If you run `--deep`, OpenClaw also attempts a best-effort live Gateway probe.
Use this when auditing access or deciding what to back up:
- **WhatsApp**: `~/.openclaw/credentials/whatsapp/<accountId>/creds.json`
- **Telegram bot token**: config/env or `channels.telegram.tokenFile` (regular file only; symlinks rejected)
- **Telegram bot token**: config/env or `channels.telegram.tokenFile`
- **Discord bot token**: config/env or SecretRef (env/file/exec providers)
- **Slack tokens**: config/env (`channels.slack.*`)
- **Pairing allowlists**:
@@ -263,14 +262,9 @@ High-signal `checkId` values you will most likely see in real deployments (not e
## Control UI over HTTP
The Control UI needs a **secure context** (HTTPS or localhost) to generate device
identity. `gateway.controlUi.allowInsecureAuth` is a local compatibility toggle:
- On localhost, it allows Control UI auth without device identity when the page
is loaded over non-secure HTTP.
- It does not bypass pairing checks.
- It does not relax remote (non-localhost) device identity requirements.
Prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`.
identity. `gateway.controlUi.allowInsecureAuth` does **not** bypass secure-context,
device-identity, or device-pairing checks. Prefer HTTPS (Tailscale Serve) or open
the UI on `127.0.0.1`.
For break-glass scenarios only, `gateway.controlUi.dangerouslyDisableDeviceAuth`
disables device identity checks entirely. This is a severe security downgrade;
@@ -371,7 +365,6 @@ If a macOS node is paired, the Gateway can invoke `system.run` on that node. Thi
- Requires node pairing (approval + token).
- Controlled on the Mac via **Settings → Exec approvals** (security + ask + allowlist).
- Approval mode binds exact request context and, when possible, one concrete local script/file operand. If OpenClaw cannot identify exactly one direct local file for an interpreter/runtime command, approval-backed execution is denied rather than promising full semantic coverage.
- If you dont want remote execution, set security to **deny** and remove node pairing for that Mac.
## Dynamic skills (watcher / remote nodes)
@@ -754,10 +747,8 @@ Doctor can generate one for you: `openclaw doctor --generate-gateway-token`.
Note: `gateway.remote.token` / `.password` are client credential sources. They
do **not** protect local WS access by themselves.
Local call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*`
Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*`
is unset.
If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via
SecretRef and unresolved, resolution fails closed (no remote fallback masking).
Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`.
Plaintext `ws://` is loopback-only by default. For trusted private-network
paths, set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` on the client process as break-glass.

View File

@@ -113,21 +113,9 @@ Common signatures:
challenge-based device auth flow (`connect.challenge` + `device.nonce`).
- `device signature invalid` / `device signature expired` → client signed the wrong
payload (or stale timestamp) for the current handshake.
- `AUTH_TOKEN_MISMATCH` with `canRetryWithDeviceToken=true` → client can do one trusted retry with cached device token.
- repeated `unauthorized` after that retry → shared token/device token drift; refresh token config and re-approve/rotate device token if needed.
- `unauthorized` / reconnect loop → token/password mismatch.
- `gateway connect failed:` → wrong host/port/url target.
### Auth detail codes quick map
Use `error.details.code` from the failed `connect` response to pick the next action:
| Detail code | Meaning | Recommended action |
| ---------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `AUTH_TOKEN_MISSING` | Client did not send a required shared token. | Paste/set token in the client and retry. For dashboard paths: `openclaw config get gateway.auth.token` then paste into Control UI settings. |
| `AUTH_TOKEN_MISMATCH` | Shared token did not match gateway auth token. | If `canRetryWithDeviceToken=true`, allow one trusted retry. If still failing, run the [token drift recovery checklist](/cli/devices#token-drift-recovery-checklist). |
| `AUTH_DEVICE_TOKEN_MISMATCH` | Cached per-device token is stale or revoked. | Rotate/re-approve device token using [devices CLI](/cli/devices), then reconnect. |
| `PAIRING_REQUIRED` | Device identity is known but not approved for this role. | Approve pending request: `openclaw devices list` then `openclaw devices approve <requestId>`. |
Device auth v2 migration check:
```bash
@@ -147,7 +135,6 @@ Related:
- [/web/control-ui](/web/control-ui)
- [/gateway/authentication](/gateway/authentication)
- [/gateway/remote](/gateway/remote)
- [/cli/devices](/cli/devices)
## Gateway service not running

View File

@@ -1452,8 +1452,7 @@ Non-loopback binds **require auth**. Configure `gateway.auth.mode` + `gateway.au
Notes:
- `gateway.remote.token` / `.password` do **not** enable local gateway auth by themselves.
- Local call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset.
- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking).
- Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset.
- The Control UI authenticates via `connect.params.auth.token` (stored in app/UI settings). Avoid putting tokens in URLs.
### Why do I need a token on localhost now
@@ -1490,16 +1489,10 @@ Set `cli.banner.taglineMode` in config:
### How do I enable web search and web fetch
`web_fetch` works without an API key. `web_search` requires a key for your
selected provider (Brave, Gemini, Grok, Kimi, or Perplexity).
**Recommended:** run `openclaw configure --section web` and choose a provider.
Environment alternatives:
- Brave: `BRAVE_API_KEY`
- Gemini: `GEMINI_API_KEY`
- Grok: `XAI_API_KEY`
- Kimi: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
- Perplexity: `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
`web_fetch` works without an API key. `web_search` requires a Brave Search API
key. **Recommended:** run `openclaw configure --section web` to store it in
`tools.web.search.apiKey`. Environment alternative: set `BRAVE_API_KEY` for the
Gateway process.
```json5
{
@@ -1507,7 +1500,6 @@ Environment alternatives:
web: {
search: {
enabled: true,
provider: "brave",
apiKey: "BRAVE_API_KEY_HERE",
maxResults: 5,
},
@@ -2513,7 +2505,6 @@ Your gateway is running with auth enabled (`gateway.auth.*`), but the UI is not
Facts (from code):
- The Control UI keeps the token in `sessionStorage` for the current browser tab session and selected gateway URL, so same-tab refreshes keep working without restoring long-lived localStorage token persistence.
- On `AUTH_TOKEN_MISMATCH`, trusted clients can attempt one bounded retry with a cached device token when the gateway returns retry hints (`canRetryWithDeviceToken=true`, `recommendedNextStep=retry_with_device_token`).
Fix:
@@ -2522,9 +2513,6 @@ Fix:
- If remote, tunnel first: `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/`.
- Set `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`) on the gateway host.
- In the Control UI settings, paste the same token.
- If mismatch persists after the one retry, rotate/re-approve the paired device token:
- `openclaw devices list`
- `openclaw devices rotate --device <id> --role operator`
- Still stuck? Run `openclaw status --all` and follow [Troubleshooting](/gateway/troubleshooting). See [Dashboard](/web/dashboard) for auth details.
### I set gatewaybind tailnet but it can't bind nothing listens

View File

@@ -311,11 +311,11 @@ Include at least one image-capable model in `OPENCLAW_LIVE_GATEWAY_MODELS` (Clau
If you have keys enabled, we also support testing via:
- OpenRouter: `openrouter/...` (hundreds of models; use `openclaw models scan` to find tool+image capable candidates)
- OpenCode: `opencode/...` for Zen and `opencode-go/...` for Go (auth via `OPENCODE_API_KEY` / `OPENCODE_ZEN_API_KEY`)
- OpenCode Zen: `opencode/...` (auth via `OPENCODE_API_KEY` / `OPENCODE_ZEN_API_KEY`)
More providers you can include in the live matrix (if you have creds/config):
- Built-in: `openai`, `openai-codex`, `anthropic`, `google`, `google-vertex`, `google-antigravity`, `google-gemini-cli`, `zai`, `openrouter`, `opencode`, `opencode-go`, `xai`, `groq`, `cerebras`, `mistral`, `github-copilot`
- Built-in: `openai`, `openai-codex`, `anthropic`, `google`, `google-vertex`, `google-antigravity`, `google-gemini-cli`, `zai`, `openrouter`, `opencode`, `xai`, `groq`, `cerebras`, `mistral`, `github-copilot`
- Via `models.providers` (custom endpoints): `minimax` (cloud/API), plus any OpenAI/Anthropic-compatible proxy (LM Studio, vLLM, LiteLLM, etc.)
Tip: dont try to hardcode “all models” in docs. The authoritative list is whatever `discoverModels(...)` returns on your machine + whatever keys are available.
@@ -409,6 +409,3 @@ When you fix a provider/model issue discovered in live:
- Prefer targeting the smallest layer that catches the bug:
- provider request conversion/replay bug → direct models test
- gateway session/history/tool pipeline bug → gateway live smoke or CI-safe gateway mock test
- SecretRef traversal guardrail:
- `src/secrets/exec-secret-ref-id-parity.test.ts` derives one sampled target per SecretRef class from registry metadata (`listSecretTargetRegistryEntries()`), then asserts traversal-segment exec ids are rejected.
- If you add a new `includeInPlan` SecretRef target family in `src/secrets/target-registry-data.ts`, update `classifyTargetClass` in that test. The test intentionally fails on unclassified target ids so new classes cannot be skipped silently.

View File

@@ -136,8 +136,7 @@ flowchart TD
Common log signatures:
- `device identity required` → HTTP/non-secure context cannot complete device auth.
- `AUTH_TOKEN_MISMATCH` with retry hints (`canRetryWithDeviceToken=true`) → one trusted device-token retry may occur automatically.
- repeated `unauthorized` after that retry → wrong token/password, auth mode mismatch, or stale paired device token.
- `unauthorized` / reconnect loop → wrong token/password or auth mode mismatch.
- `gateway connect failed:` → UI is targeting the wrong URL/port or unreachable gateway.
Deep pages:

View File

@@ -54,15 +54,6 @@ forwards `exec` calls to the **node host** when `host=node` is selected.
- **Node host**: executes `system.run`/`system.which` on the node machine.
- **Approvals**: enforced on the node host via `~/.openclaw/exec-approvals.json`.
Approval note:
- Approval-backed node runs bind exact request context.
- For direct shell/runtime file executions, OpenClaw also best-effort binds one concrete local
file operand and denies the run if that file changes before execution.
- If OpenClaw cannot identify exactly one concrete local file for an interpreter/runtime command,
approval-backed execution is denied instead of pretending full runtime coverage. Use sandboxing,
separate hosts, or an explicit trusted allowlist/full workflow for broader interpreter semantics.
### Start a node host (foreground)
On the node machine:
@@ -92,10 +83,7 @@ Notes:
- `openclaw node run` supports token or password auth.
- Env vars are preferred: `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`.
- Config fallback is `gateway.auth.token` / `gateway.auth.password`.
- In local mode, node host intentionally ignores `gateway.remote.token` / `gateway.remote.password`.
- In remote mode, `gateway.remote.token` / `gateway.remote.password` are eligible per remote precedence rules.
- If active local `gateway.auth.*` SecretRefs are configured but unresolved, node-host auth fails closed.
- Config fallback is `gateway.auth.token` / `gateway.auth.password`; in remote mode, `gateway.remote.token` / `gateway.remote.password` are also eligible.
- Legacy `CLAWDBOT_GATEWAY_*` env vars are intentionally ignored by node-host auth resolution.
### Start a node host (service)

View File

@@ -71,14 +71,11 @@ Optional legacy controls:
**Via config:** run `openclaw configure --section web`. It stores the key in
`~/.openclaw/openclaw.json` under `tools.web.search.perplexity.apiKey`.
That field also accepts SecretRef objects.
**Via environment:** set `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
in the Gateway process environment. For a gateway install, put it in
`~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
If `provider: "perplexity"` is configured and the Perplexity key SecretRef is unresolved with no env fallback, startup/reload fails fast.
## Tool parameters
These parameters apply to the native Perplexity Search API path.

View File

@@ -39,7 +39,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
- [NVIDIA](/providers/nvidia)
- [Ollama (local models)](/providers/ollama)
- [OpenAI (API + Codex)](/providers/openai)
- [OpenCode (Zen + Go)](/providers/opencode)
- [OpenCode Zen](/providers/opencode)
- [OpenRouter](/providers/openrouter)
- [Qianfan](/providers/qianfan)
- [Qwen (OAuth)](/providers/qwen)

View File

@@ -32,7 +32,7 @@ model as `provider/model`.
- [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot)
- [Mistral](/providers/mistral)
- [Synthetic](/providers/synthetic)
- [OpenCode (Zen + Go)](/providers/opencode)
- [OpenCode Zen](/providers/opencode)
- [Z.AI](/providers/zai)
- [GLM models](/providers/glm)
- [MiniMax](/providers/minimax)

View File

@@ -1,45 +0,0 @@
---
summary: "Use the OpenCode Go catalog with the shared OpenCode setup"
read_when:
- You want the OpenCode Go catalog
- You need the runtime model refs for Go-hosted models
title: "OpenCode Go"
---
# OpenCode Go
OpenCode Go is the Go catalog within [OpenCode](/providers/opencode).
It uses the same `OPENCODE_API_KEY` as the Zen catalog, but keeps the runtime
provider id `opencode-go` so upstream per-model routing stays correct.
## Supported models
- `opencode-go/kimi-k2.5`
- `opencode-go/glm-5`
- `opencode-go/minimax-m2.5`
## CLI setup
```bash
openclaw onboard --auth-choice opencode-go
# or non-interactive
openclaw onboard --opencode-go-api-key "$OPENCODE_API_KEY"
```
## Config snippet
```json5
{
env: { OPENCODE_API_KEY: "YOUR_API_KEY_HERE" }, // pragma: allowlist secret
agents: { defaults: { model: { primary: "opencode-go/kimi-k2.5" } } },
}
```
## Routing behavior
OpenClaw handles per-model routing automatically when the model ref uses `opencode-go/...`.
## Notes
- Use [OpenCode](/providers/opencode) for the shared onboarding and catalog overview.
- Runtime refs stay explicit: `opencode/...` for Zen, `opencode-go/...` for Go.

View File

@@ -1,38 +1,25 @@
---
summary: "Use OpenCode Zen and Go catalogs with OpenClaw"
summary: "Use OpenCode Zen (curated models) with OpenClaw"
read_when:
- You want OpenCode-hosted model access
- You want to pick between the Zen and Go catalogs
title: "OpenCode"
- You want OpenCode Zen for model access
- You want a curated list of coding-friendly models
title: "OpenCode Zen"
---
# OpenCode
# OpenCode Zen
OpenCode exposes two hosted catalogs in OpenClaw:
- `opencode/...` for the **Zen** catalog
- `opencode-go/...` for the **Go** catalog
Both catalogs use the same OpenCode API key. OpenClaw keeps the runtime provider ids
split so upstream per-model routing stays correct, but onboarding and docs treat them
as one OpenCode setup.
OpenCode Zen is a **curated list of models** recommended by the OpenCode team for coding agents.
It is an optional, hosted model access path that uses an API key and the `opencode` provider.
Zen is currently in beta.
## CLI setup
### Zen catalog
```bash
openclaw onboard --auth-choice opencode-zen
# or non-interactive
openclaw onboard --opencode-zen-api-key "$OPENCODE_API_KEY"
```
### Go catalog
```bash
openclaw onboard --auth-choice opencode-go
openclaw onboard --opencode-go-api-key "$OPENCODE_API_KEY"
```
## Config snippet
```json5
@@ -42,23 +29,8 @@ openclaw onboard --opencode-go-api-key "$OPENCODE_API_KEY"
}
```
## Catalogs
### Zen
- Runtime provider: `opencode`
- Example models: `opencode/claude-opus-4-6`, `opencode/gpt-5.2`, `opencode/gemini-3-pro`
- Best when you want the curated OpenCode multi-model proxy
### Go
- Runtime provider: `opencode-go`
- Example models: `opencode-go/kimi-k2.5`, `opencode-go/glm-5`, `opencode-go/minimax-m2.5`
- Best when you want the OpenCode-hosted Kimi/GLM/MiniMax lineup
## Notes
- `OPENCODE_ZEN_API_KEY` is also supported.
- Entering one OpenCode key during onboarding stores credentials for both runtime providers.
- You sign in to OpenCode, add billing details, and copy your API key.
- Billing and catalog availability are managed from the OpenCode dashboard.
- You sign in to Zen, add billing details, and copy your API key.
- OpenCode Zen bills per request; check the OpenCode dashboard for details.

View File

@@ -19,32 +19,6 @@ When the operator says “release”, immediately do this preflight (no extra qu
- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set (SPARKLE_PRIVATE_KEY_FILE should live in `~/.profile`).
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
## Versioning
Current OpenClaw releases use date-based versioning.
- Stable release version: `YYYY.M.D`
- Git tag: `vYYYY.M.D`
- Examples from repo history: `v2026.2.26`, `v2026.3.8`
- Beta prerelease version: `YYYY.M.D-beta.N`
- Git tag: `vYYYY.M.D-beta.N`
- Examples from repo history: `v2026.2.15-beta.1`, `v2026.3.8-beta.1`
- Use the same version string everywhere, minus the leading `v` where Git tags are not used:
- `package.json`: `2026.3.8`
- Git tag: `v2026.3.8`
- GitHub release title: `openclaw 2026.3.8`
- Do not zero-pad month or day. Use `2026.3.8`, not `2026.03.08`.
- Stable and beta are npm dist-tags, not separate release lines:
- `latest` = stable
- `beta` = prerelease/testing
- Dev is the moving head of `main`, not a normal git-tagged release.
- The release workflow enforces the current stable/beta tag formats and rejects versions whose CalVer date is more than 2 UTC calendar days away from the release date.
Historical note:
- Older tags such as `v2026.1.11-1`, `v2026.2.6-3`, and `v2.0.0-beta2` exist in repo history.
- Treat those as legacy tag patterns. New releases should use `vYYYY.M.D` for stable and `vYYYY.M.D-beta.N` for beta.
1. **Version & metadata**
- [ ] Bump `package.json` version (e.g., `2026.1.29`).
@@ -93,11 +67,8 @@ Historical note:
6. **Publish (npm)**
- [ ] Confirm git status is clean; commit and push as needed.
- [ ] Confirm npm trusted publishing is configured for the `openclaw` package.
- [ ] Push the matching git tag to trigger `.github/workflows/openclaw-npm-release.yml`.
- Stable tags publish to npm `latest`.
- Beta tags publish to npm `beta`.
- The workflow rejects tags that do not match `package.json`, are not on `main`, or whose CalVer date is more than 2 UTC calendar days away from the release date.
- [ ] `npm login` (verify 2FA) if needed.
- [ ] `npm publish --access public` (use `--tag beta` for pre-releases).
- [ ] Verify the registry: `npm view openclaw version`, `npm view openclaw dist-tags`, and `npx -y openclaw@X.Y.Z --version` (or `--help`).
### Troubleshooting (notes from 2.0.0-beta2 release)
@@ -113,7 +84,6 @@ Historical note:
7. **GitHub release + appcast**
- [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`).
- Pushing the tag also triggers the npm release workflow.
- [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `openclaw X.Y.Z`** (not just the tag); body should include the **full** changelog section for that version (Highlights + Changes + Fixes), inline (no bare links), and **must not repeat the title inside the body**.
- [ ] Attach artifacts: `npm pack` tarball (optional), `OpenClaw-X.Y.Z.zip`, and `OpenClaw-X.Y.Z.dSYM.zip` (if generated).
- [ ] Commit the updated `appcast.xml` and push it (Sparkle feeds from main).

View File

@@ -80,10 +80,10 @@ See [Memory](/concepts/memory).
`web_search` uses API keys and may incur usage charges depending on your provider:
- **Brave Search API**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
- **Gemini (Google Search)**: `GEMINI_API_KEY` or `tools.web.search.gemini.apiKey`
- **Grok (xAI)**: `XAI_API_KEY` or `tools.web.search.grok.apiKey`
- **Kimi (Moonshot)**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey`
- **Perplexity Search API**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey`
- **Gemini (Google Search)**: `GEMINI_API_KEY`
- **Grok (xAI)**: `XAI_API_KEY`
- **Kimi (Moonshot)**: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
- **Perplexity Search API**: `PERPLEXITY_API_KEY`
**Brave Search free credit:** Each Brave plan includes $5/month in renewing
free credit. The Search plan costs $5 per 1,000 requests, so the credit covers

View File

@@ -31,7 +31,6 @@ Scope intent:
- `talk.providers.*.apiKey`
- `messages.tts.elevenlabs.apiKey`
- `messages.tts.openai.apiKey`
- `tools.web.fetch.firecrawl.apiKey`
- `tools.web.search.apiKey`
- `tools.web.search.gemini.apiKey`
- `tools.web.search.grok.apiKey`
@@ -103,8 +102,7 @@ Notes:
- For SecretRef-managed model providers, generated `agents/*/agent/models.json` entries persist non-secret markers (not resolved secret values) for `apiKey`/header surfaces.
- For web search:
- In explicit provider mode (`tools.web.search.provider` set), only the selected provider key is active.
- In auto mode (`tools.web.search.provider` unset), only the first provider key that resolves by precedence is active.
- In auto mode, non-selected provider refs are treated as inactive until selected.
- In auto mode (`tools.web.search.provider` unset), `tools.web.search.apiKey` and provider-specific keys are active.
## Unsupported credentials

View File

@@ -454,13 +454,6 @@
"secretShape": "secret_input",
"optIn": true
},
{
"id": "tools.web.fetch.firecrawl.apiKey",
"configFile": "openclaw.json",
"path": "tools.web.fetch.firecrawl.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "tools.web.search.apiKey",
"configFile": "openclaw.json",

View File

@@ -38,7 +38,7 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard).
- Sets `agents.defaults.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`.
- **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then stores it in auth profiles.
- **xAI (Grok) API key**: prompts for `XAI_API_KEY` and configures xAI as a model provider.
- **OpenCode**: prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`, get it at https://opencode.ai/auth) and lets you pick the Zen or Go catalog.
- **OpenCode Zen (multi-model proxy)**: prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`, get it at https://opencode.ai/auth).
- **API key**: stores the key for you.
- **Vercel AI Gateway (multi-model proxy)**: prompts for `AI_GATEWAY_API_KEY`.
- More detail: [Vercel AI Gateway](/providers/vercel-ai-gateway)
@@ -228,7 +228,7 @@ openclaw onboard --non-interactive \
--gateway-bind loopback
```
</Accordion>
<Accordion title="OpenCode example">
<Accordion title="OpenCode Zen example">
```bash
openclaw onboard --non-interactive \
--mode local \
@@ -237,7 +237,6 @@ openclaw onboard --non-interactive \
--gateway-port 18789 \
--gateway-bind loopback
```
Swap to `--auth-choice opencode-go --opencode-go-api-key "$OPENCODE_API_KEY"` for the Go catalog.
</Accordion>
</AccordionGroup>

View File

@@ -127,7 +127,7 @@ openclaw health
Use this when debugging auth or deciding what to back up:
- **WhatsApp**: `~/.openclaw/credentials/whatsapp/<accountId>/creds.json`
- **Telegram bot token**: config/env or `channels.telegram.tokenFile` (regular file only; symlinks rejected)
- **Telegram bot token**: config/env or `channels.telegram.tokenFile`
- **Discord bot token**: config/env or SecretRef (env/file/exec providers)
- **Slack tokens**: config/env (`channels.slack.*`)
- **Pairing allowlists**:

View File

@@ -123,7 +123,7 @@ openclaw onboard --non-interactive \
--gateway-bind loopback
```
</Accordion>
<Accordion title="OpenCode example">
<Accordion title="OpenCode Zen example">
```bash
openclaw onboard --non-interactive \
--mode local \
@@ -132,7 +132,6 @@ openclaw onboard --non-interactive \
--gateway-port 18789 \
--gateway-bind loopback
```
Swap to `--auth-choice opencode-go --opencode-go-api-key "$OPENCODE_API_KEY"` for the Go catalog.
</Accordion>
<Accordion title="Custom provider example">
```bash

View File

@@ -155,8 +155,8 @@ What you set:
<Accordion title="xAI (Grok) API key">
Prompts for `XAI_API_KEY` and configures xAI as a model provider.
</Accordion>
<Accordion title="OpenCode">
Prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`) and lets you choose the Zen or Go catalog.
<Accordion title="OpenCode Zen">
Prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`).
Setup URL: [opencode.ai/auth](https://opencode.ai/auth).
</Accordion>
<Accordion title="API key (generic)">

View File

@@ -243,76 +243,9 @@ Interface details:
- `mode: "session"` requires `thread: true`
- `cwd` (optional): requested runtime working directory (validated by backend/runtime policy).
- `label` (optional): operator-facing label used in session/banner text.
- `resumeSessionId` (optional): resume an existing ACP session instead of creating a new one. The agent replays its conversation history via `session/load`. Requires `runtime: "acp"`.
- `streamTo` (optional): `"parent"` streams initial ACP run progress summaries back to the requester session as system events.
- When available, accepted responses include `streamLogPath` pointing to a session-scoped JSONL log (`<sessionId>.acp-stream.jsonl`) you can tail for full relay history.
### Resume an existing session
Use `resumeSessionId` to continue a previous ACP session instead of starting fresh. The agent replays its conversation history via `session/load`, so it picks up with full context of what came before.
```json
{
"task": "Continue where we left off — fix the remaining test failures",
"runtime": "acp",
"agentId": "codex",
"resumeSessionId": "<previous-session-id>"
}
```
Common use cases:
- Hand off a Codex session from your laptop to your phone — tell your agent to pick up where you left off
- Continue a coding session you started interactively in the CLI, now headlessly through your agent
- Pick up work that was interrupted by a gateway restart or idle timeout
Notes:
- `resumeSessionId` requires `runtime: "acp"` — returns an error if used with the sub-agent runtime.
- `resumeSessionId` restores the upstream ACP conversation history; `thread` and `mode` still apply normally to the new OpenClaw session you are creating, so `mode: "session"` still requires `thread: true`.
- The target agent must support `session/load` (Codex and Claude Code do).
- If the session ID isn't found, the spawn fails with a clear error — no silent fallback to a new session.
### Operator smoke test
Use this after a gateway deploy when you want a quick live check that ACP spawn
is actually working end-to-end, not just passing unit tests.
Recommended gate:
1. Verify the deployed gateway version/commit on the target host.
2. Confirm the deployed source includes the ACP lineage acceptance in
`src/gateway/sessions-patch.ts` (`subagent:* or acp:* sessions`).
3. Open a temporary ACPX bridge session to a live agent (for example
`razor(main)` on `jpclawhq`).
4. Ask that agent to call `sessions_spawn` with:
- `runtime: "acp"`
- `agentId: "codex"`
- `mode: "run"`
- task: `Reply with exactly LIVE-ACP-SPAWN-OK`
5. Verify the agent reports:
- `accepted=yes`
- a real `childSessionKey`
- no validator error
6. Clean up the temporary ACPX bridge session.
Example prompt to the live agent:
```text
Use the sessions_spawn tool now with runtime: "acp", agentId: "codex", and mode: "run".
Set the task to: "Reply with exactly LIVE-ACP-SPAWN-OK".
Then report only: accepted=<yes/no>; childSessionKey=<value or none>; error=<exact text or none>.
```
Notes:
- Keep this smoke test on `mode: "run"` unless you are intentionally testing
thread-bound persistent ACP sessions.
- Do not require `streamTo: "parent"` for the basic gate. That path depends on
requester/session capabilities and is a separate integration check.
- Treat thread-bound `mode: "session"` testing as a second, richer integration
pass from a real Discord thread or Telegram topic.
## Sandbox compatibility
ACP sessions currently run on the host runtime, not inside the OpenClaw sandbox.

View File

@@ -30,14 +30,9 @@ Trust model note:
- Gateway-authenticated callers are trusted operators for that Gateway.
- Paired nodes extend that trusted operator capability onto the node host.
- Exec approvals reduce accidental execution risk, but are not a per-user auth boundary.
- Approved node-host runs bind canonical execution context: canonical cwd, exact argv, env
binding when present, and pinned executable path when applicable.
- For shell scripts and direct interpreter/runtime file invocations, OpenClaw also tries to bind
one concrete local file operand. If that bound file changes after approval but before execution,
the run is denied instead of executing drifted content.
- This file binding is intentionally best-effort, not a complete semantic model of every
interpreter/runtime loader path. If approval mode cannot identify exactly one concrete local
file to bind, it refuses to mint an approval-backed run instead of pretending full coverage.
- Approved node-host runs also bind canonical execution context: canonical cwd, pinned executable
path when applicable, and interpreter-style script operands. If a bound script changes after
approval but before execution, the run is denied instead of executing drifted content.
macOS split:
@@ -264,20 +259,6 @@ For `host=node`, approval requests include a canonical `systemRunPlan` payload.
that plan as the authoritative command/cwd/session context when forwarding approved `system.run`
requests.
## Interpreter/runtime commands
Approval-backed interpreter/runtime runs are intentionally conservative:
- Exact argv/cwd/env context is always bound.
- Direct shell script and direct runtime file forms are best-effort bound to one concrete local
file snapshot.
- If OpenClaw cannot identify exactly one concrete local file for an interpreter/runtime command
(for example package scripts, eval forms, runtime-specific loader chains, or ambiguous multi-file
forms), approval-backed execution is denied instead of claiming semantic coverage it does not
have.
- For those workflows, prefer sandboxing, a separate host boundary, or an explicit trusted
allowlist/full workflow where the operator accepts the broader runtime semantics.
When approvals are required, the exec tool returns immediately with an approval id. Use that id to
correlate later system events (`Exec finished` / `Exec denied`). If no decision arrives before the
timeout, the request is treated as an approval timeout and surfaced as a denial reason.
@@ -328,32 +309,6 @@ Reply in chat:
/approve <id> deny
```
### Built-in chat approval clients
Discord and Telegram can also act as explicit exec approval clients with channel-specific config.
- Discord: `channels.discord.execApprovals.*`
- Telegram: `channels.telegram.execApprovals.*`
These clients are opt-in. If a channel does not have exec approvals enabled, OpenClaw does not treat
that channel as an approval surface just because the conversation happened there.
Shared behavior:
- only configured approvers can approve or deny
- the requester does not need to be an approver
- when channel delivery is enabled, approval prompts include the command text
- if no operator UI or configured approval client can accept the request, the prompt falls back to `askFallback`
Telegram defaults to approver DMs (`target: "dm"`). You can switch to `channel` or `both` when you
want approval prompts to appear in the originating Telegram chat/topic as well. For Telegram forum
topics, OpenClaw preserves the topic for the approval prompt and the post-approval follow-up.
See:
- [Discord](/channels/discord#exec-approvals-in-discord)
- [Telegram](/channels/telegram#exec-approvals-in-telegram)
### macOS IPC flow
```

View File

@@ -40,8 +40,7 @@ with JS-heavy sites or pages that block plain HTTP fetches.
Notes:
- `firecrawl.enabled` defaults to `true` unless explicitly set to `false`.
- Firecrawl fallback attempts run only when an API key is available (`tools.web.fetch.firecrawl.apiKey` or `FIRECRAWL_API_KEY`).
- `firecrawl.enabled` defaults to true when an API key is present.
- `maxAgeMs` controls how old cached results can be (ms). Default is 2 days.
## Stealth / bot circumvention

View File

@@ -123,7 +123,6 @@ Notes:
- `/new <model>` accepts a model alias, `provider/model`, or a provider name (fuzzy match); if no match, the text is treated as the message body.
- For full provider usage breakdown, use `openclaw status --usage`.
- `/allowlist add|remove` requires `commands.config=true` and honors channel `configWrites`.
- In multi-account channels, config-targeted `/allowlist --account <id>` and `/config set channels.<provider>.accounts.<id>...` also honor the target account's `configWrites`.
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from OpenClaw session logs.
- `/restart` is enabled by default; set `commands.restart: false` to disable it.
- Discord-only native command: `/vc join|leave|status` controls voice channels (requires `channels.discord.voice` and native commands; not available as text).

View File

@@ -182,7 +182,6 @@ Each level only sees announces from its direct children.
### Tool policy by depth
- Role and control scope are written into session metadata at spawn time. That keeps flat or restored session keys from accidentally regaining orchestrator privileges.
- **Depth 1 (orchestrator, when `maxSpawnDepth >= 2`)**: Gets `sessions_spawn`, `subagents`, `sessions_list`, `sessions_history` so it can manage its children. Other session/system tools remain denied.
- **Depth 1 (leaf, when `maxSpawnDepth == 1`)**: No session tools (current default behavior).
- **Depth 2 (leaf worker)**: No session tools — `sessions_spawn` is always denied at depth 2. Cannot spawn further children.

View File

@@ -2,7 +2,7 @@
summary: "Web search + fetch tools (Brave, Gemini, Grok, Kimi, and Perplexity providers)"
read_when:
- You want to enable web_search or web_fetch
- You need provider API key setup
- You need Brave or Perplexity Search API key setup
- You want to use Gemini with Google Search grounding
title: "Web Tools"
---
@@ -49,12 +49,6 @@ The table above is alphabetical. If no `provider` is explicitly set, runtime aut
If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one).
Runtime SecretRef behavior:
- Web tool SecretRefs are resolved atomically at gateway startup/reload.
- In auto-detect mode, OpenClaw resolves only the selected provider key. Non-selected provider SecretRefs stay inactive until selected.
- If the selected provider SecretRef is unresolved and no provider env fallback exists, startup/reload fails fast.
## Setting up web search
Use `openclaw configure --section web` to set up your API key and choose a provider.
@@ -83,25 +77,9 @@ See [Perplexity Search API Docs](https://docs.perplexity.ai/guides/search-quicks
### Where to store the key
**Via config:** run `openclaw configure --section web`. It stores the key under the provider-specific config path:
**Via config:** run `openclaw configure --section web`. It stores the key under `tools.web.search.apiKey` or `tools.web.search.perplexity.apiKey`, depending on provider.
- Brave: `tools.web.search.apiKey`
- Gemini: `tools.web.search.gemini.apiKey`
- Grok: `tools.web.search.grok.apiKey`
- Kimi: `tools.web.search.kimi.apiKey`
- Perplexity: `tools.web.search.perplexity.apiKey`
All of these fields also support SecretRef objects.
**Via environment:** set provider env vars in the Gateway process environment:
- Brave: `BRAVE_API_KEY`
- Gemini: `GEMINI_API_KEY`
- Grok: `XAI_API_KEY`
- Kimi: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
- Perplexity: `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
For a gateway install, put these in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
**Via environment:** set `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `BRAVE_API_KEY` in the Gateway process environment. For a gateway install, put it in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
### Config examples
@@ -238,7 +216,6 @@ Search the web using your configured provider.
- **Grok**: `XAI_API_KEY` or `tools.web.search.grok.apiKey`
- **Kimi**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey`
- **Perplexity**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey`
- All provider key fields above support SecretRef objects.
### Config
@@ -333,7 +310,6 @@ Fetch a URL and extract readable content.
- `tools.web.fetch.enabled` must not be `false` (default: enabled)
- Optional Firecrawl fallback: set `tools.web.fetch.firecrawl.apiKey` or `FIRECRAWL_API_KEY`.
- `tools.web.fetch.firecrawl.apiKey` supports SecretRef objects.
### web_fetch config
@@ -375,8 +351,6 @@ Notes:
- `web_fetch` uses Readability (main-content extraction) first, then Firecrawl (if configured). If both fail, the tool returns an error.
- Firecrawl requests use bot-circumvention mode and cache results by default.
- Firecrawl SecretRefs are resolved only when Firecrawl is active (`tools.web.fetch.enabled !== false` and `tools.web.fetch.firecrawl.enabled !== false`).
- If Firecrawl is active and its SecretRef is unresolved with no `FIRECRAWL_API_KEY` fallback, startup/reload fails fast.
- `web_fetch` sends a Chrome-like User-Agent and `Accept-Language` by default; override `userAgent` if needed.
- `web_fetch` blocks private/internal hostnames and re-checks redirects (limit with `maxRedirects`).
- `maxChars` is clamped to `tools.web.fetch.maxCharsCap`.

View File

@@ -174,12 +174,7 @@ OpenClaw **blocks** Control UI connections without device identity.
}
```
`allowInsecureAuth` is a local compatibility toggle only:
- It allows localhost Control UI sessions to proceed without device identity in
non-secure HTTP contexts.
- It does not bypass pairing checks.
- It does not relax remote (non-localhost) device identity requirements.
`allowInsecureAuth` does not bypass Control UI device identity or pairing checks.
**Break-glass only:**

View File

@@ -45,8 +45,6 @@ Prefer localhost, Tailscale Serve, or an SSH tunnel.
## If you see “unauthorized” / 1008
- Ensure the gateway is reachable (local: `openclaw status`; remote: SSH tunnel `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/`).
- For `AUTH_TOKEN_MISMATCH`, clients may do one trusted retry with a cached device token when the gateway returns retry hints. If auth still fails after that retry, resolve token drift manually.
- For token drift repair steps, follow [Token drift recovery checklist](/cli/devices#token-drift-recovery-checklist).
- Retrieve or supply the token from the gateway host:
- Plaintext config: `openclaw config get gateway.auth.token`
- SecretRef-managed config: resolve the external secret provider or export `OPENCLAW_GATEWAY_TOKEN` in this shell, then rerun `openclaw dashboard`

View File

@@ -67,7 +67,7 @@
},
"expectedVersion": {
"label": "Expected acpx Version",
"help": "Exact version to enforce (for example 0.1.16) or \"any\" to skip strict version matching."
"help": "Exact version to enforce (for example 0.1.15) or \"any\" to skip strict version matching."
},
"cwd": {
"label": "Default Working Directory",

View File

@@ -4,7 +4,7 @@
"description": "OpenClaw ACP runtime backend via acpx",
"type": "module",
"dependencies": {
"acpx": "0.1.16"
"acpx": "0.1.15"
},
"openclaw": {
"extensions": [

View File

@@ -5,6 +5,7 @@ import {
ACPX_PINNED_VERSION,
createAcpxPluginConfigSchema,
resolveAcpxPluginConfig,
toAcpMcpServers,
} from "./config.js";
describe("acpx plugin config parsing", () => {
@@ -19,9 +20,9 @@ describe("acpx plugin config parsing", () => {
expect(resolved.command).toBe(ACPX_BUNDLED_BIN);
expect(resolved.expectedVersion).toBe(ACPX_PINNED_VERSION);
expect(resolved.allowPluginLocalInstall).toBe(true);
expect(resolved.stripProviderAuthEnvVars).toBe(true);
expect(resolved.cwd).toBe(path.resolve("/tmp/workspace"));
expect(resolved.strictWindowsCmdWrapper).toBe(true);
expect(resolved.mcpServers).toEqual({});
});
it("accepts command override and disables plugin-local auto-install", () => {
@@ -36,7 +37,6 @@ describe("acpx plugin config parsing", () => {
expect(resolved.command).toBe(path.resolve(command));
expect(resolved.expectedVersion).toBeUndefined();
expect(resolved.allowPluginLocalInstall).toBe(false);
expect(resolved.stripProviderAuthEnvVars).toBe(false);
});
it("resolves relative command paths against workspace directory", () => {
@@ -50,7 +50,6 @@ describe("acpx plugin config parsing", () => {
expect(resolved.command).toBe(path.resolve("/home/user/repos/openclaw", "../acpx/dist/cli.js"));
expect(resolved.expectedVersion).toBeUndefined();
expect(resolved.allowPluginLocalInstall).toBe(false);
expect(resolved.stripProviderAuthEnvVars).toBe(false);
});
it("keeps bare command names as-is", () => {
@@ -64,7 +63,6 @@ describe("acpx plugin config parsing", () => {
expect(resolved.command).toBe("acpx");
expect(resolved.expectedVersion).toBeUndefined();
expect(resolved.allowPluginLocalInstall).toBe(false);
expect(resolved.stripProviderAuthEnvVars).toBe(false);
});
it("accepts exact expectedVersion override", () => {
@@ -80,7 +78,6 @@ describe("acpx plugin config parsing", () => {
expect(resolved.command).toBe(path.resolve(command));
expect(resolved.expectedVersion).toBe("0.1.99");
expect(resolved.allowPluginLocalInstall).toBe(false);
expect(resolved.stripProviderAuthEnvVars).toBe(false);
});
it("treats expectedVersion=any as no version constraint", () => {
@@ -137,4 +134,97 @@ describe("acpx plugin config parsing", () => {
}),
).toThrow("strictWindowsCmdWrapper must be a boolean");
});
it("accepts mcp server maps", () => {
const resolved = resolveAcpxPluginConfig({
rawConfig: {
mcpServers: {
canva: {
command: "npx",
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
env: {
CANVA_TOKEN: "secret",
},
},
},
},
workspaceDir: "/tmp/workspace",
});
expect(resolved.mcpServers).toEqual({
canva: {
command: "npx",
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
env: {
CANVA_TOKEN: "secret",
},
},
});
});
it("rejects invalid mcp server definitions", () => {
expect(() =>
resolveAcpxPluginConfig({
rawConfig: {
mcpServers: {
canva: {
command: "npx",
args: ["-y", 1],
},
},
},
workspaceDir: "/tmp/workspace",
}),
).toThrow(
"mcpServers.canva must have a command string, optional args array, and optional env object",
);
});
it("schema accepts mcp server config", () => {
const schema = createAcpxPluginConfigSchema();
if (!schema.safeParse) {
throw new Error("acpx config schema missing safeParse");
}
const parsed = schema.safeParse({
mcpServers: {
canva: {
command: "npx",
args: ["-y", "mcp-remote@latest"],
env: {
CANVA_TOKEN: "secret",
},
},
},
});
expect(parsed.success).toBe(true);
});
});
describe("toAcpMcpServers", () => {
it("converts plugin config maps into ACP stdio MCP entries", () => {
expect(
toAcpMcpServers({
canva: {
command: "npx",
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
env: {
CANVA_TOKEN: "secret",
},
},
}),
).toEqual([
{
name: "canva",
command: "npx",
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
env: [
{
name: "CANVA_TOKEN",
value: "secret",
},
],
},
]);
});
});

View File

@@ -8,7 +8,7 @@ export type AcpxPermissionMode = (typeof ACPX_PERMISSION_MODES)[number];
export const ACPX_NON_INTERACTIVE_POLICIES = ["deny", "fail"] as const;
export type AcpxNonInteractivePermissionPolicy = (typeof ACPX_NON_INTERACTIVE_POLICIES)[number];
export const ACPX_PINNED_VERSION = "0.1.16";
export const ACPX_PINNED_VERSION = "0.1.15";
export const ACPX_VERSION_ANY = "any";
const ACPX_BIN_NAME = process.platform === "win32" ? "acpx.cmd" : "acpx";
export const ACPX_PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -47,7 +47,6 @@ export type ResolvedAcpxPluginConfig = {
command: string;
expectedVersion?: string;
allowPluginLocalInstall: boolean;
stripProviderAuthEnvVars: boolean;
installCommand: string;
cwd: string;
permissionMode: AcpxPermissionMode;
@@ -333,7 +332,6 @@ export function resolveAcpxPluginConfig(params: {
workspaceDir: params.workspaceDir,
});
const allowPluginLocalInstall = command === ACPX_BUNDLED_BIN;
const stripProviderAuthEnvVars = command === ACPX_BUNDLED_BIN;
const configuredExpectedVersion = normalized.expectedVersion;
const expectedVersion =
configuredExpectedVersion === ACPX_VERSION_ANY
@@ -345,7 +343,6 @@ export function resolveAcpxPluginConfig(params: {
command,
expectedVersion,
allowPluginLocalInstall,
stripProviderAuthEnvVars,
installCommand,
cwd,
permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE,

View File

@@ -77,7 +77,6 @@ describe("acpx ensure", () => {
command: "/plugin/node_modules/.bin/acpx",
args: ["--version"],
cwd: "/plugin",
stripProviderAuthEnvVars: undefined,
});
});
@@ -149,30 +148,6 @@ describe("acpx ensure", () => {
command: "/custom/acpx",
args: ["--help"],
cwd: "/custom",
stripProviderAuthEnvVars: undefined,
});
});
it("forwards stripProviderAuthEnvVars to version checks", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: "Usage: acpx [options]\n",
stderr: "",
code: 0,
error: null,
});
await checkAcpxVersion({
command: "/plugin/node_modules/.bin/acpx",
cwd: "/plugin",
expectedVersion: undefined,
stripProviderAuthEnvVars: true,
});
expect(spawnAndCollectMock).toHaveBeenCalledWith({
command: "/plugin/node_modules/.bin/acpx",
args: ["--help"],
cwd: "/plugin",
stripProviderAuthEnvVars: true,
});
});
@@ -211,54 +186,6 @@ describe("acpx ensure", () => {
});
});
it("threads stripProviderAuthEnvVars through version probes and install", async () => {
spawnAndCollectMock
.mockResolvedValueOnce({
stdout: "acpx 0.0.9\n",
stderr: "",
code: 0,
error: null,
})
.mockResolvedValueOnce({
stdout: "added 1 package\n",
stderr: "",
code: 0,
error: null,
})
.mockResolvedValueOnce({
stdout: `acpx ${ACPX_PINNED_VERSION}\n`,
stderr: "",
code: 0,
error: null,
});
await ensureAcpx({
command: "/plugin/node_modules/.bin/acpx",
pluginRoot: "/plugin",
expectedVersion: ACPX_PINNED_VERSION,
stripProviderAuthEnvVars: true,
});
expect(spawnAndCollectMock.mock.calls[0]?.[0]).toMatchObject({
command: "/plugin/node_modules/.bin/acpx",
args: ["--version"],
cwd: "/plugin",
stripProviderAuthEnvVars: true,
});
expect(spawnAndCollectMock.mock.calls[1]?.[0]).toMatchObject({
command: "npm",
args: ["install", "--omit=dev", "--no-save", `acpx@${ACPX_PINNED_VERSION}`],
cwd: "/plugin",
stripProviderAuthEnvVars: true,
});
expect(spawnAndCollectMock.mock.calls[2]?.[0]).toMatchObject({
command: "/plugin/node_modules/.bin/acpx",
args: ["--version"],
cwd: "/plugin",
stripProviderAuthEnvVars: true,
});
});
it("fails with actionable error when npm install fails", async () => {
spawnAndCollectMock
.mockResolvedValueOnce({

View File

@@ -102,7 +102,6 @@ export async function checkAcpxVersion(params: {
command: string;
cwd?: string;
expectedVersion?: string;
stripProviderAuthEnvVars?: boolean;
spawnOptions?: SpawnCommandOptions;
}): Promise<AcpxVersionCheckResult> {
const expectedVersion = params.expectedVersion?.trim() || undefined;
@@ -114,7 +113,6 @@ export async function checkAcpxVersion(params: {
command: params.command,
args: probeArgs,
cwd,
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
};
let result: Awaited<ReturnType<typeof spawnAndCollect>>;
try {
@@ -200,7 +198,6 @@ export async function ensureAcpx(params: {
pluginRoot?: string;
expectedVersion?: string;
allowInstall?: boolean;
stripProviderAuthEnvVars?: boolean;
spawnOptions?: SpawnCommandOptions;
}): Promise<void> {
if (pendingEnsure) {
@@ -217,7 +214,6 @@ export async function ensureAcpx(params: {
command: params.command,
cwd: pluginRoot,
expectedVersion,
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
spawnOptions: params.spawnOptions,
});
if (precheck.ok) {
@@ -235,7 +231,6 @@ export async function ensureAcpx(params: {
command: "npm",
args: ["install", "--omit=dev", "--no-save", `acpx@${installVersion}`],
cwd: pluginRoot,
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
});
if (install.error) {
@@ -257,7 +252,6 @@ export async function ensureAcpx(params: {
command: params.command,
cwd: pluginRoot,
expectedVersion,
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
spawnOptions: params.spawnOptions,
});

View File

@@ -1,59 +0,0 @@
import { describe, expect, it, vi } from "vitest";
const { spawnAndCollectMock } = vi.hoisted(() => ({
spawnAndCollectMock: vi.fn(),
}));
vi.mock("./process.js", () => ({
spawnAndCollect: spawnAndCollectMock,
}));
import { __testing, resolveAcpxAgentCommand } from "./mcp-agent-command.js";
describe("resolveAcpxAgentCommand", () => {
it("threads stripProviderAuthEnvVars through the config show probe", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: JSON.stringify({
agents: {
codex: {
command: "custom-codex",
},
},
}),
stderr: "",
code: 0,
error: null,
});
const command = await resolveAcpxAgentCommand({
acpxCommand: "/plugin/node_modules/.bin/acpx",
cwd: "/plugin",
agent: "codex",
stripProviderAuthEnvVars: true,
});
expect(command).toBe("custom-codex");
expect(spawnAndCollectMock).toHaveBeenCalledWith(
{
command: "/plugin/node_modules/.bin/acpx",
args: ["--cwd", "/plugin", "config", "show"],
cwd: "/plugin",
stripProviderAuthEnvVars: true,
},
undefined,
);
});
});
describe("buildMcpProxyAgentCommand", () => {
it("escapes Windows-style proxy paths without double-escaping backslashes", () => {
const quoted = __testing.quoteCommandPart(
"C:\\repo\\extensions\\acpx\\src\\runtime-internals\\mcp-proxy.mjs",
);
expect(quoted).toBe(
'"C:\\\\repo\\\\extensions\\\\acpx\\\\src\\\\runtime-internals\\\\mcp-proxy.mjs"',
);
expect(quoted).not.toContain("\\\\\\");
});
});

View File

@@ -37,10 +37,6 @@ function quoteCommandPart(value: string): string {
return `"${value.replace(/["\\]/g, "\\$&")}"`;
}
export const __testing = {
quoteCommandPart,
};
function toCommandLine(parts: string[]): string {
return parts.map(quoteCommandPart).join(" ");
}
@@ -66,7 +62,6 @@ function readConfiguredAgentOverrides(value: unknown): Record<string, string> {
async function loadAgentOverrides(params: {
acpxCommand: string;
cwd: string;
stripProviderAuthEnvVars?: boolean;
spawnOptions?: SpawnCommandOptions;
}): Promise<Record<string, string>> {
const result = await spawnAndCollect(
@@ -74,7 +69,6 @@ async function loadAgentOverrides(params: {
command: params.acpxCommand,
args: ["--cwd", params.cwd, "config", "show"],
cwd: params.cwd,
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
},
params.spawnOptions,
);
@@ -93,14 +87,12 @@ export async function resolveAcpxAgentCommand(params: {
acpxCommand: string;
cwd: string;
agent: string;
stripProviderAuthEnvVars?: boolean;
spawnOptions?: SpawnCommandOptions;
}): Promise<string> {
const normalizedAgent = normalizeAgentName(params.agent);
const overrides = await loadAgentOverrides({
acpxCommand: params.acpxCommand,
cwd: params.cwd,
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
spawnOptions: params.spawnOptions,
});
return overrides[normalizedAgent] ?? ACPX_BUILTIN_AGENT_COMMANDS[normalizedAgent] ?? params.agent;

View File

@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { createWindowsCmdShimFixture } from "../../../shared/windows-cmd-shim-test-fixtures.js";
import {
resolveSpawnCommand,
@@ -28,7 +28,6 @@ async function createTempDir(): Promise<string> {
}
afterEach(async () => {
vi.unstubAllEnvs();
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) {
@@ -290,99 +289,4 @@ describe("spawnAndCollect", () => {
const result = await resultPromise;
expect(result.error?.name).toBe("AbortError");
});
it("strips shared provider auth env vars from spawned acpx children", async () => {
vi.stubEnv("OPENAI_API_KEY", "openai-secret");
vi.stubEnv("GITHUB_TOKEN", "gh-secret");
vi.stubEnv("HF_TOKEN", "hf-secret");
vi.stubEnv("OPENCLAW_API_KEY", "keep-me");
const result = await spawnAndCollect({
command: process.execPath,
args: [
"-e",
"process.stdout.write(JSON.stringify({openai:process.env.OPENAI_API_KEY,github:process.env.GITHUB_TOKEN,hf:process.env.HF_TOKEN,openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}))",
],
cwd: process.cwd(),
stripProviderAuthEnvVars: true,
});
expect(result.code).toBe(0);
expect(result.error).toBeNull();
const parsed = JSON.parse(result.stdout) as {
openai?: string;
github?: string;
hf?: string;
openclaw?: string;
shell?: string;
};
expect(parsed.openai).toBeUndefined();
expect(parsed.github).toBeUndefined();
expect(parsed.hf).toBeUndefined();
expect(parsed.openclaw).toBe("keep-me");
expect(parsed.shell).toBe("acp");
});
it("strips provider auth env vars case-insensitively", async () => {
vi.stubEnv("OpenAI_Api_Key", "openai-secret");
vi.stubEnv("Github_Token", "gh-secret");
vi.stubEnv("OPENCLAW_API_KEY", "keep-me");
const result = await spawnAndCollect({
command: process.execPath,
args: [
"-e",
"process.stdout.write(JSON.stringify({openai:process.env.OpenAI_Api_Key,github:process.env.Github_Token,openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}))",
],
cwd: process.cwd(),
stripProviderAuthEnvVars: true,
});
expect(result.code).toBe(0);
expect(result.error).toBeNull();
const parsed = JSON.parse(result.stdout) as {
openai?: string;
github?: string;
openclaw?: string;
shell?: string;
};
expect(parsed.openai).toBeUndefined();
expect(parsed.github).toBeUndefined();
expect(parsed.openclaw).toBe("keep-me");
expect(parsed.shell).toBe("acp");
});
it("preserves provider auth env vars for explicit custom commands by default", async () => {
vi.stubEnv("OPENAI_API_KEY", "openai-secret");
vi.stubEnv("GITHUB_TOKEN", "gh-secret");
vi.stubEnv("HF_TOKEN", "hf-secret");
vi.stubEnv("OPENCLAW_API_KEY", "keep-me");
const result = await spawnAndCollect({
command: process.execPath,
args: [
"-e",
"process.stdout.write(JSON.stringify({openai:process.env.OPENAI_API_KEY,github:process.env.GITHUB_TOKEN,hf:process.env.HF_TOKEN,openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}))",
],
cwd: process.cwd(),
});
expect(result.code).toBe(0);
expect(result.error).toBeNull();
const parsed = JSON.parse(result.stdout) as {
openai?: string;
github?: string;
hf?: string;
openclaw?: string;
shell?: string;
};
expect(parsed.openai).toBe("openai-secret");
expect(parsed.github).toBe("gh-secret");
expect(parsed.hf).toBe("hf-secret");
expect(parsed.openclaw).toBe("keep-me");
expect(parsed.shell).toBe("acp");
});
});

View File

@@ -7,9 +7,7 @@ import type {
} from "openclaw/plugin-sdk/acpx";
import {
applyWindowsSpawnProgramPolicy,
listKnownProviderAuthEnvVarNames,
materializeWindowsSpawnProgram,
omitEnvKeysCaseInsensitive,
resolveWindowsSpawnProgramCandidate,
} from "openclaw/plugin-sdk/acpx";
@@ -127,7 +125,6 @@ export function spawnWithResolvedCommand(
command: string;
args: string[];
cwd: string;
stripProviderAuthEnvVars?: boolean;
},
options?: SpawnCommandOptions,
): ChildProcessWithoutNullStreams {
@@ -139,15 +136,9 @@ export function spawnWithResolvedCommand(
options,
);
const childEnv = omitEnvKeysCaseInsensitive(
process.env,
params.stripProviderAuthEnvVars ? listKnownProviderAuthEnvVarNames() : [],
);
childEnv.OPENCLAW_SHELL = "acp";
return spawn(resolved.command, resolved.args, {
cwd: params.cwd,
env: childEnv,
env: { ...process.env, OPENCLAW_SHELL: "acp" },
stdio: ["pipe", "pipe", "pipe"],
shell: resolved.shell,
windowsHide: resolved.windowsHide,
@@ -189,7 +180,6 @@ export async function spawnAndCollect(
command: string;
args: string[];
cwd: string;
stripProviderAuthEnvVars?: boolean;
},
options?: SpawnCommandOptions,
runtime?: {

View File

@@ -1,6 +1,6 @@
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js";
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
import {
@@ -19,14 +19,13 @@ beforeAll(async () => {
{
command: "/definitely/missing/acpx",
allowPluginLocalInstall: false,
stripProviderAuthEnvVars: false,
installCommand: "n/a",
cwd: process.cwd(),
mcpServers: {},
permissionMode: "approve-reads",
nonInteractivePermissions: "fail",
strictWindowsCmdWrapper: true,
queueOwnerTtlSeconds: 0.1,
mcpServers: {},
},
{ logger: NOOP_LOGGER },
);
@@ -128,99 +127,6 @@ describe("AcpxRuntime", () => {
expect(promptArgs).toContain("--approve-all");
});
it("uses sessions new with --resume-session when resumeSessionId is provided", async () => {
const { runtime, logPath } = await createMockRuntimeFixture();
const resumeSessionId = "sid-resume-123";
const sessionKey = "agent:codex:acp:resume";
const handle = await runtime.ensureSession({
sessionKey,
agent: "codex",
mode: "persistent",
resumeSessionId,
});
expect(handle.backend).toBe("acpx");
expect(handle.acpxRecordId).toBe("rec-" + sessionKey);
const logs = await readMockRuntimeLogEntries(logPath);
expect(logs.some((entry) => entry.kind === "ensure")).toBe(false);
const resumeEntry = logs.find(
(entry) => entry.kind === "new" && String(entry.sessionName ?? "") === sessionKey,
);
expect(resumeEntry).toBeDefined();
const resumeArgs = (resumeEntry?.args as string[]) ?? [];
const resumeFlagIndex = resumeArgs.indexOf("--resume-session");
expect(resumeFlagIndex).toBeGreaterThanOrEqual(0);
expect(resumeArgs[resumeFlagIndex + 1]).toBe(resumeSessionId);
});
it("serializes text plus image attachments into ACP prompt blocks", async () => {
const { runtime, logPath } = await createMockRuntimeFixture();
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:with-image",
agent: "codex",
mode: "persistent",
});
for await (const _event of runtime.runTurn({
handle,
text: "describe this image",
attachments: [{ mediaType: "image/png", data: "aW1hZ2UtYnl0ZXM=" }], // pragma: allowlist secret
mode: "prompt",
requestId: "req-image",
})) {
// Consume stream to completion so prompt logging is finalized.
}
const logs = await readMockRuntimeLogEntries(logPath);
const prompt = logs.find(
(entry) =>
entry.kind === "prompt" && String(entry.sessionName ?? "") === "agent:codex:acp:with-image",
);
expect(prompt).toBeDefined();
const stdinBlocks = JSON.parse(String(prompt?.stdinText ?? ""));
expect(stdinBlocks).toEqual([
{ type: "text", text: "describe this image" },
{ type: "image", mimeType: "image/png", data: "aW1hZ2UtYnl0ZXM=" },
]);
});
it("preserves provider auth env vars when runtime uses a custom acpx command", async () => {
vi.stubEnv("OPENAI_API_KEY", "openai-secret"); // pragma: allowlist secret
vi.stubEnv("GITHUB_TOKEN", "gh-secret"); // pragma: allowlist secret
try {
const { runtime, logPath } = await createMockRuntimeFixture();
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:custom-env",
agent: "codex",
mode: "persistent",
});
for await (const _event of runtime.runTurn({
handle,
text: "custom-env",
mode: "prompt",
requestId: "req-custom-env",
})) {
// Drain events; assertions inspect the mock runtime log.
}
const logs = await readMockRuntimeLogEntries(logPath);
const prompt = logs.find(
(entry) =>
entry.kind === "prompt" &&
String(entry.sessionName ?? "") === "agent:codex:acp:custom-env",
);
expect(prompt?.openaiApiKey).toBe("openai-secret");
expect(prompt?.githubToken).toBe("gh-secret");
} finally {
vi.unstubAllEnvs();
}
});
it("preserves leading spaces across streamed text deltas", async () => {
const runtime = sharedFixture?.runtime;
expect(runtime).toBeDefined();
@@ -430,7 +336,7 @@ describe("AcpxRuntime", () => {
command: "npx",
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
env: {
CANVA_TOKEN: "secret", // pragma: allowlist secret
CANVA_TOKEN: "secret",
},
},
},

View File

@@ -170,7 +170,6 @@ export class AcpxRuntime implements AcpRuntime {
command: this.config.command,
cwd: this.config.cwd,
expectedVersion: this.config.expectedVersion,
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
spawnOptions: this.spawnCommandOptions,
});
if (!versionCheck.ok) {
@@ -184,7 +183,6 @@ export class AcpxRuntime implements AcpRuntime {
command: this.config.command,
args: ["--help"],
cwd: this.config.cwd,
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
},
this.spawnCommandOptions,
);
@@ -205,14 +203,10 @@ export class AcpxRuntime implements AcpRuntime {
}
const cwd = asTrimmedString(input.cwd) || this.config.cwd;
const mode = input.mode;
const resumeSessionId = asTrimmedString(input.resumeSessionId);
const ensureSubcommand = resumeSessionId
? ["sessions", "new", "--name", sessionName, "--resume-session", resumeSessionId]
: ["sessions", "ensure", "--name", sessionName];
const ensureCommand = await this.buildVerbArgs({
agent,
cwd,
command: ensureSubcommand,
command: ["sessions", "ensure", "--name", sessionName],
});
let events = await this.runControlCommand({
@@ -227,7 +221,7 @@ export class AcpxRuntime implements AcpRuntime {
asOptionalString(event.acpxRecordId),
);
if (!ensuredEvent && !resumeSessionId) {
if (!ensuredEvent) {
const newCommand = await this.buildVerbArgs({
agent,
cwd,
@@ -244,14 +238,12 @@ export class AcpxRuntime implements AcpRuntime {
asOptionalString(event.acpxSessionId) ||
asOptionalString(event.acpxRecordId),
);
}
if (!ensuredEvent) {
throw new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
resumeSessionId
? `ACP session init failed: 'sessions new --resume-session' returned no session identifiers for ${sessionName}.`
: `ACP session init failed: neither 'sessions ensure' nor 'sessions new' returned valid session identifiers for ${sessionName}.`,
);
if (!ensuredEvent) {
throw new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
`ACP session init failed: neither 'sessions ensure' nor 'sessions new' returned valid session identifiers for ${sessionName}.`,
);
}
}
const acpxRecordId = ensuredEvent ? asOptionalString(ensuredEvent.acpxRecordId) : undefined;
@@ -311,7 +303,6 @@ export class AcpxRuntime implements AcpRuntime {
command: this.config.command,
args,
cwd: state.cwd,
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
},
this.spawnCommandOptions,
);
@@ -319,20 +310,7 @@ export class AcpxRuntime implements AcpRuntime {
// Ignore EPIPE when the child exits before stdin flush completes.
});
if (input.attachments && input.attachments.length > 0) {
const blocks: unknown[] = [];
if (input.text) {
blocks.push({ type: "text", text: input.text });
}
for (const attachment of input.attachments) {
if (attachment.mediaType.startsWith("image/")) {
blocks.push({ type: "image", mimeType: attachment.mediaType, data: attachment.data });
}
}
child.stdin.end(blocks.length > 0 ? JSON.stringify(blocks) : input.text);
} else {
child.stdin.end(input.text);
}
child.stdin.end(input.text);
let stderr = "";
child.stderr.on("data", (chunk) => {
@@ -498,7 +476,6 @@ export class AcpxRuntime implements AcpRuntime {
command: this.config.command,
cwd: this.config.cwd,
expectedVersion: this.config.expectedVersion,
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
spawnOptions: this.spawnCommandOptions,
});
if (!versionCheck.ok) {
@@ -522,7 +499,6 @@ export class AcpxRuntime implements AcpRuntime {
command: this.config.command,
args: ["--help"],
cwd: this.config.cwd,
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
},
this.spawnCommandOptions,
);
@@ -688,7 +664,6 @@ export class AcpxRuntime implements AcpRuntime {
acpxCommand: this.config.command,
cwd: params.cwd,
agent: params.agent,
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
spawnOptions: this.spawnCommandOptions,
});
const resolved = buildMcpProxyAgentCommand({
@@ -711,7 +686,6 @@ export class AcpxRuntime implements AcpRuntime {
command: this.config.command,
args: params.args,
cwd: params.cwd,
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
},
this.spawnCommandOptions,
{

View File

@@ -89,11 +89,6 @@ describe("createAcpxRuntimeService", () => {
await vi.waitFor(() => {
expect(ensureAcpxSpy).toHaveBeenCalledOnce();
expect(ensureAcpxSpy).toHaveBeenCalledWith(
expect.objectContaining({
stripProviderAuthEnvVars: true,
}),
);
expect(probeAvailabilitySpy).toHaveBeenCalledOnce();
});

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