diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
deleted file mode 100644
index 082086ea079..00000000000
--- a/.github/FUNDING.yml
+++ /dev/null
@@ -1 +0,0 @@
-custom: ["https://github.com/sponsors/steipete"]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f7e55cf2faa..e6a3f5ec78a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,12 @@ Docs: https://docs.openclaw.ai
- 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.
- Memory/Gemini: add `gemini-embedding-2-preview` memory-search support with configurable output dimensions and automatic reindexing when the configured dimensions change. (#42501) thanks @BillChirico.
+- 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.
+- iOS/TestFlight: add a local beta release flow with Fastlane prepare/archive/upload support, canonical beta bundle IDs, and watch-app archive fixes. (#42991) Thanks @ngutman.
+- macOS/onboarding: detect when remote gateways need a shared auth token, explain where to find it on the gateway host, and clarify when a successful check used paired-device auth instead. (#43100) Thanks @ngutman.
+- Onboarding/Ollama: add first-class Ollama setup with Local or Cloud + Local modes, browser-based cloud sign-in, curated model suggestions, and cloud-model handling that skips unnecessary local pulls. (#41529) Thanks @BruceMacD.
### Breaking
@@ -26,6 +32,8 @@ Docs: https://docs.openclaw.ai
- 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.
@@ -76,8 +84,27 @@ Docs: https://docs.openclaw.ai
- 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. (#43045) Thanks @MoerAI.
+- fix(models): guard optional model.input capability checks (#42096) thanks @andyliu
+- Security/plugin runtime: stop unauthenticated plugin HTTP routes from inheriting synthetic admin gateway scopes when they call `runtime.subagent.*`, so admin-only methods like `sessions.delete` stay blocked without gateway auth.
+- Security/session_status: enforce sandbox session-tree visibility and shared agent-to-agent access guards before reading or mutating target session state, so sandboxed subagents can no longer inspect parent session metadata or write parent model overrides via `session_status`.
+- Security/nodes: treat the `nodes` agent tool as owner-only fallback policy so non-owner senders cannot reach paired-node approval or invoke paths through the shared tool set.
+- Telegram/final preview cleanup follow-up: clear stale cleanup-retain state only for transient preview finals so archived-preview retains no longer leave a stale partial bubble beside a later fallback-sent final. (#41763) Thanks @obviyus.
+- Signal/config schema: accept `channels.signal.accountUuid` in strict config validation so loop-protection configs no longer fail with an unrecognized-key error. (#35578) Thanks @ingyukoh.
+- Telegram/config schema: accept `channels.telegram.actions.editMessage` and `createForumTopic` in strict config validation so existing Telegram action toggles no longer fail as unrecognized keys. (#35498) Thanks @ingyukoh.
## 2026.3.8
@@ -133,6 +160,7 @@ 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.
@@ -141,11 +169,15 @@ Docs: https://docs.openclaw.ai
- Cron/owner-only tools: pass trusted isolated cron runs into the embedded agent with owner context so `cron`/`gateway` tooling remains available after the owner-auth hardening narrowed direct-message ownership inference.
- Browser/SSRF: block private-network intermediate redirect hops in strict browser navigation flows and fail closed when remote tab-open paths cannot inspect redirect chains. Thanks @zpbrent.
- MS Teams/authz: keep `groupPolicy: "allowlist"` enforcing sender allowlists even when a team/channel route allowlist is configured, so route matches no longer widen group access to every sender in that route. Thanks @zpbrent.
+- Security/Gateway: block `device.token.rotate` from minting operator scopes broader than the caller session already holds, closing the critical paired-device token privilege escalation reported as GHSA-4jpw-hj22-2xmc.
- 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.
+- macOS/browser proxy: serialize non-GET browser proxy request bodies through `AnyCodable.foundationValue` so nested JSON bodies no longer crash the macOS app with `Invalid type in JSON write (__SwiftValue)`. (#43069) Thanks @Effet.
+- CLI/skills tables: keep terminal table borders aligned for wide graphemes, use full reported terminal width, and switch a few ambiguous skill icons to Terminal-safe emoji so `openclaw skills` renders more consistently in Terminal.app and iTerm. Thanks @vincentkoc.
## 2026.3.7
@@ -212,6 +244,7 @@ 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.
@@ -505,6 +538,7 @@ Docs: https://docs.openclaw.ai
- 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.
+- Voice-call/OpenAI TTS config parity: add missing `speed`, `instructions`, and `baseUrl` fields to the OpenAI TTS config schema and gate `instructions` to supported models so voice-call overrides validate and route cleanly through core TTS. (#39226) Thanks @ademczuk.
## 2026.3.2
@@ -1012,6 +1046,7 @@ Docs: https://docs.openclaw.ai
- Browser/Navigate: resolve the correct `targetId` in navigate responses after renderer swaps. (#25326) Thanks @stone-jin and @vincentkoc.
- FS/Sandbox workspace boundaries: add a dedicated `outside-workspace` safe-open error code for root-escape checks, and propagate specific outside-workspace messages across edit/browser/media consumers instead of generic not-found/invalid-path fallbacks. (#29715) Thanks @YuzuruS.
- Diagnostics/Stuck session signal: add configurable stuck-session warning threshold via `diagnostics.stuckSessionWarnMs` (default 120000ms) to reduce false-positive warnings on long multi-tool turns. (#31032)
+- Agents/error classification: check billing errors before context overflow heuristics in the agent runner catch block so spend-limit and quota errors show the billing-specific message instead of being misclassified as "Context overflow: prompt too large". (#40409) Thanks @ademczuk.
## 2026.2.26
diff --git a/SECURITY.md b/SECURITY.md
index 5f1e8f0cb9e..204dadbf36d 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -125,6 +125,7 @@ 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.
@@ -165,6 +166,7 @@ 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.
diff --git a/apps/ios/ActivityWidget/Info.plist b/apps/ios/ActivityWidget/Info.plist
index 4c2d89e1566..4c965121bf9 100644
--- a/apps/ios/ActivityWidget/Info.plist
+++ b/apps/ios/ActivityWidget/Info.plist
@@ -17,9 +17,9 @@
CFBundlePackageType
XPC!
CFBundleShortVersionString
- 2026.3.9
+ $(OPENCLAW_MARKETING_VERSION)
CFBundleVersion
- 20260308
+ $(OPENCLAW_BUILD_VERSION)
NSExtension
NSExtensionPointIdentifier
diff --git a/apps/ios/Config/Signing.xcconfig b/apps/ios/Config/Signing.xcconfig
index 1285d2a38a4..4fef287a09d 100644
--- a/apps/ios/Config/Signing.xcconfig
+++ b/apps/ios/Config/Signing.xcconfig
@@ -1,10 +1,12 @@
// Shared iOS signing defaults for local development + CI.
+#include "Version.xcconfig"
+
OPENCLAW_IOS_DEFAULT_TEAM = Y5PE65HELJ
OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM)
-OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios
-OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.ios.watchkitapp
-OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.ios.watchkitapp.extension
-OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.ios.activitywidget
+OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
+OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
+OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
+OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
// Local contributors can override this by running scripts/ios-configure-signing.sh.
// Keep include after defaults: xcconfig is evaluated top-to-bottom.
diff --git a/apps/ios/Config/Version.xcconfig b/apps/ios/Config/Version.xcconfig
new file mode 100644
index 00000000000..db38e86df80
--- /dev/null
+++ b/apps/ios/Config/Version.xcconfig
@@ -0,0 +1,8 @@
+// Shared iOS version defaults.
+// Generated overrides live in build/Version.xcconfig (git-ignored).
+
+OPENCLAW_GATEWAY_VERSION = 0.0.0
+OPENCLAW_MARKETING_VERSION = 0.0.0
+OPENCLAW_BUILD_VERSION = 0
+
+#include? "../build/Version.xcconfig"
diff --git a/apps/ios/README.md b/apps/ios/README.md
index c7c501fcbff..42c5a51dec2 100644
--- a/apps/ios/README.md
+++ b/apps/ios/README.md
@@ -1,15 +1,12 @@
# OpenClaw iOS (Super Alpha)
-NO TEST FLIGHT AVAILABLE AT THIS POINT
-
This iPhone app is super-alpha and internal-use only. It connects to an OpenClaw Gateway as a `role: node`.
## Distribution Status
-NO TEST FLIGHT AVAILABLE AT THIS POINT
-
-- Current distribution: local/manual deploy from source via Xcode.
-- App Store flow is not part of the current internal development path.
+- Public distribution: not available.
+- Internal beta distribution: local archive + TestFlight upload via Fastlane.
+- Local/manual deploy from source via Xcode remains the default development path.
## Super-Alpha Disclaimer
@@ -50,6 +47,45 @@ Shortcut command (same flow + open project):
pnpm ios:open
```
+## Local Beta Release Flow
+
+Prereqs:
+
+- Xcode 16+
+- `pnpm`
+- `xcodegen`
+- `fastlane`
+- Apple account signed into Xcode for automatic signing/provisioning
+- App Store Connect API key set up in Keychain via `scripts/ios-asc-keychain-setup.sh` when auto-resolving a beta build number or uploading to TestFlight
+
+Release behavior:
+
+- Local development keeps using unique per-developer bundle IDs from `scripts/ios-configure-signing.sh`.
+- Beta release uses canonical `ai.openclaw.client*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/BetaRelease.xcconfig`.
+- The beta flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
+- Root `package.json.version` is the only version source for iOS.
+- A root version like `2026.3.9-beta.1` becomes:
+ - `CFBundleShortVersionString = 2026.3.9`
+ - `CFBundleVersion = next TestFlight build number for 2026.3.9`
+
+Archive without upload:
+
+```bash
+pnpm ios:beta:archive
+```
+
+Archive and upload to TestFlight:
+
+```bash
+pnpm ios:beta
+```
+
+If you need to force a specific build number:
+
+```bash
+pnpm ios:beta -- --build-number 7
+```
+
## APNs Expectations For Local/Manual Builds
- The app calls `registerForRemoteNotifications()` at launch.
diff --git a/apps/ios/ShareExtension/Info.plist b/apps/ios/ShareExtension/Info.plist
index 90a7e09e0fc..9469daa08a8 100644
--- a/apps/ios/ShareExtension/Info.plist
+++ b/apps/ios/ShareExtension/Info.plist
@@ -17,9 +17,9 @@
CFBundlePackageType
XPC!
CFBundleShortVersionString
- 2026.3.9
+ $(OPENCLAW_MARKETING_VERSION)
CFBundleVersion
- 20260308
+ $(OPENCLAW_BUILD_VERSION)
NSExtension
NSExtensionAttributes
diff --git a/apps/ios/Signing.xcconfig b/apps/ios/Signing.xcconfig
index 5966d6e2c2f..d6acc35dee8 100644
--- a/apps/ios/Signing.xcconfig
+++ b/apps/ios/Signing.xcconfig
@@ -2,6 +2,8 @@
// Auto-selected local team overrides live in .local-signing.xcconfig (git-ignored).
// Manual local overrides can go in LocalSigning.xcconfig (git-ignored).
+#include "Config/Version.xcconfig"
+
OPENCLAW_CODE_SIGN_STYLE = Manual
OPENCLAW_DEVELOPMENT_TEAM = Y5PE65HELJ
diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist
index 2f1f03d24a1..892d53e7ae9 100644
--- a/apps/ios/Sources/Info.plist
+++ b/apps/ios/Sources/Info.plist
@@ -23,7 +23,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 2026.3.9
+ $(OPENCLAW_MARKETING_VERSION)
CFBundleURLTypes
@@ -36,7 +36,7 @@
CFBundleVersion
- 20260308
+ $(OPENCLAW_BUILD_VERSION)
ITSAppUsesNonExemptEncryption
NSAppTransportSecurity
diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift
index babb6b449da..685b30f0887 100644
--- a/apps/ios/Sources/Model/NodeAppModel.swift
+++ b/apps/ios/Sources/Model/NodeAppModel.swift
@@ -2255,8 +2255,7 @@ extension NodeAppModel {
from: payload)
guard !decoded.actions.isEmpty else { return }
self.pendingActionLogger.info(
- "Pending actions pulled trigger=\(trigger, privacy: .public) "
- + "count=\(decoded.actions.count, privacy: .public)")
+ "Pending actions pulled trigger=\(trigger, privacy: .public) count=\(decoded.actions.count, privacy: .public)")
await self.applyPendingForegroundNodeActions(decoded.actions, trigger: trigger)
} catch {
// Best-effort only.
@@ -2279,9 +2278,7 @@ extension NodeAppModel {
paramsJSON: action.paramsJSON)
let result = await self.handleInvoke(req)
self.pendingActionLogger.info(
- "Pending action replay trigger=\(trigger, privacy: .public) "
- + "id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) "
- + "ok=\(result.ok, privacy: .public)")
+ "Pending action replay trigger=\(trigger, privacy: .public) id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) ok=\(result.ok, privacy: .public)")
guard result.ok else { return }
let acked = await self.ackPendingForegroundNodeAction(
id: action.id,
@@ -2306,9 +2303,7 @@ extension NodeAppModel {
return true
} catch {
self.pendingActionLogger.error(
- "Pending action ack failed trigger=\(trigger, privacy: .public) "
- + "id=\(id, privacy: .public) command=\(command, privacy: .public) "
- + "error=\(String(describing: error), privacy: .public)")
+ "Pending action ack failed trigger=\(trigger, privacy: .public) id=\(id, privacy: .public) command=\(command, privacy: .public) error=\(String(describing: error), privacy: .public)")
return false
}
}
diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist
index 46e3fb97eb1..5bcf88ff5ad 100644
--- a/apps/ios/Tests/Info.plist
+++ b/apps/ios/Tests/Info.plist
@@ -17,8 +17,8 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 2026.3.9
+ $(OPENCLAW_MARKETING_VERSION)
CFBundleVersion
- 20260308
+ $(OPENCLAW_BUILD_VERSION)
diff --git a/apps/ios/WatchApp/Info.plist b/apps/ios/WatchApp/Info.plist
index fa45d719b9c..3eea1e6ff09 100644
--- a/apps/ios/WatchApp/Info.plist
+++ b/apps/ios/WatchApp/Info.plist
@@ -17,9 +17,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 2026.3.9
+ $(OPENCLAW_MARKETING_VERSION)
CFBundleVersion
- 20260308
+ $(OPENCLAW_BUILD_VERSION)
WKCompanionAppBundleIdentifier
$(OPENCLAW_APP_BUNDLE_ID)
WKWatchKitApp
diff --git a/apps/ios/WatchExtension/Info.plist b/apps/ios/WatchExtension/Info.plist
index 1d898d43757..87313064945 100644
--- a/apps/ios/WatchExtension/Info.plist
+++ b/apps/ios/WatchExtension/Info.plist
@@ -15,9 +15,9 @@
CFBundleName
$(PRODUCT_NAME)
CFBundleShortVersionString
- 2026.3.9
+ $(OPENCLAW_MARKETING_VERSION)
CFBundleVersion
- 20260308
+ $(OPENCLAW_BUILD_VERSION)
NSExtension
NSExtensionAttributes
diff --git a/apps/ios/fastlane/Fastfile b/apps/ios/fastlane/Fastfile
index 33e6bfa8adb..62d79f9995c 100644
--- a/apps/ios/fastlane/Fastfile
+++ b/apps/ios/fastlane/Fastfile
@@ -1,8 +1,11 @@
require "shellwords"
require "open3"
+require "json"
default_platform(:ios)
+BETA_APP_IDENTIFIER = "ai.openclaw.client"
+
def load_env_file(path)
return unless File.exist?(path)
@@ -84,6 +87,111 @@ def read_asc_key_content_from_keychain
end
end
+def repo_root
+ File.expand_path("../../..", __dir__)
+end
+
+def ios_root
+ File.expand_path("..", __dir__)
+end
+
+def normalize_release_version(raw_value)
+ version = raw_value.to_s.strip.sub(/\Av/, "")
+ UI.user_error!("Missing root package.json version.") unless env_present?(version)
+ unless version.match?(/\A\d+\.\d+\.\d+(?:[.-]?beta[.-]\d+)?\z/i)
+ UI.user_error!("Invalid package.json version '#{raw_value}'. Expected 2026.3.9 or 2026.3.9-beta.1.")
+ end
+
+ version
+end
+
+def read_root_package_version
+ package_json_path = File.join(repo_root, "package.json")
+ UI.user_error!("Missing package.json at #{package_json_path}.") unless File.exist?(package_json_path)
+
+ parsed = JSON.parse(File.read(package_json_path))
+ normalize_release_version(parsed["version"])
+rescue JSON::ParserError => e
+ UI.user_error!("Invalid package.json at #{package_json_path}: #{e.message}")
+end
+
+def short_release_version(version)
+ normalize_release_version(version).sub(/([.-]?beta[.-]\d+)\z/i, "")
+end
+
+def shell_join(parts)
+ Shellwords.join(parts.compact)
+end
+
+def resolve_beta_build_number(api_key:, version:)
+ explicit = ENV["IOS_BETA_BUILD_NUMBER"]
+ if env_present?(explicit)
+ UI.user_error!("Invalid IOS_BETA_BUILD_NUMBER '#{explicit}'. Expected digits only.") unless explicit.match?(/\A\d+\z/)
+ UI.message("Using explicit iOS beta build number #{explicit}.")
+ return explicit
+ end
+
+ short_version = short_release_version(version)
+ latest_build = latest_testflight_build_number(
+ api_key: api_key,
+ app_identifier: BETA_APP_IDENTIFIER,
+ version: short_version,
+ initial_build_number: 0
+ )
+ next_build = latest_build.to_i + 1
+ UI.message("Resolved iOS beta build number #{next_build} for #{short_version} (latest TestFlight build: #{latest_build}).")
+ next_build.to_s
+end
+
+def beta_build_number_needs_asc_auth?
+ explicit = ENV["IOS_BETA_BUILD_NUMBER"]
+ !env_present?(explicit)
+end
+
+def prepare_beta_release!(version:, build_number:)
+ script_path = File.join(repo_root, "scripts", "ios-beta-prepare.sh")
+ UI.message("Preparing iOS beta release #{version} (build #{build_number}).")
+ sh(shell_join(["bash", script_path, "--build-number", build_number]))
+
+ beta_xcconfig = File.join(ios_root, "build", "BetaRelease.xcconfig")
+ UI.user_error!("Missing beta xcconfig at #{beta_xcconfig}.") unless File.exist?(beta_xcconfig)
+
+ ENV["XCODE_XCCONFIG_FILE"] = beta_xcconfig
+ beta_xcconfig
+end
+
+def build_beta_release(context)
+ version = context[:version]
+ output_directory = File.join("build", "beta")
+ archive_path = File.join(output_directory, "OpenClaw-#{version}.xcarchive")
+
+ build_app(
+ project: "OpenClaw.xcodeproj",
+ scheme: "OpenClaw",
+ configuration: "Release",
+ export_method: "app-store",
+ clean: true,
+ skip_profile_detection: true,
+ build_path: "build",
+ archive_path: archive_path,
+ output_directory: output_directory,
+ output_name: "OpenClaw-#{version}.ipa",
+ xcargs: "-allowProvisioningUpdates",
+ export_xcargs: "-allowProvisioningUpdates",
+ export_options: {
+ signingStyle: "automatic"
+ }
+ )
+
+ {
+ archive_path: archive_path,
+ build_number: context[:build_number],
+ ipa_path: lane_context[SharedValues::IPA_OUTPUT_PATH],
+ short_version: context[:short_version],
+ version: version
+ }
+end
+
platform :ios do
private_lane :asc_api_key do
load_env_file(File.join(__dir__, ".env"))
@@ -132,38 +240,48 @@ platform :ios do
api_key
end
- desc "Build + upload to TestFlight"
+ private_lane :prepare_beta_context do |options|
+ require_api_key = options[:require_api_key] == true
+ needs_api_key = require_api_key || beta_build_number_needs_asc_auth?
+ api_key = needs_api_key ? asc_api_key : nil
+ version = read_root_package_version
+ build_number = resolve_beta_build_number(api_key: api_key, version: version)
+ beta_xcconfig = prepare_beta_release!(version: version, build_number: build_number)
+
+ {
+ api_key: api_key,
+ beta_xcconfig: beta_xcconfig,
+ build_number: build_number,
+ short_version: short_release_version(version),
+ version: version
+ }
+ end
+
+ desc "Build a beta archive locally without uploading"
+ lane :beta_archive do
+ context = prepare_beta_context(require_api_key: false)
+ build = build_beta_release(context)
+ UI.success("Built iOS beta archive: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
+ build
+ ensure
+ ENV.delete("XCODE_XCCONFIG_FILE")
+ end
+
+ desc "Build + upload a beta to TestFlight"
lane :beta do
- api_key = asc_api_key
-
- team_id = ENV["IOS_DEVELOPMENT_TEAM"]
- if team_id.nil? || team_id.strip.empty?
- helper_path = File.expand_path("../../../scripts/ios-team-id.sh", __dir__)
- if File.exist?(helper_path)
- # Keep CI/local compatibility where teams are present in keychain but not Xcode account metadata.
- team_id = sh("IOS_ALLOW_KEYCHAIN_TEAM_FALLBACK=1 bash #{helper_path.shellescape}").strip
- end
- end
- UI.user_error!("Missing IOS_DEVELOPMENT_TEAM (Apple Team ID). Add it to fastlane/.env or export it in your shell.") if team_id.nil? || team_id.strip.empty?
-
- build_app(
- project: "OpenClaw.xcodeproj",
- scheme: "OpenClaw",
- export_method: "app-store",
- clean: true,
- skip_profile_detection: true,
- xcargs: "DEVELOPMENT_TEAM=#{team_id} -allowProvisioningUpdates",
- export_xcargs: "-allowProvisioningUpdates",
- export_options: {
- signingStyle: "automatic"
- }
- )
+ context = prepare_beta_context(require_api_key: true)
+ build = build_beta_release(context)
upload_to_testflight(
- api_key: api_key,
+ api_key: context[:api_key],
+ ipa: build[:ipa_path],
skip_waiting_for_build_processing: true,
uses_non_exempt_encryption: false
)
+
+ UI.success("Uploaded iOS beta: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
+ ensure
+ ENV.delete("XCODE_XCCONFIG_FILE")
end
desc "Upload App Store metadata (and optionally screenshots)"
diff --git a/apps/ios/fastlane/SETUP.md b/apps/ios/fastlane/SETUP.md
index 8dccf264b41..67d4fcc843a 100644
--- a/apps/ios/fastlane/SETUP.md
+++ b/apps/ios/fastlane/SETUP.md
@@ -32,9 +32,9 @@ ASC_KEYCHAIN_ACCOUNT=YOUR_MAC_USERNAME
Optional app targeting variables (helpful if Fastlane cannot auto-resolve app by bundle):
```bash
-ASC_APP_IDENTIFIER=ai.openclaw.ios
+ASC_APP_IDENTIFIER=ai.openclaw.client
# or
-ASC_APP_ID=6760218713
+ASC_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID
```
File-based fallback (CI/non-macOS):
@@ -60,9 +60,37 @@ cd apps/ios
fastlane ios auth_check
```
-Run:
+ASC auth is only required when:
+
+- uploading to TestFlight
+- auto-resolving the next build number from App Store Connect
+
+If you pass `--build-number` to `pnpm ios:beta:archive`, the local archive path does not need ASC auth.
+
+Archive locally without upload:
+
+```bash
+pnpm ios:beta:archive
+```
+
+Upload to TestFlight:
+
+```bash
+pnpm ios:beta
+```
+
+Direct Fastlane entry point:
```bash
cd apps/ios
-fastlane beta
+fastlane ios beta
```
+
+Versioning rules:
+
+- Root `package.json.version` is the single source of truth for iOS
+- Use `YYYY.M.D` for stable versions and `YYYY.M.D-beta.N` for beta versions
+- Fastlane stamps `CFBundleShortVersionString` to `YYYY.M.D`
+- Fastlane resolves `CFBundleVersion` as the next integer TestFlight build number for that short version
+- The beta flow regenerates `apps/ios/OpenClaw.xcodeproj` from `apps/ios/project.yml` before archiving
+- Local beta signing uses a temporary generated xcconfig and leaves local development signing overrides untouched
diff --git a/apps/ios/fastlane/metadata/README.md b/apps/ios/fastlane/metadata/README.md
index 74eb7df87d3..07e7824311f 100644
--- a/apps/ios/fastlane/metadata/README.md
+++ b/apps/ios/fastlane/metadata/README.md
@@ -6,7 +6,7 @@ This directory is used by `fastlane deliver` for App Store Connect text metadata
```bash
cd apps/ios
-ASC_APP_ID=6760218713 \
+ASC_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID \
DELIVER_METADATA=1 fastlane ios metadata
```
diff --git a/apps/ios/project.yml b/apps/ios/project.yml
index 0664db9c6be..91b2a8e46d1 100644
--- a/apps/ios/project.yml
+++ b/apps/ios/project.yml
@@ -107,8 +107,8 @@ targets:
- CFBundleURLName: ai.openclaw.ios
CFBundleURLSchemes:
- openclaw
- CFBundleShortVersionString: "2026.3.9"
- CFBundleVersion: "20260308"
+ CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
+ CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@@ -168,8 +168,8 @@ targets:
path: ShareExtension/Info.plist
properties:
CFBundleDisplayName: OpenClaw Share
- CFBundleShortVersionString: "2026.3.9"
- CFBundleVersion: "20260308"
+ CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
+ CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
NSExtension:
NSExtensionPointIdentifier: com.apple.share-services
NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController"
@@ -205,8 +205,8 @@ targets:
path: ActivityWidget/Info.plist
properties:
CFBundleDisplayName: OpenClaw Activity
- CFBundleShortVersionString: "2026.3.9"
- CFBundleVersion: "20260308"
+ CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
+ CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
NSSupportsLiveActivities: true
NSExtension:
NSExtensionPointIdentifier: com.apple.widgetkit-extension
@@ -224,6 +224,7 @@ targets:
Release: Config/Signing.xcconfig
settings:
base:
+ ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ENABLE_APPINTENTS_METADATA: NO
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
@@ -231,8 +232,8 @@ targets:
path: WatchApp/Info.plist
properties:
CFBundleDisplayName: OpenClaw
- CFBundleShortVersionString: "2026.3.9"
- CFBundleVersion: "20260308"
+ CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
+ CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)"
WKWatchKitApp: true
@@ -256,8 +257,8 @@ targets:
path: WatchExtension/Info.plist
properties:
CFBundleDisplayName: OpenClaw
- CFBundleShortVersionString: "2026.3.9"
- CFBundleVersion: "20260308"
+ CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
+ CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
NSExtension:
NSExtensionAttributes:
WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
@@ -293,8 +294,8 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: OpenClawTests
- CFBundleShortVersionString: "2026.3.9"
- CFBundleVersion: "20260308"
+ CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
+ CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
OpenClawLogicTests:
type: bundle.unit-test
@@ -319,5 +320,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: OpenClawLogicTests
- CFBundleShortVersionString: "2026.3.9"
- CFBundleVersion: "20260308"
+ CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
+ CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift
index 5e8238ebe92..d503686ba57 100644
--- a/apps/macos/Sources/OpenClaw/AppState.swift
+++ b/apps/macos/Sources/OpenClaw/AppState.swift
@@ -600,30 +600,29 @@ final class AppState {
private func syncGatewayConfigIfNeeded() {
guard !self.isPreview, !self.isInitializing else { return }
- let connectionMode = self.connectionMode
- let remoteTarget = self.remoteTarget
- let remoteIdentity = self.remoteIdentity
- let remoteTransport = self.remoteTransport
- let remoteUrl = self.remoteUrl
- let remoteToken = self.remoteToken
- let remoteTokenDirty = self.remoteTokenDirty
-
Task { @MainActor in
- // Keep app-only connection settings local to avoid overwriting remote gateway config.
- let synced = Self.syncedGatewayRoot(
- currentRoot: OpenClawConfigFile.loadDict(),
- connectionMode: connectionMode,
- remoteTransport: remoteTransport,
- remoteTarget: remoteTarget,
- remoteIdentity: remoteIdentity,
- remoteUrl: remoteUrl,
- remoteToken: remoteToken,
- remoteTokenDirty: remoteTokenDirty)
- guard synced.changed else { return }
- OpenClawConfigFile.saveDict(synced.root)
+ self.syncGatewayConfigNow()
}
}
+ @MainActor
+ func syncGatewayConfigNow() {
+ guard !self.isPreview, !self.isInitializing else { return }
+
+ // Keep app-only connection settings local to avoid overwriting remote gateway config.
+ let synced = Self.syncedGatewayRoot(
+ currentRoot: OpenClawConfigFile.loadDict(),
+ connectionMode: self.connectionMode,
+ remoteTransport: self.remoteTransport,
+ remoteTarget: self.remoteTarget,
+ remoteIdentity: self.remoteIdentity,
+ remoteUrl: self.remoteUrl,
+ remoteToken: self.remoteToken,
+ remoteTokenDirty: self.remoteTokenDirty)
+ guard synced.changed else { return }
+ OpenClawConfigFile.saveDict(synced.root)
+ }
+
func triggerVoiceEars(ttl: TimeInterval? = 5) {
self.earBoostTask?.cancel()
self.earBoostActive = true
diff --git a/apps/macos/Sources/OpenClaw/ControlChannel.swift b/apps/macos/Sources/OpenClaw/ControlChannel.swift
index aecf9539ef5..c4472f8f452 100644
--- a/apps/macos/Sources/OpenClaw/ControlChannel.swift
+++ b/apps/macos/Sources/OpenClaw/ControlChannel.swift
@@ -188,6 +188,10 @@ final class ControlChannel {
return desc
}
+ if let authIssue = RemoteGatewayAuthIssue(error: error) {
+ return authIssue.statusMessage
+ }
+
// If the gateway explicitly rejects the hello (e.g., auth/token mismatch), surface it.
if let urlErr = error as? URLError,
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift
index b55ed439489..633879367ea 100644
--- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift
+++ b/apps/macos/Sources/OpenClaw/GeneralSettings.swift
@@ -348,10 +348,18 @@ struct GeneralSettings: View {
Text("Testing…")
.font(.caption)
.foregroundStyle(.secondary)
- case .ok:
- Label("Ready", systemImage: "checkmark.circle.fill")
- .font(.caption)
- .foregroundStyle(.green)
+ case let .ok(success):
+ VStack(alignment: .leading, spacing: 2) {
+ Label(success.title, systemImage: "checkmark.circle.fill")
+ .font(.caption)
+ .foregroundStyle(.green)
+ if let detail = success.detail {
+ Text(detail)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
case let .failed(message):
Text(message)
.font(.caption)
@@ -518,7 +526,7 @@ struct GeneralSettings: View {
private enum RemoteStatus: Equatable {
case idle
case checking
- case ok
+ case ok(RemoteGatewayProbeSuccess)
case failed(String)
}
@@ -558,114 +566,14 @@ extension GeneralSettings {
@MainActor
func testRemote() async {
self.remoteStatus = .checking
- let settings = CommandResolver.connectionSettings()
- if self.state.remoteTransport == .direct {
- let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmedUrl.isEmpty else {
- self.remoteStatus = .failed("Set a gateway URL first")
- return
- }
- guard Self.isValidWsUrl(trimmedUrl) else {
- self.remoteStatus = .failed(
- "Gateway URL must use wss:// for remote hosts (ws:// only for localhost)")
- return
- }
- } else {
- guard !settings.target.isEmpty else {
- self.remoteStatus = .failed("Set an SSH target first")
- return
- }
-
- // Step 1: basic SSH reachability check
- guard let sshCommand = Self.sshCheckCommand(
- target: settings.target,
- identity: settings.identity)
- else {
- self.remoteStatus = .failed("SSH target is invalid")
- return
- }
- let sshResult = await ShellExecutor.run(
- command: sshCommand,
- cwd: nil,
- env: nil,
- timeout: 8)
-
- guard sshResult.ok else {
- self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
- return
- }
+ switch await RemoteGatewayProbe.run() {
+ case let .ready(success):
+ self.remoteStatus = .ok(success)
+ case let .authIssue(issue):
+ self.remoteStatus = .failed(issue.statusMessage)
+ case let .failed(message):
+ self.remoteStatus = .failed(message)
}
-
- // Step 2: control channel health check
- let originalMode = AppStateStore.shared.connectionMode
- do {
- try await ControlChannel.shared.configure(mode: .remote(
- target: settings.target,
- identity: settings.identity))
- let data = try await ControlChannel.shared.health(timeout: 10)
- if decodeHealthSnapshot(from: data) != nil {
- self.remoteStatus = .ok
- } else {
- self.remoteStatus = .failed("Control channel returned invalid health JSON")
- }
- } catch {
- self.remoteStatus = .failed(error.localizedDescription)
- }
-
- // Restore original mode if we temporarily switched
- switch originalMode {
- case .remote:
- break
- case .local:
- try? await ControlChannel.shared.configure(mode: .local)
- case .unconfigured:
- await ControlChannel.shared.disconnect()
- }
- }
-
- private static func isValidWsUrl(_ raw: String) -> Bool {
- GatewayRemoteConfig.normalizeGatewayUrl(raw) != nil
- }
-
- private static func sshCheckCommand(target: String, identity: String) -> [String]? {
- guard let parsed = CommandResolver.parseSSHTarget(target) else { return nil }
- let options = [
- "-o", "BatchMode=yes",
- "-o", "ConnectTimeout=5",
- "-o", "StrictHostKeyChecking=accept-new",
- "-o", "UpdateHostKeys=yes",
- ]
- let args = CommandResolver.sshArguments(
- target: parsed,
- identity: identity,
- options: options,
- remoteCommand: ["echo", "ok"])
- return ["/usr/bin/ssh"] + args
- }
-
- private func formatSSHFailure(_ response: Response, target: String) -> String {
- let payload = response.payload.flatMap { String(data: $0, encoding: .utf8) }
- let trimmed = payload?
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .split(whereSeparator: \.isNewline)
- .joined(separator: " ")
- if let trimmed,
- trimmed.localizedCaseInsensitiveContains("host key verification failed")
- {
- let host = CommandResolver.parseSSHTarget(target)?.host ?? target
- return "SSH check failed: Host key verification failed. Remove the old key with " +
- "`ssh-keygen -R \(host)` and try again."
- }
- if let trimmed, !trimmed.isEmpty {
- if let message = response.message, message.hasPrefix("exit ") {
- return "SSH check failed: \(trimmed) (\(message))"
- }
- return "SSH check failed: \(trimmed)"
- }
- if let message = response.message {
- return "SSH check failed (\(message))"
- }
- return "SSH check failed"
}
private func revealLogs() {
diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift
index 0da6510f608..367907f9fb7 100644
--- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift
+++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift
@@ -146,8 +146,8 @@ actor MacNodeBrowserProxy {
request.setValue(password, forHTTPHeaderField: "x-openclaw-password")
}
- if method != "GET", let body = params.body?.value {
- request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [.fragmentsAllowed])
+ if method != "GET", let body = params.body {
+ request.httpBody = try JSONSerialization.data(withJSONObject: body.foundationValue, options: [.fragmentsAllowed])
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
diff --git a/apps/macos/Sources/OpenClaw/Onboarding.swift b/apps/macos/Sources/OpenClaw/Onboarding.swift
index 4eae7e092b0..ca183d35311 100644
--- a/apps/macos/Sources/OpenClaw/Onboarding.swift
+++ b/apps/macos/Sources/OpenClaw/Onboarding.swift
@@ -9,6 +9,13 @@ enum UIStrings {
static let welcomeTitle = "Welcome to OpenClaw"
}
+enum RemoteOnboardingProbeState: Equatable {
+ case idle
+ case checking
+ case ok(RemoteGatewayProbeSuccess)
+ case failed(String)
+}
+
@MainActor
final class OnboardingController {
static let shared = OnboardingController()
@@ -72,6 +79,9 @@ struct OnboardingView: View {
@State var didAutoKickoff = false
@State var showAdvancedConnection = false
@State var preferredGatewayID: String?
+ @State var remoteProbeState: RemoteOnboardingProbeState = .idle
+ @State var remoteAuthIssue: RemoteGatewayAuthIssue?
+ @State var suppressRemoteProbeReset = false
@State var gatewayDiscovery: GatewayDiscoveryModel
@State var onboardingChatModel: OpenClawChatViewModel
@State var onboardingSkillsModel = SkillsSettingsModel()
diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift
index 8f4d16420bc..0beeb2bdc27 100644
--- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift
+++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift
@@ -2,6 +2,7 @@ import AppKit
import OpenClawChatUI
import OpenClawDiscovery
import OpenClawIPC
+import OpenClawKit
import SwiftUI
extension OnboardingView {
@@ -97,6 +98,11 @@ extension OnboardingView {
self.gatewayDiscoverySection()
+ if self.shouldShowRemoteConnectionSection {
+ Divider().padding(.vertical, 4)
+ self.remoteConnectionSection()
+ }
+
self.connectionChoiceButton(
title: "Configure later",
subtitle: "Don’t start the Gateway yet.",
@@ -109,6 +115,22 @@ extension OnboardingView {
}
}
}
+ .onChange(of: self.state.connectionMode) { _, newValue in
+ guard Self.shouldResetRemoteProbeFeedback(
+ for: newValue,
+ suppressReset: self.suppressRemoteProbeReset)
+ else { return }
+ self.resetRemoteProbeFeedback()
+ }
+ .onChange(of: self.state.remoteTransport) { _, _ in
+ self.resetRemoteProbeFeedback()
+ }
+ .onChange(of: self.state.remoteTarget) { _, _ in
+ self.resetRemoteProbeFeedback()
+ }
+ .onChange(of: self.state.remoteUrl) { _, _ in
+ self.resetRemoteProbeFeedback()
+ }
}
private var localGatewaySubtitle: String {
@@ -199,25 +221,6 @@ extension OnboardingView {
.pickerStyle(.segmented)
.frame(width: fieldWidth)
}
- GridRow {
- Text("Gateway token")
- .font(.callout.weight(.semibold))
- .frame(width: labelWidth, alignment: .leading)
- SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken)
- .textFieldStyle(.roundedBorder)
- .frame(width: fieldWidth)
- }
- if self.state.remoteTokenUnsupported {
- GridRow {
- Text("")
- .frame(width: labelWidth, alignment: .leading)
- Text(
- "The current gateway.remote.token value is not plain text. OpenClaw for macOS cannot use it directly; enter a plaintext token here to replace it.")
- .font(.caption)
- .foregroundStyle(.orange)
- .frame(width: fieldWidth, alignment: .leading)
- }
- }
if self.state.remoteTransport == .direct {
GridRow {
Text("Gateway URL")
@@ -289,6 +292,248 @@ extension OnboardingView {
}
}
+ private var shouldShowRemoteConnectionSection: Bool {
+ self.state.connectionMode == .remote ||
+ self.showAdvancedConnection ||
+ self.remoteProbeState != .idle ||
+ self.remoteAuthIssue != nil ||
+ Self.shouldShowRemoteTokenField(
+ showAdvancedConnection: self.showAdvancedConnection,
+ remoteToken: self.state.remoteToken,
+ remoteTokenUnsupported: self.state.remoteTokenUnsupported,
+ authIssue: self.remoteAuthIssue)
+ }
+
+ private var shouldShowRemoteTokenField: Bool {
+ guard self.shouldShowRemoteConnectionSection else { return false }
+ return Self.shouldShowRemoteTokenField(
+ showAdvancedConnection: self.showAdvancedConnection,
+ remoteToken: self.state.remoteToken,
+ remoteTokenUnsupported: self.state.remoteTokenUnsupported,
+ authIssue: self.remoteAuthIssue)
+ }
+
+ private var remoteProbePreflightMessage: String? {
+ switch self.state.remoteTransport {
+ case .direct:
+ let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmedUrl.isEmpty {
+ return "Select a nearby gateway or open Advanced to enter a gateway URL."
+ }
+ if GatewayRemoteConfig.normalizeGatewayUrl(trimmedUrl) == nil {
+ return "Gateway URL must use wss:// for remote hosts (ws:// only for localhost)."
+ }
+ return nil
+ case .ssh:
+ let trimmedTarget = self.state.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmedTarget.isEmpty {
+ return "Select a nearby gateway or open Advanced to enter an SSH target."
+ }
+ return CommandResolver.sshTargetValidationMessage(trimmedTarget)
+ }
+ }
+
+ private var canProbeRemoteConnection: Bool {
+ self.remoteProbePreflightMessage == nil && self.remoteProbeState != .checking
+ }
+
+ @ViewBuilder
+ private func remoteConnectionSection() -> some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(alignment: .top, spacing: 12) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Remote connection")
+ .font(.callout.weight(.semibold))
+ Text("Checks the real remote websocket and auth handshake.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ Spacer(minLength: 0)
+ Button {
+ Task { await self.probeRemoteConnection() }
+ } label: {
+ if self.remoteProbeState == .checking {
+ ProgressView()
+ .controlSize(.small)
+ .frame(minWidth: 120)
+ } else {
+ Text("Check connection")
+ .frame(minWidth: 120)
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(!self.canProbeRemoteConnection)
+ }
+
+ if self.shouldShowRemoteTokenField {
+ self.remoteTokenField()
+ }
+
+ if let message = self.remoteProbePreflightMessage, self.remoteProbeState != .checking {
+ Text(message)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ self.remoteProbeStatusView()
+
+ if let issue = self.remoteAuthIssue {
+ self.remoteAuthPromptView(issue: issue)
+ }
+ }
+ }
+
+ private func remoteTokenField() -> some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(alignment: .center, spacing: 12) {
+ Text("Gateway token")
+ .font(.callout.weight(.semibold))
+ .frame(width: 110, alignment: .leading)
+ SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken)
+ .textFieldStyle(.roundedBorder)
+ .frame(maxWidth: 320)
+ }
+ Text("Used when the remote gateway requires token auth.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ if self.state.remoteTokenUnsupported {
+ Text(
+ "The current gateway.remote.token value is not plain text. OpenClaw for macOS cannot use it directly; enter a plaintext token here to replace it.")
+ .font(.caption)
+ .foregroundStyle(.orange)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func remoteProbeStatusView() -> some View {
+ switch self.remoteProbeState {
+ case .idle:
+ EmptyView()
+ case .checking:
+ Text("Checking remote gateway…")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ case let .ok(success):
+ VStack(alignment: .leading, spacing: 2) {
+ Label(success.title, systemImage: "checkmark.circle.fill")
+ .font(.caption)
+ .foregroundStyle(.green)
+ if let detail = success.detail {
+ Text(detail)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ case let .failed(message):
+ if self.remoteAuthIssue == nil {
+ Text(message)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ }
+
+ private func remoteAuthPromptView(issue: RemoteGatewayAuthIssue) -> some View {
+ let promptStyle = Self.remoteAuthPromptStyle(for: issue)
+ return HStack(alignment: .top, spacing: 10) {
+ Image(systemName: promptStyle.systemImage)
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(promptStyle.tint)
+ .frame(width: 16, alignment: .center)
+ .padding(.top, 1)
+ VStack(alignment: .leading, spacing: 4) {
+ Text(issue.title)
+ .font(.caption.weight(.semibold))
+ Text(.init(issue.body))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ if let footnote = issue.footnote {
+ Text(.init(footnote))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ }
+ }
+
+ @MainActor
+ private func probeRemoteConnection() async {
+ let originalMode = self.state.connectionMode
+ let shouldRestoreMode = originalMode != .remote
+ if shouldRestoreMode {
+ // Reuse the shared remote endpoint stack for probing without committing the user's mode choice.
+ self.state.connectionMode = .remote
+ }
+ self.remoteProbeState = .checking
+ self.remoteAuthIssue = nil
+ defer {
+ if shouldRestoreMode {
+ self.suppressRemoteProbeReset = true
+ self.state.connectionMode = originalMode
+ self.suppressRemoteProbeReset = false
+ }
+ }
+
+ switch await RemoteGatewayProbe.run() {
+ case let .ready(success):
+ self.remoteProbeState = .ok(success)
+ case let .authIssue(issue):
+ self.remoteAuthIssue = issue
+ self.remoteProbeState = .failed(issue.statusMessage)
+ case let .failed(message):
+ self.remoteProbeState = .failed(message)
+ }
+ }
+
+ private func resetRemoteProbeFeedback() {
+ self.remoteProbeState = .idle
+ self.remoteAuthIssue = nil
+ }
+
+ static func remoteAuthPromptStyle(
+ for issue: RemoteGatewayAuthIssue)
+ -> (systemImage: String, tint: Color)
+ {
+ switch issue {
+ case .tokenRequired:
+ return ("key.fill", .orange)
+ case .tokenMismatch:
+ return ("exclamationmark.triangle.fill", .orange)
+ case .gatewayTokenNotConfigured:
+ return ("wrench.and.screwdriver.fill", .orange)
+ case .passwordRequired:
+ return ("lock.slash.fill", .orange)
+ case .pairingRequired:
+ return ("link.badge.plus", .orange)
+ }
+ }
+
+ static func shouldShowRemoteTokenField(
+ showAdvancedConnection: Bool,
+ remoteToken: String,
+ remoteTokenUnsupported: Bool,
+ authIssue: RemoteGatewayAuthIssue?) -> Bool
+ {
+ showAdvancedConnection ||
+ remoteTokenUnsupported ||
+ !remoteToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ||
+ authIssue?.showsTokenField == true
+ }
+
+ static func shouldResetRemoteProbeFeedback(
+ for connectionMode: AppState.ConnectionMode,
+ suppressReset: Bool) -> Bool
+ {
+ !suppressReset && connectionMode != .remote
+ }
+
func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
if self.state.remoteTransport == .direct {
return GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "Gateway pairing only"
diff --git a/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift b/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift
new file mode 100644
index 00000000000..f878d0f5e28
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift
@@ -0,0 +1,222 @@
+import Foundation
+import OpenClawIPC
+import OpenClawKit
+
+enum RemoteGatewayAuthIssue: Equatable {
+ case tokenRequired
+ case tokenMismatch
+ case gatewayTokenNotConfigured
+ case passwordRequired
+ case pairingRequired
+
+ init?(error: Error) {
+ guard let authError = error as? GatewayConnectAuthError else {
+ return nil
+ }
+ switch authError.detail {
+ case .authTokenMissing:
+ self = .tokenRequired
+ case .authTokenMismatch:
+ self = .tokenMismatch
+ case .authTokenNotConfigured:
+ self = .gatewayTokenNotConfigured
+ case .authPasswordMissing, .authPasswordMismatch, .authPasswordNotConfigured:
+ self = .passwordRequired
+ case .pairingRequired:
+ self = .pairingRequired
+ default:
+ return nil
+ }
+ }
+
+ var showsTokenField: Bool {
+ switch self {
+ case .tokenRequired, .tokenMismatch:
+ true
+ case .gatewayTokenNotConfigured, .passwordRequired, .pairingRequired:
+ false
+ }
+ }
+
+ var title: String {
+ switch self {
+ case .tokenRequired:
+ "This gateway requires an auth token"
+ case .tokenMismatch:
+ "That token did not match the gateway"
+ case .gatewayTokenNotConfigured:
+ "This gateway host needs token setup"
+ case .passwordRequired:
+ "This gateway is using unsupported auth"
+ case .pairingRequired:
+ "This device needs pairing approval"
+ }
+ }
+
+ var body: String {
+ switch self {
+ case .tokenRequired:
+ "Paste the token configured on the gateway host. On the gateway host, run `openclaw config get gateway.auth.token`. If the gateway uses an environment variable instead, use `OPENCLAW_GATEWAY_TOKEN`."
+ case .tokenMismatch:
+ "Check `gateway.auth.token` or `OPENCLAW_GATEWAY_TOKEN` on the gateway host and try again."
+ case .gatewayTokenNotConfigured:
+ "This gateway is set to token auth, but no `gateway.auth.token` is configured on the gateway host. If the gateway uses an environment variable instead, set `OPENCLAW_GATEWAY_TOKEN` before starting the gateway."
+ case .passwordRequired:
+ "This onboarding flow does not support password auth yet. Reconfigure the gateway to use token auth, then retry."
+ case .pairingRequired:
+ "Approve this device from an already-paired OpenClaw client. In your OpenClaw chat, run `/pair approve`, then click **Check connection** again."
+ }
+ }
+
+ var footnote: String? {
+ switch self {
+ case .tokenRequired, .gatewayTokenNotConfigured:
+ "No token yet? Generate one on the gateway host with `openclaw doctor --generate-gateway-token`, then set it as `gateway.auth.token`."
+ case .pairingRequired:
+ "If you do not have another paired OpenClaw client yet, approve the pending request on the gateway host with `openclaw devices approve`."
+ case .tokenMismatch, .passwordRequired:
+ nil
+ }
+ }
+
+ var statusMessage: String {
+ switch self {
+ case .tokenRequired:
+ "This gateway requires an auth token from the gateway host."
+ case .tokenMismatch:
+ "Gateway token mismatch. Check gateway.auth.token or OPENCLAW_GATEWAY_TOKEN on the gateway host."
+ case .gatewayTokenNotConfigured:
+ "This gateway has token auth enabled, but no gateway.auth.token is configured on the host."
+ case .passwordRequired:
+ "This gateway uses password auth. Remote onboarding on macOS cannot collect gateway passwords yet."
+ case .pairingRequired:
+ "Pairing required. In an already-paired OpenClaw client, run /pair approve, then check the connection again."
+ }
+ }
+}
+
+enum RemoteGatewayProbeResult: Equatable {
+ case ready(RemoteGatewayProbeSuccess)
+ case authIssue(RemoteGatewayAuthIssue)
+ case failed(String)
+}
+
+struct RemoteGatewayProbeSuccess: Equatable {
+ let authSource: GatewayAuthSource?
+
+ var title: String {
+ switch self.authSource {
+ case .some(.deviceToken):
+ "Connected via paired device"
+ case .some(.sharedToken):
+ "Connected with gateway token"
+ case .some(.password):
+ "Connected with password"
+ case .some(GatewayAuthSource.none), nil:
+ "Remote gateway ready"
+ }
+ }
+
+ var detail: String? {
+ switch self.authSource {
+ case .some(.deviceToken):
+ "This Mac used a stored device token. New or unpaired devices may still need the gateway token."
+ case .some(.sharedToken), .some(.password), .some(GatewayAuthSource.none), nil:
+ nil
+ }
+ }
+}
+
+enum RemoteGatewayProbe {
+ @MainActor
+ static func run() async -> RemoteGatewayProbeResult {
+ AppStateStore.shared.syncGatewayConfigNow()
+ let settings = CommandResolver.connectionSettings()
+ let transport = AppStateStore.shared.remoteTransport
+
+ if transport == .direct {
+ let trimmedUrl = AppStateStore.shared.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedUrl.isEmpty else {
+ return .failed("Set a gateway URL first")
+ }
+ guard self.isValidWsUrl(trimmedUrl) else {
+ return .failed("Gateway URL must use wss:// for remote hosts (ws:// only for localhost)")
+ }
+ } else {
+ let trimmedTarget = settings.target.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedTarget.isEmpty else {
+ return .failed("Set an SSH target first")
+ }
+ if let validationMessage = CommandResolver.sshTargetValidationMessage(trimmedTarget) {
+ return .failed(validationMessage)
+ }
+ guard let sshCommand = self.sshCheckCommand(target: settings.target, identity: settings.identity) else {
+ return .failed("SSH target is invalid")
+ }
+
+ let sshResult = await ShellExecutor.run(
+ command: sshCommand,
+ cwd: nil,
+ env: nil,
+ timeout: 8)
+ guard sshResult.ok else {
+ return .failed(self.formatSSHFailure(sshResult, target: settings.target))
+ }
+ }
+
+ do {
+ _ = try await GatewayConnection.shared.healthSnapshot(timeoutMs: 10_000)
+ let authSource = await GatewayConnection.shared.authSource()
+ return .ready(RemoteGatewayProbeSuccess(authSource: authSource))
+ } catch {
+ if let authIssue = RemoteGatewayAuthIssue(error: error) {
+ return .authIssue(authIssue)
+ }
+ return .failed(error.localizedDescription)
+ }
+ }
+
+ private static func isValidWsUrl(_ raw: String) -> Bool {
+ GatewayRemoteConfig.normalizeGatewayUrl(raw) != nil
+ }
+
+ private static func sshCheckCommand(target: String, identity: String) -> [String]? {
+ guard let parsed = CommandResolver.parseSSHTarget(target) else { return nil }
+ let options = [
+ "-o", "BatchMode=yes",
+ "-o", "ConnectTimeout=5",
+ "-o", "StrictHostKeyChecking=accept-new",
+ "-o", "UpdateHostKeys=yes",
+ ]
+ let args = CommandResolver.sshArguments(
+ target: parsed,
+ identity: identity,
+ options: options,
+ remoteCommand: ["echo", "ok"])
+ return ["/usr/bin/ssh"] + args
+ }
+
+ private static func formatSSHFailure(_ response: Response, target: String) -> String {
+ let payload = response.payload.flatMap { String(data: $0, encoding: .utf8) }
+ let trimmed = payload?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .split(whereSeparator: \.isNewline)
+ .joined(separator: " ")
+ if let trimmed,
+ trimmed.localizedCaseInsensitiveContains("host key verification failed")
+ {
+ let host = CommandResolver.parseSSHTarget(target)?.host ?? target
+ return "SSH check failed: Host key verification failed. Remove the old key with ssh-keygen -R \(host) and try again."
+ }
+ if let trimmed, !trimmed.isEmpty {
+ if let message = response.message, message.hasPrefix("exit ") {
+ return "SSH check failed: \(trimmed) (\(message))"
+ }
+ return "SSH check failed: \(trimmed)"
+ }
+ if let message = response.message {
+ return "SSH check failed (\(message))"
+ }
+ return "SSH check failed"
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift
index cbec3e74e93..9110ce59faf 100644
--- a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift
+++ b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift
@@ -8,6 +8,7 @@ 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)
@@ -21,6 +22,21 @@ 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",
@@ -46,6 +62,28 @@ 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,
@@ -133,6 +171,14 @@ 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
@@ -155,7 +201,13 @@ final class WebChatSwiftUIWindowController {
init(sessionKey: String, presentation: WebChatPresentation, transport: any OpenClawChatTransport) {
self.sessionKey = sessionKey
self.presentation = presentation
- let vm = OpenClawChatViewModel(sessionKey: sessionKey, transport: transport)
+ let vm = OpenClawChatViewModel(
+ sessionKey: sessionKey,
+ transport: transport,
+ initialThinkingLevel: Self.persistedThinkingLevel(),
+ onThinkingLevelChanged: { level in
+ UserDefaults.standard.set(level, forKey: webChatThinkingLevelDefaultsKey)
+ })
let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex)
self.hosting = NSHostingController(rootView: OpenClawChatView(
viewModel: vm,
@@ -254,6 +306,16 @@ 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
diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
index cf69609e673..ea85e6c1511 100644
--- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
+++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
@@ -1337,6 +1337,8 @@ 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?
@@ -1355,6 +1357,8 @@ public struct SessionsPatchParams: Codable, Sendable {
model: AnyCodable?,
spawnedby: AnyCodable?,
spawndepth: AnyCodable?,
+ subagentrole: AnyCodable?,
+ subagentcontrolscope: AnyCodable?,
sendpolicy: AnyCodable?,
groupactivation: AnyCodable?)
{
@@ -1372,6 +1376,8 @@ 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
}
@@ -1391,6 +1397,8 @@ 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"
}
@@ -3046,7 +3054,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]?
@@ -3067,7 +3075,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public init(
id: String?,
- command: String,
+ command: String?,
commandargv: [String]?,
systemrunplan: [String: AnyCodable]?,
env: [String: AnyCodable]?,
diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift
index 8d37faa511e..9942f6e84ce 100644
--- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift
@@ -7,6 +7,11 @@ struct GatewayChannelConnectTests {
private enum FakeResponse {
case helloOk(delayMs: Int)
case invalid(delayMs: Int)
+ case authFailed(
+ delayMs: Int,
+ detailCode: String,
+ canRetryWithDeviceToken: Bool,
+ recommendedNextStep: String?)
}
private func makeSession(response: FakeResponse) -> GatewayTestWebSocketSession {
@@ -27,6 +32,14 @@ struct GatewayChannelConnectTests {
case let .invalid(ms):
delayMs = ms
message = .string("not json")
+ case let .authFailed(ms, detailCode, canRetryWithDeviceToken, recommendedNextStep):
+ delayMs = ms
+ let id = task.snapshotConnectRequestID() ?? "connect"
+ message = .data(GatewayWebSocketTestSupport.connectAuthFailureData(
+ id: id,
+ detailCode: detailCode,
+ canRetryWithDeviceToken: canRetryWithDeviceToken,
+ recommendedNextStep: recommendedNextStep))
}
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
return message
@@ -71,4 +84,29 @@ struct GatewayChannelConnectTests {
}())
#expect(session.snapshotMakeCount() == 1)
}
+
+ @Test func `connect surfaces structured auth failure`() async throws {
+ let session = self.makeSession(response: .authFailed(
+ delayMs: 0,
+ detailCode: GatewayConnectAuthDetailCode.authTokenMissing.rawValue,
+ canRetryWithDeviceToken: true,
+ recommendedNextStep: GatewayConnectRecoveryNextStep.updateAuthConfiguration.rawValue))
+ let channel = try GatewayChannelActor(
+ url: #require(URL(string: "ws://example.invalid")),
+ token: nil,
+ session: WebSocketSessionBox(session: session))
+
+ do {
+ try await channel.connect()
+ Issue.record("expected GatewayConnectAuthError")
+ } catch let error as GatewayConnectAuthError {
+ #expect(error.detail == .authTokenMissing)
+ #expect(error.detailCode == GatewayConnectAuthDetailCode.authTokenMissing.rawValue)
+ #expect(error.canRetryWithDeviceToken)
+ #expect(error.recommendedNextStep == .updateAuthConfiguration)
+ #expect(error.recommendedNextStepCode == GatewayConnectRecoveryNextStep.updateAuthConfiguration.rawValue)
+ } catch {
+ Issue.record("unexpected error: \(error)")
+ }
+ }
}
diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift
index 8af4ccf6905..cf2b13de5ea 100644
--- a/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift
@@ -52,6 +52,40 @@ enum GatewayWebSocketTestSupport {
return Data(json.utf8)
}
+ static func connectAuthFailureData(
+ id: String,
+ detailCode: String,
+ message: String = "gateway auth rejected",
+ canRetryWithDeviceToken: Bool = false,
+ recommendedNextStep: String? = nil) -> Data
+ {
+ let recommendedNextStepJson: String
+ if let recommendedNextStep {
+ recommendedNextStepJson = """
+ ,
+ "recommendedNextStep": "\(recommendedNextStep)"
+ """
+ } else {
+ recommendedNextStepJson = ""
+ }
+ let json = """
+ {
+ "type": "res",
+ "id": "\(id)",
+ "ok": false,
+ "error": {
+ "message": "\(message)",
+ "details": {
+ "code": "\(detailCode)",
+ "canRetryWithDeviceToken": \(canRetryWithDeviceToken ? "true" : "false")
+ \(recommendedNextStepJson)
+ }
+ }
+ }
+ """
+ return Data(json.utf8)
+ }
+
static func requestID(from message: URLSessionWebSocketTask.Message) -> String? {
guard let obj = self.requestFrameObject(from: message) else { return nil }
guard (obj["type"] as? String) == "req" else {
diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeBrowserProxyTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeBrowserProxyTests.swift
index c000f6d4241..b341263b21f 100644
--- a/apps/macos/Tests/OpenClawIPCTests/MacNodeBrowserProxyTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeBrowserProxyTests.swift
@@ -38,4 +38,49 @@ struct MacNodeBrowserProxyTests {
#expect(tabs.count == 1)
#expect(tabs[0]["id"] as? String == "tab-1")
}
+
+ // Regression test: nested POST bodies must serialize without __SwiftValue crashes.
+ @Test func postRequestSerializesNestedBodyWithoutCrash() async throws {
+ actor BodyCapture {
+ private var body: Data?
+
+ func set(_ body: Data?) {
+ self.body = body
+ }
+
+ func get() -> Data? {
+ self.body
+ }
+ }
+
+ let capturedBody = BodyCapture()
+ let proxy = MacNodeBrowserProxy(
+ endpointProvider: {
+ MacNodeBrowserProxy.Endpoint(
+ baseURL: URL(string: "http://127.0.0.1:18791")!,
+ token: nil,
+ password: nil)
+ },
+ performRequest: { request in
+ await capturedBody.set(request.httpBody)
+ let url = try #require(request.url)
+ let response = try #require(
+ HTTPURLResponse(
+ url: url,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil))
+ return (Data(#"{"ok":true}"#.utf8), response)
+ })
+
+ _ = try await proxy.request(
+ paramsJSON: #"{"method":"POST","path":"/action","body":{"nested":{"key":"val"},"arr":[1,2]}}"#)
+
+ let bodyData = try #require(await capturedBody.get())
+ let parsed = try #require(JSONSerialization.jsonObject(with: bodyData) as? [String: Any])
+ let nested = try #require(parsed["nested"] as? [String: Any])
+ #expect(nested["key"] as? String == "val")
+ let arr = try #require(parsed["arr"] as? [Any])
+ #expect(arr.count == 2)
+ }
}
diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingRemoteAuthPromptTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingRemoteAuthPromptTests.swift
new file mode 100644
index 00000000000..d33cff562f9
--- /dev/null
+++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingRemoteAuthPromptTests.swift
@@ -0,0 +1,126 @@
+import OpenClawKit
+import Testing
+@testable import OpenClaw
+
+@MainActor
+struct OnboardingRemoteAuthPromptTests {
+ @Test func `auth detail codes map to remote auth issues`() {
+ let tokenMissing = GatewayConnectAuthError(
+ message: "token missing",
+ detailCode: GatewayConnectAuthDetailCode.authTokenMissing.rawValue,
+ canRetryWithDeviceToken: false)
+ let tokenMismatch = GatewayConnectAuthError(
+ message: "token mismatch",
+ detailCode: GatewayConnectAuthDetailCode.authTokenMismatch.rawValue,
+ canRetryWithDeviceToken: false)
+ let tokenNotConfigured = GatewayConnectAuthError(
+ message: "token not configured",
+ detailCode: GatewayConnectAuthDetailCode.authTokenNotConfigured.rawValue,
+ canRetryWithDeviceToken: false)
+ let passwordMissing = GatewayConnectAuthError(
+ message: "password missing",
+ detailCode: GatewayConnectAuthDetailCode.authPasswordMissing.rawValue,
+ canRetryWithDeviceToken: false)
+ let pairingRequired = GatewayConnectAuthError(
+ message: "pairing required",
+ detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
+ canRetryWithDeviceToken: false)
+ let unknown = GatewayConnectAuthError(
+ message: "other",
+ detailCode: "SOMETHING_ELSE",
+ canRetryWithDeviceToken: false)
+
+ #expect(RemoteGatewayAuthIssue(error: tokenMissing) == .tokenRequired)
+ #expect(RemoteGatewayAuthIssue(error: tokenMismatch) == .tokenMismatch)
+ #expect(RemoteGatewayAuthIssue(error: tokenNotConfigured) == .gatewayTokenNotConfigured)
+ #expect(RemoteGatewayAuthIssue(error: passwordMissing) == .passwordRequired)
+ #expect(RemoteGatewayAuthIssue(error: pairingRequired) == .pairingRequired)
+ #expect(RemoteGatewayAuthIssue(error: unknown) == nil)
+ }
+
+ @Test func `password detail family maps to password required issue`() {
+ let mismatch = GatewayConnectAuthError(
+ message: "password mismatch",
+ detailCode: GatewayConnectAuthDetailCode.authPasswordMismatch.rawValue,
+ canRetryWithDeviceToken: false)
+ let notConfigured = GatewayConnectAuthError(
+ message: "password not configured",
+ detailCode: GatewayConnectAuthDetailCode.authPasswordNotConfigured.rawValue,
+ canRetryWithDeviceToken: false)
+
+ #expect(RemoteGatewayAuthIssue(error: mismatch) == .passwordRequired)
+ #expect(RemoteGatewayAuthIssue(error: notConfigured) == .passwordRequired)
+ }
+
+ @Test func `token field visibility follows onboarding rules`() {
+ #expect(OnboardingView.shouldShowRemoteTokenField(
+ showAdvancedConnection: false,
+ remoteToken: "",
+ remoteTokenUnsupported: false,
+ authIssue: nil) == false)
+ #expect(OnboardingView.shouldShowRemoteTokenField(
+ showAdvancedConnection: true,
+ remoteToken: "",
+ remoteTokenUnsupported: false,
+ authIssue: nil))
+ #expect(OnboardingView.shouldShowRemoteTokenField(
+ showAdvancedConnection: false,
+ remoteToken: "secret",
+ remoteTokenUnsupported: false,
+ authIssue: nil))
+ #expect(OnboardingView.shouldShowRemoteTokenField(
+ showAdvancedConnection: false,
+ remoteToken: "",
+ remoteTokenUnsupported: true,
+ authIssue: nil))
+ #expect(OnboardingView.shouldShowRemoteTokenField(
+ showAdvancedConnection: false,
+ remoteToken: "",
+ remoteTokenUnsupported: false,
+ authIssue: .tokenRequired))
+ #expect(OnboardingView.shouldShowRemoteTokenField(
+ showAdvancedConnection: false,
+ remoteToken: "",
+ remoteTokenUnsupported: false,
+ authIssue: .tokenMismatch))
+ #expect(OnboardingView.shouldShowRemoteTokenField(
+ showAdvancedConnection: false,
+ remoteToken: "",
+ remoteTokenUnsupported: false,
+ authIssue: .gatewayTokenNotConfigured) == false)
+ #expect(OnboardingView.shouldShowRemoteTokenField(
+ showAdvancedConnection: false,
+ remoteToken: "",
+ remoteTokenUnsupported: false,
+ authIssue: .pairingRequired) == false)
+ }
+
+ @Test func `pairing required copy points users to pair approve`() {
+ let issue = RemoteGatewayAuthIssue.pairingRequired
+
+ #expect(issue.title == "This device needs pairing approval")
+ #expect(issue.body.contains("`/pair approve`"))
+ #expect(issue.statusMessage.contains("/pair approve"))
+ #expect(issue.footnote?.contains("`openclaw devices approve`") == true)
+ }
+
+ @Test func `paired device success copy explains auth source`() {
+ let pairedDevice = RemoteGatewayProbeSuccess(authSource: .deviceToken)
+ let sharedToken = RemoteGatewayProbeSuccess(authSource: .sharedToken)
+ let noAuth = RemoteGatewayProbeSuccess(authSource: GatewayAuthSource.none)
+
+ #expect(pairedDevice.title == "Connected via paired device")
+ #expect(pairedDevice.detail == "This Mac used a stored device token. New or unpaired devices may still need the gateway token.")
+ #expect(sharedToken.title == "Connected with gateway token")
+ #expect(sharedToken.detail == nil)
+ #expect(noAuth.title == "Remote gateway ready")
+ #expect(noAuth.detail == nil)
+ }
+
+ @Test func `transient probe mode restore does not clear probe feedback`() {
+ #expect(OnboardingView.shouldResetRemoteProbeFeedback(for: .local, suppressReset: false))
+ #expect(OnboardingView.shouldResetRemoteProbeFeedback(for: .unconfigured, suppressReset: false))
+ #expect(OnboardingView.shouldResetRemoteProbeFeedback(for: .remote, suppressReset: false) == false)
+ #expect(OnboardingView.shouldResetRemoteProbeFeedback(for: .local, suppressReset: true) == false)
+ }
+}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift
index 14bd67ed445..3cd290389fe 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift
@@ -9,6 +9,8 @@ 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
@@ -27,11 +29,15 @@ 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 {
@@ -83,11 +89,19 @@ struct OpenClawChatComposer: View {
}
private var thinkingPicker: some View {
- Picker("Thinking", selection: self.$viewModel.thinkingLevel) {
+ Picker(
+ "Thinking",
+ selection: Binding(
+ get: { self.viewModel.thinkingLevel },
+ set: { next in self.viewModel.selectThinkingLevel(next) }))
+ {
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)
@@ -95,6 +109,25 @@ 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",
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift
index febe69a3cbe..48f01e09c6a 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift
@@ -1,5 +1,36 @@
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?
@@ -27,6 +58,7 @@ 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?
}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift
index 037c1352205..bfbd33bfda3 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift
@@ -10,6 +10,7 @@ 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,
@@ -19,6 +20,8 @@ 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
@@ -42,4 +45,25 @@ 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"])
+ }
}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift
index 62cb97a0e2f..a136469fbd8 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift
@@ -15,9 +15,13 @@ 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 var thinkingLevel: String = "off"
+ public private(set) var thinkingLevel: String
+ public private(set) var modelSelectionID: String = "__default__"
+ public private(set) var modelChoices: [OpenClawChatModelChoice] = []
public private(set) var isLoading = false
public private(set) var isSending = false
public private(set) var isAborting = false
@@ -32,6 +36,9 @@ 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?
@@ -42,6 +49,17 @@ public final class OpenClawChatViewModel {
@ObservationIgnored
private nonisolated(unsafe) var pendingRunTimeoutTasks: [String: Task] = [:]
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]] = [:]
+ private var nextThinkingSelectionRequestID: UInt64 = 0
+ private var latestThinkingSelectionRequestIDsBySession: [String: UInt64] = [:]
+ private var latestThinkingLevelsBySession: [String: String] = [:]
private var pendingToolCallsById: [String: OpenClawChatPendingToolCall] = [:] {
didSet {
@@ -52,9 +70,18 @@ public final class OpenClawChatViewModel {
private var lastHealthPollAt: Date?
- public init(sessionKey: String, transport: any OpenClawChatTransport) {
+ public init(
+ sessionKey: String,
+ transport: any OpenClawChatTransport,
+ initialThinkingLevel: String? = nil,
+ onThinkingLevelChanged: (@MainActor @Sendable (String) -> Void)? = nil)
+ {
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 }
@@ -99,6 +126,14 @@ 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)
@@ -134,6 +169,17 @@ 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) }
}
@@ -174,11 +220,14 @@ public final class OpenClawChatViewModel {
previous: self.messages,
incoming: Self.decodeMessages(payload.messages ?? []))
self.sessionId = payload.sessionId
- if let level = payload.thinkingLevel, !level.isEmpty {
+ if !self.prefersExplicitThinkingLevel,
+ let level = Self.normalizedThinkingLevel(payload.thinkingLevel)
+ {
self.thinkingLevel = level
}
await self.pollHealthIfNeeded(force: true)
await self.fetchSessions(limit: 50)
+ await self.fetchModels()
self.errorText = nil
} catch {
self.errorText = error.localizedDescription
@@ -320,6 +369,7 @@ 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"
@@ -330,6 +380,7 @@ 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 = [:]
@@ -382,10 +433,11 @@ public final class OpenClawChatViewModel {
self.attachments = []
do {
+ await self.waitForPendingModelPatches(in: sessionKey)
let response = try await self.transport.sendMessage(
- sessionKey: self.sessionKey,
+ sessionKey: sessionKey,
message: messageText,
- thinking: self.thinkingLevel,
+ thinking: thinkingLevel,
idempotencyKey: runId,
attachments: encodedAttachments)
if response.runId != runId {
@@ -422,6 +474,17 @@ 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.
}
@@ -432,9 +495,106 @@ 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,
@@ -453,10 +613,159 @@ 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):
@@ -573,7 +882,9 @@ public final class OpenClawChatViewModel {
previous: self.messages,
incoming: Self.decodeMessages(payload.messages ?? []))
self.sessionId = payload.sessionId
- if let level = payload.thinkingLevel, !level.isEmpty {
+ if !self.prefersExplicitThinkingLevel,
+ let level = Self.normalizedThinkingLevel(payload.thinkingLevel)
+ {
self.thinkingLevel = level
}
} catch {
@@ -682,4 +993,13 @@ 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
+ }
}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift
index 3dc5eacee6e..4848043980b 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift
@@ -131,6 +131,20 @@ private let defaultOperatorConnectScopes: [String] = [
"operator.pairing",
]
+private enum GatewayConnectErrorCodes {
+ static let authTokenMismatch = GatewayConnectAuthDetailCode.authTokenMismatch.rawValue
+ static let authDeviceTokenMismatch = GatewayConnectAuthDetailCode.authDeviceTokenMismatch.rawValue
+ static let authTokenMissing = GatewayConnectAuthDetailCode.authTokenMissing.rawValue
+ static let authTokenNotConfigured = GatewayConnectAuthDetailCode.authTokenNotConfigured.rawValue
+ static let authPasswordMissing = GatewayConnectAuthDetailCode.authPasswordMissing.rawValue
+ static let authPasswordMismatch = GatewayConnectAuthDetailCode.authPasswordMismatch.rawValue
+ static let authPasswordNotConfigured = GatewayConnectAuthDetailCode.authPasswordNotConfigured.rawValue
+ static let authRateLimited = GatewayConnectAuthDetailCode.authRateLimited.rawValue
+ static let pairingRequired = GatewayConnectAuthDetailCode.pairingRequired.rawValue
+ static let controlUiDeviceIdentityRequired = GatewayConnectAuthDetailCode.controlUiDeviceIdentityRequired.rawValue
+ static let deviceIdentityRequired = GatewayConnectAuthDetailCode.deviceIdentityRequired.rawValue
+}
+
public actor GatewayChannelActor {
private let logger = Logger(subsystem: "ai.openclaw", category: "gateway")
private var task: WebSocketTaskBox?
@@ -160,6 +174,9 @@ public actor GatewayChannelActor {
private var watchdogTask: Task?
private var tickTask: Task?
private var keepaliveTask: Task?
+ 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?
@@ -232,10 +249,18 @@ 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)")
}
@@ -267,7 +292,12 @@ public actor GatewayChannelActor {
},
operation: { try await self.sendConnect() })
} catch {
- let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
+ let wrapped: Error
+ if let authError = error as? GatewayConnectAuthError {
+ wrapped = authError
+ } else {
+ 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)")
@@ -281,6 +311,7 @@ public actor GatewayChannelActor {
}
self.listen()
self.connected = true
+ self.reconnectPausedForAuthFailure = false
self.backoffMs = 500
self.lastSeq = nil
self.startKeepalive()
@@ -371,11 +402,18 @@ public actor GatewayChannelActor {
(includeDeviceIdentity && identity != nil)
? DeviceAuthStore.loadToken(deviceId: identity!.deviceId, role: role)?.token
: 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 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
let authSource: GatewayAuthSource
- if storedToken != nil {
+ if authDeviceToken != nil || (self.token == nil && storedToken != nil) {
authSource = .deviceToken
} else if authToken != nil {
authSource = .sharedToken
@@ -386,9 +424,12 @@ 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 {
- params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])
+ var auth: [String: ProtoAnyCodable] = ["token": ProtoAnyCodable(authToken)]
+ if let authDeviceToken {
+ auth["deviceToken"] = ProtoAnyCodable(authDeviceToken)
+ }
+ params["auth"] = ProtoAnyCodable(auth)
} else if let password = self.password {
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
}
@@ -426,11 +467,24 @@ 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 {
- if canFallbackToShared {
- if let identity {
- DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
- }
+ 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)
}
throw error
}
@@ -443,7 +497,15 @@ public actor GatewayChannelActor {
) async throws {
if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
- throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg])
+ let details = res.error?["details"]?.value as? [String: ProtoAnyCodable]
+ let detailCode = details?["code"]?.value as? String
+ let canRetryWithDeviceToken = details?["canRetryWithDeviceToken"]?.value as? Bool ?? false
+ let recommendedNextStep = details?["recommendedNextStep"]?.value as? String
+ throw GatewayConnectAuthError(
+ message: msg,
+ detailCodeRaw: detailCode,
+ canRetryWithDeviceToken: canRetryWithDeviceToken,
+ recommendedNextStepRaw: recommendedNextStep)
}
guard let payload = res.payload else {
throw NSError(
@@ -616,19 +678,90 @@ 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.detail == .authTokenMismatch
+ }
+
+ private func shouldPauseReconnectAfterAuthFailure(_ error: Error) -> Bool {
+ guard let authError = error as? GatewayConnectAuthError else {
+ return false
+ }
+ if authError.isNonRecoverable {
+ return true
+ }
+ if authError.detail == .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.detail == .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)
@@ -713,6 +846,9 @@ public actor GatewayChannelActor {
// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
private func wrap(_ error: Error, context: String) -> Error {
+ if error is GatewayConnectAuthError || error is GatewayResponseError || error is GatewayDecodingError {
+ return error
+ }
if let urlError = error as? URLError {
let desc = urlError.localizedDescription.isEmpty ? "cancelled" : urlError.localizedDescription
return NSError(
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift
index 6ca81dec445..3b1d97059a3 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift
@@ -1,6 +1,112 @@
import OpenClawProtocol
import Foundation
+public enum GatewayConnectAuthDetailCode: String, Sendable {
+ case authRequired = "AUTH_REQUIRED"
+ case authUnauthorized = "AUTH_UNAUTHORIZED"
+ case authTokenMismatch = "AUTH_TOKEN_MISMATCH"
+ case authDeviceTokenMismatch = "AUTH_DEVICE_TOKEN_MISMATCH"
+ case authTokenMissing = "AUTH_TOKEN_MISSING"
+ case authTokenNotConfigured = "AUTH_TOKEN_NOT_CONFIGURED"
+ case authPasswordMissing = "AUTH_PASSWORD_MISSING"
+ case authPasswordMismatch = "AUTH_PASSWORD_MISMATCH"
+ case authPasswordNotConfigured = "AUTH_PASSWORD_NOT_CONFIGURED"
+ case authRateLimited = "AUTH_RATE_LIMITED"
+ case authTailscaleIdentityMissing = "AUTH_TAILSCALE_IDENTITY_MISSING"
+ case authTailscaleProxyMissing = "AUTH_TAILSCALE_PROXY_MISSING"
+ case authTailscaleWhoisFailed = "AUTH_TAILSCALE_WHOIS_FAILED"
+ case authTailscaleIdentityMismatch = "AUTH_TAILSCALE_IDENTITY_MISMATCH"
+ case pairingRequired = "PAIRING_REQUIRED"
+ case controlUiDeviceIdentityRequired = "CONTROL_UI_DEVICE_IDENTITY_REQUIRED"
+ case deviceIdentityRequired = "DEVICE_IDENTITY_REQUIRED"
+ case deviceAuthInvalid = "DEVICE_AUTH_INVALID"
+ case deviceAuthDeviceIdMismatch = "DEVICE_AUTH_DEVICE_ID_MISMATCH"
+ case deviceAuthSignatureExpired = "DEVICE_AUTH_SIGNATURE_EXPIRED"
+ case deviceAuthNonceRequired = "DEVICE_AUTH_NONCE_REQUIRED"
+ case deviceAuthNonceMismatch = "DEVICE_AUTH_NONCE_MISMATCH"
+ case deviceAuthSignatureInvalid = "DEVICE_AUTH_SIGNATURE_INVALID"
+ case deviceAuthPublicKeyInvalid = "DEVICE_AUTH_PUBLIC_KEY_INVALID"
+}
+
+public enum GatewayConnectRecoveryNextStep: String, Sendable {
+ case retryWithDeviceToken = "retry_with_device_token"
+ case updateAuthConfiguration = "update_auth_configuration"
+ case updateAuthCredentials = "update_auth_credentials"
+ case waitThenRetry = "wait_then_retry"
+ case reviewAuthConfiguration = "review_auth_configuration"
+}
+
+/// Structured websocket connect-auth rejection surfaced before the channel is usable.
+public struct GatewayConnectAuthError: LocalizedError, Sendable {
+ public let message: String
+ public let detailCodeRaw: String?
+ public let recommendedNextStepRaw: String?
+ public let canRetryWithDeviceToken: Bool
+
+ public init(
+ message: String,
+ detailCodeRaw: String?,
+ canRetryWithDeviceToken: Bool,
+ recommendedNextStepRaw: String? = nil)
+ {
+ let trimmedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines)
+ let trimmedDetailCode = detailCodeRaw?.trimmingCharacters(in: .whitespacesAndNewlines)
+ let trimmedRecommendedNextStep =
+ recommendedNextStepRaw?.trimmingCharacters(in: .whitespacesAndNewlines)
+ self.message = trimmedMessage.isEmpty ? "gateway connect failed" : trimmedMessage
+ self.detailCodeRaw = trimmedDetailCode?.isEmpty == false ? trimmedDetailCode : nil
+ self.canRetryWithDeviceToken = canRetryWithDeviceToken
+ self.recommendedNextStepRaw =
+ trimmedRecommendedNextStep?.isEmpty == false ? trimmedRecommendedNextStep : nil
+ }
+
+ public init(
+ message: String,
+ detailCode: String?,
+ canRetryWithDeviceToken: Bool,
+ recommendedNextStep: String? = nil)
+ {
+ self.init(
+ message: message,
+ detailCodeRaw: detailCode,
+ canRetryWithDeviceToken: canRetryWithDeviceToken,
+ recommendedNextStepRaw: recommendedNextStep)
+ }
+
+ public var detailCode: String? { self.detailCodeRaw }
+
+ public var recommendedNextStepCode: String? { self.recommendedNextStepRaw }
+
+ public var detail: GatewayConnectAuthDetailCode? {
+ guard let detailCodeRaw else { return nil }
+ return GatewayConnectAuthDetailCode(rawValue: detailCodeRaw)
+ }
+
+ public var recommendedNextStep: GatewayConnectRecoveryNextStep? {
+ guard let recommendedNextStepRaw else { return nil }
+ return GatewayConnectRecoveryNextStep(rawValue: recommendedNextStepRaw)
+ }
+
+ public var errorDescription: String? { self.message }
+
+ public var isNonRecoverable: Bool {
+ switch self.detail {
+ case .authTokenMissing,
+ .authTokenNotConfigured,
+ .authPasswordMissing,
+ .authPasswordMismatch,
+ .authPasswordNotConfigured,
+ .authRateLimited,
+ .pairingRequired,
+ .controlUiDeviceIdentityRequired,
+ .deviceIdentityRequired:
+ return true
+ default:
+ return false
+ }
+ }
+}
+
/// Structured error surfaced when the gateway responds with `{ ok: false }`.
public struct GatewayResponseError: LocalizedError, @unchecked Sendable {
public let method: String
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
index cf69609e673..ea85e6c1511 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
@@ -1337,6 +1337,8 @@ 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?
@@ -1355,6 +1357,8 @@ public struct SessionsPatchParams: Codable, Sendable {
model: AnyCodable?,
spawnedby: AnyCodable?,
spawndepth: AnyCodable?,
+ subagentrole: AnyCodable?,
+ subagentcontrolscope: AnyCodable?,
sendpolicy: AnyCodable?,
groupactivation: AnyCodable?)
{
@@ -1372,6 +1376,8 @@ 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
}
@@ -1391,6 +1397,8 @@ 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"
}
@@ -3046,7 +3054,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]?
@@ -3067,7 +3075,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public init(
id: String?,
- command: String,
+ command: String?,
commandargv: [String]?,
systemrunplan: [String: AnyCodable]?,
env: [String: AnyCodable]?,
diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift
index e7ba4523e68..abfd267a66c 100644
--- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift
+++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift
@@ -41,17 +41,67 @@ 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] = []) async -> (TestChatTransport, OpenClawChatViewModel)
+ 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)
{
- let transport = TestChatTransport(historyResponses: historyResponses, sessionsResponses: sessionsResponses)
- let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: sessionKey, transport: transport) }
+ 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)
+ }
return (transport, vm)
}
@@ -125,27 +175,60 @@ private func emitExternalFinal(
errorMessage: nil)))
}
+@MainActor
+private final class CallbackBox {
+ var values: [String] = []
+}
+
+private actor AsyncGate {
+ private var continuation: CheckedContinuation?
+
+ 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
private let continuation: AsyncStream.Continuation
init(
historyResponses: [OpenClawChatHistoryPayload],
- sessionsResponses: [OpenClawChatSessionsListResponse] = [])
+ sessionsResponses: [OpenClawChatSessionsListResponse] = [],
+ modelResponses: [[OpenClawChatModelChoice]] = [],
+ setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
+ setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil)
{
self.historyResponses = historyResponses
self.sessionsResponses = sessionsResponses
+ self.modelResponses = modelResponses
+ self.setSessionModelHook = setSessionModelHook
+ self.setSessionThinkingHook = setSessionThinkingHook
var cont: AsyncStream.Continuation!
self.stream = AsyncStream { c in
cont = c
@@ -175,11 +258,12 @@ 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")
}
@@ -201,6 +285,29 @@ 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
}
@@ -217,6 +324,18 @@ 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 {
@@ -228,6 +347,10 @@ extension TestChatTransportState {
self.sessionsCallCount = v
}
+ fileprivate func setModelsCallCount(_ v: Int) {
+ self.modelsCallCount = v
+ }
+
fileprivate func sentRunIdsAppend(_ v: String) {
self.sentRunIds.append(v)
}
@@ -235,6 +358,18 @@ 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 {
@@ -457,6 +592,512 @@ 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)
diff --git a/docs/channels/discord.md b/docs/channels/discord.md
index 48a8a03f59e..e179417e9b8 100644
--- a/docs/channels/discord.md
+++ b/docs/channels/discord.md
@@ -946,7 +946,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 when `gateway.auth.*` is unset
+ - in local mode, `gateway.remote.*` can be used as fallback only when `gateway.auth.*` is unset; configured-but-unresolved local SecretRefs fail closed
- 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
diff --git a/docs/channels/line.md b/docs/channels/line.md
index 50972d93d21..a965dc6e991 100644
--- a/docs/channels/line.md
+++ b/docs/channels/line.md
@@ -87,6 +87,8 @@ Token/secret files:
}
```
+`tokenFile` and `secretFile` must point to regular files. Symlinks are rejected.
+
Multiple accounts:
```json5
diff --git a/docs/channels/nextcloud-talk.md b/docs/channels/nextcloud-talk.md
index d4ab9e2c397..7797b1276ff 100644
--- a/docs/channels/nextcloud-talk.md
+++ b/docs/channels/nextcloud-talk.md
@@ -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`: secret file path.
+- `channels.nextcloud-talk.botSecretFile`: regular-file secret path. Symlinks are rejected.
- `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.
diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md
index 7c32c29ab19..f2467d12b0a 100644
--- a/docs/channels/telegram.md
+++ b/docs/channels/telegram.md
@@ -892,7 +892,7 @@ Primary reference:
- `channels.telegram.enabled`: enable/disable channel startup.
- `channels.telegram.botToken`: bot token (BotFather).
-- `channels.telegram.tokenFile`: read token from file path.
+- `channels.telegram.tokenFile`: read token from a regular file path. Symlinks are rejected.
- `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`).
@@ -953,7 +953,7 @@ Primary reference:
Telegram-specific high-signal fields:
-- startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*`
+- startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*` (`tokenFile` must point to a regular file; symlinks are rejected)
- 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`
diff --git a/docs/channels/zalo.md b/docs/channels/zalo.md
index 8e5d8ab0382..77b288b0ab7 100644
--- a/docs/channels/zalo.md
+++ b/docs/channels/zalo.md
@@ -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 file path.
+- `channels.zalo.tokenFile`: read token from a regular file path. Symlinks are rejected.
- `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..botToken`: per-account token.
-- `channels.zalo.accounts..tokenFile`: per-account token file.
+- `channels.zalo.accounts..tokenFile`: per-account regular token file. Symlinks are rejected.
- `channels.zalo.accounts..name`: display name.
- `channels.zalo.accounts..enabled`: enable/disable account.
- `channels.zalo.accounts..dmPolicy`: per-account DM policy.
diff --git a/docs/cli/acp.md b/docs/cli/acp.md
index 152770e6d86..9e239fc8bdf 100644
--- a/docs/cli/acp.md
+++ b/docs/cli/acp.md
@@ -273,7 +273,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 when `gateway.auth.*` is unset
+ - local mode: env (`OPENCLAW_GATEWAY_*`) -> `gateway.auth.*` -> `gateway.remote.*` fallback only when `gateway.auth.*` is unset (configured-but-unresolved local SecretRefs fail closed)
- 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.
diff --git a/docs/cli/devices.md b/docs/cli/devices.md
index be01e3cc0d5..f73f30dfa1d 100644
--- a/docs/cli/devices.md
+++ b/docs/cli/devices.md
@@ -92,3 +92,40 @@ 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 --role operator
+```
+
+4. If rotation is not enough, remove stale pairing and approve again:
+
+```bash
+openclaw devices remove
+openclaw devices list
+openclaw devices approve
+```
+
+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)
diff --git a/docs/cli/index.md b/docs/cli/index.md
index fdee80038c0..cbcd5bff0b5 100644
--- a/docs/cli/index.md
+++ b/docs/cli/index.md
@@ -337,7 +337,7 @@ Options:
- `--non-interactive`
- `--mode `
- `--flow ` (manual is an alias for advanced)
-- `--auth-choice `
+- `--auth-choice `
- `--token-provider ` (non-interactive; used with `--auth-choice token`)
- `--token ` (non-interactive; used with `--auth-choice token`)
- `--token-profile-id ` (non-interactive; default: `:manual`)
@@ -354,6 +354,7 @@ Options:
- `--zai-api-key `
- `--minimax-api-key `
- `--opencode-zen-api-key `
+- `--opencode-go-api-key `
- `--custom-base-url ` (non-interactive; used with `--auth-choice custom-api-key`)
- `--custom-model-id ` (non-interactive; used with `--auth-choice custom-api-key`)
- `--custom-api-key ` (non-interactive; optional; used with `--auth-choice custom-api-key`; falls back to `CUSTOM_API_KEY` when omitted)
@@ -1018,7 +1019,7 @@ Subcommands:
Auth notes:
-- `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.*`.
+- `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.
- Legacy `CLAWDBOT_GATEWAY_*` env vars are intentionally ignored for node-host auth resolution.
## Nodes
diff --git a/docs/cli/node.md b/docs/cli/node.md
index 95f0936065e..baf8c3cd45e 100644
--- a/docs/cli/node.md
+++ b/docs/cli/node.md
@@ -64,7 +64,8 @@ Options:
- `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD` are checked first.
- Then local config fallback: `gateway.auth.token` / `gateway.auth.password`.
-- In local mode, `gateway.remote.token` / `gateway.remote.password` are also eligible as fallback when `gateway.auth.*` is unset.
+- 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 `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.
diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md
index 6dd4c2f9c03..4f3d80b2420 100644
--- a/docs/concepts/model-providers.md
+++ b/docs/concepts/model-providers.md
@@ -86,12 +86,13 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
}
```
-### OpenCode Zen
+### OpenCode
-- Provider: `opencode`
- Auth: `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`)
-- Example model: `opencode/claude-opus-4-6`
-- CLI: `openclaw onboard --auth-choice opencode-zen`
+- 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`
```json5
{
@@ -104,8 +105,8 @@ OpenClaw ships with the pi‑ai 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`, `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`
+- 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`
- CLI: `openclaw onboard --auth-choice gemini-api-key`
### Google Vertex, Antigravity, and Gemini CLI
diff --git a/docs/concepts/models.md b/docs/concepts/models.md
index 2ad809d9599..f87eead821c 100644
--- a/docs/concepts/models.md
+++ b/docs/concepts/models.md
@@ -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 Zen) live in
-[/gateway/configuration](/gateway/configuration#opencode-zen-multi-model-proxy).
+Provider configuration examples (including OpenCode) live in
+[/gateway/configuration](/gateway/configuration#opencode).
## “Model is not allowed” (and why replies stop)
diff --git a/docs/docs.json b/docs/docs.json
index 8592618cd7d..e6cf5ba382b 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -103,6 +103,10 @@
"source": "/opencode",
"destination": "/providers/opencode"
},
+ {
+ "source": "/opencode-go",
+ "destination": "/providers/opencode-go"
+ },
{
"source": "/qianfan",
"destination": "/providers/qianfan"
@@ -1013,8 +1017,7 @@
"tools/browser",
"tools/browser-login",
"tools/chrome-extension",
- "tools/browser-linux-troubleshooting",
- "tools/browser-wsl2-windows-remote-cdp-troubleshooting"
+ "tools/browser-linux-troubleshooting"
]
},
{
@@ -1112,6 +1115,7 @@
"providers/nvidia",
"providers/ollama",
"providers/openai",
+ "providers/opencode-go",
"providers/opencode",
"providers/openrouter",
"providers/qianfan",
diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md
index 5cad5acea9d..1e48f69d6f8 100644
--- a/docs/gateway/configuration-reference.md
+++ b/docs/gateway/configuration-reference.md
@@ -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`, with `TELEGRAM_BOT_TOKEN` as fallback for the default account.
+- Bot token: `channels.telegram.botToken` or `channels.telegram.tokenFile` (regular file only; symlinks rejected), 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`).
@@ -748,6 +748,7 @@ Include your own number in `allowFrom` to enable self-chat mode (ignores native
- `bash: true` enables `! ` for host shell. Requires `tools.elevated.enabled` and sender in `tools.elevated.allowFrom.`.
- `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..configWrites` gates config mutations per channel (default: true).
+- For multi-account channels, `channels..accounts..configWrites` also gates writes that target that account (for example `/allowlist --config --account ` or `/config set channels..accounts....`).
- `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.
@@ -2078,7 +2079,7 @@ Use `cerebras/zai-glm-4.7` for Cerebras; `zai/glm-4.7` for Z.AI direct.
-
+
```json5
{
@@ -2091,7 +2092,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`). Shortcut: `openclaw onboard --auth-choice opencode-zen`.
+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`.
@@ -2469,7 +2470,8 @@ 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 when `gateway.auth.*` is unset.
+- 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).
- `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).
diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md
index b46b90520d1..95027906750 100644
--- a/docs/gateway/doctor.md
+++ b/docs/gateway/doctor.md
@@ -63,7 +63,7 @@ cat ~/.openclaw/openclaw.json
- Health check + restart prompt.
- Skills status summary (eligible/missing/blocked).
- Config normalization for legacy values.
-- OpenCode Zen provider override warnings (`models.providers.opencode`).
+- OpenCode provider override warnings (`models.providers.opencode` / `models.providers.opencode-go`).
- 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).
@@ -134,12 +134,12 @@ Doctor warnings also include account-default guidance for multi-account channels
- If two or more `channels..accounts` entries are configured without `channels..defaultAccount` or `accounts.default`, doctor warns that fallback routing can pick an unexpected account.
- If `channels..defaultAccount` is set to an unknown account ID, doctor warns and lists configured account IDs.
-### 2b) OpenCode Zen provider overrides
+### 2b) OpenCode provider overrides
-If you’ve 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.
+If you’ve 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.
### 3) Legacy state migrations (disk layout)
diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md
index 62a5adb1fef..9c886a31716 100644
--- a/docs/gateway/protocol.md
+++ b/docs/gateway/protocol.md
@@ -206,6 +206,12 @@ 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
@@ -217,8 +223,9 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- **Local** connects include loopback and the gateway host’s own tailnet address
(so same‑host tailnet binds can still auto‑approve).
- All WS clients must include `device` identity during `connect` (operator + node).
- Control UI can omit it **only** when `gateway.controlUi.dangerouslyDisableDeviceAuth`
- is enabled for break-glass use.
+ 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).
- All connections must sign the server-provided `connect.challenge` nonce.
### Device auth migration diagnostics
diff --git a/docs/gateway/remote.md b/docs/gateway/remote.md
index a9aadc49dd1..dcbae985b74 100644
--- a/docs/gateway/remote.md
+++ b/docs/gateway/remote.md
@@ -103,18 +103,19 @@ 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, Discord exec-approval monitoring, and node-host connections:
+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.*`):
- 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`
- - password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.auth.password` -> `gateway.remote.password`
+ - 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)
- 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.
@@ -140,7 +141,8 @@ Short version: **keep the Gateway loopback-only** unless you’re 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 when `gateway.auth.*` is unset.
+- 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).
- `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
diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md
index 76b89a0f28a..93cd508d4f1 100644
--- a/docs/gateway/secrets.md
+++ b/docs/gateway/secrets.md
@@ -41,13 +41,13 @@ Examples of inactive 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 (when `gateway.remote.enabled` is not `false`) if one of these is true:
+- `gateway.remote.token` / `gateway.remote.password` SecretRefs are active 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
diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md
index c62b77352e8..3084adf82ad 100644
--- a/docs/gateway/security/index.md
+++ b/docs/gateway/security/index.md
@@ -104,6 +104,7 @@ 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.
@@ -199,7 +200,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//creds.json`
-- **Telegram bot token**: config/env or `channels.telegram.tokenFile`
+- **Telegram bot token**: config/env or `channels.telegram.tokenFile` (regular file only; symlinks rejected)
- **Discord bot token**: config/env or SecretRef (env/file/exec providers)
- **Slack tokens**: config/env (`channels.slack.*`)
- **Pairing allowlists**:
@@ -262,9 +263,14 @@ 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` does **not** bypass secure-context,
-device-identity, or device-pairing checks. Prefer HTTPS (Tailscale Serve) or open
-the UI on `127.0.0.1`.
+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`.
For break-glass scenarios only, `gateway.controlUi.dangerouslyDisableDeviceAuth`
disables device identity checks entirely. This is a severe security downgrade;
@@ -365,6 +371,7 @@ 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 don’t want remote execution, set security to **deny** and remove node pairing for that Mac.
## Dynamic skills (watcher / remote nodes)
@@ -747,8 +754,10 @@ 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 when `gateway.auth.*`
+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).
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.
diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md
index 46d2c58b966..ebea28a6541 100644
--- a/docs/gateway/troubleshooting.md
+++ b/docs/gateway/troubleshooting.md
@@ -113,9 +113,21 @@ 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.
-- `unauthorized` / reconnect loop → token/password mismatch.
+- `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.
- `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 `. |
+
Device auth v2 migration check:
```bash
@@ -135,6 +147,7 @@ Related:
- [/web/control-ui](/web/control-ui)
- [/gateway/authentication](/gateway/authentication)
- [/gateway/remote](/gateway/remote)
+- [/cli/devices](/cli/devices)
## Gateway service not running
diff --git a/docs/help/faq.md b/docs/help/faq.md
index a43e91f4396..8b738b60fc2 100644
--- a/docs/help/faq.md
+++ b/docs/help/faq.md
@@ -1452,7 +1452,8 @@ 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 when `gateway.auth.*` is unset.
+- 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).
- 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
@@ -2512,6 +2513,7 @@ 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:
@@ -2520,6 +2522,9 @@ 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 --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
diff --git a/docs/help/testing.md b/docs/help/testing.md
index 6580de4da20..db374bb03da 100644
--- a/docs/help/testing.md
+++ b/docs/help/testing.md
@@ -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 Zen: `opencode/...` (auth via `OPENCODE_API_KEY` / `OPENCODE_ZEN_API_KEY`)
+- OpenCode: `opencode/...` for Zen and `opencode-go/...` for Go (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`, `xai`, `groq`, `cerebras`, `mistral`, `github-copilot`
+- 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`
- Via `models.providers` (custom endpoints): `minimax` (cloud/API), plus any OpenAI/Anthropic-compatible proxy (LM Studio, vLLM, LiteLLM, etc.)
Tip: don’t try to hardcode “all models” in docs. The authoritative list is whatever `discoverModels(...)` returns on your machine + whatever keys are available.
diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md
index e051f77f589..951e1a480d7 100644
--- a/docs/help/troubleshooting.md
+++ b/docs/help/troubleshooting.md
@@ -136,7 +136,8 @@ flowchart TD
Common log signatures:
- `device identity required` → HTTP/non-secure context cannot complete device auth.
- - `unauthorized` / reconnect loop → wrong token/password or auth mode mismatch.
+ - `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.
- `gateway connect failed:` → UI is targeting the wrong URL/port or unreachable gateway.
Deep pages:
diff --git a/docs/nodes/index.md b/docs/nodes/index.md
index 1b9b2bfaea2..7c087162c46 100644
--- a/docs/nodes/index.md
+++ b/docs/nodes/index.md
@@ -54,6 +54,15 @@ 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:
@@ -83,7 +92,10 @@ 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 remote mode, `gateway.remote.token` / `gateway.remote.password` are also eligible.
+- 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.
- Legacy `CLAWDBOT_GATEWAY_*` env vars are intentionally ignored by node-host auth resolution.
### Start a node host (service)
diff --git a/docs/providers/index.md b/docs/providers/index.md
index a4587213832..50e45c6559b 100644
--- a/docs/providers/index.md
+++ b/docs/providers/index.md
@@ -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](/providers/opencode)
+- [OpenCode (Zen + Go)](/providers/opencode)
- [OpenRouter](/providers/openrouter)
- [Qianfan](/providers/qianfan)
- [Qwen (OAuth)](/providers/qwen)
diff --git a/docs/providers/models.md b/docs/providers/models.md
index 7da741f4077..a117d286051 100644
--- a/docs/providers/models.md
+++ b/docs/providers/models.md
@@ -32,7 +32,7 @@ model as `provider/model`.
- [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot)
- [Mistral](/providers/mistral)
- [Synthetic](/providers/synthetic)
-- [OpenCode Zen](/providers/opencode)
+- [OpenCode (Zen + Go)](/providers/opencode)
- [Z.AI](/providers/zai)
- [GLM models](/providers/glm)
- [MiniMax](/providers/minimax)
diff --git a/docs/providers/opencode-go.md b/docs/providers/opencode-go.md
new file mode 100644
index 00000000000..4552e916beb
--- /dev/null
+++ b/docs/providers/opencode-go.md
@@ -0,0 +1,45 @@
+---
+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.
diff --git a/docs/providers/opencode.md b/docs/providers/opencode.md
index aa0614bff80..bf8d54afc9e 100644
--- a/docs/providers/opencode.md
+++ b/docs/providers/opencode.md
@@ -1,25 +1,38 @@
---
-summary: "Use OpenCode Zen (curated models) with OpenClaw"
+summary: "Use OpenCode Zen and Go catalogs with OpenClaw"
read_when:
- - You want OpenCode Zen for model access
- - You want a curated list of coding-friendly models
-title: "OpenCode Zen"
+ - You want OpenCode-hosted model access
+ - You want to pick between the Zen and Go catalogs
+title: "OpenCode"
---
-# OpenCode Zen
+# OpenCode
-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.
+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.
## 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
@@ -29,8 +42,23 @@ openclaw onboard --opencode-zen-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.
-- You sign in to Zen, add billing details, and copy your API key.
-- OpenCode Zen bills per request; check the OpenCode dashboard for details.
+- 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.
diff --git a/docs/reference/wizard.md b/docs/reference/wizard.md
index 2e7a43bdecc..d58ab96c83a 100644
--- a/docs/reference/wizard.md
+++ b/docs/reference/wizard.md
@@ -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 Zen (multi-model proxy)**: prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`, get it at https://opencode.ai/auth).
+ - **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.
- **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
```
-
+
```bash
openclaw onboard --non-interactive \
--mode local \
@@ -237,6 +237,7 @@ 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.
diff --git a/docs/start/setup.md b/docs/start/setup.md
index 4b6113743f8..205f14d20a5 100644
--- a/docs/start/setup.md
+++ b/docs/start/setup.md
@@ -127,7 +127,7 @@ openclaw health
Use this when debugging auth or deciding what to back up:
- **WhatsApp**: `~/.openclaw/credentials/whatsapp//creds.json`
-- **Telegram bot token**: config/env or `channels.telegram.tokenFile`
+- **Telegram bot token**: config/env or `channels.telegram.tokenFile` (regular file only; symlinks rejected)
- **Discord bot token**: config/env or SecretRef (env/file/exec providers)
- **Slack tokens**: config/env (`channels.slack.*`)
- **Pairing allowlists**:
diff --git a/docs/start/wizard-cli-automation.md b/docs/start/wizard-cli-automation.md
index 14f4a9d5d32..8547f60ac19 100644
--- a/docs/start/wizard-cli-automation.md
+++ b/docs/start/wizard-cli-automation.md
@@ -123,7 +123,7 @@ openclaw onboard --non-interactive \
--gateway-bind loopback
```
-
+
```bash
openclaw onboard --non-interactive \
--mode local \
@@ -132,6 +132,7 @@ 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.
```bash
diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md
index 44f470ea73b..20f99accd8d 100644
--- a/docs/start/wizard-cli-reference.md
+++ b/docs/start/wizard-cli-reference.md
@@ -155,8 +155,8 @@ What you set:
Prompts for `XAI_API_KEY` and configures xAI as a model provider.
-
- Prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`).
+
+ Prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`) and lets you choose the Zen or Go catalog.
Setup URL: [opencode.ai/auth](https://opencode.ai/auth).
diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md
index 91fdff80650..0bca1dee488 100644
--- a/docs/tools/exec-approvals.md
+++ b/docs/tools/exec-approvals.md
@@ -30,9 +30,14 @@ 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 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.
+- 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.
macOS split:
@@ -259,6 +264,20 @@ 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.
diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md
index dea4fb0d30f..d792398f1fa 100644
--- a/docs/tools/slash-commands.md
+++ b/docs/tools/slash-commands.md
@@ -123,6 +123,7 @@ Notes:
- `/new ` 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 ` and `/config set channels..accounts....` 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).
diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md
index d5ec66b884b..dabfc91dfc2 100644
--- a/docs/tools/subagents.md
+++ b/docs/tools/subagents.md
@@ -182,6 +182,7 @@ 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.
diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md
index c96a91de0ba..59e9c0c226b 100644
--- a/docs/web/control-ui.md
+++ b/docs/web/control-ui.md
@@ -174,7 +174,12 @@ OpenClaw **blocks** Control UI connections without device identity.
}
```
-`allowInsecureAuth` does not bypass Control UI device identity or pairing checks.
+`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.
**Break-glass only:**
diff --git a/docs/web/dashboard.md b/docs/web/dashboard.md
index ab5872a6754..86cd6fffd4e 100644
--- a/docs/web/dashboard.md
+++ b/docs/web/dashboard.md
@@ -45,6 +45,8 @@ 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`
diff --git a/extensions/acpx/src/config.test.ts b/extensions/acpx/src/config.test.ts
index ef1491d1682..45be08e3edf 100644
--- a/extensions/acpx/src/config.test.ts
+++ b/extensions/acpx/src/config.test.ts
@@ -5,7 +5,6 @@ import {
ACPX_PINNED_VERSION,
createAcpxPluginConfigSchema,
resolveAcpxPluginConfig,
- toAcpMcpServers,
} from "./config.js";
describe("acpx plugin config parsing", () => {
@@ -20,9 +19,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", () => {
@@ -37,6 +36,7 @@ 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,6 +50,7 @@ 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", () => {
@@ -63,6 +64,7 @@ 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", () => {
@@ -78,6 +80,7 @@ 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", () => {
@@ -134,97 +137,4 @@ 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",
- },
- ],
- },
- ]);
- });
});
diff --git a/extensions/acpx/src/config.ts b/extensions/acpx/src/config.ts
index 9c581c68a8f..ef0207a1365 100644
--- a/extensions/acpx/src/config.ts
+++ b/extensions/acpx/src/config.ts
@@ -47,6 +47,7 @@ export type ResolvedAcpxPluginConfig = {
command: string;
expectedVersion?: string;
allowPluginLocalInstall: boolean;
+ stripProviderAuthEnvVars: boolean;
installCommand: string;
cwd: string;
permissionMode: AcpxPermissionMode;
@@ -332,6 +333,7 @@ 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
@@ -343,6 +345,7 @@ export function resolveAcpxPluginConfig(params: {
command,
expectedVersion,
allowPluginLocalInstall,
+ stripProviderAuthEnvVars,
installCommand,
cwd,
permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE,
diff --git a/extensions/acpx/src/ensure.test.ts b/extensions/acpx/src/ensure.test.ts
index 3bc6f666031..cae52f29f9b 100644
--- a/extensions/acpx/src/ensure.test.ts
+++ b/extensions/acpx/src/ensure.test.ts
@@ -77,6 +77,7 @@ describe("acpx ensure", () => {
command: "/plugin/node_modules/.bin/acpx",
args: ["--version"],
cwd: "/plugin",
+ stripProviderAuthEnvVars: undefined,
});
});
@@ -148,6 +149,30 @@ 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,
});
});
@@ -186,6 +211,54 @@ 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({
diff --git a/extensions/acpx/src/ensure.ts b/extensions/acpx/src/ensure.ts
index 39307db1f4f..9b85d53f618 100644
--- a/extensions/acpx/src/ensure.ts
+++ b/extensions/acpx/src/ensure.ts
@@ -102,6 +102,7 @@ export async function checkAcpxVersion(params: {
command: string;
cwd?: string;
expectedVersion?: string;
+ stripProviderAuthEnvVars?: boolean;
spawnOptions?: SpawnCommandOptions;
}): Promise {
const expectedVersion = params.expectedVersion?.trim() || undefined;
@@ -113,6 +114,7 @@ export async function checkAcpxVersion(params: {
command: params.command,
args: probeArgs,
cwd,
+ stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
};
let result: Awaited>;
try {
@@ -198,6 +200,7 @@ export async function ensureAcpx(params: {
pluginRoot?: string;
expectedVersion?: string;
allowInstall?: boolean;
+ stripProviderAuthEnvVars?: boolean;
spawnOptions?: SpawnCommandOptions;
}): Promise {
if (pendingEnsure) {
@@ -214,6 +217,7 @@ export async function ensureAcpx(params: {
command: params.command,
cwd: pluginRoot,
expectedVersion,
+ stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
spawnOptions: params.spawnOptions,
});
if (precheck.ok) {
@@ -231,6 +235,7 @@ export async function ensureAcpx(params: {
command: "npm",
args: ["install", "--omit=dev", "--no-save", `acpx@${installVersion}`],
cwd: pluginRoot,
+ stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
});
if (install.error) {
@@ -252,6 +257,7 @@ export async function ensureAcpx(params: {
command: params.command,
cwd: pluginRoot,
expectedVersion,
+ stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
spawnOptions: params.spawnOptions,
});
diff --git a/extensions/acpx/src/runtime-internals/mcp-agent-command.test.ts b/extensions/acpx/src/runtime-internals/mcp-agent-command.test.ts
new file mode 100644
index 00000000000..5deed2e8f0f
--- /dev/null
+++ b/extensions/acpx/src/runtime-internals/mcp-agent-command.test.ts
@@ -0,0 +1,59 @@
+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("\\\\\\");
+ });
+});
diff --git a/extensions/acpx/src/runtime-internals/mcp-agent-command.ts b/extensions/acpx/src/runtime-internals/mcp-agent-command.ts
index f494bd3d32b..481c8156aca 100644
--- a/extensions/acpx/src/runtime-internals/mcp-agent-command.ts
+++ b/extensions/acpx/src/runtime-internals/mcp-agent-command.ts
@@ -37,6 +37,10 @@ function quoteCommandPart(value: string): string {
return `"${value.replace(/["\\]/g, "\\$&")}"`;
}
+export const __testing = {
+ quoteCommandPart,
+};
+
function toCommandLine(parts: string[]): string {
return parts.map(quoteCommandPart).join(" ");
}
@@ -62,6 +66,7 @@ function readConfiguredAgentOverrides(value: unknown): Record {
async function loadAgentOverrides(params: {
acpxCommand: string;
cwd: string;
+ stripProviderAuthEnvVars?: boolean;
spawnOptions?: SpawnCommandOptions;
}): Promise> {
const result = await spawnAndCollect(
@@ -69,6 +74,7 @@ async function loadAgentOverrides(params: {
command: params.acpxCommand,
args: ["--cwd", params.cwd, "config", "show"],
cwd: params.cwd,
+ stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
},
params.spawnOptions,
);
@@ -87,12 +93,14 @@ export async function resolveAcpxAgentCommand(params: {
acpxCommand: string;
cwd: string;
agent: string;
+ stripProviderAuthEnvVars?: boolean;
spawnOptions?: SpawnCommandOptions;
}): Promise {
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;
diff --git a/extensions/acpx/src/runtime-internals/process.test.ts b/extensions/acpx/src/runtime-internals/process.test.ts
index 0eee162eddf..ba6ad923d3b 100644
--- a/extensions/acpx/src/runtime-internals/process.test.ts
+++ b/extensions/acpx/src/runtime-internals/process.test.ts
@@ -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 } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { createWindowsCmdShimFixture } from "../../../shared/windows-cmd-shim-test-fixtures.js";
import {
resolveSpawnCommand,
@@ -28,6 +28,7 @@ async function createTempDir(): Promise {
}
afterEach(async () => {
+ vi.unstubAllEnvs();
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) {
@@ -289,4 +290,99 @@ 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");
+ });
});
diff --git a/extensions/acpx/src/runtime-internals/process.ts b/extensions/acpx/src/runtime-internals/process.ts
index 4df84aece2f..2724f467ab1 100644
--- a/extensions/acpx/src/runtime-internals/process.ts
+++ b/extensions/acpx/src/runtime-internals/process.ts
@@ -7,7 +7,9 @@ import type {
} from "openclaw/plugin-sdk/acpx";
import {
applyWindowsSpawnProgramPolicy,
+ listKnownProviderAuthEnvVarNames,
materializeWindowsSpawnProgram,
+ omitEnvKeysCaseInsensitive,
resolveWindowsSpawnProgramCandidate,
} from "openclaw/plugin-sdk/acpx";
@@ -125,6 +127,7 @@ export function spawnWithResolvedCommand(
command: string;
args: string[];
cwd: string;
+ stripProviderAuthEnvVars?: boolean;
},
options?: SpawnCommandOptions,
): ChildProcessWithoutNullStreams {
@@ -136,9 +139,15 @@ 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: { ...process.env, OPENCLAW_SHELL: "acp" },
+ env: childEnv,
stdio: ["pipe", "pipe", "pipe"],
shell: resolved.shell,
windowsHide: resolved.windowsHide,
@@ -180,6 +189,7 @@ export async function spawnAndCollect(
command: string;
args: string[];
cwd: string;
+ stripProviderAuthEnvVars?: boolean;
},
options?: SpawnCommandOptions,
runtime?: {
diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts
index 60ad7f49082..198a0367b59 100644
--- a/extensions/acpx/src/runtime.test.ts
+++ b/extensions/acpx/src/runtime.test.ts
@@ -1,6 +1,6 @@
import os from "node:os";
import path from "node:path";
-import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js";
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
import {
@@ -19,13 +19,14 @@ 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 },
);
@@ -165,7 +166,7 @@ describe("AcpxRuntime", () => {
for await (const _event of runtime.runTurn({
handle,
text: "describe this image",
- attachments: [{ mediaType: "image/png", data: "aW1hZ2UtYnl0ZXM=" }],
+ attachments: [{ mediaType: "image/png", data: "aW1hZ2UtYnl0ZXM=" }], // pragma: allowlist secret
mode: "prompt",
requestId: "req-image",
})) {
@@ -186,6 +187,40 @@ describe("AcpxRuntime", () => {
]);
});
+ 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();
@@ -395,7 +430,7 @@ describe("AcpxRuntime", () => {
command: "npx",
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
env: {
- CANVA_TOKEN: "secret",
+ CANVA_TOKEN: "secret", // pragma: allowlist secret
},
},
},
diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts
index b1d33a64f09..b0f166584d5 100644
--- a/extensions/acpx/src/runtime.ts
+++ b/extensions/acpx/src/runtime.ts
@@ -170,6 +170,7 @@ 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) {
@@ -183,6 +184,7 @@ export class AcpxRuntime implements AcpRuntime {
command: this.config.command,
args: ["--help"],
cwd: this.config.cwd,
+ stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
},
this.spawnCommandOptions,
);
@@ -309,6 +311,7 @@ export class AcpxRuntime implements AcpRuntime {
command: this.config.command,
args,
cwd: state.cwd,
+ stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
},
this.spawnCommandOptions,
);
@@ -495,6 +498,7 @@ 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) {
@@ -518,6 +522,7 @@ export class AcpxRuntime implements AcpRuntime {
command: this.config.command,
args: ["--help"],
cwd: this.config.cwd,
+ stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
},
this.spawnCommandOptions,
);
@@ -683,6 +688,7 @@ 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({
@@ -705,6 +711,7 @@ export class AcpxRuntime implements AcpRuntime {
command: this.config.command,
args: params.args,
cwd: params.cwd,
+ stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
},
this.spawnCommandOptions,
{
diff --git a/extensions/acpx/src/service.test.ts b/extensions/acpx/src/service.test.ts
index 402fd9ae67b..a4572bf2c90 100644
--- a/extensions/acpx/src/service.test.ts
+++ b/extensions/acpx/src/service.test.ts
@@ -89,6 +89,11 @@ describe("createAcpxRuntimeService", () => {
await vi.waitFor(() => {
expect(ensureAcpxSpy).toHaveBeenCalledOnce();
+ expect(ensureAcpxSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ stripProviderAuthEnvVars: true,
+ }),
+ );
expect(probeAvailabilitySpy).toHaveBeenCalledOnce();
});
diff --git a/extensions/acpx/src/service.ts b/extensions/acpx/src/service.ts
index ab57dc8b885..a863546fb30 100644
--- a/extensions/acpx/src/service.ts
+++ b/extensions/acpx/src/service.ts
@@ -59,9 +59,8 @@ export function createAcpxRuntimeService(
});
const expectedVersionLabel = pluginConfig.expectedVersion ?? "any";
const installLabel = pluginConfig.allowPluginLocalInstall ? "enabled" : "disabled";
- const mcpServerCount = Object.keys(pluginConfig.mcpServers).length;
ctx.logger.info(
- `acpx runtime backend registered (command: ${pluginConfig.command}, expectedVersion: ${expectedVersionLabel}, pluginLocalInstall: ${installLabel}${mcpServerCount > 0 ? `, mcpServers: ${mcpServerCount}` : ""})`,
+ `acpx runtime backend registered (command: ${pluginConfig.command}, expectedVersion: ${expectedVersionLabel}, pluginLocalInstall: ${installLabel})`,
);
lifecycleRevision += 1;
@@ -73,6 +72,7 @@ export function createAcpxRuntimeService(
logger: ctx.logger,
expectedVersion: pluginConfig.expectedVersion,
allowInstall: pluginConfig.allowPluginLocalInstall,
+ stripProviderAuthEnvVars: pluginConfig.stripProviderAuthEnvVars,
spawnOptions: {
strictWindowsCmdWrapper: pluginConfig.strictWindowsCmdWrapper,
},
diff --git a/extensions/acpx/src/test-utils/runtime-fixtures.ts b/extensions/acpx/src/test-utils/runtime-fixtures.ts
index c99417fbd21..c5cbef83877 100644
--- a/extensions/acpx/src/test-utils/runtime-fixtures.ts
+++ b/extensions/acpx/src/test-utils/runtime-fixtures.ts
@@ -204,6 +204,8 @@ if (command === "prompt") {
sessionName: sessionFromOption,
stdinText,
openclawShell,
+ openaiApiKey: process.env.OPENAI_API_KEY || "",
+ githubToken: process.env.GITHUB_TOKEN || "",
});
const requestId = "req-1";
@@ -326,6 +328,7 @@ export async function createMockRuntimeFixture(params?: {
const config: ResolvedAcpxPluginConfig = {
command: scriptPath,
allowPluginLocalInstall: false,
+ stripProviderAuthEnvVars: false,
installCommand: "n/a",
cwd: dir,
permissionMode: params?.permissionMode ?? "approve-all",
@@ -378,6 +381,7 @@ export async function readMockRuntimeLogEntries(
export async function cleanupMockRuntimeFixtures(): Promise {
delete process.env.MOCK_ACPX_LOG;
+ delete process.env.MOCK_ACPX_CONFIG_SHOW_AGENTS;
sharedMockCliScriptPath = null;
logFileSequence = 0;
while (tempDirs.length > 0) {
diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts
index d0f076f6e84..747fba5b67b 100644
--- a/extensions/bluebubbles/src/channel.ts
+++ b/extensions/bluebubbles/src/channel.ts
@@ -21,6 +21,7 @@ import {
import {
buildAccountScopedDmSecurityPolicy,
collectOpenGroupPolicyRestrictSendersWarnings,
+ createAccountStatusSink,
formatNormalizedAllowFromEntries,
mapAllowFromEntries,
} from "openclaw/plugin-sdk/compat";
@@ -369,8 +370,11 @@ export const bluebubblesPlugin: ChannelPlugin = {
startAccount: async (ctx) => {
const account = ctx.account;
const webhookPath = resolveWebhookPathFromConfig(account.config);
- ctx.setStatus({
- accountId: account.accountId,
+ const statusSink = createAccountStatusSink({
+ accountId: ctx.accountId,
+ setStatus: ctx.setStatus,
+ });
+ statusSink({
baseUrl: account.baseUrl,
});
ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`);
@@ -379,7 +383,7 @@ export const bluebubblesPlugin: ChannelPlugin = {
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
- statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
+ statusSink,
webhookPath,
});
},
diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts
index 32e239d3f45..76fe4523f16 100644
--- a/extensions/bluebubbles/src/config-schema.ts
+++ b/extensions/bluebubbles/src/config-schema.ts
@@ -1,7 +1,9 @@
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/bluebubbles";
import {
- AllowFromEntrySchema,
+ AllowFromListSchema,
buildCatchallMultiAccountChannelSchema,
+ DmPolicySchema,
+ GroupPolicySchema,
} from "openclaw/plugin-sdk/compat";
import { z } from "zod";
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
@@ -35,10 +37,10 @@ const bluebubblesAccountSchema = z
serverUrl: z.string().optional(),
password: buildSecretInputSchema().optional(),
webhookPath: z.string().optional(),
- dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
- allowFrom: z.array(AllowFromEntrySchema).optional(),
- groupAllowFrom: z.array(AllowFromEntrySchema).optional(),
- groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
+ dmPolicy: DmPolicySchema.optional(),
+ allowFrom: AllowFromListSchema,
+ groupAllowFrom: AllowFromListSchema,
+ groupPolicy: GroupPolicySchema.optional(),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
textChunkLimit: z.number().int().positive().optional(),
diff --git a/extensions/bluebubbles/src/onboarding.ts b/extensions/bluebubbles/src/onboarding.ts
index 86b9719ae24..eb66afdfe21 100644
--- a/extensions/bluebubbles/src/onboarding.ts
+++ b/extensions/bluebubbles/src/onboarding.ts
@@ -10,6 +10,7 @@ import {
formatDocsLink,
mergeAllowFromEntries,
normalizeAccountId,
+ patchScopedAccountConfig,
resolveAccountIdForConfigure,
setTopLevelChannelDmPolicyWithAllowFrom,
} from "openclaw/plugin-sdk/bluebubbles";
@@ -38,34 +39,14 @@ function setBlueBubblesAllowFrom(
accountId: string,
allowFrom: string[],
): OpenClawConfig {
- if (accountId === DEFAULT_ACCOUNT_ID) {
- return {
- ...cfg,
- channels: {
- ...cfg.channels,
- bluebubbles: {
- ...cfg.channels?.bluebubbles,
- allowFrom,
- },
- },
- };
- }
- return {
- ...cfg,
- channels: {
- ...cfg.channels,
- bluebubbles: {
- ...cfg.channels?.bluebubbles,
- accounts: {
- ...cfg.channels?.bluebubbles?.accounts,
- [accountId]: {
- ...cfg.channels?.bluebubbles?.accounts?.[accountId],
- allowFrom,
- },
- },
- },
- },
- };
+ return patchScopedAccountConfig({
+ cfg,
+ channelKey: channel,
+ accountId,
+ patch: { allowFrom },
+ ensureChannelEnabled: false,
+ ensureAccountEnabled: false,
+ });
}
function parseBlueBubblesAllowFromInput(raw: string): string[] {
diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json
index 2c1db3bcd27..61128b78032 100644
--- a/extensions/googlechat/package.json
+++ b/extensions/googlechat/package.json
@@ -7,6 +7,9 @@
"dependencies": {
"google-auth-library": "^10.6.1"
},
+ "devDependencies": {
+ "openclaw": "workspace:*"
+ },
"peerDependencies": {
"openclaw": ">=2026.3.7"
},
diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts
index 2be9ae3335b..47980f97d92 100644
--- a/extensions/googlechat/src/channel.ts
+++ b/extensions/googlechat/src/channel.ts
@@ -1,9 +1,9 @@
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
import {
- buildAccountScopedDmSecurityPolicy,
buildOpenGroupPolicyConfigureRouteAllowlistWarning,
collectAllowlistProviderGroupPolicyWarnings,
createScopedAccountConfigAccessors,
+ createScopedDmSecurityResolver,
formatNormalizedAllowFromEntries,
} from "openclaw/plugin-sdk/compat";
import {
@@ -12,6 +12,7 @@ import {
buildComputedAccountStatusSnapshot,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
+ createAccountStatusSink,
getChatChannelMeta,
listDirectoryGroupEntriesFromMapKeys,
listDirectoryUserEntriesFromAllowFrom,
@@ -21,6 +22,7 @@ import {
PAIRING_APPROVED_MESSAGE,
resolveChannelMediaMaxBytes,
resolveGoogleChatGroupRequireMention,
+ runPassiveAccountLifecycle,
type ChannelDock,
type ChannelMessageActionAdapter,
type ChannelPlugin,
@@ -84,6 +86,14 @@ const googleChatConfigBase = createScopedChannelConfigBase({
+ channelKey: "googlechat",
+ resolvePolicy: (account) => account.config.dm?.policy,
+ resolveAllowFrom: (account) => account.config.dm?.allowFrom,
+ allowFromPathSuffix: "dm.",
+ normalizeEntry: (raw) => formatAllowFromEntry(raw),
+});
+
export const googlechatDock: ChannelDock = {
id: "googlechat",
capabilities: {
@@ -170,18 +180,7 @@ export const googlechatPlugin: ChannelPlugin = {
...googleChatConfigAccessors,
},
security: {
- resolveDmPolicy: ({ cfg, accountId, account }) => {
- return buildAccountScopedDmSecurityPolicy({
- cfg,
- channelKey: "googlechat",
- accountId,
- fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
- policy: account.config.dm?.policy,
- allowFrom: account.config.dm?.allowFrom ?? [],
- allowFromPathSuffix: "dm.",
- normalizeEntry: (raw) => formatAllowFromEntry(raw),
- });
- },
+ resolveDmPolicy: resolveGoogleChatDmPolicy,
collectWarnings: ({ account, cfg }) => {
const warnings = collectAllowlistProviderGroupPolicyWarnings({
cfg,
@@ -512,37 +511,39 @@ export const googlechatPlugin: ChannelPlugin = {
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
- ctx.log?.info(`[${account.accountId}] starting Google Chat webhook`);
- ctx.setStatus({
+ const statusSink = createAccountStatusSink({
accountId: account.accountId,
+ setStatus: ctx.setStatus,
+ });
+ ctx.log?.info(`[${account.accountId}] starting Google Chat webhook`);
+ statusSink({
running: true,
lastStartAt: Date.now(),
webhookPath: resolveGoogleChatWebhookPath({ account }),
audienceType: account.config.audienceType,
audience: account.config.audience,
});
- const unregister = await startGoogleChatMonitor({
- account,
- config: ctx.cfg,
- runtime: ctx.runtime,
+ await runPassiveAccountLifecycle({
abortSignal: ctx.abortSignal,
- webhookPath: account.config.webhookPath,
- webhookUrl: account.config.webhookUrl,
- statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }),
- });
- // Keep the promise pending until abort (webhook mode is passive).
- await new Promise((resolve) => {
- if (ctx.abortSignal.aborted) {
- resolve();
- return;
- }
- ctx.abortSignal.addEventListener("abort", () => resolve(), { once: true });
- });
- unregister?.();
- ctx.setStatus({
- accountId: account.accountId,
- running: false,
- lastStopAt: Date.now(),
+ start: async () =>
+ await startGoogleChatMonitor({
+ account,
+ config: ctx.cfg,
+ runtime: ctx.runtime,
+ abortSignal: ctx.abortSignal,
+ webhookPath: account.config.webhookPath,
+ webhookUrl: account.config.webhookUrl,
+ statusSink,
+ }),
+ stop: async (unregister) => {
+ unregister?.();
+ },
+ onStop: async () => {
+ statusSink({
+ running: false,
+ lastStopAt: Date.now(),
+ });
+ },
});
},
},
diff --git a/extensions/googlechat/src/onboarding.ts b/extensions/googlechat/src/onboarding.ts
index 2fadfe7661a..f7708dd30b9 100644
--- a/extensions/googlechat/src/onboarding.ts
+++ b/extensions/googlechat/src/onboarding.ts
@@ -1,5 +1,7 @@
import type { OpenClawConfig, DmPolicy } from "openclaw/plugin-sdk/googlechat";
import {
+ DEFAULT_ACCOUNT_ID,
+ applySetupAccountConfigPatch,
addWildcardAllowFrom,
formatDocsLink,
mergeAllowFromEntries,
@@ -8,7 +10,6 @@ import {
type ChannelOnboardingAdapter,
type ChannelOnboardingDmPolicy,
type WizardPrompter,
- DEFAULT_ACCOUNT_ID,
migrateBaseNameToDefaultAccount,
} from "openclaw/plugin-sdk/googlechat";
import {
@@ -83,45 +84,6 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
promptAllowFrom,
};
-function applyAccountConfig(params: {
- cfg: OpenClawConfig;
- accountId: string;
- patch: Record;
-}): OpenClawConfig {
- const { cfg, accountId, patch } = params;
- if (accountId === DEFAULT_ACCOUNT_ID) {
- return {
- ...cfg,
- channels: {
- ...cfg.channels,
- googlechat: {
- ...cfg.channels?.["googlechat"],
- enabled: true,
- ...patch,
- },
- },
- };
- }
- return {
- ...cfg,
- channels: {
- ...cfg.channels,
- googlechat: {
- ...cfg.channels?.["googlechat"],
- enabled: true,
- accounts: {
- ...cfg.channels?.["googlechat"]?.accounts,
- [accountId]: {
- ...cfg.channels?.["googlechat"]?.accounts?.[accountId],
- enabled: true,
- ...patch,
- },
- },
- },
- },
- };
-}
-
async function promptCredentials(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
@@ -137,7 +99,7 @@ async function promptCredentials(params: {
initialValue: true,
});
if (useEnv) {
- return applyAccountConfig({ cfg, accountId, patch: {} });
+ return applySetupAccountConfigPatch({ cfg, channelKey: channel, accountId, patch: {} });
}
}
@@ -156,8 +118,9 @@ async function promptCredentials(params: {
placeholder: "/path/to/service-account.json",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
- return applyAccountConfig({
+ return applySetupAccountConfigPatch({
cfg,
+ channelKey: channel,
accountId,
patch: { serviceAccountFile: String(path).trim() },
});
@@ -168,8 +131,9 @@ async function promptCredentials(params: {
placeholder: '{"type":"service_account", ... }',
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
- return applyAccountConfig({
+ return applySetupAccountConfigPatch({
cfg,
+ channelKey: channel,
accountId,
patch: { serviceAccount: String(json).trim() },
});
@@ -200,8 +164,9 @@ async function promptAudience(params: {
initialValue: currentAudience || undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
- return applyAccountConfig({
+ return applySetupAccountConfigPatch({
cfg: params.cfg,
+ channelKey: channel,
accountId: params.accountId,
patch: { audienceType, audience: String(audience).trim() },
});
diff --git a/extensions/irc/src/accounts.test.ts b/extensions/irc/src/accounts.test.ts
index 59a72d7cbcb..afd1b597b81 100644
--- a/extensions/irc/src/accounts.test.ts
+++ b/extensions/irc/src/accounts.test.ts
@@ -1,5 +1,8 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
import { describe, expect, it } from "vitest";
-import { listIrcAccountIds, resolveDefaultIrcAccountId } from "./accounts.js";
+import { listIrcAccountIds, resolveDefaultIrcAccountId, resolveIrcAccount } from "./accounts.js";
import type { CoreConfig } from "./types.js";
function asConfig(value: unknown): CoreConfig {
@@ -76,3 +79,28 @@ describe("resolveDefaultIrcAccountId", () => {
expect(resolveDefaultIrcAccountId(cfg)).toBe("aaa");
});
});
+
+describe("resolveIrcAccount", () => {
+ it.runIf(process.platform !== "win32")("rejects symlinked password files", () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-irc-account-"));
+ const passwordFile = path.join(dir, "password.txt");
+ const passwordLink = path.join(dir, "password-link.txt");
+ fs.writeFileSync(passwordFile, "secret-pass\n", "utf8");
+ fs.symlinkSync(passwordFile, passwordLink);
+
+ const cfg = asConfig({
+ channels: {
+ irc: {
+ host: "irc.example.com",
+ nick: "claw",
+ passwordFile: passwordLink,
+ },
+ },
+ });
+
+ const account = resolveIrcAccount({ cfg });
+ expect(account.password).toBe("");
+ expect(account.passwordSource).toBe("none");
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+});
diff --git a/extensions/irc/src/accounts.ts b/extensions/irc/src/accounts.ts
index d61499c4d39..13d48fffdb7 100644
--- a/extensions/irc/src/accounts.ts
+++ b/extensions/irc/src/accounts.ts
@@ -1,5 +1,5 @@
-import { readFileSync } from "node:fs";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
+import { tryReadSecretFileSync } from "openclaw/plugin-sdk/core";
import {
createAccountListHelpers,
normalizeResolvedSecretInputString,
@@ -100,13 +100,11 @@ function resolvePassword(accountId: string, merged: IrcAccountConfig) {
}
if (merged.passwordFile?.trim()) {
- try {
- const filePassword = readFileSync(merged.passwordFile.trim(), "utf-8").trim();
- if (filePassword) {
- return { password: filePassword, source: "passwordFile" as const };
- }
- } catch {
- // Ignore unreadable files here; status will still surface missing configuration.
+ const filePassword = tryReadSecretFileSync(merged.passwordFile, "IRC password file", {
+ rejectSymlink: true,
+ });
+ if (filePassword) {
+ return { password: filePassword, source: "passwordFile" as const };
}
}
@@ -137,11 +135,10 @@ function resolveNickServConfig(accountId: string, nickserv?: IrcNickServConfig):
envPassword ||
"";
if (!resolvedPassword && passwordFile) {
- try {
- resolvedPassword = readFileSync(passwordFile, "utf-8").trim();
- } catch {
- // Ignore unreadable files; monitor/probe status will surface failures.
- }
+ resolvedPassword =
+ tryReadSecretFileSync(passwordFile, "IRC NickServ password file", {
+ rejectSymlink: true,
+ }) ?? "";
}
const merged: IrcNickServConfig = {
diff --git a/extensions/irc/src/channel.startup.test.ts b/extensions/irc/src/channel.startup.test.ts
new file mode 100644
index 00000000000..ef972f64c0e
--- /dev/null
+++ b/extensions/irc/src/channel.startup.test.ts
@@ -0,0 +1,67 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createStartAccountContext } from "../../test-utils/start-account-context.js";
+import type { ResolvedIrcAccount } from "./accounts.js";
+
+const hoisted = vi.hoisted(() => ({
+ monitorIrcProvider: vi.fn(),
+}));
+
+vi.mock("./monitor.js", async () => {
+ const actual = await vi.importActual("./monitor.js");
+ return {
+ ...actual,
+ monitorIrcProvider: hoisted.monitorIrcProvider,
+ };
+});
+
+import { ircPlugin } from "./channel.js";
+
+describe("ircPlugin gateway.startAccount", () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("keeps startAccount pending until abort, then stops the monitor", async () => {
+ const stop = vi.fn();
+ hoisted.monitorIrcProvider.mockResolvedValue({ stop });
+
+ const account: ResolvedIrcAccount = {
+ accountId: "default",
+ enabled: true,
+ name: "default",
+ configured: true,
+ host: "irc.example.com",
+ port: 6697,
+ tls: true,
+ nick: "openclaw",
+ username: "openclaw",
+ realname: "OpenClaw",
+ password: "",
+ passwordSource: "none",
+ config: {} as ResolvedIrcAccount["config"],
+ };
+
+ const abort = new AbortController();
+ const task = ircPlugin.gateway!.startAccount!(
+ createStartAccountContext({
+ account,
+ abortSignal: abort.signal,
+ }),
+ );
+ let settled = false;
+ void task.then(() => {
+ settled = true;
+ });
+
+ await vi.waitFor(() => {
+ expect(hoisted.monitorIrcProvider).toHaveBeenCalledOnce();
+ });
+ expect(settled).toBe(false);
+ expect(stop).not.toHaveBeenCalled();
+
+ abort.abort();
+ await task;
+
+ expect(stop).toHaveBeenCalledOnce();
+ });
+});
diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts
index 03d86da4c54..c598a9a0ef3 100644
--- a/extensions/irc/src/channel.ts
+++ b/extensions/irc/src/channel.ts
@@ -9,10 +9,12 @@ import {
buildBaseAccountStatusSnapshot,
buildBaseChannelStatusSummary,
buildChannelConfigSchema,
+ createAccountStatusSink,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
getChatChannelMeta,
PAIRING_APPROVED_MESSAGE,
+ runPassiveAccountLifecycle,
setAccountEnabledInConfigSection,
type ChannelPlugin,
} from "openclaw/plugin-sdk/irc";
@@ -353,6 +355,10 @@ export const ircPlugin: ChannelPlugin = {
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
+ const statusSink = createAccountStatusSink({
+ accountId: ctx.accountId,
+ setStatus: ctx.setStatus,
+ });
if (!account.configured) {
throw new Error(
`IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`,
@@ -361,14 +367,20 @@ export const ircPlugin: ChannelPlugin = {
ctx.log?.info(
`[${account.accountId}] starting IRC provider (${account.host}:${account.port}${account.tls ? " tls" : ""})`,
);
- const { stop } = await monitorIrcProvider({
- accountId: account.accountId,
- config: ctx.cfg as CoreConfig,
- runtime: ctx.runtime,
+ await runPassiveAccountLifecycle({
abortSignal: ctx.abortSignal,
- statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
+ start: async () =>
+ await monitorIrcProvider({
+ accountId: account.accountId,
+ config: ctx.cfg as CoreConfig,
+ runtime: ctx.runtime,
+ abortSignal: ctx.abortSignal,
+ statusSink,
+ }),
+ stop: async (monitor) => {
+ monitor.stop();
+ },
});
- return { stop };
},
},
};
diff --git a/extensions/irc/src/onboarding.ts b/extensions/irc/src/onboarding.ts
index d7d7b7f79a9..5e7c80c94d7 100644
--- a/extensions/irc/src/onboarding.ts
+++ b/extensions/irc/src/onboarding.ts
@@ -1,6 +1,7 @@
import {
DEFAULT_ACCOUNT_ID,
formatDocsLink,
+ patchScopedAccountConfig,
promptChannelAccessConfig,
resolveAccountIdForConfigure,
setTopLevelChannelAllowFrom,
@@ -59,35 +60,14 @@ function updateIrcAccountConfig(
accountId: string,
patch: Partial,
): CoreConfig {
- const current = cfg.channels?.irc ?? {};
- if (accountId === DEFAULT_ACCOUNT_ID) {
- return {
- ...cfg,
- channels: {
- ...cfg.channels,
- irc: {
- ...current,
- ...patch,
- },
- },
- };
- }
- return {
- ...cfg,
- channels: {
- ...cfg.channels,
- irc: {
- ...current,
- accounts: {
- ...current.accounts,
- [accountId]: {
- ...current.accounts?.[accountId],
- ...patch,
- },
- },
- },
- },
- };
+ return patchScopedAccountConfig({
+ cfg,
+ channelKey: channel,
+ accountId,
+ patch,
+ ensureChannelEnabled: false,
+ ensureAccountEnabled: false,
+ }) as CoreConfig;
}
function setIrcDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig {
diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts
index 9388579ab38..ddc612b8fa7 100644
--- a/extensions/line/src/channel.ts
+++ b/extensions/line/src/channel.ts
@@ -1,7 +1,8 @@
import {
- buildAccountScopedDmSecurityPolicy,
- createScopedAccountConfigAccessors,
collectAllowlistProviderRestrictSendersWarnings,
+ createScopedAccountConfigAccessors,
+ createScopedChannelConfigBase,
+ createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/compat";
import {
buildChannelConfigSchema,
@@ -43,6 +44,24 @@ const lineConfigAccessors = createScopedAccountConfigAccessors({
.map((entry) => entry.replace(/^line:(?:user:)?/i, "")),
});
+const lineConfigBase = createScopedChannelConfigBase({
+ sectionKey: "line",
+ listAccountIds: (cfg) => getLineRuntime().channel.line.listLineAccountIds(cfg),
+ resolveAccount: (cfg, accountId) =>
+ getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId: accountId ?? undefined }),
+ defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg),
+ clearBaseFields: ["channelSecret", "tokenFile", "secretFile"],
+});
+
+const resolveLineDmPolicy = createScopedDmSecurityResolver({
+ channelKey: "line",
+ resolvePolicy: (account) => account.config.dmPolicy,
+ resolveAllowFrom: (account) => account.config.allowFrom,
+ policyPathSuffix: "dmPolicy",
+ approveHint: "openclaw pairing approve line ",
+ normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""),
+});
+
function patchLineAccountConfig(
cfg: OpenClawConfig,
lineConfig: LineConfig,
@@ -113,40 +132,7 @@ export const linePlugin: ChannelPlugin = {
reload: { configPrefixes: ["channels.line"] },
configSchema: buildChannelConfigSchema(LineConfigSchema),
config: {
- listAccountIds: (cfg) => getLineRuntime().channel.line.listLineAccountIds(cfg),
- resolveAccount: (cfg, accountId) =>
- getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId: accountId ?? undefined }),
- defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg),
- setAccountEnabled: ({ cfg, accountId, enabled }) => {
- const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
- return patchLineAccountConfig(cfg, lineConfig, accountId, { enabled });
- },
- deleteAccount: ({ cfg, accountId }) => {
- const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
- if (accountId === DEFAULT_ACCOUNT_ID) {
- // oxlint-disable-next-line no-unused-vars
- const { channelSecret, tokenFile, secretFile, ...rest } = lineConfig;
- return {
- ...cfg,
- channels: {
- ...cfg.channels,
- line: rest,
- },
- };
- }
- const accounts = { ...lineConfig.accounts };
- delete accounts[accountId];
- return {
- ...cfg,
- channels: {
- ...cfg.channels,
- line: {
- ...lineConfig,
- accounts: Object.keys(accounts).length > 0 ? accounts : undefined,
- },
- },
- };
- },
+ ...lineConfigBase,
isConfigured: (account) =>
Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
describeAccount: (account) => ({
@@ -159,19 +145,7 @@ export const linePlugin: ChannelPlugin = {
...lineConfigAccessors,
},
security: {
- resolveDmPolicy: ({ cfg, accountId, account }) => {
- return buildAccountScopedDmSecurityPolicy({
- cfg,
- channelKey: "line",
- accountId,
- fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
- policy: account.config.dmPolicy,
- allowFrom: account.config.allowFrom ?? [],
- policyPathSuffix: "dmPolicy",
- approveHint: "openclaw pairing approve line ",
- normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""),
- });
- },
+ resolveDmPolicy: resolveLineDmPolicy,
collectWarnings: ({ account, cfg }) => {
return collectAllowlistProviderRestrictSendersWarnings({
cfg,
diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts
index c33c85ebe05..a024b3f3e8a 100644
--- a/extensions/matrix/src/channel.ts
+++ b/extensions/matrix/src/channel.ts
@@ -1,8 +1,9 @@
import {
- buildAccountScopedDmSecurityPolicy,
buildOpenGroupPolicyWarning,
collectAllowlistProviderGroupPolicyWarnings,
createScopedAccountConfigAccessors,
+ createScopedChannelConfigBase,
+ createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/compat";
import {
applyAccountNameToChannelSection,
@@ -10,10 +11,8 @@ import {
buildProbeChannelStatusSummary,
collectStatusIssuesFromLastError,
DEFAULT_ACCOUNT_ID,
- deleteAccountFromConfigSection,
normalizeAccountId,
PAIRING_APPROVED_MESSAGE,
- setAccountEnabledInConfigSection,
type ChannelPlugin,
} from "openclaw/plugin-sdk/matrix";
import { matrixMessageActions } from "./actions.js";
@@ -106,6 +105,30 @@ const matrixConfigAccessors = createScopedAccountConfigAccessors({
formatAllowFrom: (allowFrom) => normalizeMatrixAllowList(allowFrom),
});
+const matrixConfigBase = createScopedChannelConfigBase({
+ sectionKey: "matrix",
+ listAccountIds: listMatrixAccountIds,
+ resolveAccount: (cfg, accountId) => resolveMatrixAccount({ cfg, accountId }),
+ defaultAccountId: resolveDefaultMatrixAccountId,
+ clearBaseFields: [
+ "name",
+ "homeserver",
+ "userId",
+ "accessToken",
+ "password",
+ "deviceName",
+ "initialSyncLimit",
+ ],
+});
+
+const resolveMatrixDmPolicy = createScopedDmSecurityResolver({
+ channelKey: "matrix",
+ resolvePolicy: (account) => account.config.dm?.policy,
+ resolveAllowFrom: (account) => account.config.dm?.allowFrom,
+ allowFromPathSuffix: "dm.",
+ normalizeEntry: (raw) => normalizeMatrixUserId(raw),
+});
+
export const matrixPlugin: ChannelPlugin = {
id: "matrix",
meta,
@@ -127,32 +150,7 @@ export const matrixPlugin: ChannelPlugin = {
reload: { configPrefixes: ["channels.matrix"] },
configSchema: buildChannelConfigSchema(MatrixConfigSchema),
config: {
- listAccountIds: (cfg) => listMatrixAccountIds(cfg as CoreConfig),
- resolveAccount: (cfg, accountId) => resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }),
- defaultAccountId: (cfg) => resolveDefaultMatrixAccountId(cfg as CoreConfig),
- setAccountEnabled: ({ cfg, accountId, enabled }) =>
- setAccountEnabledInConfigSection({
- cfg: cfg as CoreConfig,
- sectionKey: "matrix",
- accountId,
- enabled,
- allowTopLevel: true,
- }),
- deleteAccount: ({ cfg, accountId }) =>
- deleteAccountFromConfigSection({
- cfg: cfg as CoreConfig,
- sectionKey: "matrix",
- accountId,
- clearBaseFields: [
- "name",
- "homeserver",
- "userId",
- "accessToken",
- "password",
- "deviceName",
- "initialSyncLimit",
- ],
- }),
+ ...matrixConfigBase,
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
@@ -164,18 +162,7 @@ export const matrixPlugin: ChannelPlugin = {
...matrixConfigAccessors,
},
security: {
- resolveDmPolicy: ({ cfg, accountId, account }) => {
- return buildAccountScopedDmSecurityPolicy({
- cfg: cfg as CoreConfig,
- channelKey: "matrix",
- accountId,
- fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
- policy: account.config.dm?.policy,
- allowFrom: account.config.dm?.allowFrom ?? [],
- allowFromPathSuffix: "dm.",
- normalizeEntry: (raw) => normalizeMatrixUserId(raw),
- });
- },
+ resolveDmPolicy: resolveMatrixDmPolicy,
collectWarnings: ({ account, cfg }) => {
return collectAllowlistProviderGroupPolicyWarnings({
cfg: cfg as CoreConfig,
diff --git a/extensions/matrix/src/config-schema.ts b/extensions/matrix/src/config-schema.ts
index cd1c89fbdb6..a95d2fbda96 100644
--- a/extensions/matrix/src/config-schema.ts
+++ b/extensions/matrix/src/config-schema.ts
@@ -1,9 +1,13 @@
+import {
+ AllowFromListSchema,
+ buildNestedDmConfigSchema,
+ DmPolicySchema,
+ GroupPolicySchema,
+} from "openclaw/plugin-sdk/compat";
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/matrix";
import { z } from "zod";
import { buildSecretInputSchema } from "./secret-input.js";
-const allowFromEntry = z.union([z.string(), z.number()]);
-
const matrixActionSchema = z
.object({
reactions: z.boolean().optional(),
@@ -14,14 +18,6 @@ const matrixActionSchema = z
})
.optional();
-const matrixDmSchema = z
- .object({
- enabled: z.boolean().optional(),
- policy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
- allowFrom: z.array(allowFromEntry).optional(),
- })
- .optional();
-
const matrixRoomSchema = z
.object({
enabled: z.boolean().optional(),
@@ -29,7 +25,7 @@ const matrixRoomSchema = z
requireMention: z.boolean().optional(),
tools: ToolPolicySchema,
autoReply: z.boolean().optional(),
- users: z.array(allowFromEntry).optional(),
+ users: AllowFromListSchema,
skills: z.array(z.string()).optional(),
systemPrompt: z.string().optional(),
})
@@ -49,7 +45,7 @@ export const MatrixConfigSchema = z.object({
initialSyncLimit: z.number().optional(),
encryption: z.boolean().optional(),
allowlistOnly: z.boolean().optional(),
- groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
+ groupPolicy: GroupPolicySchema.optional(),
replyToMode: z.enum(["off", "first", "all"]).optional(),
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
textChunkLimit: z.number().optional(),
@@ -57,9 +53,9 @@ export const MatrixConfigSchema = z.object({
responsePrefix: z.string().optional(),
mediaMaxMb: z.number().optional(),
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
- autoJoinAllowlist: z.array(allowFromEntry).optional(),
- groupAllowFrom: z.array(allowFromEntry).optional(),
- dm: matrixDmSchema,
+ autoJoinAllowlist: AllowFromListSchema,
+ groupAllowFrom: AllowFromListSchema,
+ dm: buildNestedDmConfigSchema(),
groups: z.object({}).catchall(matrixRoomSchema).optional(),
rooms: z.object({}).catchall(matrixRoomSchema).optional(),
actions: matrixActionSchema,
diff --git a/extensions/matrix/src/matrix/monitor/allowlist.ts b/extensions/matrix/src/matrix/monitor/allowlist.ts
index e9402c38362..326360cade5 100644
--- a/extensions/matrix/src/matrix/monitor/allowlist.ts
+++ b/extensions/matrix/src/matrix/monitor/allowlist.ts
@@ -1,6 +1,7 @@
import {
+ compileAllowlist,
normalizeStringEntries,
- resolveAllowlistMatchByCandidates,
+ resolveAllowlistCandidates,
type AllowlistMatch,
} from "openclaw/plugin-sdk/matrix";
@@ -75,11 +76,11 @@ export function resolveMatrixAllowListMatch(params: {
allowList: string[];
userId?: string;
}): MatrixAllowListMatch {
- const allowList = params.allowList;
- if (allowList.length === 0) {
+ const compiledAllowList = compileAllowlist(params.allowList);
+ if (compiledAllowList.set.size === 0) {
return { allowed: false };
}
- if (allowList.includes("*")) {
+ if (compiledAllowList.wildcard) {
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
}
const userId = normalizeMatrixUser(params.userId);
@@ -88,7 +89,10 @@ export function resolveMatrixAllowListMatch(params: {
{ value: userId ? `matrix:${userId}` : "", source: "prefixed-id" },
{ value: userId ? `user:${userId}` : "", source: "prefixed-user" },
];
- return resolveAllowlistMatchByCandidates({ allowList, candidates });
+ return resolveAllowlistCandidates({
+ compiledAllowlist: compiledAllowList,
+ candidates,
+ });
}
export function resolveMatrixAllowListMatches(params: { allowList: string[]; userId?: string }) {
diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts
index b62231ac997..2dffaa6f3cf 100644
--- a/extensions/mattermost/src/channel.ts
+++ b/extensions/mattermost/src/channel.ts
@@ -9,6 +9,7 @@ import {
applySetupAccountConfigPatch,
buildComputedAccountStatusSnapshot,
buildChannelConfigSchema,
+ createAccountStatusSink,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
migrateBaseNameToDefaultAccount,
@@ -500,8 +501,11 @@ export const mattermostPlugin: ChannelPlugin = {
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
- ctx.setStatus({
- accountId: account.accountId,
+ const statusSink = createAccountStatusSink({
+ accountId: ctx.accountId,
+ setStatus: ctx.setStatus,
+ });
+ statusSink({
baseUrl: account.baseUrl,
botTokenSource: account.botTokenSource,
});
@@ -513,7 +517,7 @@ export const mattermostPlugin: ChannelPlugin = {
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
- statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
+ statusSink,
});
},
},
diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json
index 664d0a469f4..0af3fc45281 100644
--- a/extensions/memory-core/package.json
+++ b/extensions/memory-core/package.json
@@ -4,6 +4,9 @@
"private": true,
"description": "OpenClaw core memory search plugin",
"type": "module",
+ "devDependencies": {
+ "openclaw": "workspace:*"
+ },
"peerDependencies": {
"openclaw": ">=2026.3.7"
},
diff --git a/extensions/nextcloud-talk/src/accounts.test.ts b/extensions/nextcloud-talk/src/accounts.test.ts
new file mode 100644
index 00000000000..dbc43690a3b
--- /dev/null
+++ b/extensions/nextcloud-talk/src/accounts.test.ts
@@ -0,0 +1,30 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+import { resolveNextcloudTalkAccount } from "./accounts.js";
+import type { CoreConfig } from "./types.js";
+
+describe("resolveNextcloudTalkAccount", () => {
+ it.runIf(process.platform !== "win32")("rejects symlinked botSecretFile paths", () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-nextcloud-talk-"));
+ const secretFile = path.join(dir, "secret.txt");
+ const secretLink = path.join(dir, "secret-link.txt");
+ fs.writeFileSync(secretFile, "bot-secret\n", "utf8");
+ fs.symlinkSync(secretFile, secretLink);
+
+ const cfg = {
+ channels: {
+ "nextcloud-talk": {
+ baseUrl: "https://cloud.example.com",
+ botSecretFile: secretLink,
+ },
+ },
+ } as CoreConfig;
+
+ const account = resolveNextcloudTalkAccount({ cfg });
+ expect(account.secret).toBe("");
+ expect(account.secretSource).toBe("none");
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+});
diff --git a/extensions/nextcloud-talk/src/accounts.ts b/extensions/nextcloud-talk/src/accounts.ts
index 74bb45cfd8b..2cfba6fea44 100644
--- a/extensions/nextcloud-talk/src/accounts.ts
+++ b/extensions/nextcloud-talk/src/accounts.ts
@@ -1,4 +1,4 @@
-import { readFileSync } from "node:fs";
+import { tryReadSecretFileSync } from "openclaw/plugin-sdk/core";
import {
createAccountListHelpers,
DEFAULT_ACCOUNT_ID,
@@ -88,13 +88,13 @@ function resolveNextcloudTalkSecret(
}
if (merged.botSecretFile) {
- try {
- const fileSecret = readFileSync(merged.botSecretFile, "utf-8").trim();
- if (fileSecret) {
- return { secret: fileSecret, source: "secretFile" };
- }
- } catch {
- // File not found or unreadable, fall through.
+ const fileSecret = tryReadSecretFileSync(
+ merged.botSecretFile,
+ "Nextcloud Talk bot secret file",
+ { rejectSymlink: true },
+ );
+ if (fileSecret) {
+ return { secret: fileSecret, source: "secretFile" };
}
}
diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts
index 6fdf36e9f8c..8a908b7e0ac 100644
--- a/extensions/nextcloud-talk/src/channel.ts
+++ b/extensions/nextcloud-talk/src/channel.ts
@@ -2,8 +2,10 @@ import {
buildAccountScopedDmSecurityPolicy,
collectAllowlistProviderGroupPolicyWarnings,
collectOpenGroupPolicyRouteAllowlistWarnings,
+ createAccountStatusSink,
formatAllowFromLowercase,
mapAllowFromEntries,
+ runPassiveAccountLifecycle,
} from "openclaw/plugin-sdk/compat";
import {
applyAccountNameToChannelSection,
@@ -15,7 +17,6 @@ import {
deleteAccountFromConfigSection,
normalizeAccountId,
setAccountEnabledInConfigSection,
- waitForAbortSignal,
type ChannelPlugin,
type OpenClawConfig,
type ChannelSetupInput,
@@ -338,17 +339,25 @@ export const nextcloudTalkPlugin: ChannelPlugin =
ctx.log?.info(`[${account.accountId}] starting Nextcloud Talk webhook server`);
- const { stop } = await monitorNextcloudTalkProvider({
- accountId: account.accountId,
- config: ctx.cfg as CoreConfig,
- runtime: ctx.runtime,
- abortSignal: ctx.abortSignal,
- statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
+ const statusSink = createAccountStatusSink({
+ accountId: ctx.accountId,
+ setStatus: ctx.setStatus,
});
- // Keep webhook channels pending for the account lifecycle.
- await waitForAbortSignal(ctx.abortSignal);
- stop();
+ await runPassiveAccountLifecycle({
+ abortSignal: ctx.abortSignal,
+ start: async () =>
+ await monitorNextcloudTalkProvider({
+ accountId: account.accountId,
+ config: ctx.cfg as CoreConfig,
+ runtime: ctx.runtime,
+ abortSignal: ctx.abortSignal,
+ statusSink,
+ }),
+ stop: async (monitor) => {
+ monitor.stop();
+ },
+ });
},
logoutAccount: async ({ accountId, cfg }) => {
const nextCfg = { ...cfg } as OpenClawConfig;
diff --git a/extensions/nextcloud-talk/src/onboarding.ts b/extensions/nextcloud-talk/src/onboarding.ts
index 3ccf2851c3b..7b1a8b11d28 100644
--- a/extensions/nextcloud-talk/src/onboarding.ts
+++ b/extensions/nextcloud-talk/src/onboarding.ts
@@ -1,15 +1,14 @@
import {
- buildSingleChannelSecretPromptState,
formatDocsLink,
hasConfiguredSecretInput,
mapAllowFromEntries,
mergeAllowFromEntries,
- promptSingleChannelSecretInput,
+ patchScopedAccountConfig,
+ runSingleChannelSecretStep,
resolveAccountIdForConfigure,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
setTopLevelChannelDmPolicyWithAllowFrom,
- type SecretInput,
type ChannelOnboardingAdapter,
type ChannelOnboardingDmPolicy,
type OpenClawConfig,
@@ -39,38 +38,12 @@ function setNextcloudTalkAccountConfig(
accountId: string,
updates: Record,
): CoreConfig {
- if (accountId === DEFAULT_ACCOUNT_ID) {
- return {
- ...cfg,
- channels: {
- ...cfg.channels,
- "nextcloud-talk": {
- ...cfg.channels?.["nextcloud-talk"],
- enabled: true,
- ...updates,
- },
- },
- };
- }
-
- return {
- ...cfg,
- channels: {
- ...cfg.channels,
- "nextcloud-talk": {
- ...cfg.channels?.["nextcloud-talk"],
- enabled: true,
- accounts: {
- ...cfg.channels?.["nextcloud-talk"]?.accounts,
- [accountId]: {
- ...cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId],
- enabled: cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true,
- ...updates,
- },
- },
- },
- },
- };
+ return patchScopedAccountConfig({
+ cfg,
+ channelKey: channel,
+ accountId,
+ patch: updates,
+ }) as CoreConfig;
}
async function noteNextcloudTalkSecretHelp(prompter: WizardPrompter): Promise {
@@ -215,12 +188,6 @@ export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
hasConfiguredSecretInput(resolvedAccount.config.botSecret) ||
resolvedAccount.config.botSecretFile,
);
- const secretPromptState = buildSingleChannelSecretPromptState({
- accountConfigured,
- hasConfigToken: hasConfigSecret,
- allowEnv,
- envValue: process.env.NEXTCLOUD_TALK_BOT_SECRET,
- });
let baseUrl = resolvedAccount.baseUrl;
if (!baseUrl) {
@@ -241,32 +208,35 @@ export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
).trim();
}
- let secret: SecretInput | null = null;
- if (!accountConfigured) {
- await noteNextcloudTalkSecretHelp(prompter);
- }
-
- const secretResult = await promptSingleChannelSecretInput({
+ const secretStep = await runSingleChannelSecretStep({
cfg: next,
prompter,
providerHint: "nextcloud-talk",
credentialLabel: "bot secret",
- accountConfigured: secretPromptState.accountConfigured,
- canUseEnv: secretPromptState.canUseEnv,
- hasConfigToken: secretPromptState.hasConfigToken,
+ accountConfigured,
+ hasConfigToken: hasConfigSecret,
+ allowEnv,
+ envValue: process.env.NEXTCLOUD_TALK_BOT_SECRET,
envPrompt: "NEXTCLOUD_TALK_BOT_SECRET detected. Use env var?",
keepPrompt: "Nextcloud Talk bot secret already configured. Keep it?",
inputPrompt: "Enter Nextcloud Talk bot secret",
preferredEnvVar: "NEXTCLOUD_TALK_BOT_SECRET",
+ onMissingConfigured: async () => await noteNextcloudTalkSecretHelp(prompter),
+ applyUseEnv: async (cfg) =>
+ setNextcloudTalkAccountConfig(cfg as CoreConfig, accountId, {
+ baseUrl,
+ }),
+ applySet: async (cfg, value) =>
+ setNextcloudTalkAccountConfig(cfg as CoreConfig, accountId, {
+ baseUrl,
+ botSecret: value,
+ }),
});
- if (secretResult.action === "set") {
- secret = secretResult.value;
- }
+ next = secretStep.cfg as CoreConfig;
- if (secretResult.action === "use-env" || secret || baseUrl !== resolvedAccount.baseUrl) {
+ if (secretStep.action === "keep" && baseUrl !== resolvedAccount.baseUrl) {
next = setNextcloudTalkAccountConfig(next, accountId, {
baseUrl,
- ...(secret ? { botSecret: secret } : {}),
});
}
@@ -287,26 +257,28 @@ export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
}),
).trim();
- const apiPasswordResult = await promptSingleChannelSecretInput({
+ const apiPasswordStep = await runSingleChannelSecretStep({
cfg: next,
prompter,
providerHint: "nextcloud-talk-api",
credentialLabel: "API password",
- ...buildSingleChannelSecretPromptState({
- accountConfigured: Boolean(existingApiUser && existingApiPasswordConfigured),
- hasConfigToken: existingApiPasswordConfigured,
- allowEnv: false,
- }),
+ accountConfigured: Boolean(existingApiUser && existingApiPasswordConfigured),
+ hasConfigToken: existingApiPasswordConfigured,
+ allowEnv: false,
envPrompt: "",
keepPrompt: "Nextcloud Talk API password already configured. Keep it?",
inputPrompt: "Enter Nextcloud Talk API password",
preferredEnvVar: "NEXTCLOUD_TALK_API_PASSWORD",
+ applySet: async (cfg, value) =>
+ setNextcloudTalkAccountConfig(cfg as CoreConfig, accountId, {
+ apiUser,
+ apiPassword: value,
+ }),
});
- const apiPassword = apiPasswordResult.action === "set" ? apiPasswordResult.value : undefined;
- next = setNextcloudTalkAccountConfig(next, accountId, {
- apiUser,
- ...(apiPassword ? { apiPassword } : {}),
- });
+ next =
+ apiPasswordStep.action === "keep"
+ ? setNextcloudTalkAccountConfig(next, accountId, { apiUser })
+ : (apiPasswordStep.cfg as CoreConfig);
}
if (forceAllowFrom) {
diff --git a/extensions/nostr/src/config-schema.ts b/extensions/nostr/src/config-schema.ts
index a25868da356..25d928b4837 100644
--- a/extensions/nostr/src/config-schema.ts
+++ b/extensions/nostr/src/config-schema.ts
@@ -1,8 +1,7 @@
+import { AllowFromListSchema, DmPolicySchema } from "openclaw/plugin-sdk/compat";
import { MarkdownConfigSchema, buildChannelConfigSchema } from "openclaw/plugin-sdk/nostr";
import { z } from "zod";
-const allowFromEntry = z.union([z.string(), z.number()]);
-
/**
* Validates https:// URLs only (no javascript:, data:, file:, etc.)
*/
@@ -76,10 +75,10 @@ export const NostrConfigSchema = z.object({
relays: z.array(z.string()).optional(),
/** DM access policy: pairing, allowlist, open, or disabled */
- dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
+ dmPolicy: DmPolicySchema.optional(),
/** Allowed sender pubkeys (npub or hex format) */
- allowFrom: z.array(allowFromEntry).optional(),
+ allowFrom: AllowFromListSchema,
/** Profile metadata (NIP-01 kind:0 content) */
profile: NostrProfileSchema.optional(),
diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts
index 2bf1b681497..f0736069015 100644
--- a/extensions/telegram/src/channel.test.ts
+++ b/extensions/telegram/src/channel.test.ts
@@ -313,6 +313,68 @@ describe("telegramPlugin duplicate token guard", () => {
expect(result).toMatchObject({ channel: "telegram", messageId: "tg-2" });
});
+ it("sends outbound payload media lists and keeps buttons on the first message only", async () => {
+ const sendMessageTelegram = vi
+ .fn()
+ .mockResolvedValueOnce({ messageId: "tg-3", chatId: "12345" })
+ .mockResolvedValueOnce({ messageId: "tg-4", chatId: "12345" });
+ setTelegramRuntime({
+ channel: {
+ telegram: {
+ sendMessageTelegram,
+ },
+ },
+ } as unknown as PluginRuntime);
+
+ const result = await telegramPlugin.outbound!.sendPayload!({
+ cfg: createCfg(),
+ to: "12345",
+ text: "",
+ payload: {
+ text: "Approval required",
+ mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
+ channelData: {
+ telegram: {
+ quoteText: "quoted",
+ buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]],
+ },
+ },
+ },
+ mediaLocalRoots: ["/tmp/media"],
+ accountId: "ops",
+ silent: true,
+ });
+
+ expect(sendMessageTelegram).toHaveBeenCalledTimes(2);
+ expect(sendMessageTelegram).toHaveBeenNthCalledWith(
+ 1,
+ "12345",
+ "Approval required",
+ expect.objectContaining({
+ mediaUrl: "https://example.com/1.jpg",
+ mediaLocalRoots: ["/tmp/media"],
+ quoteText: "quoted",
+ silent: true,
+ buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]],
+ }),
+ );
+ expect(sendMessageTelegram).toHaveBeenNthCalledWith(
+ 2,
+ "12345",
+ "",
+ expect.objectContaining({
+ mediaUrl: "https://example.com/2.jpg",
+ mediaLocalRoots: ["/tmp/media"],
+ quoteText: "quoted",
+ silent: true,
+ }),
+ );
+ expect(
+ (sendMessageTelegram.mock.calls[1]?.[2] as Record)?.buttons,
+ ).toBeUndefined();
+ expect(result).toMatchObject({ channel: "telegram", messageId: "tg-4" });
+ });
+
it("ignores accounts with missing tokens during duplicate-token checks", async () => {
const cfg = createCfg();
cfg.channels!.telegram!.accounts!.ops = {} as never;
diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts
index 5893f4e0a2e..52ae2b15ea8 100644
--- a/extensions/telegram/src/channel.ts
+++ b/extensions/telegram/src/channel.ts
@@ -1,9 +1,9 @@
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
import {
collectAllowlistProviderGroupPolicyWarnings,
- buildAccountScopedDmSecurityPolicy,
collectOpenGroupPolicyRouteAllowlistWarnings,
createScopedAccountConfigAccessors,
+ createScopedDmSecurityResolver,
formatAllowFromLowercase,
} from "openclaw/plugin-sdk/compat";
import {
@@ -31,6 +31,7 @@ import {
resolveTelegramAccount,
resolveTelegramGroupRequireMention,
resolveTelegramGroupToolPolicy,
+ sendTelegramPayloadMessages,
telegramOnboardingAdapter,
TelegramConfigSchema,
type ChannelMessageActionAdapter,
@@ -91,10 +92,6 @@ const telegramMessageActions: ChannelMessageActionAdapter = {
},
};
-type TelegramInlineButtons = ReadonlyArray<
- ReadonlyArray<{ text: string; callback_data: string; style?: "danger" | "success" | "primary" }>
->;
-
const telegramConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }),
resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom,
@@ -112,6 +109,14 @@ const telegramConfigBase = createScopedChannelConfigBase({
+ channelKey: "telegram",
+ resolvePolicy: (account) => account.config.dmPolicy,
+ resolveAllowFrom: (account) => account.config.allowFrom,
+ policyPathSuffix: "dmPolicy",
+ normalizeEntry: (raw) => raw.replace(/^(telegram|tg):/i, ""),
+});
+
export const telegramPlugin: ChannelPlugin = {
id: "telegram",
meta: {
@@ -180,18 +185,7 @@ export const telegramPlugin: ChannelPlugin {
- return buildAccountScopedDmSecurityPolicy({
- cfg,
- channelKey: "telegram",
- accountId,
- fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
- policy: account.config.dmPolicy,
- allowFrom: account.config.allowFrom ?? [],
- policyPathSuffix: "dmPolicy",
- normalizeEntry: (raw) => raw.replace(/^(telegram|tg):/i, ""),
- });
- },
+ resolveDmPolicy: resolveTelegramDmPolicy,
collectWarnings: ({ account, cfg }) => {
const groupAllowlistConfigured =
account.config.groups && Object.keys(account.config.groups).length > 0;
@@ -335,47 +329,21 @@ export const telegramPlugin: ChannelPlugin> | undefined;
- for (let i = 0; i < mediaUrls.length; i += 1) {
- const mediaUrl = mediaUrls[i];
- const isFirst = i === 0;
- finalResult = await send(to, isFirst ? text : "", {
- ...baseOpts,
- mediaUrl,
- ...(isFirst ? { buttons: telegramData?.buttons } : {}),
- });
- }
- return { channel: "telegram", ...(finalResult ?? { messageId: "unknown", chatId: to }) };
+ const result = await sendTelegramPayloadMessages({
+ send,
+ to,
+ payload,
+ baseOpts: {
+ verbose: false,
+ cfg,
+ mediaLocalRoots,
+ messageThreadId,
+ replyToMessageId,
+ accountId: accountId ?? undefined,
+ silent: silent ?? undefined,
+ },
+ });
+ return { channel: "telegram", ...result };
},
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) => {
const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
diff --git a/extensions/tlon/src/onboarding.ts b/extensions/tlon/src/onboarding.ts
index 6558dab0257..8207b190628 100644
--- a/extensions/tlon/src/onboarding.ts
+++ b/extensions/tlon/src/onboarding.ts
@@ -1,6 +1,7 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/tlon";
import {
formatDocsLink,
+ patchScopedAccountConfig,
resolveAccountIdForConfigure,
DEFAULT_ACCOUNT_ID,
type ChannelOnboardingAdapter,
@@ -32,46 +33,30 @@ function applyAccountConfig(params: {
};
}): OpenClawConfig {
const { cfg, accountId, input } = params;
- const useDefault = accountId === DEFAULT_ACCOUNT_ID;
- const base = cfg.channels?.tlon ?? {};
const nextValues = {
enabled: true,
...(input.name ? { name: input.name } : {}),
...buildTlonAccountFields(input),
};
-
- if (useDefault) {
- return {
- ...cfg,
- channels: {
- ...cfg.channels,
- tlon: {
- ...base,
- ...nextValues,
- },
- },
- };
+ if (accountId === DEFAULT_ACCOUNT_ID) {
+ return patchScopedAccountConfig({
+ cfg,
+ channelKey: channel,
+ accountId,
+ patch: nextValues,
+ ensureChannelEnabled: false,
+ ensureAccountEnabled: false,
+ });
}
-
- return {
- ...cfg,
- channels: {
- ...cfg.channels,
- tlon: {
- ...base,
- enabled: base.enabled ?? true,
- accounts: {
- ...(base as { accounts?: Record }).accounts,
- [accountId]: {
- ...(base as { accounts?: Record> }).accounts?.[
- accountId
- ],
- ...nextValues,
- },
- },
- },
- },
- };
+ return patchScopedAccountConfig({
+ cfg,
+ channelKey: channel,
+ accountId,
+ patch: { enabled: cfg.channels?.tlon?.enabled ?? true },
+ accountPatch: nextValues,
+ ensureChannelEnabled: false,
+ ensureAccountEnabled: false,
+ });
}
async function noteTlonHelp(prompter: WizardPrompter): Promise {
diff --git a/extensions/voice-call/openclaw.plugin.json b/extensions/voice-call/openclaw.plugin.json
index d9a904c73eb..fef3ccc6ad9 100644
--- a/extensions/voice-call/openclaw.plugin.json
+++ b/extensions/voice-call/openclaw.plugin.json
@@ -522,11 +522,22 @@
"apiKey": {
"type": "string"
},
+ "baseUrl": {
+ "type": "string"
+ },
"model": {
"type": "string"
},
"voice": {
"type": "string"
+ },
+ "speed": {
+ "type": "number",
+ "minimum": 0.25,
+ "maximum": 4.0
+ },
+ "instructions": {
+ "type": "string"
}
}
},
diff --git a/extensions/voice-call/src/providers/tts-openai.ts b/extensions/voice-call/src/providers/tts-openai.ts
index a27030b4578..0a7c74d90ac 100644
--- a/extensions/voice-call/src/providers/tts-openai.ts
+++ b/extensions/voice-call/src/providers/tts-openai.ts
@@ -1,3 +1,4 @@
+import { resolveOpenAITtsInstructions } from "openclaw/plugin-sdk/voice-call";
import { pcmToMulaw } from "../telephony-audio.js";
/**
@@ -110,9 +111,11 @@ export class OpenAITTSProvider {
speed: this.speed,
};
- // Add instructions if using gpt-4o-mini-tts model
- const effectiveInstructions = trimToUndefined(instructions) ?? this.instructions;
- if (effectiveInstructions && this.model.includes("gpt-4o-mini-tts")) {
+ const effectiveInstructions = resolveOpenAITtsInstructions(
+ this.model,
+ trimToUndefined(instructions) ?? this.instructions,
+ );
+ if (effectiveInstructions) {
body.instructions = effectiveInstructions;
}
diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts
index e4671bb90c1..b374ecfbd63 100644
--- a/extensions/zalo/src/channel.ts
+++ b/extensions/zalo/src/channel.ts
@@ -1,8 +1,9 @@
import {
buildAccountScopedDmSecurityPolicy,
- collectOpenProviderGroupPolicyWarnings,
buildOpenGroupPolicyRestrictSendersWarning,
buildOpenGroupPolicyWarning,
+ collectOpenProviderGroupPolicyWarnings,
+ createAccountStatusSink,
mapAllowFromEntries,
} from "openclaw/plugin-sdk/compat";
import type {
@@ -357,6 +358,10 @@ export const zaloPlugin: ChannelPlugin = {
`[${account.accountId}] Zalo probe threw before provider start: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
);
}
+ const statusSink = createAccountStatusSink({
+ accountId: ctx.accountId,
+ setStatus: ctx.setStatus,
+ });
ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel} mode=${mode}`);
const { monitorZaloProvider } = await import("./monitor.js");
return monitorZaloProvider({
@@ -370,7 +375,7 @@ export const zaloPlugin: ChannelPlugin = {
webhookSecret: normalizeSecretInputString(account.config.webhookSecret),
webhookPath: account.config.webhookPath,
fetcher,
- statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
+ statusSink,
});
},
},
diff --git a/extensions/zalo/src/config-schema.ts b/extensions/zalo/src/config-schema.ts
index 5f4886cdaf9..253830eb858 100644
--- a/extensions/zalo/src/config-schema.ts
+++ b/extensions/zalo/src/config-schema.ts
@@ -1,6 +1,8 @@
import {
- AllowFromEntrySchema,
+ AllowFromListSchema,
buildCatchallMultiAccountChannelSchema,
+ DmPolicySchema,
+ GroupPolicySchema,
} from "openclaw/plugin-sdk/compat";
import { MarkdownConfigSchema } from "openclaw/plugin-sdk/zalo";
import { z } from "zod";
@@ -15,10 +17,10 @@ const zaloAccountSchema = z.object({
webhookUrl: z.string().optional(),
webhookSecret: buildSecretInputSchema().optional(),
webhookPath: z.string().optional(),
- dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
- allowFrom: z.array(AllowFromEntrySchema).optional(),
- groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
- groupAllowFrom: z.array(AllowFromEntrySchema).optional(),
+ dmPolicy: DmPolicySchema.optional(),
+ allowFrom: AllowFromListSchema,
+ groupPolicy: GroupPolicySchema.optional(),
+ groupAllowFrom: AllowFromListSchema,
mediaMaxMb: z.number().optional(),
proxy: z.string().optional(),
responsePrefix: z.string().optional(),
diff --git a/extensions/zalo/src/onboarding.ts b/extensions/zalo/src/onboarding.ts
index e23765f4f7d..4c6f7cbe4de 100644
--- a/extensions/zalo/src/onboarding.ts
+++ b/extensions/zalo/src/onboarding.ts
@@ -12,6 +12,7 @@ import {
mergeAllowFromEntries,
normalizeAccountId,
promptSingleChannelSecretInput,
+ runSingleChannelSecretStep,
resolveAccountIdForConfigure,
setTopLevelChannelDmPolicyWithAllowFrom,
} from "openclaw/plugin-sdk/zalo";
@@ -255,80 +256,66 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
const hasConfigToken = Boolean(
hasConfiguredSecretInput(resolvedAccount.config.botToken) || resolvedAccount.config.tokenFile,
);
- const tokenPromptState = buildSingleChannelSecretPromptState({
- accountConfigured,
- hasConfigToken,
- allowEnv,
- envValue: process.env.ZALO_BOT_TOKEN,
- });
-
- let token: SecretInput | null = null;
- if (!accountConfigured) {
- await noteZaloTokenHelp(prompter);
- }
- const tokenResult = await promptSingleChannelSecretInput({
+ const tokenStep = await runSingleChannelSecretStep({
cfg: next,
prompter,
providerHint: "zalo",
credentialLabel: "bot token",
- accountConfigured: tokenPromptState.accountConfigured,
- canUseEnv: tokenPromptState.canUseEnv,
- hasConfigToken: tokenPromptState.hasConfigToken,
+ accountConfigured,
+ hasConfigToken,
+ allowEnv,
+ envValue: process.env.ZALO_BOT_TOKEN,
envPrompt: "ZALO_BOT_TOKEN detected. Use env var?",
keepPrompt: "Zalo token already configured. Keep it?",
inputPrompt: "Enter Zalo bot token",
preferredEnvVar: "ZALO_BOT_TOKEN",
- });
- if (tokenResult.action === "set") {
- token = tokenResult.value;
- }
- if (tokenResult.action === "use-env" && zaloAccountId === DEFAULT_ACCOUNT_ID) {
- next = {
- ...next,
- channels: {
- ...next.channels,
- zalo: {
- ...next.channels?.zalo,
- enabled: true,
- },
- },
- } as OpenClawConfig;
- }
-
- if (token) {
- if (zaloAccountId === DEFAULT_ACCOUNT_ID) {
- next = {
- ...next,
- channels: {
- ...next.channels,
- zalo: {
- ...next.channels?.zalo,
- enabled: true,
- botToken: token,
- },
- },
- } as OpenClawConfig;
- } else {
- next = {
- ...next,
- channels: {
- ...next.channels,
- zalo: {
- ...next.channels?.zalo,
- enabled: true,
- accounts: {
- ...next.channels?.zalo?.accounts,
- [zaloAccountId]: {
- ...next.channels?.zalo?.accounts?.[zaloAccountId],
+ onMissingConfigured: async () => await noteZaloTokenHelp(prompter),
+ applyUseEnv: async (cfg) =>
+ zaloAccountId === DEFAULT_ACCOUNT_ID
+ ? ({
+ ...cfg,
+ channels: {
+ ...cfg.channels,
+ zalo: {
+ ...cfg.channels?.zalo,
enabled: true,
- botToken: token,
},
},
- },
- },
- } as OpenClawConfig;
- }
- }
+ } as OpenClawConfig)
+ : cfg,
+ applySet: async (cfg, value) =>
+ zaloAccountId === DEFAULT_ACCOUNT_ID
+ ? ({
+ ...cfg,
+ channels: {
+ ...cfg.channels,
+ zalo: {
+ ...cfg.channels?.zalo,
+ enabled: true,
+ botToken: value,
+ },
+ },
+ } as OpenClawConfig)
+ : ({
+ ...cfg,
+ channels: {
+ ...cfg.channels,
+ zalo: {
+ ...cfg.channels?.zalo,
+ enabled: true,
+ accounts: {
+ ...cfg.channels?.zalo?.accounts,
+ [zaloAccountId]: {
+ ...cfg.channels?.zalo?.accounts?.[zaloAccountId],
+ enabled: true,
+ botToken: value,
+ },
+ },
+ },
+ },
+ } as OpenClawConfig),
+ });
+ next = tokenStep.cfg;
const wantsWebhook = await prompter.confirm({
message: "Use webhook mode for Zalo?",
diff --git a/extensions/zalo/src/token.test.ts b/extensions/zalo/src/token.test.ts
index d6b02f30483..ff3e84ce293 100644
--- a/extensions/zalo/src/token.test.ts
+++ b/extensions/zalo/src/token.test.ts
@@ -1,3 +1,6 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolveZaloToken } from "./token.js";
import type { ZaloConfig } from "./types.js";
@@ -55,4 +58,20 @@ describe("resolveZaloToken", () => {
expect(res.token).toBe("work-token");
expect(res.source).toBe("config");
});
+
+ it.runIf(process.platform !== "win32")("rejects symlinked token files", () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-zalo-token-"));
+ const tokenFile = path.join(dir, "token.txt");
+ const tokenLink = path.join(dir, "token-link.txt");
+ fs.writeFileSync(tokenFile, "file-token\n", "utf8");
+ fs.symlinkSync(tokenFile, tokenLink);
+
+ const cfg = {
+ tokenFile: tokenLink,
+ } as ZaloConfig;
+ const res = resolveZaloToken(cfg);
+ expect(res.token).toBe("");
+ expect(res.source).toBe("none");
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
});
diff --git a/extensions/zalo/src/token.ts b/extensions/zalo/src/token.ts
index 00ed1d720f7..10a4aca6cd1 100644
--- a/extensions/zalo/src/token.ts
+++ b/extensions/zalo/src/token.ts
@@ -1,5 +1,5 @@
-import { readFileSync } from "node:fs";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
+import { tryReadSecretFileSync } from "openclaw/plugin-sdk/core";
import type { BaseTokenResolution } from "openclaw/plugin-sdk/zalo";
import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "./secret-input.js";
import type { ZaloConfig } from "./types.js";
@@ -9,16 +9,7 @@ export type ZaloTokenResolution = BaseTokenResolution & {
};
function readTokenFromFile(tokenFile: string | undefined): string {
- const trimmedPath = tokenFile?.trim();
- if (!trimmedPath) {
- return "";
- }
- try {
- return readFileSync(trimmedPath, "utf8").trim();
- } catch {
- // ignore read failures
- return "";
- }
+ return tryReadSecretFileSync(tokenFile, "Zalo token file", { rejectSymlink: true }) ?? "";
}
export function resolveZaloToken(
diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts
index e01775d0dbb..2091124be6e 100644
--- a/extensions/zalouser/src/channel.ts
+++ b/extensions/zalouser/src/channel.ts
@@ -1,5 +1,6 @@
import {
buildAccountScopedDmSecurityPolicy,
+ createAccountStatusSink,
mapAllowFromEntries,
} from "openclaw/plugin-sdk/compat";
import type {
@@ -682,6 +683,10 @@ export const zalouserPlugin: ChannelPlugin = {
} catch {
// ignore probe errors
}
+ const statusSink = createAccountStatusSink({
+ accountId: ctx.accountId,
+ setStatus: ctx.setStatus,
+ });
ctx.log?.info(`[${account.accountId}] starting zalouser provider${userLabel}`);
const { monitorZalouserProvider } = await import("./monitor.js");
return monitorZalouserProvider({
@@ -689,7 +694,7 @@ export const zalouserPlugin: ChannelPlugin = {
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
- statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
+ statusSink,
});
},
loginWithQrStart: async (params) => {
diff --git a/extensions/zalouser/src/config-schema.ts b/extensions/zalouser/src/config-schema.ts
index e5cb64d012e..4879a2d46cd 100644
--- a/extensions/zalouser/src/config-schema.ts
+++ b/extensions/zalouser/src/config-schema.ts
@@ -1,6 +1,8 @@
import {
- AllowFromEntrySchema,
+ AllowFromListSchema,
buildCatchallMultiAccountChannelSchema,
+ DmPolicySchema,
+ GroupPolicySchema,
} from "openclaw/plugin-sdk/compat";
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/zalouser";
import { z } from "zod";
@@ -17,11 +19,11 @@ const zalouserAccountSchema = z.object({
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
profile: z.string().optional(),
- dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
- allowFrom: z.array(AllowFromEntrySchema).optional(),
+ dmPolicy: DmPolicySchema.optional(),
+ allowFrom: AllowFromListSchema,
historyLimit: z.number().int().min(0).optional(),
- groupAllowFrom: z.array(AllowFromEntrySchema).optional(),
- groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
+ groupAllowFrom: AllowFromListSchema,
+ groupPolicy: GroupPolicySchema.optional(),
groups: z.object({}).catchall(groupConfigSchema).optional(),
messagePrefix: z.string().optional(),
responsePrefix: z.string().optional(),
diff --git a/extensions/zalouser/src/onboarding.ts b/extensions/zalouser/src/onboarding.ts
index ae8f53bf0d5..d5b828b6711 100644
--- a/extensions/zalouser/src/onboarding.ts
+++ b/extensions/zalouser/src/onboarding.ts
@@ -9,6 +9,7 @@ import {
formatResolvedUnresolvedNote,
mergeAllowFromEntries,
normalizeAccountId,
+ patchScopedAccountConfig,
promptChannelAccessConfig,
resolveAccountIdForConfigure,
setTopLevelChannelDmPolicyWithAllowFrom,
@@ -36,37 +37,13 @@ function setZalouserAccountScopedConfig(
defaultPatch: Record,
accountPatch: Record = defaultPatch,
): OpenClawConfig {
- if (accountId === DEFAULT_ACCOUNT_ID) {
- return {
- ...cfg,
- channels: {
- ...cfg.channels,
- zalouser: {
- ...cfg.channels?.zalouser,
- enabled: true,
- ...defaultPatch,
- },
- },
- } as OpenClawConfig;
- }
- return {
- ...cfg,
- channels: {
- ...cfg.channels,
- zalouser: {
- ...cfg.channels?.zalouser,
- enabled: true,
- accounts: {
- ...cfg.channels?.zalouser?.accounts,
- [accountId]: {
- ...cfg.channels?.zalouser?.accounts?.[accountId],
- enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
- ...accountPatch,
- },
- },
- },
- },
- } as OpenClawConfig;
+ return patchScopedAccountConfig({
+ cfg,
+ channelKey: channel,
+ accountId,
+ patch: defaultPatch,
+ accountPatch,
+ }) as OpenClawConfig;
}
function setZalouserDmPolicy(
diff --git a/package.json b/package.json
index 43fd734092a..f673633009c 100644
--- a/package.json
+++ b/package.json
@@ -262,10 +262,13 @@
"gateway:watch": "node scripts/watch-node.mjs gateway --force",
"gen:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --write",
"ghsa:patch": "node scripts/ghsa-patch.mjs",
- "ios:build": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build'",
- "ios:gen": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate'",
- "ios:open": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'",
- "ios:run": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build && xcrun simctl boot \"${IOS_SIM:-iPhone 17}\" || true && xcrun simctl launch booted ai.openclaw.ios'",
+ "ios:beta": "bash scripts/ios-beta-release.sh",
+ "ios:beta:archive": "bash scripts/ios-beta-archive.sh",
+ "ios:beta:prepare": "bash scripts/ios-beta-prepare.sh",
+ "ios:build": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build'",
+ "ios:gen": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate'",
+ "ios:open": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'",
+ "ios:run": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build && xcrun simctl boot \"${IOS_SIM:-iPhone 17}\" || true && xcrun simctl launch booted ai.openclaw.ios'",
"lint": "oxlint --type-aware",
"lint:agent:ingress-owner": "node scripts/check-ingress-agent-owner-context.mjs",
"lint:all": "pnpm lint && pnpm lint:swift",
@@ -299,6 +302,7 @@
"start": "node scripts/run-node.mjs",
"test": "node scripts/test-parallel.mjs",
"test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all",
+ "test:auth:compat": "vitest run --config vitest.gateway.config.ts src/gateway/server.auth.compat-baseline.test.ts src/gateway/client.test.ts src/gateway/reconnect-gating.test.ts src/gateway/protocol/connect-error-details.test.ts",
"test:channels": "vitest run --config vitest.channels.config.ts",
"test:coverage": "vitest run --config vitest.unit.config.ts --coverage",
"test:docker:all": "pnpm test:docker:live-models && pnpm test:docker:live-gateway && pnpm test:docker:onboard && pnpm test:docker:gateway-network && pnpm test:docker:qr && pnpm test:docker:doctor-switch && pnpm test:docker:plugins && pnpm test:docker:cleanup",
@@ -363,8 +367,9 @@
"discord-api-types": "^0.38.41",
"dotenv": "^17.3.1",
"express": "^5.2.1",
- "file-type": "^21.3.0",
+ "file-type": "^21.3.1",
"grammy": "^1.41.1",
+ "hono": "4.12.7",
"https-proxy-agent": "^7.0.6",
"ipaddr.js": "^2.3.0",
"jiti": "^2.6.1",
@@ -421,17 +426,18 @@
"pnpm": {
"minimumReleaseAge": 2880,
"overrides": {
- "hono": "4.12.5",
+ "hono": "4.12.7",
"@hono/node-server": "1.19.10",
"fast-xml-parser": "5.3.8",
"request": "npm:@cypress/request@3.0.10",
"request-promise": "npm:@cypress/request-promise@5.0.0",
+ "file-type": "21.3.1",
"form-data": "2.5.4",
"minimatch": "10.2.4",
"qs": "6.14.2",
"node-domexception": "npm:@nolyfill/domexception@^1.0.28",
"@sinclair/typebox": "0.34.48",
- "tar": "7.5.10",
+ "tar": "7.5.11",
"tough-cookie": "4.1.3"
},
"onlyBuiltDependencies": [
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7b3028f61eb..72fa7353329 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -5,17 +5,18 @@ settings:
excludeLinksFromLockfile: false
overrides:
- hono: 4.12.5
+ hono: 4.12.7
'@hono/node-server': 1.19.10
fast-xml-parser: 5.3.8
request: npm:@cypress/request@3.0.10
request-promise: npm:@cypress/request-promise@5.0.0
+ file-type: 21.3.1
form-data: 2.5.4
minimatch: 10.2.4
qs: 6.14.2
node-domexception: npm:@nolyfill/domexception@^1.0.28
'@sinclair/typebox': 0.34.48
- tar: 7.5.10
+ tar: 7.5.11
tough-cookie: 4.1.3
packageExtensionsChecksum: sha256-n+P/SQo4Pf+dHYpYn1Y6wL4cJEVoVzZ835N0OEp4TM8=
@@ -32,7 +33,7 @@ importers:
version: 3.1004.0
'@buape/carbon':
specifier: 0.0.0-beta-20260216184201
- version: 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.5)(opusscript@0.1.1)
+ version: 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.7)(opusscript@0.1.1)
'@clack/prompts':
specifier: ^1.1.0
version: 1.1.0
@@ -115,11 +116,14 @@ importers:
specifier: ^5.2.1
version: 5.2.1
file-type:
- specifier: ^21.3.0
- version: 21.3.0
+ specifier: 21.3.1
+ version: 21.3.1
grammy:
specifier: ^1.41.1
version: 1.41.1
+ hono:
+ specifier: 4.12.7
+ version: 4.12.7
https-proxy-agent:
specifier: ^7.0.6
version: 7.0.6
@@ -172,8 +176,8 @@ importers:
specifier: 0.1.7-alpha.2
version: 0.1.7-alpha.2
tar:
- specifier: 7.5.10
- version: 7.5.10
+ specifier: 7.5.11
+ version: 7.5.11
tslog:
specifier: ^4.10.2
version: 4.10.2
@@ -337,9 +341,10 @@ importers:
google-auth-library:
specifier: ^10.6.1
version: 10.6.1
+ devDependencies:
openclaw:
- specifier: '>=2026.3.7'
- version: 2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3))
+ specifier: workspace:*
+ version: link:../..
extensions/imessage: {}
@@ -397,10 +402,10 @@ importers:
version: 4.3.6
extensions/memory-core:
- dependencies:
+ devDependencies:
openclaw:
- specifier: '>=2026.3.7'
- version: 2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3))
+ specifier: workspace:*
+ version: link:../..
extensions/memory-lancedb:
dependencies:
@@ -1234,7 +1239,7 @@ packages:
resolution: {integrity: sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==}
engines: {node: '>=18.14.1'}
peerDependencies:
- hono: 4.12.5
+ hono: 4.12.7
'@huggingface/jinja@0.5.5':
resolution: {integrity: sha512-xRlzazC+QZwr6z4ixEqYHo9fgwhTZ3xNSdljlKfUFGZSdlvt166DljRELFUfFytlYOYvo3vTisA/AFOuOAzFQQ==}
@@ -4281,8 +4286,8 @@ packages:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
- file-type@21.3.0:
- resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==}
+ file-type@21.3.1:
+ resolution: {integrity: sha512-SrzXX46I/zsRDjTb82eucsGg0ODq2NpGDp4HcsFKApPy8P8vACjpJRDoGGMfEzhFC0ry61ajd7f72J3603anBA==}
engines: {node: '>=20'}
filename-reserved-regex@3.0.0:
@@ -4498,8 +4503,8 @@ packages:
highlight.js@10.7.3:
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
- hono@4.12.5:
- resolution: {integrity: sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==}
+ hono@4.12.7:
+ resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==}
engines: {node: '>=16.9.0'}
hookable@6.0.1:
@@ -5327,14 +5332,6 @@ packages:
zod:
optional: true
- openclaw@2026.3.8:
- resolution: {integrity: sha512-e5Rk2Aj55sD/5LyX94mdYCQj7zpHXo0xIZsl+k140+nRopePfPAxC7nsu0V/NyypPRtaotP1riFfzK7IhaYkuQ==}
- engines: {node: '>=22.12.0'}
- hasBin: true
- peerDependencies:
- '@napi-rs/canvas': ^0.1.89
- node-llama-cpp: 3.16.2
-
opus-decoder@0.7.11:
resolution: {integrity: sha512-+e+Jz3vGQLxRTBHs8YJQPRPc1Tr+/aC6coV/DlZylriA29BdHQAYXhvNRKtjftof17OFng0+P4wsFIqQu3a48A==}
@@ -6121,8 +6118,8 @@ packages:
tar-stream@3.1.7:
resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==}
- tar@7.5.10:
- resolution: {integrity: sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==}
+ tar@7.5.11:
+ resolution: {integrity: sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==}
engines: {node: '>=18'}
text-decoder@1.2.7:
@@ -7500,14 +7497,14 @@ snapshots:
'@borewit/text-codec@0.2.1': {}
- '@buape/carbon@0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.5)(opusscript@0.1.1)':
+ '@buape/carbon@0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.7)(opusscript@0.1.1)':
dependencies:
'@types/node': 25.3.5
discord-api-types: 0.38.37
optionalDependencies:
'@cloudflare/workers-types': 4.20260120.0
'@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1)
- '@hono/node-server': 1.19.10(hono@4.12.5)
+ '@hono/node-server': 1.19.10(hono@4.12.7)
'@types/bun': 1.3.9
'@types/ws': 8.18.1
ws: 8.19.0
@@ -7642,7 +7639,7 @@ snapshots:
npmlog: 5.0.1
rimraf: 3.0.2
semver: 7.7.4
- tar: 7.5.10
+ tar: 7.5.11
transitivePeerDependencies:
- encoding
- supports-color
@@ -7819,9 +7816,9 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@hono/node-server@1.19.10(hono@4.12.5)':
+ '@hono/node-server@1.19.10(hono@4.12.7)':
dependencies:
- hono: 4.12.5
+ hono: 4.12.7
optional: true
'@huggingface/jinja@0.5.5': {}
@@ -8196,7 +8193,7 @@ snapshots:
cli-highlight: 2.1.11
diff: 8.0.3
extract-zip: 2.0.1
- file-type: 21.3.0
+ file-type: 21.3.1
glob: 13.0.6
hosted-git-info: 9.0.2
ignore: 7.0.5
@@ -10720,7 +10717,7 @@ snapshots:
node-api-headers: 1.8.0
rc: 1.2.8
semver: 7.7.4
- tar: 7.5.10
+ tar: 7.5.11
url-join: 4.0.1
which: 6.0.1
yargs: 17.7.2
@@ -11157,7 +11154,7 @@ snapshots:
node-domexception: '@nolyfill/domexception@1.0.28'
web-streams-polyfill: 3.3.3
- file-type@21.3.0:
+ file-type@21.3.1:
dependencies:
'@tokenizer/inflate': 0.4.1
strtok3: 10.3.4
@@ -11434,8 +11431,7 @@ snapshots:
highlight.js@10.7.3: {}
- hono@4.12.5:
- optional: true
+ hono@4.12.7: {}
hookable@6.0.1: {}
@@ -12091,7 +12087,7 @@ snapshots:
'@tokenizer/token': 0.3.0
content-type: 1.0.5
debug: 4.4.3
- file-type: 21.3.0
+ file-type: 21.3.1
media-typer: 1.1.0
strtok3: 10.3.4
token-types: 6.1.2
@@ -12309,81 +12305,6 @@ snapshots:
ws: 8.19.0
zod: 4.3.6
- openclaw@2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)):
- dependencies:
- '@agentclientprotocol/sdk': 0.15.0(zod@4.3.6)
- '@aws-sdk/client-bedrock': 3.1004.0
- '@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.5)(opusscript@0.1.1)
- '@clack/prompts': 1.1.0
- '@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1)
- '@grammyjs/runner': 2.0.3(grammy@1.41.1)
- '@grammyjs/transformer-throttler': 1.2.1(grammy@1.41.1)
- '@homebridge/ciao': 1.3.5
- '@larksuiteoapi/node-sdk': 1.59.0
- '@line/bot-sdk': 10.6.0
- '@lydell/node-pty': 1.2.0-beta.3
- '@mariozechner/pi-agent-core': 0.57.1(ws@8.19.0)(zod@4.3.6)
- '@mariozechner/pi-ai': 0.57.1(ws@8.19.0)(zod@4.3.6)
- '@mariozechner/pi-coding-agent': 0.57.1(ws@8.19.0)(zod@4.3.6)
- '@mariozechner/pi-tui': 0.57.1
- '@mozilla/readability': 0.6.0
- '@napi-rs/canvas': 0.1.95
- '@sinclair/typebox': 0.34.48
- '@slack/bolt': 4.6.0(@types/express@5.0.6)
- '@slack/web-api': 7.14.1
- '@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5)
- ajv: 8.18.0
- chalk: 5.6.2
- chokidar: 5.0.0
- cli-highlight: 2.1.11
- commander: 14.0.3
- croner: 10.0.1
- discord-api-types: 0.38.41
- dotenv: 17.3.1
- express: 5.2.1
- file-type: 21.3.0
- grammy: 1.41.1
- https-proxy-agent: 7.0.6
- ipaddr.js: 2.3.0
- jiti: 2.6.1
- json5: 2.2.3
- jszip: 3.10.1
- linkedom: 0.18.12
- long: 5.3.2
- markdown-it: 14.1.1
- node-edge-tts: 1.2.10
- node-llama-cpp: 3.16.2(typescript@5.9.3)
- opusscript: 0.1.1
- osc-progress: 0.3.0
- pdfjs-dist: 5.5.207
- playwright-core: 1.58.2
- qrcode-terminal: 0.12.0
- sharp: 0.34.5
- sqlite-vec: 0.1.7-alpha.2
- tar: 7.5.10
- tslog: 4.10.2
- undici: 7.22.0
- ws: 8.19.0
- yaml: 2.8.2
- zod: 4.3.6
- transitivePeerDependencies:
- - '@discordjs/opus'
- - '@modelcontextprotocol/sdk'
- - '@types/express'
- - audio-decode
- - aws-crt
- - bufferutil
- - canvas
- - debug
- - encoding
- - ffmpeg-static
- - hono
- - jimp
- - link-preview-js
- - node-opus
- - supports-color
- - utf-8-validate
-
opus-decoder@0.7.11:
dependencies:
'@wasm-audio-decoders/common': 9.0.7
@@ -13388,7 +13309,7 @@ snapshots:
- bare-abort-controller
- react-native-b4a
- tar@7.5.10:
+ tar@7.5.11:
dependencies:
'@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0
diff --git a/scripts/bundle-a2ui.sh b/scripts/bundle-a2ui.sh
index 3278e1d35a3..85bc265c7c9 100755
--- a/scripts/bundle-a2ui.sh
+++ b/scripts/bundle-a2ui.sh
@@ -86,7 +86,7 @@ if [[ -f "$HASH_FILE" ]]; then
fi
pnpm -s exec tsc -p "$A2UI_RENDERER_DIR/tsconfig.json"
-if command -v rolldown >/dev/null 2>&1; then
+if command -v rolldown >/dev/null 2>&1 && rolldown --version >/dev/null 2>&1; then
rolldown -c "$A2UI_APP_DIR/rolldown.config.mjs"
else
pnpm -s dlx rolldown -c "$A2UI_APP_DIR/rolldown.config.mjs"
diff --git a/scripts/ios-beta-archive.sh b/scripts/ios-beta-archive.sh
new file mode 100755
index 00000000000..c65e9991389
--- /dev/null
+++ b/scripts/ios-beta-archive.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage:
+ scripts/ios-beta-archive.sh [--build-number 7]
+
+Archives and exports a beta-release IPA locally without uploading.
+EOF
+}
+
+BUILD_NUMBER="${IOS_BETA_BUILD_NUMBER:-}"
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --)
+ shift
+ ;;
+ --build-number)
+ BUILD_NUMBER="${2:-}"
+ shift 2
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown argument: $1" >&2
+ usage
+ exit 1
+ ;;
+ esac
+done
+
+(
+ cd "${ROOT_DIR}/apps/ios"
+ IOS_BETA_BUILD_NUMBER="${BUILD_NUMBER}" fastlane ios beta_archive
+)
diff --git a/scripts/ios-beta-prepare.sh b/scripts/ios-beta-prepare.sh
new file mode 100755
index 00000000000..1d88add46db
--- /dev/null
+++ b/scripts/ios-beta-prepare.sh
@@ -0,0 +1,117 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage:
+ scripts/ios-beta-prepare.sh --build-number 7 [--team-id TEAMID]
+
+Prepares local beta-release inputs without touching local signing overrides:
+- reads package.json.version and writes apps/ios/build/Version.xcconfig
+- writes apps/ios/build/BetaRelease.xcconfig with canonical bundle IDs
+- regenerates apps/ios/OpenClaw.xcodeproj via xcodegen
+EOF
+}
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+IOS_DIR="${ROOT_DIR}/apps/ios"
+BUILD_DIR="${IOS_DIR}/build"
+BETA_XCCONFIG="${IOS_DIR}/build/BetaRelease.xcconfig"
+TEAM_HELPER="${ROOT_DIR}/scripts/ios-team-id.sh"
+VERSION_HELPER="${ROOT_DIR}/scripts/ios-write-version-xcconfig.sh"
+
+BUILD_NUMBER=""
+TEAM_ID="${IOS_DEVELOPMENT_TEAM:-}"
+PACKAGE_VERSION="$(cd "${ROOT_DIR}" && node -p "require('./package.json').version" 2>/dev/null || true)"
+
+prepare_build_dir() {
+ if [[ -L "${BUILD_DIR}" ]]; then
+ echo "Refusing to use symlinked build directory: ${BUILD_DIR}" >&2
+ exit 1
+ fi
+
+ mkdir -p "${BUILD_DIR}"
+}
+
+write_generated_file() {
+ local output_path="$1"
+ local tmp_file=""
+
+ if [[ -e "${output_path}" && -L "${output_path}" ]]; then
+ echo "Refusing to overwrite symlinked file: ${output_path}" >&2
+ exit 1
+ fi
+
+ tmp_file="$(mktemp "${output_path}.XXXXXX")"
+ cat >"${tmp_file}"
+ mv -f "${tmp_file}" "${output_path}"
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --)
+ shift
+ ;;
+ --build-number)
+ BUILD_NUMBER="${2:-}"
+ shift 2
+ ;;
+ --team-id)
+ TEAM_ID="${2:-}"
+ shift 2
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown argument: $1" >&2
+ usage
+ exit 1
+ ;;
+ esac
+done
+
+if [[ -z "${BUILD_NUMBER}" ]]; then
+ echo "Missing required --build-number." >&2
+ usage
+ exit 1
+fi
+
+if [[ -z "${TEAM_ID}" ]]; then
+ TEAM_ID="$(IOS_ALLOW_KEYCHAIN_TEAM_FALLBACK=1 bash "${TEAM_HELPER}")"
+fi
+
+if [[ -z "${TEAM_ID}" ]]; then
+ echo "Could not resolve Apple Team ID. Set IOS_DEVELOPMENT_TEAM or sign into Xcode." >&2
+ exit 1
+fi
+
+prepare_build_dir
+
+(
+ bash "${VERSION_HELPER}" --build-number "${BUILD_NUMBER}"
+)
+
+write_generated_file "${BETA_XCCONFIG}" <&2
+ usage
+ exit 1
+ ;;
+ esac
+done
+
+(
+ cd "${ROOT_DIR}/apps/ios"
+ IOS_BETA_BUILD_NUMBER="${BUILD_NUMBER}" fastlane ios beta
+)
diff --git a/scripts/ios-write-version-xcconfig.sh b/scripts/ios-write-version-xcconfig.sh
new file mode 100755
index 00000000000..e6214c9188c
--- /dev/null
+++ b/scripts/ios-write-version-xcconfig.sh
@@ -0,0 +1,99 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage:
+ scripts/ios-write-version-xcconfig.sh [--build-number 7]
+
+Writes apps/ios/build/Version.xcconfig from root package.json.version:
+- OPENCLAW_GATEWAY_VERSION = exact package.json version
+- OPENCLAW_MARKETING_VERSION = short iOS/App Store version
+- OPENCLAW_BUILD_VERSION = explicit build number or local numeric fallback
+EOF
+}
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+IOS_DIR="${ROOT_DIR}/apps/ios"
+BUILD_DIR="${IOS_DIR}/build"
+VERSION_XCCONFIG="${IOS_DIR}/build/Version.xcconfig"
+PACKAGE_VERSION="$(cd "${ROOT_DIR}" && node -p "require('./package.json').version" 2>/dev/null || true)"
+BUILD_NUMBER=""
+
+prepare_build_dir() {
+ if [[ -L "${BUILD_DIR}" ]]; then
+ echo "Refusing to use symlinked build directory: ${BUILD_DIR}" >&2
+ exit 1
+ fi
+
+ mkdir -p "${BUILD_DIR}"
+}
+
+write_generated_file() {
+ local output_path="$1"
+ local tmp_file=""
+
+ if [[ -e "${output_path}" && -L "${output_path}" ]]; then
+ echo "Refusing to overwrite symlinked file: ${output_path}" >&2
+ exit 1
+ fi
+
+ tmp_file="$(mktemp "${output_path}.XXXXXX")"
+ cat >"${tmp_file}"
+ mv -f "${tmp_file}" "${output_path}"
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --)
+ shift
+ ;;
+ --build-number)
+ BUILD_NUMBER="${2:-}"
+ shift 2
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown argument: $1" >&2
+ usage
+ exit 1
+ ;;
+ esac
+done
+
+PACKAGE_VERSION="$(printf '%s' "${PACKAGE_VERSION}" | tr -d '\n' | xargs)"
+if [[ -z "${PACKAGE_VERSION}" ]]; then
+ echo "Unable to read package.json.version from ${ROOT_DIR}/package.json." >&2
+ exit 1
+fi
+
+if [[ "${PACKAGE_VERSION}" =~ ^([0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2})([.-]?beta[.-][0-9]+)?$ ]]; then
+ MARKETING_VERSION="${BASH_REMATCH[1]}"
+else
+ echo "Unsupported package.json.version '${PACKAGE_VERSION}'. Expected 2026.3.9 or 2026.3.9-beta.1." >&2
+ exit 1
+fi
+
+if [[ -z "${BUILD_NUMBER}" ]]; then
+ BUILD_NUMBER="$(cd "${ROOT_DIR}" && git rev-list --count HEAD 2>/dev/null || printf '0')"
+fi
+
+if [[ ! "${BUILD_NUMBER}" =~ ^[0-9]+$ ]]; then
+ echo "Invalid build number '${BUILD_NUMBER}'. Expected digits only." >&2
+ exit 1
+fi
+
+prepare_build_dir
+
+write_generated_file "${VERSION_XCCONFIG}" < {
expect(env.OPENCLAW_SHELL).toBe("acp-client");
expect(env.OPENAI_API_KEY).toBeUndefined();
});
+
+ it("strips provider auth env vars for the default OpenClaw bridge", () => {
+ const stripKeys = new Set(["OPENAI_API_KEY", "GITHUB_TOKEN", "HF_TOKEN"]);
+ const env = resolveAcpClientSpawnEnv(
+ {
+ OPENAI_API_KEY: "openai-secret", // pragma: allowlist secret
+ GITHUB_TOKEN: "gh-secret", // pragma: allowlist secret
+ HF_TOKEN: "hf-secret", // pragma: allowlist secret
+ OPENCLAW_API_KEY: "keep-me",
+ PATH: "/usr/bin",
+ },
+ { stripKeys },
+ );
+
+ expect(env.OPENAI_API_KEY).toBeUndefined();
+ expect(env.GITHUB_TOKEN).toBeUndefined();
+ expect(env.HF_TOKEN).toBeUndefined();
+ expect(env.OPENCLAW_API_KEY).toBe("keep-me");
+ expect(env.PATH).toBe("/usr/bin");
+ expect(env.OPENCLAW_SHELL).toBe("acp-client");
+ });
+
+ it("strips provider auth env vars case-insensitively", () => {
+ const env = resolveAcpClientSpawnEnv(
+ {
+ OpenAI_Api_Key: "openai-secret", // pragma: allowlist secret
+ Github_Token: "gh-secret", // pragma: allowlist secret
+ OPENCLAW_API_KEY: "keep-me",
+ },
+ { stripKeys: new Set(["OPENAI_API_KEY", "GITHUB_TOKEN"]) },
+ );
+
+ expect(env.OpenAI_Api_Key).toBeUndefined();
+ expect(env.Github_Token).toBeUndefined();
+ expect(env.OPENCLAW_API_KEY).toBe("keep-me");
+ expect(env.OPENCLAW_SHELL).toBe("acp-client");
+ });
+
+ it("preserves provider auth env vars for explicit custom ACP servers", () => {
+ const env = resolveAcpClientSpawnEnv({
+ OPENAI_API_KEY: "openai-secret", // pragma: allowlist secret
+ GITHUB_TOKEN: "gh-secret", // pragma: allowlist secret
+ HF_TOKEN: "hf-secret", // pragma: allowlist secret
+ OPENCLAW_API_KEY: "keep-me",
+ });
+
+ expect(env.OPENAI_API_KEY).toBe("openai-secret");
+ expect(env.GITHUB_TOKEN).toBe("gh-secret");
+ expect(env.HF_TOKEN).toBe("hf-secret");
+ expect(env.OPENCLAW_API_KEY).toBe("keep-me");
+ expect(env.OPENCLAW_SHELL).toBe("acp-client");
+ });
+});
+
+describe("shouldStripProviderAuthEnvVarsForAcpServer", () => {
+ it("strips provider auth env vars for the default bridge", () => {
+ expect(shouldStripProviderAuthEnvVarsForAcpServer()).toBe(true);
+ expect(
+ shouldStripProviderAuthEnvVarsForAcpServer({
+ serverCommand: "openclaw",
+ serverArgs: ["acp"],
+ defaultServerCommand: "openclaw",
+ defaultServerArgs: ["acp"],
+ }),
+ ).toBe(true);
+ });
+
+ it("preserves provider auth env vars for explicit custom ACP servers", () => {
+ expect(
+ shouldStripProviderAuthEnvVarsForAcpServer({
+ serverCommand: "custom-acp-server",
+ serverArgs: ["serve"],
+ defaultServerCommand: "openclaw",
+ defaultServerArgs: ["acp"],
+ }),
+ ).toBe(false);
+ });
+
+ it("preserves provider auth env vars when an explicit override uses the default executable with different args", () => {
+ expect(
+ shouldStripProviderAuthEnvVarsForAcpServer({
+ serverCommand: process.execPath,
+ serverArgs: ["custom-entry.js"],
+ defaultServerCommand: process.execPath,
+ defaultServerArgs: ["dist/entry.js", "acp"],
+ }),
+ ).toBe(false);
+ });
+});
+
+describe("buildAcpClientStripKeys", () => {
+ it("always includes active skill env keys", () => {
+ const stripKeys = buildAcpClientStripKeys({
+ stripProviderAuthEnvVars: false,
+ activeSkillEnvKeys: ["SKILL_SECRET", "OPENAI_API_KEY"],
+ });
+
+ expect(stripKeys.has("SKILL_SECRET")).toBe(true);
+ expect(stripKeys.has("OPENAI_API_KEY")).toBe(true);
+ expect(stripKeys.has("GITHUB_TOKEN")).toBe(false);
+ });
+
+ it("adds provider auth env vars for the default bridge", () => {
+ const stripKeys = buildAcpClientStripKeys({
+ stripProviderAuthEnvVars: true,
+ activeSkillEnvKeys: ["SKILL_SECRET"],
+ });
+
+ expect(stripKeys.has("SKILL_SECRET")).toBe(true);
+ expect(stripKeys.has("OPENAI_API_KEY")).toBe(true);
+ expect(stripKeys.has("GITHUB_TOKEN")).toBe(true);
+ expect(stripKeys.has("HF_TOKEN")).toBe(true);
+ expect(stripKeys.has("OPENCLAW_API_KEY")).toBe(false);
+ });
});
describe("resolveAcpClientSpawnInvocation", () => {
diff --git a/src/acp/client.ts b/src/acp/client.ts
index 54be5ffc455..2f3ac28641a 100644
--- a/src/acp/client.ts
+++ b/src/acp/client.ts
@@ -19,6 +19,10 @@ import {
materializeWindowsSpawnProgram,
resolveWindowsSpawnProgram,
} from "../plugin-sdk/windows-spawn.js";
+import {
+ listKnownProviderAuthEnvVarNames,
+ omitEnvKeysCaseInsensitive,
+} from "../secrets/provider-env-vars.js";
import { DANGEROUS_ACP_TOOLS } from "../security/dangerous-tools.js";
const SAFE_AUTO_APPROVE_TOOL_IDS = new Set(["read", "search", "web_search", "memory_search"]);
@@ -346,20 +350,56 @@ function buildServerArgs(opts: AcpClientOptions): string[] {
return args;
}
+type AcpClientSpawnEnvOptions = {
+ stripKeys?: Iterable;
+};
+
export function resolveAcpClientSpawnEnv(
baseEnv: NodeJS.ProcessEnv = process.env,
- options?: { stripKeys?: ReadonlySet },
+ options: AcpClientSpawnEnvOptions = {},
): NodeJS.ProcessEnv {
- const env: NodeJS.ProcessEnv = { ...baseEnv };
- if (options?.stripKeys) {
- for (const key of options.stripKeys) {
- delete env[key];
- }
- }
+ const env = omitEnvKeysCaseInsensitive(baseEnv, options.stripKeys ?? []);
env.OPENCLAW_SHELL = "acp-client";
return env;
}
+export function shouldStripProviderAuthEnvVarsForAcpServer(
+ params: {
+ serverCommand?: string;
+ serverArgs?: string[];
+ defaultServerCommand?: string;
+ defaultServerArgs?: string[];
+ } = {},
+): boolean {
+ const serverCommand = params.serverCommand?.trim();
+ if (!serverCommand) {
+ return true;
+ }
+ const defaultServerCommand = params.defaultServerCommand?.trim();
+ if (!defaultServerCommand || serverCommand !== defaultServerCommand) {
+ return false;
+ }
+ const serverArgs = params.serverArgs ?? [];
+ const defaultServerArgs = params.defaultServerArgs ?? [];
+ return (
+ serverArgs.length === defaultServerArgs.length &&
+ serverArgs.every((arg, index) => arg === defaultServerArgs[index])
+ );
+}
+
+export function buildAcpClientStripKeys(params: {
+ stripProviderAuthEnvVars?: boolean;
+ activeSkillEnvKeys?: Iterable;
+}): Set {
+ const stripKeys = new Set(params.activeSkillEnvKeys ?? []);
+ if (params.stripProviderAuthEnvVars) {
+ for (const key of listKnownProviderAuthEnvVarNames()) {
+ stripKeys.add(key);
+ }
+ }
+ return stripKeys;
+}
+
type AcpSpawnRuntime = {
platform: NodeJS.Platform;
env: NodeJS.ProcessEnv;
@@ -456,12 +496,22 @@ export async function createAcpClient(opts: AcpClientOptions = {}): Promise tempDirs.make("openclaw-secret-file-test-");
-
-afterEach(async () => {
- await tempDirs.cleanup();
-});
-
describe("readSecretFromFile", () => {
- it("reads and trims a regular secret file", async () => {
- const dir = await createTempDir();
- const file = path.join(dir, "secret.txt");
- await writeFile(file, " top-secret \n", "utf8");
-
- expect(readSecretFromFile(file, "Gateway password")).toBe("top-secret");
+ it("keeps the shared secret-file limit", () => {
+ expect(MAX_SECRET_FILE_BYTES).toBe(16 * 1024);
});
- it("rejects files larger than the secret-file limit", async () => {
- const dir = await createTempDir();
- const file = path.join(dir, "secret.txt");
- await writeFile(file, "x".repeat(MAX_SECRET_FILE_BYTES + 1), "utf8");
-
- expect(() => readSecretFromFile(file, "Gateway password")).toThrow(
- `Gateway password file at ${file} exceeds ${MAX_SECRET_FILE_BYTES} bytes.`,
- );
- });
-
- it("rejects non-regular files", async () => {
- const dir = await createTempDir();
- const nestedDir = path.join(dir, "secret-dir");
- await mkdir(nestedDir);
-
- expect(() => readSecretFromFile(nestedDir, "Gateway password")).toThrow(
- `Gateway password file at ${nestedDir} must be a regular file.`,
- );
- });
-
- it("rejects symlinks", async () => {
- const dir = await createTempDir();
- const target = path.join(dir, "target.txt");
- const link = path.join(dir, "secret-link.txt");
- await writeFile(target, "top-secret\n", "utf8");
- await symlink(target, link);
-
- expect(() => readSecretFromFile(link, "Gateway password")).toThrow(
- `Gateway password file at ${link} must not be a symlink.`,
- );
+ it("exposes the hardened secret reader", () => {
+ expect(typeof readSecretFromFile).toBe("function");
});
});
diff --git a/src/acp/secret-file.ts b/src/acp/secret-file.ts
index 45ec36d28cb..902e0fc0627 100644
--- a/src/acp/secret-file.ts
+++ b/src/acp/secret-file.ts
@@ -1,43 +1,10 @@
-import fs from "node:fs";
-import { resolveUserPath } from "../utils.js";
+import { DEFAULT_SECRET_FILE_MAX_BYTES, readSecretFileSync } from "../infra/secret-file.js";
-export const MAX_SECRET_FILE_BYTES = 16 * 1024;
+export const MAX_SECRET_FILE_BYTES = DEFAULT_SECRET_FILE_MAX_BYTES;
export function readSecretFromFile(filePath: string, label: string): string {
- const resolvedPath = resolveUserPath(filePath.trim());
- if (!resolvedPath) {
- throw new Error(`${label} file path is empty.`);
- }
-
- let stat: fs.Stats;
- try {
- stat = fs.lstatSync(resolvedPath);
- } catch (err) {
- throw new Error(`Failed to inspect ${label} file at ${resolvedPath}: ${String(err)}`, {
- cause: err,
- });
- }
- if (stat.isSymbolicLink()) {
- throw new Error(`${label} file at ${resolvedPath} must not be a symlink.`);
- }
- if (!stat.isFile()) {
- throw new Error(`${label} file at ${resolvedPath} must be a regular file.`);
- }
- if (stat.size > MAX_SECRET_FILE_BYTES) {
- throw new Error(`${label} file at ${resolvedPath} exceeds ${MAX_SECRET_FILE_BYTES} bytes.`);
- }
-
- let raw = "";
- try {
- raw = fs.readFileSync(resolvedPath, "utf8");
- } catch (err) {
- throw new Error(`Failed to read ${label} file at ${resolvedPath}: ${String(err)}`, {
- cause: err,
- });
- }
- const secret = raw.trim();
- if (!secret) {
- throw new Error(`${label} file at ${resolvedPath} is empty.`);
- }
- return secret;
+ return readSecretFileSync(filePath, label, {
+ maxBytes: MAX_SECRET_FILE_BYTES,
+ rejectSymlink: true,
+ });
}
diff --git a/src/agents/bash-tools.exec-approval-request.ts b/src/agents/bash-tools.exec-approval-request.ts
index 7c28827c051..2b2fd7d9a5b 100644
--- a/src/agents/bash-tools.exec-approval-request.ts
+++ b/src/agents/bash-tools.exec-approval-request.ts
@@ -7,7 +7,7 @@ import { callGatewayTool } from "./tools/gateway.js";
export type RequestExecApprovalDecisionParams = {
id: string;
- command: string;
+ command?: string;
commandArgv?: string[];
systemRunPlan?: SystemRunApprovalPlan;
env?: Record;
@@ -35,8 +35,8 @@ function buildExecApprovalRequestToolParams(
): ExecApprovalRequestToolParams {
return {
id: params.id,
- command: params.command,
- commandArgv: params.commandArgv,
+ ...(params.command ? { command: params.command } : {}),
+ ...(params.commandArgv ? { commandArgv: params.commandArgv } : {}),
systemRunPlan: params.systemRunPlan,
env: params.env,
cwd: params.cwd,
@@ -150,7 +150,7 @@ export async function requestExecApprovalDecision(
type HostExecApprovalParams = {
approvalId: string;
- command: string;
+ command?: string;
commandArgv?: string[];
systemRunPlan?: SystemRunApprovalPlan;
env?: Record;
diff --git a/src/agents/bash-tools.exec-host-node.ts b/src/agents/bash-tools.exec-host-node.ts
index 97eb4218035..c3a23197f0a 100644
--- a/src/agents/bash-tools.exec-host-node.ts
+++ b/src/agents/bash-tools.exec-host-node.ts
@@ -125,7 +125,7 @@ export async function executeNodeHostCommand(
throw new Error("invalid system.run.prepare response");
}
const runArgv = prepared.plan.argv;
- const runRawCommand = prepared.plan.rawCommand ?? prepared.cmdText;
+ const runRawCommand = prepared.plan.commandText;
const runCwd = prepared.plan.cwd ?? params.workdir;
const runAgentId = prepared.plan.agentId ?? params.agentId;
const runSessionKey = prepared.plan.sessionKey ?? params.sessionKey;
@@ -238,8 +238,6 @@ export async function executeNodeHostCommand(
// Register first so the returned approval ID is actionable immediately.
const registration = await registerExecApprovalRequestForHostOrThrow({
approvalId,
- command: prepared.cmdText,
- commandArgv: prepared.plan.argv,
systemRunPlan: prepared.plan,
env: nodeEnv,
workdir: runCwd,
@@ -391,7 +389,7 @@ export async function executeNodeHostCommand(
warningText,
approvalSlug,
approvalId,
- command: prepared.cmdText,
+ command: prepared.plan.commandText,
cwd: runCwd,
host: "node",
nodeId,
diff --git a/src/agents/huggingface-models.ts b/src/agents/huggingface-models.ts
index 7d3755adefb..0e7ae4270f7 100644
--- a/src/agents/huggingface-models.ts
+++ b/src/agents/huggingface-models.ts
@@ -1,5 +1,6 @@
import type { ModelDefinitionConfig } from "../config/types.models.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
+import { isReasoningModelHeuristic } from "./ollama-models.js";
const log = createSubsystemLogger("huggingface-models");
@@ -125,7 +126,7 @@ export function buildHuggingfaceModelDefinition(
*/
function inferredMetaFromModelId(id: string): { name: string; reasoning: boolean } {
const base = id.split("/").pop() ?? id;
- const reasoning = /r1|reasoning|thinking|reason/i.test(id) || /-\d+[tb]?-thinking/i.test(base);
+ const reasoning = isReasoningModelHeuristic(id);
const name = base.replace(/-/g, " ").replace(/\b(\w)/g, (c) => c.toUpperCase());
return { name, reasoning };
}
diff --git a/src/agents/lanes.test.ts b/src/agents/lanes.test.ts
new file mode 100644
index 00000000000..9538de70d26
--- /dev/null
+++ b/src/agents/lanes.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from "vitest";
+import { AGENT_LANE_NESTED, resolveNestedAgentLane } from "./lanes.js";
+
+describe("resolveNestedAgentLane", () => {
+ it("defaults to the nested lane when no lane is provided", () => {
+ expect(resolveNestedAgentLane()).toBe(AGENT_LANE_NESTED);
+ });
+
+ it("moves cron lane callers onto the nested lane", () => {
+ expect(resolveNestedAgentLane("cron")).toBe(AGENT_LANE_NESTED);
+ expect(resolveNestedAgentLane(" cron ")).toBe(AGENT_LANE_NESTED);
+ });
+
+ it("preserves non-cron lanes", () => {
+ expect(resolveNestedAgentLane("subagent")).toBe("subagent");
+ expect(resolveNestedAgentLane(" custom-lane ")).toBe("custom-lane");
+ });
+});
diff --git a/src/agents/lanes.ts b/src/agents/lanes.ts
index 1688a4b8b9a..e9fa2217cf7 100644
--- a/src/agents/lanes.ts
+++ b/src/agents/lanes.ts
@@ -2,3 +2,13 @@ import { CommandLane } from "../process/lanes.js";
export const AGENT_LANE_NESTED = CommandLane.Nested;
export const AGENT_LANE_SUBAGENT = CommandLane.Subagent;
+
+export function resolveNestedAgentLane(lane?: string): string {
+ const trimmed = lane?.trim();
+ // Nested agent runs should not inherit the cron execution lane. Cron jobs
+ // already occupy that lane while they dispatch inner work.
+ if (!trimmed || trimmed === "cron") {
+ return AGENT_LANE_NESTED;
+ }
+ return trimmed;
+}
diff --git a/src/agents/live-model-filter.ts b/src/agents/live-model-filter.ts
index 03de7d772cc..059e12d9711 100644
--- a/src/agents/live-model-filter.ts
+++ b/src/agents/live-model-filter.ts
@@ -81,7 +81,7 @@ export function isModernModelRef(ref: ModelRef): boolean {
return false;
}
- if (provider === "openrouter" || provider === "opencode") {
+ if (provider === "openrouter" || provider === "opencode" || provider === "opencode-go") {
// OpenRouter/opencode are pass-through proxies; accept any model ID
// rather than restricting to a static prefix list.
return true;
diff --git a/src/agents/model-auth-env-vars.ts b/src/agents/model-auth-env-vars.ts
index 0f387bf3ce3..fbe5a78917d 100644
--- a/src/agents/model-auth-env-vars.ts
+++ b/src/agents/model-auth-env-vars.ts
@@ -4,6 +4,7 @@ export const PROVIDER_ENV_API_KEY_CANDIDATES: Record = {
chutes: ["CHUTES_OAUTH_TOKEN", "CHUTES_API_KEY"],
zai: ["ZAI_API_KEY", "Z_AI_API_KEY"],
opencode: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
+ "opencode-go": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
"qwen-portal": ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"],
volcengine: ["VOLCANO_ENGINE_API_KEY"],
"volcengine-plan": ["VOLCANO_ENGINE_API_KEY"],
diff --git a/src/agents/model-auth-label.test.ts b/src/agents/model-auth-label.test.ts
index a46eebbbc34..41afd4bb426 100644
--- a/src/agents/model-auth-label.test.ts
+++ b/src/agents/model-auth-label.test.ts
@@ -12,7 +12,7 @@ vi.mock("./auth-profiles.js", () => ({
}));
vi.mock("./model-auth.js", () => ({
- getCustomProviderApiKey: () => undefined,
+ resolveUsableCustomProviderApiKey: () => null,
resolveEnvApiKey: () => null,
}));
diff --git a/src/agents/model-auth-label.ts b/src/agents/model-auth-label.ts
index ca564ab4dec..f28013c9825 100644
--- a/src/agents/model-auth-label.ts
+++ b/src/agents/model-auth-label.ts
@@ -5,7 +5,7 @@ import {
resolveAuthProfileDisplayLabel,
resolveAuthProfileOrder,
} from "./auth-profiles.js";
-import { getCustomProviderApiKey, resolveEnvApiKey } from "./model-auth.js";
+import { resolveEnvApiKey, resolveUsableCustomProviderApiKey } from "./model-auth.js";
import { normalizeProviderId } from "./model-selection.js";
export function resolveModelAuthLabel(params: {
@@ -59,7 +59,10 @@ export function resolveModelAuthLabel(params: {
return `api-key (${envKey.source})`;
}
- const customKey = getCustomProviderApiKey(params.cfg, providerKey);
+ const customKey = resolveUsableCustomProviderApiKey({
+ cfg: params.cfg,
+ provider: providerKey,
+ });
if (customKey) {
return `api-key (models.json)`;
}
diff --git a/src/agents/model-auth-markers.test.ts b/src/agents/model-auth-markers.test.ts
index e2225588df7..b90f1fd9ffa 100644
--- a/src/agents/model-auth-markers.test.ts
+++ b/src/agents/model-auth-markers.test.ts
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { listKnownProviderEnvApiKeyNames } from "./model-auth-env-vars.js";
-import { isNonSecretApiKeyMarker, NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
+import {
+ isKnownEnvApiKeyMarker,
+ isNonSecretApiKeyMarker,
+ NON_ENV_SECRETREF_MARKER,
+} from "./model-auth-markers.js";
describe("model auth markers", () => {
it("recognizes explicit non-secret markers", () => {
@@ -23,4 +27,9 @@ describe("model auth markers", () => {
it("can exclude env marker-name interpretation for display-only paths", () => {
expect(isNonSecretApiKeyMarker("OPENAI_API_KEY", { includeEnvVarName: false })).toBe(false);
});
+
+ it("excludes aws-sdk env markers from known api key env marker helper", () => {
+ expect(isKnownEnvApiKeyMarker("OPENAI_API_KEY")).toBe(true);
+ expect(isKnownEnvApiKeyMarker("AWS_PROFILE")).toBe(false);
+ });
});
diff --git a/src/agents/model-auth-markers.ts b/src/agents/model-auth-markers.ts
index 0b3b4960eb8..e888f06d0c5 100644
--- a/src/agents/model-auth-markers.ts
+++ b/src/agents/model-auth-markers.ts
@@ -35,6 +35,11 @@ export function isAwsSdkAuthMarker(value: string): boolean {
return AWS_SDK_ENV_MARKERS.has(value.trim());
}
+export function isKnownEnvApiKeyMarker(value: string): boolean {
+ const trimmed = value.trim();
+ return KNOWN_ENV_API_KEY_MARKERS.has(trimmed) && !isAwsSdkAuthMarker(trimmed);
+}
+
export function resolveNonEnvSecretRefApiKeyMarker(_source: SecretRefSource): string {
return NON_ENV_SECRETREF_MARKER;
}
diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts
index 24a881a63cd..a1fc511aaf8 100644
--- a/src/agents/model-auth.profiles.test.ts
+++ b/src/agents/model-auth.profiles.test.ts
@@ -412,4 +412,18 @@ describe("getApiKeyForModel", () => {
},
);
});
+
+ it("resolveEnvApiKey('opencode-go') falls back to OPENCODE_ZEN_API_KEY", async () => {
+ await withEnvAsync(
+ {
+ OPENCODE_API_KEY: undefined,
+ OPENCODE_ZEN_API_KEY: "sk-opencode-zen-fallback", // pragma: allowlist secret
+ },
+ async () => {
+ const resolved = resolveEnvApiKey("opencode-go");
+ expect(resolved?.apiKey).toBe("sk-opencode-zen-fallback");
+ expect(resolved?.source).toContain("OPENCODE_ZEN_API_KEY");
+ },
+ );
+ });
});
diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts
index 943070960d3..2deaeb7dbf6 100644
--- a/src/agents/model-auth.test.ts
+++ b/src/agents/model-auth.test.ts
@@ -1,6 +1,13 @@
import { describe, expect, it } from "vitest";
import type { AuthProfileStore } from "./auth-profiles.js";
-import { requireApiKey, resolveAwsSdkEnvVarName, resolveModelAuthMode } from "./model-auth.js";
+import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
+import {
+ hasUsableCustomProviderApiKey,
+ requireApiKey,
+ resolveAwsSdkEnvVarName,
+ resolveModelAuthMode,
+ resolveUsableCustomProviderApiKey,
+} from "./model-auth.js";
describe("resolveAwsSdkEnvVarName", () => {
it("prefers bearer token over access keys and profile", () => {
@@ -117,3 +124,102 @@ describe("requireApiKey", () => {
).toThrow('No API key resolved for provider "openai"');
});
});
+
+describe("resolveUsableCustomProviderApiKey", () => {
+ it("returns literal custom provider keys", () => {
+ const resolved = resolveUsableCustomProviderApiKey({
+ cfg: {
+ models: {
+ providers: {
+ custom: {
+ baseUrl: "https://example.com/v1",
+ apiKey: "sk-custom-runtime", // pragma: allowlist secret
+ models: [],
+ },
+ },
+ },
+ },
+ provider: "custom",
+ });
+ expect(resolved).toEqual({
+ apiKey: "sk-custom-runtime",
+ source: "models.json",
+ });
+ });
+
+ it("does not treat non-env markers as usable credentials", () => {
+ const resolved = resolveUsableCustomProviderApiKey({
+ cfg: {
+ models: {
+ providers: {
+ custom: {
+ baseUrl: "https://example.com/v1",
+ apiKey: NON_ENV_SECRETREF_MARKER,
+ models: [],
+ },
+ },
+ },
+ },
+ provider: "custom",
+ });
+ expect(resolved).toBeNull();
+ });
+
+ it("resolves known env marker names from process env for custom providers", () => {
+ const previous = process.env.OPENAI_API_KEY;
+ process.env.OPENAI_API_KEY = "sk-from-env"; // pragma: allowlist secret
+ try {
+ const resolved = resolveUsableCustomProviderApiKey({
+ cfg: {
+ models: {
+ providers: {
+ custom: {
+ baseUrl: "https://example.com/v1",
+ apiKey: "OPENAI_API_KEY",
+ models: [],
+ },
+ },
+ },
+ },
+ provider: "custom",
+ });
+ expect(resolved?.apiKey).toBe("sk-from-env");
+ expect(resolved?.source).toContain("OPENAI_API_KEY");
+ } finally {
+ if (previous === undefined) {
+ delete process.env.OPENAI_API_KEY;
+ } else {
+ process.env.OPENAI_API_KEY = previous;
+ }
+ }
+ });
+
+ it("does not treat known env marker names as usable when env value is missing", () => {
+ const previous = process.env.OPENAI_API_KEY;
+ delete process.env.OPENAI_API_KEY;
+ try {
+ expect(
+ hasUsableCustomProviderApiKey(
+ {
+ models: {
+ providers: {
+ custom: {
+ baseUrl: "https://example.com/v1",
+ apiKey: "OPENAI_API_KEY",
+ models: [],
+ },
+ },
+ },
+ },
+ "custom",
+ ),
+ ).toBe(false);
+ } finally {
+ if (previous === undefined) {
+ delete process.env.OPENAI_API_KEY;
+ } else {
+ process.env.OPENAI_API_KEY = previous;
+ }
+ }
+ });
+});
diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts
index aa94fddb02e..ffc7c1e2e9d 100644
--- a/src/agents/model-auth.ts
+++ b/src/agents/model-auth.ts
@@ -18,7 +18,11 @@ import {
resolveAuthStorePathForDisplay,
} from "./auth-profiles.js";
import { PROVIDER_ENV_API_KEY_CANDIDATES } from "./model-auth-env-vars.js";
-import { OLLAMA_LOCAL_AUTH_MARKER } from "./model-auth-markers.js";
+import {
+ isKnownEnvApiKeyMarker,
+ isNonSecretApiKeyMarker,
+ OLLAMA_LOCAL_AUTH_MARKER,
+} from "./model-auth-markers.js";
import { normalizeProviderId } from "./model-selection.js";
export { ensureAuthProfileStore, resolveAuthProfileOrder } from "./auth-profiles.js";
@@ -60,6 +64,49 @@ export function getCustomProviderApiKey(
return normalizeOptionalSecretInput(entry?.apiKey);
}
+type ResolvedCustomProviderApiKey = {
+ apiKey: string;
+ source: string;
+};
+
+export function resolveUsableCustomProviderApiKey(params: {
+ cfg: OpenClawConfig | undefined;
+ provider: string;
+ env?: NodeJS.ProcessEnv;
+}): ResolvedCustomProviderApiKey | null {
+ const customKey = getCustomProviderApiKey(params.cfg, params.provider);
+ if (!customKey) {
+ return null;
+ }
+ if (!isNonSecretApiKeyMarker(customKey)) {
+ return { apiKey: customKey, source: "models.json" };
+ }
+ if (!isKnownEnvApiKeyMarker(customKey)) {
+ return null;
+ }
+ const envValue = normalizeOptionalSecretInput((params.env ?? process.env)[customKey]);
+ if (!envValue) {
+ return null;
+ }
+ const applied = new Set(getShellEnvAppliedKeys());
+ return {
+ apiKey: envValue,
+ source: resolveEnvSourceLabel({
+ applied,
+ envVars: [customKey],
+ label: `${customKey} (models.json marker)`,
+ }),
+ };
+}
+
+export function hasUsableCustomProviderApiKey(
+ cfg: OpenClawConfig | undefined,
+ provider: string,
+ env?: NodeJS.ProcessEnv,
+): boolean {
+ return Boolean(resolveUsableCustomProviderApiKey({ cfg, provider, env }));
+}
+
function resolveProviderAuthOverride(
cfg: OpenClawConfig | undefined,
provider: string,
@@ -238,9 +285,9 @@ export async function resolveApiKeyForProvider(params: {
};
}
- const customKey = getCustomProviderApiKey(cfg, provider);
+ const customKey = resolveUsableCustomProviderApiKey({ cfg, provider });
if (customKey) {
- return { apiKey: customKey, source: "models.json", mode: "api-key" };
+ return { apiKey: customKey.apiKey, source: customKey.source, mode: "api-key" };
}
const syntheticLocalAuth = resolveSyntheticLocalProviderAuth({ cfg, provider });
@@ -360,7 +407,7 @@ export function resolveModelAuthMode(
return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key";
}
- if (getCustomProviderApiKey(cfg, resolved)) {
+ if (hasUsableCustomProviderApiKey(cfg, resolved)) {
return "api-key";
}
diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts
index 3c1894bb390..fc52ee2205e 100644
--- a/src/agents/model-compat.test.ts
+++ b/src/agents/model-compat.test.ts
@@ -313,6 +313,12 @@ describe("isModernModelRef", () => {
expect(isModernModelRef({ provider: "opencode", id: "claude-opus-4-6" })).toBe(true);
expect(isModernModelRef({ provider: "opencode", id: "gemini-3-pro" })).toBe(true);
});
+
+ it("accepts all opencode-go models without zen exclusions", () => {
+ expect(isModernModelRef({ provider: "opencode-go", id: "kimi-k2.5" })).toBe(true);
+ expect(isModernModelRef({ provider: "opencode-go", id: "glm-5" })).toBe(true);
+ expect(isModernModelRef({ provider: "opencode-go", id: "minimax-m2.5" })).toBe(true);
+ });
});
describe("resolveForwardCompatModel", () => {
diff --git a/src/agents/model-scan.ts b/src/agents/model-scan.ts
index a0f05e05475..dec46b4db21 100644
--- a/src/agents/model-scan.ts
+++ b/src/agents/model-scan.ts
@@ -326,12 +326,12 @@ async function probeImage(
}
function ensureImageInput(model: OpenAIModel): OpenAIModel {
- if (model.input.includes("image")) {
+ if (model.input?.includes("image")) {
return model;
}
return {
...model,
- input: Array.from(new Set([...model.input, "image"])),
+ input: Array.from(new Set([...(model.input ?? []), "image"])),
};
}
@@ -472,7 +472,7 @@ export async function scanOpenRouterModels(
};
const toolResult = await probeTool(model, apiKey, timeoutMs);
- const imageResult = model.input.includes("image")
+ const imageResult = model.input?.includes("image")
? await probeImage(ensureImageInput(model), apiKey, timeoutMs)
: { ok: false, latencyMs: null, skipped: true };
diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts
index 75df5ed22fa..205c2f1cce0 100644
--- a/src/agents/model-selection.ts
+++ b/src/agents/model-selection.ts
@@ -46,6 +46,9 @@ export function normalizeProviderId(provider: string): string {
if (normalized === "opencode-zen") {
return "opencode";
}
+ if (normalized === "opencode-go-auth") {
+ return "opencode-go";
+ }
if (normalized === "qwen") {
return "qwen-portal";
}
diff --git a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts
index ef03fb3863b..1d214e2cc1a 100644
--- a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts
+++ b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts
@@ -477,6 +477,51 @@ describe("models-config", () => {
});
});
+ it("replaces stale merged apiKey when config key normalizes to a known env marker", async () => {
+ await withEnvVar("OPENAI_API_KEY", "sk-plaintext-should-not-appear", async () => {
+ await withTempHome(async () => {
+ await writeAgentModelsJson({
+ providers: {
+ openai: {
+ baseUrl: "https://api.openai.com/v1",
+ apiKey: "STALE_AGENT_KEY", // pragma: allowlist secret
+ api: "openai-completions",
+ models: [{ id: "gpt-4.1", name: "GPT-4.1", input: ["text"] }],
+ },
+ },
+ });
+ const cfg: OpenClawConfig = {
+ models: {
+ mode: "merge",
+ providers: {
+ openai: {
+ baseUrl: "https://api.openai.com/v1",
+ apiKey: "sk-plaintext-should-not-appear", // pragma: allowlist secret; simulates resolved ${OPENAI_API_KEY}
+ api: "openai-completions",
+ models: [
+ {
+ id: "gpt-4.1",
+ name: "GPT-4.1",
+ input: ["text"],
+ reasoning: false,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 128000,
+ maxTokens: 16384,
+ },
+ ],
+ },
+ },
+ },
+ };
+ await ensureOpenClawModelsJson(cfg);
+ const result = await readGeneratedModelsJson<{
+ providers: Record;
+ }>();
+ expect(result.providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret
+ });
+ });
+ });
+
it("preserves explicit larger token limits when they exceed implicit catalog defaults", async () => {
await withTempHome(async () => {
await withEnvVar("MOONSHOT_API_KEY", "sk-moonshot-test", async () => {
diff --git a/src/agents/models-config.merge.test.ts b/src/agents/models-config.merge.test.ts
index 5e0483fdb59..60c3624c3c1 100644
--- a/src/agents/models-config.merge.test.ts
+++ b/src/agents/models-config.merge.test.ts
@@ -92,4 +92,25 @@ describe("models-config merge helpers", () => {
}),
);
});
+
+ it("does not preserve stale plaintext apiKey when next entry is a marker", () => {
+ const merged = mergeWithExistingProviderSecrets({
+ nextProviders: {
+ custom: {
+ apiKey: "OPENAI_API_KEY", // pragma: allowlist secret
+ models: [{ id: "model", api: "openai-responses" }],
+ } as ProviderConfig,
+ },
+ existingProviders: {
+ custom: {
+ apiKey: preservedApiKey,
+ models: [{ id: "model", api: "openai-responses" }],
+ } as ExistingProviderConfig,
+ },
+ secretRefManagedProviders: new Set(),
+ explicitBaseUrlProviders: new Set(),
+ });
+
+ expect(merged.custom?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret
+ });
});
diff --git a/src/agents/models-config.merge.ts b/src/agents/models-config.merge.ts
index da8a4abdaa2..e227ee413d5 100644
--- a/src/agents/models-config.merge.ts
+++ b/src/agents/models-config.merge.ts
@@ -148,9 +148,14 @@ function resolveProviderApiSurface(
function shouldPreserveExistingApiKey(params: {
providerKey: string;
existing: ExistingProviderConfig;
+ nextEntry: ProviderConfig;
secretRefManagedProviders: ReadonlySet;
}): boolean {
- const { providerKey, existing, secretRefManagedProviders } = params;
+ const { providerKey, existing, nextEntry, secretRefManagedProviders } = params;
+ const nextApiKey = typeof nextEntry.apiKey === "string" ? nextEntry.apiKey : "";
+ if (nextApiKey && isNonSecretApiKeyMarker(nextApiKey)) {
+ return false;
+ }
return (
!secretRefManagedProviders.has(providerKey) &&
typeof existing.apiKey === "string" &&
@@ -198,7 +203,14 @@ export function mergeWithExistingProviderSecrets(params: {
continue;
}
const preserved: Record = {};
- if (shouldPreserveExistingApiKey({ providerKey: key, existing, secretRefManagedProviders })) {
+ if (
+ shouldPreserveExistingApiKey({
+ providerKey: key,
+ existing,
+ nextEntry: newEntry,
+ secretRefManagedProviders,
+ })
+ ) {
preserved.apiKey = existing.apiKey;
}
if (
diff --git a/src/agents/models-config.providers.discovery.ts b/src/agents/models-config.providers.discovery.ts
index caab5cafb4e..dd0504d2a53 100644
--- a/src/agents/models-config.providers.discovery.ts
+++ b/src/agents/models-config.providers.discovery.ts
@@ -9,27 +9,26 @@ import {
buildHuggingfaceModelDefinition,
} from "./huggingface-models.js";
import { discoverKilocodeModels } from "./kilocode-models.js";
-import { OLLAMA_NATIVE_BASE_URL } from "./ollama-stream.js";
+import {
+ OLLAMA_DEFAULT_CONTEXT_WINDOW,
+ OLLAMA_DEFAULT_COST,
+ OLLAMA_DEFAULT_MAX_TOKENS,
+ isReasoningModelHeuristic,
+ resolveOllamaApiBase,
+ type OllamaTagsResponse,
+} from "./ollama-models.js";
import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js";
import { discoverVercelAiGatewayModels, VERCEL_AI_GATEWAY_BASE_URL } from "./vercel-ai-gateway.js";
+export { resolveOllamaApiBase } from "./ollama-models.js";
+
type ModelsConfig = NonNullable;
type ProviderConfig = NonNullable[string];
const log = createSubsystemLogger("agents/model-providers");
-const OLLAMA_BASE_URL = OLLAMA_NATIVE_BASE_URL;
-const OLLAMA_API_BASE_URL = OLLAMA_BASE_URL;
const OLLAMA_SHOW_CONCURRENCY = 8;
const OLLAMA_SHOW_MAX_MODELS = 200;
-const OLLAMA_DEFAULT_CONTEXT_WINDOW = 128000;
-const OLLAMA_DEFAULT_MAX_TOKENS = 8192;
-const OLLAMA_DEFAULT_COST = {
- input: 0,
- output: 0,
- cacheRead: 0,
- cacheWrite: 0,
-};
const VLLM_BASE_URL = "http://127.0.0.1:8000/v1";
const VLLM_DEFAULT_CONTEXT_WINDOW = 128000;
@@ -41,44 +40,12 @@ const VLLM_DEFAULT_COST = {
cacheWrite: 0,
};
-interface OllamaModel {
- name: string;
- modified_at: string;
- size: number;
- digest: string;
- details?: {
- family?: string;
- parameter_size?: string;
- };
-}
-
-interface OllamaTagsResponse {
- models: OllamaModel[];
-}
-
type VllmModelsResponse = {
data?: Array<{
id?: string;
}>;
};
-/**
- * Derive the Ollama native API base URL from a configured base URL.
- *
- * Users typically configure `baseUrl` with a `/v1` suffix (e.g.
- * `http://192.168.20.14:11434/v1`) for the OpenAI-compatible endpoint.
- * The native Ollama API lives at the root (e.g. `/api/tags`), so we
- * strip the `/v1` suffix when present.
- */
-export function resolveOllamaApiBase(configuredBaseUrl?: string): string {
- if (!configuredBaseUrl) {
- return OLLAMA_API_BASE_URL;
- }
- // Strip trailing slash, then strip /v1 suffix if present
- const trimmed = configuredBaseUrl.replace(/\/+$/, "");
- return trimmed.replace(/\/v1$/i, "");
-}
-
async function queryOllamaContextWindow(
apiBase: string,
modelName: string,
@@ -147,12 +114,10 @@ async function discoverOllamaModels(
batch.map(async (model) => {
const modelId = model.name;
const contextWindow = await queryOllamaContextWindow(apiBase, modelId);
- const isReasoning =
- modelId.toLowerCase().includes("r1") || modelId.toLowerCase().includes("reasoning");
return {
id: modelId,
name: modelId,
- reasoning: isReasoning,
+ reasoning: isReasoningModelHeuristic(modelId),
input: ["text"],
cost: OLLAMA_DEFAULT_COST,
contextWindow: contextWindow ?? OLLAMA_DEFAULT_CONTEXT_WINDOW,
@@ -204,13 +169,10 @@ async function discoverVllmModels(
.filter((model) => Boolean(model.id))
.map((model) => {
const modelId = model.id;
- const lower = modelId.toLowerCase();
- const isReasoning =
- lower.includes("r1") || lower.includes("reasoning") || lower.includes("think");
return {
id: modelId,
name: modelId,
- reasoning: isReasoning,
+ reasoning: isReasoningModelHeuristic(modelId),
input: ["text"],
cost: VLLM_DEFAULT_COST,
contextWindow: VLLM_DEFAULT_CONTEXT_WINDOW,
diff --git a/src/agents/models-config.providers.normalize-keys.test.ts b/src/agents/models-config.providers.normalize-keys.test.ts
index be92bbcd474..f8422d797dd 100644
--- a/src/agents/models-config.providers.normalize-keys.test.ts
+++ b/src/agents/models-config.providers.normalize-keys.test.ts
@@ -78,6 +78,7 @@ describe("normalizeProviders", () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-"));
const original = process.env.OPENAI_API_KEY;
process.env.OPENAI_API_KEY = "sk-test-secret-value-12345"; // pragma: allowlist secret
+ const secretRefManagedProviders = new Set();
try {
const providers: NonNullable["providers"]> = {
openai: {
@@ -97,8 +98,9 @@ describe("normalizeProviders", () => {
],
},
};
- const normalized = normalizeProviders({ providers, agentDir });
+ const normalized = normalizeProviders({ providers, agentDir, secretRefManagedProviders });
expect(normalized?.openai?.apiKey).toBe("OPENAI_API_KEY");
+ expect(secretRefManagedProviders.has("openai")).toBe(true);
} finally {
if (original === undefined) {
delete process.env.OPENAI_API_KEY;
diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts
index 54cbf69b182..c63ed6865a8 100644
--- a/src/agents/models-config.providers.ts
+++ b/src/agents/models-config.providers.ts
@@ -347,6 +347,9 @@ export function normalizeProviders(params: {
apiKey: normalizedConfiguredApiKey,
};
}
+ if (isNonSecretApiKeyMarker(normalizedConfiguredApiKey)) {
+ params.secretRefManagedProviders?.add(normalizedKey);
+ }
if (
profileApiKey &&
profileApiKey.source !== "plaintext" &&
@@ -370,6 +373,7 @@ export function normalizeProviders(params: {
if (envVarName && env[envVarName] === currentApiKey) {
mutated = true;
normalizedProvider = { ...normalizedProvider, apiKey: envVarName };
+ params.secretRefManagedProviders?.add(normalizedKey);
}
}
diff --git a/src/agents/models-config.runtime-source-snapshot.test.ts b/src/agents/models-config.runtime-source-snapshot.test.ts
index 6d6ea0284ee..4c5889769cc 100644
--- a/src/agents/models-config.runtime-source-snapshot.test.ts
+++ b/src/agents/models-config.runtime-source-snapshot.test.ts
@@ -101,6 +101,56 @@ describe("models-config runtime source snapshot", () => {
});
});
+ it("projects cloned runtime configs onto source snapshot when preserving provider auth", async () => {
+ await withTempHome(async () => {
+ const sourceConfig: OpenClawConfig = {
+ models: {
+ providers: {
+ openai: {
+ baseUrl: "https://api.openai.com/v1",
+ apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret
+ api: "openai-completions" as const,
+ models: [],
+ },
+ },
+ },
+ };
+ const runtimeConfig: OpenClawConfig = {
+ models: {
+ providers: {
+ openai: {
+ baseUrl: "https://api.openai.com/v1",
+ apiKey: "sk-runtime-resolved", // pragma: allowlist secret
+ api: "openai-completions" as const,
+ models: [],
+ },
+ },
+ },
+ };
+ const clonedRuntimeConfig: OpenClawConfig = {
+ ...runtimeConfig,
+ agents: {
+ defaults: {
+ imageModel: "openai/gpt-image-1",
+ },
+ },
+ };
+
+ try {
+ setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
+ await ensureOpenClawModelsJson(clonedRuntimeConfig);
+
+ const parsed = await readGeneratedModelsJson<{
+ providers: Record;
+ }>();
+ expect(parsed.providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret
+ } finally {
+ clearRuntimeConfigSnapshot();
+ clearConfigCache();
+ }
+ });
+ });
+
it("uses header markers from runtime source snapshot instead of resolved runtime values", async () => {
await withTempHome(async () => {
const sourceConfig: OpenClawConfig = {
diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts
index b9b8a7316d3..99714a1a792 100644
--- a/src/agents/models-config.ts
+++ b/src/agents/models-config.ts
@@ -1,8 +1,8 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
- getRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
+ projectConfigOntoRuntimeSourceSnapshot,
type OpenClawConfig,
loadConfig,
} from "../config/config.js";
@@ -44,17 +44,13 @@ async function writeModelsFileAtomic(targetPath: string, contents: string): Prom
function resolveModelsConfigInput(config?: OpenClawConfig): OpenClawConfig {
const runtimeSource = getRuntimeConfigSourceSnapshot();
- if (!runtimeSource) {
- return config ?? loadConfig();
- }
if (!config) {
- return runtimeSource;
+ return runtimeSource ?? loadConfig();
}
- const runtimeResolved = getRuntimeConfigSnapshot();
- if (runtimeResolved && config === runtimeResolved) {
- return runtimeSource;
+ if (!runtimeSource) {
+ return config;
}
- return config;
+ return projectConfigOntoRuntimeSourceSnapshot(config);
}
async function withModelsJsonWriteLock(targetPath: string, run: () => Promise): Promise {
diff --git a/src/agents/models.profiles.live.test.ts b/src/agents/models.profiles.live.test.ts
index 6386eaef158..81c7a64cb8c 100644
--- a/src/agents/models.profiles.live.test.ts
+++ b/src/agents/models.profiles.live.test.ts
@@ -9,10 +9,6 @@ import {
isAnthropicBillingError,
isAnthropicRateLimitError,
} from "./live-auth-keys.js";
-import {
- isMiniMaxModelNotFoundErrorMessage,
- isModelNotFoundErrorMessage,
-} from "./live-model-errors.js";
import { isModernModelRef } from "./live-model-filter.js";
import { getApiKeyForModel, requireApiKey } from "./model-auth.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
@@ -86,6 +82,35 @@ function isGoogleModelNotFoundError(err: unknown): boolean {
return false;
}
+function isModelNotFoundErrorMessage(raw: string): boolean {
+ const msg = raw.trim();
+ if (!msg) {
+ return false;
+ }
+ if (/\b404\b/.test(msg) && /not(?:[\s_-]+)?found/i.test(msg)) {
+ return true;
+ }
+ if (/not_found_error/i.test(msg)) {
+ return true;
+ }
+ if (/model:\s*[a-z0-9._-]+/i.test(msg) && /not(?:[\s_-]+)?found/i.test(msg)) {
+ return true;
+ }
+ return false;
+}
+
+describe("isModelNotFoundErrorMessage", () => {
+ it("matches whitespace-separated not found errors", () => {
+ expect(isModelNotFoundErrorMessage("404 model not found")).toBe(true);
+ expect(isModelNotFoundErrorMessage("model: minimax-text-01 not found")).toBe(true);
+ });
+
+ it("still matches underscore and hyphen variants", () => {
+ expect(isModelNotFoundErrorMessage("404 model not_found")).toBe(true);
+ expect(isModelNotFoundErrorMessage("404 model not-found")).toBe(true);
+ });
+});
+
function isChatGPTUsageLimitErrorMessage(raw: string): boolean {
const msg = raw.toLowerCase();
return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in");
@@ -475,11 +500,7 @@ describeLive("live models (profile keys)", () => {
if (ok.res.stopReason === "error") {
const msg = ok.res.errorMessage ?? "";
- if (
- allowNotFoundSkip &&
- (isModelNotFoundErrorMessage(msg) ||
- (model.provider === "minimax" && isMiniMaxModelNotFoundErrorMessage(msg)))
- ) {
+ if (allowNotFoundSkip && isModelNotFoundErrorMessage(msg)) {
skipped.push({ model: id, reason: msg });
logProgress(`${progressLabel}: skip (model not found)`);
break;
@@ -500,7 +521,9 @@ describeLive("live models (profile keys)", () => {
}
if (
ok.text.length === 0 &&
- (model.provider === "openrouter" || model.provider === "opencode")
+ (model.provider === "openrouter" ||
+ model.provider === "opencode" ||
+ model.provider === "opencode-go")
) {
skipped.push({
model: id,
@@ -563,15 +586,6 @@ describeLive("live models (profile keys)", () => {
logProgress(`${progressLabel}: skip (google model not found)`);
break;
}
- if (
- allowNotFoundSkip &&
- model.provider === "minimax" &&
- isMiniMaxModelNotFoundErrorMessage(message)
- ) {
- skipped.push({ model: id, reason: message });
- logProgress(`${progressLabel}: skip (model not found)`);
- break;
- }
if (
allowNotFoundSkip &&
model.provider === "minimax" &&
@@ -592,7 +606,7 @@ describeLive("live models (profile keys)", () => {
}
if (
allowNotFoundSkip &&
- model.provider === "opencode" &&
+ (model.provider === "opencode" || model.provider === "opencode-go") &&
isRateLimitErrorMessage(message)
) {
skipped.push({ model: id, reason: message });
diff --git a/src/agents/ollama-models.ts b/src/agents/ollama-models.ts
new file mode 100644
index 00000000000..19d95605203
--- /dev/null
+++ b/src/agents/ollama-models.ts
@@ -0,0 +1,85 @@
+import type { ModelDefinitionConfig } from "../config/types.models.js";
+import { OLLAMA_NATIVE_BASE_URL } from "./ollama-stream.js";
+
+export const OLLAMA_DEFAULT_BASE_URL = OLLAMA_NATIVE_BASE_URL;
+export const OLLAMA_DEFAULT_CONTEXT_WINDOW = 128000;
+export const OLLAMA_DEFAULT_MAX_TOKENS = 8192;
+export const OLLAMA_DEFAULT_COST = {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+};
+
+export type OllamaTagModel = {
+ name: string;
+ modified_at?: string;
+ size?: number;
+ digest?: string;
+ remote_host?: string;
+ details?: {
+ family?: string;
+ parameter_size?: string;
+ };
+};
+
+export type OllamaTagsResponse = {
+ models?: OllamaTagModel[];
+};
+
+/**
+ * Derive the Ollama native API base URL from a configured base URL.
+ *
+ * Users typically configure `baseUrl` with a `/v1` suffix (e.g.
+ * `http://192.168.20.14:11434/v1`) for the OpenAI-compatible endpoint.
+ * The native Ollama API lives at the root (e.g. `/api/tags`), so we
+ * strip the `/v1` suffix when present.
+ */
+export function resolveOllamaApiBase(configuredBaseUrl?: string): string {
+ if (!configuredBaseUrl) {
+ return OLLAMA_DEFAULT_BASE_URL;
+ }
+ const trimmed = configuredBaseUrl.replace(/\/+$/, "");
+ return trimmed.replace(/\/v1$/i, "");
+}
+
+/** Heuristic: treat models with "r1", "reasoning", or "think" in the name as reasoning models. */
+export function isReasoningModelHeuristic(modelId: string): boolean {
+ return /r1|reasoning|think|reason/i.test(modelId);
+}
+
+/** Build a ModelDefinitionConfig for an Ollama model with default values. */
+export function buildOllamaModelDefinition(
+ modelId: string,
+ contextWindow?: number,
+): ModelDefinitionConfig {
+ return {
+ id: modelId,
+ name: modelId,
+ reasoning: isReasoningModelHeuristic(modelId),
+ input: ["text"],
+ cost: OLLAMA_DEFAULT_COST,
+ contextWindow: contextWindow ?? OLLAMA_DEFAULT_CONTEXT_WINDOW,
+ maxTokens: OLLAMA_DEFAULT_MAX_TOKENS,
+ };
+}
+
+/** Fetch the model list from a running Ollama instance. */
+export async function fetchOllamaModels(
+ baseUrl: string,
+): Promise<{ reachable: boolean; models: OllamaTagModel[] }> {
+ try {
+ const apiBase = resolveOllamaApiBase(baseUrl);
+ const response = await fetch(`${apiBase}/api/tags`, {
+ signal: AbortSignal.timeout(5000),
+ });
+ if (!response.ok) {
+ return { reachable: true, models: [] };
+ }
+ const data = (await response.json()) as OllamaTagsResponse;
+ const models = (data.models ?? []).filter((m) => m.name);
+ return { reachable: true, models };
+ } catch {
+ return { reachable: false, models: [] };
+ }
+}
diff --git a/src/agents/openclaw-tools.camera.test.ts b/src/agents/openclaw-tools.camera.test.ts
index 83c4d3e48d6..5d3f14772fd 100644
--- a/src/agents/openclaw-tools.camera.test.ts
+++ b/src/agents/openclaw-tools.camera.test.ts
@@ -135,11 +135,10 @@ function setupNodeInvokeMock(params: {
function createSystemRunPreparePayload(cwd: string | null) {
return {
payload: {
- cmdText: "echo hi",
plan: {
argv: ["echo", "hi"],
cwd,
- rawCommand: "echo hi",
+ commandText: "echo hi",
agentId: null,
sessionKey: null,
},
@@ -662,10 +661,9 @@ describe("nodes run", () => {
onApprovalRequest: (approvalParams) => {
expect(approvalParams).toMatchObject({
id: expect.any(String),
- command: "echo hi",
- commandArgv: ["echo", "hi"],
systemRunPlan: expect.objectContaining({
argv: ["echo", "hi"],
+ commandText: "echo hi",
}),
nodeId: NODE_ID,
host: "node",
diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts
index dd361b70e67..db45e8d48b8 100644
--- a/src/agents/openclaw-tools.session-status.test.ts
+++ b/src/agents/openclaw-tools.session-status.test.ts
@@ -63,7 +63,7 @@ vi.mock("../agents/auth-profiles.js", () => ({
vi.mock("../agents/model-auth.js", () => ({
resolveEnvApiKey: () => null,
- getCustomProviderApiKey: () => null,
+ resolveUsableCustomProviderApiKey: () => null,
resolveModelAuthMode: () => "api-key",
}));
diff --git a/src/agents/openclaw-tools.subagents.scope.test.ts b/src/agents/openclaw-tools.subagents.scope.test.ts
new file mode 100644
index 00000000000..c985f1712e1
--- /dev/null
+++ b/src/agents/openclaw-tools.subagents.scope.test.ts
@@ -0,0 +1,245 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { beforeEach, describe, expect, it } from "vitest";
+import {
+ callGatewayMock,
+ resetSubagentsConfigOverride,
+ setSubagentsConfigOverride,
+} from "./openclaw-tools.subagents.test-harness.js";
+import { addSubagentRunForTests, resetSubagentRegistryForTests } from "./subagent-registry.js";
+import "./test-helpers/fast-core-tools.js";
+import { createPerSenderSessionConfig } from "./test-helpers/session-config.js";
+import { createSubagentsTool } from "./tools/subagents-tool.js";
+
+function writeStore(storePath: string, store: Record) {
+ fs.mkdirSync(path.dirname(storePath), { recursive: true });
+ fs.writeFileSync(storePath, JSON.stringify(store, null, 2), "utf-8");
+}
+
+describe("openclaw-tools: subagents scope isolation", () => {
+ let storePath = "";
+
+ beforeEach(() => {
+ resetSubagentRegistryForTests();
+ resetSubagentsConfigOverride();
+ callGatewayMock.mockReset();
+ storePath = path.join(
+ os.tmpdir(),
+ `openclaw-subagents-scope-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
+ );
+ setSubagentsConfigOverride({
+ session: createPerSenderSessionConfig({ store: storePath }),
+ });
+ writeStore(storePath, {});
+ });
+
+ it("leaf subagents do not inherit parent sibling control scope", async () => {
+ const leafKey = "agent:main:subagent:leaf";
+ const siblingKey = "agent:main:subagent:unsandboxed";
+
+ writeStore(storePath, {
+ [leafKey]: {
+ sessionId: "leaf-session",
+ updatedAt: Date.now(),
+ spawnedBy: "agent:main:main",
+ },
+ [siblingKey]: {
+ sessionId: "sibling-session",
+ updatedAt: Date.now(),
+ spawnedBy: "agent:main:main",
+ },
+ });
+
+ addSubagentRunForTests({
+ runId: "run-leaf",
+ childSessionKey: leafKey,
+ requesterSessionKey: "agent:main:main",
+ requesterDisplayKey: "main",
+ task: "sandboxed leaf",
+ cleanup: "keep",
+ createdAt: Date.now() - 30_000,
+ startedAt: Date.now() - 30_000,
+ });
+ addSubagentRunForTests({
+ runId: "run-sibling",
+ childSessionKey: siblingKey,
+ requesterSessionKey: "agent:main:main",
+ requesterDisplayKey: "main",
+ task: "unsandboxed sibling",
+ cleanup: "keep",
+ createdAt: Date.now() - 20_000,
+ startedAt: Date.now() - 20_000,
+ });
+
+ const tool = createSubagentsTool({ agentSessionKey: leafKey });
+ const result = await tool.execute("call-leaf-list", { action: "list" });
+
+ expect(result.details).toMatchObject({
+ status: "ok",
+ requesterSessionKey: leafKey,
+ callerSessionKey: leafKey,
+ callerIsSubagent: true,
+ total: 0,
+ active: [],
+ recent: [],
+ });
+ expect(callGatewayMock).not.toHaveBeenCalled();
+ });
+
+ it("orchestrator subagents still see children they spawned", async () => {
+ const orchestratorKey = "agent:main:subagent:orchestrator";
+ const workerKey = `${orchestratorKey}:subagent:worker`;
+ const siblingKey = "agent:main:subagent:sibling";
+
+ writeStore(storePath, {
+ [orchestratorKey]: {
+ sessionId: "orchestrator-session",
+ updatedAt: Date.now(),
+ spawnedBy: "agent:main:main",
+ },
+ [workerKey]: {
+ sessionId: "worker-session",
+ updatedAt: Date.now(),
+ spawnedBy: orchestratorKey,
+ },
+ [siblingKey]: {
+ sessionId: "sibling-session",
+ updatedAt: Date.now(),
+ spawnedBy: "agent:main:main",
+ },
+ });
+
+ addSubagentRunForTests({
+ runId: "run-worker",
+ childSessionKey: workerKey,
+ requesterSessionKey: orchestratorKey,
+ requesterDisplayKey: orchestratorKey,
+ task: "worker child",
+ cleanup: "keep",
+ createdAt: Date.now() - 30_000,
+ startedAt: Date.now() - 30_000,
+ });
+ addSubagentRunForTests({
+ runId: "run-sibling",
+ childSessionKey: siblingKey,
+ requesterSessionKey: "agent:main:main",
+ requesterDisplayKey: "main",
+ task: "sibling of orchestrator",
+ cleanup: "keep",
+ createdAt: Date.now() - 20_000,
+ startedAt: Date.now() - 20_000,
+ });
+
+ const tool = createSubagentsTool({ agentSessionKey: orchestratorKey });
+ const result = await tool.execute("call-orchestrator-list", { action: "list" });
+ const details = result.details as {
+ status?: string;
+ requesterSessionKey?: string;
+ total?: number;
+ active?: Array<{ sessionKey?: string }>;
+ };
+
+ expect(details.status).toBe("ok");
+ expect(details.requesterSessionKey).toBe(orchestratorKey);
+ expect(details.total).toBe(1);
+ expect(details.active).toEqual([
+ expect.objectContaining({
+ sessionKey: workerKey,
+ }),
+ ]);
+ });
+
+ it("leaf subagents cannot kill even explicitly-owned child sessions", async () => {
+ const leafKey = "agent:main:subagent:leaf";
+ const childKey = `${leafKey}:subagent:child`;
+
+ writeStore(storePath, {
+ [leafKey]: {
+ sessionId: "leaf-session",
+ updatedAt: Date.now(),
+ spawnedBy: "agent:main:main",
+ subagentRole: "leaf",
+ subagentControlScope: "none",
+ },
+ [childKey]: {
+ sessionId: "child-session",
+ updatedAt: Date.now(),
+ spawnedBy: leafKey,
+ subagentRole: "leaf",
+ subagentControlScope: "none",
+ },
+ });
+
+ addSubagentRunForTests({
+ runId: "run-child",
+ childSessionKey: childKey,
+ controllerSessionKey: leafKey,
+ requesterSessionKey: leafKey,
+ requesterDisplayKey: leafKey,
+ task: "impossible child",
+ cleanup: "keep",
+ createdAt: Date.now() - 30_000,
+ startedAt: Date.now() - 30_000,
+ });
+
+ const tool = createSubagentsTool({ agentSessionKey: leafKey });
+ const result = await tool.execute("call-leaf-kill", {
+ action: "kill",
+ target: childKey,
+ });
+
+ expect(result.details).toMatchObject({
+ status: "forbidden",
+ error: "Leaf subagents cannot control other sessions.",
+ });
+ expect(callGatewayMock).not.toHaveBeenCalled();
+ });
+
+ it("leaf subagents cannot steer even explicitly-owned child sessions", async () => {
+ const leafKey = "agent:main:subagent:leaf";
+ const childKey = `${leafKey}:subagent:child`;
+
+ writeStore(storePath, {
+ [leafKey]: {
+ sessionId: "leaf-session",
+ updatedAt: Date.now(),
+ spawnedBy: "agent:main:main",
+ subagentRole: "leaf",
+ subagentControlScope: "none",
+ },
+ [childKey]: {
+ sessionId: "child-session",
+ updatedAt: Date.now(),
+ spawnedBy: leafKey,
+ subagentRole: "leaf",
+ subagentControlScope: "none",
+ },
+ });
+
+ addSubagentRunForTests({
+ runId: "run-child",
+ childSessionKey: childKey,
+ controllerSessionKey: leafKey,
+ requesterSessionKey: leafKey,
+ requesterDisplayKey: leafKey,
+ task: "impossible child",
+ cleanup: "keep",
+ createdAt: Date.now() - 30_000,
+ startedAt: Date.now() - 30_000,
+ });
+
+ const tool = createSubagentsTool({ agentSessionKey: leafKey });
+ const result = await tool.execute("call-leaf-steer", {
+ action: "steer",
+ target: childKey,
+ message: "continue",
+ });
+
+ expect(result.details).toMatchObject({
+ status: "forbidden",
+ error: "Leaf subagents cannot control other sessions.",
+ });
+ expect(callGatewayMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts
index 7a5b93d7ae1..b9c86bf7472 100644
--- a/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts
+++ b/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts
@@ -116,6 +116,8 @@ describe("sessions_spawn depth + child limits", () => {
(entry) => entry.method === "sessions.patch" && entry.params?.spawnDepth === 2,
);
expect(spawnDepthPatch?.params?.key).toMatch(/^agent:main:subagent:/);
+ expect(spawnDepthPatch?.params?.subagentRole).toBe("leaf");
+ expect(spawnDepthPatch?.params?.subagentControlScope).toBe("none");
});
it("rejects depth-2 callers when maxSpawnDepth is 2 (using stored spawnDepth on flat keys)", async () => {
diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts
index 608483b99bf..0430bd3814b 100644
--- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts
+++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts
@@ -439,6 +439,18 @@ describe("isLikelyContextOverflowError", () => {
expect(isLikelyContextOverflowError(sample)).toBe(false);
}
});
+
+ it("excludes billing errors even when text matches context overflow patterns", () => {
+ const samples = [
+ "402 Payment Required: request token limit exceeded for this billing plan",
+ "insufficient credits: request size exceeds your current plan limits",
+ "Your credit balance is too low. Maximum request token limit exceeded.",
+ ];
+ for (const sample of samples) {
+ expect(isBillingErrorMessage(sample)).toBe(true);
+ expect(isLikelyContextOverflowError(sample)).toBe(false);
+ }
+ });
});
describe("isTransientHttpError", () => {
diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts
index 181ba89d8ce..e9bfd92951e 100644
--- a/src/agents/pi-embedded-helpers/errors.ts
+++ b/src/agents/pi-embedded-helpers/errors.ts
@@ -138,6 +138,13 @@ export function isLikelyContextOverflowError(errorMessage?: string): boolean {
return false;
}
+ // Billing/quota errors can contain patterns like "request size exceeds" or
+ // "maximum token limit exceeded" that match the context overflow heuristic.
+ // Billing is a more specific error class — exclude it early.
+ if (isBillingErrorMessage(errorMessage)) {
+ return false;
+ }
+
if (CONTEXT_WINDOW_TOO_SMALL_RE.test(errorMessage)) {
return false;
}
diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts
index 232cdfcaa0b..500df72cced 100644
--- a/src/agents/pi-embedded-runner-extraparams.test.ts
+++ b/src/agents/pi-embedded-runner-extraparams.test.ts
@@ -1449,6 +1449,20 @@ describe("applyExtraParamsToAgent", () => {
expect(payload.store).toBe(true);
});
+ it("forces store=true for azure-openai provider with openai-responses API (#42800)", () => {
+ const payload = runResponsesPayloadMutationCase({
+ applyProvider: "azure-openai",
+ applyModelId: "gpt-5-mini",
+ model: {
+ api: "openai-responses",
+ provider: "azure-openai",
+ id: "gpt-5-mini",
+ baseUrl: "https://myresource.openai.azure.com/openai/v1",
+ } as unknown as Model<"openai-responses">,
+ });
+ expect(payload.store).toBe(true);
+ });
+
it("injects configured OpenAI service_tier into Responses payloads", () => {
const payload = runResponsesPayloadMutationCase({
applyProvider: "openai",
diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts
index 60b34684866..5789dfaad75 100644
--- a/src/agents/pi-embedded-runner/model.test.ts
+++ b/src/agents/pi-embedded-runner/model.test.ts
@@ -180,7 +180,7 @@ describe("buildInlineProviderModels", () => {
expect(result[0].headers).toBeUndefined();
});
- it("preserves literal marker-shaped headers in inline provider models", () => {
+ it("drops SecretRef marker headers in inline provider models", () => {
const providers: Parameters[0] = {
custom: {
headers: {
@@ -196,14 +196,48 @@ describe("buildInlineProviderModels", () => {
expect(result).toHaveLength(1);
expect(result[0].headers).toEqual({
- Authorization: "secretref-env:OPENAI_HEADER_TOKEN",
- "X-Managed": "secretref-managed",
"X-Static": "tenant-a",
});
});
});
describe("resolveModel", () => {
+ it("defaults model input to text when discovery omits input", () => {
+ mockDiscoveredModel({
+ provider: "custom",
+ modelId: "missing-input",
+ templateModel: {
+ id: "missing-input",
+ name: "missing-input",
+ api: "openai-completions",
+ provider: "custom",
+ baseUrl: "http://localhost:9999",
+ reasoning: false,
+ // NOTE: deliberately omit input to simulate buggy/custom catalogs.
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 8192,
+ maxTokens: 1024,
+ },
+ });
+
+ const result = resolveModel("custom", "missing-input", "/tmp/agent", {
+ models: {
+ providers: {
+ custom: {
+ baseUrl: "http://localhost:9999",
+ api: "openai-completions",
+ // Intentionally keep this minimal — the discovered model provides the rest.
+ models: [{ id: "missing-input", name: "missing-input" }],
+ },
+ },
+ },
+ } as unknown as OpenClawConfig);
+
+ expect(result.error).toBeUndefined();
+ expect(Array.isArray(result.model?.input)).toBe(true);
+ expect(result.model?.input).toEqual(["text"]);
+ });
+
it("includes provider baseUrl in fallback model", () => {
const cfg = {
models: {
@@ -245,7 +279,7 @@ describe("resolveModel", () => {
});
});
- it("preserves literal marker-shaped provider headers in fallback models", () => {
+ it("drops SecretRef marker provider headers in fallback models", () => {
const cfg = {
models: {
providers: {
@@ -266,8 +300,6 @@ describe("resolveModel", () => {
expect(result.error).toBeUndefined();
expect((result.model as unknown as { headers?: Record }).headers).toEqual({
- Authorization: "secretref-env:OPENAI_HEADER_TOKEN",
- "X-Managed": "secretref-managed",
"X-Custom-Auth": "token-123",
});
});
diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts
index 638d66f787f..eb9fa675b8a 100644
--- a/src/agents/pi-embedded-runner/model.ts
+++ b/src/agents/pi-embedded-runner/model.ts
@@ -81,20 +81,30 @@ function applyConfiguredProviderOverrides(params: {
const discoveredHeaders = sanitizeModelHeaders(discoveredModel.headers, {
stripSecretRefMarkers: true,
});
- const providerHeaders = sanitizeModelHeaders(providerConfig.headers);
- const configuredHeaders = sanitizeModelHeaders(configuredModel?.headers);
+ const providerHeaders = sanitizeModelHeaders(providerConfig.headers, {
+ stripSecretRefMarkers: true,
+ });
+ const configuredHeaders = sanitizeModelHeaders(configuredModel?.headers, {
+ stripSecretRefMarkers: true,
+ });
if (!configuredModel && !providerConfig.baseUrl && !providerConfig.api && !providerHeaders) {
return {
...discoveredModel,
headers: discoveredHeaders,
};
}
+ const resolvedInput = configuredModel?.input ?? discoveredModel.input;
+ const normalizedInput =
+ Array.isArray(resolvedInput) && resolvedInput.length > 0
+ ? resolvedInput.filter((item) => item === "text" || item === "image")
+ : (["text"] as Array<"text" | "image">);
+
return {
...discoveredModel,
api: configuredModel?.api ?? providerConfig.api ?? discoveredModel.api,
baseUrl: providerConfig.baseUrl ?? discoveredModel.baseUrl,
reasoning: configuredModel?.reasoning ?? discoveredModel.reasoning,
- input: configuredModel?.input ?? discoveredModel.input,
+ input: normalizedInput,
cost: configuredModel?.cost ?? discoveredModel.cost,
contextWindow: configuredModel?.contextWindow ?? discoveredModel.contextWindow,
maxTokens: configuredModel?.maxTokens ?? discoveredModel.maxTokens,
@@ -118,14 +128,18 @@ export function buildInlineProviderModels(
if (!trimmed) {
return [];
}
- const providerHeaders = sanitizeModelHeaders(entry?.headers);
+ const providerHeaders = sanitizeModelHeaders(entry?.headers, {
+ stripSecretRefMarkers: true,
+ });
return (entry?.models ?? []).map((model) => ({
...model,
provider: trimmed,
baseUrl: entry?.baseUrl,
api: model.api ?? entry?.api,
headers: (() => {
- const modelHeaders = sanitizeModelHeaders((model as InlineModelEntry).headers);
+ const modelHeaders = sanitizeModelHeaders((model as InlineModelEntry).headers, {
+ stripSecretRefMarkers: true,
+ });
if (!providerHeaders && !modelHeaders) {
return undefined;
}
@@ -205,8 +219,12 @@ export function resolveModelWithRegistry(params: {
}
const configuredModel = providerConfig?.models?.find((candidate) => candidate.id === modelId);
- const providerHeaders = sanitizeModelHeaders(providerConfig?.headers);
- const modelHeaders = sanitizeModelHeaders(configuredModel?.headers);
+ const providerHeaders = sanitizeModelHeaders(providerConfig?.headers, {
+ stripSecretRefMarkers: true,
+ });
+ const modelHeaders = sanitizeModelHeaders(configuredModel?.headers, {
+ stripSecretRefMarkers: true,
+ });
if (providerConfig || modelId.startsWith("mock-")) {
return normalizeResolvedModel({
provider,
diff --git a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts
index 3fc46dac0ae..dfe42ff1835 100644
--- a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts
+++ b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts
@@ -6,7 +6,7 @@ import { log } from "./logger.js";
type OpenAIServiceTier = "auto" | "default" | "flex" | "priority";
const OPENAI_RESPONSES_APIS = new Set(["openai-responses"]);
-const OPENAI_RESPONSES_PROVIDERS = new Set(["openai", "azure-openai-responses"]);
+const OPENAI_RESPONSES_PROVIDERS = new Set(["openai", "azure-openai", "azure-openai-responses"]);
function isDirectOpenAIBaseUrl(baseUrl: unknown): boolean {
if (typeof baseUrl !== "string" || !baseUrl.trim()) {
diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts
index 0341ee97587..3801231f1f2 100644
--- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts
+++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts
@@ -79,6 +79,7 @@ vi.mock("../../../infra/machine-name.js", () => ({
}));
vi.mock("../../../infra/net/undici-global-dispatcher.js", () => ({
+ ensureGlobalUndiciEnvProxyDispatcher: () => {},
ensureGlobalUndiciStreamTimeouts: () => {},
}));
diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts
index 084a6d39746..0014475a880 100644
--- a/src/agents/pi-embedded-runner/run/attempt.ts
+++ b/src/agents/pi-embedded-runner/run/attempt.ts
@@ -11,7 +11,10 @@ import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js";
import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js";
import type { OpenClawConfig } from "../../../config/config.js";
import { getMachineDisplayName } from "../../../infra/machine-name.js";
-import { ensureGlobalUndiciStreamTimeouts } from "../../../infra/net/undici-global-dispatcher.js";
+import {
+ ensureGlobalUndiciEnvProxyDispatcher,
+ ensureGlobalUndiciStreamTimeouts,
+} from "../../../infra/net/undici-global-dispatcher.js";
import { MAX_IMAGE_BYTES } from "../../../media/constants.js";
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
import type {
@@ -749,6 +752,9 @@ export async function runEmbeddedAttempt(
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
const prevCwd = process.cwd();
const runAbortController = new AbortController();
+ // Proxy bootstrap must happen before timeout tuning so the timeouts wrap the
+ // active EnvHttpProxyAgent instead of being replaced by a bare proxy dispatcher.
+ ensureGlobalUndiciEnvProxyDispatcher();
ensureGlobalUndiciStreamTimeouts();
log.debug(
diff --git a/src/agents/pi-embedded-runner/run/history-image-prune.test.ts b/src/agents/pi-embedded-runner/run/history-image-prune.test.ts
index bf4b27f5beb..dbed0335435 100644
--- a/src/agents/pi-embedded-runner/run/history-image-prune.test.ts
+++ b/src/agents/pi-embedded-runner/run/history-image-prune.test.ts
@@ -49,6 +49,30 @@ describe("pruneProcessedHistoryImages", () => {
expect(first.content[1]).toMatchObject({ type: "image", data: "abc" });
});
+ it("prunes image blocks from toolResult messages that already have assistant replies", () => {
+ const messages: AgentMessage[] = [
+ castAgentMessage({
+ role: "toolResult",
+ toolName: "read",
+ content: [{ type: "text", text: "screenshot bytes" }, { ...image }],
+ }),
+ castAgentMessage({
+ role: "assistant",
+ content: "ack",
+ }),
+ ];
+
+ const didMutate = pruneProcessedHistoryImages(messages);
+
+ expect(didMutate).toBe(true);
+ const firstTool = messages[0] as Extract | undefined;
+ if (!firstTool || !Array.isArray(firstTool.content)) {
+ throw new Error("expected toolResult array content");
+ }
+ expect(firstTool.content).toHaveLength(2);
+ expect(firstTool.content[1]).toMatchObject({ type: "text", text: PRUNED_HISTORY_IMAGE_MARKER });
+ });
+
it("does not change messages when no assistant turn exists", () => {
const messages: AgentMessage[] = [
castAgentMessage({
diff --git a/src/agents/pi-embedded-runner/run/history-image-prune.ts b/src/agents/pi-embedded-runner/run/history-image-prune.ts
index d7dbea5de38..4e92bb08f01 100644
--- a/src/agents/pi-embedded-runner/run/history-image-prune.ts
+++ b/src/agents/pi-embedded-runner/run/history-image-prune.ts
@@ -21,7 +21,11 @@ export function pruneProcessedHistoryImages(messages: AgentMessage[]): boolean {
let didMutate = false;
for (let i = 0; i < lastAssistantIndex; i++) {
const message = messages[i];
- if (!message || message.role !== "user" || !Array.isArray(message.content)) {
+ if (
+ !message ||
+ (message.role !== "user" && message.role !== "toolResult") ||
+ !Array.isArray(message.content)
+ ) {
continue;
}
for (let j = 0; j < message.content.length; j++) {
diff --git a/src/agents/pi-extensions/context-pruning.test.ts b/src/agents/pi-extensions/context-pruning.test.ts
index 7812f5db00a..9dedff97def 100644
--- a/src/agents/pi-extensions/context-pruning.test.ts
+++ b/src/agents/pi-extensions/context-pruning.test.ts
@@ -358,21 +358,26 @@ describe("context-pruning", () => {
expect(toolText(findToolResult(next, "t2"))).toContain("y".repeat(20_000));
});
- it("skips tool results that contain images (no soft trim, no hard clear)", () => {
+ it("replaces image blocks in tool results during soft trim", () => {
const messages: AgentMessage[] = [
makeUser("u1"),
makeImageToolResult({
toolCallId: "t1",
toolName: "exec",
- text: "x".repeat(20_000),
+ text: "visible tool text",
}),
];
- const next = pruneWithAggressiveDefaults(messages);
+ const next = pruneWithAggressiveDefaults(messages, {
+ hardClearRatio: 10.0,
+ hardClear: { enabled: false, placeholder: "[cleared]" },
+ softTrim: { maxChars: 200, headChars: 100, tailChars: 100 },
+ });
const tool = findToolResult(next, "t1");
- expect(tool.content.some((b) => b.type === "image")).toBe(true);
- expect(toolText(tool)).toContain("x".repeat(20_000));
+ expect(tool.content.some((b) => b.type === "image")).toBe(false);
+ expect(toolText(tool)).toContain("[image removed during context pruning]");
+ expect(toolText(tool)).toContain("visible tool text");
});
it("soft-trims across block boundaries", () => {
diff --git a/src/agents/pi-extensions/context-pruning/pruner.test.ts b/src/agents/pi-extensions/context-pruning/pruner.test.ts
index 3985bb2feb1..a847bff0e8c 100644
--- a/src/agents/pi-extensions/context-pruning/pruner.test.ts
+++ b/src/agents/pi-extensions/context-pruning/pruner.test.ts
@@ -45,6 +45,19 @@ function makeAssistant(content: AssistantMessage["content"]): AgentMessage {
};
}
+function makeToolResult(
+ content: Array<
+ { type: "text"; text: string } | { type: "image"; data: string; mimeType: string }
+ >,
+): AgentMessage {
+ return {
+ role: "toolResult",
+ toolName: "read",
+ content,
+ timestamp: Date.now(),
+ } as AgentMessage;
+}
+
describe("pruneContextMessages", () => {
it("does not crash on assistant message with malformed thinking block (missing thinking string)", () => {
const messages: AgentMessage[] = [
@@ -109,4 +122,119 @@ describe("pruneContextMessages", () => {
});
expect(result).toHaveLength(2);
});
+
+ it("soft-trims image-containing tool results by replacing image blocks with placeholders", () => {
+ const messages: AgentMessage[] = [
+ makeUser("summarize this"),
+ makeToolResult([
+ { type: "text", text: "A".repeat(120) },
+ { type: "image", data: "img", mimeType: "image/png" },
+ { type: "text", text: "B".repeat(120) },
+ ]),
+ makeAssistant([{ type: "text", text: "done" }]),
+ ];
+
+ const result = pruneContextMessages({
+ messages,
+ settings: {
+ ...DEFAULT_CONTEXT_PRUNING_SETTINGS,
+ keepLastAssistants: 1,
+ softTrimRatio: 0,
+ hardClear: {
+ ...DEFAULT_CONTEXT_PRUNING_SETTINGS.hardClear,
+ enabled: false,
+ },
+ softTrim: {
+ maxChars: 200,
+ headChars: 170,
+ tailChars: 30,
+ },
+ },
+ ctx: CONTEXT_WINDOW_1M,
+ isToolPrunable: () => true,
+ contextWindowTokensOverride: 16,
+ });
+
+ const toolResult = result[1] as Extract;
+ expect(toolResult.content).toHaveLength(1);
+ expect(toolResult.content[0]).toMatchObject({ type: "text" });
+ const textBlock = toolResult.content[0] as { type: "text"; text: string };
+ expect(textBlock.text).toContain("[image removed during context pruning]");
+ expect(textBlock.text).toContain(
+ "[Tool result trimmed: kept first 170 chars and last 30 chars",
+ );
+ });
+
+ it("replaces image-only tool results with placeholders even when text trimming is not needed", () => {
+ const messages: AgentMessage[] = [
+ makeUser("summarize this"),
+ makeToolResult([{ type: "image", data: "img", mimeType: "image/png" }]),
+ makeAssistant([{ type: "text", text: "done" }]),
+ ];
+
+ const result = pruneContextMessages({
+ messages,
+ settings: {
+ ...DEFAULT_CONTEXT_PRUNING_SETTINGS,
+ keepLastAssistants: 1,
+ softTrimRatio: 0,
+ hardClearRatio: 10,
+ hardClear: {
+ ...DEFAULT_CONTEXT_PRUNING_SETTINGS.hardClear,
+ enabled: false,
+ },
+ softTrim: {
+ maxChars: 5_000,
+ headChars: 2_000,
+ tailChars: 2_000,
+ },
+ },
+ ctx: CONTEXT_WINDOW_1M,
+ isToolPrunable: () => true,
+ contextWindowTokensOverride: 1,
+ });
+
+ const toolResult = result[1] as Extract;
+ expect(toolResult.content).toEqual([
+ { type: "text", text: "[image removed during context pruning]" },
+ ]);
+ });
+
+ it("hard-clears image-containing tool results once ratios require clearing", () => {
+ const messages: AgentMessage[] = [
+ makeUser("summarize this"),
+ makeToolResult([
+ { type: "text", text: "small text" },
+ { type: "image", data: "img", mimeType: "image/png" },
+ ]),
+ makeAssistant([{ type: "text", text: "done" }]),
+ ];
+
+ const placeholder = "[hard cleared test placeholder]";
+ const result = pruneContextMessages({
+ messages,
+ settings: {
+ ...DEFAULT_CONTEXT_PRUNING_SETTINGS,
+ keepLastAssistants: 1,
+ softTrimRatio: 0,
+ hardClearRatio: 0,
+ minPrunableToolChars: 1,
+ softTrim: {
+ maxChars: 5_000,
+ headChars: 2_000,
+ tailChars: 2_000,
+ },
+ hardClear: {
+ enabled: true,
+ placeholder,
+ },
+ },
+ ctx: CONTEXT_WINDOW_1M,
+ isToolPrunable: () => true,
+ contextWindowTokensOverride: 8,
+ });
+
+ const toolResult = result[1] as Extract;
+ expect(toolResult.content).toEqual([{ type: "text", text: placeholder }]);
+ });
});
diff --git a/src/agents/pi-extensions/context-pruning/pruner.ts b/src/agents/pi-extensions/context-pruning/pruner.ts
index c195fa79e09..a0f4458f6d4 100644
--- a/src/agents/pi-extensions/context-pruning/pruner.ts
+++ b/src/agents/pi-extensions/context-pruning/pruner.ts
@@ -5,9 +5,8 @@ import type { EffectiveContextPruningSettings } from "./settings.js";
import { makeToolPrunablePredicate } from "./tools.js";
const CHARS_PER_TOKEN_ESTIMATE = 4;
-// We currently skip pruning tool results that contain images. Still, we count them (approx.) so
-// we start trimming prunable tool results earlier when image-heavy context is consuming the window.
const IMAGE_CHAR_ESTIMATE = 8_000;
+const PRUNED_CONTEXT_IMAGE_MARKER = "[image removed during context pruning]";
function asText(text: string): TextContent {
return { type: "text", text };
@@ -23,6 +22,22 @@ function collectTextSegments(content: ReadonlyArray)
return parts;
}
+function collectPrunableToolResultSegments(
+ content: ReadonlyArray,
+): string[] {
+ const parts: string[] = [];
+ for (const block of content) {
+ if (block.type === "text") {
+ parts.push(block.text);
+ continue;
+ }
+ if (block.type === "image") {
+ parts.push(PRUNED_CONTEXT_IMAGE_MARKER);
+ }
+ }
+ return parts;
+}
+
function estimateJoinedTextLength(parts: string[]): number {
if (parts.length === 0) {
return 0;
@@ -190,21 +205,25 @@ function softTrimToolResultMessage(params: {
settings: EffectiveContextPruningSettings;
}): ToolResultMessage | null {
const { msg, settings } = params;
- // Ignore image tool results for now: these are often directly relevant and hard to partially prune safely.
- if (hasImageBlocks(msg.content)) {
- return null;
- }
-
- const parts = collectTextSegments(msg.content);
+ const hasImages = hasImageBlocks(msg.content);
+ const parts = hasImages
+ ? collectPrunableToolResultSegments(msg.content)
+ : collectTextSegments(msg.content);
const rawLen = estimateJoinedTextLength(parts);
if (rawLen <= settings.softTrim.maxChars) {
- return null;
+ if (!hasImages) {
+ return null;
+ }
+ return { ...msg, content: [asText(parts.join("\n"))] };
}
const headChars = Math.max(0, settings.softTrim.headChars);
const tailChars = Math.max(0, settings.softTrim.tailChars);
if (headChars + tailChars >= rawLen) {
- return null;
+ if (!hasImages) {
+ return null;
+ }
+ return { ...msg, content: [asText(parts.join("\n"))] };
}
const head = takeHeadFromJoinedText(parts, headChars);
@@ -274,9 +293,6 @@ export function pruneContextMessages(params: {
if (!isToolPrunable(msg.toolName)) {
continue;
}
- if (hasImageBlocks(msg.content)) {
- continue;
- }
prunableToolIndexes.push(i);
const updated = softTrimToolResultMessage({
diff --git a/src/agents/pi-tools.policy.test.ts b/src/agents/pi-tools.policy.test.ts
index 0cdc572c448..846044c41c0 100644
--- a/src/agents/pi-tools.policy.test.ts
+++ b/src/agents/pi-tools.policy.test.ts
@@ -1,3 +1,6 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
@@ -5,6 +8,7 @@ import {
isToolAllowedByPolicyName,
resolveEffectiveToolPolicy,
resolveSubagentToolPolicy,
+ resolveSubagentToolPolicyForSession,
} from "./pi-tools.policy.js";
import { createStubTool } from "./test-helpers/pi-tool-stubs.js";
@@ -144,9 +148,9 @@ describe("resolveSubagentToolPolicy depth awareness", () => {
expect(isToolAllowedByPolicyName("sessions_spawn", policy)).toBe(false);
});
- it("depth-2 leaf allows subagents (for visibility)", () => {
+ it("depth-2 leaf denies subagents", () => {
const policy = resolveSubagentToolPolicy(baseCfg, 2);
- expect(isToolAllowedByPolicyName("subagents", policy)).toBe(true);
+ expect(isToolAllowedByPolicyName("subagents", policy)).toBe(false);
});
it("depth-2 leaf denies sessions_list and sessions_history", () => {
@@ -165,6 +169,41 @@ describe("resolveSubagentToolPolicy depth awareness", () => {
expect(isToolAllowedByPolicyName("sessions_list", policy)).toBe(false);
});
+ it("uses stored leaf role for flat depth-1 session keys", () => {
+ const storePath = path.join(
+ os.tmpdir(),
+ `openclaw-subagent-policy-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
+ );
+ fs.mkdirSync(path.dirname(storePath), { recursive: true });
+ fs.writeFileSync(
+ storePath,
+ JSON.stringify(
+ {
+ "agent:main:subagent:flat-leaf": {
+ sessionId: "flat-leaf",
+ updatedAt: Date.now(),
+ spawnDepth: 1,
+ subagentRole: "leaf",
+ subagentControlScope: "none",
+ },
+ },
+ null,
+ 2,
+ ),
+ "utf-8",
+ );
+ const cfg = {
+ ...baseCfg,
+ session: {
+ store: storePath,
+ },
+ } as unknown as OpenClawConfig;
+
+ const policy = resolveSubagentToolPolicyForSession(cfg, "agent:main:subagent:flat-leaf");
+ expect(isToolAllowedByPolicyName("sessions_spawn", policy)).toBe(false);
+ expect(isToolAllowedByPolicyName("subagents", policy)).toBe(false);
+ });
+
it("defaults to leaf behavior when no depth is provided", () => {
const policy = resolveSubagentToolPolicy(baseCfg);
// Default depth=1, maxSpawnDepth=2 → orchestrator
diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts
index 61d037dd9f3..0353c454865 100644
--- a/src/agents/pi-tools.policy.ts
+++ b/src/agents/pi-tools.policy.ts
@@ -11,6 +11,10 @@ import { compileGlobPatterns, matchesAnyGlobPattern } from "./glob-pattern.js";
import type { AnyAgentTool } from "./pi-tools.types.js";
import { pickSandboxToolPolicy } from "./sandbox-tool-policy.js";
import type { SandboxToolPolicy } from "./sandbox.js";
+import {
+ resolveStoredSubagentCapabilities,
+ type SubagentSessionRole,
+} from "./subagent-capabilities.js";
import { expandToolGroups, normalizeToolName } from "./tool-policy.js";
function makeToolPolicyMatcher(policy: SandboxToolPolicy) {
@@ -64,15 +68,20 @@ const SUBAGENT_TOOL_DENY_ALWAYS = [
* Additional tools denied for leaf sub-agents (depth >= maxSpawnDepth).
* These are tools that only make sense for orchestrator sub-agents that can spawn children.
*/
-const SUBAGENT_TOOL_DENY_LEAF = ["sessions_list", "sessions_history", "sessions_spawn"];
+const SUBAGENT_TOOL_DENY_LEAF = [
+ "subagents",
+ "sessions_list",
+ "sessions_history",
+ "sessions_spawn",
+];
/**
* Build the deny list for a sub-agent at a given depth.
*
* - Depth 1 with maxSpawnDepth >= 2 (orchestrator): allowed to use sessions_spawn,
* subagents, sessions_list, sessions_history so it can manage its children.
- * - Depth >= maxSpawnDepth (leaf): denied sessions_spawn and
- * session management tools. Still allowed subagents (for list/status visibility).
+ * - Depth >= maxSpawnDepth (leaf): denied subagents, sessions_spawn, and
+ * session management tools.
*/
function resolveSubagentDenyList(depth: number, maxSpawnDepth: number): string[] {
const isLeaf = depth >= Math.max(1, Math.floor(maxSpawnDepth));
@@ -84,6 +93,13 @@ function resolveSubagentDenyList(depth: number, maxSpawnDepth: number): string[]
return [...SUBAGENT_TOOL_DENY_ALWAYS];
}
+function resolveSubagentDenyListForRole(role: SubagentSessionRole): string[] {
+ if (role === "leaf") {
+ return [...SUBAGENT_TOOL_DENY_ALWAYS, ...SUBAGENT_TOOL_DENY_LEAF];
+ }
+ return [...SUBAGENT_TOOL_DENY_ALWAYS];
+}
+
export function resolveSubagentToolPolicy(cfg?: OpenClawConfig, depth?: number): SandboxToolPolicy {
const configured = cfg?.tools?.subagents?.tools;
const maxSpawnDepth =
@@ -103,6 +119,27 @@ export function resolveSubagentToolPolicy(cfg?: OpenClawConfig, depth?: number):
return { allow: mergedAllow, deny };
}
+export function resolveSubagentToolPolicyForSession(
+ cfg: OpenClawConfig | undefined,
+ sessionKey: string,
+): SandboxToolPolicy {
+ const configured = cfg?.tools?.subagents?.tools;
+ const capabilities = resolveStoredSubagentCapabilities(sessionKey, { cfg });
+ const allow = Array.isArray(configured?.allow) ? configured.allow : undefined;
+ const alsoAllow = Array.isArray(configured?.alsoAllow) ? configured.alsoAllow : undefined;
+ const explicitAllow = new Set(
+ [...(allow ?? []), ...(alsoAllow ?? [])].map((toolName) => normalizeToolName(toolName)),
+ );
+ const deny = [
+ ...resolveSubagentDenyListForRole(capabilities.role).filter(
+ (toolName) => !explicitAllow.has(normalizeToolName(toolName)),
+ ),
+ ...(Array.isArray(configured?.deny) ? configured.deny : []),
+ ];
+ const mergedAllow = allow && alsoAllow ? Array.from(new Set([...allow, ...alsoAllow])) : allow;
+ return { allow: mergedAllow, deny };
+}
+
export function isToolAllowedByPolicyName(name: string, policy?: SandboxToolPolicy): boolean {
if (!policy) {
return true;
diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts
index ff71b53baf4..a89aff3d9dd 100644
--- a/src/agents/pi-tools.ts
+++ b/src/agents/pi-tools.ts
@@ -24,7 +24,7 @@ import {
isToolAllowedByPolicies,
resolveEffectiveToolPolicy,
resolveGroupToolPolicy,
- resolveSubagentToolPolicy,
+ resolveSubagentToolPolicyForSession,
} from "./pi-tools.policy.js";
import {
assertRequiredParams,
@@ -45,7 +45,6 @@ import { cleanToolSchemaForGemini, normalizeToolParameters } from "./pi-tools.sc
import type { AnyAgentTool } from "./pi-tools.types.js";
import type { SandboxContext } from "./sandbox.js";
import { isXaiProvider } from "./schema/clean-for-xai.js";
-import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
import { createToolFsPolicy, resolveToolFsConfig } from "./tool-fs-policy.js";
import {
applyToolPolicyPipeline,
@@ -321,10 +320,7 @@ export function createOpenClawCodingTools(options?: {
options?.exec?.scopeKey ?? options?.sessionKey ?? (agentId ? `agent:${agentId}` : undefined);
const subagentPolicy =
isSubagentSessionKey(options?.sessionKey) && options?.sessionKey
- ? resolveSubagentToolPolicy(
- options.config,
- getSubagentDepthFromSessionStore(options.sessionKey, { cfg: options.config }),
- )
+ ? resolveSubagentToolPolicyForSession(options.config, options.sessionKey)
: undefined;
const allowBackground = isToolAllowedByPolicies("process", [
profilePolicyWithAlsoAllow,
diff --git a/src/agents/provider-capabilities.test.ts b/src/agents/provider-capabilities.test.ts
index 5e162c87794..90d2b52ff5a 100644
--- a/src/agents/provider-capabilities.test.ts
+++ b/src/agents/provider-capabilities.test.ts
@@ -47,6 +47,7 @@ describe("resolveProviderCapabilities", () => {
it("flags providers that opt out of OpenAI-compatible turn validation", () => {
expect(supportsOpenAiCompatTurnValidation("openrouter")).toBe(false);
expect(supportsOpenAiCompatTurnValidation("opencode")).toBe(false);
+ expect(supportsOpenAiCompatTurnValidation("opencode-go")).toBe(false);
expect(supportsOpenAiCompatTurnValidation("moonshot")).toBe(true);
});
@@ -63,6 +64,12 @@ describe("resolveProviderCapabilities", () => {
modelId: "gemini-2.0-flash",
}),
).toBe(true);
+ expect(
+ shouldSanitizeGeminiThoughtSignaturesForModel({
+ provider: "opencode-go",
+ modelId: "google/gemini-2.5-pro-preview",
+ }),
+ ).toBe(true);
expect(resolveTranscriptToolCallIdMode("mistral", "mistral-large-latest")).toBe("strict9");
});
diff --git a/src/agents/provider-capabilities.ts b/src/agents/provider-capabilities.ts
index 62007b810f8..27aadbcd7d3 100644
--- a/src/agents/provider-capabilities.ts
+++ b/src/agents/provider-capabilities.ts
@@ -66,6 +66,11 @@ const PROVIDER_CAPABILITIES: Record> = {
geminiThoughtSignatureSanitization: true,
geminiThoughtSignatureModelHints: ["gemini"],
},
+ "opencode-go": {
+ openAiCompatTurnValidation: false,
+ geminiThoughtSignatureSanitization: true,
+ geminiThoughtSignatureModelHints: ["gemini"],
+ },
kilocode: {
geminiThoughtSignatureSanitization: true,
geminiThoughtSignatureModelHints: ["gemini"],
diff --git a/src/agents/sandbox-create-args.test.ts b/src/agents/sandbox-create-args.test.ts
index 0d9621ad9e1..60b6241f58a 100644
--- a/src/agents/sandbox-create-args.test.ts
+++ b/src/agents/sandbox-create-args.test.ts
@@ -137,6 +137,33 @@ describe("buildSandboxCreateArgs", () => {
);
});
+ it("preserves the OpenClaw exec marker when strict env sanitization is enabled", () => {
+ const cfg = createSandboxConfig({
+ env: {
+ NODE_ENV: "test",
+ },
+ });
+
+ const args = buildSandboxCreateArgs({
+ name: "openclaw-sbx-marker",
+ cfg,
+ scopeKey: "main",
+ createdAtMs: 1700000000000,
+ envSanitizationOptions: {
+ strictMode: true,
+ },
+ });
+
+ expect(args).toEqual(
+ expect.arrayContaining([
+ "--env",
+ "NODE_ENV=test",
+ "--env",
+ `OPENCLAW_CLI=${OPENCLAW_CLI_ENV_VALUE}`,
+ ]),
+ );
+ });
+
it("emits -v flags for safe custom binds", () => {
const cfg: SandboxDockerConfig = {
image: "openclaw-sandbox:bookworm-slim",
diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts
index 68c95e343ea..aefceb08495 100644
--- a/src/agents/sandbox/docker.ts
+++ b/src/agents/sandbox/docker.ts
@@ -5,6 +5,7 @@ import {
resolveWindowsSpawnProgram,
} from "../../plugin-sdk/windows-spawn.js";
import { sanitizeEnvVars } from "./sanitize-env-vars.js";
+import type { EnvSanitizationOptions } from "./sanitize-env-vars.js";
type ExecDockerRawOptions = {
allowFailure?: boolean;
@@ -52,7 +53,7 @@ export function resolveDockerSpawnInvocation(
env: runtime.env,
execPath: runtime.execPath,
packageName: "docker",
- allowShellFallback: true,
+ allowShellFallback: false,
});
const resolved = materializeWindowsSpawnProgram(program, args);
return {
@@ -325,6 +326,7 @@ export function buildSandboxCreateArgs(params: {
allowSourcesOutsideAllowedRoots?: boolean;
allowReservedContainerTargets?: boolean;
allowContainerNamespaceJoin?: boolean;
+ envSanitizationOptions?: EnvSanitizationOptions;
}) {
// Runtime security validation: blocks dangerous bind mounts, network modes, and profiles.
validateSandboxSecurity({
@@ -366,14 +368,14 @@ export function buildSandboxCreateArgs(params: {
if (params.cfg.user) {
args.push("--user", params.cfg.user);
}
- const envSanitization = sanitizeEnvVars(markOpenClawExecEnv(params.cfg.env ?? {}));
+ const envSanitization = sanitizeEnvVars(params.cfg.env ?? {}, params.envSanitizationOptions);
if (envSanitization.blocked.length > 0) {
log.warn(`Blocked sensitive environment variables: ${envSanitization.blocked.join(", ")}`);
}
if (envSanitization.warnings.length > 0) {
log.warn(`Suspicious environment variables: ${envSanitization.warnings.join(", ")}`);
}
- for (const [key, value] of Object.entries(envSanitization.allowed)) {
+ for (const [key, value] of Object.entries(markOpenClawExecEnv(envSanitization.allowed))) {
args.push("--env", `${key}=${value}`);
}
for (const cap of params.cfg.capDrop) {
diff --git a/src/agents/sandbox/docker.windows.test.ts b/src/agents/sandbox/docker.windows.test.ts
index 3dd294e8360..7abebad98ab 100644
--- a/src/agents/sandbox/docker.windows.test.ts
+++ b/src/agents/sandbox/docker.windows.test.ts
@@ -47,22 +47,20 @@ describe("resolveDockerSpawnInvocation", () => {
});
});
- it("falls back to shell mode when only unresolved docker.cmd wrapper exists", async () => {
+ it("rejects unresolved docker.cmd wrappers instead of shelling out", async () => {
const dir = await createTempDir();
const cmdPath = path.join(dir, "docker.cmd");
await mkdir(path.dirname(cmdPath), { recursive: true });
await writeFile(cmdPath, "@ECHO off\r\necho docker\r\n", "utf8");
- const resolved = resolveDockerSpawnInvocation(["ps"], {
- platform: "win32",
- env: { PATH: dir, PATHEXT: ".CMD;.EXE;.BAT" },
- execPath: "C:\\node\\node.exe",
- });
- expect(path.normalize(resolved.command).toLowerCase()).toBe(
- path.normalize(cmdPath).toLowerCase(),
+ expect(() =>
+ resolveDockerSpawnInvocation(["ps"], {
+ platform: "win32",
+ env: { PATH: dir, PATHEXT: ".CMD;.EXE;.BAT" },
+ execPath: "C:\\node\\node.exe",
+ }),
+ ).toThrow(
+ /wrapper resolved, but no executable\/Node entrypoint could be resolved without shell execution\./i,
);
- expect(resolved.args).toEqual(["ps"]);
- expect(resolved.shell).toBe(true);
- expect(resolved.windowsHide).toBeUndefined();
});
});
diff --git a/src/agents/sandbox/fs-bridge-mutation-helper.test.ts b/src/agents/sandbox/fs-bridge-mutation-helper.test.ts
new file mode 100644
index 00000000000..f2d3974f0cc
--- /dev/null
+++ b/src/agents/sandbox/fs-bridge-mutation-helper.test.ts
@@ -0,0 +1,143 @@
+import { spawnSync } from "node:child_process";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+import { SANDBOX_PINNED_MUTATION_PYTHON } from "./fs-bridge-mutation-helper.js";
+
+async function withTempRoot(prefix: string, run: (root: string) => Promise): Promise {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
+ try {
+ return await run(root);
+ } finally {
+ await fs.rm(root, { recursive: true, force: true });
+ }
+}
+
+function runMutation(args: string[], input?: string) {
+ return spawnSync("python3", ["-c", SANDBOX_PINNED_MUTATION_PYTHON, ...args], {
+ input,
+ encoding: "utf8",
+ stdio: ["pipe", "pipe", "pipe"],
+ });
+}
+
+describe("sandbox pinned mutation helper", () => {
+ it("writes through a pinned directory fd", async () => {
+ await withTempRoot("openclaw-mutation-helper-", async (root) => {
+ const workspace = path.join(root, "workspace");
+ await fs.mkdir(workspace, { recursive: true });
+
+ const result = runMutation(["write", workspace, "nested/deeper", "note.txt", "1"], "hello");
+
+ expect(result.status).toBe(0);
+ await expect(
+ fs.readFile(path.join(workspace, "nested", "deeper", "note.txt"), "utf8"),
+ ).resolves.toBe("hello");
+ });
+ });
+
+ it.runIf(process.platform !== "win32")(
+ "rejects symlink-parent writes instead of materializing a temp file outside the mount",
+ async () => {
+ await withTempRoot("openclaw-mutation-helper-", async (root) => {
+ const workspace = path.join(root, "workspace");
+ const outside = path.join(root, "outside");
+ await fs.mkdir(workspace, { recursive: true });
+ await fs.mkdir(outside, { recursive: true });
+ await fs.symlink(outside, path.join(workspace, "alias"));
+
+ const result = runMutation(["write", workspace, "alias", "escape.txt", "0"], "owned");
+
+ expect(result.status).not.toBe(0);
+ await expect(fs.readFile(path.join(outside, "escape.txt"), "utf8")).rejects.toThrow();
+ });
+ },
+ );
+
+ it.runIf(process.platform !== "win32")("rejects symlink segments during mkdirp", async () => {
+ await withTempRoot("openclaw-mutation-helper-", async (root) => {
+ const workspace = path.join(root, "workspace");
+ const outside = path.join(root, "outside");
+ await fs.mkdir(workspace, { recursive: true });
+ await fs.mkdir(outside, { recursive: true });
+ await fs.symlink(outside, path.join(workspace, "alias"));
+
+ const result = runMutation(["mkdirp", workspace, "alias/nested"]);
+
+ expect(result.status).not.toBe(0);
+ await expect(fs.readFile(path.join(outside, "nested"), "utf8")).rejects.toThrow();
+ });
+ });
+
+ it.runIf(process.platform !== "win32")("remove unlinks the symlink itself", async () => {
+ await withTempRoot("openclaw-mutation-helper-", async (root) => {
+ const workspace = path.join(root, "workspace");
+ const outside = path.join(root, "outside");
+ await fs.mkdir(workspace, { recursive: true });
+ await fs.mkdir(outside, { recursive: true });
+ await fs.writeFile(path.join(outside, "secret.txt"), "classified", "utf8");
+ await fs.symlink(path.join(outside, "secret.txt"), path.join(workspace, "link.txt"));
+
+ const result = runMutation(["remove", workspace, "", "link.txt", "0", "0"]);
+
+ expect(result.status).toBe(0);
+ await expect(fs.readlink(path.join(workspace, "link.txt"))).rejects.toThrow();
+ await expect(fs.readFile(path.join(outside, "secret.txt"), "utf8")).resolves.toBe(
+ "classified",
+ );
+ });
+ });
+
+ it.runIf(process.platform !== "win32")(
+ "rejects symlink destination parents during rename",
+ async () => {
+ await withTempRoot("openclaw-mutation-helper-", async (root) => {
+ const workspace = path.join(root, "workspace");
+ const outside = path.join(root, "outside");
+ await fs.mkdir(workspace, { recursive: true });
+ await fs.mkdir(outside, { recursive: true });
+ await fs.writeFile(path.join(workspace, "from.txt"), "payload", "utf8");
+ await fs.symlink(outside, path.join(workspace, "alias"));
+
+ const result = runMutation([
+ "rename",
+ workspace,
+ "",
+ "from.txt",
+ workspace,
+ "alias",
+ "escape.txt",
+ "1",
+ ]);
+
+ expect(result.status).not.toBe(0);
+ await expect(fs.readFile(path.join(workspace, "from.txt"), "utf8")).resolves.toBe(
+ "payload",
+ );
+ await expect(fs.readFile(path.join(outside, "escape.txt"), "utf8")).rejects.toThrow();
+ });
+ },
+ );
+
+ it.runIf(process.platform !== "win32")(
+ "copies directories across different mount roots during rename fallback",
+ async () => {
+ await withTempRoot("openclaw-mutation-helper-", async (root) => {
+ const sourceRoot = path.join(root, "source");
+ const destRoot = path.join(root, "dest");
+ await fs.mkdir(path.join(sourceRoot, "dir", "nested"), { recursive: true });
+ await fs.mkdir(destRoot, { recursive: true });
+ await fs.writeFile(path.join(sourceRoot, "dir", "nested", "file.txt"), "payload", "utf8");
+
+ const result = runMutation(["rename", sourceRoot, "", "dir", destRoot, "", "moved", "1"]);
+
+ expect(result.status).toBe(0);
+ await expect(
+ fs.readFile(path.join(destRoot, "moved", "nested", "file.txt"), "utf8"),
+ ).resolves.toBe("payload");
+ await expect(fs.stat(path.join(sourceRoot, "dir"))).rejects.toThrow();
+ });
+ },
+ );
+});
diff --git a/src/agents/sandbox/fs-bridge-mutation-helper.ts b/src/agents/sandbox/fs-bridge-mutation-helper.ts
new file mode 100644
index 00000000000..fc50c5ab756
--- /dev/null
+++ b/src/agents/sandbox/fs-bridge-mutation-helper.ts
@@ -0,0 +1,347 @@
+import { PATH_ALIAS_POLICIES } from "../../infra/path-alias-guards.js";
+import type {
+ PathSafetyCheck,
+ PinnedSandboxDirectoryEntry,
+ PinnedSandboxEntry,
+} from "./fs-bridge-path-safety.js";
+import type { SandboxFsCommandPlan } from "./fs-bridge-shell-command-plans.js";
+
+export const SANDBOX_PINNED_MUTATION_PYTHON = [
+ "import errno",
+ "import os",
+ "import secrets",
+ "import stat",
+ "import sys",
+ "",
+ "operation = sys.argv[1]",
+ "",
+ "DIR_FLAGS = os.O_RDONLY",
+ "if hasattr(os, 'O_DIRECTORY'):",
+ " DIR_FLAGS |= os.O_DIRECTORY",
+ "if hasattr(os, 'O_NOFOLLOW'):",
+ " DIR_FLAGS |= os.O_NOFOLLOW",
+ "",
+ "READ_FLAGS = os.O_RDONLY",
+ "if hasattr(os, 'O_NOFOLLOW'):",
+ " READ_FLAGS |= os.O_NOFOLLOW",
+ "",
+ "WRITE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL",
+ "if hasattr(os, 'O_NOFOLLOW'):",
+ " WRITE_FLAGS |= os.O_NOFOLLOW",
+ "",
+ "def split_relative(path_value):",
+ " segments = []",
+ " for segment in path_value.split('/'):",
+ " if not segment or segment == '.':",
+ " continue",
+ " if segment == '..':",
+ " raise OSError(errno.EPERM, 'path traversal is not allowed', segment)",
+ " segments.append(segment)",
+ " return segments",
+ "",
+ "def open_dir(path_value, dir_fd=None):",
+ " return os.open(path_value, DIR_FLAGS, dir_fd=dir_fd)",
+ "",
+ "def walk_dir(root_fd, rel_path, mkdir_enabled):",
+ " current_fd = os.dup(root_fd)",
+ " try:",
+ " for segment in split_relative(rel_path):",
+ " try:",
+ " next_fd = open_dir(segment, dir_fd=current_fd)",
+ " except FileNotFoundError:",
+ " if not mkdir_enabled:",
+ " raise",
+ " os.mkdir(segment, 0o777, dir_fd=current_fd)",
+ " next_fd = open_dir(segment, dir_fd=current_fd)",
+ " os.close(current_fd)",
+ " current_fd = next_fd",
+ " return current_fd",
+ " except Exception:",
+ " os.close(current_fd)",
+ " raise",
+ "",
+ "def create_temp_file(parent_fd, basename):",
+ " prefix = '.openclaw-write-' + basename + '.'",
+ " for _ in range(128):",
+ " candidate = prefix + secrets.token_hex(6)",
+ " try:",
+ " fd = os.open(candidate, WRITE_FLAGS, 0o600, dir_fd=parent_fd)",
+ " return candidate, fd",
+ " except FileExistsError:",
+ " continue",
+ " raise RuntimeError('failed to allocate sandbox temp file')",
+ "",
+ "def create_temp_dir(parent_fd, basename, mode):",
+ " prefix = '.openclaw-move-' + basename + '.'",
+ " for _ in range(128):",
+ " candidate = prefix + secrets.token_hex(6)",
+ " try:",
+ " os.mkdir(candidate, mode, dir_fd=parent_fd)",
+ " return candidate",
+ " except FileExistsError:",
+ " continue",
+ " raise RuntimeError('failed to allocate sandbox temp directory')",
+ "",
+ "def write_atomic(parent_fd, basename, stdin_buffer):",
+ " temp_fd = None",
+ " temp_name = None",
+ " try:",
+ " temp_name, temp_fd = create_temp_file(parent_fd, basename)",
+ " while True:",
+ " chunk = stdin_buffer.read(65536)",
+ " if not chunk:",
+ " break",
+ " os.write(temp_fd, chunk)",
+ " os.fsync(temp_fd)",
+ " os.close(temp_fd)",
+ " temp_fd = None",
+ " os.replace(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)",
+ " temp_name = None",
+ " os.fsync(parent_fd)",
+ " finally:",
+ " if temp_fd is not None:",
+ " os.close(temp_fd)",
+ " if temp_name is not None:",
+ " try:",
+ " os.unlink(temp_name, dir_fd=parent_fd)",
+ " except FileNotFoundError:",
+ " pass",
+ "",
+ "def remove_tree(parent_fd, basename):",
+ " entry_stat = os.lstat(basename, dir_fd=parent_fd)",
+ " if not stat.S_ISDIR(entry_stat.st_mode) or stat.S_ISLNK(entry_stat.st_mode):",
+ " os.unlink(basename, dir_fd=parent_fd)",
+ " return",
+ " dir_fd = open_dir(basename, dir_fd=parent_fd)",
+ " try:",
+ " for child in os.listdir(dir_fd):",
+ " remove_tree(dir_fd, child)",
+ " finally:",
+ " os.close(dir_fd)",
+ " os.rmdir(basename, dir_fd=parent_fd)",
+ "",
+ "def move_entry(src_parent_fd, src_basename, dst_parent_fd, dst_basename):",
+ " try:",
+ " os.rename(src_basename, dst_basename, src_dir_fd=src_parent_fd, dst_dir_fd=dst_parent_fd)",
+ " os.fsync(dst_parent_fd)",
+ " os.fsync(src_parent_fd)",
+ " return",
+ " except OSError as err:",
+ " if err.errno != errno.EXDEV:",
+ " raise",
+ " src_stat = os.lstat(src_basename, dir_fd=src_parent_fd)",
+ " if stat.S_ISDIR(src_stat.st_mode) and not stat.S_ISLNK(src_stat.st_mode):",
+ " temp_dir_name = create_temp_dir(dst_parent_fd, dst_basename, stat.S_IMODE(src_stat.st_mode) or 0o755)",
+ " temp_dir_fd = open_dir(temp_dir_name, dir_fd=dst_parent_fd)",
+ " src_dir_fd = open_dir(src_basename, dir_fd=src_parent_fd)",
+ " try:",
+ " for child in os.listdir(src_dir_fd):",
+ " move_entry(src_dir_fd, child, temp_dir_fd, child)",
+ " finally:",
+ " os.close(src_dir_fd)",
+ " os.close(temp_dir_fd)",
+ " os.rename(temp_dir_name, dst_basename, src_dir_fd=dst_parent_fd, dst_dir_fd=dst_parent_fd)",
+ " os.rmdir(src_basename, dir_fd=src_parent_fd)",
+ " os.fsync(dst_parent_fd)",
+ " os.fsync(src_parent_fd)",
+ " return",
+ " if stat.S_ISLNK(src_stat.st_mode):",
+ " link_target = os.readlink(src_basename, dir_fd=src_parent_fd)",
+ " try:",
+ " os.unlink(dst_basename, dir_fd=dst_parent_fd)",
+ " except FileNotFoundError:",
+ " pass",
+ " os.symlink(link_target, dst_basename, dir_fd=dst_parent_fd)",
+ " os.unlink(src_basename, dir_fd=src_parent_fd)",
+ " os.fsync(dst_parent_fd)",
+ " os.fsync(src_parent_fd)",
+ " return",
+ " src_fd = os.open(src_basename, READ_FLAGS, dir_fd=src_parent_fd)",
+ " temp_fd = None",
+ " temp_name = None",
+ " try:",
+ " temp_name, temp_fd = create_temp_file(dst_parent_fd, dst_basename)",
+ " while True:",
+ " chunk = os.read(src_fd, 65536)",
+ " if not chunk:",
+ " break",
+ " os.write(temp_fd, chunk)",
+ " try:",
+ " os.fchmod(temp_fd, stat.S_IMODE(src_stat.st_mode))",
+ " except AttributeError:",
+ " pass",
+ " os.fsync(temp_fd)",
+ " os.close(temp_fd)",
+ " temp_fd = None",
+ " os.replace(temp_name, dst_basename, src_dir_fd=dst_parent_fd, dst_dir_fd=dst_parent_fd)",
+ " temp_name = None",
+ " os.unlink(src_basename, dir_fd=src_parent_fd)",
+ " os.fsync(dst_parent_fd)",
+ " os.fsync(src_parent_fd)",
+ " finally:",
+ " if temp_fd is not None:",
+ " os.close(temp_fd)",
+ " if temp_name is not None:",
+ " try:",
+ " os.unlink(temp_name, dir_fd=dst_parent_fd)",
+ " except FileNotFoundError:",
+ " pass",
+ " os.close(src_fd)",
+ "",
+ "if operation == 'write':",
+ " root_fd = open_dir(sys.argv[2])",
+ " parent_fd = None",
+ " try:",
+ " parent_fd = walk_dir(root_fd, sys.argv[3], sys.argv[5] == '1')",
+ " write_atomic(parent_fd, sys.argv[4], sys.stdin.buffer)",
+ " finally:",
+ " if parent_fd is not None:",
+ " os.close(parent_fd)",
+ " os.close(root_fd)",
+ "elif operation == 'mkdirp':",
+ " root_fd = open_dir(sys.argv[2])",
+ " target_fd = None",
+ " try:",
+ " target_fd = walk_dir(root_fd, sys.argv[3], True)",
+ " os.fsync(target_fd)",
+ " finally:",
+ " if target_fd is not None:",
+ " os.close(target_fd)",
+ " os.close(root_fd)",
+ "elif operation == 'remove':",
+ " root_fd = open_dir(sys.argv[2])",
+ " parent_fd = None",
+ " try:",
+ " parent_fd = walk_dir(root_fd, sys.argv[3], False)",
+ " try:",
+ " if sys.argv[5] == '1':",
+ " remove_tree(parent_fd, sys.argv[4])",
+ " else:",
+ " entry_stat = os.lstat(sys.argv[4], dir_fd=parent_fd)",
+ " if stat.S_ISDIR(entry_stat.st_mode) and not stat.S_ISLNK(entry_stat.st_mode):",
+ " os.rmdir(sys.argv[4], dir_fd=parent_fd)",
+ " else:",
+ " os.unlink(sys.argv[4], dir_fd=parent_fd)",
+ " os.fsync(parent_fd)",
+ " except FileNotFoundError:",
+ " if sys.argv[6] != '1':",
+ " raise",
+ " finally:",
+ " if parent_fd is not None:",
+ " os.close(parent_fd)",
+ " os.close(root_fd)",
+ "elif operation == 'rename':",
+ " src_root_fd = open_dir(sys.argv[2])",
+ " dst_root_fd = open_dir(sys.argv[5])",
+ " src_parent_fd = None",
+ " dst_parent_fd = None",
+ " try:",
+ " src_parent_fd = walk_dir(src_root_fd, sys.argv[3], False)",
+ " dst_parent_fd = walk_dir(dst_root_fd, sys.argv[6], sys.argv[8] == '1')",
+ " move_entry(src_parent_fd, sys.argv[4], dst_parent_fd, sys.argv[7])",
+ " finally:",
+ " if src_parent_fd is not None:",
+ " os.close(src_parent_fd)",
+ " if dst_parent_fd is not None:",
+ " os.close(dst_parent_fd)",
+ " os.close(src_root_fd)",
+ " os.close(dst_root_fd)",
+ "else:",
+ " raise RuntimeError('unknown sandbox mutation operation: ' + operation)",
+].join("\n");
+
+function buildPinnedMutationPlan(params: {
+ args: string[];
+ checks: PathSafetyCheck[];
+}): SandboxFsCommandPlan {
+ return {
+ checks: params.checks,
+ recheckBeforeCommand: true,
+ script: ["set -eu", "python3 - \"$@\" <<'PY'", SANDBOX_PINNED_MUTATION_PYTHON, "PY"].join("\n"),
+ args: params.args,
+ };
+}
+
+export function buildPinnedWritePlan(params: {
+ check: PathSafetyCheck;
+ pinned: PinnedSandboxEntry;
+ mkdir: boolean;
+}): SandboxFsCommandPlan {
+ return buildPinnedMutationPlan({
+ checks: [params.check],
+ args: [
+ "write",
+ params.pinned.mountRootPath,
+ params.pinned.relativeParentPath,
+ params.pinned.basename,
+ params.mkdir ? "1" : "0",
+ ],
+ });
+}
+
+export function buildPinnedMkdirpPlan(params: {
+ check: PathSafetyCheck;
+ pinned: PinnedSandboxDirectoryEntry;
+}): SandboxFsCommandPlan {
+ return buildPinnedMutationPlan({
+ checks: [params.check],
+ args: ["mkdirp", params.pinned.mountRootPath, params.pinned.relativePath],
+ });
+}
+
+export function buildPinnedRemovePlan(params: {
+ check: PathSafetyCheck;
+ pinned: PinnedSandboxEntry;
+ recursive?: boolean;
+ force?: boolean;
+}): SandboxFsCommandPlan {
+ return buildPinnedMutationPlan({
+ checks: [
+ {
+ target: params.check.target,
+ options: {
+ ...params.check.options,
+ aliasPolicy: PATH_ALIAS_POLICIES.unlinkTarget,
+ },
+ },
+ ],
+ args: [
+ "remove",
+ params.pinned.mountRootPath,
+ params.pinned.relativeParentPath,
+ params.pinned.basename,
+ params.recursive ? "1" : "0",
+ params.force === false ? "0" : "1",
+ ],
+ });
+}
+
+export function buildPinnedRenamePlan(params: {
+ fromCheck: PathSafetyCheck;
+ toCheck: PathSafetyCheck;
+ from: PinnedSandboxEntry;
+ to: PinnedSandboxEntry;
+}): SandboxFsCommandPlan {
+ return buildPinnedMutationPlan({
+ checks: [
+ {
+ target: params.fromCheck.target,
+ options: {
+ ...params.fromCheck.options,
+ aliasPolicy: PATH_ALIAS_POLICIES.unlinkTarget,
+ },
+ },
+ params.toCheck,
+ ],
+ args: [
+ "rename",
+ params.from.mountRootPath,
+ params.from.relativeParentPath,
+ params.from.basename,
+ params.to.mountRootPath,
+ params.to.relativeParentPath,
+ params.to.basename,
+ "1",
+ ],
+ });
+}
diff --git a/src/agents/sandbox/fs-bridge-mutation-python-source.ts b/src/agents/sandbox/fs-bridge-mutation-python-source.ts
new file mode 100644
index 00000000000..d0653e6ae41
--- /dev/null
+++ b/src/agents/sandbox/fs-bridge-mutation-python-source.ts
@@ -0,0 +1,190 @@
+// language=python
+export const SANDBOX_PINNED_FS_MUTATION_PYTHON = String.raw`import os
+import secrets
+import subprocess
+import sys
+
+operation = sys.argv[1]
+
+DIR_FLAGS = os.O_RDONLY
+if hasattr(os, "O_DIRECTORY"):
+ DIR_FLAGS |= os.O_DIRECTORY
+if hasattr(os, "O_NOFOLLOW"):
+ DIR_FLAGS |= os.O_NOFOLLOW
+
+WRITE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL
+if hasattr(os, "O_NOFOLLOW"):
+ WRITE_FLAGS |= os.O_NOFOLLOW
+
+
+def open_dir(path, dir_fd=None):
+ return os.open(path, DIR_FLAGS, dir_fd=dir_fd)
+
+
+def walk_parent(root_fd, rel_parent, mkdir_enabled):
+ current_fd = os.dup(root_fd)
+ try:
+ segments = [segment for segment in rel_parent.split("/") if segment and segment != "."]
+ for segment in segments:
+ if segment == "..":
+ raise OSError("path traversal is not allowed")
+ try:
+ next_fd = open_dir(segment, dir_fd=current_fd)
+ except FileNotFoundError:
+ if not mkdir_enabled:
+ raise
+ os.mkdir(segment, 0o777, dir_fd=current_fd)
+ next_fd = open_dir(segment, dir_fd=current_fd)
+ os.close(current_fd)
+ current_fd = next_fd
+ return current_fd
+ except Exception:
+ os.close(current_fd)
+ raise
+
+
+def create_temp_file(parent_fd, basename):
+ prefix = ".openclaw-write-" + basename + "."
+ for _ in range(128):
+ candidate = prefix + secrets.token_hex(6)
+ try:
+ fd = os.open(candidate, WRITE_FLAGS, 0o600, dir_fd=parent_fd)
+ return candidate, fd
+ except FileExistsError:
+ continue
+ raise RuntimeError("failed to allocate sandbox temp file")
+
+
+def fd_path(fd, basename=None):
+ base = f"/proc/self/fd/{fd}"
+ if basename is None:
+ return base
+ return f"{base}/{basename}"
+
+
+def run_command(argv, pass_fds):
+ subprocess.run(argv, check=True, pass_fds=tuple(pass_fds))
+
+
+def write_stdin_to_fd(fd):
+ while True:
+ chunk = sys.stdin.buffer.read(65536)
+ if not chunk:
+ break
+ os.write(fd, chunk)
+
+
+def run_write(args):
+ mount_root, relative_parent, basename, mkdir_enabled_raw = args
+ mkdir_enabled = mkdir_enabled_raw == "1"
+ root_fd = open_dir(mount_root)
+ parent_fd = None
+ temp_fd = None
+ temp_name = None
+ try:
+ parent_fd = walk_parent(root_fd, relative_parent, mkdir_enabled)
+ temp_name, temp_fd = create_temp_file(parent_fd, basename)
+ write_stdin_to_fd(temp_fd)
+ os.fsync(temp_fd)
+ os.close(temp_fd)
+ temp_fd = None
+ os.replace(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
+ os.fsync(parent_fd)
+ except Exception:
+ if temp_fd is not None:
+ os.close(temp_fd)
+ temp_fd = None
+ if temp_name is not None and parent_fd is not None:
+ try:
+ os.unlink(temp_name, dir_fd=parent_fd)
+ except FileNotFoundError:
+ pass
+ raise
+ finally:
+ if parent_fd is not None:
+ os.close(parent_fd)
+ os.close(root_fd)
+
+
+def run_mkdirp(args):
+ mount_root, relative_parent, basename = args
+ root_fd = open_dir(mount_root)
+ parent_fd = None
+ try:
+ parent_fd = walk_parent(root_fd, relative_parent, True)
+ run_command(["mkdir", "-p", "--", fd_path(parent_fd, basename)], [parent_fd])
+ os.fsync(parent_fd)
+ finally:
+ if parent_fd is not None:
+ os.close(parent_fd)
+ os.close(root_fd)
+
+
+def run_remove(args):
+ mount_root, relative_parent, basename, recursive_raw, force_raw = args
+ root_fd = open_dir(mount_root)
+ parent_fd = None
+ try:
+ parent_fd = walk_parent(root_fd, relative_parent, False)
+ argv = ["rm"]
+ if force_raw == "1":
+ argv.append("-f")
+ if recursive_raw == "1":
+ argv.append("-r")
+ argv.extend(["--", fd_path(parent_fd, basename)])
+ run_command(argv, [parent_fd])
+ os.fsync(parent_fd)
+ finally:
+ if parent_fd is not None:
+ os.close(parent_fd)
+ os.close(root_fd)
+
+
+def run_rename(args):
+ (
+ from_mount_root,
+ from_relative_parent,
+ from_basename,
+ to_mount_root,
+ to_relative_parent,
+ to_basename,
+ ) = args
+ from_root_fd = open_dir(from_mount_root)
+ to_root_fd = open_dir(to_mount_root)
+ from_parent_fd = None
+ to_parent_fd = None
+ try:
+ from_parent_fd = walk_parent(from_root_fd, from_relative_parent, False)
+ to_parent_fd = walk_parent(to_root_fd, to_relative_parent, True)
+ run_command(
+ [
+ "mv",
+ "--",
+ fd_path(from_parent_fd, from_basename),
+ fd_path(to_parent_fd, to_basename),
+ ],
+ [from_parent_fd, to_parent_fd],
+ )
+ os.fsync(from_parent_fd)
+ if to_parent_fd != from_parent_fd:
+ os.fsync(to_parent_fd)
+ finally:
+ if from_parent_fd is not None:
+ os.close(from_parent_fd)
+ if to_parent_fd is not None:
+ os.close(to_parent_fd)
+ os.close(from_root_fd)
+ os.close(to_root_fd)
+
+
+OPERATIONS = {
+ "write": run_write,
+ "mkdirp": run_mkdirp,
+ "remove": run_remove,
+ "rename": run_rename,
+}
+
+if operation not in OPERATIONS:
+ raise RuntimeError(f"unknown sandbox fs mutation: {operation}")
+
+OPERATIONS[operation](sys.argv[2:])`;
diff --git a/src/agents/sandbox/fs-bridge-path-safety.ts b/src/agents/sandbox/fs-bridge-path-safety.ts
index a18ed500287..dfc6c6692a1 100644
--- a/src/agents/sandbox/fs-bridge-path-safety.ts
+++ b/src/agents/sandbox/fs-bridge-path-safety.ts
@@ -18,11 +18,17 @@ export type PathSafetyCheck = {
options: PathSafetyOptions;
};
-export type AnchoredSandboxEntry = {
- canonicalParentPath: string;
+export type PinnedSandboxEntry = {
+ mountRootPath: string;
+ relativeParentPath: string;
basename: string;
};
+export type PinnedSandboxDirectoryEntry = {
+ mountRootPath: string;
+ relativePath: string;
+};
+
type RunCommand = (
script: string,
options?: {
@@ -128,22 +134,43 @@ export class SandboxFsPathGuard {
return guarded;
}
- async resolveAnchoredSandboxEntry(target: SandboxResolvedFsPath): Promise {
+ resolvePinnedEntry(target: SandboxResolvedFsPath, action: string): PinnedSandboxEntry {
const basename = path.posix.basename(target.containerPath);
if (!basename || basename === "." || basename === "/") {
throw new Error(`Invalid sandbox entry target: ${target.containerPath}`);
}
const parentPath = normalizeContainerPath(path.posix.dirname(target.containerPath));
- const canonicalParentPath = await this.resolveCanonicalContainerPath({
- containerPath: parentPath,
- allowFinalSymlinkForUnlink: false,
- });
+ const mount = this.resolveRequiredMount(parentPath, action);
+ const relativeParentPath = path.posix.relative(mount.containerRoot, parentPath);
+ if (relativeParentPath.startsWith("..") || path.posix.isAbsolute(relativeParentPath)) {
+ throw new Error(
+ `Sandbox path escapes allowed mounts; cannot ${action}: ${target.containerPath}`,
+ );
+ }
return {
- canonicalParentPath,
+ mountRootPath: mount.containerRoot,
+ relativeParentPath: relativeParentPath === "." ? "" : relativeParentPath,
basename,
};
}
+ resolvePinnedDirectoryEntry(
+ target: SandboxResolvedFsPath,
+ action: string,
+ ): PinnedSandboxDirectoryEntry {
+ const mount = this.resolveRequiredMount(target.containerPath, action);
+ const relativePath = path.posix.relative(mount.containerRoot, target.containerPath);
+ if (relativePath.startsWith("..") || path.posix.isAbsolute(relativePath)) {
+ throw new Error(
+ `Sandbox path escapes allowed mounts; cannot ${action}: ${target.containerPath}`,
+ );
+ }
+ return {
+ mountRootPath: mount.containerRoot,
+ relativePath: relativePath === "." ? "" : relativePath,
+ };
+ }
+
private pathIsExistingDirectory(hostPath: string): boolean {
try {
return fs.statSync(hostPath).isDirectory();
diff --git a/src/agents/sandbox/fs-bridge-shell-command-plans.ts b/src/agents/sandbox/fs-bridge-shell-command-plans.ts
index 4c1a9b8d64f..2987472762b 100644
--- a/src/agents/sandbox/fs-bridge-shell-command-plans.ts
+++ b/src/agents/sandbox/fs-bridge-shell-command-plans.ts
@@ -1,107 +1,15 @@
-import { PATH_ALIAS_POLICIES } from "../../infra/path-alias-guards.js";
-import type { AnchoredSandboxEntry, PathSafetyCheck } from "./fs-bridge-path-safety.js";
+import type { PathSafetyCheck } from "./fs-bridge-path-safety.js";
import type { SandboxResolvedFsPath } from "./fs-paths.js";
export type SandboxFsCommandPlan = {
checks: PathSafetyCheck[];
script: string;
args?: string[];
+ stdin?: Buffer | string;
recheckBeforeCommand?: boolean;
allowFailure?: boolean;
};
-export function buildWriteCommitPlan(
- target: SandboxResolvedFsPath,
- tempPath: string,
-): SandboxFsCommandPlan {
- return {
- checks: [{ target, options: { action: "write files", requireWritable: true } }],
- recheckBeforeCommand: true,
- script: 'set -eu; mv -f -- "$1" "$2"',
- args: [tempPath, target.containerPath],
- };
-}
-
-export function buildMkdirpPlan(
- target: SandboxResolvedFsPath,
- anchoredTarget: AnchoredSandboxEntry,
-): SandboxFsCommandPlan {
- return {
- checks: [
- {
- target,
- options: {
- action: "create directories",
- requireWritable: true,
- allowedType: "directory",
- },
- },
- ],
- script: 'set -eu\ncd -- "$1"\nmkdir -p -- "$2"',
- args: [anchoredTarget.canonicalParentPath, anchoredTarget.basename],
- };
-}
-
-export function buildRemovePlan(params: {
- target: SandboxResolvedFsPath;
- anchoredTarget: AnchoredSandboxEntry;
- recursive?: boolean;
- force?: boolean;
-}): SandboxFsCommandPlan {
- const flags = [params.force === false ? "" : "-f", params.recursive ? "-r" : ""].filter(Boolean);
- const rmCommand = flags.length > 0 ? `rm ${flags.join(" ")}` : "rm";
- return {
- checks: [
- {
- target: params.target,
- options: {
- action: "remove files",
- requireWritable: true,
- aliasPolicy: PATH_ALIAS_POLICIES.unlinkTarget,
- },
- },
- ],
- recheckBeforeCommand: true,
- script: `set -eu\ncd -- "$1"\n${rmCommand} -- "$2"`,
- args: [params.anchoredTarget.canonicalParentPath, params.anchoredTarget.basename],
- };
-}
-
-export function buildRenamePlan(params: {
- from: SandboxResolvedFsPath;
- to: SandboxResolvedFsPath;
- anchoredFrom: AnchoredSandboxEntry;
- anchoredTo: AnchoredSandboxEntry;
-}): SandboxFsCommandPlan {
- return {
- checks: [
- {
- target: params.from,
- options: {
- action: "rename files",
- requireWritable: true,
- aliasPolicy: PATH_ALIAS_POLICIES.unlinkTarget,
- },
- },
- {
- target: params.to,
- options: {
- action: "rename files",
- requireWritable: true,
- },
- },
- ],
- recheckBeforeCommand: true,
- script: ["set -eu", 'mkdir -p -- "$2"', 'cd -- "$1"', 'mv -- "$3" "$2/$4"'].join("\n"),
- args: [
- params.anchoredFrom.canonicalParentPath,
- params.anchoredTo.canonicalParentPath,
- params.anchoredFrom.basename,
- params.anchoredTo.basename,
- ],
- };
-}
-
export function buildStatPlan(target: SandboxResolvedFsPath): SandboxFsCommandPlan {
return {
checks: [{ target, options: { action: "stat files" } }],
diff --git a/src/agents/sandbox/fs-bridge.anchored-ops.test.ts b/src/agents/sandbox/fs-bridge.anchored-ops.test.ts
index 79bc5a55f3c..9b15f02adf5 100644
--- a/src/agents/sandbox/fs-bridge.anchored-ops.test.ts
+++ b/src/agents/sandbox/fs-bridge.anchored-ops.test.ts
@@ -4,8 +4,6 @@ import { describe, expect, it } from "vitest";
import {
createSandbox,
createSandboxFsBridge,
- findCallByScriptFragment,
- findCallsByScriptFragment,
getDockerArg,
installFsBridgeTestHarness,
mockedExecDockerRaw,
@@ -67,54 +65,60 @@ describe("sandbox fs bridge anchored ops", () => {
});
});
- const anchoredCases = [
+ const pinnedCases = [
{
- name: "mkdirp anchors parent + basename",
+ name: "mkdirp pins mount root + relative path",
invoke: (bridge: ReturnType) =>
bridge.mkdirp({ filePath: "nested/leaf" }),
- scriptFragment: 'mkdir -p -- "$2"',
- expectedArgs: ["/workspace/nested", "leaf"],
+ expectedArgs: ["mkdirp", "/workspace", "nested/leaf"],
forbiddenArgs: ["/workspace/nested/leaf"],
- canonicalProbe: "/workspace/nested",
},
{
- name: "remove anchors parent + basename",
+ name: "remove pins mount root + parent/basename",
invoke: (bridge: ReturnType) =>
bridge.remove({ filePath: "nested/file.txt" }),
- scriptFragment: 'rm -f -- "$2"',
- expectedArgs: ["/workspace/nested", "file.txt"],
+ expectedArgs: ["remove", "/workspace", "nested", "file.txt", "0", "1"],
forbiddenArgs: ["/workspace/nested/file.txt"],
- canonicalProbe: "/workspace/nested",
},
{
- name: "rename anchors both parents + basenames",
+ name: "rename pins both parents + basenames",
invoke: (bridge: ReturnType) =>
bridge.rename({ from: "from.txt", to: "nested/to.txt" }),
- scriptFragment: 'mv -- "$3" "$2/$4"',
- expectedArgs: ["/workspace", "/workspace/nested", "from.txt", "to.txt"],
+ expectedArgs: ["rename", "/workspace", "", "from.txt", "/workspace", "nested", "to.txt", "1"],
forbiddenArgs: ["/workspace/from.txt", "/workspace/nested/to.txt"],
- canonicalProbe: "/workspace/nested",
},
] as const;
- it.each(anchoredCases)("$name", async (testCase) => {
- const bridge = createSandboxFsBridge({ sandbox: createSandbox() });
+ it.each(pinnedCases)("$name", async (testCase) => {
+ await withTempDir("openclaw-fs-bridge-contract-write-", async (stateDir) => {
+ const workspaceDir = path.join(stateDir, "workspace");
+ await fs.mkdir(path.join(workspaceDir, "nested"), { recursive: true });
+ await fs.writeFile(path.join(workspaceDir, "from.txt"), "hello", "utf8");
+ await fs.writeFile(path.join(workspaceDir, "nested", "file.txt"), "bye", "utf8");
- await testCase.invoke(bridge);
+ const bridge = createSandboxFsBridge({
+ sandbox: createSandbox({
+ workspaceDir,
+ agentWorkspaceDir: workspaceDir,
+ }),
+ });
- const opCall = findCallByScriptFragment(testCase.scriptFragment);
- expect(opCall).toBeDefined();
- const args = opCall?.[0] ?? [];
- testCase.expectedArgs.forEach((value, index) => {
- expect(getDockerArg(args, index + 1)).toBe(value);
+ await testCase.invoke(bridge);
+
+ const opCall = mockedExecDockerRaw.mock.calls.find(
+ ([args]) =>
+ typeof args[5] === "string" &&
+ args[5].includes("python3 - \"$@\" <<'PY'") &&
+ getDockerArg(args, 1) === testCase.expectedArgs[0],
+ );
+ expect(opCall).toBeDefined();
+ const args = opCall?.[0] ?? [];
+ testCase.expectedArgs.forEach((value, index) => {
+ expect(getDockerArg(args, index + 1)).toBe(value);
+ });
+ testCase.forbiddenArgs.forEach((value) => {
+ expect(args).not.toContain(value);
+ });
});
- testCase.forbiddenArgs.forEach((value) => {
- expect(args).not.toContain(value);
- });
-
- const canonicalCalls = findCallsByScriptFragment('readlink -f -- "$cursor"');
- expect(
- canonicalCalls.some(([callArgs]) => getDockerArg(callArgs, 1) === testCase.canonicalProbe),
- ).toBe(true);
});
});
diff --git a/src/agents/sandbox/fs-bridge.boundary.test.ts b/src/agents/sandbox/fs-bridge.boundary.test.ts
index 3b86496fac6..574a698db4c 100644
--- a/src/agents/sandbox/fs-bridge.boundary.test.ts
+++ b/src/agents/sandbox/fs-bridge.boundary.test.ts
@@ -6,7 +6,7 @@ import {
createSandbox,
createSandboxFsBridge,
expectMkdirpAllowsExistingDirectory,
- getScriptsFromCalls,
+ findCallByDockerArg,
installFsBridgeTestHarness,
mockedExecDockerRaw,
withTempDir,
@@ -55,8 +55,7 @@ describe("sandbox fs bridge boundary validation", () => {
await expect(bridge.mkdirp({ filePath: "memory/kemik" })).rejects.toThrow(
/cannot create directories/i,
);
- const scripts = getScriptsFromCalls();
- expect(scripts.some((script) => script.includes('mkdir -p -- "$2"'))).toBe(false);
+ expect(findCallByDockerArg(1, "mkdirp")).toBeUndefined();
});
});
@@ -111,7 +110,6 @@ describe("sandbox fs bridge boundary validation", () => {
it("rejects missing files before any docker read command runs", async () => {
const bridge = createSandboxFsBridge({ sandbox: createSandbox() });
await expect(bridge.readFile({ filePath: "a.txt" })).rejects.toThrow(/ENOENT|no such file/i);
- const scripts = getScriptsFromCalls();
- expect(scripts.some((script) => script.includes('cat -- "$1"'))).toBe(false);
+ expect(mockedExecDockerRaw).not.toHaveBeenCalled();
});
});
diff --git a/src/agents/sandbox/fs-bridge.e2e-docker.test.ts b/src/agents/sandbox/fs-bridge.e2e-docker.test.ts
new file mode 100644
index 00000000000..62a064b49f5
--- /dev/null
+++ b/src/agents/sandbox/fs-bridge.e2e-docker.test.ts
@@ -0,0 +1,89 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+import { DEFAULT_SANDBOX_IMAGE } from "./constants.js";
+import { buildSandboxCreateArgs, execDocker, execDockerRaw } from "./docker.js";
+import { createSandboxFsBridge } from "./fs-bridge.js";
+import { createSandboxTestContext } from "./test-fixtures.js";
+import { appendWorkspaceMountArgs } from "./workspace-mounts.js";
+
+async function sandboxImageReady(): Promise {
+ try {
+ const dockerVersion = await execDockerRaw(["version"], { allowFailure: true });
+ if (dockerVersion.code !== 0) {
+ return false;
+ }
+ const pythonCheck = await execDockerRaw(
+ ["run", "--rm", "--entrypoint", "python3", DEFAULT_SANDBOX_IMAGE, "--version"],
+ { allowFailure: true },
+ );
+ return pythonCheck.code === 0;
+ } catch {
+ return false;
+ }
+}
+
+describe("sandbox fs bridge docker e2e", () => {
+ it.runIf(process.platform !== "win32")(
+ "writes through docker exec using the pinned mutation helper",
+ async () => {
+ if (!(await sandboxImageReady())) {
+ return;
+ }
+
+ const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fsbridge-e2e-"));
+ const workspaceDir = path.join(stateDir, "workspace");
+ await fs.mkdir(workspaceDir, { recursive: true });
+
+ const suffix = `${process.pid}-${Date.now()}`;
+ const containerName = `openclaw-fsbridge-${suffix}`.slice(0, 63);
+
+ try {
+ const sandbox = createSandboxTestContext({
+ overrides: {
+ workspaceDir,
+ agentWorkspaceDir: workspaceDir,
+ containerName,
+ containerWorkdir: "/workspace",
+ },
+ dockerOverrides: {
+ image: DEFAULT_SANDBOX_IMAGE,
+ containerPrefix: "openclaw-fsbridge-",
+ user: "",
+ },
+ });
+
+ const createArgs = buildSandboxCreateArgs({
+ name: containerName,
+ cfg: sandbox.docker,
+ scopeKey: sandbox.sessionKey,
+ includeBinds: false,
+ bindSourceRoots: [workspaceDir],
+ });
+ createArgs.push("--workdir", sandbox.containerWorkdir);
+ appendWorkspaceMountArgs({
+ args: createArgs,
+ workspaceDir,
+ agentWorkspaceDir: workspaceDir,
+ workdir: sandbox.containerWorkdir,
+ workspaceAccess: sandbox.workspaceAccess,
+ });
+ createArgs.push(sandbox.docker.image, "sleep", "infinity");
+
+ await execDocker(createArgs);
+ await execDocker(["start", containerName]);
+
+ const bridge = createSandboxFsBridge({ sandbox });
+ await bridge.writeFile({ filePath: "nested/hello.txt", data: "from-docker" });
+
+ await expect(
+ fs.readFile(path.join(workspaceDir, "nested", "hello.txt"), "utf8"),
+ ).resolves.toBe("from-docker");
+ } finally {
+ await execDocker(["rm", "-f", containerName], { allowFailure: true });
+ await fs.rm(stateDir, { recursive: true, force: true });
+ }
+ },
+ );
+});
diff --git a/src/agents/sandbox/fs-bridge.shell.test.ts b/src/agents/sandbox/fs-bridge.shell.test.ts
index d8b29c0f5d5..24b7d9faba4 100644
--- a/src/agents/sandbox/fs-bridge.shell.test.ts
+++ b/src/agents/sandbox/fs-bridge.shell.test.ts
@@ -45,10 +45,10 @@ describe("sandbox fs bridge shell compatibility", () => {
});
});
- it("resolveCanonicalContainerPath script is valid POSIX sh (no do; token)", async () => {
+ it("path canonicalization recheck script is valid POSIX sh", async () => {
const bridge = createSandboxFsBridge({ sandbox: createSandbox() });
- await bridge.mkdirp({ filePath: "nested" });
+ await bridge.writeFile({ filePath: "b.txt", data: "hello" });
const scripts = getScriptsFromCalls();
const canonicalScript = scripts.find((script) => script.includes("allow_final"));
@@ -130,11 +130,37 @@ describe("sandbox fs bridge shell compatibility", () => {
const scripts = getScriptsFromCalls();
expect(scripts.some((script) => script.includes('cat >"$1"'))).toBe(false);
- expect(scripts.some((script) => script.includes('cat >"$tmp"'))).toBe(true);
- expect(scripts.some((script) => script.includes('mv -f -- "$1" "$2"'))).toBe(true);
+ expect(scripts.some((script) => script.includes('cat >"$tmp"'))).toBe(false);
+ expect(scripts.some((script) => script.includes("os.replace("))).toBe(true);
});
- it("re-validates target before final rename and cleans temp file on failure", async () => {
+ it("routes mkdirp, remove, and rename through the pinned mutation helper", async () => {
+ await withTempDir("openclaw-fs-bridge-shell-write-", async (stateDir) => {
+ const workspaceDir = path.join(stateDir, "workspace");
+ await fs.mkdir(path.join(workspaceDir, "nested"), { recursive: true });
+ await fs.writeFile(path.join(workspaceDir, "a.txt"), "hello", "utf8");
+ await fs.writeFile(path.join(workspaceDir, "nested", "file.txt"), "bye", "utf8");
+
+ const bridge = createSandboxFsBridge({
+ sandbox: createSandbox({
+ workspaceDir,
+ agentWorkspaceDir: workspaceDir,
+ }),
+ });
+
+ await bridge.mkdirp({ filePath: "nested" });
+ await bridge.remove({ filePath: "nested/file.txt" });
+ await bridge.rename({ from: "a.txt", to: "nested/b.txt" });
+
+ const scripts = getScriptsFromCalls();
+ expect(scripts.filter((script) => script.includes("operation = sys.argv[1]")).length).toBe(3);
+ expect(scripts.some((script) => script.includes('mkdir -p -- "$2"'))).toBe(false);
+ expect(scripts.some((script) => script.includes('rm -f -- "$2"'))).toBe(false);
+ expect(scripts.some((script) => script.includes('mv -- "$3" "$2/$4"'))).toBe(false);
+ });
+ });
+
+ it("re-validates target before the pinned write helper runs", async () => {
const { mockedOpenBoundaryFile } = await import("./fs-bridge.test-helpers.js");
mockedOpenBoundaryFile
.mockImplementationOnce(async () => ({ ok: false, reason: "path" }))
@@ -150,8 +176,6 @@ describe("sandbox fs bridge shell compatibility", () => {
);
const scripts = getScriptsFromCalls();
- expect(scripts.some((script) => script.includes("mktemp"))).toBe(true);
- expect(scripts.some((script) => script.includes('mv -f -- "$1" "$2"'))).toBe(false);
- expect(scripts.some((script) => script.includes('rm -f -- "$1"'))).toBe(true);
+ expect(scripts.some((script) => script.includes("os.replace("))).toBe(false);
});
});
diff --git a/src/agents/sandbox/fs-bridge.test-helpers.ts b/src/agents/sandbox/fs-bridge.test-helpers.ts
index e81bb65a4e0..87a184154af 100644
--- a/src/agents/sandbox/fs-bridge.test-helpers.ts
+++ b/src/agents/sandbox/fs-bridge.test-helpers.ts
@@ -48,6 +48,10 @@ export function findCallByScriptFragment(fragment: string) {
return mockedExecDockerRaw.mock.calls.find(([args]) => getDockerScript(args).includes(fragment));
}
+export function findCallByDockerArg(position: number, value: string) {
+ return mockedExecDockerRaw.mock.calls.find(([args]) => getDockerArg(args, position) === value);
+}
+
export function findCallsByScriptFragment(fragment: string) {
return mockedExecDockerRaw.mock.calls.filter(([args]) =>
getDockerScript(args).includes(fragment),
@@ -142,12 +146,16 @@ export async function expectMkdirpAllowsExistingDirectory(params?: {
await expect(bridge.mkdirp({ filePath: "memory/kemik" })).resolves.toBeUndefined();
- const mkdirCall = findCallByScriptFragment('mkdir -p -- "$2"');
+ const mkdirCall = mockedExecDockerRaw.mock.calls.find(
+ ([args]) =>
+ getDockerScript(args).includes("operation = sys.argv[1]") &&
+ getDockerArg(args, 1) === "mkdirp",
+ );
expect(mkdirCall).toBeDefined();
- const mkdirParent = mkdirCall ? getDockerArg(mkdirCall[0], 1) : "";
- const mkdirBase = mkdirCall ? getDockerArg(mkdirCall[0], 2) : "";
- expect(mkdirParent).toBe("/workspace/memory");
- expect(mkdirBase).toBe("kemik");
+ const mountRoot = mkdirCall ? getDockerArg(mkdirCall[0], 2) : "";
+ const relativePath = mkdirCall ? getDockerArg(mkdirCall[0], 3) : "";
+ expect(mountRoot).toBe("/workspace");
+ expect(relativePath).toBe("memory/kemik");
});
}
diff --git a/src/agents/sandbox/fs-bridge.ts b/src/agents/sandbox/fs-bridge.ts
index f937ad2c702..83504d9b908 100644
--- a/src/agents/sandbox/fs-bridge.ts
+++ b/src/agents/sandbox/fs-bridge.ts
@@ -1,20 +1,18 @@
import fs from "node:fs";
import { execDockerRaw, type ExecDockerRawResult } from "./docker.js";
-import { SandboxFsPathGuard } from "./fs-bridge-path-safety.js";
import {
- buildMkdirpPlan,
- buildRemovePlan,
- buildRenamePlan,
- buildStatPlan,
- buildWriteCommitPlan,
- type SandboxFsCommandPlan,
-} from "./fs-bridge-shell-command-plans.js";
+ buildPinnedMkdirpPlan,
+ buildPinnedRemovePlan,
+ buildPinnedRenamePlan,
+ buildPinnedWritePlan,
+} from "./fs-bridge-mutation-helper.js";
+import { SandboxFsPathGuard } from "./fs-bridge-path-safety.js";
+import { buildStatPlan, type SandboxFsCommandPlan } from "./fs-bridge-shell-command-plans.js";
import {
buildSandboxFsMounts,
resolveSandboxFsPathWithMounts,
type SandboxResolvedFsPath,
} from "./fs-paths.js";
-import { normalizeContainerPath } from "./path-utils.js";
import type { SandboxContext, SandboxWorkspaceAccess } from "./types.js";
type RunCommandOptions = {
@@ -112,33 +110,44 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
}): Promise {
const target = this.resolveResolvedPath(params);
this.ensureWriteAccess(target, "write files");
- await this.pathGuard.assertPathSafety(target, { action: "write files", requireWritable: true });
+ const writeCheck = {
+ target,
+ options: { action: "write files", requireWritable: true } as const,
+ };
+ await this.pathGuard.assertPathSafety(target, writeCheck.options);
const buffer = Buffer.isBuffer(params.data)
? params.data
: Buffer.from(params.data, params.encoding ?? "utf8");
- const tempPath = await this.writeFileToTempPath({
- targetContainerPath: target.containerPath,
- mkdir: params.mkdir !== false,
- data: buffer,
+ const pinnedWriteTarget = this.pathGuard.resolvePinnedEntry(target, "write files");
+ await this.runCheckedCommand({
+ ...buildPinnedWritePlan({
+ check: writeCheck,
+ pinned: pinnedWriteTarget,
+ mkdir: params.mkdir !== false,
+ }),
+ stdin: buffer,
signal: params.signal,
});
-
- try {
- await this.runCheckedCommand({
- ...buildWriteCommitPlan(target, tempPath),
- signal: params.signal,
- });
- } catch (error) {
- await this.cleanupTempPath(tempPath, params.signal);
- throw error;
- }
}
async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise {
const target = this.resolveResolvedPath(params);
this.ensureWriteAccess(target, "create directories");
- const anchoredTarget = await this.pathGuard.resolveAnchoredSandboxEntry(target);
- await this.runPlannedCommand(buildMkdirpPlan(target, anchoredTarget), params.signal);
+ const mkdirCheck = {
+ target,
+ options: {
+ action: "create directories",
+ requireWritable: true,
+ allowedType: "directory",
+ } as const,
+ };
+ await this.runCheckedCommand({
+ ...buildPinnedMkdirpPlan({
+ check: mkdirCheck,
+ pinned: this.pathGuard.resolvePinnedDirectoryEntry(target, "create directories"),
+ }),
+ signal: params.signal,
+ });
}
async remove(params: {
@@ -150,16 +159,22 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
}): Promise {
const target = this.resolveResolvedPath(params);
this.ensureWriteAccess(target, "remove files");
- const anchoredTarget = await this.pathGuard.resolveAnchoredSandboxEntry(target);
- await this.runPlannedCommand(
- buildRemovePlan({
- target,
- anchoredTarget,
+ const removeCheck = {
+ target,
+ options: {
+ action: "remove files",
+ requireWritable: true,
+ } as const,
+ };
+ await this.runCheckedCommand({
+ ...buildPinnedRemovePlan({
+ check: removeCheck,
+ pinned: this.pathGuard.resolvePinnedEntry(target, "remove files"),
recursive: params.recursive,
force: params.force,
}),
- params.signal,
- );
+ signal: params.signal,
+ });
}
async rename(params: {
@@ -172,17 +187,29 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
const to = this.resolveResolvedPath({ filePath: params.to, cwd: params.cwd });
this.ensureWriteAccess(from, "rename files");
this.ensureWriteAccess(to, "rename files");
- const anchoredFrom = await this.pathGuard.resolveAnchoredSandboxEntry(from);
- const anchoredTo = await this.pathGuard.resolveAnchoredSandboxEntry(to);
- await this.runPlannedCommand(
- buildRenamePlan({
- from,
- to,
- anchoredFrom,
- anchoredTo,
+ const fromCheck = {
+ target: from,
+ options: {
+ action: "rename files",
+ requireWritable: true,
+ } as const,
+ };
+ const toCheck = {
+ target: to,
+ options: {
+ action: "rename files",
+ requireWritable: true,
+ } as const,
+ };
+ await this.runCheckedCommand({
+ ...buildPinnedRenamePlan({
+ fromCheck,
+ toCheck,
+ from: this.pathGuard.resolvePinnedEntry(from, "rename files"),
+ to: this.pathGuard.resolvePinnedEntry(to, "rename files"),
}),
- params.signal,
- );
+ signal: params.signal,
+ });
}
async stat(params: {
@@ -265,58 +292,6 @@ class SandboxFsBridgeImpl implements SandboxFsBridge {
return await this.runCheckedCommand({ ...plan, signal });
}
- private async writeFileToTempPath(params: {
- targetContainerPath: string;
- mkdir: boolean;
- data: Buffer;
- signal?: AbortSignal;
- }): Promise {
- const script = params.mkdir
- ? [
- "set -eu",
- 'target="$1"',
- 'dir=$(dirname -- "$target")',
- 'if [ "$dir" != "." ]; then mkdir -p -- "$dir"; fi',
- 'base=$(basename -- "$target")',
- 'tmp=$(mktemp "$dir/.openclaw-write-$base.XXXXXX")',
- 'cat >"$tmp"',
- 'printf "%s\\n" "$tmp"',
- ].join("\n")
- : [
- "set -eu",
- 'target="$1"',
- 'dir=$(dirname -- "$target")',
- 'base=$(basename -- "$target")',
- 'tmp=$(mktemp "$dir/.openclaw-write-$base.XXXXXX")',
- 'cat >"$tmp"',
- 'printf "%s\\n" "$tmp"',
- ].join("\n");
- const result = await this.runCommand(script, {
- args: [params.targetContainerPath],
- stdin: params.data,
- signal: params.signal,
- });
- const tempPath = result.stdout.toString("utf8").trim().split(/\r?\n/).at(-1)?.trim();
- if (!tempPath || !tempPath.startsWith("/")) {
- throw new Error(
- `Failed to create temporary sandbox write path for ${params.targetContainerPath}`,
- );
- }
- return normalizeContainerPath(tempPath);
- }
-
- private async cleanupTempPath(tempPath: string, signal?: AbortSignal): Promise {
- try {
- await this.runCommand('set -eu; rm -f -- "$1"', {
- args: [tempPath],
- signal,
- allowFailure: true,
- });
- } catch {
- // Best-effort cleanup only.
- }
- }
-
private ensureWriteAccess(target: SandboxResolvedFsPath, action: string) {
if (!allowsWrites(this.sandbox.workspaceAccess) || !target.writable) {
throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`);
diff --git a/src/agents/skills-install-extract.ts b/src/agents/skills-install-extract.ts
index 4578935378f..02a5b22c3d5 100644
--- a/src/agents/skills-install-extract.ts
+++ b/src/agents/skills-install-extract.ts
@@ -1,14 +1,21 @@
import { createHash } from "node:crypto";
import fs from "node:fs";
import {
- createTarEntrySafetyChecker,
+ createTarEntryPreflightChecker,
extractArchive as extractArchiveSafe,
+ mergeExtractedTreeIntoDestination,
+ prepareArchiveDestinationDir,
+ withStagedArchiveDestination,
} from "../infra/archive.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { parseTarVerboseMetadata } from "./skills-install-tar-verbose.js";
import { hasBinary } from "./skills.js";
export type ArchiveExtractResult = { stdout: string; stderr: string; code: number | null };
+type TarPreflightResult = {
+ entries: string[];
+ metadata: ReturnType;
+};
async function hashFileSha256(filePath: string): Promise {
const hash = createHash("sha256");
@@ -24,6 +31,112 @@ async function hashFileSha256(filePath: string): Promise {
});
}
+function commandFailureResult(
+ result: { stdout: string; stderr: string; code: number | null },
+ fallbackStderr: string,
+): ArchiveExtractResult {
+ return {
+ stdout: result.stdout,
+ stderr: result.stderr || fallbackStderr,
+ code: result.code,
+ };
+}
+
+function buildTarExtractArgv(params: {
+ archivePath: string;
+ targetDir: string;
+ stripComponents: number;
+}): string[] {
+ const argv = ["tar", "xf", params.archivePath, "-C", params.targetDir];
+ if (params.stripComponents > 0) {
+ argv.push("--strip-components", String(params.stripComponents));
+ }
+ return argv;
+}
+
+async function readTarPreflight(params: {
+ archivePath: string;
+ timeoutMs: number;
+}): Promise {
+ const listResult = await runCommandWithTimeout(["tar", "tf", params.archivePath], {
+ timeoutMs: params.timeoutMs,
+ });
+ if (listResult.code !== 0) {
+ return commandFailureResult(listResult, "tar list failed");
+ }
+ const entries = listResult.stdout
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean);
+
+ const verboseResult = await runCommandWithTimeout(["tar", "tvf", params.archivePath], {
+ timeoutMs: params.timeoutMs,
+ });
+ if (verboseResult.code !== 0) {
+ return commandFailureResult(verboseResult, "tar verbose list failed");
+ }
+ const metadata = parseTarVerboseMetadata(verboseResult.stdout);
+ if (metadata.length !== entries.length) {
+ return {
+ stdout: verboseResult.stdout,
+ stderr: `tar verbose/list entry count mismatch (${metadata.length} vs ${entries.length})`,
+ code: 1,
+ };
+ }
+ return { entries, metadata };
+}
+
+function isArchiveExtractFailure(
+ value: TarPreflightResult | ArchiveExtractResult,
+): value is ArchiveExtractResult {
+ return "code" in value;
+}
+
+async function verifyArchiveHashStable(params: {
+ archivePath: string;
+ expectedHash: string;
+}): Promise {
+ const postPreflightHash = await hashFileSha256(params.archivePath);
+ if (postPreflightHash === params.expectedHash) {
+ return null;
+ }
+ return {
+ stdout: "",
+ stderr: "tar archive changed during safety preflight; refusing to extract",
+ code: 1,
+ };
+}
+
+async function extractTarBz2WithStaging(params: {
+ archivePath: string;
+ destinationRealDir: string;
+ stripComponents: number;
+ timeoutMs: number;
+}): Promise {
+ return await withStagedArchiveDestination({
+ destinationRealDir: params.destinationRealDir,
+ run: async (stagingDir) => {
+ const extractResult = await runCommandWithTimeout(
+ buildTarExtractArgv({
+ archivePath: params.archivePath,
+ targetDir: stagingDir,
+ stripComponents: params.stripComponents,
+ }),
+ { timeoutMs: params.timeoutMs },
+ );
+ if (extractResult.code !== 0) {
+ return extractResult;
+ }
+ await mergeExtractedTreeIntoDestination({
+ sourceDir: stagingDir,
+ destinationDir: params.destinationRealDir,
+ destinationRealDir: params.destinationRealDir,
+ });
+ return extractResult;
+ },
+ });
+}
+
export async function extractArchive(params: {
archivePath: string;
archiveType: string;
@@ -66,49 +179,25 @@ export async function extractArchive(params: {
return { stdout: "", stderr: "tar not found on PATH", code: null };
}
+ const destinationRealDir = await prepareArchiveDestinationDir(targetDir);
const preflightHash = await hashFileSha256(archivePath);
// Preflight list to prevent zip-slip style traversal before extraction.
- const listResult = await runCommandWithTimeout(["tar", "tf", archivePath], { timeoutMs });
- if (listResult.code !== 0) {
- return {
- stdout: listResult.stdout,
- stderr: listResult.stderr || "tar list failed",
- code: listResult.code,
- };
+ const preflight = await readTarPreflight({ archivePath, timeoutMs });
+ if (isArchiveExtractFailure(preflight)) {
+ return preflight;
}
- const entries = listResult.stdout
- .split("\n")
- .map((line) => line.trim())
- .filter(Boolean);
-
- const verboseResult = await runCommandWithTimeout(["tar", "tvf", archivePath], { timeoutMs });
- if (verboseResult.code !== 0) {
- return {
- stdout: verboseResult.stdout,
- stderr: verboseResult.stderr || "tar verbose list failed",
- code: verboseResult.code,
- };
- }
- const metadata = parseTarVerboseMetadata(verboseResult.stdout);
- if (metadata.length !== entries.length) {
- return {
- stdout: verboseResult.stdout,
- stderr: `tar verbose/list entry count mismatch (${metadata.length} vs ${entries.length})`,
- code: 1,
- };
- }
- const checkTarEntrySafety = createTarEntrySafetyChecker({
- rootDir: targetDir,
+ const checkTarEntrySafety = createTarEntryPreflightChecker({
+ rootDir: destinationRealDir,
stripComponents: strip,
escapeLabel: "targetDir",
});
- for (let i = 0; i < entries.length; i += 1) {
- const entryPath = entries[i];
- const entryMeta = metadata[i];
+ for (let i = 0; i < preflight.entries.length; i += 1) {
+ const entryPath = preflight.entries[i];
+ const entryMeta = preflight.metadata[i];
if (!entryPath || !entryMeta) {
return {
- stdout: verboseResult.stdout,
+ stdout: "",
stderr: "tar metadata parse failure",
code: 1,
};
@@ -120,20 +209,20 @@ export async function extractArchive(params: {
});
}
- const postPreflightHash = await hashFileSha256(archivePath);
- if (postPreflightHash !== preflightHash) {
- return {
- stdout: "",
- stderr: "tar archive changed during safety preflight; refusing to extract",
- code: 1,
- };
+ const hashFailure = await verifyArchiveHashStable({
+ archivePath,
+ expectedHash: preflightHash,
+ });
+ if (hashFailure) {
+ return hashFailure;
}
- const argv = ["tar", "xf", archivePath, "-C", targetDir];
- if (strip > 0) {
- argv.push("--strip-components", String(strip));
- }
- return await runCommandWithTimeout(argv, { timeoutMs });
+ return await extractTarBz2WithStaging({
+ archivePath,
+ destinationRealDir,
+ stripComponents: strip,
+ timeoutMs,
+ });
}
return { stdout: "", stderr: `unsupported archive type: ${archiveType}`, code: null };
diff --git a/src/agents/skills-install.download.test.ts b/src/agents/skills-install.download.test.ts
index 0c357089678..cee0d37b876 100644
--- a/src/agents/skills-install.download.test.ts
+++ b/src/agents/skills-install.download.test.ts
@@ -425,4 +425,47 @@ describe("installDownloadSpec extraction safety (tar.bz2)", () => {
.some((call) => (call[0] as string[])[1] === "xf");
expect(extractionAttempted).toBe(false);
});
+
+ it("rejects tar.bz2 entries that traverse pre-existing targetDir symlinks", async () => {
+ const entry = buildEntry("tbz2-targetdir-symlink");
+ const targetDir = path.join(resolveSkillToolsRootDir(entry), "target");
+ const outsideDir = path.join(workspaceDir, "tbz2-targetdir-outside");
+ await fs.mkdir(targetDir, { recursive: true });
+ await fs.mkdir(outsideDir, { recursive: true });
+ await fs.symlink(
+ outsideDir,
+ path.join(targetDir, "escape"),
+ process.platform === "win32" ? "junction" : undefined,
+ );
+
+ mockArchiveResponse(new Uint8Array([1, 2, 3]));
+
+ runCommandWithTimeoutMock.mockImplementation(async (...argv: unknown[]) => {
+ const cmd = (argv[0] ?? []) as string[];
+ if (cmd[0] === "tar" && cmd[1] === "tf") {
+ return runCommandResult({ stdout: "escape/pwn.txt\n" });
+ }
+ if (cmd[0] === "tar" && cmd[1] === "tvf") {
+ return runCommandResult({ stdout: "-rw-r--r-- 0 0 0 0 Jan 1 00:00 escape/pwn.txt\n" });
+ }
+ if (cmd[0] === "tar" && cmd[1] === "xf") {
+ const stagingDir = String(cmd[cmd.indexOf("-C") + 1] ?? "");
+ await fs.mkdir(path.join(stagingDir, "escape"), { recursive: true });
+ await fs.writeFile(path.join(stagingDir, "escape", "pwn.txt"), "owned");
+ return runCommandResult({ stdout: "ok" });
+ }
+ return runCommandResult();
+ });
+
+ const result = await installDownloadSkill({
+ name: "tbz2-targetdir-symlink",
+ url: "https://example.invalid/evil.tbz2",
+ archive: "tar.bz2",
+ targetDir,
+ });
+
+ expect(result.ok).toBe(false);
+ expect(result.stderr.toLowerCase()).toContain("archive entry traverses symlink in destination");
+ expect(await fileExists(path.join(outsideDir, "pwn.txt"))).toBe(false);
+ });
});
diff --git a/src/agents/subagent-capabilities.ts b/src/agents/subagent-capabilities.ts
new file mode 100644
index 00000000000..5350b4f6321
--- /dev/null
+++ b/src/agents/subagent-capabilities.ts
@@ -0,0 +1,156 @@
+import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js";
+import type { OpenClawConfig } from "../config/config.js";
+import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
+import { isSubagentSessionKey, parseAgentSessionKey } from "../routing/session-key.js";
+import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
+
+export const SUBAGENT_SESSION_ROLES = ["main", "orchestrator", "leaf"] as const;
+export type SubagentSessionRole = (typeof SUBAGENT_SESSION_ROLES)[number];
+
+export const SUBAGENT_CONTROL_SCOPES = ["children", "none"] as const;
+export type SubagentControlScope = (typeof SUBAGENT_CONTROL_SCOPES)[number];
+
+type SessionCapabilityEntry = {
+ sessionId?: unknown;
+ spawnDepth?: unknown;
+ subagentRole?: unknown;
+ subagentControlScope?: unknown;
+};
+
+function normalizeSessionKey(value: unknown): string | undefined {
+ if (typeof value !== "string") {
+ return undefined;
+ }
+ const trimmed = value.trim();
+ return trimmed || undefined;
+}
+
+function normalizeSubagentRole(value: unknown): SubagentSessionRole | undefined {
+ if (typeof value !== "string") {
+ return undefined;
+ }
+ const trimmed = value.trim().toLowerCase();
+ return SUBAGENT_SESSION_ROLES.find((entry) => entry === trimmed);
+}
+
+function normalizeSubagentControlScope(value: unknown): SubagentControlScope | undefined {
+ if (typeof value !== "string") {
+ return undefined;
+ }
+ const trimmed = value.trim().toLowerCase();
+ return SUBAGENT_CONTROL_SCOPES.find((entry) => entry === trimmed);
+}
+
+function readSessionStore(storePath: string): Record {
+ try {
+ return loadSessionStore(storePath);
+ } catch {
+ return {};
+ }
+}
+
+function findEntryBySessionId(
+ store: Record,
+ sessionId: string,
+): SessionCapabilityEntry | undefined {
+ const normalizedSessionId = normalizeSessionKey(sessionId);
+ if (!normalizedSessionId) {
+ return undefined;
+ }
+ for (const entry of Object.values(store)) {
+ const candidateSessionId = normalizeSessionKey(entry?.sessionId);
+ if (candidateSessionId === normalizedSessionId) {
+ return entry;
+ }
+ }
+ return undefined;
+}
+
+function resolveSessionCapabilityEntry(params: {
+ sessionKey: string;
+ cfg?: OpenClawConfig;
+ store?: Record;
+}): SessionCapabilityEntry | undefined {
+ if (params.store) {
+ return params.store[params.sessionKey] ?? findEntryBySessionId(params.store, params.sessionKey);
+ }
+ if (!params.cfg) {
+ return undefined;
+ }
+ const parsed = parseAgentSessionKey(params.sessionKey);
+ if (!parsed?.agentId) {
+ return undefined;
+ }
+ const storePath = resolveStorePath(params.cfg.session?.store, { agentId: parsed.agentId });
+ const store = readSessionStore(storePath);
+ return store[params.sessionKey] ?? findEntryBySessionId(store, params.sessionKey);
+}
+
+export function resolveSubagentRoleForDepth(params: {
+ depth: number;
+ maxSpawnDepth?: number;
+}): SubagentSessionRole {
+ const depth = Number.isInteger(params.depth) ? Math.max(0, params.depth) : 0;
+ const maxSpawnDepth =
+ typeof params.maxSpawnDepth === "number" && Number.isFinite(params.maxSpawnDepth)
+ ? Math.max(1, Math.floor(params.maxSpawnDepth))
+ : DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH;
+ if (depth <= 0) {
+ return "main";
+ }
+ return depth < maxSpawnDepth ? "orchestrator" : "leaf";
+}
+
+export function resolveSubagentControlScopeForRole(
+ role: SubagentSessionRole,
+): SubagentControlScope {
+ return role === "leaf" ? "none" : "children";
+}
+
+export function resolveSubagentCapabilities(params: { depth: number; maxSpawnDepth?: number }) {
+ const role = resolveSubagentRoleForDepth(params);
+ const controlScope = resolveSubagentControlScopeForRole(role);
+ return {
+ depth: Math.max(0, Math.floor(params.depth)),
+ role,
+ controlScope,
+ canSpawn: role === "main" || role === "orchestrator",
+ canControlChildren: controlScope === "children",
+ };
+}
+
+export function resolveStoredSubagentCapabilities(
+ sessionKey: string | undefined | null,
+ opts?: {
+ cfg?: OpenClawConfig;
+ store?: Record;
+ },
+) {
+ const normalizedSessionKey = normalizeSessionKey(sessionKey);
+ const maxSpawnDepth =
+ opts?.cfg?.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH;
+ const depth = getSubagentDepthFromSessionStore(normalizedSessionKey, {
+ cfg: opts?.cfg,
+ store: opts?.store,
+ });
+ if (!normalizedSessionKey || !isSubagentSessionKey(normalizedSessionKey)) {
+ return resolveSubagentCapabilities({ depth, maxSpawnDepth });
+ }
+ const entry = resolveSessionCapabilityEntry({
+ sessionKey: normalizedSessionKey,
+ cfg: opts?.cfg,
+ store: opts?.store,
+ });
+ const storedRole = normalizeSubagentRole(entry?.subagentRole);
+ const storedControlScope = normalizeSubagentControlScope(entry?.subagentControlScope);
+ const fallback = resolveSubagentCapabilities({ depth, maxSpawnDepth });
+ const role = storedRole ?? fallback.role;
+ const controlScope = storedControlScope ?? resolveSubagentControlScopeForRole(role);
+ return {
+ depth,
+ role,
+ controlScope,
+ canSpawn: role === "main" || role === "orchestrator",
+ canControlChildren: controlScope === "children",
+ };
+}
diff --git a/src/agents/subagent-control.ts b/src/agents/subagent-control.ts
new file mode 100644
index 00000000000..528a84eebd3
--- /dev/null
+++ b/src/agents/subagent-control.ts
@@ -0,0 +1,768 @@
+import crypto from "node:crypto";
+import { clearSessionQueues } from "../auto-reply/reply/queue.js";
+import {
+ resolveSubagentLabel,
+ resolveSubagentTargetFromRuns,
+ sortSubagentRuns,
+ type SubagentTargetResolution,
+} from "../auto-reply/reply/subagents-utils.js";
+import type { OpenClawConfig } from "../config/config.js";
+import type { SessionEntry } from "../config/sessions.js";
+import { loadSessionStore, resolveStorePath, updateSessionStore } from "../config/sessions.js";
+import { callGateway } from "../gateway/call.js";
+import { logVerbose } from "../globals.js";
+import {
+ isSubagentSessionKey,
+ parseAgentSessionKey,
+ type ParsedAgentSessionKey,
+} from "../routing/session-key.js";
+import {
+ formatDurationCompact,
+ formatTokenUsageDisplay,
+ resolveTotalTokens,
+ truncateLine,
+} from "../shared/subagents-format.js";
+import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js";
+import { AGENT_LANE_SUBAGENT } from "./lanes.js";
+import { abortEmbeddedPiRun } from "./pi-embedded.js";
+import { resolveStoredSubagentCapabilities } from "./subagent-capabilities.js";
+import {
+ clearSubagentRunSteerRestart,
+ countPendingDescendantRuns,
+ listSubagentRunsForController,
+ markSubagentRunTerminated,
+ markSubagentRunForSteerRestart,
+ replaceSubagentRunAfterSteer,
+ type SubagentRunRecord,
+} from "./subagent-registry.js";
+import {
+ extractAssistantText,
+ resolveInternalSessionKey,
+ resolveMainSessionAlias,
+ stripToolMessages,
+} from "./tools/sessions-helpers.js";
+
+export const DEFAULT_RECENT_MINUTES = 30;
+export const MAX_RECENT_MINUTES = 24 * 60;
+export const MAX_STEER_MESSAGE_CHARS = 4_000;
+export const STEER_RATE_LIMIT_MS = 2_000;
+export const STEER_ABORT_SETTLE_TIMEOUT_MS = 5_000;
+
+const steerRateLimit = new Map();
+
+export type SessionEntryResolution = {
+ storePath: string;
+ entry: SessionEntry | undefined;
+};
+
+export type ResolvedSubagentController = {
+ controllerSessionKey: string;
+ callerSessionKey: string;
+ callerIsSubagent: boolean;
+ controlScope: "children" | "none";
+};
+
+export type SubagentListItem = {
+ index: number;
+ line: string;
+ runId: string;
+ sessionKey: string;
+ label: string;
+ task: string;
+ status: string;
+ pendingDescendants: number;
+ runtime: string;
+ runtimeMs: number;
+ model?: string;
+ totalTokens?: number;
+ startedAt?: number;
+ endedAt?: number;
+};
+
+export type BuiltSubagentList = {
+ total: number;
+ active: SubagentListItem[];
+ recent: SubagentListItem[];
+ text: string;
+};
+
+function resolveStorePathForKey(
+ cfg: OpenClawConfig,
+ key: string,
+ parsed?: ParsedAgentSessionKey | null,
+) {
+ return resolveStorePath(cfg.session?.store, {
+ agentId: parsed?.agentId,
+ });
+}
+
+export function resolveSessionEntryForKey(params: {
+ cfg: OpenClawConfig;
+ key: string;
+ cache: Map>;
+}): SessionEntryResolution {
+ const parsed = parseAgentSessionKey(params.key);
+ const storePath = resolveStorePathForKey(params.cfg, params.key, parsed);
+ let store = params.cache.get(storePath);
+ if (!store) {
+ store = loadSessionStore(storePath);
+ params.cache.set(storePath, store);
+ }
+ return {
+ storePath,
+ entry: store[params.key],
+ };
+}
+
+export function resolveSubagentController(params: {
+ cfg: OpenClawConfig;
+ agentSessionKey?: string;
+}): ResolvedSubagentController {
+ const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
+ const callerRaw = params.agentSessionKey?.trim() || alias;
+ const callerSessionKey = resolveInternalSessionKey({
+ key: callerRaw,
+ alias,
+ mainKey,
+ });
+ if (!isSubagentSessionKey(callerSessionKey)) {
+ return {
+ controllerSessionKey: callerSessionKey,
+ callerSessionKey,
+ callerIsSubagent: false,
+ controlScope: "children",
+ };
+ }
+ const capabilities = resolveStoredSubagentCapabilities(callerSessionKey, {
+ cfg: params.cfg,
+ });
+ return {
+ controllerSessionKey: callerSessionKey,
+ callerSessionKey,
+ callerIsSubagent: true,
+ controlScope: capabilities.controlScope,
+ };
+}
+
+export function listControlledSubagentRuns(controllerSessionKey: string): SubagentRunRecord[] {
+ return sortSubagentRuns(listSubagentRunsForController(controllerSessionKey));
+}
+
+export function createPendingDescendantCounter() {
+ const pendingDescendantCache = new Map();
+ return (sessionKey: string) => {
+ if (pendingDescendantCache.has(sessionKey)) {
+ return pendingDescendantCache.get(sessionKey) ?? 0;
+ }
+ const pending = Math.max(0, countPendingDescendantRuns(sessionKey));
+ pendingDescendantCache.set(sessionKey, pending);
+ return pending;
+ };
+}
+
+export function isActiveSubagentRun(
+ entry: SubagentRunRecord,
+ pendingDescendantCount: (sessionKey: string) => number,
+) {
+ return !entry.endedAt || pendingDescendantCount(entry.childSessionKey) > 0;
+}
+
+function resolveRunStatus(entry: SubagentRunRecord, options?: { pendingDescendants?: number }) {
+ const pendingDescendants = Math.max(0, options?.pendingDescendants ?? 0);
+ if (pendingDescendants > 0) {
+ const childLabel = pendingDescendants === 1 ? "child" : "children";
+ return `active (waiting on ${pendingDescendants} ${childLabel})`;
+ }
+ if (!entry.endedAt) {
+ return "running";
+ }
+ const status = entry.outcome?.status ?? "done";
+ if (status === "ok") {
+ return "done";
+ }
+ if (status === "error") {
+ return "failed";
+ }
+ return status;
+}
+
+function resolveModelRef(entry?: SessionEntry) {
+ const model = typeof entry?.model === "string" ? entry.model.trim() : "";
+ const provider = typeof entry?.modelProvider === "string" ? entry.modelProvider.trim() : "";
+ if (model.includes("/")) {
+ return model;
+ }
+ if (model && provider) {
+ return `${provider}/${model}`;
+ }
+ if (model) {
+ return model;
+ }
+ if (provider) {
+ return provider;
+ }
+ const overrideModel = typeof entry?.modelOverride === "string" ? entry.modelOverride.trim() : "";
+ const overrideProvider =
+ typeof entry?.providerOverride === "string" ? entry.providerOverride.trim() : "";
+ if (overrideModel.includes("/")) {
+ return overrideModel;
+ }
+ if (overrideModel && overrideProvider) {
+ return `${overrideProvider}/${overrideModel}`;
+ }
+ if (overrideModel) {
+ return overrideModel;
+ }
+ return overrideProvider || undefined;
+}
+
+function resolveModelDisplay(entry?: SessionEntry, fallbackModel?: string) {
+ const modelRef = resolveModelRef(entry) || fallbackModel || undefined;
+ if (!modelRef) {
+ return "model n/a";
+ }
+ const slash = modelRef.lastIndexOf("/");
+ if (slash >= 0 && slash < modelRef.length - 1) {
+ return modelRef.slice(slash + 1);
+ }
+ return modelRef;
+}
+
+function buildListText(params: {
+ active: Array<{ line: string }>;
+ recent: Array<{ line: string }>;
+ recentMinutes: number;
+}) {
+ const lines: string[] = [];
+ lines.push("active subagents:");
+ if (params.active.length === 0) {
+ lines.push("(none)");
+ } else {
+ lines.push(...params.active.map((entry) => entry.line));
+ }
+ lines.push("");
+ lines.push(`recent (last ${params.recentMinutes}m):`);
+ if (params.recent.length === 0) {
+ lines.push("(none)");
+ } else {
+ lines.push(...params.recent.map((entry) => entry.line));
+ }
+ return lines.join("\n");
+}
+
+export function buildSubagentList(params: {
+ cfg: OpenClawConfig;
+ runs: SubagentRunRecord[];
+ recentMinutes: number;
+ taskMaxChars?: number;
+}): BuiltSubagentList {
+ const now = Date.now();
+ const recentCutoff = now - params.recentMinutes * 60_000;
+ const cache = new Map>();
+ const pendingDescendantCount = createPendingDescendantCounter();
+ let index = 1;
+ const buildListEntry = (entry: SubagentRunRecord, runtimeMs: number) => {
+ const sessionEntry = resolveSessionEntryForKey({
+ cfg: params.cfg,
+ key: entry.childSessionKey,
+ cache,
+ }).entry;
+ const totalTokens = resolveTotalTokens(sessionEntry);
+ const usageText = formatTokenUsageDisplay(sessionEntry);
+ const pendingDescendants = pendingDescendantCount(entry.childSessionKey);
+ const status = resolveRunStatus(entry, {
+ pendingDescendants,
+ });
+ const runtime = formatDurationCompact(runtimeMs);
+ const label = truncateLine(resolveSubagentLabel(entry), 48);
+ const task = truncateLine(entry.task.trim(), params.taskMaxChars ?? 72);
+ const line = `${index}. ${label} (${resolveModelDisplay(sessionEntry, entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`;
+ const view: SubagentListItem = {
+ index,
+ line,
+ runId: entry.runId,
+ sessionKey: entry.childSessionKey,
+ label,
+ task,
+ status,
+ pendingDescendants,
+ runtime,
+ runtimeMs,
+ model: resolveModelRef(sessionEntry) || entry.model,
+ totalTokens,
+ startedAt: entry.startedAt,
+ ...(entry.endedAt ? { endedAt: entry.endedAt } : {}),
+ };
+ index += 1;
+ return view;
+ };
+ const active = params.runs
+ .filter((entry) => isActiveSubagentRun(entry, pendingDescendantCount))
+ .map((entry) => buildListEntry(entry, now - (entry.startedAt ?? entry.createdAt)));
+ const recent = params.runs
+ .filter(
+ (entry) =>
+ !isActiveSubagentRun(entry, pendingDescendantCount) &&
+ !!entry.endedAt &&
+ (entry.endedAt ?? 0) >= recentCutoff,
+ )
+ .map((entry) =>
+ buildListEntry(entry, (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt)),
+ );
+ return {
+ total: params.runs.length,
+ active,
+ recent,
+ text: buildListText({ active, recent, recentMinutes: params.recentMinutes }),
+ };
+}
+
+function ensureControllerOwnsRun(params: {
+ controller: ResolvedSubagentController;
+ entry: SubagentRunRecord;
+}) {
+ const owner = params.entry.controllerSessionKey?.trim() || params.entry.requesterSessionKey;
+ if (owner === params.controller.controllerSessionKey) {
+ return undefined;
+ }
+ return "Subagents can only control runs spawned from their own session.";
+}
+
+async function killSubagentRun(params: {
+ cfg: OpenClawConfig;
+ entry: SubagentRunRecord;
+ cache: Map>;
+}): Promise<{ killed: boolean; sessionId?: string }> {
+ if (params.entry.endedAt) {
+ return { killed: false };
+ }
+ const childSessionKey = params.entry.childSessionKey;
+ const resolved = resolveSessionEntryForKey({
+ cfg: params.cfg,
+ key: childSessionKey,
+ cache: params.cache,
+ });
+ const sessionId = resolved.entry?.sessionId;
+ const aborted = sessionId ? abortEmbeddedPiRun(sessionId) : false;
+ const cleared = clearSessionQueues([childSessionKey, sessionId]);
+ if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
+ logVerbose(
+ `subagents control kill: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
+ );
+ }
+ if (resolved.entry) {
+ await updateSessionStore(resolved.storePath, (store) => {
+ const current = store[childSessionKey];
+ if (!current) {
+ return;
+ }
+ current.abortedLastRun = true;
+ current.updatedAt = Date.now();
+ store[childSessionKey] = current;
+ });
+ }
+ const marked = markSubagentRunTerminated({
+ runId: params.entry.runId,
+ childSessionKey,
+ reason: "killed",
+ });
+ const killed = marked > 0 || aborted || cleared.followupCleared > 0 || cleared.laneCleared > 0;
+ return { killed, sessionId };
+}
+
+async function cascadeKillChildren(params: {
+ cfg: OpenClawConfig;
+ parentChildSessionKey: string;
+ cache: Map>;
+ seenChildSessionKeys?: Set;
+}): Promise<{ killed: number; labels: string[] }> {
+ const childRuns = listSubagentRunsForController(params.parentChildSessionKey);
+ const seenChildSessionKeys = params.seenChildSessionKeys ?? new Set();
+ let killed = 0;
+ const labels: string[] = [];
+
+ for (const run of childRuns) {
+ const childKey = run.childSessionKey?.trim();
+ if (!childKey || seenChildSessionKeys.has(childKey)) {
+ continue;
+ }
+ seenChildSessionKeys.add(childKey);
+
+ if (!run.endedAt) {
+ const stopResult = await killSubagentRun({
+ cfg: params.cfg,
+ entry: run,
+ cache: params.cache,
+ });
+ if (stopResult.killed) {
+ killed += 1;
+ labels.push(resolveSubagentLabel(run));
+ }
+ }
+
+ const cascade = await cascadeKillChildren({
+ cfg: params.cfg,
+ parentChildSessionKey: childKey,
+ cache: params.cache,
+ seenChildSessionKeys,
+ });
+ killed += cascade.killed;
+ labels.push(...cascade.labels);
+ }
+
+ return { killed, labels };
+}
+
+export async function killAllControlledSubagentRuns(params: {
+ cfg: OpenClawConfig;
+ controller: ResolvedSubagentController;
+ runs: SubagentRunRecord[];
+}) {
+ if (params.controller.controlScope !== "children") {
+ return {
+ status: "forbidden" as const,
+ error: "Leaf subagents cannot control other sessions.",
+ killed: 0,
+ labels: [],
+ };
+ }
+ const cache = new Map>();
+ const seenChildSessionKeys = new Set();
+ const killedLabels: string[] = [];
+ let killed = 0;
+ for (const entry of params.runs) {
+ const childKey = entry.childSessionKey?.trim();
+ if (!childKey || seenChildSessionKeys.has(childKey)) {
+ continue;
+ }
+ seenChildSessionKeys.add(childKey);
+
+ if (!entry.endedAt) {
+ const stopResult = await killSubagentRun({ cfg: params.cfg, entry, cache });
+ if (stopResult.killed) {
+ killed += 1;
+ killedLabels.push(resolveSubagentLabel(entry));
+ }
+ }
+
+ const cascade = await cascadeKillChildren({
+ cfg: params.cfg,
+ parentChildSessionKey: childKey,
+ cache,
+ seenChildSessionKeys,
+ });
+ killed += cascade.killed;
+ killedLabels.push(...cascade.labels);
+ }
+ return { status: "ok" as const, killed, labels: killedLabels };
+}
+
+export async function killControlledSubagentRun(params: {
+ cfg: OpenClawConfig;
+ controller: ResolvedSubagentController;
+ entry: SubagentRunRecord;
+}) {
+ const ownershipError = ensureControllerOwnsRun({
+ controller: params.controller,
+ entry: params.entry,
+ });
+ if (ownershipError) {
+ return {
+ status: "forbidden" as const,
+ runId: params.entry.runId,
+ sessionKey: params.entry.childSessionKey,
+ error: ownershipError,
+ };
+ }
+ if (params.controller.controlScope !== "children") {
+ return {
+ status: "forbidden" as const,
+ runId: params.entry.runId,
+ sessionKey: params.entry.childSessionKey,
+ error: "Leaf subagents cannot control other sessions.",
+ };
+ }
+ const killCache = new Map>();
+ const stopResult = await killSubagentRun({
+ cfg: params.cfg,
+ entry: params.entry,
+ cache: killCache,
+ });
+ const seenChildSessionKeys = new Set();
+ const targetChildKey = params.entry.childSessionKey?.trim();
+ if (targetChildKey) {
+ seenChildSessionKeys.add(targetChildKey);
+ }
+ const cascade = await cascadeKillChildren({
+ cfg: params.cfg,
+ parentChildSessionKey: params.entry.childSessionKey,
+ cache: killCache,
+ seenChildSessionKeys,
+ });
+ if (!stopResult.killed && cascade.killed === 0) {
+ return {
+ status: "done" as const,
+ runId: params.entry.runId,
+ sessionKey: params.entry.childSessionKey,
+ label: resolveSubagentLabel(params.entry),
+ text: `${resolveSubagentLabel(params.entry)} is already finished.`,
+ };
+ }
+ const cascadeText =
+ cascade.killed > 0 ? ` (+ ${cascade.killed} descendant${cascade.killed === 1 ? "" : "s"})` : "";
+ return {
+ status: "ok" as const,
+ runId: params.entry.runId,
+ sessionKey: params.entry.childSessionKey,
+ label: resolveSubagentLabel(params.entry),
+ cascadeKilled: cascade.killed,
+ cascadeLabels: cascade.killed > 0 ? cascade.labels : undefined,
+ text: stopResult.killed
+ ? `killed ${resolveSubagentLabel(params.entry)}${cascadeText}.`
+ : `killed ${cascade.killed} descendant${cascade.killed === 1 ? "" : "s"} of ${resolveSubagentLabel(params.entry)}.`,
+ };
+}
+
+export async function steerControlledSubagentRun(params: {
+ cfg: OpenClawConfig;
+ controller: ResolvedSubagentController;
+ entry: SubagentRunRecord;
+ message: string;
+}): Promise<
+ | {
+ status: "forbidden" | "done" | "rate_limited" | "error";
+ runId?: string;
+ sessionKey: string;
+ sessionId?: string;
+ error?: string;
+ text?: string;
+ }
+ | {
+ status: "accepted";
+ runId: string;
+ sessionKey: string;
+ sessionId?: string;
+ mode: "restart";
+ label: string;
+ text: string;
+ }
+> {
+ const ownershipError = ensureControllerOwnsRun({
+ controller: params.controller,
+ entry: params.entry,
+ });
+ if (ownershipError) {
+ return {
+ status: "forbidden",
+ runId: params.entry.runId,
+ sessionKey: params.entry.childSessionKey,
+ error: ownershipError,
+ };
+ }
+ if (params.controller.controlScope !== "children") {
+ return {
+ status: "forbidden",
+ runId: params.entry.runId,
+ sessionKey: params.entry.childSessionKey,
+ error: "Leaf subagents cannot control other sessions.",
+ };
+ }
+ if (params.entry.endedAt) {
+ return {
+ status: "done",
+ runId: params.entry.runId,
+ sessionKey: params.entry.childSessionKey,
+ text: `${resolveSubagentLabel(params.entry)} is already finished.`,
+ };
+ }
+ if (params.controller.callerSessionKey === params.entry.childSessionKey) {
+ return {
+ status: "forbidden",
+ runId: params.entry.runId,
+ sessionKey: params.entry.childSessionKey,
+ error: "Subagents cannot steer themselves.",
+ };
+ }
+
+ const rateKey = `${params.controller.callerSessionKey}:${params.entry.childSessionKey}`;
+ if (process.env.VITEST !== "true") {
+ const now = Date.now();
+ const lastSentAt = steerRateLimit.get(rateKey) ?? 0;
+ if (now - lastSentAt < STEER_RATE_LIMIT_MS) {
+ return {
+ status: "rate_limited",
+ runId: params.entry.runId,
+ sessionKey: params.entry.childSessionKey,
+ error: "Steer rate limit exceeded. Wait a moment before sending another steer.",
+ };
+ }
+ steerRateLimit.set(rateKey, now);
+ }
+
+ markSubagentRunForSteerRestart(params.entry.runId);
+
+ const targetSession = resolveSessionEntryForKey({
+ cfg: params.cfg,
+ key: params.entry.childSessionKey,
+ cache: new Map>(),
+ });
+ const sessionId =
+ typeof targetSession.entry?.sessionId === "string" && targetSession.entry.sessionId.trim()
+ ? targetSession.entry.sessionId.trim()
+ : undefined;
+
+ if (sessionId) {
+ abortEmbeddedPiRun(sessionId);
+ }
+ const cleared = clearSessionQueues([params.entry.childSessionKey, sessionId]);
+ if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
+ logVerbose(
+ `subagents control steer: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
+ );
+ }
+
+ try {
+ await callGateway({
+ method: "agent.wait",
+ params: {
+ runId: params.entry.runId,
+ timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS,
+ },
+ timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS + 2_000,
+ });
+ } catch {
+ // Continue even if wait fails; steer should still be attempted.
+ }
+
+ const idempotencyKey = crypto.randomUUID();
+ let runId: string = idempotencyKey;
+ try {
+ const response = await callGateway<{ runId: string }>({
+ method: "agent",
+ params: {
+ message: params.message,
+ sessionKey: params.entry.childSessionKey,
+ sessionId,
+ idempotencyKey,
+ deliver: false,
+ channel: INTERNAL_MESSAGE_CHANNEL,
+ lane: AGENT_LANE_SUBAGENT,
+ timeout: 0,
+ },
+ timeoutMs: 10_000,
+ });
+ if (typeof response?.runId === "string" && response.runId) {
+ runId = response.runId;
+ }
+ } catch (err) {
+ clearSubagentRunSteerRestart(params.entry.runId);
+ const error = err instanceof Error ? err.message : String(err);
+ return {
+ status: "error",
+ runId,
+ sessionKey: params.entry.childSessionKey,
+ sessionId,
+ error,
+ };
+ }
+
+ replaceSubagentRunAfterSteer({
+ previousRunId: params.entry.runId,
+ nextRunId: runId,
+ fallback: params.entry,
+ runTimeoutSeconds: params.entry.runTimeoutSeconds ?? 0,
+ });
+
+ return {
+ status: "accepted",
+ runId,
+ sessionKey: params.entry.childSessionKey,
+ sessionId,
+ mode: "restart",
+ label: resolveSubagentLabel(params.entry),
+ text: `steered ${resolveSubagentLabel(params.entry)}.`,
+ };
+}
+
+export async function sendControlledSubagentMessage(params: {
+ cfg: OpenClawConfig;
+ entry: SubagentRunRecord;
+ message: string;
+}) {
+ const targetSessionKey = params.entry.childSessionKey;
+ const parsed = parseAgentSessionKey(targetSessionKey);
+ const storePath = resolveStorePath(params.cfg.session?.store, { agentId: parsed?.agentId });
+ const store = loadSessionStore(storePath);
+ const targetSessionEntry = store[targetSessionKey];
+ const targetSessionId =
+ typeof targetSessionEntry?.sessionId === "string" && targetSessionEntry.sessionId.trim()
+ ? targetSessionEntry.sessionId.trim()
+ : undefined;
+
+ const idempotencyKey = crypto.randomUUID();
+ let runId: string = idempotencyKey;
+ const response = await callGateway<{ runId: string }>({
+ method: "agent",
+ params: {
+ message: params.message,
+ sessionKey: targetSessionKey,
+ sessionId: targetSessionId,
+ idempotencyKey,
+ deliver: false,
+ channel: INTERNAL_MESSAGE_CHANNEL,
+ lane: AGENT_LANE_SUBAGENT,
+ timeout: 0,
+ },
+ timeoutMs: 10_000,
+ });
+ const responseRunId = typeof response?.runId === "string" ? response.runId : undefined;
+ if (responseRunId) {
+ runId = responseRunId;
+ }
+
+ const waitMs = 30_000;
+ const wait = await callGateway<{ status?: string; error?: string }>({
+ method: "agent.wait",
+ params: { runId, timeoutMs: waitMs },
+ timeoutMs: waitMs + 2_000,
+ });
+ if (wait?.status === "timeout") {
+ return { status: "timeout" as const, runId };
+ }
+ if (wait?.status === "error") {
+ const waitError = typeof wait.error === "string" ? wait.error : "unknown error";
+ return { status: "error" as const, runId, error: waitError };
+ }
+
+ const history = await callGateway<{ messages: Array }>({
+ method: "chat.history",
+ params: { sessionKey: targetSessionKey, limit: 50 },
+ });
+ const filtered = stripToolMessages(Array.isArray(history?.messages) ? history.messages : []);
+ const last = filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
+ const replyText = last ? extractAssistantText(last) : undefined;
+ return { status: "ok" as const, runId, replyText };
+}
+
+export function resolveControlledSubagentTarget(
+ runs: SubagentRunRecord[],
+ token: string | undefined,
+ options?: { recentMinutes?: number; isActive?: (entry: SubagentRunRecord) => boolean },
+): SubagentTargetResolution {
+ return resolveSubagentTargetFromRuns({
+ runs,
+ token,
+ recentWindowMinutes: options?.recentMinutes ?? DEFAULT_RECENT_MINUTES,
+ label: (entry) => resolveSubagentLabel(entry),
+ isActive: options?.isActive,
+ errors: {
+ missingTarget: "Missing subagent target.",
+ invalidIndex: (value) => `Invalid subagent index: ${value}`,
+ unknownSession: (value) => `Unknown subagent session: ${value}`,
+ ambiguousLabel: (value) => `Ambiguous subagent label: ${value}`,
+ ambiguousLabelPrefix: (value) => `Ambiguous subagent label prefix: ${value}`,
+ ambiguousRunIdPrefix: (value) => `Ambiguous subagent run id prefix: ${value}`,
+ unknownTarget: (value) => `Unknown subagent target: ${value}`,
+ },
+ });
+}
diff --git a/src/agents/subagent-registry-queries.ts b/src/agents/subagent-registry-queries.ts
index 7c40444d6f1..4ddf23bf2db 100644
--- a/src/agents/subagent-registry-queries.ts
+++ b/src/agents/subagent-registry-queries.ts
@@ -1,6 +1,10 @@
import type { DeliveryContext } from "../utils/delivery-context.js";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
+function resolveControllerSessionKey(entry: SubagentRunRecord): string {
+ return entry.controllerSessionKey?.trim() || entry.requesterSessionKey;
+}
+
export function findRunIdsByChildSessionKeyFromRuns(
runs: Map,
childSessionKey: string,
@@ -51,6 +55,17 @@ export function listRunsForRequesterFromRuns(
});
}
+export function listRunsForControllerFromRuns(
+ runs: Map,
+ controllerSessionKey: string,
+): SubagentRunRecord[] {
+ const key = controllerSessionKey.trim();
+ if (!key) {
+ return [];
+ }
+ return [...runs.values()].filter((entry) => resolveControllerSessionKey(entry) === key);
+}
+
function findLatestRunForChildSession(
runs: Map,
childSessionKey: string,
@@ -104,9 +119,9 @@ export function shouldIgnorePostCompletionAnnounceForSessionFromRuns(
export function countActiveRunsForSessionFromRuns(
runs: Map,
- requesterSessionKey: string,
+ controllerSessionKey: string,
): number {
- const key = requesterSessionKey.trim();
+ const key = controllerSessionKey.trim();
if (!key) {
return 0;
}
@@ -123,7 +138,7 @@ export function countActiveRunsForSessionFromRuns(
let count = 0;
for (const entry of runs.values()) {
- if (entry.requesterSessionKey !== key) {
+ if (resolveControllerSessionKey(entry) !== key) {
continue;
}
if (typeof entry.endedAt !== "number") {
diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts
index 9ef58933f35..477544bdd3d 100644
--- a/src/agents/subagent-registry.ts
+++ b/src/agents/subagent-registry.ts
@@ -45,6 +45,7 @@ import {
countPendingDescendantRunsExcludingRunFromRuns,
countPendingDescendantRunsFromRuns,
findRunIdsByChildSessionKeyFromRuns,
+ listRunsForControllerFromRuns,
listDescendantRunsForRequesterFromRuns,
listRunsForRequesterFromRuns,
resolveRequesterForChildSessionFromRuns,
@@ -1146,6 +1147,7 @@ export function replaceSubagentRunAfterSteer(params: {
export function registerSubagentRun(params: {
runId: string;
childSessionKey: string;
+ controllerSessionKey?: string;
requesterSessionKey: string;
requesterOrigin?: DeliveryContext;
requesterDisplayKey: string;
@@ -1173,6 +1175,7 @@ export function registerSubagentRun(params: {
subagentRuns.set(params.runId, {
runId: params.runId,
childSessionKey: params.childSessionKey,
+ controllerSessionKey: params.controllerSessionKey ?? params.requesterSessionKey,
requesterSessionKey: params.requesterSessionKey,
requesterOrigin,
requesterDisplayKey: params.requesterDisplayKey,
@@ -1419,6 +1422,13 @@ export function listSubagentRunsForRequester(
return listRunsForRequesterFromRuns(subagentRuns, requesterSessionKey, options);
}
+export function listSubagentRunsForController(controllerSessionKey: string): SubagentRunRecord[] {
+ return listRunsForControllerFromRuns(
+ getSubagentRunsSnapshotForRead(subagentRuns),
+ controllerSessionKey,
+ );
+}
+
export function countActiveRunsForSession(requesterSessionKey: string): number {
return countActiveRunsForSessionFromRuns(
getSubagentRunsSnapshotForRead(subagentRuns),
diff --git a/src/agents/subagent-registry.types.ts b/src/agents/subagent-registry.types.ts
index a153ddbadd7..f5dc56775ae 100644
--- a/src/agents/subagent-registry.types.ts
+++ b/src/agents/subagent-registry.types.ts
@@ -6,6 +6,7 @@ import type { SpawnSubagentMode } from "./subagent-spawn.js";
export type SubagentRunRecord = {
runId: string;
childSessionKey: string;
+ controllerSessionKey?: string;
requesterSessionKey: string;
requesterOrigin?: DeliveryContext;
requesterDisplayKey: string;
diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts
index f2a63552189..be5dac37f83 100644
--- a/src/agents/subagent-spawn.ts
+++ b/src/agents/subagent-spawn.ts
@@ -27,6 +27,7 @@ import {
materializeSubagentAttachments,
type SubagentAttachmentReceiptFile,
} from "./subagent-attachments.js";
+import { resolveSubagentCapabilities } from "./subagent-capabilities.js";
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
import { countActiveRunsForSession, registerSubagentRun } from "./subagent-registry.js";
import { readStringParam } from "./tools/common.js";
@@ -376,6 +377,10 @@ export async function spawnSubagentDirect(
}
const childDepth = callerDepth + 1;
const spawnedByKey = requesterInternalKey;
+ const childCapabilities = resolveSubagentCapabilities({
+ depth: childDepth,
+ maxSpawnDepth,
+ });
const targetAgentConfig = resolveAgentConfig(cfg, targetAgentId);
const resolvedModel = resolveSubagentSpawnModelSelection({
cfg,
@@ -414,7 +419,11 @@ export async function spawnSubagentDirect(
}
};
- const spawnDepthPatchError = await patchChildSession({ spawnDepth: childDepth });
+ const spawnDepthPatchError = await patchChildSession({
+ spawnDepth: childDepth,
+ subagentRole: childCapabilities.role === "main" ? null : childCapabilities.role,
+ subagentControlScope: childCapabilities.controlScope,
+ });
if (spawnDepthPatchError) {
return {
status: "error",
@@ -643,6 +652,7 @@ export async function spawnSubagentDirect(
registerSubagentRun({
runId: childRunId,
childSessionKey,
+ controllerSessionKey: requesterInternalKey,
requesterSessionKey: requesterInternalKey,
requesterOrigin,
requesterDisplayKey,
diff --git a/src/agents/tool-policy.test.ts b/src/agents/tool-policy.test.ts
index 9a9f512189b..963c703a409 100644
--- a/src/agents/tool-policy.test.ts
+++ b/src/agents/tool-policy.test.ts
@@ -80,6 +80,7 @@ describe("tool-policy", () => {
expect(isOwnerOnlyToolName("whatsapp_login")).toBe(true);
expect(isOwnerOnlyToolName("cron")).toBe(true);
expect(isOwnerOnlyToolName("gateway")).toBe(true);
+ expect(isOwnerOnlyToolName("nodes")).toBe(true);
expect(isOwnerOnlyToolName("read")).toBe(false);
});
@@ -107,6 +108,27 @@ describe("tool-policy", () => {
expect(applyOwnerOnlyToolPolicy(tools, false)).toEqual([]);
expect(applyOwnerOnlyToolPolicy(tools, true)).toHaveLength(1);
});
+
+ it("strips nodes for non-owner senders via fallback policy", () => {
+ const tools = [
+ {
+ name: "read",
+ // oxlint-disable-next-line typescript/no-explicit-any
+ execute: async () => ({ content: [], details: {} }) as any,
+ },
+ {
+ name: "nodes",
+ // oxlint-disable-next-line typescript/no-explicit-any
+ execute: async () => ({ content: [], details: {} }) as any,
+ },
+ ] as unknown as AnyAgentTool[];
+
+ expect(applyOwnerOnlyToolPolicy(tools, false).map((tool) => tool.name)).toEqual(["read"]);
+ expect(applyOwnerOnlyToolPolicy(tools, true).map((tool) => tool.name)).toEqual([
+ "read",
+ "nodes",
+ ]);
+ });
});
describe("TOOL_POLICY_CONFORMANCE", () => {
diff --git a/src/agents/tool-policy.ts b/src/agents/tool-policy.ts
index 188a9c3361c..5538fb765ce 100644
--- a/src/agents/tool-policy.ts
+++ b/src/agents/tool-policy.ts
@@ -28,7 +28,12 @@ function wrapOwnerOnlyToolExecution(tool: AnyAgentTool, senderIsOwner: boolean):
};
}
-const OWNER_ONLY_TOOL_NAME_FALLBACKS = new Set(["whatsapp_login", "cron", "gateway"]);
+const OWNER_ONLY_TOOL_NAME_FALLBACKS = new Set([
+ "whatsapp_login",
+ "cron",
+ "gateway",
+ "nodes",
+]);
export function isOwnerOnlyToolName(name: string) {
return OWNER_ONLY_TOOL_NAME_FALLBACKS.has(normalizeToolName(name));
diff --git a/src/agents/tools/nodes-tool.test.ts b/src/agents/tools/nodes-tool.test.ts
index 99780a16238..ddde0b850e1 100644
--- a/src/agents/tools/nodes-tool.test.ts
+++ b/src/agents/tools/nodes-tool.test.ts
@@ -97,11 +97,11 @@ describe("createNodesTool screen_record duration guardrails", () => {
if (payload?.command === "system.run.prepare") {
return {
payload: {
- cmdText: "echo hi",
plan: {
argv: ["bash", "-lc", "echo hi"],
cwd: null,
- rawCommand: null,
+ commandText: 'bash -lc "echo hi"',
+ commandPreview: "echo hi",
agentId: null,
sessionKey: null,
},
diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts
index 9c335c012b4..e57ff735cdf 100644
--- a/src/agents/tools/nodes-tool.ts
+++ b/src/agents/tools/nodes-tool.ts
@@ -664,7 +664,7 @@ export function createNodesTool(options?: {
}
const runParams = {
command: prepared.plan.argv,
- rawCommand: prepared.plan.rawCommand ?? prepared.cmdText,
+ rawCommand: prepared.plan.commandText,
cwd: prepared.plan.cwd ?? cwd,
env,
timeoutMs: commandTimeoutMs,
@@ -699,8 +699,6 @@ export function createNodesTool(options?: {
{ ...gatewayOpts, timeoutMs: APPROVAL_TIMEOUT_MS + 5_000 },
{
id: approvalId,
- command: prepared.cmdText,
- commandArgv: prepared.plan.argv,
systemRunPlan: prepared.plan,
cwd: prepared.plan.cwd ?? cwd,
nodeId,
diff --git a/src/agents/tools/subagents-tool.ts b/src/agents/tools/subagents-tool.ts
index f2b073934ab..a7eb53c5d46 100644
--- a/src/agents/tools/subagents-tool.ts
+++ b/src/agents/tools/subagents-tool.ts
@@ -1,58 +1,26 @@
-import crypto from "node:crypto";
import { Type } from "@sinclair/typebox";
-import { clearSessionQueues } from "../../auto-reply/reply/queue.js";
-import {
- resolveSubagentLabel,
- resolveSubagentTargetFromRuns,
- sortSubagentRuns,
- type SubagentTargetResolution,
-} from "../../auto-reply/reply/subagents-utils.js";
-import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../../config/agent-limits.js";
import { loadConfig } from "../../config/config.js";
-import type { SessionEntry } from "../../config/sessions.js";
-import { loadSessionStore, resolveStorePath, updateSessionStore } from "../../config/sessions.js";
-import { callGateway } from "../../gateway/call.js";
-import { logVerbose } from "../../globals.js";
-import {
- isSubagentSessionKey,
- parseAgentSessionKey,
- type ParsedAgentSessionKey,
-} from "../../routing/session-key.js";
-import {
- formatDurationCompact,
- formatTokenUsageDisplay,
- resolveTotalTokens,
- truncateLine,
-} from "../../shared/subagents-format.js";
-import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
-import { AGENT_LANE_SUBAGENT } from "../lanes.js";
-import { abortEmbeddedPiRun } from "../pi-embedded.js";
import { optionalStringEnum } from "../schema/typebox.js";
-import { getSubagentDepthFromSessionStore } from "../subagent-depth.js";
import {
- clearSubagentRunSteerRestart,
- countPendingDescendantRuns,
- listSubagentRunsForRequester,
- markSubagentRunTerminated,
- markSubagentRunForSteerRestart,
- replaceSubagentRunAfterSteer,
- type SubagentRunRecord,
-} from "../subagent-registry.js";
+ buildSubagentList,
+ DEFAULT_RECENT_MINUTES,
+ isActiveSubagentRun,
+ killAllControlledSubagentRuns,
+ killControlledSubagentRun,
+ listControlledSubagentRuns,
+ MAX_RECENT_MINUTES,
+ MAX_STEER_MESSAGE_CHARS,
+ resolveControlledSubagentTarget,
+ resolveSubagentController,
+ steerControlledSubagentRun,
+ createPendingDescendantCounter,
+} from "../subagent-control.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
-import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
const SUBAGENT_ACTIONS = ["list", "kill", "steer"] as const;
type SubagentAction = (typeof SUBAGENT_ACTIONS)[number];
-const DEFAULT_RECENT_MINUTES = 30;
-const MAX_RECENT_MINUTES = 24 * 60;
-const MAX_STEER_MESSAGE_CHARS = 4_000;
-const STEER_RATE_LIMIT_MS = 2_000;
-const STEER_ABORT_SETTLE_TIMEOUT_MS = 5_000;
-
-const steerRateLimit = new Map();
-
const SubagentsToolSchema = Type.Object({
action: optionalStringEnum(SUBAGENT_ACTIONS),
target: Type.Optional(Type.String()),
@@ -60,292 +28,6 @@ const SubagentsToolSchema = Type.Object({
recentMinutes: Type.Optional(Type.Number({ minimum: 1 })),
});
-type SessionEntryResolution = {
- storePath: string;
- entry: SessionEntry | undefined;
-};
-
-type ResolvedRequesterKey = {
- requesterSessionKey: string;
- callerSessionKey: string;
- callerIsSubagent: boolean;
-};
-
-function resolveRunStatus(entry: SubagentRunRecord, options?: { pendingDescendants?: number }) {
- const pendingDescendants = Math.max(0, options?.pendingDescendants ?? 0);
- if (pendingDescendants > 0) {
- const childLabel = pendingDescendants === 1 ? "child" : "children";
- return `active (waiting on ${pendingDescendants} ${childLabel})`;
- }
- if (!entry.endedAt) {
- return "running";
- }
- const status = entry.outcome?.status ?? "done";
- if (status === "ok") {
- return "done";
- }
- if (status === "error") {
- return "failed";
- }
- return status;
-}
-
-function resolveModelRef(entry?: SessionEntry) {
- const model = typeof entry?.model === "string" ? entry.model.trim() : "";
- const provider = typeof entry?.modelProvider === "string" ? entry.modelProvider.trim() : "";
- if (model.includes("/")) {
- return model;
- }
- if (model && provider) {
- return `${provider}/${model}`;
- }
- if (model) {
- return model;
- }
- if (provider) {
- return provider;
- }
- // Fall back to override fields which are populated at spawn time,
- // before the first run completes and writes model/modelProvider.
- const overrideModel = typeof entry?.modelOverride === "string" ? entry.modelOverride.trim() : "";
- const overrideProvider =
- typeof entry?.providerOverride === "string" ? entry.providerOverride.trim() : "";
- if (overrideModel.includes("/")) {
- return overrideModel;
- }
- if (overrideModel && overrideProvider) {
- return `${overrideProvider}/${overrideModel}`;
- }
- if (overrideModel) {
- return overrideModel;
- }
- return overrideProvider || undefined;
-}
-
-function resolveModelDisplay(entry?: SessionEntry, fallbackModel?: string) {
- const modelRef = resolveModelRef(entry) || fallbackModel || undefined;
- if (!modelRef) {
- return "model n/a";
- }
- const slash = modelRef.lastIndexOf("/");
- if (slash >= 0 && slash < modelRef.length - 1) {
- return modelRef.slice(slash + 1);
- }
- return modelRef;
-}
-
-function resolveSubagentTarget(
- runs: SubagentRunRecord[],
- token: string | undefined,
- options?: { recentMinutes?: number; isActive?: (entry: SubagentRunRecord) => boolean },
-): SubagentTargetResolution {
- return resolveSubagentTargetFromRuns({
- runs,
- token,
- recentWindowMinutes: options?.recentMinutes ?? DEFAULT_RECENT_MINUTES,
- label: (entry) => resolveSubagentLabel(entry),
- isActive: options?.isActive,
- errors: {
- missingTarget: "Missing subagent target.",
- invalidIndex: (value) => `Invalid subagent index: ${value}`,
- unknownSession: (value) => `Unknown subagent session: ${value}`,
- ambiguousLabel: (value) => `Ambiguous subagent label: ${value}`,
- ambiguousLabelPrefix: (value) => `Ambiguous subagent label prefix: ${value}`,
- ambiguousRunIdPrefix: (value) => `Ambiguous subagent run id prefix: ${value}`,
- unknownTarget: (value) => `Unknown subagent target: ${value}`,
- },
- });
-}
-
-function resolveStorePathForKey(
- cfg: ReturnType,
- key: string,
- parsed?: ParsedAgentSessionKey | null,
-) {
- return resolveStorePath(cfg.session?.store, {
- agentId: parsed?.agentId,
- });
-}
-
-function resolveSessionEntryForKey(params: {
- cfg: ReturnType;
- key: string;
- cache: Map>;
-}): SessionEntryResolution {
- const parsed = parseAgentSessionKey(params.key);
- const storePath = resolveStorePathForKey(params.cfg, params.key, parsed);
- let store = params.cache.get(storePath);
- if (!store) {
- store = loadSessionStore(storePath);
- params.cache.set(storePath, store);
- }
- return {
- storePath,
- entry: store[params.key],
- };
-}
-
-function resolveRequesterKey(params: {
- cfg: ReturnType;
- agentSessionKey?: string;
-}): ResolvedRequesterKey {
- const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
- const callerRaw = params.agentSessionKey?.trim() || alias;
- const callerSessionKey = resolveInternalSessionKey({
- key: callerRaw,
- alias,
- mainKey,
- });
- if (!isSubagentSessionKey(callerSessionKey)) {
- return {
- requesterSessionKey: callerSessionKey,
- callerSessionKey,
- callerIsSubagent: false,
- };
- }
-
- // Check if this sub-agent can spawn children (orchestrator).
- // If so, it should see its own children, not its parent's children.
- const callerDepth = getSubagentDepthFromSessionStore(callerSessionKey, { cfg: params.cfg });
- const maxSpawnDepth =
- params.cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH;
- if (callerDepth < maxSpawnDepth) {
- // Orchestrator sub-agent: use its own session key as requester
- // so it sees children it spawned.
- return {
- requesterSessionKey: callerSessionKey,
- callerSessionKey,
- callerIsSubagent: true,
- };
- }
-
- // Leaf sub-agent: walk up to its parent so it can see sibling runs.
- const cache = new Map>();
- const callerEntry = resolveSessionEntryForKey({
- cfg: params.cfg,
- key: callerSessionKey,
- cache,
- }).entry;
- const spawnedBy = typeof callerEntry?.spawnedBy === "string" ? callerEntry.spawnedBy.trim() : "";
- return {
- requesterSessionKey: spawnedBy || callerSessionKey,
- callerSessionKey,
- callerIsSubagent: true,
- };
-}
-
-async function killSubagentRun(params: {
- cfg: ReturnType;
- entry: SubagentRunRecord;
- cache: Map>;
-}): Promise<{ killed: boolean; sessionId?: string }> {
- if (params.entry.endedAt) {
- return { killed: false };
- }
- const childSessionKey = params.entry.childSessionKey;
- const resolved = resolveSessionEntryForKey({
- cfg: params.cfg,
- key: childSessionKey,
- cache: params.cache,
- });
- const sessionId = resolved.entry?.sessionId;
- const aborted = sessionId ? abortEmbeddedPiRun(sessionId) : false;
- const cleared = clearSessionQueues([childSessionKey, sessionId]);
- if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
- logVerbose(
- `subagents tool kill: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
- );
- }
- if (resolved.entry) {
- await updateSessionStore(resolved.storePath, (store) => {
- const current = store[childSessionKey];
- if (!current) {
- return;
- }
- current.abortedLastRun = true;
- current.updatedAt = Date.now();
- store[childSessionKey] = current;
- });
- }
- const marked = markSubagentRunTerminated({
- runId: params.entry.runId,
- childSessionKey,
- reason: "killed",
- });
- const killed = marked > 0 || aborted || cleared.followupCleared > 0 || cleared.laneCleared > 0;
- return { killed, sessionId };
-}
-
-/**
- * Recursively kill all descendant subagent runs spawned by a given parent session key.
- * This ensures that when a subagent is killed, all of its children (and their children) are also killed.
- */
-async function cascadeKillChildren(params: {
- cfg: ReturnType;
- parentChildSessionKey: string;
- cache: Map>;
- seenChildSessionKeys?: Set;
-}): Promise<{ killed: number; labels: string[] }> {
- const childRuns = listSubagentRunsForRequester(params.parentChildSessionKey);
- const seenChildSessionKeys = params.seenChildSessionKeys ?? new Set();
- let killed = 0;
- const labels: string[] = [];
-
- for (const run of childRuns) {
- const childKey = run.childSessionKey?.trim();
- if (!childKey || seenChildSessionKeys.has(childKey)) {
- continue;
- }
- seenChildSessionKeys.add(childKey);
-
- if (!run.endedAt) {
- const stopResult = await killSubagentRun({
- cfg: params.cfg,
- entry: run,
- cache: params.cache,
- });
- if (stopResult.killed) {
- killed += 1;
- labels.push(resolveSubagentLabel(run));
- }
- }
-
- // Recurse for grandchildren even if this parent already ended.
- const cascade = await cascadeKillChildren({
- cfg: params.cfg,
- parentChildSessionKey: childKey,
- cache: params.cache,
- seenChildSessionKeys,
- });
- killed += cascade.killed;
- labels.push(...cascade.labels);
- }
-
- return { killed, labels };
-}
-
-function buildListText(params: {
- active: Array<{ line: string }>;
- recent: Array<{ line: string }>;
- recentMinutes: number;
-}) {
- const lines: string[] = [];
- lines.push("active subagents:");
- if (params.active.length === 0) {
- lines.push("(none)");
- } else {
- lines.push(...params.active.map((entry) => entry.line));
- }
- lines.push("");
- lines.push(`recent (last ${params.recentMinutes}m):`);
- if (params.recent.length === 0) {
- lines.push("(none)");
- } else {
- lines.push(...params.recent.map((entry) => entry.line));
- }
- return lines.join("\n");
-}
-
export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAgentTool {
return {
label: "Subagents",
@@ -357,139 +39,69 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge
const params = args as Record;
const action = (readStringParam(params, "action") ?? "list") as SubagentAction;
const cfg = loadConfig();
- const requester = resolveRequesterKey({
+ const controller = resolveSubagentController({
cfg,
agentSessionKey: opts?.agentSessionKey,
});
- const runs = sortSubagentRuns(listSubagentRunsForRequester(requester.requesterSessionKey));
+ const runs = listControlledSubagentRuns(controller.controllerSessionKey);
const recentMinutesRaw = readNumberParam(params, "recentMinutes");
const recentMinutes = recentMinutesRaw
? Math.max(1, Math.min(MAX_RECENT_MINUTES, Math.floor(recentMinutesRaw)))
: DEFAULT_RECENT_MINUTES;
- const pendingDescendantCache = new Map();
- const pendingDescendantCount = (sessionKey: string) => {
- if (pendingDescendantCache.has(sessionKey)) {
- return pendingDescendantCache.get(sessionKey) ?? 0;
- }
- const pending = Math.max(0, countPendingDescendantRuns(sessionKey));
- pendingDescendantCache.set(sessionKey, pending);
- return pending;
- };
- const isActiveRun = (entry: SubagentRunRecord) =>
- !entry.endedAt || pendingDescendantCount(entry.childSessionKey) > 0;
+ const pendingDescendantCount = createPendingDescendantCounter();
+ const isActive = (entry: (typeof runs)[number]) =>
+ isActiveSubagentRun(entry, pendingDescendantCount);
if (action === "list") {
- const now = Date.now();
- const recentCutoff = now - recentMinutes * 60_000;
- const cache = new Map>();
-
- let index = 1;
- const buildListEntry = (entry: SubagentRunRecord, runtimeMs: number) => {
- const sessionEntry = resolveSessionEntryForKey({
- cfg,
- key: entry.childSessionKey,
- cache,
- }).entry;
- const totalTokens = resolveTotalTokens(sessionEntry);
- const usageText = formatTokenUsageDisplay(sessionEntry);
- const pendingDescendants = pendingDescendantCount(entry.childSessionKey);
- const status = resolveRunStatus(entry, {
- pendingDescendants,
- });
- const runtime = formatDurationCompact(runtimeMs);
- const label = truncateLine(resolveSubagentLabel(entry), 48);
- const task = truncateLine(entry.task.trim(), 72);
- const line = `${index}. ${label} (${resolveModelDisplay(sessionEntry, entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`;
- const baseView = {
- index,
- runId: entry.runId,
- sessionKey: entry.childSessionKey,
- label,
- task,
- status,
- pendingDescendants,
- runtime,
- runtimeMs,
- model: resolveModelRef(sessionEntry) || entry.model,
- totalTokens,
- startedAt: entry.startedAt,
- };
- index += 1;
- return { line, view: entry.endedAt ? { ...baseView, endedAt: entry.endedAt } : baseView };
- };
- const active = runs
- .filter((entry) => isActiveRun(entry))
- .map((entry) => buildListEntry(entry, now - (entry.startedAt ?? entry.createdAt)));
- const recent = runs
- .filter(
- (entry) =>
- !isActiveRun(entry) && !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff,
- )
- .map((entry) =>
- buildListEntry(entry, (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt)),
- );
-
- const text = buildListText({ active, recent, recentMinutes });
+ const list = buildSubagentList({
+ cfg,
+ runs,
+ recentMinutes,
+ });
return jsonResult({
status: "ok",
action: "list",
- requesterSessionKey: requester.requesterSessionKey,
- callerSessionKey: requester.callerSessionKey,
- callerIsSubagent: requester.callerIsSubagent,
- total: runs.length,
- active: active.map((entry) => entry.view),
- recent: recent.map((entry) => entry.view),
- text,
+ requesterSessionKey: controller.controllerSessionKey,
+ callerSessionKey: controller.callerSessionKey,
+ callerIsSubagent: controller.callerIsSubagent,
+ total: list.total,
+ active: list.active.map(({ line: _line, ...view }) => view),
+ recent: list.recent.map(({ line: _line, ...view }) => view),
+ text: list.text,
});
}
if (action === "kill") {
const target = readStringParam(params, "target", { required: true });
if (target === "all" || target === "*") {
- const cache = new Map>();
- const seenChildSessionKeys = new Set();
- const killedLabels: string[] = [];
- let killed = 0;
- for (const entry of runs) {
- const childKey = entry.childSessionKey?.trim();
- if (!childKey || seenChildSessionKeys.has(childKey)) {
- continue;
- }
- seenChildSessionKeys.add(childKey);
-
- if (!entry.endedAt) {
- const stopResult = await killSubagentRun({ cfg, entry, cache });
- if (stopResult.killed) {
- killed += 1;
- killedLabels.push(resolveSubagentLabel(entry));
- }
- }
-
- // Traverse descendants even when the direct run is already finished.
- const cascade = await cascadeKillChildren({
- cfg,
- parentChildSessionKey: childKey,
- cache,
- seenChildSessionKeys,
+ const result = await killAllControlledSubagentRuns({
+ cfg,
+ controller,
+ runs,
+ });
+ if (result.status === "forbidden") {
+ return jsonResult({
+ status: "forbidden",
+ action: "kill",
+ target: "all",
+ error: result.error,
});
- killed += cascade.killed;
- killedLabels.push(...cascade.labels);
}
return jsonResult({
status: "ok",
action: "kill",
target: "all",
- killed,
- labels: killedLabels,
+ killed: result.killed,
+ labels: result.labels,
text:
- killed > 0
- ? `killed ${killed} subagent${killed === 1 ? "" : "s"}.`
+ result.killed > 0
+ ? `killed ${result.killed} subagent${result.killed === 1 ? "" : "s"}.`
: "no running subagents to kill.",
});
}
- const resolved = resolveSubagentTarget(runs, target, {
+ const resolved = resolveControlledSubagentTarget(runs, target, {
recentMinutes,
- isActive: isActiveRun,
+ isActive,
});
if (!resolved.entry) {
return jsonResult({
@@ -499,52 +111,25 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge
error: resolved.error ?? "Unknown subagent target.",
});
}
- const killCache = new Map>();
- const stopResult = await killSubagentRun({
+ const result = await killControlledSubagentRun({
cfg,
+ controller,
entry: resolved.entry,
- cache: killCache,
});
- const seenChildSessionKeys = new Set();
- const targetChildKey = resolved.entry.childSessionKey?.trim();
- if (targetChildKey) {
- seenChildSessionKeys.add(targetChildKey);
- }
- // Traverse descendants even when the selected run is already finished.
- const cascade = await cascadeKillChildren({
- cfg,
- parentChildSessionKey: resolved.entry.childSessionKey,
- cache: killCache,
- seenChildSessionKeys,
- });
- if (!stopResult.killed && cascade.killed === 0) {
- return jsonResult({
- status: "done",
- action: "kill",
- target,
- runId: resolved.entry.runId,
- sessionKey: resolved.entry.childSessionKey,
- text: `${resolveSubagentLabel(resolved.entry)} is already finished.`,
- });
- }
- const cascadeText =
- cascade.killed > 0
- ? ` (+ ${cascade.killed} descendant${cascade.killed === 1 ? "" : "s"})`
- : "";
return jsonResult({
- status: "ok",
+ status: result.status,
action: "kill",
target,
- runId: resolved.entry.runId,
- sessionKey: resolved.entry.childSessionKey,
- label: resolveSubagentLabel(resolved.entry),
- cascadeKilled: cascade.killed,
- cascadeLabels: cascade.killed > 0 ? cascade.labels : undefined,
- text: stopResult.killed
- ? `killed ${resolveSubagentLabel(resolved.entry)}${cascadeText}.`
- : `killed ${cascade.killed} descendant${cascade.killed === 1 ? "" : "s"} of ${resolveSubagentLabel(resolved.entry)}.`,
+ runId: result.runId,
+ sessionKey: result.sessionKey,
+ label: result.label,
+ cascadeKilled: "cascadeKilled" in result ? result.cascadeKilled : undefined,
+ cascadeLabels: "cascadeLabels" in result ? result.cascadeLabels : undefined,
+ error: "error" in result ? result.error : undefined,
+ text: result.text,
});
}
+
if (action === "steer") {
const target = readStringParam(params, "target", { required: true });
const message = readStringParam(params, "message", { required: true });
@@ -556,9 +141,9 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge
error: `Message too long (${message.length} chars, max ${MAX_STEER_MESSAGE_CHARS}).`,
});
}
- const resolved = resolveSubagentTarget(runs, target, {
+ const resolved = resolveControlledSubagentTarget(runs, target, {
recentMinutes,
- isActive: isActiveRun,
+ isActive,
});
if (!resolved.entry) {
return jsonResult({
@@ -568,140 +153,26 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge
error: resolved.error ?? "Unknown subagent target.",
});
}
- if (resolved.entry.endedAt) {
- return jsonResult({
- status: "done",
- action: "steer",
- target,
- runId: resolved.entry.runId,
- sessionKey: resolved.entry.childSessionKey,
- text: `${resolveSubagentLabel(resolved.entry)} is already finished.`,
- });
- }
- if (
- requester.callerIsSubagent &&
- requester.callerSessionKey === resolved.entry.childSessionKey
- ) {
- return jsonResult({
- status: "forbidden",
- action: "steer",
- target,
- runId: resolved.entry.runId,
- sessionKey: resolved.entry.childSessionKey,
- error: "Subagents cannot steer themselves.",
- });
- }
-
- const rateKey = `${requester.callerSessionKey}:${resolved.entry.childSessionKey}`;
- const now = Date.now();
- const lastSentAt = steerRateLimit.get(rateKey) ?? 0;
- if (now - lastSentAt < STEER_RATE_LIMIT_MS) {
- return jsonResult({
- status: "rate_limited",
- action: "steer",
- target,
- runId: resolved.entry.runId,
- sessionKey: resolved.entry.childSessionKey,
- error: "Steer rate limit exceeded. Wait a moment before sending another steer.",
- });
- }
- steerRateLimit.set(rateKey, now);
-
- // Suppress announce for the interrupted run before aborting so we don't
- // emit stale pre-steer findings if the run exits immediately.
- markSubagentRunForSteerRestart(resolved.entry.runId);
-
- const targetSession = resolveSessionEntryForKey({
+ const result = await steerControlledSubagentRun({
cfg,
- key: resolved.entry.childSessionKey,
- cache: new Map>(),
+ controller,
+ entry: resolved.entry,
+ message,
});
- const sessionId =
- typeof targetSession.entry?.sessionId === "string" && targetSession.entry.sessionId.trim()
- ? targetSession.entry.sessionId.trim()
- : undefined;
-
- // Interrupt current work first so steer takes precedence immediately.
- if (sessionId) {
- abortEmbeddedPiRun(sessionId);
- }
- const cleared = clearSessionQueues([resolved.entry.childSessionKey, sessionId]);
- if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
- logVerbose(
- `subagents tool steer: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
- );
- }
-
- // Best effort: wait for the interrupted run to settle so the steer
- // message appends onto the existing conversation context.
- try {
- await callGateway({
- method: "agent.wait",
- params: {
- runId: resolved.entry.runId,
- timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS,
- },
- timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS + 2_000,
- });
- } catch {
- // Continue even if wait fails; steer should still be attempted.
- }
-
- const idempotencyKey = crypto.randomUUID();
- let runId: string = idempotencyKey;
- try {
- const response = await callGateway<{ runId: string }>({
- method: "agent",
- params: {
- message,
- sessionKey: resolved.entry.childSessionKey,
- sessionId,
- idempotencyKey,
- deliver: false,
- channel: INTERNAL_MESSAGE_CHANNEL,
- lane: AGENT_LANE_SUBAGENT,
- timeout: 0,
- },
- timeoutMs: 10_000,
- });
- if (typeof response?.runId === "string" && response.runId) {
- runId = response.runId;
- }
- } catch (err) {
- // Replacement launch failed; restore normal announce behavior for the
- // original run so completion is not silently suppressed.
- clearSubagentRunSteerRestart(resolved.entry.runId);
- const error = err instanceof Error ? err.message : String(err);
- return jsonResult({
- status: "error",
- action: "steer",
- target,
- runId,
- sessionKey: resolved.entry.childSessionKey,
- sessionId,
- error,
- });
- }
-
- replaceSubagentRunAfterSteer({
- previousRunId: resolved.entry.runId,
- nextRunId: runId,
- fallback: resolved.entry,
- runTimeoutSeconds: resolved.entry.runTimeoutSeconds ?? 0,
- });
-
return jsonResult({
- status: "accepted",
+ status: result.status,
action: "steer",
target,
- runId,
- sessionKey: resolved.entry.childSessionKey,
- sessionId,
- mode: "restart",
- label: resolveSubagentLabel(resolved.entry),
- text: `steered ${resolveSubagentLabel(resolved.entry)}.`,
+ runId: result.runId,
+ sessionKey: result.sessionKey,
+ sessionId: result.sessionId,
+ mode: "mode" in result ? result.mode : undefined,
+ label: "label" in result ? result.label : undefined,
+ error: "error" in result ? result.error : undefined,
+ text: result.text,
});
}
+
return jsonResult({
status: "error",
error: "Unsupported action.",
diff --git a/src/agents/tools/web-fetch.cf-markdown.test.ts b/src/agents/tools/web-fetch.cf-markdown.test.ts
index e235177a309..f22dc10df52 100644
--- a/src/agents/tools/web-fetch.cf-markdown.test.ts
+++ b/src/agents/tools/web-fetch.cf-markdown.test.ts
@@ -114,7 +114,7 @@ describe("web_fetch Cloudflare Markdown for Agents", () => {
sandboxed: false,
runtimeFirecrawl: {
active: false,
- apiKeySource: "secretRef",
+ apiKeySource: "secretRef", // pragma: allowlist secret
diagnostics: [],
},
});
diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts
index ad3345a3e06..c416804fa11 100644
--- a/src/agents/tools/web-tools.enabled-defaults.test.ts
+++ b/src/agents/tools/web-tools.enabled-defaults.test.ts
@@ -652,7 +652,7 @@ describe("web_search Perplexity lazy resolution", () => {
web: {
search: {
provider: "gemini",
- gemini: { apiKey: "gemini-config-test" },
+ gemini: { apiKey: "gemini-config-test" }, // pragma: allowlist secret
perplexity: perplexityConfig as { apiKey?: string; baseUrl?: string; model?: string },
},
},
diff --git a/src/auto-reply/reply/abort.ts b/src/auto-reply/reply/abort.ts
index d0f97f04fa8..58ea5e59fa6 100644
--- a/src/auto-reply/reply/abort.ts
+++ b/src/auto-reply/reply/abort.ts
@@ -2,7 +2,7 @@ import { getAcpSessionManager } from "../../acp/control-plane/manager.js";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js";
import {
- listSubagentRunsForRequester,
+ listSubagentRunsForController,
markSubagentRunTerminated,
} from "../../agents/subagent-registry.js";
import {
@@ -222,7 +222,7 @@ export function stopSubagentsForRequester(params: {
if (!requesterKey) {
return { stopped: 0 };
}
- const runs = listSubagentRunsForRequester(requesterKey);
+ const runs = listSubagentRunsForController(requesterKey);
if (runs.length === 0) {
return { stopped: 0 };
}
diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts
index 2f6c27519b0..bdbd68ac2e4 100644
--- a/src/auto-reply/reply/agent-runner-execution.ts
+++ b/src/auto-reply/reply/agent-runner-execution.ts
@@ -6,8 +6,10 @@ import { getCliSessionId } from "../../agents/cli-session.js";
import { runWithModelFallback } from "../../agents/model-fallback.js";
import { isCliProvider } from "../../agents/model-selection.js";
import {
+ BILLING_ERROR_USER_MESSAGE,
isCompactionFailureError,
isContextOverflowError,
+ isBillingErrorMessage,
isLikelyContextOverflowError,
isTransientHttpError,
sanitizeUserFacingText,
@@ -514,8 +516,9 @@ export async function runAgentTurnWithFallback(params: {
break;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
- const isContextOverflow = isLikelyContextOverflowError(message);
- const isCompactionFailure = isCompactionFailureError(message);
+ const isBilling = isBillingErrorMessage(message);
+ const isContextOverflow = !isBilling && isLikelyContextOverflowError(message);
+ const isCompactionFailure = !isBilling && isCompactionFailureError(message);
const isSessionCorruption = /function call turn comes immediately after/i.test(message);
const isRoleOrderingError = /incorrect role information|roles must alternate/i.test(message);
const isTransientHttp = isTransientHttpError(message);
@@ -610,11 +613,13 @@ export async function runAgentTurnWithFallback(params: {
? sanitizeUserFacingText(message, { errorContext: true })
: message;
const trimmedMessage = safeMessage.replace(/\.\s*$/, "");
- const fallbackText = isContextOverflow
- ? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model."
- : isRoleOrderingError
- ? "⚠️ Message ordering conflict - please try again. If this persists, use /new to start a fresh session."
- : `⚠️ Agent failed before reply: ${trimmedMessage}.\nLogs: openclaw logs --follow`;
+ const fallbackText = isBilling
+ ? BILLING_ERROR_USER_MESSAGE
+ : isContextOverflow
+ ? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model."
+ : isRoleOrderingError
+ ? "⚠️ Message ordering conflict - please try again. If this persists, use /new to start a fresh session."
+ : `⚠️ Agent failed before reply: ${trimmedMessage}.\nLogs: openclaw logs --follow`;
return {
kind: "final",
diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts
index 659ccfe7951..14731dbb0ff 100644
--- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts
+++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts
@@ -1628,3 +1628,72 @@ describe("runReplyAgent transient HTTP retry", () => {
expect(payload?.text).toContain("Recovered response");
});
});
+
+describe("runReplyAgent billing error classification", () => {
+ // Regression guard for the runner-level catch block in runAgentTurnWithFallback.
+ // Billing errors from providers like OpenRouter can contain token/size wording that
+ // matches context overflow heuristics. This test verifies the final user-visible
+ // message is the billing-specific one, not the "Context overflow" fallback.
+ it("returns billing message for mixed-signal error (billing text + overflow patterns)", async () => {
+ runEmbeddedPiAgentMock.mockRejectedValueOnce(
+ new Error("402 Payment Required: request token limit exceeded for this billing plan"),
+ );
+
+ const typing = createMockTypingController();
+ const sessionCtx = {
+ Provider: "telegram",
+ MessageSid: "msg",
+ } as unknown as TemplateContext;
+ const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
+ const followupRun = {
+ prompt: "hello",
+ summaryLine: "hello",
+ enqueuedAt: Date.now(),
+ run: {
+ sessionId: "session",
+ sessionKey: "main",
+ messageProvider: "telegram",
+ sessionFile: "/tmp/session.jsonl",
+ workspaceDir: "/tmp",
+ config: {},
+ skillsSnapshot: {},
+ provider: "anthropic",
+ model: "claude",
+ thinkLevel: "low",
+ verboseLevel: "off",
+ elevatedLevel: "off",
+ bashElevated: {
+ enabled: false,
+ allowed: false,
+ defaultLevel: "off",
+ },
+ timeoutMs: 1_000,
+ blockReplyBreak: "message_end",
+ },
+ } as unknown as FollowupRun;
+
+ const result = await runReplyAgent({
+ commandBody: "hello",
+ followupRun,
+ queueKey: "main",
+ resolvedQueue,
+ shouldSteer: false,
+ shouldFollowup: false,
+ isActive: false,
+ isStreaming: false,
+ typing,
+ sessionCtx,
+ defaultModel: "anthropic/claude",
+ resolvedVerboseLevel: "off",
+ isNewSession: false,
+ blockStreamingEnabled: false,
+ resolvedBlockStreamingBreak: "message_end",
+ shouldInjectGroupIntro: false,
+ typingMode: "instant",
+ });
+
+ const payload = Array.isArray(result) ? result[0] : result;
+ expect(payload?.text).toContain("billing error");
+ expect(payload?.text).not.toContain("Context overflow");
+ });
+});
diff --git a/src/auto-reply/reply/commands-allowlist.ts b/src/auto-reply/reply/commands-allowlist.ts
index 766bb5f41b3..ffba3bf2505 100644
--- a/src/auto-reply/reply/commands-allowlist.ts
+++ b/src/auto-reply/reply/commands-allowlist.ts
@@ -1,5 +1,10 @@
import { getChannelDock } from "../../channels/dock.js";
-import { resolveChannelConfigWrites } from "../../channels/plugins/config-writes.js";
+import {
+ authorizeConfigWrite,
+ canBypassConfigWritePolicy,
+ formatConfigWriteDeniedMessage,
+ resolveExplicitConfigWriteTarget,
+} from "../../channels/plugins/config-writes.js";
import { listPairingChannels } from "../../channels/plugins/pairing.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import { normalizeChannelId } from "../../channels/registry.js";
@@ -231,12 +236,22 @@ function resolveAccountTarget(
const channel = (channels[channelId] ??= {}) as Record;
const normalizedAccountId = normalizeAccountId(accountId);
if (isBlockedObjectKey(normalizedAccountId)) {
- return { target: channel, pathPrefix: `channels.${channelId}`, accountId: DEFAULT_ACCOUNT_ID };
+ return {
+ target: channel,
+ pathPrefix: `channels.${channelId}`,
+ accountId: DEFAULT_ACCOUNT_ID,
+ writeTarget: resolveExplicitConfigWriteTarget({ channelId }),
+ };
}
const hasAccounts = Boolean(channel.accounts && typeof channel.accounts === "object");
const useAccount = normalizedAccountId !== DEFAULT_ACCOUNT_ID || hasAccounts;
if (!useAccount) {
- return { target: channel, pathPrefix: `channels.${channelId}`, accountId: normalizedAccountId };
+ return {
+ target: channel,
+ pathPrefix: `channels.${channelId}`,
+ accountId: normalizedAccountId,
+ writeTarget: resolveExplicitConfigWriteTarget({ channelId }),
+ };
}
const accounts = (channel.accounts ??= {}) as Record;
const existingAccount = Object.hasOwn(accounts, normalizedAccountId)
@@ -250,6 +265,10 @@ function resolveAccountTarget(
target: account,
pathPrefix: `channels.${channelId}.accounts.${normalizedAccountId}`,
accountId: normalizedAccountId,
+ writeTarget: resolveExplicitConfigWriteTarget({
+ channelId,
+ accountId: normalizedAccountId,
+ }),
};
}
@@ -585,19 +604,6 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
const shouldTouchStore = parsed.target !== "config" && listPairingChannels().includes(channelId);
if (shouldUpdateConfig) {
- const allowWrites = resolveChannelConfigWrites({
- cfg: params.cfg,
- channelId,
- accountId: params.ctx.AccountId,
- });
- if (!allowWrites) {
- const hint = `channels.${channelId}.configWrites=true`;
- return {
- shouldContinue: false,
- reply: { text: `⚠️ Config writes are disabled for ${channelId}. Set ${hint} to enable.` },
- };
- }
-
const allowlistPath = resolveChannelAllowFromPaths(channelId, scope);
if (!allowlistPath) {
return {
@@ -620,7 +626,26 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
target,
pathPrefix,
accountId: normalizedAccountId,
+ writeTarget,
} = resolveAccountTarget(parsedConfig, channelId, accountId);
+ const writeAuth = authorizeConfigWrite({
+ cfg: params.cfg,
+ origin: { channelId, accountId: params.ctx.AccountId },
+ target: writeTarget,
+ allowBypass: canBypassConfigWritePolicy({
+ channel: params.command.channel,
+ gatewayClientScopes: params.ctx.GatewayClientScopes,
+ }),
+ });
+ if (!writeAuth.allowed) {
+ return {
+ shouldContinue: false,
+ reply: {
+ text: formatConfigWriteDeniedMessage({ result: writeAuth, fallbackChannelId: channelId }),
+ },
+ };
+ }
+
const existing: string[] = [];
const existingPaths =
scope === "dm" && (channelId === "slack" || channelId === "discord")
diff --git a/src/auto-reply/reply/commands-config.ts b/src/auto-reply/reply/commands-config.ts
index 00ef8048efe..0d00358e582 100644
--- a/src/auto-reply/reply/commands-config.ts
+++ b/src/auto-reply/reply/commands-config.ts
@@ -1,4 +1,9 @@
-import { resolveChannelConfigWrites } from "../../channels/plugins/config-writes.js";
+import {
+ authorizeConfigWrite,
+ canBypassConfigWritePolicy,
+ formatConfigWriteDeniedMessage,
+ resolveConfigWriteTargetFromPath,
+} from "../../channels/plugins/config-writes.js";
import { normalizeChannelId } from "../../channels/registry.js";
import {
getConfigValueAtPath,
@@ -52,6 +57,7 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
};
}
+ let parsedWritePath: string[] | undefined;
if (configCommand.action === "set" || configCommand.action === "unset") {
const missingAdminScope = requireGatewayClientScopeForInternalChannel(params, {
label: "/config write",
@@ -61,21 +67,29 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
if (missingAdminScope) {
return missingAdminScope;
}
+ const parsedPath = parseConfigPath(configCommand.path);
+ if (!parsedPath.ok || !parsedPath.path) {
+ return {
+ shouldContinue: false,
+ reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` },
+ };
+ }
+ parsedWritePath = parsedPath.path;
const channelId = params.command.channelId ?? normalizeChannelId(params.command.channel);
- const allowWrites = resolveChannelConfigWrites({
+ const writeAuth = authorizeConfigWrite({
cfg: params.cfg,
- channelId,
- accountId: params.ctx.AccountId,
+ origin: { channelId, accountId: params.ctx.AccountId },
+ target: resolveConfigWriteTargetFromPath(parsedWritePath),
+ allowBypass: canBypassConfigWritePolicy({
+ channel: params.command.channel,
+ gatewayClientScopes: params.ctx.GatewayClientScopes,
+ }),
});
- if (!allowWrites) {
- const channelLabel = channelId ?? "this channel";
- const hint = channelId
- ? `channels.${channelId}.configWrites=true`
- : "channels..configWrites=true";
+ if (!writeAuth.allowed) {
return {
shouldContinue: false,
reply: {
- text: `⚠️ Config writes are disabled for ${channelLabel}. Set ${hint} to enable.`,
+ text: formatConfigWriteDeniedMessage({ result: writeAuth, fallbackChannelId: channelId }),
},
};
}
@@ -119,14 +133,7 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
}
if (configCommand.action === "unset") {
- const parsedPath = parseConfigPath(configCommand.path);
- if (!parsedPath.ok || !parsedPath.path) {
- return {
- shouldContinue: false,
- reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` },
- };
- }
- const removed = unsetConfigValueAtPath(parsedBase, parsedPath.path);
+ const removed = unsetConfigValueAtPath(parsedBase, parsedWritePath ?? []);
if (!removed) {
return {
shouldContinue: false,
@@ -151,14 +158,7 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
}
if (configCommand.action === "set") {
- const parsedPath = parseConfigPath(configCommand.path);
- if (!parsedPath.ok || !parsedPath.path) {
- return {
- shouldContinue: false,
- reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` },
- };
- }
- setConfigValueAtPath(parsedBase, parsedPath.path, configCommand.value);
+ setConfigValueAtPath(parsedBase, parsedWritePath ?? [], configCommand.value);
const validated = validateConfigObjectWithPlugins(parsedBase);
if (!validated.ok) {
const issue = validated.issues[0];
diff --git a/src/auto-reply/reply/commands-subagents.ts b/src/auto-reply/reply/commands-subagents.ts
index 906ad93eb48..cffc6e003a8 100644
--- a/src/auto-reply/reply/commands-subagents.ts
+++ b/src/auto-reply/reply/commands-subagents.ts
@@ -1,4 +1,4 @@
-import { listSubagentRunsForRequester } from "../../agents/subagent-registry.js";
+import { listSubagentRunsForController } from "../../agents/subagent-registry.js";
import { logVerbose } from "../../globals.js";
import { handleSubagentsAgentsAction } from "./commands-subagents/action-agents.js";
import { handleSubagentsFocusAction } from "./commands-subagents/action-focus.js";
@@ -61,7 +61,7 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo
params,
handledPrefix,
requesterKey,
- runs: listSubagentRunsForRequester(requesterKey),
+ runs: listSubagentRunsForController(requesterKey),
restTokens,
};
diff --git a/src/auto-reply/reply/commands-subagents/action-kill.ts b/src/auto-reply/reply/commands-subagents/action-kill.ts
index cb91b4432f7..597e3b4c9c4 100644
--- a/src/auto-reply/reply/commands-subagents/action-kill.ts
+++ b/src/auto-reply/reply/commands-subagents/action-kill.ts
@@ -1,19 +1,13 @@
-import { abortEmbeddedPiRun } from "../../../agents/pi-embedded.js";
-import { markSubagentRunTerminated } from "../../../agents/subagent-registry.js";
import {
- loadSessionStore,
- resolveStorePath,
- updateSessionStore,
-} from "../../../config/sessions.js";
-import { logVerbose } from "../../../globals.js";
-import { stopSubagentsForRequester } from "../abort.js";
+ killAllControlledSubagentRuns,
+ killControlledSubagentRun,
+} from "../../../agents/subagent-control.js";
import type { CommandHandlerResult } from "../commands-types.js";
-import { clearSessionQueues } from "../queue.js";
import { formatRunLabel } from "../subagents-utils.js";
import {
type SubagentsCommandContext,
COMMAND,
- loadSubagentSessionEntry,
+ resolveCommandSubagentController,
resolveSubagentEntryForToken,
stopWithText,
} from "./shared.js";
@@ -30,10 +24,18 @@ export async function handleSubagentsKillAction(
}
if (target === "all" || target === "*") {
- stopSubagentsForRequester({
+ const controller = resolveCommandSubagentController(params, requesterKey);
+ const result = await killAllControlledSubagentRuns({
cfg: params.cfg,
- requesterSessionKey: requesterKey,
+ controller,
+ runs,
});
+ if (result.status === "forbidden") {
+ return stopWithText(`⚠️ ${result.error}`);
+ }
+ if (result.killed > 0) {
+ return { shouldContinue: false };
+ }
return { shouldContinue: false };
}
@@ -45,42 +47,17 @@ export async function handleSubagentsKillAction(
return stopWithText(`${formatRunLabel(targetResolution.entry)} is already finished.`);
}
- const childKey = targetResolution.entry.childSessionKey;
- const { storePath, store, entry } = loadSubagentSessionEntry(params, childKey, {
- loadSessionStore,
- resolveStorePath,
- });
- const sessionId = entry?.sessionId;
- if (sessionId) {
- abortEmbeddedPiRun(sessionId);
- }
-
- const cleared = clearSessionQueues([childKey, sessionId]);
- if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
- logVerbose(
- `subagents kill: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
- );
- }
-
- if (entry) {
- entry.abortedLastRun = true;
- entry.updatedAt = Date.now();
- store[childKey] = entry;
- await updateSessionStore(storePath, (nextStore) => {
- nextStore[childKey] = entry;
- });
- }
-
- markSubagentRunTerminated({
- runId: targetResolution.entry.runId,
- childSessionKey: childKey,
- reason: "killed",
- });
-
- stopSubagentsForRequester({
+ const controller = resolveCommandSubagentController(params, requesterKey);
+ const result = await killControlledSubagentRun({
cfg: params.cfg,
- requesterSessionKey: childKey,
+ controller,
+ entry: targetResolution.entry,
});
-
+ if (result.status === "forbidden") {
+ return stopWithText(`⚠️ ${result.error}`);
+ }
+ if (result.status === "done") {
+ return stopWithText(result.text);
+ }
return { shouldContinue: false };
}
diff --git a/src/auto-reply/reply/commands-subagents/action-list.ts b/src/auto-reply/reply/commands-subagents/action-list.ts
index 026874e22aa..e777c498d5f 100644
--- a/src/auto-reply/reply/commands-subagents/action-list.ts
+++ b/src/auto-reply/reply/commands-subagents/action-list.ts
@@ -1,79 +1,26 @@
-import { countPendingDescendantRuns } from "../../../agents/subagent-registry.js";
-import { loadSessionStore, resolveStorePath } from "../../../config/sessions.js";
+import { buildSubagentList } from "../../../agents/subagent-control.js";
import type { CommandHandlerResult } from "../commands-types.js";
-import { sortSubagentRuns } from "../subagents-utils.js";
-import {
- type SessionStoreCache,
- type SubagentsCommandContext,
- RECENT_WINDOW_MINUTES,
- formatSubagentListLine,
- loadSubagentSessionEntry,
- stopWithText,
-} from "./shared.js";
+import { type SubagentsCommandContext, RECENT_WINDOW_MINUTES, stopWithText } from "./shared.js";
export function handleSubagentsListAction(ctx: SubagentsCommandContext): CommandHandlerResult {
const { params, runs } = ctx;
- const sorted = sortSubagentRuns(runs);
- const now = Date.now();
- const recentCutoff = now - RECENT_WINDOW_MINUTES * 60_000;
- const storeCache: SessionStoreCache = new Map();
- const pendingDescendantCache = new Map();
- const pendingDescendantCount = (sessionKey: string) => {
- if (pendingDescendantCache.has(sessionKey)) {
- return pendingDescendantCache.get(sessionKey) ?? 0;
- }
- const pending = Math.max(0, countPendingDescendantRuns(sessionKey));
- pendingDescendantCache.set(sessionKey, pending);
- return pending;
- };
- const isActiveRun = (entry: (typeof runs)[number]) =>
- !entry.endedAt || pendingDescendantCount(entry.childSessionKey) > 0;
-
- let index = 1;
-
- const mapRuns = (entries: typeof runs, runtimeMs: (entry: (typeof runs)[number]) => number) =>
- entries.map((entry) => {
- const { entry: sessionEntry } = loadSubagentSessionEntry(
- params,
- entry.childSessionKey,
- {
- loadSessionStore,
- resolveStorePath,
- },
- storeCache,
- );
- const line = formatSubagentListLine({
- entry,
- index,
- runtimeMs: runtimeMs(entry),
- sessionEntry,
- pendingDescendants: pendingDescendantCount(entry.childSessionKey),
- });
- index += 1;
- return line;
- });
-
- const activeEntries = sorted.filter((entry) => isActiveRun(entry));
- const activeLines = mapRuns(activeEntries, (entry) => now - (entry.startedAt ?? entry.createdAt));
- const recentEntries = sorted.filter(
- (entry) => !isActiveRun(entry) && !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff,
- );
- const recentLines = mapRuns(
- recentEntries,
- (entry) => (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt),
- );
-
+ const list = buildSubagentList({
+ cfg: params.cfg,
+ runs,
+ recentMinutes: RECENT_WINDOW_MINUTES,
+ taskMaxChars: 110,
+ });
const lines = ["active subagents:", "-----"];
- if (activeLines.length === 0) {
+ if (list.active.length === 0) {
lines.push("(none)");
} else {
- lines.push(activeLines.join("\n"));
+ lines.push(list.active.map((entry) => entry.line).join("\n"));
}
lines.push("", `recent subagents (last ${RECENT_WINDOW_MINUTES}m):`, "-----");
- if (recentLines.length === 0) {
+ if (list.recent.length === 0) {
lines.push("(none)");
} else {
- lines.push(recentLines.join("\n"));
+ lines.push(list.recent.map((entry) => entry.line).join("\n"));
}
return stopWithText(lines.join("\n"));
diff --git a/src/auto-reply/reply/commands-subagents/action-send.ts b/src/auto-reply/reply/commands-subagents/action-send.ts
index d8b752571c0..3e764e2a6bb 100644
--- a/src/auto-reply/reply/commands-subagents/action-send.ts
+++ b/src/auto-reply/reply/commands-subagents/action-send.ts
@@ -1,27 +1,15 @@
-import crypto from "node:crypto";
-import { AGENT_LANE_SUBAGENT } from "../../../agents/lanes.js";
-import { abortEmbeddedPiRun } from "../../../agents/pi-embedded.js";
import {
- clearSubagentRunSteerRestart,
- replaceSubagentRunAfterSteer,
- markSubagentRunForSteerRestart,
-} from "../../../agents/subagent-registry.js";
-import { loadSessionStore, resolveStorePath } from "../../../config/sessions.js";
-import { callGateway } from "../../../gateway/call.js";
-import { logVerbose } from "../../../globals.js";
-import { INTERNAL_MESSAGE_CHANNEL } from "../../../utils/message-channel.js";
+ sendControlledSubagentMessage,
+ steerControlledSubagentRun,
+} from "../../../agents/subagent-control.js";
import type { CommandHandlerResult } from "../commands-types.js";
-import { clearSessionQueues } from "../queue.js";
import { formatRunLabel } from "../subagents-utils.js";
import {
type SubagentsCommandContext,
COMMAND,
- STEER_ABORT_SETTLE_TIMEOUT_MS,
- extractAssistantText,
- loadSubagentSessionEntry,
+ resolveCommandSubagentController,
resolveSubagentEntryForToken,
stopWithText,
- stripToolMessages,
} from "./shared.js";
export async function handleSubagentsSendAction(
@@ -49,111 +37,41 @@ export async function handleSubagentsSendAction(
return stopWithText(`${formatRunLabel(targetResolution.entry)} is already finished.`);
}
- const { entry: targetSessionEntry } = loadSubagentSessionEntry(
- params,
- targetResolution.entry.childSessionKey,
- {
- loadSessionStore,
- resolveStorePath,
- },
- );
- const targetSessionId =
- typeof targetSessionEntry?.sessionId === "string" && targetSessionEntry.sessionId.trim()
- ? targetSessionEntry.sessionId.trim()
- : undefined;
-
if (steerRequested) {
- markSubagentRunForSteerRestart(targetResolution.entry.runId);
-
- if (targetSessionId) {
- abortEmbeddedPiRun(targetSessionId);
- }
-
- const cleared = clearSessionQueues([targetResolution.entry.childSessionKey, targetSessionId]);
- if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
- logVerbose(
- `subagents steer: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
+ const controller = resolveCommandSubagentController(params, ctx.requesterKey);
+ const result = await steerControlledSubagentRun({
+ cfg: params.cfg,
+ controller,
+ entry: targetResolution.entry,
+ message,
+ });
+ if (result.status === "accepted") {
+ return stopWithText(
+ `steered ${formatRunLabel(targetResolution.entry)} (run ${result.runId.slice(0, 8)}).`,
);
}
-
- try {
- await callGateway({
- method: "agent.wait",
- params: {
- runId: targetResolution.entry.runId,
- timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS,
- },
- timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS + 2_000,
- });
- } catch {
- // Continue even if wait fails; steer should still be attempted.
+ if (result.status === "done" && result.text) {
+ return stopWithText(result.text);
}
+ if (result.status === "error") {
+ return stopWithText(`send failed: ${result.error ?? "error"}`);
+ }
+ return stopWithText(`⚠️ ${result.error ?? "send failed"}`);
}
- const idempotencyKey = crypto.randomUUID();
- let runId: string = idempotencyKey;
- try {
- const response = await callGateway<{ runId: string }>({
- method: "agent",
- params: {
- message,
- sessionKey: targetResolution.entry.childSessionKey,
- sessionId: targetSessionId,
- idempotencyKey,
- deliver: false,
- channel: INTERNAL_MESSAGE_CHANNEL,
- lane: AGENT_LANE_SUBAGENT,
- timeout: 0,
- },
- timeoutMs: 10_000,
- });
- const responseRunId = typeof response?.runId === "string" ? response.runId : undefined;
- if (responseRunId) {
- runId = responseRunId;
- }
- } catch (err) {
- if (steerRequested) {
- clearSubagentRunSteerRestart(targetResolution.entry.runId);
- }
- const messageText =
- err instanceof Error ? err.message : typeof err === "string" ? err : "error";
- return stopWithText(`send failed: ${messageText}`);
- }
-
- if (steerRequested) {
- replaceSubagentRunAfterSteer({
- previousRunId: targetResolution.entry.runId,
- nextRunId: runId,
- fallback: targetResolution.entry,
- runTimeoutSeconds: targetResolution.entry.runTimeoutSeconds ?? 0,
- });
- return stopWithText(
- `steered ${formatRunLabel(targetResolution.entry)} (run ${runId.slice(0, 8)}).`,
- );
- }
-
- const waitMs = 30_000;
- const wait = await callGateway<{ status?: string; error?: string }>({
- method: "agent.wait",
- params: { runId, timeoutMs: waitMs },
- timeoutMs: waitMs + 2000,
+ const result = await sendControlledSubagentMessage({
+ cfg: params.cfg,
+ entry: targetResolution.entry,
+ message,
});
- if (wait?.status === "timeout") {
- return stopWithText(`⏳ Subagent still running (run ${runId.slice(0, 8)}).`);
+ if (result.status === "timeout") {
+ return stopWithText(`⏳ Subagent still running (run ${result.runId.slice(0, 8)}).`);
}
- if (wait?.status === "error") {
- const waitError = typeof wait.error === "string" ? wait.error : "unknown error";
- return stopWithText(`⚠️ Subagent error: ${waitError} (run ${runId.slice(0, 8)}).`);
+ if (result.status === "error") {
+ return stopWithText(`⚠️ Subagent error: ${result.error} (run ${result.runId.slice(0, 8)}).`);
}
-
- const history = await callGateway<{ messages: Array }>({
- method: "chat.history",
- params: { sessionKey: targetResolution.entry.childSessionKey, limit: 50 },
- });
- const filtered = stripToolMessages(Array.isArray(history?.messages) ? history.messages : []);
- const last = filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
- const replyText = last ? extractAssistantText(last) : undefined;
return stopWithText(
- replyText ?? `✅ Sent to ${formatRunLabel(targetResolution.entry)} (run ${runId.slice(0, 8)}).`,
+ result.replyText ??
+ `✅ Sent to ${formatRunLabel(targetResolution.entry)} (run ${result.runId.slice(0, 8)}).`,
);
}
diff --git a/src/auto-reply/reply/commands-subagents/shared.ts b/src/auto-reply/reply/commands-subagents/shared.ts
index ec96437e645..bb923b52e46 100644
--- a/src/auto-reply/reply/commands-subagents/shared.ts
+++ b/src/auto-reply/reply/commands-subagents/shared.ts
@@ -1,3 +1,5 @@
+import { resolveStoredSubagentCapabilities } from "../../../agents/subagent-capabilities.js";
+import type { ResolvedSubagentController } from "../../../agents/subagent-control.js";
import {
countPendingDescendantRuns,
type SubagentRunRecord,
@@ -18,6 +20,7 @@ import { parseDiscordTarget } from "../../../discord/targets.js";
import { callGateway } from "../../../gateway/call.js";
import { formatTimeAgo } from "../../../infra/format-time/format-relative.ts";
import { parseAgentSessionKey } from "../../../routing/session-key.js";
+import { isSubagentSessionKey } from "../../../routing/session-key.js";
import { looksLikeSessionId } from "../../../sessions/session-id.js";
import { extractTextFromChatContent } from "../../../shared/chat-content.js";
import {
@@ -247,6 +250,29 @@ export function resolveRequesterSessionKey(
return resolveInternalSessionKey({ key: raw, alias, mainKey });
}
+export function resolveCommandSubagentController(
+ params: SubagentsCommandParams,
+ requesterKey: string,
+): ResolvedSubagentController {
+ if (!isSubagentSessionKey(requesterKey)) {
+ return {
+ controllerSessionKey: requesterKey,
+ callerSessionKey: requesterKey,
+ callerIsSubagent: false,
+ controlScope: "children",
+ };
+ }
+ const capabilities = resolveStoredSubagentCapabilities(requesterKey, {
+ cfg: params.cfg,
+ });
+ return {
+ controllerSessionKey: requesterKey,
+ callerSessionKey: requesterKey,
+ callerIsSubagent: true,
+ controlScope: capabilities.controlScope,
+ };
+}
+
export function resolveHandledPrefix(normalized: string): string | null {
return normalized.startsWith(COMMAND)
? COMMAND
diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts
index 0f526d6edaa..073cc36488c 100644
--- a/src/auto-reply/reply/commands.test.ts
+++ b/src/auto-reply/reply/commands.test.ts
@@ -682,6 +682,52 @@ describe("handleCommands /config configWrites gating", () => {
expect(result.reply?.text).toContain("Config writes are disabled");
});
+ it("blocks /config set when the target account disables writes", async () => {
+ const previousWriteCount = writeConfigFileMock.mock.calls.length;
+ const cfg = {
+ commands: { config: true, text: true },
+ channels: {
+ telegram: {
+ configWrites: true,
+ accounts: {
+ work: { configWrites: false, enabled: true },
+ },
+ },
+ },
+ } as OpenClawConfig;
+ const params = buildPolicyParams(
+ "/config set channels.telegram.accounts.work.enabled=false",
+ cfg,
+ {
+ AccountId: "default",
+ Provider: "telegram",
+ Surface: "telegram",
+ },
+ );
+ const result = await handleCommands(params);
+ expect(result.shouldContinue).toBe(false);
+ expect(result.reply?.text).toContain("channels.telegram.accounts.work.configWrites=true");
+ expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount);
+ });
+
+ it("blocks ambiguous channel-root /config writes from channel commands", async () => {
+ const previousWriteCount = writeConfigFileMock.mock.calls.length;
+ const cfg = {
+ commands: { config: true, text: true },
+ channels: { telegram: { configWrites: true } },
+ } as OpenClawConfig;
+ const params = buildPolicyParams('/config set channels.telegram={"enabled":false}', cfg, {
+ Provider: "telegram",
+ Surface: "telegram",
+ });
+ const result = await handleCommands(params);
+ expect(result.shouldContinue).toBe(false);
+ expect(result.reply?.text).toContain(
+ "cannot replace channels, channel roots, or accounts collections",
+ );
+ expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount);
+ });
+
it("blocks /config set from gateway clients without operator.admin", async () => {
const cfg = {
commands: { config: true, text: true },
@@ -739,6 +785,49 @@ describe("handleCommands /config configWrites gating", () => {
expect(writeConfigFileMock).toHaveBeenCalledOnce();
expect(result.reply?.text).toContain("Config updated");
});
+
+ it("keeps /config set working for gateway operator.admin on protected account paths", async () => {
+ readConfigFileSnapshotMock.mockResolvedValueOnce({
+ valid: true,
+ parsed: {
+ channels: {
+ telegram: {
+ accounts: {
+ work: { enabled: true, configWrites: false },
+ },
+ },
+ },
+ },
+ });
+ validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
+ ok: true,
+ config,
+ }));
+ const params = buildParams(
+ "/config set channels.telegram.accounts.work.enabled=false",
+ {
+ commands: { config: true, text: true },
+ channels: {
+ telegram: {
+ accounts: {
+ work: { enabled: true, configWrites: false },
+ },
+ },
+ },
+ } as OpenClawConfig,
+ {
+ Provider: INTERNAL_MESSAGE_CHANNEL,
+ Surface: INTERNAL_MESSAGE_CHANNEL,
+ GatewayClientScopes: ["operator.write", "operator.admin"],
+ },
+ );
+ params.command.channel = INTERNAL_MESSAGE_CHANNEL;
+ const result = await handleCommands(params);
+ expect(result.shouldContinue).toBe(false);
+ expect(result.reply?.text).toContain("Config updated");
+ const written = writeConfigFileMock.mock.calls.at(-1)?.[0] as OpenClawConfig;
+ expect(written.channels?.telegram?.accounts?.work?.enabled).toBe(false);
+ });
});
describe("handleCommands bash alias", () => {
@@ -891,6 +980,35 @@ describe("handleCommands /allowlist", () => {
});
});
+ it("blocks config-targeted /allowlist edits when the target account disables writes", async () => {
+ const previousWriteCount = writeConfigFileMock.mock.calls.length;
+ const cfg = {
+ commands: { text: true, config: true },
+ channels: {
+ telegram: {
+ configWrites: true,
+ accounts: {
+ work: { configWrites: false, allowFrom: ["123"] },
+ },
+ },
+ },
+ } as OpenClawConfig;
+ readConfigFileSnapshotMock.mockResolvedValueOnce({
+ valid: true,
+ parsed: structuredClone(cfg),
+ });
+ const params = buildPolicyParams("/allowlist add dm --account work --config 789", cfg, {
+ AccountId: "default",
+ Provider: "telegram",
+ Surface: "telegram",
+ });
+ const result = await handleCommands(params);
+
+ expect(result.shouldContinue).toBe(false);
+ expect(result.reply?.text).toContain("channels.telegram.accounts.work.configWrites=true");
+ expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount);
+ });
+
it("removes default-account entries from scoped and legacy pairing stores", async () => {
removeChannelAllowFromStoreEntryMock
.mockResolvedValueOnce({
diff --git a/src/auto-reply/reply/directive-handling.auth.test.ts b/src/auto-reply/reply/directive-handling.auth.test.ts
index 04249b88795..4faad0c3ee6 100644
--- a/src/auto-reply/reply/directive-handling.auth.test.ts
+++ b/src/auto-reply/reply/directive-handling.auth.test.ts
@@ -32,7 +32,7 @@ vi.mock("../../agents/model-selection.js", () => ({
vi.mock("../../agents/model-auth.js", () => ({
ensureAuthProfileStore: () => mockStore,
- getCustomProviderApiKey: () => undefined,
+ resolveUsableCustomProviderApiKey: () => null,
resolveAuthProfileOrder: () => mockOrder,
resolveEnvApiKey: () => null,
}));
diff --git a/src/auto-reply/reply/directive-handling.auth.ts b/src/auto-reply/reply/directive-handling.auth.ts
index dd33ed6ae73..26647d77c68 100644
--- a/src/auto-reply/reply/directive-handling.auth.ts
+++ b/src/auto-reply/reply/directive-handling.auth.ts
@@ -6,9 +6,9 @@ import {
} from "../../agents/auth-profiles.js";
import {
ensureAuthProfileStore,
- getCustomProviderApiKey,
resolveAuthProfileOrder,
resolveEnvApiKey,
+ resolveUsableCustomProviderApiKey,
} from "../../agents/model-auth.js";
import { findNormalizedProviderValue, normalizeProviderId } from "../../agents/model-selection.js";
import type { OpenClawConfig } from "../../config/config.js";
@@ -204,7 +204,7 @@ export const resolveAuthLabel = async (
const label = isOAuthEnv ? "OAuth (env)" : maskApiKey(envKey.apiKey);
return { label, source: mode === "verbose" ? envKey.source : "" };
}
- const customKey = getCustomProviderApiKey(cfg, provider);
+ const customKey = resolveUsableCustomProviderApiKey({ cfg, provider })?.apiKey;
if (customKey) {
return {
label: maskApiKey(customKey),
diff --git a/src/auto-reply/reply/directive-handling.model-picker.ts b/src/auto-reply/reply/directive-handling.model-picker.ts
index 0c2bcaf61e6..46c892dab0f 100644
--- a/src/auto-reply/reply/directive-handling.model-picker.ts
+++ b/src/auto-reply/reply/directive-handling.model-picker.ts
@@ -19,6 +19,7 @@ const MODEL_PICK_PROVIDER_PREFERENCE = [
"zai",
"openrouter",
"opencode",
+ "opencode-go",
"github-copilot",
"groq",
"cerebras",
diff --git a/src/browser/cdp.helpers.ts b/src/browser/cdp.helpers.ts
index 5749a591fd6..44f689e8706 100644
--- a/src/browser/cdp.helpers.ts
+++ b/src/browser/cdp.helpers.ts
@@ -3,6 +3,7 @@ import { isLoopbackHost } from "../gateway/net.js";
import { rawDataToString } from "../infra/ws.js";
import { getDirectAgentForCdp, withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js";
import { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js";
+import { resolveBrowserRateLimitMessage } from "./client-fetch.js";
import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js";
export { isLoopbackHost };
@@ -172,6 +173,10 @@ export async function fetchCdpChecked(
fetch(url, { ...init, headers, signal: ctrl.signal }),
);
if (!res.ok) {
+ if (res.status === 429) {
+ // Do not reflect upstream response text into the error surface (log/agent injection risk)
+ throw new Error(`${resolveBrowserRateLimitMessage(url)} Do NOT retry the browser tool.`);
+ }
throw new Error(`HTTP ${res.status}`);
}
return res;
diff --git a/src/browser/client-fetch.loopback-auth.test.ts b/src/browser/client-fetch.loopback-auth.test.ts
index cda6d29d4e3..7967d11c76e 100644
--- a/src/browser/client-fetch.loopback-auth.test.ts
+++ b/src/browser/client-fetch.loopback-auth.test.ts
@@ -1,4 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { BrowserDispatchResponse } from "./routes/dispatcher.js";
+
+function okDispatchResponse(): BrowserDispatchResponse {
+ return { status: 200, body: { ok: true } };
+}
const mocks = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({
@@ -9,7 +14,7 @@ const mocks = vi.hoisted(() => ({
},
})),
startBrowserControlServiceFromConfig: vi.fn(async () => ({ ok: true })),
- dispatch: vi.fn(async () => ({ status: 200, body: { ok: true } })),
+ dispatch: vi.fn(async (): Promise => okDispatchResponse()),
}));
vi.mock("../config/config.js", async (importOriginal) => {
@@ -57,7 +62,7 @@ describe("fetchBrowserJson loopback auth", () => {
},
});
mocks.startBrowserControlServiceFromConfig.mockReset().mockResolvedValue({ ok: true });
- mocks.dispatch.mockReset().mockResolvedValue({ status: 200, body: { ok: true } });
+ mocks.dispatch.mockReset().mockResolvedValue(okDispatchResponse());
});
afterEach(() => {
@@ -133,6 +138,102 @@ describe("fetchBrowserJson loopback auth", () => {
expect(thrown.message).not.toContain("Can't reach the OpenClaw browser control service");
});
+ it("surfaces 429 from HTTP URL as rate-limit error with no-retry hint", async () => {
+ const response = new Response("max concurrent sessions exceeded", { status: 429 });
+ const text = vi.spyOn(response, "text");
+ const cancel = vi.spyOn(response.body!, "cancel").mockResolvedValue(undefined);
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => response),
+ );
+
+ const thrown = await fetchBrowserJson<{ ok: boolean }>("http://127.0.0.1:18888/").catch(
+ (err: unknown) => err,
+ );
+
+ expect(thrown).toBeInstanceOf(Error);
+ if (!(thrown instanceof Error)) {
+ throw new Error(`Expected Error, got ${String(thrown)}`);
+ }
+ expect(thrown.message).toContain("Browser service rate limit reached");
+ expect(thrown.message).toContain("Do NOT retry the browser tool");
+ expect(thrown.message).not.toContain("max concurrent sessions exceeded");
+ expect(text).not.toHaveBeenCalled();
+ expect(cancel).toHaveBeenCalledOnce();
+ });
+
+ it("surfaces 429 from HTTP URL without body detail when empty", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => new Response("", { status: 429 })),
+ );
+
+ const thrown = await fetchBrowserJson<{ ok: boolean }>("http://127.0.0.1:18888/").catch(
+ (err: unknown) => err,
+ );
+
+ expect(thrown).toBeInstanceOf(Error);
+ if (!(thrown instanceof Error)) {
+ throw new Error(`Expected Error, got ${String(thrown)}`);
+ }
+ expect(thrown.message).toContain("rate limit reached");
+ expect(thrown.message).toContain("Do NOT retry the browser tool");
+ });
+
+ it("keeps Browserbase-specific wording for Browserbase 429 responses", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => new Response("max concurrent sessions exceeded", { status: 429 })),
+ );
+
+ const thrown = await fetchBrowserJson<{ ok: boolean }>(
+ "https://connect.browserbase.com/session",
+ ).catch((err: unknown) => err);
+
+ expect(thrown).toBeInstanceOf(Error);
+ if (!(thrown instanceof Error)) {
+ throw new Error(`Expected Error, got ${String(thrown)}`);
+ }
+ expect(thrown.message).toContain("Browserbase rate limit reached");
+ expect(thrown.message).toContain("upgrade your plan");
+ expect(thrown.message).not.toContain("max concurrent sessions exceeded");
+ });
+
+ it("non-429 errors still produce generic messages", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => new Response("internal error", { status: 500 })),
+ );
+
+ const thrown = await fetchBrowserJson<{ ok: boolean }>("http://127.0.0.1:18888/").catch(
+ (err: unknown) => err,
+ );
+
+ expect(thrown).toBeInstanceOf(Error);
+ if (!(thrown instanceof Error)) {
+ throw new Error(`Expected Error, got ${String(thrown)}`);
+ }
+ expect(thrown.message).toContain("internal error");
+ expect(thrown.message).not.toContain("rate limit");
+ });
+
+ it("surfaces 429 from dispatcher path as rate-limit error", async () => {
+ mocks.dispatch.mockResolvedValueOnce({
+ status: 429,
+ body: { error: "too many sessions" },
+ });
+
+ const thrown = await fetchBrowserJson<{ ok: boolean }>("/tabs").catch((err: unknown) => err);
+
+ expect(thrown).toBeInstanceOf(Error);
+ if (!(thrown instanceof Error)) {
+ throw new Error(`Expected Error, got ${String(thrown)}`);
+ }
+ expect(thrown.message).toContain("Browser service rate limit reached");
+ expect(thrown.message).toContain("Do NOT retry the browser tool");
+ expect(thrown.message).not.toContain("too many sessions");
+ });
+
it("keeps absolute URL failures wrapped as reachability errors", async () => {
vi.stubGlobal(
"fetch",
diff --git a/src/browser/client-fetch.ts b/src/browser/client-fetch.ts
index 8f13da4e1aa..e321c5a1e62 100644
--- a/src/browser/client-fetch.ts
+++ b/src/browser/client-fetch.ts
@@ -102,6 +102,36 @@ const BROWSER_TOOL_MODEL_HINT =
"Do NOT retry the browser tool — it will keep failing. " +
"Use an alternative approach or inform the user that the browser is currently unavailable.";
+const BROWSER_SERVICE_RATE_LIMIT_MESSAGE =
+ "Browser service rate limit reached. " +
+ "Wait for the current session to complete, or retry later.";
+
+const BROWSERBASE_RATE_LIMIT_MESSAGE =
+ "Browserbase rate limit reached (max concurrent sessions). " +
+ "Wait for the current session to complete, or upgrade your plan.";
+
+function isRateLimitStatus(status: number): boolean {
+ return status === 429;
+}
+
+function isBrowserbaseUrl(url: string): boolean {
+ if (!isAbsoluteHttp(url)) {
+ return false;
+ }
+ try {
+ const host = new URL(url).hostname.toLowerCase();
+ return host === "browserbase.com" || host.endsWith(".browserbase.com");
+ } catch {
+ return false;
+ }
+}
+
+export function resolveBrowserRateLimitMessage(url: string): string {
+ return isBrowserbaseUrl(url)
+ ? BROWSERBASE_RATE_LIMIT_MESSAGE
+ : BROWSER_SERVICE_RATE_LIMIT_MESSAGE;
+}
+
function resolveBrowserFetchOperatorHint(url: string): string {
const isLocal = !isAbsoluteHttp(url);
return isLocal
@@ -123,6 +153,14 @@ function appendBrowserToolModelHint(message: string): string {
return `${message} ${BROWSER_TOOL_MODEL_HINT}`;
}
+async function discardResponseBody(res: Response): Promise {
+ try {
+ await res.body?.cancel();
+ } catch {
+ // Best effort only; we're already returning a stable error message.
+ }
+}
+
function enhanceDispatcherPathError(url: string, err: unknown): Error {
const msg = normalizeErrorMessage(err);
const suffix = `${resolveBrowserFetchOperatorHint(url)} ${BROWSER_TOOL_MODEL_HINT}`;
@@ -175,6 +213,13 @@ async function fetchHttpJson(
try {
const res = await fetch(url, { ...init, signal: ctrl.signal });
if (!res.ok) {
+ if (isRateLimitStatus(res.status)) {
+ // Do not reflect upstream response text into the error surface (log/agent injection risk)
+ await discardResponseBody(res);
+ throw new BrowserServiceError(
+ `${resolveBrowserRateLimitMessage(url)} ${BROWSER_TOOL_MODEL_HINT}`,
+ );
+ }
const text = await res.text().catch(() => "");
throw new BrowserServiceError(text || `HTTP ${res.status}`);
}
@@ -269,6 +314,12 @@ export async function fetchBrowserJson(
});
if (result.status >= 400) {
+ if (isRateLimitStatus(result.status)) {
+ // Do not reflect upstream response text into the error surface (log/agent injection risk)
+ throw new BrowserServiceError(
+ `${resolveBrowserRateLimitMessage(url)} ${BROWSER_TOOL_MODEL_HINT}`,
+ );
+ }
const message =
result.body && typeof result.body === "object" && "error" in result.body
? String((result.body as { error?: unknown }).error)
diff --git a/src/browser/pw-session.ts b/src/browser/pw-session.ts
index a7103c1174c..2e63d190dea 100644
--- a/src/browser/pw-session.ts
+++ b/src/browser/pw-session.ts
@@ -365,6 +365,11 @@ async function connectBrowser(cdpUrl: string): Promise {
return connected;
} catch (err) {
lastErr = err;
+ // Don't retry rate-limit errors; retrying worsens the 429.
+ const errMsg = err instanceof Error ? err.message : String(err);
+ if (errMsg.includes("rate limit")) {
+ break;
+ }
const delay = 250 + attempt * 250;
await new Promise((r) => setTimeout(r, delay));
}
diff --git a/src/channels/allowlist-match.test.ts b/src/channels/allowlist-match.test.ts
new file mode 100644
index 00000000000..9a55e593e57
--- /dev/null
+++ b/src/channels/allowlist-match.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest";
+import {
+ resolveAllowlistMatchByCandidates,
+ resolveAllowlistMatchSimple,
+} from "./allowlist-match.js";
+
+describe("channels/allowlist-match", () => {
+ it("reflects in-place allowFrom edits even when array length stays the same", () => {
+ const allowFrom = ["alice", "bob"];
+
+ expect(resolveAllowlistMatchSimple({ allowFrom, senderId: "bob" })).toEqual({
+ allowed: true,
+ matchKey: "bob",
+ matchSource: "id",
+ });
+
+ allowFrom[1] = "mallory";
+
+ expect(resolveAllowlistMatchSimple({ allowFrom, senderId: "bob" })).toEqual({
+ allowed: false,
+ });
+ expect(resolveAllowlistMatchSimple({ allowFrom, senderId: "mallory" })).toEqual({
+ allowed: true,
+ matchKey: "mallory",
+ matchSource: "id",
+ });
+ });
+
+ it("drops wildcard access after in-place wildcard replacement", () => {
+ const allowFrom = ["*"];
+
+ expect(resolveAllowlistMatchSimple({ allowFrom, senderId: "eve" })).toEqual({
+ allowed: true,
+ matchKey: "*",
+ matchSource: "wildcard",
+ });
+
+ allowFrom[0] = "alice";
+
+ expect(resolveAllowlistMatchSimple({ allowFrom, senderId: "eve" })).toEqual({
+ allowed: false,
+ });
+ expect(resolveAllowlistMatchSimple({ allowFrom, senderId: "alice" })).toEqual({
+ allowed: true,
+ matchKey: "alice",
+ matchSource: "id",
+ });
+ });
+
+ it("recomputes candidate allowlist sets after in-place replacement", () => {
+ const allowList = ["user:alice", "user:bob"];
+
+ expect(
+ resolveAllowlistMatchByCandidates({
+ allowList,
+ candidates: [{ value: "user:bob", source: "prefixed-user" }],
+ }),
+ ).toEqual({
+ allowed: true,
+ matchKey: "user:bob",
+ matchSource: "prefixed-user",
+ });
+
+ allowList[1] = "user:mallory";
+
+ expect(
+ resolveAllowlistMatchByCandidates({
+ allowList,
+ candidates: [{ value: "user:bob", source: "prefixed-user" }],
+ }),
+ ).toEqual({
+ allowed: false,
+ });
+ expect(
+ resolveAllowlistMatchByCandidates({
+ allowList,
+ candidates: [{ value: "user:mallory", source: "prefixed-user" }],
+ }),
+ ).toEqual({
+ allowed: true,
+ matchKey: "user:mallory",
+ matchSource: "prefixed-user",
+ });
+ });
+});
diff --git a/src/channels/allowlist-match.ts b/src/channels/allowlist-match.ts
index b30ef119c84..f32d5a2487c 100644
--- a/src/channels/allowlist-match.ts
+++ b/src/channels/allowlist-match.ts
@@ -16,33 +16,40 @@ export type AllowlistMatch = {
matchSource?: TSource;
};
-type CachedAllowListSet = {
- size: number;
- set: Set;
+export type CompiledAllowlist = {
+ set: ReadonlySet;
+ wildcard: boolean;
};
-const ALLOWLIST_SET_CACHE = new WeakMap();
-const SIMPLE_ALLOWLIST_CACHE = new WeakMap<
- Array,
- { normalized: string[]; size: number; wildcard: boolean; set: Set }
->();
-
export function formatAllowlistMatchMeta(
match?: { matchKey?: string; matchSource?: string } | null,
): string {
return `matchKey=${match?.matchKey ?? "none"} matchSource=${match?.matchSource ?? "none"}`;
}
-export function resolveAllowlistMatchByCandidates(params: {
- allowList: string[];
+export function compileAllowlist(entries: ReadonlyArray): CompiledAllowlist {
+ const set = new Set(entries.filter(Boolean));
+ return {
+ set,
+ wildcard: set.has("*"),
+ };
+}
+
+function compileSimpleAllowlist(entries: ReadonlyArray): CompiledAllowlist {
+ return compileAllowlist(
+ entries.map((entry) => String(entry).trim().toLowerCase()).filter(Boolean),
+ );
+}
+
+export function resolveAllowlistCandidates(params: {
+ compiledAllowlist: CompiledAllowlist;
candidates: Array<{ value?: string; source: TSource }>;
}): AllowlistMatch {
- const allowSet = resolveAllowListSet(params.allowList);
for (const candidate of params.candidates) {
if (!candidate.value) {
continue;
}
- if (allowSet.has(candidate.value)) {
+ if (params.compiledAllowlist.set.has(candidate.value)) {
return {
allowed: true,
matchKey: candidate.value,
@@ -53,15 +60,25 @@ export function resolveAllowlistMatchByCandidates(params
return { allowed: false };
}
+export function resolveAllowlistMatchByCandidates(params: {
+ allowList: ReadonlyArray;
+ candidates: Array<{ value?: string; source: TSource }>;
+}): AllowlistMatch {
+ return resolveAllowlistCandidates({
+ compiledAllowlist: compileAllowlist(params.allowList),
+ candidates: params.candidates,
+ });
+}
+
export function resolveAllowlistMatchSimple(params: {
- allowFrom: Array;
+ allowFrom: ReadonlyArray;
senderId: string;
senderName?: string | null;
allowNameMatching?: boolean;
}): AllowlistMatch<"wildcard" | "id" | "name"> {
- const allowFrom = resolveSimpleAllowFrom(params.allowFrom);
+ const allowFrom = compileSimpleAllowlist(params.allowFrom);
- if (allowFrom.size === 0) {
+ if (allowFrom.set.size === 0) {
return { allowed: false };
}
if (allowFrom.wildcard) {
@@ -69,47 +86,17 @@ export function resolveAllowlistMatchSimple(params: {
}
const senderId = params.senderId.toLowerCase();
- if (allowFrom.set.has(senderId)) {
- return { allowed: true, matchKey: senderId, matchSource: "id" };
- }
-
const senderName = params.senderName?.toLowerCase();
- if (params.allowNameMatching === true && senderName && allowFrom.set.has(senderName)) {
- return { allowed: true, matchKey: senderName, matchSource: "name" };
- }
-
- return { allowed: false };
-}
-
-function resolveAllowListSet(allowList: string[]): Set {
- const cached = ALLOWLIST_SET_CACHE.get(allowList);
- if (cached && cached.size === allowList.length) {
- return cached.set;
- }
- const set = new Set(allowList);
- ALLOWLIST_SET_CACHE.set(allowList, { size: allowList.length, set });
- return set;
-}
-
-function resolveSimpleAllowFrom(allowFrom: Array): {
- normalized: string[];
- size: number;
- wildcard: boolean;
- set: Set;
-} {
- const cached = SIMPLE_ALLOWLIST_CACHE.get(allowFrom);
- if (cached && cached.size === allowFrom.length) {
- return cached;
- }
-
- const normalized = allowFrom.map((entry) => String(entry).trim().toLowerCase()).filter(Boolean);
- const set = new Set(normalized);
- const built = {
- normalized,
- size: allowFrom.length,
- wildcard: set.has("*"),
- set,
- };
- SIMPLE_ALLOWLIST_CACHE.set(allowFrom, built);
- return built;
+ return resolveAllowlistCandidates({
+ compiledAllowlist: allowFrom,
+ candidates: [
+ { value: senderId, source: "id" },
+ ...(params.allowNameMatching === true && senderName
+ ? ([{ value: senderName, source: "name" as const }] satisfies Array<{
+ value?: string;
+ source: "id" | "name";
+ }>)
+ : []),
+ ],
+ });
}
diff --git a/src/channels/plugins/config-schema.ts b/src/channels/plugins/config-schema.ts
index 35be4c9d388..5ae166aa5a7 100644
--- a/src/channels/plugins/config-schema.ts
+++ b/src/channels/plugins/config-schema.ts
@@ -1,4 +1,5 @@
import { z, type ZodTypeAny } from "zod";
+import { DmPolicySchema } from "../../config/zod-schema.core.js";
import type { ChannelConfigSchema } from "./types.plugin.js";
type ZodSchemaWithToJsonSchema = ZodTypeAny & {
@@ -10,6 +11,17 @@ type ExtendableZodObject = ZodTypeAny & {
};
export const AllowFromEntrySchema = z.union([z.string(), z.number()]);
+export const AllowFromListSchema = z.array(AllowFromEntrySchema).optional();
+
+export function buildNestedDmConfigSchema() {
+ return z
+ .object({
+ enabled: z.boolean().optional(),
+ policy: DmPolicySchema.optional(),
+ allowFrom: AllowFromListSchema,
+ })
+ .optional();
+}
export function buildCatchallMultiAccountChannelSchema(
accountSchema: T,
diff --git a/src/channels/plugins/config-writes.ts b/src/channels/plugins/config-writes.ts
index 87e220d7029..3e3ef36ed04 100644
--- a/src/channels/plugins/config-writes.ts
+++ b/src/channels/plugins/config-writes.ts
@@ -1,6 +1,8 @@
import type { OpenClawConfig } from "../../config/config.js";
import { resolveAccountEntry } from "../../routing/account-lookup.js";
+import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import { normalizeAccountId } from "../../routing/session-key.js";
+import { isInternalMessageChannel } from "../../utils/message-channel.js";
import type { ChannelId } from "./types.js";
type ChannelConfigWithAccounts = {
@@ -12,6 +14,25 @@ function resolveAccountConfig(accounts: ChannelConfigWithAccounts["accounts"], a
return resolveAccountEntry(accounts, accountId);
}
+export type ConfigWriteScope = {
+ channelId?: ChannelId | null;
+ accountId?: string | null;
+};
+
+export type ConfigWriteTarget =
+ | { kind: "global" }
+ | { kind: "channel"; scope: { channelId: ChannelId } }
+ | { kind: "account"; scope: { channelId: ChannelId; accountId: string } }
+ | { kind: "ambiguous"; scopes: ConfigWriteScope[] };
+
+export type ConfigWriteAuthorizationResult =
+ | { allowed: true }
+ | {
+ allowed: false;
+ reason: "ambiguous-target" | "origin-disabled" | "target-disabled";
+ blockedScope?: { kind: "origin" | "target"; scope: ConfigWriteScope };
+ };
+
export function resolveChannelConfigWrites(params: {
cfg: OpenClawConfig;
channelId?: ChannelId | null;
@@ -30,3 +51,133 @@ export function resolveChannelConfigWrites(params: {
const value = accountConfig?.configWrites ?? channelConfig.configWrites;
return value !== false;
}
+
+export function authorizeConfigWrite(params: {
+ cfg: OpenClawConfig;
+ origin?: ConfigWriteScope;
+ target?: ConfigWriteTarget;
+ allowBypass?: boolean;
+}): ConfigWriteAuthorizationResult {
+ if (params.allowBypass) {
+ return { allowed: true };
+ }
+ if (params.target?.kind === "ambiguous") {
+ return { allowed: false, reason: "ambiguous-target" };
+ }
+ if (
+ params.origin?.channelId &&
+ !resolveChannelConfigWrites({
+ cfg: params.cfg,
+ channelId: params.origin.channelId,
+ accountId: params.origin.accountId,
+ })
+ ) {
+ return {
+ allowed: false,
+ reason: "origin-disabled",
+ blockedScope: { kind: "origin", scope: params.origin },
+ };
+ }
+ const seen = new Set();
+ for (const target of listConfigWriteTargetScopes(params.target)) {
+ if (!target.channelId) {
+ continue;
+ }
+ const key = `${target.channelId}:${normalizeAccountId(target.accountId)}`;
+ if (seen.has(key)) {
+ continue;
+ }
+ seen.add(key);
+ if (
+ !resolveChannelConfigWrites({
+ cfg: params.cfg,
+ channelId: target.channelId,
+ accountId: target.accountId,
+ })
+ ) {
+ return {
+ allowed: false,
+ reason: "target-disabled",
+ blockedScope: { kind: "target", scope: target },
+ };
+ }
+ }
+ return { allowed: true };
+}
+
+export function resolveExplicitConfigWriteTarget(scope: ConfigWriteScope): ConfigWriteTarget {
+ if (!scope.channelId) {
+ return { kind: "global" };
+ }
+ const accountId = normalizeAccountId(scope.accountId);
+ if (!accountId || accountId === DEFAULT_ACCOUNT_ID) {
+ return { kind: "channel", scope: { channelId: scope.channelId } };
+ }
+ return { kind: "account", scope: { channelId: scope.channelId, accountId } };
+}
+
+export function resolveConfigWriteTargetFromPath(path: string[]): ConfigWriteTarget {
+ if (path[0] !== "channels") {
+ return { kind: "global" };
+ }
+ if (path.length < 2) {
+ return { kind: "ambiguous", scopes: [] };
+ }
+ const channelId = path[1].trim().toLowerCase() as ChannelId;
+ if (!channelId) {
+ return { kind: "ambiguous", scopes: [] };
+ }
+ if (path.length === 2) {
+ return { kind: "ambiguous", scopes: [{ channelId }] };
+ }
+ if (path[2] !== "accounts") {
+ return { kind: "channel", scope: { channelId } };
+ }
+ if (path.length < 4) {
+ return { kind: "ambiguous", scopes: [{ channelId }] };
+ }
+ return resolveExplicitConfigWriteTarget({
+ channelId,
+ accountId: normalizeAccountId(path[3]),
+ });
+}
+
+export function canBypassConfigWritePolicy(params: {
+ channel?: string | null;
+ gatewayClientScopes?: string[] | null;
+}): boolean {
+ return (
+ isInternalMessageChannel(params.channel) &&
+ params.gatewayClientScopes?.includes("operator.admin") === true
+ );
+}
+
+export function formatConfigWriteDeniedMessage(params: {
+ result: Exclude;
+ fallbackChannelId?: ChannelId | null;
+}): string {
+ if (params.result.reason === "ambiguous-target") {
+ return "⚠️ Channel-initiated /config writes cannot replace channels, channel roots, or accounts collections. Use a more specific path or gateway operator.admin.";
+ }
+
+ const blocked = params.result.blockedScope?.scope;
+ const channelLabel = blocked?.channelId ?? params.fallbackChannelId ?? "this channel";
+ const hint = blocked?.channelId
+ ? blocked.accountId
+ ? `channels.${blocked.channelId}.accounts.${blocked.accountId}.configWrites=true`
+ : `channels.${blocked.channelId}.configWrites=true`
+ : params.fallbackChannelId
+ ? `channels.${params.fallbackChannelId}.configWrites=true`
+ : "channels..configWrites=true";
+ return `⚠️ Config writes are disabled for ${channelLabel}. Set ${hint} to enable.`;
+}
+
+function listConfigWriteTargetScopes(target?: ConfigWriteTarget): ConfigWriteScope[] {
+ if (!target || target.kind === "global") {
+ return [];
+ }
+ if (target.kind === "ambiguous") {
+ return target.scopes;
+ }
+ return [target.scope];
+}
diff --git a/src/channels/plugins/onboarding/discord.ts b/src/channels/plugins/onboarding/discord.ts
index 52f0d2b1373..d6a8c8df370 100644
--- a/src/channels/plugins/onboarding/discord.ts
+++ b/src/channels/plugins/onboarding/discord.ts
@@ -20,15 +20,14 @@ import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onb
import { configureChannelAccessWithAllowlist } from "./channel-access-configure.js";
import {
applySingleTokenPromptResult,
- buildSingleChannelSecretPromptState,
parseMentionOrPrefixedId,
noteChannelLookupFailure,
noteChannelLookupSummary,
patchChannelConfigForAccount,
promptLegacyChannelAllowFrom,
- promptSingleChannelSecretInput,
resolveAccountIdForConfigure,
resolveOnboardingAccountId,
+ runSingleChannelSecretStep,
setAccountGroupPolicyForChannel,
setLegacyChannelDmPolicyWithAllowFrom,
setOnboardingChannelEnabled,
@@ -179,52 +178,39 @@ export const discordOnboardingAdapter: ChannelOnboardingAdapter = {
accountId: discordAccountId,
});
const allowEnv = discordAccountId === DEFAULT_ACCOUNT_ID;
- const tokenPromptState = buildSingleChannelSecretPromptState({
- accountConfigured: Boolean(resolvedAccount.token),
- hasConfigToken: hasConfiguredSecretInput(resolvedAccount.config.token),
- allowEnv,
- envValue: process.env.DISCORD_BOT_TOKEN,
- });
-
- if (!tokenPromptState.accountConfigured) {
- await noteDiscordTokenHelp(prompter);
- }
-
- const tokenResult = await promptSingleChannelSecretInput({
+ const tokenStep = await runSingleChannelSecretStep({
cfg: next,
prompter,
providerHint: "discord",
credentialLabel: "Discord bot token",
secretInputMode: options?.secretInputMode,
- accountConfigured: tokenPromptState.accountConfigured,
- canUseEnv: tokenPromptState.canUseEnv,
- hasConfigToken: tokenPromptState.hasConfigToken,
+ accountConfigured: Boolean(resolvedAccount.token),
+ hasConfigToken: hasConfiguredSecretInput(resolvedAccount.config.token),
+ allowEnv,
+ envValue: process.env.DISCORD_BOT_TOKEN,
envPrompt: "DISCORD_BOT_TOKEN detected. Use env var?",
keepPrompt: "Discord token already configured. Keep it?",
inputPrompt: "Enter Discord bot token",
preferredEnvVar: allowEnv ? "DISCORD_BOT_TOKEN" : undefined,
+ onMissingConfigured: async () => await noteDiscordTokenHelp(prompter),
+ applyUseEnv: async (cfg) =>
+ applySingleTokenPromptResult({
+ cfg,
+ channel: "discord",
+ accountId: discordAccountId,
+ tokenPatchKey: "token",
+ tokenResult: { useEnv: true, token: null },
+ }),
+ applySet: async (cfg, value) =>
+ applySingleTokenPromptResult({
+ cfg,
+ channel: "discord",
+ accountId: discordAccountId,
+ tokenPatchKey: "token",
+ tokenResult: { useEnv: false, token: value },
+ }),
});
-
- let resolvedTokenForAllowlist: string | undefined;
- if (tokenResult.action === "use-env") {
- next = applySingleTokenPromptResult({
- cfg: next,
- channel: "discord",
- accountId: discordAccountId,
- tokenPatchKey: "token",
- tokenResult: { useEnv: true, token: null },
- });
- resolvedTokenForAllowlist = process.env.DISCORD_BOT_TOKEN?.trim() || undefined;
- } else if (tokenResult.action === "set") {
- next = applySingleTokenPromptResult({
- cfg: next,
- channel: "discord",
- accountId: discordAccountId,
- tokenPatchKey: "token",
- tokenResult: { useEnv: false, token: tokenResult.value },
- });
- resolvedTokenForAllowlist = tokenResult.resolvedValue;
- }
+ next = tokenStep.cfg;
const currentEntries = Object.entries(resolvedAccount.config.guilds ?? {}).flatMap(
([guildKey, value]) => {
@@ -261,7 +247,7 @@ export const discordOnboardingAdapter: ChannelOnboardingAdapter = {
input,
resolved: false,
}));
- const activeToken = accountWithTokens.token || resolvedTokenForAllowlist || "";
+ const activeToken = accountWithTokens.token || tokenStep.resolvedValue || "";
if (activeToken && entries.length > 0) {
try {
resolved = await resolveDiscordChannelAllowlist({
diff --git a/src/channels/plugins/onboarding/helpers.ts b/src/channels/plugins/onboarding/helpers.ts
index 31ba023ba2f..6eab25fd239 100644
--- a/src/channels/plugins/onboarding/helpers.ts
+++ b/src/channels/plugins/onboarding/helpers.ts
@@ -9,7 +9,10 @@ import { promptAccountId as promptAccountIdSdk } from "../../../plugin-sdk/onboa
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type { PromptAccountId, PromptAccountIdParams } from "../onboarding-types.js";
-import { moveSingleAccountChannelSectionToDefaultAccount } from "../setup-helpers.js";
+import {
+ moveSingleAccountChannelSectionToDefaultAccount,
+ patchScopedAccountConfig,
+} from "../setup-helpers.js";
export const promptAccountId: PromptAccountId = async (params: PromptAccountIdParams) => {
return await promptAccountIdSdk(params);
@@ -364,50 +367,14 @@ function patchConfigForScopedAccount(params: {
cfg,
channelKey: channel,
});
- const channelConfig =
- (seededCfg.channels?.[channel] as Record | undefined) ?? {};
-
- if (accountId === DEFAULT_ACCOUNT_ID) {
- return {
- ...seededCfg,
- channels: {
- ...seededCfg.channels,
- [channel]: {
- ...channelConfig,
- ...(ensureEnabled ? { enabled: true } : {}),
- ...patch,
- },
- },
- };
- }
-
- const accounts =
- (channelConfig.accounts as Record> | undefined) ?? {};
- const existingAccount = accounts[accountId] ?? {};
-
- return {
- ...seededCfg,
- channels: {
- ...seededCfg.channels,
- [channel]: {
- ...channelConfig,
- ...(ensureEnabled ? { enabled: true } : {}),
- accounts: {
- ...accounts,
- [accountId]: {
- ...existingAccount,
- ...(ensureEnabled
- ? {
- enabled:
- typeof existingAccount.enabled === "boolean" ? existingAccount.enabled : true,
- }
- : {}),
- ...patch,
- },
- },
- },
- },
- };
+ return patchScopedAccountConfig({
+ cfg: seededCfg,
+ channelKey: channel,
+ accountId,
+ patch,
+ ensureChannelEnabled: ensureEnabled,
+ ensureAccountEnabled: ensureEnabled,
+ });
}
export function patchChannelConfigForAccount(params: {
@@ -515,6 +482,82 @@ export type SingleChannelSecretInputPromptResult =
| { action: "use-env" }
| { action: "set"; value: SecretInput; resolvedValue: string };
+export async function runSingleChannelSecretStep(params: {
+ cfg: OpenClawConfig;
+ prompter: Pick;
+ providerHint: string;
+ credentialLabel: string;
+ secretInputMode?: "plaintext" | "ref";
+ accountConfigured: boolean;
+ hasConfigToken: boolean;
+ allowEnv: boolean;
+ envValue?: string;
+ envPrompt: string;
+ keepPrompt: string;
+ inputPrompt: string;
+ preferredEnvVar?: string;
+ onMissingConfigured?: () => Promise;
+ applyUseEnv?: (cfg: OpenClawConfig) => OpenClawConfig | Promise;
+ applySet?: (
+ cfg: OpenClawConfig,
+ value: SecretInput,
+ resolvedValue: string,
+ ) => OpenClawConfig | Promise;
+}): Promise<{
+ cfg: OpenClawConfig;
+ action: SingleChannelSecretInputPromptResult["action"];
+ resolvedValue?: string;
+}> {
+ const promptState = buildSingleChannelSecretPromptState({
+ accountConfigured: params.accountConfigured,
+ hasConfigToken: params.hasConfigToken,
+ allowEnv: params.allowEnv,
+ envValue: params.envValue,
+ });
+
+ if (!promptState.accountConfigured && params.onMissingConfigured) {
+ await params.onMissingConfigured();
+ }
+
+ const result = await promptSingleChannelSecretInput({
+ cfg: params.cfg,
+ prompter: params.prompter,
+ providerHint: params.providerHint,
+ credentialLabel: params.credentialLabel,
+ secretInputMode: params.secretInputMode,
+ accountConfigured: promptState.accountConfigured,
+ canUseEnv: promptState.canUseEnv,
+ hasConfigToken: promptState.hasConfigToken,
+ envPrompt: params.envPrompt,
+ keepPrompt: params.keepPrompt,
+ inputPrompt: params.inputPrompt,
+ preferredEnvVar: params.preferredEnvVar,
+ });
+
+ if (result.action === "use-env") {
+ return {
+ cfg: params.applyUseEnv ? await params.applyUseEnv(params.cfg) : params.cfg,
+ action: result.action,
+ resolvedValue: params.envValue?.trim() || undefined,
+ };
+ }
+
+ if (result.action === "set") {
+ return {
+ cfg: params.applySet
+ ? await params.applySet(params.cfg, result.value, result.resolvedValue)
+ : params.cfg,
+ action: result.action,
+ resolvedValue: result.resolvedValue,
+ };
+ }
+
+ return {
+ cfg: params.cfg,
+ action: result.action,
+ };
+}
+
export async function promptSingleChannelSecretInput(params: {
cfg: OpenClawConfig;
prompter: Pick;
diff --git a/src/channels/plugins/onboarding/slack.ts b/src/channels/plugins/onboarding/slack.ts
index cc683477c09..0cceb859e4d 100644
--- a/src/channels/plugins/onboarding/slack.ts
+++ b/src/channels/plugins/onboarding/slack.ts
@@ -14,15 +14,14 @@ import type { WizardPrompter } from "../../../wizard/prompts.js";
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js";
import { configureChannelAccessWithAllowlist } from "./channel-access-configure.js";
import {
- buildSingleChannelSecretPromptState,
parseMentionOrPrefixedId,
noteChannelLookupFailure,
noteChannelLookupSummary,
patchChannelConfigForAccount,
promptLegacyChannelAllowFrom,
- promptSingleChannelSecretInput,
resolveAccountIdForConfigure,
resolveOnboardingAccountId,
+ runSingleChannelSecretStep,
setAccountGroupPolicyForChannel,
setLegacyChannelDmPolicyWithAllowFrom,
setOnboardingChannelEnabled,
@@ -235,18 +234,6 @@ export const slackOnboardingAdapter: ChannelOnboardingAdapter = {
const accountConfigured =
Boolean(resolvedAccount.botToken && resolvedAccount.appToken) || hasConfigTokens;
const allowEnv = slackAccountId === DEFAULT_ACCOUNT_ID;
- const botPromptState = buildSingleChannelSecretPromptState({
- accountConfigured: Boolean(resolvedAccount.botToken) || hasConfiguredBotToken,
- hasConfigToken: hasConfiguredBotToken,
- allowEnv,
- envValue: process.env.SLACK_BOT_TOKEN,
- });
- const appPromptState = buildSingleChannelSecretPromptState({
- accountConfigured: Boolean(resolvedAccount.appToken) || hasConfiguredAppToken,
- hasConfigToken: hasConfiguredAppToken,
- allowEnv,
- envValue: process.env.SLACK_APP_TOKEN,
- });
let resolvedBotTokenForAllowlist = resolvedAccount.botToken;
const slackBotName = String(
await prompter.text({
@@ -257,54 +244,56 @@ export const slackOnboardingAdapter: ChannelOnboardingAdapter = {
if (!accountConfigured) {
await noteSlackTokenHelp(prompter, slackBotName);
}
- const botTokenResult = await promptSingleChannelSecretInput({
+ const botTokenStep = await runSingleChannelSecretStep({
cfg: next,
prompter,
providerHint: "slack-bot",
credentialLabel: "Slack bot token",
secretInputMode: options?.secretInputMode,
- accountConfigured: botPromptState.accountConfigured,
- canUseEnv: botPromptState.canUseEnv,
- hasConfigToken: botPromptState.hasConfigToken,
+ accountConfigured: Boolean(resolvedAccount.botToken) || hasConfiguredBotToken,
+ hasConfigToken: hasConfiguredBotToken,
+ allowEnv,
+ envValue: process.env.SLACK_BOT_TOKEN,
envPrompt: "SLACK_BOT_TOKEN detected. Use env var?",
keepPrompt: "Slack bot token already configured. Keep it?",
inputPrompt: "Enter Slack bot token (xoxb-...)",
preferredEnvVar: allowEnv ? "SLACK_BOT_TOKEN" : undefined,
+ applySet: async (cfg, value) =>
+ patchChannelConfigForAccount({
+ cfg,
+ channel: "slack",
+ accountId: slackAccountId,
+ patch: { botToken: value },
+ }),
});
- if (botTokenResult.action === "use-env") {
- resolvedBotTokenForAllowlist = process.env.SLACK_BOT_TOKEN?.trim() || undefined;
- } else if (botTokenResult.action === "set") {
- next = patchChannelConfigForAccount({
- cfg: next,
- channel: "slack",
- accountId: slackAccountId,
- patch: { botToken: botTokenResult.value },
- });
- resolvedBotTokenForAllowlist = botTokenResult.resolvedValue;
+ next = botTokenStep.cfg;
+ if (botTokenStep.resolvedValue) {
+ resolvedBotTokenForAllowlist = botTokenStep.resolvedValue;
}
- const appTokenResult = await promptSingleChannelSecretInput({
+ const appTokenStep = await runSingleChannelSecretStep({
cfg: next,
prompter,
providerHint: "slack-app",
credentialLabel: "Slack app token",
secretInputMode: options?.secretInputMode,
- accountConfigured: appPromptState.accountConfigured,
- canUseEnv: appPromptState.canUseEnv,
- hasConfigToken: appPromptState.hasConfigToken,
+ accountConfigured: Boolean(resolvedAccount.appToken) || hasConfiguredAppToken,
+ hasConfigToken: hasConfiguredAppToken,
+ allowEnv,
+ envValue: process.env.SLACK_APP_TOKEN,
envPrompt: "SLACK_APP_TOKEN detected. Use env var?",
keepPrompt: "Slack app token already configured. Keep it?",
inputPrompt: "Enter Slack app token (xapp-...)",
preferredEnvVar: allowEnv ? "SLACK_APP_TOKEN" : undefined,
+ applySet: async (cfg, value) =>
+ patchChannelConfigForAccount({
+ cfg,
+ channel: "slack",
+ accountId: slackAccountId,
+ patch: { appToken: value },
+ }),
});
- if (appTokenResult.action === "set") {
- next = patchChannelConfigForAccount({
- cfg: next,
- channel: "slack",
- accountId: slackAccountId,
- patch: { appToken: appTokenResult.value },
- });
- }
+ next = appTokenStep.cfg;
next = await configureChannelAccessWithAllowlist({
cfg: next,
diff --git a/src/channels/plugins/onboarding/telegram.ts b/src/channels/plugins/onboarding/telegram.ts
index 22a173d47fe..2c37c24bcee 100644
--- a/src/channels/plugins/onboarding/telegram.ts
+++ b/src/channels/plugins/onboarding/telegram.ts
@@ -14,12 +14,11 @@ import { fetchTelegramChatId } from "../../telegram/api.js";
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js";
import {
applySingleTokenPromptResult,
- buildSingleChannelSecretPromptState,
patchChannelConfigForAccount,
- promptSingleChannelSecretInput,
promptResolvedAllowFrom,
resolveAccountIdForConfigure,
resolveOnboardingAccountId,
+ runSingleChannelSecretStep,
setChannelDmPolicyWithAllowFrom,
setOnboardingChannelEnabled,
splitOnboardingEntries,
@@ -194,59 +193,46 @@ export const telegramOnboardingAdapter: ChannelOnboardingAdapter = {
const hasConfigToken =
hasConfiguredBotToken || Boolean(resolvedAccount.config.tokenFile?.trim());
const allowEnv = telegramAccountId === DEFAULT_ACCOUNT_ID;
- const tokenPromptState = buildSingleChannelSecretPromptState({
- accountConfigured: Boolean(resolvedAccount.token) || hasConfigToken,
- hasConfigToken,
- allowEnv,
- envValue: process.env.TELEGRAM_BOT_TOKEN,
- });
-
- if (!tokenPromptState.accountConfigured) {
- await noteTelegramTokenHelp(prompter);
- }
-
- const tokenResult = await promptSingleChannelSecretInput({
+ const tokenStep = await runSingleChannelSecretStep({
cfg: next,
prompter,
providerHint: "telegram",
credentialLabel: "Telegram bot token",
secretInputMode: options?.secretInputMode,
- accountConfigured: tokenPromptState.accountConfigured,
- canUseEnv: tokenPromptState.canUseEnv,
- hasConfigToken: tokenPromptState.hasConfigToken,
+ accountConfigured: Boolean(resolvedAccount.token) || hasConfigToken,
+ hasConfigToken,
+ allowEnv,
+ envValue: process.env.TELEGRAM_BOT_TOKEN,
envPrompt: "TELEGRAM_BOT_TOKEN detected. Use env var?",
keepPrompt: "Telegram token already configured. Keep it?",
inputPrompt: "Enter Telegram bot token",
preferredEnvVar: allowEnv ? "TELEGRAM_BOT_TOKEN" : undefined,
+ onMissingConfigured: async () => await noteTelegramTokenHelp(prompter),
+ applyUseEnv: async (cfg) =>
+ applySingleTokenPromptResult({
+ cfg,
+ channel: "telegram",
+ accountId: telegramAccountId,
+ tokenPatchKey: "botToken",
+ tokenResult: { useEnv: true, token: null },
+ }),
+ applySet: async (cfg, value) =>
+ applySingleTokenPromptResult({
+ cfg,
+ channel: "telegram",
+ accountId: telegramAccountId,
+ tokenPatchKey: "botToken",
+ tokenResult: { useEnv: false, token: value },
+ }),
});
-
- let resolvedTokenForAllowFrom: string | undefined;
- if (tokenResult.action === "use-env") {
- next = applySingleTokenPromptResult({
- cfg: next,
- channel: "telegram",
- accountId: telegramAccountId,
- tokenPatchKey: "botToken",
- tokenResult: { useEnv: true, token: null },
- });
- resolvedTokenForAllowFrom = process.env.TELEGRAM_BOT_TOKEN?.trim() || undefined;
- } else if (tokenResult.action === "set") {
- next = applySingleTokenPromptResult({
- cfg: next,
- channel: "telegram",
- accountId: telegramAccountId,
- tokenPatchKey: "botToken",
- tokenResult: { useEnv: false, token: tokenResult.value },
- });
- resolvedTokenForAllowFrom = tokenResult.resolvedValue;
- }
+ next = tokenStep.cfg;
if (forceAllowFrom) {
next = await promptTelegramAllowFrom({
cfg: next,
prompter,
accountId: telegramAccountId,
- tokenOverride: resolvedTokenForAllowFrom,
+ tokenOverride: tokenStep.resolvedValue,
});
}
diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts
index 2afc67d439d..8af1b5831ee 100644
--- a/src/channels/plugins/outbound/telegram.ts
+++ b/src/channels/plugins/outbound/telegram.ts
@@ -1,3 +1,4 @@
+import type { ReplyPayload } from "../../../auto-reply/types.js";
import type { OutboundSendDeps } from "../../../infra/outbound/deliver.js";
import type { TelegramInlineButtons } from "../../../telegram/button-types.js";
import { markdownToTelegramHtmlChunks } from "../../../telegram/format.js";
@@ -8,16 +9,19 @@ import {
import { sendMessageTelegram } from "../../../telegram/send.js";
import type { ChannelOutboundAdapter } from "../types.js";
+type TelegramSendFn = typeof sendMessageTelegram;
+type TelegramSendOpts = Parameters[2];
+
function resolveTelegramSendContext(params: {
- cfg: NonNullable[2]>["cfg"];
+ cfg: NonNullable["cfg"];
deps?: OutboundSendDeps;
accountId?: string | null;
replyToId?: string | null;
threadId?: string | number | null;
}): {
- send: typeof sendMessageTelegram;
+ send: TelegramSendFn;
baseOpts: {
- cfg: NonNullable[2]>["cfg"];
+ cfg: NonNullable["cfg"];
verbose: false;
textMode: "html";
messageThreadId?: number;
@@ -39,6 +43,49 @@ function resolveTelegramSendContext(params: {
};
}
+export async function sendTelegramPayloadMessages(params: {
+ send: TelegramSendFn;
+ to: string;
+ payload: ReplyPayload;
+ baseOpts: Omit, "buttons" | "mediaUrl" | "quoteText">;
+}): Promise>> {
+ const telegramData = params.payload.channelData?.telegram as
+ | { buttons?: TelegramInlineButtons; quoteText?: string }
+ | undefined;
+ const quoteText =
+ typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined;
+ const text = params.payload.text ?? "";
+ const mediaUrls = params.payload.mediaUrls?.length
+ ? params.payload.mediaUrls
+ : params.payload.mediaUrl
+ ? [params.payload.mediaUrl]
+ : [];
+ const payloadOpts = {
+ ...params.baseOpts,
+ quoteText,
+ };
+
+ if (mediaUrls.length === 0) {
+ return await params.send(params.to, text, {
+ ...payloadOpts,
+ buttons: telegramData?.buttons,
+ });
+ }
+
+ // Telegram allows reply_markup on media; attach buttons only to the first send.
+ let finalResult: Awaited> | undefined;
+ for (let i = 0; i < mediaUrls.length; i += 1) {
+ const mediaUrl = mediaUrls[i];
+ const isFirst = i === 0;
+ finalResult = await params.send(params.to, isFirst ? text : "", {
+ ...payloadOpts,
+ mediaUrl,
+ ...(isFirst ? { buttons: telegramData?.buttons } : {}),
+ });
+ }
+ return finalResult ?? { messageId: "unknown", chatId: params.to };
+}
+
export const telegramOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: markdownToTelegramHtmlChunks,
@@ -92,48 +139,22 @@ export const telegramOutbound: ChannelOutboundAdapter = {
replyToId,
threadId,
}) => {
- const { send, baseOpts: contextOpts } = resolveTelegramSendContext({
+ const { send, baseOpts } = resolveTelegramSendContext({
cfg,
deps,
accountId,
replyToId,
threadId,
});
- const telegramData = payload.channelData?.telegram as
- | { buttons?: TelegramInlineButtons; quoteText?: string }
- | undefined;
- const quoteText =
- typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined;
- const text = payload.text ?? "";
- const mediaUrls = payload.mediaUrls?.length
- ? payload.mediaUrls
- : payload.mediaUrl
- ? [payload.mediaUrl]
- : [];
- const payloadOpts = {
- ...contextOpts,
- quoteText,
- mediaLocalRoots,
- };
- if (mediaUrls.length === 0) {
- const result = await send(to, text, {
- ...payloadOpts,
- buttons: telegramData?.buttons,
- });
- return { channel: "telegram", ...result };
- }
-
- // Telegram allows reply_markup on media; attach buttons only to first send.
- let finalResult: Awaited> | undefined;
- for (let i = 0; i < mediaUrls.length; i += 1) {
- const mediaUrl = mediaUrls[i];
- const isFirst = i === 0;
- finalResult = await send(to, isFirst ? text : "", {
- ...payloadOpts,
- mediaUrl,
- ...(isFirst ? { buttons: telegramData?.buttons } : {}),
- });
- }
- return { channel: "telegram", ...(finalResult ?? { messageId: "unknown", chatId: to }) };
+ const result = await sendTelegramPayloadMessages({
+ send,
+ to,
+ payload,
+ baseOpts: {
+ ...baseOpts,
+ mediaLocalRoots,
+ },
+ });
+ return { channel: "telegram", ...result };
},
};
diff --git a/src/channels/plugins/plugins-core.test.ts b/src/channels/plugins/plugins-core.test.ts
index 49012222982..4e346f465bd 100644
--- a/src/channels/plugins/plugins-core.test.ts
+++ b/src/channels/plugins/plugins-core.test.ts
@@ -19,8 +19,16 @@ import {
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { withEnvAsync } from "../../test-utils/env.js";
+import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "./catalog.js";
-import { resolveChannelConfigWrites } from "./config-writes.js";
+import {
+ authorizeConfigWrite,
+ canBypassConfigWritePolicy,
+ formatConfigWriteDeniedMessage,
+ resolveExplicitConfigWriteTarget,
+ resolveChannelConfigWrites,
+ resolveConfigWriteTargetFromPath,
+} from "./config-writes.js";
import {
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,
@@ -325,6 +333,98 @@ describe("resolveChannelConfigWrites", () => {
});
});
+describe("authorizeConfigWrite", () => {
+ it("blocks when a target account disables writes", () => {
+ const cfg = makeSlackConfigWritesCfg("work");
+ expect(
+ authorizeConfigWrite({
+ cfg,
+ origin: { channelId: "slack", accountId: "default" },
+ target: resolveExplicitConfigWriteTarget({ channelId: "slack", accountId: "work" }),
+ }),
+ ).toEqual({
+ allowed: false,
+ reason: "target-disabled",
+ blockedScope: { kind: "target", scope: { channelId: "slack", accountId: "work" } },
+ });
+ });
+
+ it("blocks when the origin account disables writes", () => {
+ const cfg = makeSlackConfigWritesCfg("default");
+ expect(
+ authorizeConfigWrite({
+ cfg,
+ origin: { channelId: "slack", accountId: "default" },
+ target: resolveExplicitConfigWriteTarget({ channelId: "slack", accountId: "work" }),
+ }),
+ ).toEqual({
+ allowed: false,
+ reason: "origin-disabled",
+ blockedScope: { kind: "origin", scope: { channelId: "slack", accountId: "default" } },
+ });
+ });
+
+ it("allows bypass for internal operator.admin writes", () => {
+ const cfg = makeSlackConfigWritesCfg("work");
+ expect(
+ authorizeConfigWrite({
+ cfg,
+ origin: { channelId: "slack", accountId: "default" },
+ target: resolveExplicitConfigWriteTarget({ channelId: "slack", accountId: "work" }),
+ allowBypass: canBypassConfigWritePolicy({
+ channel: INTERNAL_MESSAGE_CHANNEL,
+ gatewayClientScopes: ["operator.admin"],
+ }),
+ }),
+ ).toEqual({ allowed: true });
+ });
+
+ it("treats non-channel config paths as global writes", () => {
+ const cfg = makeSlackConfigWritesCfg("work");
+ expect(
+ authorizeConfigWrite({
+ cfg,
+ origin: { channelId: "slack", accountId: "default" },
+ target: resolveConfigWriteTargetFromPath(["messages", "ackReaction"]),
+ }),
+ ).toEqual({ allowed: true });
+ });
+
+ it("rejects ambiguous channel collection writes", () => {
+ expect(resolveConfigWriteTargetFromPath(["channels", "telegram"])).toEqual({
+ kind: "ambiguous",
+ scopes: [{ channelId: "telegram" }],
+ });
+ expect(resolveConfigWriteTargetFromPath(["channels", "telegram", "accounts"])).toEqual({
+ kind: "ambiguous",
+ scopes: [{ channelId: "telegram" }],
+ });
+ });
+
+ it("resolves explicit channel and account targets", () => {
+ expect(resolveExplicitConfigWriteTarget({ channelId: "slack" })).toEqual({
+ kind: "channel",
+ scope: { channelId: "slack" },
+ });
+ expect(resolveExplicitConfigWriteTarget({ channelId: "slack", accountId: "work" })).toEqual({
+ kind: "account",
+ scope: { channelId: "slack", accountId: "work" },
+ });
+ });
+
+ it("formats denied messages consistently", () => {
+ expect(
+ formatConfigWriteDeniedMessage({
+ result: {
+ allowed: false,
+ reason: "target-disabled",
+ blockedScope: { kind: "target", scope: { channelId: "slack", accountId: "work" } },
+ },
+ }),
+ ).toContain("channels.slack.accounts.work.configWrites=true");
+ });
+});
+
describe("directory (config-backed)", () => {
it("lists Slack peers/groups from config", async () => {
const cfg = {
diff --git a/src/channels/plugins/setup-helpers.test.ts b/src/channels/plugins/setup-helpers.test.ts
index df4609fc76f..10069c0b9f4 100644
--- a/src/channels/plugins/setup-helpers.test.ts
+++ b/src/channels/plugins/setup-helpers.test.ts
@@ -30,7 +30,7 @@ describe("applySetupAccountConfigPatch", () => {
});
});
- it("patches named account config and enables both channel and account", () => {
+ it("patches named account config and preserves existing account enabled flag", () => {
const next = applySetupAccountConfigPatch({
cfg: asConfig({
channels: {
@@ -50,7 +50,7 @@ describe("applySetupAccountConfigPatch", () => {
expect(next.channels?.zalo).toMatchObject({
enabled: true,
accounts: {
- work: { enabled: true, botToken: "new" },
+ work: { enabled: false, botToken: "new" },
},
});
});
diff --git a/src/channels/plugins/setup-helpers.ts b/src/channels/plugins/setup-helpers.ts
index 5045c431d60..d592a56e475 100644
--- a/src/channels/plugins/setup-helpers.ts
+++ b/src/channels/plugins/setup-helpers.ts
@@ -125,6 +125,23 @@ export function applySetupAccountConfigPatch(params: {
channelKey: string;
accountId: string;
patch: Record;
+}): OpenClawConfig {
+ return patchScopedAccountConfig({
+ cfg: params.cfg,
+ channelKey: params.channelKey,
+ accountId: params.accountId,
+ patch: params.patch,
+ });
+}
+
+export function patchScopedAccountConfig(params: {
+ cfg: OpenClawConfig;
+ channelKey: string;
+ accountId: string;
+ patch: Record;
+ accountPatch?: Record;
+ ensureChannelEnabled?: boolean;
+ ensureAccountEnabled?: boolean;
}): OpenClawConfig {
const accountId = normalizeAccountId(params.accountId);
const channels = params.cfg.channels as Record | undefined;
@@ -135,6 +152,10 @@ export function applySetupAccountConfigPatch(params: {
accounts?: Record>;
})
: undefined;
+ const ensureChannelEnabled = params.ensureChannelEnabled ?? true;
+ const ensureAccountEnabled = params.ensureAccountEnabled ?? ensureChannelEnabled;
+ const patch = params.patch;
+ const accountPatch = params.accountPatch ?? patch;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...params.cfg,
@@ -142,27 +163,33 @@ export function applySetupAccountConfigPatch(params: {
...params.cfg.channels,
[params.channelKey]: {
...base,
- enabled: true,
- ...params.patch,
+ ...(ensureChannelEnabled ? { enabled: true } : {}),
+ ...patch,
},
},
} as OpenClawConfig;
}
const accounts = base?.accounts ?? {};
+ const existingAccount = accounts[accountId] ?? {};
return {
...params.cfg,
channels: {
...params.cfg.channels,
[params.channelKey]: {
...base,
- enabled: true,
+ ...(ensureChannelEnabled ? { enabled: true } : {}),
accounts: {
...accounts,
[accountId]: {
- ...accounts[accountId],
- enabled: true,
- ...params.patch,
+ ...existingAccount,
+ ...(ensureAccountEnabled
+ ? {
+ enabled:
+ typeof existingAccount.enabled === "boolean" ? existingAccount.enabled : true,
+ }
+ : {}),
+ ...accountPatch,
},
},
},
diff --git a/src/cli/daemon-cli/gateway-token-drift.test.ts b/src/cli/daemon-cli/gateway-token-drift.test.ts
index ff221b24e44..0b9d0cfb308 100644
--- a/src/cli/daemon-cli/gateway-token-drift.test.ts
+++ b/src/cli/daemon-cli/gateway-token-drift.test.ts
@@ -43,4 +43,29 @@ describe("resolveGatewayTokenForDriftCheck", () => {
}),
).toThrow(/gateway\.auth\.token/i);
});
+
+ it("does not fall back to gateway.remote token for unresolved local token refs", () => {
+ expect(() =>
+ resolveGatewayTokenForDriftCheck({
+ cfg: {
+ secrets: {
+ providers: {
+ default: { source: "env" },
+ },
+ },
+ gateway: {
+ mode: "local",
+ auth: {
+ mode: "token",
+ token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" },
+ },
+ remote: {
+ token: "remote-token",
+ },
+ },
+ } as OpenClawConfig,
+ env: {} as NodeJS.ProcessEnv,
+ }),
+ ).toThrow(/gateway\.auth\.token/i);
+ });
});
diff --git a/src/cli/daemon-cli/gateway-token-drift.ts b/src/cli/daemon-cli/gateway-token-drift.ts
index e382a7a91c3..a05ea975ca2 100644
--- a/src/cli/daemon-cli/gateway-token-drift.ts
+++ b/src/cli/daemon-cli/gateway-token-drift.ts
@@ -1,16 +1,10 @@
import type { OpenClawConfig } from "../../config/config.js";
-import { resolveGatewayCredentialsFromConfig } from "../../gateway/credentials.js";
+import { resolveGatewayDriftCheckCredentialsFromConfig } from "../../gateway/credentials.js";
export function resolveGatewayTokenForDriftCheck(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}) {
- return resolveGatewayCredentialsFromConfig({
- cfg: params.cfg,
- env: {} as NodeJS.ProcessEnv,
- modeOverride: "local",
- // Drift checks should compare the configured local token source against the
- // persisted service token, not let exported shell env hide stale service state.
- localTokenPrecedence: "config-first",
- }).token;
+ void params.env;
+ return resolveGatewayDriftCheckCredentialsFromConfig({ cfg: params.cfg }).token;
}
diff --git a/src/cli/devices-cli.ts b/src/cli/devices-cli.ts
index 0344bf7967a..143d27b20ff 100644
--- a/src/cli/devices-cli.ts
+++ b/src/cli/devices-cli.ts
@@ -9,7 +9,7 @@ import {
} from "../infra/device-pairing.js";
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
import { defaultRuntime } from "../runtime.js";
-import { renderTable } from "../terminal/table.js";
+import { getTerminalTableWidth, renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { withProgress } from "./progress.js";
@@ -224,7 +224,7 @@ export function registerDevicesCli(program: Command) {
return;
}
if (list.pending?.length) {
- const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
+ const tableWidth = getTerminalTableWidth();
defaultRuntime.log(
`${theme.heading("Pending")} ${theme.muted(`(${list.pending.length})`)}`,
);
@@ -251,7 +251,7 @@ export function registerDevicesCli(program: Command) {
);
}
if (list.paired?.length) {
- const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
+ const tableWidth = getTerminalTableWidth();
defaultRuntime.log(
`${theme.heading("Paired")} ${theme.muted(`(${list.paired.length})`)}`,
);
diff --git a/src/cli/directory-cli.ts b/src/cli/directory-cli.ts
index d11867fbb40..1a9949f224a 100644
--- a/src/cli/directory-cli.ts
+++ b/src/cli/directory-cli.ts
@@ -6,7 +6,7 @@ import { danger } from "../globals.js";
import { resolveMessageChannelSelection } from "../infra/outbound/channel-selection.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
-import { renderTable } from "../terminal/table.js";
+import { getTerminalTableWidth, renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import { formatHelpExamples } from "./help-format.js";
@@ -48,7 +48,7 @@ function printDirectoryList(params: {
return;
}
- const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
+ const tableWidth = getTerminalTableWidth();
defaultRuntime.log(`${theme.heading(params.title)} ${theme.muted(`(${params.entries.length})`)}`);
defaultRuntime.log(
renderTable({
@@ -166,7 +166,7 @@ export function registerDirectoryCli(program: Command) {
defaultRuntime.log(theme.muted("Not available."));
return;
}
- const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
+ const tableWidth = getTerminalTableWidth();
defaultRuntime.log(theme.heading("Self"));
defaultRuntime.log(
renderTable({
diff --git a/src/cli/dns-cli.ts b/src/cli/dns-cli.ts
index de6e6c0dec0..f9781d2f38e 100644
--- a/src/cli/dns-cli.ts
+++ b/src/cli/dns-cli.ts
@@ -7,7 +7,7 @@ import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet
import { getWideAreaZonePath, resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
-import { renderTable } from "../terminal/table.js";
+import { getTerminalTableWidth, renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
type RunOpts = { allowFailure?: boolean; inherit?: boolean };
@@ -133,7 +133,7 @@ export function registerDnsCli(program: Command) {
}
const zonePath = getWideAreaZonePath(wideAreaDomain);
- const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
+ const tableWidth = getTerminalTableWidth();
defaultRuntime.log(theme.heading("DNS setup"));
defaultRuntime.log(
renderTable({
diff --git a/src/cli/exec-approvals-cli.ts b/src/cli/exec-approvals-cli.ts
index 07fe5a462a6..c243fb7a0aa 100644
--- a/src/cli/exec-approvals-cli.ts
+++ b/src/cli/exec-approvals-cli.ts
@@ -10,7 +10,7 @@ import {
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
-import { renderTable } from "../terminal/table.js";
+import { getTerminalTableWidth, renderTable } from "../terminal/table.js";
import { isRich, theme } from "../terminal/theme.js";
import { describeUnknownError } from "./gateway-cli/shared.js";
import { callGatewayFromCli } from "./gateway-rpc.js";
@@ -151,7 +151,7 @@ function renderApprovalsSnapshot(snapshot: ExecApprovalsSnapshot, targetLabel: s
const rich = isRich();
const heading = (text: string) => (rich ? theme.heading(text) : text);
const muted = (text: string) => (rich ? theme.muted(text) : text);
- const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
+ const tableWidth = getTerminalTableWidth();
const file = snapshot.file ?? { version: 1 };
const defaults = file.defaults ?? {};
diff --git a/src/cli/hooks-cli.ts b/src/cli/hooks-cli.ts
index 7ea0de030da..85aa0d0e4b9 100644
--- a/src/cli/hooks-cli.ts
+++ b/src/cli/hooks-cli.ts
@@ -22,7 +22,7 @@ import { resolveArchiveKind } from "../infra/archive.js";
import { buildPluginStatusReport } from "../plugins/status.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
-import { renderTable } from "../terminal/table.js";
+import { getTerminalTableWidth, renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import { resolveUserPath, shortenHomePath } from "../utils.js";
import { formatCliCommand } from "./command-format.js";
@@ -273,7 +273,7 @@ export function formatHooksList(report: HookStatusReport, opts: HooksListOptions
}
const eligible = hooks.filter((h) => h.eligible);
- const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
+ const tableWidth = getTerminalTableWidth();
const rows = hooks.map((hook) => {
const missing = formatHookMissingSummary(hook);
return {
diff --git a/src/cli/nodes-cli.coverage.test.ts b/src/cli/nodes-cli.coverage.test.ts
index 04bdfb39bf8..81d0f17c07c 100644
--- a/src/cli/nodes-cli.coverage.test.ts
+++ b/src/cli/nodes-cli.coverage.test.ts
@@ -174,7 +174,7 @@ describe("nodes-cli coverage", () => {
expect(invoke?.params?.command).toBe("system.run");
expect(invoke?.params?.params).toEqual({
command: ["echo", "hi"],
- rawCommand: null,
+ rawCommand: "echo hi",
cwd: "/tmp",
env: { FOO: "bar" },
timeoutMs: 1200,
@@ -186,11 +186,11 @@ describe("nodes-cli coverage", () => {
});
expect(invoke?.params?.timeoutMs).toBe(5000);
const approval = getApprovalRequestCall();
- expect(approval?.params?.["commandArgv"]).toEqual(["echo", "hi"]);
expect(approval?.params?.["systemRunPlan"]).toEqual({
argv: ["echo", "hi"],
cwd: "/tmp",
- rawCommand: null,
+ commandText: "echo hi",
+ commandPreview: null,
agentId: "main",
sessionKey: null,
});
@@ -213,18 +213,18 @@ describe("nodes-cli coverage", () => {
expect(invoke?.params?.command).toBe("system.run");
expect(invoke?.params?.params).toMatchObject({
command: ["/bin/sh", "-lc", "echo hi"],
- rawCommand: "echo hi",
+ rawCommand: '/bin/sh -lc "echo hi"',
agentId: "main",
approved: true,
approvalDecision: "allow-once",
runId: expect.any(String),
});
const approval = getApprovalRequestCall();
- expect(approval?.params?.["commandArgv"]).toEqual(["/bin/sh", "-lc", "echo hi"]);
expect(approval?.params?.["systemRunPlan"]).toEqual({
argv: ["/bin/sh", "-lc", "echo hi"],
cwd: null,
- rawCommand: "echo hi",
+ commandText: '/bin/sh -lc "echo hi"',
+ commandPreview: "echo hi",
agentId: "main",
sessionKey: null,
});
diff --git a/src/cli/nodes-cli/register.camera.ts b/src/cli/nodes-cli/register.camera.ts
index 3bd7d1203dc..82cde2a35f3 100644
--- a/src/cli/nodes-cli/register.camera.ts
+++ b/src/cli/nodes-cli/register.camera.ts
@@ -1,6 +1,6 @@
import type { Command } from "commander";
import { defaultRuntime } from "../../runtime.js";
-import { renderTable } from "../../terminal/table.js";
+import { getTerminalTableWidth, renderTable } from "../../terminal/table.js";
import { shortenHomePath } from "../../utils.js";
import {
type CameraFacing,
@@ -71,7 +71,7 @@ export function registerNodesCameraCommands(nodes: Command) {
}
const { heading, muted } = getNodesTheme();
- const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
+ const tableWidth = getTerminalTableWidth();
const rows = devices.map((device) => ({
Name: typeof device.name === "string" ? device.name : "Unknown Camera",
Position: typeof device.position === "string" ? device.position : muted("unspecified"),
diff --git a/src/cli/nodes-cli/register.invoke.ts b/src/cli/nodes-cli/register.invoke.ts
index 71a3e2361e4..0bd1fdad895 100644
--- a/src/cli/nodes-cli/register.invoke.ts
+++ b/src/cli/nodes-cli/register.invoke.ts
@@ -189,7 +189,6 @@ async function maybeRequestNodesRunApproval(params: {
opts: NodesRunOpts;
nodeId: string;
agentId: string | undefined;
- preparedCmdText: string;
approvalPlan: ReturnType