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["plan"]; hostSecurity: ExecSecurity; hostAsk: ExecAsk; @@ -215,8 +214,6 @@ async function maybeRequestNodesRunApproval(params: { params.opts, { id: approvalId, - command: params.preparedCmdText, - commandArgv: params.approvalPlan.argv, systemRunPlan: params.approvalPlan, cwd: params.approvalPlan.cwd, nodeId: params.nodeId, @@ -272,7 +269,7 @@ function buildSystemRunInvokeParams(params: { command: "system.run", params: { command: params.approvalPlan.argv, - rawCommand: params.approvalPlan.rawCommand, + rawCommand: params.approvalPlan.commandText, cwd: params.approvalPlan.cwd, env: params.nodeEnv, timeoutMs: params.timeoutMs, @@ -403,7 +400,6 @@ export function registerNodesInvokeCommands(nodes: Command) { opts, nodeId, agentId, - preparedCmdText: preparedContext.prepared.cmdText, approvalPlan, hostSecurity: approvals.hostSecurity, hostAsk: approvals.hostAsk, diff --git a/src/cli/nodes-cli/register.pairing.ts b/src/cli/nodes-cli/register.pairing.ts index b20c989c1c7..fd649fae754 100644 --- a/src/cli/nodes-cli/register.pairing.ts +++ b/src/cli/nodes-cli/register.pairing.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import { defaultRuntime } from "../../runtime.js"; +import { getTerminalTableWidth } from "../../terminal/table.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { parsePairingList } from "./format.js"; import { renderPendingPairingRequestsTable } from "./pairing-render.js"; @@ -25,7 +26,7 @@ export function registerNodesPairingCommands(nodes: Command) { return; } const { heading, warn, muted } = getNodesTheme(); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const now = Date.now(); const rendered = renderPendingPairingRequestsTable({ pending, diff --git a/src/cli/nodes-cli/register.status.ts b/src/cli/nodes-cli/register.status.ts index 4dcb3be8e38..03e00cbbec4 100644 --- a/src/cli/nodes-cli/register.status.ts +++ b/src/cli/nodes-cli/register.status.ts @@ -1,7 +1,7 @@ import type { Command } from "commander"; 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 { shortenHomeInString } from "../../utils.js"; import { parseDurationMs } from "../parse-duration.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; @@ -112,7 +112,7 @@ export function registerNodesStatusCommands(nodes: Command) { const obj: Record = typeof result === "object" && result !== null ? result : {}; const { ok, warn, muted } = getNodesTheme(); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const now = Date.now(); const nodes = parseNodeList(result); const lastConnectedById = @@ -256,7 +256,7 @@ export function registerNodesStatusCommands(nodes: Command) { const status = `${paired ? ok("paired") : warn("unpaired")} · ${ connected ? ok("connected") : muted("disconnected") }`; - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const rows = [ { Field: "ID", Value: nodeId }, displayName ? { Field: "Name", Value: displayName } : null, @@ -307,7 +307,7 @@ export function registerNodesStatusCommands(nodes: Command) { const result = await callGatewayCli("node.pair.list", opts, {}); const { pending, paired } = parsePairingList(result); const { heading, muted, warn } = getNodesTheme(); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const now = Date.now(); const hasFilters = connectedOnly || sinceMs !== undefined; const pendingRows = hasFilters ? [] : pending; diff --git a/src/cli/pairing-cli.ts b/src/cli/pairing-cli.ts index 6974663bd49..7c8cbc750ea 100644 --- a/src/cli/pairing-cli.ts +++ b/src/cli/pairing-cli.ts @@ -10,7 +10,7 @@ import { } from "../pairing/pairing-store.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 { formatCliCommand } from "./command-format.js"; @@ -88,7 +88,7 @@ export function registerPairingCli(program: Command) { return; } const idLabel = resolvePairingIdLabel(channel); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); defaultRuntime.log( `${theme.heading("Pairing requests")} ${theme.muted(`(${requests.length})`)}`, ); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 36e198c71a2..e77d7026875 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -19,7 +19,7 @@ import { resolveUninstallDirectoryTarget, uninstallPlugin } from "../plugins/uni import { updateNpmInstalledPlugins } from "../plugins/update.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, shortenHomeInString, shortenHomePath } from "../utils.js"; import { looksLikeLocalInstallSpec } from "./install-spec.js"; @@ -404,7 +404,7 @@ export function registerPluginsCli(program: Command) { ); if (!opts.verbose) { - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const sourceRoots = resolvePluginSourceRoots({ workspaceDir: report.workspaceDir, }); diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 6a5bd98aea0..4dd285e63c1 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -168,6 +168,7 @@ export function registerOnboardCommand(program: Command) { togetherApiKey: opts.togetherApiKey as string | undefined, huggingfaceApiKey: opts.huggingfaceApiKey as string | undefined, opencodeZenApiKey: opts.opencodeZenApiKey as string | undefined, + opencodeGoApiKey: opts.opencodeGoApiKey as string | undefined, xaiApiKey: opts.xaiApiKey as string | undefined, litellmApiKey: opts.litellmApiKey as string | undefined, volcengineApiKey: opts.volcengineApiKey as string | undefined, diff --git a/src/cli/skills-cli.format.ts b/src/cli/skills-cli.format.ts index 5f6dcfdcd2a..580f17b2d40 100644 --- a/src/cli/skills-cli.format.ts +++ b/src/cli/skills-cli.format.ts @@ -1,5 +1,5 @@ import type { SkillStatusEntry, SkillStatusReport } from "../agents/skills-status.js"; -import { renderTable } from "../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { shortenHomePath } from "../utils.js"; import { formatCliCommand } from "./command-format.js"; @@ -38,8 +38,12 @@ function formatSkillStatus(skill: SkillStatusEntry): string { return theme.error("✗ missing"); } +function normalizeSkillEmoji(emoji?: string): string { + return (emoji ?? "📦").replaceAll("\uFE0E", "\uFE0F"); +} + function formatSkillName(skill: SkillStatusEntry): string { - const emoji = skill.emoji ?? "📦"; + const emoji = normalizeSkillEmoji(skill.emoji); return `${emoji} ${theme.command(skill.name)}`; } @@ -95,7 +99,7 @@ export function formatSkillsList(report: SkillStatusReport, opts: SkillsListOpti } const eligible = skills.filter((s) => s.eligible); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const rows = skills.map((skill) => { const missing = formatSkillMissingSummary(skill); return { @@ -109,7 +113,7 @@ export function formatSkillsList(report: SkillStatusReport, opts: SkillsListOpti const columns = [ { key: "Status", header: "Status", minWidth: 10 }, - { key: "Skill", header: "Skill", minWidth: 18, flex: true }, + { key: "Skill", header: "Skill", minWidth: 22 }, { key: "Description", header: "Description", minWidth: 24, flex: true }, { key: "Source", header: "Source", minWidth: 10 }, ]; @@ -154,7 +158,7 @@ export function formatSkillInfo( } const lines: string[] = []; - const emoji = skill.emoji ?? "📦"; + const emoji = normalizeSkillEmoji(skill.emoji); const status = skill.eligible ? theme.success("✓ Ready") : skill.disabled @@ -282,7 +286,7 @@ export function formatSkillsCheck(report: SkillStatusReport, opts: SkillsCheckOp lines.push(""); lines.push(theme.heading("Ready to use:")); for (const skill of eligible) { - const emoji = skill.emoji ?? "📦"; + const emoji = normalizeSkillEmoji(skill.emoji); lines.push(` ${emoji} ${skill.name}`); } } @@ -291,7 +295,7 @@ export function formatSkillsCheck(report: SkillStatusReport, opts: SkillsCheckOp lines.push(""); lines.push(theme.heading("Missing requirements:")); for (const skill of missingReqs) { - const emoji = skill.emoji ?? "📦"; + const emoji = normalizeSkillEmoji(skill.emoji); const missing = formatSkillMissingSummary(skill); lines.push(` ${emoji} ${skill.name} ${theme.muted(`(${missing})`)}`); } diff --git a/src/cli/skills-cli.test.ts b/src/cli/skills-cli.test.ts index 37323e7f21d..e87f8b2d313 100644 --- a/src/cli/skills-cli.test.ts +++ b/src/cli/skills-cli.test.ts @@ -148,6 +148,18 @@ describe("skills-cli", () => { expect(output).toContain("Any binaries"); expect(output).toContain("API_KEY"); }); + + it("normalizes text-presentation emoji selectors in info output", () => { + const report = createMockReport([ + createMockSkill({ + name: "info-emoji", + emoji: "🎛\uFE0E", + }), + ]); + + const output = formatSkillInfo(report, "info-emoji", {}); + expect(output).toContain("🎛️"); + }); }); describe("formatSkillsCheck", () => { @@ -170,6 +182,22 @@ describe("skills-cli", () => { expect(output).toContain("go"); // missing binary expect(output).toContain("npx clawhub"); }); + + it("normalizes text-presentation emoji selectors in check output", () => { + const report = createMockReport([ + createMockSkill({ name: "ready-emoji", emoji: "🎛\uFE0E", eligible: true }), + createMockSkill({ + name: "missing-emoji", + emoji: "🎙\uFE0E", + eligible: false, + missing: { bins: ["ffmpeg"], anyBins: [], env: [], config: [], os: [] }, + }), + ]); + + const output = formatSkillsCheck(report, {}); + expect(output).toContain("🎛️ ready-emoji"); + expect(output).toContain("🎙️ missing-emoji"); + }); }); describe("JSON output", () => { diff --git a/src/cli/update-cli/status.ts b/src/cli/update-cli/status.ts index 5cf2bf8af49..8266a1e5f21 100644 --- a/src/cli/update-cli/status.ts +++ b/src/cli/update-cli/status.ts @@ -10,7 +10,7 @@ import { } from "../../infra/update-channels.js"; import { checkUpdateStatus } from "../../infra/update-check.js"; 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 { parseTimeoutMsOrExit, resolveUpdateRoot, type UpdateStatusOptions } from "./shared.js"; @@ -89,7 +89,7 @@ export async function updateStatusCommand(opts: UpdateStatusOptions): Promise { "volcengine-api-key", "byteplus-api-key", "vllm", + "opencode-go", + "ollama", ]) { expect(options.some((opt) => opt.value === value)).toBe(true); } @@ -80,4 +82,27 @@ describe("buildAuthChoiceOptions", () => { expect(chutesGroup).toBeDefined(); expect(chutesGroup?.options.some((opt) => opt.value === "chutes")).toBe(true); }); + + it("groups OpenCode Zen and Go under one OpenCode entry", () => { + const { groups } = buildAuthChoiceGroups({ + store: EMPTY_STORE, + includeSkip: false, + }); + const openCodeGroup = groups.find((group) => group.value === "opencode"); + + expect(openCodeGroup).toBeDefined(); + expect(openCodeGroup?.options.some((opt) => opt.value === "opencode-zen")).toBe(true); + expect(openCodeGroup?.options.some((opt) => opt.value === "opencode-go")).toBe(true); + }); + + it("shows Ollama in grouped provider selection", () => { + const { groups } = buildAuthChoiceGroups({ + store: EMPTY_STORE, + includeSkip: false, + }); + const ollamaGroup = groups.find((group) => group.value === "ollama"); + + expect(ollamaGroup).toBeDefined(); + expect(ollamaGroup?.options.some((opt) => opt.value === "ollama")).toBe(true); + }); }); diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 23e9b80d958..077fee024b9 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -47,6 +47,12 @@ const AUTH_CHOICE_GROUP_DEFS: { hint: "Local/self-hosted OpenAI-compatible", choices: ["vllm"], }, + { + value: "ollama", + label: "Ollama", + hint: "Cloud and local open models", + choices: ["ollama"], + }, { value: "minimax", label: "MiniMax", @@ -138,10 +144,10 @@ const AUTH_CHOICE_GROUP_DEFS: { choices: ["ai-gateway-api-key"], }, { - value: "opencode-zen", - label: "OpenCode Zen", - hint: "API key", - choices: ["opencode-zen"], + value: "opencode", + label: "OpenCode", + hint: "Shared API key for Zen + Go catalogs", + choices: ["opencode-zen", "opencode-go"], }, { value: "xiaomi", @@ -199,6 +205,8 @@ const PROVIDER_AUTH_CHOICE_OPTION_HINTS: Partial> = { "venice-api-key": "Privacy-focused inference (uncensored models)", "together-api-key": "Access to Llama, DeepSeek, Qwen, and more open models", "huggingface-api-key": "Inference Providers — OpenAI-compatible chat", + "opencode-zen": "Shared OpenCode key; curated Zen catalog", + "opencode-go": "Shared OpenCode key; Kimi/GLM/MiniMax Go catalog", }; const PROVIDER_AUTH_CHOICE_OPTION_LABELS: Partial> = { @@ -206,6 +214,8 @@ const PROVIDER_AUTH_CHOICE_OPTION_LABELS: Partial> = "moonshot-api-key-cn": "Kimi API key (.cn)", "kimi-code-api-key": "Kimi Code API key (subscription)", "cloudflare-ai-gateway-api-key": "Cloudflare AI Gateway", + "opencode-zen": "OpenCode Zen catalog", + "opencode-go": "OpenCode Go catalog", }; function buildProviderAuthChoiceOptions(): AuthChoiceOption[] { @@ -234,6 +244,11 @@ const BASE_AUTH_CHOICE_OPTIONS: ReadonlyArray = [ label: "vLLM (custom URL + model)", hint: "Local/self-hosted OpenAI-compatible server", }, + { + value: "ollama", + label: "Ollama", + hint: "Cloud and local open models", + }, ...buildProviderAuthChoiceOptions(), { value: "moonshot-api-key-cn", @@ -289,7 +304,7 @@ const BASE_AUTH_CHOICE_OPTIONS: ReadonlyArray = [ { value: "apiKey", label: "Anthropic API key" }, { value: "opencode-zen", - label: "OpenCode Zen (multi-model proxy)", + label: "OpenCode Zen catalog", hint: "Claude, GPT, Gemini via opencode.ai/zen", }, { value: "minimax-api", label: "MiniMax M2.5" }, @@ -301,7 +316,7 @@ const BASE_AUTH_CHOICE_OPTIONS: ReadonlyArray = [ { value: "minimax-api-lightning", label: "MiniMax M2.5 Highspeed", - hint: "Official fast tier", + hint: "Official fast tier (legacy: Lightning)", }, { value: "qianfan-api-key", label: "Qianfan API key" }, { diff --git a/src/commands/auth-choice.apply.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts index 046a2e24893..9e7419f7fda 100644 --- a/src/commands/auth-choice.apply.api-providers.ts +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -34,6 +34,8 @@ import { applyMoonshotConfigCn, applyMoonshotProviderConfig, applyMoonshotProviderConfigCn, + applyOpencodeGoConfig, + applyOpencodeGoProviderConfig, applyOpencodeZenConfig, applyOpencodeZenProviderConfig, applySyntheticConfig, @@ -68,6 +70,7 @@ import { setKimiCodingApiKey, setMistralApiKey, setMoonshotApiKey, + setOpencodeGoApiKey, setOpencodeZenApiKey, setSyntheticApiKey, setTogetherApiKey, @@ -84,6 +87,7 @@ import { setModelStudioApiKey, } from "./onboard-auth.js"; import type { AuthChoice, SecretInputMode } from "./onboard-types.js"; +import { OPENCODE_GO_DEFAULT_MODEL_REF } from "./opencode-go-model-default.js"; import { OPENCODE_ZEN_DEFAULT_MODEL } from "./opencode-zen-model-default.js"; import { detectZaiEndpoint } from "./zai-endpoint-detect.js"; @@ -104,6 +108,7 @@ const API_KEY_TOKEN_PROVIDER_AUTH_CHOICE: Record = { huggingface: "huggingface-api-key", mistral: "mistral-api-key", opencode: "opencode-zen", + "opencode-go": "opencode-go", kilocode: "kilocode-api-key", qianfan: "qianfan-api-key", }; @@ -240,20 +245,40 @@ const SIMPLE_API_KEY_PROVIDER_FLOWS: Partial + vi.fn(async ({ cfg }) => ({ + config: cfg, + defaultModelId: "qwen3.5:35b", + })), +); +const ensureOllamaModelPulled = vi.hoisted(() => vi.fn(async () => {})); +vi.mock("./ollama-setup.js", () => ({ + promptAndConfigureOllama, + ensureOllamaModelPulled, +})); + +function buildParams(overrides: Partial = {}): ApplyAuthChoiceParams { + return { + authChoice: "ollama", + config: {}, + prompter: {} as ApplyAuthChoiceParams["prompter"], + runtime: {} as ApplyAuthChoiceParams["runtime"], + setDefaultModel: false, + ...overrides, + }; +} + +describe("applyAuthChoiceOllama", () => { + it("returns agentModelOverride when setDefaultModel is false", async () => { + const config = { agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } } }; + promptAndConfigureOllama.mockResolvedValueOnce({ + config, + defaultModelId: "qwen2.5-coder:7b", + }); + + const result = await applyAuthChoiceOllama( + buildParams({ + config, + setDefaultModel: false, + }), + ); + + expect(result).toEqual({ + config, + agentModelOverride: "ollama/qwen2.5-coder:7b", + }); + // Pull is deferred — the wizard model picker handles it. + expect(ensureOllamaModelPulled).not.toHaveBeenCalled(); + }); + + it("sets global default model and preserves fallbacks when setDefaultModel is true", async () => { + const config = { + agents: { + defaults: { + model: { + primary: "openai/gpt-4o-mini", + fallbacks: ["anthropic/claude-sonnet-4-5"], + }, + }, + }, + }; + promptAndConfigureOllama.mockResolvedValueOnce({ + config, + defaultModelId: "qwen2.5-coder:7b", + }); + + const result = await applyAuthChoiceOllama( + buildParams({ + config, + setDefaultModel: true, + }), + ); + + expect(result?.agentModelOverride).toBeUndefined(); + expect(result?.config.agents?.defaults?.model).toEqual({ + primary: "ollama/qwen2.5-coder:7b", + fallbacks: ["anthropic/claude-sonnet-4-5"], + }); + expect(ensureOllamaModelPulled).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/commands/auth-choice.apply.ollama.ts b/src/commands/auth-choice.apply.ollama.ts new file mode 100644 index 00000000000..640b57431cf --- /dev/null +++ b/src/commands/auth-choice.apply.ollama.ts @@ -0,0 +1,31 @@ +import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; +import { ensureOllamaModelPulled, promptAndConfigureOllama } from "./ollama-setup.js"; +import { applyAgentDefaultModelPrimary } from "./onboard-auth.config-shared.js"; + +export async function applyAuthChoiceOllama( + params: ApplyAuthChoiceParams, +): Promise { + if (params.authChoice !== "ollama") { + return null; + } + + const { config, defaultModelId } = await promptAndConfigureOllama({ + cfg: params.config, + prompter: params.prompter, + agentDir: params.agentDir, + }); + + // Set an Ollama default so the model picker pre-selects an Ollama model. + const defaultModel = `ollama/${defaultModelId}`; + const configWithDefault = applyAgentDefaultModelPrimary(config, defaultModel); + + if (!params.setDefaultModel) { + // Defer pulling: the interactive wizard will show a model picker next, + // so avoid downloading a model the user may not choose. + return { config, agentModelOverride: defaultModel }; + } + + await ensureOllamaModelPulled({ config: configWithDefault, prompter: params.prompter }); + + return { config: configWithDefault }; +} diff --git a/src/commands/auth-choice.apply.ts b/src/commands/auth-choice.apply.ts index e6dfa9ed52a..36591304da0 100644 --- a/src/commands/auth-choice.apply.ts +++ b/src/commands/auth-choice.apply.ts @@ -9,6 +9,7 @@ import { applyAuthChoiceGitHubCopilot } from "./auth-choice.apply.github-copilot import { applyAuthChoiceGoogleGeminiCli } from "./auth-choice.apply.google-gemini-cli.js"; import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js"; import { applyAuthChoiceOAuth } from "./auth-choice.apply.oauth.js"; +import { applyAuthChoiceOllama } from "./auth-choice.apply.ollama.js"; import { applyAuthChoiceOpenAI } from "./auth-choice.apply.openai.js"; import { applyAuthChoiceQwenPortal } from "./auth-choice.apply.qwen-portal.js"; import { applyAuthChoiceVllm } from "./auth-choice.apply.vllm.js"; @@ -38,6 +39,7 @@ export async function applyAuthChoice( const handlers: Array<(p: ApplyAuthChoiceParams) => Promise> = [ applyAuthChoiceAnthropic, applyAuthChoiceVllm, + applyAuthChoiceOllama, applyAuthChoiceOpenAI, applyAuthChoiceOAuth, applyAuthChoiceApiProviders, diff --git a/src/commands/auth-choice.model-check.ts b/src/commands/auth-choice.model-check.ts index ea7da2f9d6d..975fc3521d3 100644 --- a/src/commands/auth-choice.model-check.ts +++ b/src/commands/auth-choice.model-check.ts @@ -1,5 +1,5 @@ import { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth-profiles.js"; -import { getCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js"; +import { hasUsableCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import { resolveDefaultModelForAgent } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; @@ -34,8 +34,8 @@ export async function warnIfModelConfigLooksOff( const store = ensureAuthProfileStore(options?.agentDir); const hasProfile = listProfilesForProvider(store, ref.provider).length > 0; const envKey = resolveEnvApiKey(ref.provider); - const customKey = getCustomProviderApiKey(config, ref.provider); - if (!hasProfile && !envKey && !customKey) { + const hasCustomKey = hasUsableCustomProviderApiKey(config, ref.provider); + if (!hasProfile && !envKey && !hasCustomKey) { warnings.push( `No auth configured for provider "${ref.provider}". The agent may fail until credentials are added.`, ); diff --git a/src/commands/auth-choice.preferred-provider.ts b/src/commands/auth-choice.preferred-provider.ts index e56950ea711..7ebc0b24ea1 100644 --- a/src/commands/auth-choice.preferred-provider.ts +++ b/src/commands/auth-choice.preferred-provider.ts @@ -7,6 +7,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { token: "anthropic", apiKey: "anthropic", vllm: "vllm", + ollama: "ollama", "openai-codex": "openai-codex", "codex-cli": "openai-codex", chutes: "chutes", @@ -39,6 +40,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { "minimax-api-lightning": "minimax", minimax: "lmstudio", "opencode-zen": "opencode", + "opencode-go": "opencode-go", "xai-api-key": "xai", "litellm-api-key": "litellm", "qwen-portal": "qwen-portal", diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 0431e558dac..6cdf32fa1d2 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -22,6 +22,7 @@ import { } from "./test-wizard-helpers.js"; type DetectZaiEndpoint = typeof import("./zai-endpoint-detect.js").detectZaiEndpoint; +type PromptAndConfigureOllama = typeof import("./ollama-setup.js").promptAndConfigureOllama; vi.mock("../providers/github-copilot-auth.js", () => ({ githubCopilotLoginCommand: vi.fn(async () => {}), @@ -44,6 +45,16 @@ vi.mock("./zai-endpoint-detect.js", () => ({ detectZaiEndpoint, })); +const promptAndConfigureOllama = vi.hoisted(() => + vi.fn(async ({ cfg }) => ({ + config: cfg, + defaultModelId: "qwen3.5:35b", + })), +); +vi.mock("./ollama-setup.js", () => ({ + promptAndConfigureOllama, +})); + type StoredAuthProfile = { key?: string; keyRef?: { source: string; provider: string; id: string }; @@ -131,6 +142,11 @@ describe("applyAuthChoice", () => { detectZaiEndpoint.mockResolvedValue(null); loginOpenAICodexOAuth.mockReset(); loginOpenAICodexOAuth.mockResolvedValue(null); + promptAndConfigureOllama.mockReset(); + promptAndConfigureOllama.mockImplementation(async ({ cfg }) => ({ + config: cfg, + defaultModelId: "qwen3.5:35b", + })); await lifecycle.cleanup(); activeStateDir = null; }); @@ -498,6 +514,15 @@ describe("applyAuthChoice", () => { profileId: "opencode:default", provider: "opencode", modelPrefix: "opencode/", + extraProfiles: ["opencode-go:default"], + }, + { + authChoice: "opencode-go", + tokenProvider: "opencode-go", + profileId: "opencode-go:default", + provider: "opencode-go", + modelPrefix: "opencode-go/", + extraProfiles: ["opencode:default"], }, { authChoice: "together-api-key", @@ -522,7 +547,7 @@ describe("applyAuthChoice", () => { }, ] as const)( "uses opts token for $authChoice without prompting", - async ({ authChoice, tokenProvider, profileId, provider, modelPrefix }) => { + async ({ authChoice, tokenProvider, profileId, provider, modelPrefix, extraProfiles }) => { await setupTempState(); const text = vi.fn(); @@ -554,6 +579,9 @@ describe("applyAuthChoice", () => { ), ).toBe(true); expect((await readAuthProfile(profileId))?.key).toBe(token); + for (const extraProfile of extraProfiles ?? []) { + expect((await readAuthProfile(extraProfile))?.key).toBe(token); + } }, ); @@ -805,14 +833,15 @@ describe("applyAuthChoice", () => { it("keeps existing default model for explicit provider keys when setDefaultModel=false", async () => { const scenarios: Array<{ - authChoice: "xai-api-key" | "opencode-zen"; + authChoice: "xai-api-key" | "opencode-zen" | "opencode-go"; token: string; promptMessage: string; existingPrimary: string; expectedOverride: string; profileId?: string; profileProvider?: string; - expectProviderConfigUndefined?: "opencode-zen"; + extraProfileId?: string; + expectProviderConfigUndefined?: "opencode" | "opencode-go" | "opencode-zen"; agentId?: string; }> = [ { @@ -828,10 +857,24 @@ describe("applyAuthChoice", () => { { authChoice: "opencode-zen", token: "sk-opencode-zen-test", - promptMessage: "Enter OpenCode Zen API key", + promptMessage: "Enter OpenCode API key", existingPrimary: "anthropic/claude-opus-4-5", expectedOverride: "opencode/claude-opus-4-6", - expectProviderConfigUndefined: "opencode-zen", + profileId: "opencode:default", + profileProvider: "opencode", + extraProfileId: "opencode-go:default", + expectProviderConfigUndefined: "opencode", + }, + { + authChoice: "opencode-go", + token: "sk-opencode-go-test", + promptMessage: "Enter OpenCode API key", + existingPrimary: "anthropic/claude-opus-4-5", + expectedOverride: "opencode-go/kimi-k2.5", + profileId: "opencode-go:default", + profileProvider: "opencode-go", + extraProfileId: "opencode:default", + expectProviderConfigUndefined: "opencode-go", }, ]; for (const scenario of scenarios) { @@ -863,6 +906,9 @@ describe("applyAuthChoice", () => { }); expect((await readAuthProfile(scenario.profileId))?.key).toBe(scenario.token); } + if (scenario.extraProfileId) { + expect((await readAuthProfile(scenario.extraProfileId))?.key).toBe(scenario.token); + } if (scenario.expectProviderConfigUndefined) { expect( result.config.models?.providers?.[scenario.expectProviderConfigUndefined], @@ -1320,6 +1366,7 @@ describe("resolvePreferredProviderForAuthChoice", () => { { authChoice: "github-copilot" as const, expectedProvider: "github-copilot" }, { authChoice: "qwen-portal" as const, expectedProvider: "qwen-portal" }, { authChoice: "mistral-api-key" as const, expectedProvider: "mistral" }, + { authChoice: "ollama" as const, expectedProvider: "ollama" }, { authChoice: "unknown" as AuthChoice, expectedProvider: undefined }, ] as const; for (const scenario of scenarios) { diff --git a/src/commands/backup.ts b/src/commands/backup.ts index 15f0f505d76..ab4397db0f3 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -1,382 +1,31 @@ -import { randomUUID } from "node:crypto"; -import { constants as fsConstants } from "node:fs"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import * as tar from "tar"; -import type { RuntimeEnv } from "../runtime.js"; -import { resolveHomeDir, resolveUserPath } from "../utils.js"; -import { resolveRuntimeServiceVersion } from "../version.js"; import { - buildBackupArchiveBasename, - buildBackupArchiveRoot, - buildBackupArchivePath, - type BackupAsset, - resolveBackupPlanFromDisk, -} from "./backup-shared.js"; + createBackupArchive, + formatBackupCreateSummary, + type BackupCreateOptions, + type BackupCreateResult, +} from "../infra/backup-create.js"; +import type { RuntimeEnv } from "../runtime.js"; import { backupVerifyCommand } from "./backup-verify.js"; -import { isPathWithin } from "./cleanup-utils.js"; - -export type BackupCreateOptions = { - output?: string; - dryRun?: boolean; - includeWorkspace?: boolean; - onlyConfig?: boolean; - verify?: boolean; - json?: boolean; - nowMs?: number; -}; - -type BackupManifestAsset = { - kind: BackupAsset["kind"]; - sourcePath: string; - archivePath: string; -}; - -type BackupManifest = { - schemaVersion: 1; - createdAt: string; - archiveRoot: string; - runtimeVersion: string; - platform: NodeJS.Platform; - nodeVersion: string; - options: { - includeWorkspace: boolean; - onlyConfig?: boolean; - }; - paths: { - stateDir: string; - configPath: string; - oauthDir: string; - workspaceDirs: string[]; - }; - assets: BackupManifestAsset[]; - skipped: Array<{ - kind: string; - sourcePath: string; - reason: string; - coveredBy?: string; - }>; -}; - -export type BackupCreateResult = { - createdAt: string; - archiveRoot: string; - archivePath: string; - dryRun: boolean; - includeWorkspace: boolean; - onlyConfig: boolean; - verified: boolean; - assets: BackupAsset[]; - skipped: Array<{ - kind: string; - sourcePath: string; - displayPath: string; - reason: string; - coveredBy?: string; - }>; -}; - -async function resolveOutputPath(params: { - output?: string; - nowMs: number; - includedAssets: BackupAsset[]; - stateDir: string; -}): Promise { - const basename = buildBackupArchiveBasename(params.nowMs); - const rawOutput = params.output?.trim(); - if (!rawOutput) { - const cwd = path.resolve(process.cwd()); - const canonicalCwd = await fs.realpath(cwd).catch(() => cwd); - const cwdInsideSource = params.includedAssets.some((asset) => - isPathWithin(canonicalCwd, asset.sourcePath), - ); - const defaultDir = cwdInsideSource ? (resolveHomeDir() ?? path.dirname(params.stateDir)) : cwd; - return path.resolve(defaultDir, basename); - } - - const resolved = resolveUserPath(rawOutput); - if (rawOutput.endsWith("/") || rawOutput.endsWith("\\")) { - return path.join(resolved, basename); - } - - try { - const stat = await fs.stat(resolved); - if (stat.isDirectory()) { - return path.join(resolved, basename); - } - } catch { - // Treat as a file path when the target does not exist yet. - } - - return resolved; -} - -async function assertOutputPathReady(outputPath: string): Promise { - try { - await fs.access(outputPath); - throw new Error(`Refusing to overwrite existing backup archive: ${outputPath}`); - } catch (err) { - const code = (err as NodeJS.ErrnoException | undefined)?.code; - if (code === "ENOENT") { - return; - } - throw err; - } -} - -function buildTempArchivePath(outputPath: string): string { - return `${outputPath}.${randomUUID()}.tmp`; -} - -function isLinkUnsupportedError(code: string | undefined): boolean { - return code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EPERM"; -} - -async function publishTempArchive(params: { - tempArchivePath: string; - outputPath: string; -}): Promise { - try { - await fs.link(params.tempArchivePath, params.outputPath); - } catch (err) { - const code = (err as NodeJS.ErrnoException | undefined)?.code; - if (code === "EEXIST") { - throw new Error(`Refusing to overwrite existing backup archive: ${params.outputPath}`, { - cause: err, - }); - } - if (!isLinkUnsupportedError(code)) { - throw err; - } - - try { - // Some backup targets support ordinary files but not hard links. - await fs.copyFile(params.tempArchivePath, params.outputPath, fsConstants.COPYFILE_EXCL); - } catch (copyErr) { - const copyCode = (copyErr as NodeJS.ErrnoException | undefined)?.code; - if (copyCode !== "EEXIST") { - await fs.rm(params.outputPath, { force: true }).catch(() => undefined); - } - if (copyCode === "EEXIST") { - throw new Error(`Refusing to overwrite existing backup archive: ${params.outputPath}`, { - cause: copyErr, - }); - } - throw copyErr; - } - } - await fs.rm(params.tempArchivePath, { force: true }); -} - -async function canonicalizePathForContainment(targetPath: string): Promise { - const resolved = path.resolve(targetPath); - const suffix: string[] = []; - let probe = resolved; - - while (true) { - try { - const realProbe = await fs.realpath(probe); - return suffix.length === 0 ? realProbe : path.join(realProbe, ...suffix.toReversed()); - } catch { - const parent = path.dirname(probe); - if (parent === probe) { - return resolved; - } - suffix.push(path.basename(probe)); - probe = parent; - } - } -} - -function buildManifest(params: { - createdAt: string; - archiveRoot: string; - includeWorkspace: boolean; - onlyConfig: boolean; - assets: BackupAsset[]; - skipped: BackupCreateResult["skipped"]; - stateDir: string; - configPath: string; - oauthDir: string; - workspaceDirs: string[]; -}): BackupManifest { - return { - schemaVersion: 1, - createdAt: params.createdAt, - archiveRoot: params.archiveRoot, - runtimeVersion: resolveRuntimeServiceVersion(), - platform: process.platform, - nodeVersion: process.version, - options: { - includeWorkspace: params.includeWorkspace, - onlyConfig: params.onlyConfig, - }, - paths: { - stateDir: params.stateDir, - configPath: params.configPath, - oauthDir: params.oauthDir, - workspaceDirs: params.workspaceDirs, - }, - assets: params.assets.map((asset) => ({ - kind: asset.kind, - sourcePath: asset.sourcePath, - archivePath: asset.archivePath, - })), - skipped: params.skipped.map((entry) => ({ - kind: entry.kind, - sourcePath: entry.sourcePath, - reason: entry.reason, - coveredBy: entry.coveredBy, - })), - }; -} - -function formatTextSummary(result: BackupCreateResult): string[] { - const lines = [`Backup archive: ${result.archivePath}`]; - lines.push(`Included ${result.assets.length} path${result.assets.length === 1 ? "" : "s"}:`); - for (const asset of result.assets) { - lines.push(`- ${asset.kind}: ${asset.displayPath}`); - } - if (result.skipped.length > 0) { - lines.push(`Skipped ${result.skipped.length} path${result.skipped.length === 1 ? "" : "s"}:`); - for (const entry of result.skipped) { - if (entry.reason === "covered" && entry.coveredBy) { - lines.push(`- ${entry.kind}: ${entry.displayPath} (${entry.reason} by ${entry.coveredBy})`); - } else { - lines.push(`- ${entry.kind}: ${entry.displayPath} (${entry.reason})`); - } - } - } - if (result.dryRun) { - lines.push("Dry run only; archive was not written."); - } else { - lines.push(`Created ${result.archivePath}`); - if (result.verified) { - lines.push("Archive verification: passed"); - } - } - return lines; -} - -function remapArchiveEntryPath(params: { - entryPath: string; - manifestPath: string; - archiveRoot: string; -}): string { - const normalizedEntry = path.resolve(params.entryPath); - if (normalizedEntry === params.manifestPath) { - return path.posix.join(params.archiveRoot, "manifest.json"); - } - return buildBackupArchivePath(params.archiveRoot, normalizedEntry); -} +export type { BackupCreateOptions, BackupCreateResult } from "../infra/backup-create.js"; export async function backupCreateCommand( runtime: RuntimeEnv, opts: BackupCreateOptions = {}, ): Promise { - const nowMs = opts.nowMs ?? Date.now(); - const archiveRoot = buildBackupArchiveRoot(nowMs); - const onlyConfig = Boolean(opts.onlyConfig); - const includeWorkspace = onlyConfig ? false : (opts.includeWorkspace ?? true); - const plan = await resolveBackupPlanFromDisk({ includeWorkspace, onlyConfig, nowMs }); - const outputPath = await resolveOutputPath({ - output: opts.output, - nowMs, - includedAssets: plan.included, - stateDir: plan.stateDir, - }); - - if (plan.included.length === 0) { - throw new Error( - onlyConfig - ? "No OpenClaw config file was found to back up." - : "No local OpenClaw state was found to back up.", + const result = await createBackupArchive(opts); + if (opts.verify && !opts.dryRun) { + await backupVerifyCommand( + { + ...runtime, + log: () => {}, + }, + { archive: result.archivePath, json: false }, ); + result.verified = true; } - - const canonicalOutputPath = await canonicalizePathForContainment(outputPath); - const overlappingAsset = plan.included.find((asset) => - isPathWithin(canonicalOutputPath, asset.sourcePath), - ); - if (overlappingAsset) { - throw new Error( - `Backup output must not be written inside a source path: ${outputPath} is inside ${overlappingAsset.sourcePath}`, - ); - } - - if (!opts.dryRun) { - await assertOutputPathReady(outputPath); - } - - const createdAt = new Date(nowMs).toISOString(); - const result: BackupCreateResult = { - createdAt, - archiveRoot, - archivePath: outputPath, - dryRun: Boolean(opts.dryRun), - includeWorkspace, - onlyConfig, - verified: false, - assets: plan.included, - skipped: plan.skipped, - }; - - if (!opts.dryRun) { - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-")); - const manifestPath = path.join(tempDir, "manifest.json"); - const tempArchivePath = buildTempArchivePath(outputPath); - try { - const manifest = buildManifest({ - createdAt, - archiveRoot, - includeWorkspace, - onlyConfig, - assets: result.assets, - skipped: result.skipped, - stateDir: plan.stateDir, - configPath: plan.configPath, - oauthDir: plan.oauthDir, - workspaceDirs: plan.workspaceDirs, - }); - await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); - - await tar.c( - { - file: tempArchivePath, - gzip: true, - portable: true, - preservePaths: true, - onWriteEntry: (entry) => { - entry.path = remapArchiveEntryPath({ - entryPath: entry.path, - manifestPath, - archiveRoot, - }); - }, - }, - [manifestPath, ...result.assets.map((asset) => asset.sourcePath)], - ); - await publishTempArchive({ tempArchivePath, outputPath }); - } finally { - await fs.rm(tempArchivePath, { force: true }).catch(() => undefined); - await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); - } - - if (opts.verify) { - await backupVerifyCommand( - { - ...runtime, - log: () => {}, - }, - { archive: outputPath, json: false }, - ); - result.verified = true; - } - } - - const output = opts.json ? JSON.stringify(result, null, 2) : formatTextSummary(result).join("\n"); + const output = opts.json + ? JSON.stringify(result, null, 2) + : formatBackupCreateSummary(result).join("\n"); runtime.log(output); return result; } diff --git a/src/commands/doctor-config-analysis.ts b/src/commands/doctor-config-analysis.ts index dea3fa1b3f2..994bac5f863 100644 --- a/src/commands/doctor-config-analysis.ts +++ b/src/commands/doctor-config-analysis.ts @@ -105,18 +105,22 @@ export function noteOpencodeProviderOverrides(cfg: OpenClawConfig): void { if (providers["opencode-zen"]) { overrides.push("opencode-zen"); } + if (providers["opencode-go"]) { + overrides.push("opencode-go"); + } if (overrides.length === 0) { return; } const lines = overrides.flatMap((id) => { + const providerLabel = id === "opencode-go" ? "OpenCode Go" : "OpenCode Zen"; const providerEntry = providers[id]; const api = isRecord(providerEntry) && typeof providerEntry.api === "string" ? providerEntry.api : undefined; return [ - `- models.providers.${id} is set; this overrides the built-in OpenCode Zen catalog.`, + `- models.providers.${id} is set; this overrides the built-in ${providerLabel} catalog.`, api ? `- models.providers.${id}.api=${api}` : null, ].filter((line): line is string => Boolean(line)); }); @@ -124,7 +128,7 @@ export function noteOpencodeProviderOverrides(cfg: OpenClawConfig): void { lines.push( "- Remove these entries to restore per-model API routing + costs (then re-run onboarding if needed).", ); - note(lines.join("\n"), "OpenCode Zen"); + note(lines.join("\n"), "OpenCode"); } export function noteIncludeConfinementWarning(snapshot: { diff --git a/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts index 69c9da9d579..68d865996d2 100644 --- a/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts +++ b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts @@ -41,6 +41,10 @@ describe("doctor command", () => { api: "openai-completions", baseUrl: "https://opencode.ai/zen/v1", }, + "opencode-go": { + api: "openai-completions", + baseUrl: "https://opencode.ai/zen/go/v1", + }, }, }, }, @@ -53,7 +57,9 @@ describe("doctor command", () => { const warned = note.mock.calls.some( ([message, title]) => - title === "OpenCode Zen" && String(message).includes("models.providers.opencode"), + title === "OpenCode" && + String(message).includes("models.providers.opencode") && + String(message).includes("models.providers.opencode-go"), ); expect(warned).toBe(true); }); diff --git a/src/commands/message-format.ts b/src/commands/message-format.ts index aafe570287c..8f4fe9bd08c 100644 --- a/src/commands/message-format.ts +++ b/src/commands/message-format.ts @@ -4,7 +4,7 @@ import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js"; import { formatGatewaySummary, formatOutboundDeliverySummary } from "../infra/outbound/format.js"; import type { MessageActionRunResult } from "../infra/outbound/message-action-runner.js"; import { formatTargetDisplay } from "../infra/outbound/target-resolver.js"; -import { renderTable } from "../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { isRich, theme } from "../terminal/theme.js"; import { shortenText } from "./text-format.js"; @@ -257,7 +257,7 @@ export function formatMessageCliText(result: MessageActionRunResult): string[] { const muted = (text: string) => (rich ? theme.muted(text) : text); const heading = (text: string) => (rich ? theme.heading(text) : text); - const width = Math.max(60, (process.stdout.columns ?? 120) - 1); + const width = getTerminalTableWidth(); const opts: FormatOpts = { width }; if (result.handledBy === "dry-run") { diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index 5cf0fd57547..a98dd78e510 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -30,10 +30,10 @@ vi.mock("../agents/auth-profiles.js", () => ({ })); const resolveEnvApiKey = vi.hoisted(() => vi.fn(() => undefined)); -const getCustomProviderApiKey = vi.hoisted(() => vi.fn(() => undefined)); +const hasUsableCustomProviderApiKey = vi.hoisted(() => vi.fn(() => false)); vi.mock("../agents/model-auth.js", () => ({ resolveEnvApiKey, - getCustomProviderApiKey, + hasUsableCustomProviderApiKey, })); const OPENROUTER_CATALOG = [ diff --git a/src/commands/model-picker.ts b/src/commands/model-picker.ts index db794210354..1fe4170b7c2 100644 --- a/src/commands/model-picker.ts +++ b/src/commands/model-picker.ts @@ -1,6 +1,6 @@ import { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth-profiles.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; -import { getCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js"; +import { hasUsableCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import { buildAllowedModelSet, @@ -52,7 +52,7 @@ function hasAuthForProvider( if (resolveEnvApiKey(provider)) { return true; } - if (getCustomProviderApiKey(cfg, provider)) { + if (hasUsableCustomProviderApiKey(cfg, provider)) { return true; } return false; diff --git a/src/commands/models.list.e2e.test.ts b/src/commands/models.list.e2e.test.ts index e7d55e00b3c..fc80137b0f0 100644 --- a/src/commands/models.list.e2e.test.ts +++ b/src/commands/models.list.e2e.test.ts @@ -21,6 +21,8 @@ const resolveAuthStorePathForDisplay = vi const resolveProfileUnusableUntilForDisplay = vi.fn().mockReturnValue(null); const resolveEnvApiKey = vi.fn().mockReturnValue(undefined); const resolveAwsSdkEnvVarName = vi.fn().mockReturnValue(undefined); +const hasUsableCustomProviderApiKey = vi.fn().mockReturnValue(false); +const resolveUsableCustomProviderApiKey = vi.fn().mockReturnValue(null); const getCustomProviderApiKey = vi.fn().mockReturnValue(undefined); const modelRegistryState = { models: [] as Array>, @@ -57,6 +59,8 @@ vi.mock("../agents/auth-profiles.js", () => ({ vi.mock("../agents/model-auth.js", () => ({ resolveEnvApiKey, resolveAwsSdkEnvVarName, + hasUsableCustomProviderApiKey, + resolveUsableCustomProviderApiKey, getCustomProviderApiKey, })); diff --git a/src/commands/models/list.auth-overview.test.ts b/src/commands/models/list.auth-overview.test.ts index 98906ced281..69807a5d7a7 100644 --- a/src/commands/models/list.auth-overview.test.ts +++ b/src/commands/models/list.auth-overview.test.ts @@ -42,8 +42,8 @@ describe("resolveProviderAuthOverview", () => { modelsPath: "/tmp/models.json", }); - expect(overview.effective.kind).toBe("models.json"); - expect(overview.effective.detail).toContain(`marker(${NON_ENV_SECRETREF_MARKER})`); + expect(overview.effective.kind).toBe("missing"); + expect(overview.effective.detail).toBe("missing"); expect(overview.modelsJson?.value).toContain(`marker(${NON_ENV_SECRETREF_MARKER})`); }); @@ -66,8 +66,41 @@ describe("resolveProviderAuthOverview", () => { modelsPath: "/tmp/models.json", }); - expect(overview.effective.kind).toBe("models.json"); - expect(overview.effective.detail).not.toContain("marker("); - expect(overview.effective.detail).not.toContain("OPENAI_API_KEY"); + expect(overview.effective.kind).toBe("missing"); + expect(overview.effective.detail).toBe("missing"); + expect(overview.modelsJson?.value).not.toContain("marker("); + expect(overview.modelsJson?.value).not.toContain("OPENAI_API_KEY"); + }); + + it("treats env-var marker as usable only when the env key is currently resolvable", () => { + const prior = process.env.OPENAI_API_KEY; + process.env.OPENAI_API_KEY = "sk-openai-from-env"; // pragma: allowlist secret + try { + const overview = resolveProviderAuthOverview({ + provider: "openai", + cfg: { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: "OPENAI_API_KEY", // pragma: allowlist secret + models: [], + }, + }, + }, + } as never, + store: { version: 1, profiles: {} } as never, + modelsPath: "/tmp/models.json", + }); + expect(overview.effective.kind).toBe("env"); + expect(overview.effective.detail).not.toContain("OPENAI_API_KEY"); + } finally { + if (prior === undefined) { + delete process.env.OPENAI_API_KEY; + } else { + process.env.OPENAI_API_KEY = prior; + } + } }); }); diff --git a/src/commands/models/list.auth-overview.ts b/src/commands/models/list.auth-overview.ts index 28880415eeb..17803153c42 100644 --- a/src/commands/models/list.auth-overview.ts +++ b/src/commands/models/list.auth-overview.ts @@ -7,7 +7,11 @@ import { resolveProfileUnusableUntilForDisplay, } from "../../agents/auth-profiles.js"; import { isNonSecretApiKeyMarker } from "../../agents/model-auth-markers.js"; -import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js"; +import { + getCustomProviderApiKey, + resolveEnvApiKey, + resolveUsableCustomProviderApiKey, +} from "../../agents/model-auth.js"; import type { OpenClawConfig } from "../../config/config.js"; import { shortenHomePath } from "../../utils.js"; import { maskApiKey } from "./list.format.js"; @@ -99,6 +103,7 @@ export function resolveProviderAuthOverview(params: { const envKey = resolveEnvApiKey(provider); const customKey = getCustomProviderApiKey(cfg, provider); + const usableCustomKey = resolveUsableCustomProviderApiKey({ cfg, provider }); const effective: ProviderAuthOverview["effective"] = (() => { if (profiles.length > 0) { @@ -115,8 +120,8 @@ export function resolveProviderAuthOverview(params: { detail: isOAuthEnv ? "OAuth (env)" : maskApiKey(envKey.apiKey), }; } - if (customKey) { - return { kind: "models.json", detail: formatMarkerOrSecret(customKey) }; + if (usableCustomKey) { + return { kind: "models.json", detail: formatMarkerOrSecret(usableCustomKey.apiKey) }; } return { kind: "missing", detail: "missing" }; })(); diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts index 40eb6b99b9b..5311b004ce2 100644 --- a/src/commands/models/list.probe.ts +++ b/src/commands/models/list.probe.ts @@ -12,8 +12,7 @@ import { resolveAuthProfileOrder, } from "../../agents/auth-profiles.js"; import { describeFailoverError } from "../../agents/failover-error.js"; -import { isNonSecretApiKeyMarker } from "../../agents/model-auth-markers.js"; -import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js"; +import { hasUsableCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js"; import { loadModelCatalog } from "../../agents/model-catalog.js"; import { findNormalizedProviderValue, @@ -373,8 +372,7 @@ export async function buildProbeTargets(params: { } const envKey = resolveEnvApiKey(providerKey); - const customKey = getCustomProviderApiKey(cfg, providerKey); - const hasUsableModelsJsonKey = Boolean(customKey && !isNonSecretApiKeyMarker(customKey)); + const hasUsableModelsJsonKey = hasUsableCustomProviderApiKey(cfg, providerKey); if (!envKey && !hasUsableModelsJsonKey) { continue; } diff --git a/src/commands/models/list.registry.ts b/src/commands/models/list.registry.ts index 340d49155df..0bc0604432e 100644 --- a/src/commands/models/list.registry.ts +++ b/src/commands/models/list.registry.ts @@ -4,7 +4,7 @@ import { resolveOpenClawAgentDir } from "../../agents/agent-paths.js"; import type { AuthProfileStore } from "../../agents/auth-profiles.js"; import { listProfilesForProvider } from "../../agents/auth-profiles.js"; import { - getCustomProviderApiKey, + hasUsableCustomProviderApiKey, resolveAwsSdkEnvVarName, resolveEnvApiKey, } from "../../agents/model-auth.js"; @@ -35,7 +35,7 @@ const hasAuthForProvider = ( if (resolveEnvApiKey(provider)) { return true; } - if (getCustomProviderApiKey(cfg, provider)) { + if (hasUsableCustomProviderApiKey(cfg, provider)) { return true; } return false; diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts index 59614e3f866..156860bb960 100644 --- a/src/commands/models/list.status-command.ts +++ b/src/commands/models/list.status-command.ts @@ -38,7 +38,7 @@ import { } from "../../infra/provider-usage.js"; import { getShellEnvAppliedKeys, shouldEnableShellEnvFallback } from "../../infra/shell-env.js"; import type { RuntimeEnv } from "../../runtime.js"; -import { renderTable } from "../../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../../terminal/table.js"; import { colorize, theme } from "../../terminal/theme.js"; import { shortenHomePath } from "../../utils.js"; import { resolveProviderAuthOverview } from "./list.auth-overview.js"; @@ -631,7 +631,7 @@ export async function modelsStatusCommand( if (probeSummary.results.length === 0) { runtime.log(colorize(rich, theme.muted, "- none")); } else { - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); const sorted = sortProbeResults(probeSummary.results); const statusColor = (status: string) => { if (status === "ok") { diff --git a/src/commands/models/list.status.test.ts b/src/commands/models/list.status.test.ts index 6f06e63f4b8..9b408f50d93 100644 --- a/src/commands/models/list.status.test.ts +++ b/src/commands/models/list.status.test.ts @@ -61,6 +61,8 @@ const mocks = vi.hoisted(() => { } return null; }), + hasUsableCustomProviderApiKey: vi.fn().mockReturnValue(false), + resolveUsableCustomProviderApiKey: vi.fn().mockReturnValue(null), getCustomProviderApiKey: vi.fn().mockReturnValue(undefined), getShellEnvAppliedKeys: vi.fn().mockReturnValue(["OPENAI_API_KEY", "ANTHROPIC_OAUTH_TOKEN"]), shouldEnableShellEnvFallback: vi.fn().mockReturnValue(true), @@ -106,6 +108,8 @@ vi.mock("../../agents/auth-profiles.js", async (importOriginal) => { vi.mock("../../agents/model-auth.js", () => ({ resolveEnvApiKey: mocks.resolveEnvApiKey, + hasUsableCustomProviderApiKey: mocks.hasUsableCustomProviderApiKey, + resolveUsableCustomProviderApiKey: mocks.resolveUsableCustomProviderApiKey, getCustomProviderApiKey: mocks.getCustomProviderApiKey, })); diff --git a/src/commands/ollama-setup.test.ts b/src/commands/ollama-setup.test.ts new file mode 100644 index 00000000000..2313588f180 --- /dev/null +++ b/src/commands/ollama-setup.test.ts @@ -0,0 +1,391 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv } from "../runtime.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import { + configureOllamaNonInteractive, + ensureOllamaModelPulled, + promptAndConfigureOllama, +} from "./ollama-setup.js"; + +const upsertAuthProfileWithLock = vi.hoisted(() => vi.fn(async () => {})); +vi.mock("../agents/auth-profiles.js", () => ({ + upsertAuthProfileWithLock, +})); + +const openUrlMock = vi.hoisted(() => vi.fn(async () => false)); +vi.mock("./onboard-helpers.js", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, openUrl: openUrlMock }; +}); + +const isRemoteEnvironmentMock = vi.hoisted(() => vi.fn(() => false)); +vi.mock("./oauth-env.js", () => ({ + isRemoteEnvironment: isRemoteEnvironmentMock, +})); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("ollama setup", () => { + afterEach(() => { + vi.unstubAllGlobals(); + upsertAuthProfileWithLock.mockClear(); + openUrlMock.mockClear(); + isRemoteEnvironmentMock.mockReset().mockReturnValue(false); + }); + + it("returns suggested default model for local mode", async () => { + const prompter = { + text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"), + select: vi.fn().mockResolvedValueOnce("local"), + note: vi.fn(async () => undefined), + } as unknown as WizardPrompter; + + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ models: [{ name: "llama3:8b" }] })); + vi.stubGlobal("fetch", fetchMock); + + const result = await promptAndConfigureOllama({ cfg: {}, prompter }); + + expect(result.defaultModelId).toBe("glm-4.7-flash"); + }); + + it("returns suggested default model for remote mode", async () => { + const prompter = { + text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"), + select: vi.fn().mockResolvedValueOnce("remote"), + note: vi.fn(async () => undefined), + } as unknown as WizardPrompter; + + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ models: [{ name: "llama3:8b" }] })) + .mockResolvedValueOnce(jsonResponse({ username: "testuser" })); + vi.stubGlobal("fetch", fetchMock); + + const result = await promptAndConfigureOllama({ cfg: {}, prompter }); + + expect(result.defaultModelId).toBe("kimi-k2.5:cloud"); + }); + + it("mode selection affects model ordering (local)", async () => { + const prompter = { + text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"), + select: vi.fn().mockResolvedValueOnce("local"), + note: vi.fn(async () => undefined), + } as unknown as WizardPrompter; + + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ models: [{ name: "llama3:8b" }, { name: "glm-4.7-flash" }] }), + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await promptAndConfigureOllama({ cfg: {}, prompter }); + + expect(result.defaultModelId).toBe("glm-4.7-flash"); + const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id); + expect(modelIds?.[0]).toBe("glm-4.7-flash"); + expect(modelIds).toContain("llama3:8b"); + }); + + it("cloud+local mode triggers /api/me check and opens sign-in URL", async () => { + const prompter = { + text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"), + select: vi.fn().mockResolvedValueOnce("remote"), + confirm: vi.fn().mockResolvedValueOnce(true), + note: vi.fn(async () => undefined), + } as unknown as WizardPrompter; + + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ models: [{ name: "llama3:8b" }] })) + .mockResolvedValueOnce( + jsonResponse({ error: "not signed in", signin_url: "https://ollama.com/signin" }, 401), + ) + .mockResolvedValueOnce(jsonResponse({ username: "testuser" })); + vi.stubGlobal("fetch", fetchMock); + + await promptAndConfigureOllama({ cfg: {}, prompter }); + + expect(openUrlMock).toHaveBeenCalledWith("https://ollama.com/signin"); + expect(prompter.confirm).toHaveBeenCalled(); + }); + + it("cloud+local mode does not open browser in remote environment", async () => { + isRemoteEnvironmentMock.mockReturnValue(true); + const prompter = { + text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"), + select: vi.fn().mockResolvedValueOnce("remote"), + confirm: vi.fn().mockResolvedValueOnce(true), + note: vi.fn(async () => undefined), + } as unknown as WizardPrompter; + + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ models: [{ name: "llama3:8b" }] })) + .mockResolvedValueOnce( + jsonResponse({ error: "not signed in", signin_url: "https://ollama.com/signin" }, 401), + ) + .mockResolvedValueOnce(jsonResponse({ username: "testuser" })); + vi.stubGlobal("fetch", fetchMock); + + await promptAndConfigureOllama({ cfg: {}, prompter }); + + expect(openUrlMock).not.toHaveBeenCalled(); + }); + + it("local mode does not trigger cloud auth", async () => { + const prompter = { + text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"), + select: vi.fn().mockResolvedValueOnce("local"), + note: vi.fn(async () => undefined), + } as unknown as WizardPrompter; + + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ models: [{ name: "llama3:8b" }] })); + vi.stubGlobal("fetch", fetchMock); + + await promptAndConfigureOllama({ cfg: {}, prompter }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toContain("/api/tags"); + }); + + it("suggested models appear first in model list (cloud+local)", async () => { + const prompter = { + text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"), + select: vi.fn().mockResolvedValueOnce("remote"), + note: vi.fn(async () => undefined), + } as unknown as WizardPrompter; + + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + models: [{ name: "llama3:8b" }, { name: "glm-4.7-flash" }, { name: "deepseek-r1:14b" }], + }), + ) + .mockResolvedValueOnce(jsonResponse({ username: "testuser" })); + vi.stubGlobal("fetch", fetchMock); + + const result = await promptAndConfigureOllama({ cfg: {}, prompter }); + const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id); + + expect(modelIds).toEqual([ + "kimi-k2.5:cloud", + "minimax-m2.5:cloud", + "glm-5:cloud", + "llama3:8b", + "glm-4.7-flash", + "deepseek-r1:14b", + ]); + }); + + describe("ensureOllamaModelPulled", () => { + it("pulls model when not available locally", async () => { + const progress = { update: vi.fn(), stop: vi.fn() }; + const prompter = { + progress: vi.fn(() => progress), + } as unknown as WizardPrompter; + + const fetchMock = vi + .fn() + // /api/tags — model not present + .mockResolvedValueOnce(jsonResponse({ models: [{ name: "llama3:8b" }] })) + // /api/pull + .mockResolvedValueOnce(new Response('{"status":"success"}\n', { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await ensureOllamaModelPulled({ + config: { + agents: { defaults: { model: { primary: "ollama/glm-4.7-flash" } } }, + models: { providers: { ollama: { baseUrl: "http://127.0.0.1:11434", models: [] } } }, + }, + prompter, + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[1][0]).toContain("/api/pull"); + }); + + it("skips pull when model is already available", async () => { + const prompter = {} as unknown as WizardPrompter; + + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ models: [{ name: "glm-4.7-flash" }] })); + vi.stubGlobal("fetch", fetchMock); + + await ensureOllamaModelPulled({ + config: { + agents: { defaults: { model: { primary: "ollama/glm-4.7-flash" } } }, + models: { providers: { ollama: { baseUrl: "http://127.0.0.1:11434", models: [] } } }, + }, + prompter, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("skips pull for cloud models", async () => { + const prompter = {} as unknown as WizardPrompter; + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await ensureOllamaModelPulled({ + config: { + agents: { defaults: { model: { primary: "ollama/kimi-k2.5:cloud" } } }, + models: { providers: { ollama: { baseUrl: "http://127.0.0.1:11434", models: [] } } }, + }, + prompter, + }); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("skips when model is not an ollama model", async () => { + const prompter = {} as unknown as WizardPrompter; + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await ensureOllamaModelPulled({ + config: { + agents: { defaults: { model: { primary: "openai/gpt-4o" } } }, + }, + prompter, + }); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + it("uses discovered model when requested non-interactive download fails", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ models: [{ name: "qwen2.5-coder:7b" }] })) + .mockResolvedValueOnce(new Response('{"error":"disk full"}\n', { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + } as unknown as RuntimeEnv; + + const result = await configureOllamaNonInteractive({ + nextConfig: { + agents: { + defaults: { + model: { + primary: "openai/gpt-4o-mini", + fallbacks: ["anthropic/claude-sonnet-4-5"], + }, + }, + }, + }, + opts: { + customBaseUrl: "http://127.0.0.1:11434", + customModelId: "missing-model", + }, + runtime, + }); + + expect(runtime.error).toHaveBeenCalledWith("Download failed: disk full"); + expect(result.agents?.defaults?.model).toEqual({ + primary: "ollama/qwen2.5-coder:7b", + fallbacks: ["anthropic/claude-sonnet-4-5"], + }); + }); + + it("normalizes ollama/ prefix in non-interactive custom model download", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ models: [] })) + .mockResolvedValueOnce(new Response('{"status":"success"}\n', { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + } as unknown as RuntimeEnv; + + const result = await configureOllamaNonInteractive({ + nextConfig: {}, + opts: { + customBaseUrl: "http://127.0.0.1:11434", + customModelId: "ollama/llama3.2:latest", + }, + runtime, + }); + + const pullRequest = fetchMock.mock.calls[1]?.[1]; + expect(JSON.parse(String(pullRequest?.body))).toEqual({ name: "llama3.2:latest" }); + expect(result.agents?.defaults?.model).toEqual( + expect.objectContaining({ primary: "ollama/llama3.2:latest" }), + ); + }); + + it("accepts cloud models in non-interactive mode without pulling", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse({ models: [] })); + vi.stubGlobal("fetch", fetchMock); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + } as unknown as RuntimeEnv; + + const result = await configureOllamaNonInteractive({ + nextConfig: {}, + opts: { + customBaseUrl: "http://127.0.0.1:11434", + customModelId: "kimi-k2.5:cloud", + }, + runtime, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result.models?.providers?.ollama?.models?.map((model) => model.id)).toContain( + "kimi-k2.5:cloud", + ); + expect(result.agents?.defaults?.model).toEqual( + expect.objectContaining({ primary: "ollama/kimi-k2.5:cloud" }), + ); + }); + + it("exits when Ollama is unreachable", async () => { + const fetchMock = vi.fn().mockRejectedValueOnce(new Error("connect ECONNREFUSED")); + vi.stubGlobal("fetch", fetchMock); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + } as unknown as RuntimeEnv; + const nextConfig = {}; + + const result = await configureOllamaNonInteractive({ + nextConfig, + opts: { + customBaseUrl: "http://127.0.0.1:11435", + customModelId: "llama3.2:latest", + }, + runtime, + }); + + expect(runtime.error).toHaveBeenCalledWith( + expect.stringContaining("Ollama could not be reached at http://127.0.0.1:11435."), + ); + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(result).toBe(nextConfig); + }); +}); diff --git a/src/commands/ollama-setup.ts b/src/commands/ollama-setup.ts new file mode 100644 index 00000000000..7af3e18cff1 --- /dev/null +++ b/src/commands/ollama-setup.ts @@ -0,0 +1,510 @@ +import { upsertAuthProfileWithLock } from "../agents/auth-profiles.js"; +import { + OLLAMA_DEFAULT_BASE_URL, + buildOllamaModelDefinition, + fetchOllamaModels, + resolveOllamaApiBase, +} from "../agents/ollama-models.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { WizardCancelledError, type WizardPrompter } from "../wizard/prompts.js"; +import { isRemoteEnvironment } from "./oauth-env.js"; +import { applyAgentDefaultModelPrimary } from "./onboard-auth.config-shared.js"; +import { openUrl } from "./onboard-helpers.js"; +import type { OnboardMode, OnboardOptions } from "./onboard-types.js"; + +export { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-models.js"; +export const OLLAMA_DEFAULT_MODEL = "glm-4.7-flash"; + +const OLLAMA_SUGGESTED_MODELS_LOCAL = ["glm-4.7-flash"]; +const OLLAMA_SUGGESTED_MODELS_CLOUD = ["kimi-k2.5:cloud", "minimax-m2.5:cloud", "glm-5:cloud"]; + +function normalizeOllamaModelName(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + if (trimmed.toLowerCase().startsWith("ollama/")) { + const withoutPrefix = trimmed.slice("ollama/".length).trim(); + return withoutPrefix || undefined; + } + return trimmed; +} + +function isOllamaCloudModel(modelName: string | undefined): boolean { + return Boolean(modelName?.trim().toLowerCase().endsWith(":cloud")); +} + +function formatOllamaPullStatus(status: string): { text: string; hidePercent: boolean } { + const trimmed = status.trim(); + const partStatusMatch = trimmed.match(/^([a-z-]+)\s+(?:sha256:)?[a-f0-9]{8,}$/i); + if (partStatusMatch) { + return { text: `${partStatusMatch[1]} part`, hidePercent: false }; + } + if (/^verifying\b.*\bdigest\b/i.test(trimmed)) { + return { text: "verifying digest", hidePercent: true }; + } + return { text: trimmed, hidePercent: false }; +} + +type OllamaCloudAuthResult = { + signedIn: boolean; + signinUrl?: string; +}; + +/** Check if the user is signed in to Ollama cloud via /api/me. */ +async function checkOllamaCloudAuth(baseUrl: string): Promise { + try { + const apiBase = resolveOllamaApiBase(baseUrl); + const response = await fetch(`${apiBase}/api/me`, { + method: "POST", + signal: AbortSignal.timeout(5000), + }); + if (response.status === 401) { + // 401 body contains { error, signin_url } + const data = (await response.json()) as { signin_url?: string }; + return { signedIn: false, signinUrl: data.signin_url }; + } + if (!response.ok) { + return { signedIn: false }; + } + return { signedIn: true }; + } catch { + // /api/me not supported or unreachable — fail closed so cloud mode + // doesn't silently skip auth; the caller handles the fallback. + return { signedIn: false }; + } +} + +type OllamaPullChunk = { + status?: string; + total?: number; + completed?: number; + error?: string; +}; + +type OllamaPullFailureKind = "http" | "no-body" | "chunk-error" | "network"; +type OllamaPullResult = + | { ok: true } + | { + ok: false; + kind: OllamaPullFailureKind; + message: string; + }; + +async function pullOllamaModelCore(params: { + baseUrl: string; + modelName: string; + onStatus?: (status: string, percent: number | null) => void; +}): Promise { + const { onStatus } = params; + const baseUrl = resolveOllamaApiBase(params.baseUrl); + const modelName = normalizeOllamaModelName(params.modelName) ?? params.modelName.trim(); + try { + const response = await fetch(`${baseUrl}/api/pull`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: modelName }), + }); + if (!response.ok) { + return { + ok: false, + kind: "http", + message: `Failed to download ${modelName} (HTTP ${response.status})`, + }; + } + if (!response.body) { + return { + ok: false, + kind: "no-body", + message: `Failed to download ${modelName} (no response body)`, + }; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + const layers = new Map(); + + const parseLine = (line: string): OllamaPullResult => { + const trimmed = line.trim(); + if (!trimmed) { + return { ok: true }; + } + try { + const chunk = JSON.parse(trimmed) as OllamaPullChunk; + if (chunk.error) { + return { + ok: false, + kind: "chunk-error", + message: `Download failed: ${chunk.error}`, + }; + } + if (!chunk.status) { + return { ok: true }; + } + if (chunk.total && chunk.completed !== undefined) { + layers.set(chunk.status, { total: chunk.total, completed: chunk.completed }); + let totalSum = 0; + let completedSum = 0; + for (const layer of layers.values()) { + totalSum += layer.total; + completedSum += layer.completed; + } + const percent = totalSum > 0 ? Math.round((completedSum / totalSum) * 100) : null; + onStatus?.(chunk.status, percent); + } else { + onStatus?.(chunk.status, null); + } + } catch { + // Ignore malformed lines from streaming output. + } + return { ok: true }; + }; + + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + const parsed = parseLine(line); + if (!parsed.ok) { + return parsed; + } + } + } + + const trailing = buffer.trim(); + if (trailing) { + const parsed = parseLine(trailing); + if (!parsed.ok) { + return parsed; + } + } + + return { ok: true }; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + return { + ok: false, + kind: "network", + message: `Failed to download ${modelName}: ${reason}`, + }; + } +} + +/** Pull a model from Ollama, streaming progress updates. */ +async function pullOllamaModel( + baseUrl: string, + modelName: string, + prompter: WizardPrompter, +): Promise { + const spinner = prompter.progress(`Downloading ${modelName}...`); + const result = await pullOllamaModelCore({ + baseUrl, + modelName, + onStatus: (status, percent) => { + const displayStatus = formatOllamaPullStatus(status); + if (displayStatus.hidePercent) { + spinner.update(`Downloading ${modelName} - ${displayStatus.text}`); + } else { + spinner.update(`Downloading ${modelName} - ${displayStatus.text} - ${percent ?? 0}%`); + } + }, + }); + if (!result.ok) { + spinner.stop(result.message); + return false; + } + spinner.stop(`Downloaded ${modelName}`); + return true; +} + +async function pullOllamaModelNonInteractive( + baseUrl: string, + modelName: string, + runtime: RuntimeEnv, +): Promise { + runtime.log(`Downloading ${modelName}...`); + const result = await pullOllamaModelCore({ baseUrl, modelName }); + if (!result.ok) { + runtime.error(result.message); + return false; + } + runtime.log(`Downloaded ${modelName}`); + return true; +} + +function buildOllamaModelsConfig(modelNames: string[]) { + return modelNames.map((name) => buildOllamaModelDefinition(name)); +} + +function applyOllamaProviderConfig( + cfg: OpenClawConfig, + baseUrl: string, + modelNames: string[], +): OpenClawConfig { + return { + ...cfg, + models: { + ...cfg.models, + mode: cfg.models?.mode ?? "merge", + providers: { + ...cfg.models?.providers, + ollama: { + baseUrl, + api: "ollama", + apiKey: "OLLAMA_API_KEY", // pragma: allowlist secret + models: buildOllamaModelsConfig(modelNames), + }, + }, + }, + }; +} + +async function storeOllamaCredential(agentDir?: string): Promise { + await upsertAuthProfileWithLock({ + profileId: "ollama:default", + credential: { type: "api_key", provider: "ollama", key: "ollama-local" }, + agentDir, + }); +} + +/** + * Interactive: prompt for base URL, discover models, configure provider. + * Model selection is handled by the standard model picker downstream. + */ +export async function promptAndConfigureOllama(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + agentDir?: string; +}): Promise<{ config: OpenClawConfig; defaultModelId: string }> { + const { prompter } = params; + + // 1. Prompt base URL + const baseUrlRaw = await prompter.text({ + message: "Ollama base URL", + initialValue: OLLAMA_DEFAULT_BASE_URL, + placeholder: OLLAMA_DEFAULT_BASE_URL, + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + const configuredBaseUrl = String(baseUrlRaw ?? "") + .trim() + .replace(/\/+$/, ""); + const baseUrl = resolveOllamaApiBase(configuredBaseUrl); + + // 2. Check reachability + const { reachable, models } = await fetchOllamaModels(baseUrl); + const modelNames = models.map((m) => m.name); + + if (!reachable) { + await prompter.note( + [ + `Ollama could not be reached at ${baseUrl}.`, + "Download it at https://ollama.com/download", + "", + "Start Ollama and re-run onboarding.", + ].join("\n"), + "Ollama", + ); + throw new WizardCancelledError("Ollama not reachable"); + } + + // 3. Mode selection + const mode = (await prompter.select({ + message: "Ollama mode", + options: [ + { value: "remote", label: "Cloud + Local", hint: "Ollama cloud models + local models" }, + { value: "local", label: "Local", hint: "Local models only" }, + ], + })) as OnboardMode; + + // 4. Cloud auth — check /api/me upfront for remote (cloud+local) mode + let cloudAuthVerified = false; + if (mode === "remote") { + const authResult = await checkOllamaCloudAuth(baseUrl); + if (!authResult.signedIn) { + if (authResult.signinUrl) { + if (!isRemoteEnvironment()) { + await openUrl(authResult.signinUrl); + } + await prompter.note( + ["Sign in to Ollama Cloud:", authResult.signinUrl].join("\n"), + "Ollama Cloud", + ); + const confirmed = await prompter.confirm({ + message: "Have you signed in?", + }); + if (!confirmed) { + throw new WizardCancelledError("Ollama cloud sign-in cancelled"); + } + // Re-check after user claims sign-in + const recheck = await checkOllamaCloudAuth(baseUrl); + if (!recheck.signedIn) { + throw new WizardCancelledError("Ollama cloud sign-in required"); + } + cloudAuthVerified = true; + } else { + // No signin URL available (older server, unreachable /api/me, or custom gateway). + await prompter.note( + [ + "Could not verify Ollama Cloud authentication.", + "Cloud models may not work until you sign in at https://ollama.com.", + ].join("\n"), + "Ollama Cloud", + ); + const continueAnyway = await prompter.confirm({ + message: "Continue without cloud auth?", + }); + if (!continueAnyway) { + throw new WizardCancelledError("Ollama cloud auth could not be verified"); + } + // Cloud auth unverified — fall back to local defaults so the model + // picker doesn't steer toward cloud models that may fail. + } + } else { + cloudAuthVerified = true; + } + } + + // 5. Model ordering — suggested models first. + // Use cloud defaults only when auth was actually verified; otherwise fall + // back to local defaults so the user isn't steered toward cloud models + // that may fail at runtime. + const suggestedModels = + mode === "local" || !cloudAuthVerified + ? OLLAMA_SUGGESTED_MODELS_LOCAL + : OLLAMA_SUGGESTED_MODELS_CLOUD; + const orderedModelNames = [ + ...suggestedModels, + ...modelNames.filter((name) => !suggestedModels.includes(name)), + ]; + + await storeOllamaCredential(params.agentDir); + + const defaultModelId = suggestedModels[0] ?? OLLAMA_DEFAULT_MODEL; + const config = applyOllamaProviderConfig(params.cfg, baseUrl, orderedModelNames); + return { config, defaultModelId }; +} + +/** Non-interactive: auto-discover models and configure provider. */ +export async function configureOllamaNonInteractive(params: { + nextConfig: OpenClawConfig; + opts: OnboardOptions; + runtime: RuntimeEnv; +}): Promise { + const { opts, runtime } = params; + const configuredBaseUrl = (opts.customBaseUrl?.trim() || OLLAMA_DEFAULT_BASE_URL).replace( + /\/+$/, + "", + ); + const baseUrl = resolveOllamaApiBase(configuredBaseUrl); + + const { reachable, models } = await fetchOllamaModels(baseUrl); + const modelNames = models.map((m) => m.name); + const explicitModel = normalizeOllamaModelName(opts.customModelId); + + if (!reachable) { + runtime.error( + [ + `Ollama could not be reached at ${baseUrl}.`, + "Download it at https://ollama.com/download", + ].join("\n"), + ); + runtime.exit(1); + return params.nextConfig; + } + + await storeOllamaCredential(); + + // Apply local suggested model ordering. + const suggestedModels = OLLAMA_SUGGESTED_MODELS_LOCAL; + const orderedModelNames = [ + ...suggestedModels, + ...modelNames.filter((name) => !suggestedModels.includes(name)), + ]; + + const requestedDefaultModelId = explicitModel ?? suggestedModels[0]; + let pulledRequestedModel = false; + const availableModelNames = new Set(modelNames); + const requestedCloudModel = isOllamaCloudModel(requestedDefaultModelId); + + if (requestedCloudModel) { + availableModelNames.add(requestedDefaultModelId); + } + + // Pull if model not in discovered list and Ollama is reachable + if (!requestedCloudModel && !modelNames.includes(requestedDefaultModelId)) { + pulledRequestedModel = await pullOllamaModelNonInteractive( + baseUrl, + requestedDefaultModelId, + runtime, + ); + if (pulledRequestedModel) { + availableModelNames.add(requestedDefaultModelId); + } + } + + let allModelNames = orderedModelNames; + let defaultModelId = requestedDefaultModelId; + if ( + (pulledRequestedModel || requestedCloudModel) && + !allModelNames.includes(requestedDefaultModelId) + ) { + allModelNames = [...allModelNames, requestedDefaultModelId]; + } + if (!availableModelNames.has(requestedDefaultModelId)) { + if (availableModelNames.size > 0) { + const firstAvailableModel = + allModelNames.find((name) => availableModelNames.has(name)) ?? + Array.from(availableModelNames)[0]; + defaultModelId = firstAvailableModel; + runtime.log( + `Ollama model ${requestedDefaultModelId} was not available; using ${defaultModelId} instead.`, + ); + } else { + runtime.error( + [ + `No Ollama models are available at ${baseUrl}.`, + "Pull a model first, then re-run onboarding.", + ].join("\n"), + ); + runtime.exit(1); + return params.nextConfig; + } + } + + const config = applyOllamaProviderConfig(params.nextConfig, baseUrl, allModelNames); + const modelRef = `ollama/${defaultModelId}`; + runtime.log(`Default Ollama model: ${defaultModelId}`); + return applyAgentDefaultModelPrimary(config, modelRef); +} + +/** Pull the configured default Ollama model if it isn't already available locally. */ +export async function ensureOllamaModelPulled(params: { + config: OpenClawConfig; + prompter: WizardPrompter; +}): Promise { + const modelCfg = params.config.agents?.defaults?.model; + const modelId = typeof modelCfg === "string" ? modelCfg : modelCfg?.primary; + if (!modelId?.startsWith("ollama/")) { + return; + } + const baseUrl = params.config.models?.providers?.ollama?.baseUrl ?? OLLAMA_DEFAULT_BASE_URL; + const modelName = modelId.slice("ollama/".length); + if (isOllamaCloudModel(modelName)) { + return; + } + const { models } = await fetchOllamaModels(baseUrl); + if (models.some((m) => m.name === modelName)) { + return; + } + const pulled = await pullOllamaModel(baseUrl, modelName, params.prompter); + if (!pulled) { + throw new WizardCancelledError("Failed to download selected Ollama model"); + } +} diff --git a/src/commands/onboard-auth.config-opencode-go.ts b/src/commands/onboard-auth.config-opencode-go.ts new file mode 100644 index 00000000000..25be5ffa18f --- /dev/null +++ b/src/commands/onboard-auth.config-opencode-go.ts @@ -0,0 +1,36 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { applyAgentDefaultModelPrimary } from "./onboard-auth.config-shared.js"; +import { OPENCODE_GO_DEFAULT_MODEL_REF } from "./opencode-go-model-default.js"; + +const OPENCODE_GO_ALIAS_DEFAULTS: Record = { + "opencode-go/kimi-k2.5": "Kimi", + "opencode-go/glm-5": "GLM", + "opencode-go/minimax-m2.5": "MiniMax", +}; + +export function applyOpencodeGoProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + // Use the built-in opencode-go provider from pi-ai; only seed allowlist aliases. + const models = { ...cfg.agents?.defaults?.models }; + for (const [modelRef, alias] of Object.entries(OPENCODE_GO_ALIAS_DEFAULTS)) { + models[modelRef] = { + ...models[modelRef], + alias: models[modelRef]?.alias ?? alias, + }; + } + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + }, + }, + }; +} + +export function applyOpencodeGoConfig(cfg: OpenClawConfig): OpenClawConfig { + const next = applyOpencodeGoProviderConfig(cfg); + return applyAgentDefaultModelPrimary(next, OPENCODE_GO_DEFAULT_MODEL_REF); +} diff --git a/src/commands/onboard-auth.credentials.test.ts b/src/commands/onboard-auth.credentials.test.ts index 5ff2c57461d..e844ac501c2 100644 --- a/src/commands/onboard-auth.credentials.test.ts +++ b/src/commands/onboard-auth.credentials.test.ts @@ -3,6 +3,7 @@ import { setByteplusApiKey, setCloudflareAiGatewayConfig, setMoonshotApiKey, + setOpencodeZenApiKey, setOpenaiApiKey, setVolcengineApiKey, } from "./onboard-auth.js"; @@ -22,6 +23,7 @@ describe("onboard auth credentials secret refs", () => { "CLOUDFLARE_AI_GATEWAY_API_KEY", "VOLCANO_ENGINE_API_KEY", "BYTEPLUS_API_KEY", + "OPENCODE_API_KEY", ]); afterEach(async () => { @@ -207,4 +209,25 @@ describe("onboard auth credentials secret refs", () => { }); expect(parsed.profiles?.["byteplus:default"]?.key).toBeUndefined(); }); + + it("stores shared OpenCode credentials for both runtime providers", async () => { + const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-opencode-"); + lifecycle.setStateDir(env.stateDir); + process.env.OPENCODE_API_KEY = "sk-opencode-env"; // pragma: allowlist secret + + await setOpencodeZenApiKey("sk-opencode-env", env.agentDir, { + secretInputMode: "ref", // pragma: allowlist secret + }); + + const parsed = await readAuthProfilesForAgent<{ + profiles?: Record; + }>(env.agentDir); + + expect(parsed.profiles?.["opencode:default"]).toMatchObject({ + keyRef: { source: "env", provider: "default", id: "OPENCODE_API_KEY" }, + }); + expect(parsed.profiles?.["opencode-go:default"]).toMatchObject({ + keyRef: { source: "env", provider: "default", id: "OPENCODE_API_KEY" }, + }); + }); }); diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts index c83861b5685..92e1170b010 100644 --- a/src/commands/onboard-auth.credentials.ts +++ b/src/commands/onboard-auth.credentials.ts @@ -433,11 +433,30 @@ export async function setOpencodeZenApiKey( agentDir?: string, options?: ApiKeyStorageOptions, ) { - upsertAuthProfile({ - profileId: "opencode:default", - credential: buildApiKeyCredential("opencode", key, undefined, options), - agentDir: resolveAuthAgentDir(agentDir), - }); + await setSharedOpencodeApiKey(key, agentDir, options); +} + +export async function setOpencodeGoApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + await setSharedOpencodeApiKey(key, agentDir, options); +} + +async function setSharedOpencodeApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + const resolvedAgentDir = resolveAuthAgentDir(agentDir); + for (const provider of ["opencode", "opencode-go"] as const) { + upsertAuthProfile({ + profileId: `${provider}:default`, + credential: buildApiKeyCredential(provider, key, undefined, options), + agentDir: resolvedAgentDir, + }); + } } export async function setTogetherApiKey( diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index a79eb1d970a..fa2c9f4f10d 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -16,6 +16,8 @@ import { applyMistralProviderConfig, applyMinimaxApiConfig, applyMinimaxApiProviderConfig, + applyOpencodeGoConfig, + applyOpencodeGoProviderConfig, applyOpencodeZenConfig, applyOpencodeZenProviderConfig, applyOpenrouterConfig, @@ -675,6 +677,11 @@ describe("allowlist provider helpers", () => { modelRef: "opencode/claude-opus-4-6", alias: "My Opus", }, + { + applyConfig: applyOpencodeGoProviderConfig, + modelRef: "opencode-go/kimi-k2.5", + alias: "Kimi", + }, { applyConfig: applyOpenrouterProviderConfig, modelRef: OPENROUTER_DEFAULT_MODEL_REF, @@ -729,6 +736,10 @@ describe("default-model config helpers", () => { applyConfig: applyOpencodeZenConfig, primaryModel: "opencode/claude-opus-4-6", }, + { + applyConfig: applyOpencodeGoConfig, + primaryModel: "opencode-go/kimi-k2.5", + }, { applyConfig: applyOpenrouterConfig, primaryModel: OPENROUTER_DEFAULT_MODEL_REF, diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index 22946567fae..cda460b6c19 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -60,6 +60,10 @@ export { applyOpencodeZenConfig, applyOpencodeZenProviderConfig, } from "./onboard-auth.config-opencode.js"; +export { + applyOpencodeGoConfig, + applyOpencodeGoProviderConfig, +} from "./onboard-auth.config-opencode-go.js"; export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, KILOCODE_DEFAULT_MODEL_REF, @@ -77,6 +81,7 @@ export { setMinimaxApiKey, setMistralApiKey, setMoonshotApiKey, + setOpencodeGoApiKey, setOpencodeZenApiKey, setOpenrouterApiKey, setSyntheticApiKey, diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index 3f5ccee1755..9606b70259f 100644 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -42,11 +42,6 @@ let upsertAuthProfile: typeof import("../agents/auth-profiles.js").upsertAuthPro type ProviderAuthConfigSnapshot = { auth?: { profiles?: Record }; agents?: { defaults?: { model?: { primary?: string } } }; - talk?: { - provider?: string; - apiKey?: string | { source?: string; id?: string }; - providers?: Record; - }; models?: { providers?: Record< string, @@ -362,38 +357,6 @@ describe("onboard (non-interactive): provider auth", () => { }); }); - it("does not persist talk fallback secrets when OpenAI ref onboarding starts from an empty config", async () => { - await withOnboardEnv("openclaw-onboard-openai-ref-no-talk-leak-", async (env) => { - await withEnvAsync( - { - OPENAI_API_KEY: "sk-openai-env-key", // pragma: allowlist secret - ELEVENLABS_API_KEY: "elevenlabs-env-key", // pragma: allowlist secret - }, - async () => { - const cfg = await runOnboardingAndReadConfig(env, { - authChoice: "openai-api-key", - secretInputMode: "ref", // pragma: allowlist secret - }); - - expect(cfg.agents?.defaults?.model?.primary).toBe(OPENAI_DEFAULT_MODEL); - expect(cfg.talk).toBeUndefined(); - - const store = ensureAuthProfileStore(); - const profile = store.profiles["openai:default"]; - expect(profile?.type).toBe("api_key"); - if (profile?.type === "api_key") { - expect(profile.key).toBeUndefined(); - expect(profile.keyRef).toEqual({ - source: "env", - provider: "default", - id: "OPENAI_API_KEY", - }); - } - }, - ); - }); - }); - it.each([ { name: "anthropic", @@ -479,7 +442,7 @@ describe("onboard (non-interactive): provider auth", () => { }, ); - it("stores the detected env alias as keyRef for opencode ref mode", async () => { + it("stores the detected env alias as keyRef for both OpenCode runtime providers", async () => { await withOnboardEnv("openclaw-onboard-ref-opencode-alias-", async ({ runtime }) => { await withEnvAsync( { @@ -494,15 +457,17 @@ describe("onboard (non-interactive): provider auth", () => { }); const store = ensureAuthProfileStore(); - const profile = store.profiles["opencode:default"]; - expect(profile?.type).toBe("api_key"); - if (profile?.type === "api_key") { - expect(profile.key).toBeUndefined(); - expect(profile.keyRef).toEqual({ - source: "env", - provider: "default", - id: "OPENCODE_ZEN_API_KEY", - }); + for (const profileId of ["opencode:default", "opencode-go:default"]) { + const profile = store.profiles[profileId]; + expect(profile?.type).toBe("api_key"); + if (profile?.type === "api_key") { + expect(profile.key).toBeUndefined(); + expect(profile.keyRef).toEqual({ + source: "env", + provider: "default", + id: "OPENCODE_ZEN_API_KEY", + }); + } } }, ); diff --git a/src/commands/onboard-non-interactive/local/auth-choice-inference.ts b/src/commands/onboard-non-interactive/local/auth-choice-inference.ts index a49be3ad2c8..212bb9dd890 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice-inference.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice-inference.ts @@ -27,6 +27,7 @@ type AuthChoiceFlagOptions = Pick< | "xiaomiApiKey" | "minimaxApiKey" | "opencodeZenApiKey" + | "opencodeGoApiKey" | "xaiApiKey" | "litellmApiKey" | "qianfanApiKey" diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 9739f57ce2e..af119c12efe 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -10,6 +10,7 @@ import { normalizeSecretInputModeInput } from "../../auth-choice.apply-helpers.j import { buildTokenProfileId, validateAnthropicSetupToken } from "../../auth-token.js"; import { applyGoogleGeminiModelDefault } from "../../google-gemini-model-default.js"; import { applyPrimaryModel } from "../../model-picker.js"; +import { configureOllamaNonInteractive } from "../../ollama-setup.js"; import { applyAuthProfileConfig, applyCloudflareAiGatewayConfig, @@ -23,6 +24,7 @@ import { applyMinimaxConfig, applyMoonshotConfig, applyMoonshotConfigCn, + applyOpencodeGoConfig, applyOpencodeZenConfig, applyOpenrouterConfig, applySyntheticConfig, @@ -48,6 +50,7 @@ import { setMinimaxApiKey, setMoonshotApiKey, setOpenaiApiKey, + setOpencodeGoApiKey, setOpencodeZenApiKey, setOpenrouterApiKey, setSyntheticApiKey, @@ -172,6 +175,10 @@ export async function applyNonInteractiveAuthChoice(params: { return null; } + if (authChoice === "ollama") { + return configureOllamaNonInteractive({ nextConfig, opts, runtime }); + } + if (authChoice === "apiKey") { const resolved = await resolveApiKey({ provider: "anthropic", @@ -926,6 +933,33 @@ export async function applyNonInteractiveAuthChoice(params: { return applyOpencodeZenConfig(nextConfig); } + if (authChoice === "opencode-go") { + const resolved = await resolveApiKey({ + provider: "opencode-go", + cfg: baseConfig, + flagValue: opts.opencodeGoApiKey, + flagName: "--opencode-go-api-key", + envVar: "OPENCODE_API_KEY", + runtime, + }); + if (!resolved) { + return null; + } + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setOpencodeGoApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; + } + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "opencode-go:default", + provider: "opencode-go", + mode: "api_key", + }); + return applyOpencodeGoConfig(nextConfig); + } + if (authChoice === "together-api-key") { const resolved = await resolveApiKey({ provider: "together", diff --git a/src/commands/onboard-provider-auth-flags.ts b/src/commands/onboard-provider-auth-flags.ts index 43c552f99fb..7610727097f 100644 --- a/src/commands/onboard-provider-auth-flags.ts +++ b/src/commands/onboard-provider-auth-flags.ts @@ -20,6 +20,7 @@ type OnboardProviderAuthOptionKey = keyof Pick< | "togetherApiKey" | "huggingfaceApiKey" | "opencodeZenApiKey" + | "opencodeGoApiKey" | "xaiApiKey" | "litellmApiKey" | "qianfanApiKey" @@ -163,7 +164,14 @@ export const ONBOARD_PROVIDER_AUTH_FLAGS: ReadonlyArray authChoice: "opencode-zen", cliFlag: "--opencode-zen-api-key", cliOption: "--opencode-zen-api-key ", - description: "OpenCode Zen API key", + description: "OpenCode API key (Zen catalog)", + }, + { + optionKey: "opencodeGoApiKey", + authChoice: "opencode-go", + cliFlag: "--opencode-go-api-key", + cliOption: "--opencode-go-api-key ", + description: "OpenCode API key (Go catalog)", }, { optionKey: "xaiApiKey", diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 44f4660321e..40a02e85c15 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -10,6 +10,7 @@ export type AuthChoice = | "token" | "chutes" | "vllm" + | "ollama" | "openai-codex" | "openai-api-key" | "openrouter-api-key" @@ -41,6 +42,7 @@ export type AuthChoice = | "minimax-api-lightning" | "minimax-portal" | "opencode-zen" + | "opencode-go" | "github-copilot" | "copilot-proxy" | "qwen-portal" @@ -58,6 +60,7 @@ export type AuthChoiceGroupId = | "anthropic" | "chutes" | "vllm" + | "ollama" | "google" | "copilot" | "openrouter" @@ -68,7 +71,7 @@ export type AuthChoiceGroupId = | "moonshot" | "zai" | "xiaomi" - | "opencode-zen" + | "opencode" | "minimax" | "synthetic" | "venice" @@ -134,6 +137,7 @@ export type OnboardOptions = { togetherApiKey?: string; huggingfaceApiKey?: string; opencodeZenApiKey?: string; + opencodeGoApiKey?: string; xaiApiKey?: string; volcengineApiKey?: string; byteplusApiKey?: string; diff --git a/src/commands/opencode-go-model-default.ts b/src/commands/opencode-go-model-default.ts new file mode 100644 index 00000000000..c959f23ff2e --- /dev/null +++ b/src/commands/opencode-go-model-default.ts @@ -0,0 +1,11 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { applyAgentDefaultPrimaryModel } from "./model-default.js"; + +export const OPENCODE_GO_DEFAULT_MODEL_REF = "opencode-go/kimi-k2.5"; + +export function applyOpencodeGoModelDefault(cfg: OpenClawConfig): { + next: OpenClawConfig; + changed: boolean; +} { + return applyAgentDefaultPrimaryModel({ cfg, model: OPENCODE_GO_DEFAULT_MODEL_REF }); +} diff --git a/src/commands/status-all/report-lines.ts b/src/commands/status-all/report-lines.ts index 152918029b5..751237360b4 100644 --- a/src/commands/status-all/report-lines.ts +++ b/src/commands/status-all/report-lines.ts @@ -1,5 +1,5 @@ import type { ProgressReporter } from "../../cli/progress.js"; -import { renderTable } from "../../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../../terminal/table.js"; import { isRich, theme } from "../../terminal/theme.js"; import { groupChannelIssuesByChannel } from "./channel-issues.js"; import { appendStatusAllDiagnosis } from "./diagnosis.js"; @@ -57,7 +57,7 @@ export async function buildStatusAllReportLines(params: { const fail = (text: string) => (rich ? theme.error(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 overview = renderTable({ width: tableWidth, diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 0d412c9715a..7e68424c5a9 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -16,7 +16,7 @@ import { } from "../memory/status-format.js"; import type { RuntimeEnv } from "../runtime.js"; import { runSecurityAudit } from "../security/audit.js"; -import { renderTable } from "../terminal/table.js"; +import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { formatHealthChannelLines, type HealthSummary } from "./health.js"; import { resolveControlUiLinks } from "./onboard-helpers.js"; @@ -229,7 +229,7 @@ export async function statusCommand( runtime.log(""); } - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const tableWidth = getTerminalTableWidth(); if (secretDiagnostics.length > 0) { runtime.log(theme.warn("Secret diagnostics:")); diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index 02eab6789ea..99438a13e16 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -279,6 +279,31 @@ describe("config plugin validation", () => { expect(res.ok).toBe(true); }); + it("accepts voice-call OpenAI TTS speed, instructions, and baseUrl config fields", async () => { + const res = validateInSuite({ + agents: { list: [{ id: "pi" }] }, + plugins: { + enabled: true, + load: { paths: [voiceCallSchemaPluginDir] }, + entries: { + "voice-call-schema-fixture": { + config: { + tts: { + openai: { + baseUrl: "http://localhost:8880/v1", + voice: "alloy", + speed: 1.5, + instructions: "Speak in a cheerful tone", + }, + }, + }, + }, + }, + }, + }); + expect(res.ok).toBe(true); + }); + it("accepts known plugin ids and valid channel/heartbeat enums", async () => { const res = validateInSuite({ agents: { diff --git a/src/config/config.schema-regressions.test.ts b/src/config/config.schema-regressions.test.ts index 4125cb1b3d4..3e605e06c35 100644 --- a/src/config/config.schema-regressions.test.ts +++ b/src/config/config.schema-regressions.test.ts @@ -184,4 +184,31 @@ describe("config schema regressions", () => { expect(res.ok).toBe(false); }); + + it("accepts signal accountUuid for loop protection", () => { + const res = validateConfigObject({ + channels: { + signal: { + accountUuid: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + }, + }, + }); + + expect(res.ok).toBe(true); + }); + + it("accepts telegram actions editMessage and createForumTopic", () => { + const res = validateConfigObject({ + channels: { + telegram: { + actions: { + editMessage: true, + createForumTopic: false, + }, + }, + }, + }); + + expect(res.ok).toBe(true); + }); }); diff --git a/src/config/config.ts b/src/config/config.ts index 7caaa15a95f..3bd36d0d709 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -5,6 +5,7 @@ export { createConfigIO, getRuntimeConfigSnapshot, getRuntimeConfigSourceSnapshot, + projectConfigOntoRuntimeSourceSnapshot, loadConfig, readBestEffortConfig, parseConfigJson5, diff --git a/src/config/io.runtime-snapshot-write.test.ts b/src/config/io.runtime-snapshot-write.test.ts index 71ddbbb8de3..480897c698c 100644 --- a/src/config/io.runtime-snapshot-write.test.ts +++ b/src/config/io.runtime-snapshot-write.test.ts @@ -7,6 +7,7 @@ import { clearRuntimeConfigSnapshot, getRuntimeConfigSourceSnapshot, loadConfig, + projectConfigOntoRuntimeSourceSnapshot, setRuntimeConfigSnapshotRefreshHandler, setRuntimeConfigSnapshot, writeConfigFile, @@ -61,6 +62,46 @@ describe("runtime config snapshot writes", () => { }); }); + it("skips source projection for non-runtime-derived configs", async () => { + await withTempHome("openclaw-config-runtime-projection-shape-", async () => { + const sourceConfig: OpenClawConfig = { + ...createSourceConfig(), + gateway: { + auth: { + mode: "token", + }, + }, + }; + const runtimeConfig: OpenClawConfig = { + ...createRuntimeConfig(), + gateway: { + auth: { + mode: "token", + }, + }, + }; + const independentConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-independent-config", // pragma: allowlist secret + models: [], + }, + }, + }, + }; + + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + const projected = projectConfigOntoRuntimeSourceSnapshot(independentConfig); + expect(projected).toBe(independentConfig); + } finally { + resetRuntimeConfigState(); + } + }); + }); + it("clears runtime source snapshot when runtime snapshot is cleared", async () => { const sourceConfig = createSourceConfig(); const runtimeConfig = createRuntimeConfig(); diff --git a/src/config/io.ts b/src/config/io.ts index 5a9026310eb..2b542bba755 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -1374,6 +1374,58 @@ export function getRuntimeConfigSourceSnapshot(): OpenClawConfig | null { return runtimeConfigSourceSnapshot; } +function isCompatibleTopLevelRuntimeProjectionShape(params: { + runtimeSnapshot: OpenClawConfig; + candidate: OpenClawConfig; +}): boolean { + const runtime = params.runtimeSnapshot as Record; + const candidate = params.candidate as Record; + for (const key of Object.keys(runtime)) { + if (!Object.hasOwn(candidate, key)) { + return false; + } + const runtimeValue = runtime[key]; + const candidateValue = candidate[key]; + const runtimeType = Array.isArray(runtimeValue) + ? "array" + : runtimeValue === null + ? "null" + : typeof runtimeValue; + const candidateType = Array.isArray(candidateValue) + ? "array" + : candidateValue === null + ? "null" + : typeof candidateValue; + if (runtimeType !== candidateType) { + return false; + } + } + return true; +} + +export function projectConfigOntoRuntimeSourceSnapshot(config: OpenClawConfig): OpenClawConfig { + if (!runtimeConfigSnapshot || !runtimeConfigSourceSnapshot) { + return config; + } + if (config === runtimeConfigSnapshot) { + return runtimeConfigSourceSnapshot; + } + // This projection expects callers to pass config objects derived from the + // active runtime snapshot (for example shallow/deep clones with targeted edits). + // For structurally unrelated configs, skip projection to avoid accidental + // merge-patch deletions or reintroducing resolved values into source refs. + if ( + !isCompatibleTopLevelRuntimeProjectionShape({ + runtimeSnapshot: runtimeConfigSnapshot, + candidate: config, + }) + ) { + return config; + } + const runtimePatch = createMergePatch(runtimeConfigSnapshot, config); + return coerceConfig(applyMergePatch(runtimeConfigSourceSnapshot, runtimePatch)); +} + export function setRuntimeConfigSnapshotRefreshHandler( refreshHandler: RuntimeConfigSnapshotRefreshHandler | null, ): void { diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 81d67d13011..817f9efc3d8 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -82,6 +82,10 @@ export type SessionEntry = { forkedFromParent?: boolean; /** Subagent spawn depth (0 = main, 1 = sub-agent, 2 = sub-sub-agent). */ spawnDepth?: number; + /** Explicit role assigned at spawn time for subagent tool policy/control decisions. */ + subagentRole?: "orchestrator" | "leaf"; + /** Explicit control scope assigned at spawn time for subagent control decisions. */ + subagentControlScope?: "children" | "none"; systemSent?: boolean; abortedLastRun?: boolean; /** diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 41c047e860c..45eac2fb310 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -93,7 +93,7 @@ export type TelegramAccountConfig = { /** If false, do not start this Telegram account. Default: true. */ enabled?: boolean; botToken?: string; - /** Path to file containing bot token (for secret managers like agenix). */ + /** Path to a regular file containing the bot token; symlinks are rejected. */ tokenFile?: string; /** Control reply threading when reply tags are present (off|first|all). */ replyToMode?: ReplyToMode; diff --git a/src/config/types.tts.ts b/src/config/types.tts.ts index 3d898ff9c57..a6232f9de5a 100644 --- a/src/config/types.tts.ts +++ b/src/config/types.tts.ts @@ -61,6 +61,10 @@ export type TtsConfig = { baseUrl?: string; model?: string; voice?: string; + /** Playback speed (0.25–4.0, default 1.0). */ + speed?: number; + /** System-level instructions for the TTS model (gpt-4o-mini-tts only). */ + instructions?: string; }; /** Microsoft Edge (node-edge-tts) configuration. */ edge?: { diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 066a33f0f4f..305efab4b26 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -404,6 +404,8 @@ export const TtsConfigSchema = z baseUrl: z.string().optional(), model: z.string().optional(), voice: z.string().optional(), + speed: z.number().min(0.25).max(4).optional(), + instructions: z.string().optional(), }) .strict() .optional(), diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 3ceefb480ff..d68ac63759c 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -244,7 +244,9 @@ export const TelegramAccountSchemaBase = z sendMessage: z.boolean().optional(), poll: z.boolean().optional(), deleteMessage: z.boolean().optional(), + editMessage: z.boolean().optional(), sticker: z.boolean().optional(), + createForumTopic: z.boolean().optional(), }) .strict() .optional(), @@ -384,6 +386,16 @@ export const DiscordGuildChannelSchema = z systemPrompt: z.string().optional(), includeThreadStarter: z.boolean().optional(), autoThread: z.boolean().optional(), + /** Archive duration for auto-created threads in minutes. Discord supports 60, 1440 (1 day), 4320 (3 days), 10080 (1 week). Default: 60. */ + autoArchiveDuration: z + .union([ + z.enum(["60", "1440", "4320", "10080"]), + z.literal(60), + z.literal(1440), + z.literal(4320), + z.literal(10080), + ]) + .optional(), }) .strict(); @@ -967,6 +979,7 @@ export const SignalAccountSchemaBase = z enabled: z.boolean().optional(), configWrites: z.boolean().optional(), account: z.string().optional(), + accountUuid: z.string().optional(), httpUrl: z.string().optional(), httpHost: z.string().optional(), httpPort: z.number().int().positive().optional(), diff --git a/src/config/zod-schema.tts.test.ts b/src/config/zod-schema.tts.test.ts new file mode 100644 index 00000000000..70398e81054 --- /dev/null +++ b/src/config/zod-schema.tts.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { TtsConfigSchema } from "./zod-schema.core.js"; + +describe("TtsConfigSchema openai speed and instructions", () => { + it("accepts speed and instructions in openai section", () => { + expect(() => + TtsConfigSchema.parse({ + openai: { + voice: "alloy", + speed: 1.5, + instructions: "Speak in a cheerful tone", + }, + }), + ).not.toThrow(); + }); + + it("rejects out-of-range openai speed", () => { + expect(() => + TtsConfigSchema.parse({ + openai: { + speed: 5.0, + }, + }), + ).toThrow(); + }); + + it("rejects openai speed below minimum", () => { + expect(() => + TtsConfigSchema.parse({ + openai: { + speed: 0.1, + }, + }), + ).toThrow(); + }); +}); diff --git a/src/cron/isolated-agent.lane.test.ts b/src/cron/isolated-agent.lane.test.ts new file mode 100644 index 00000000000..5d26faff327 --- /dev/null +++ b/src/cron/isolated-agent.lane.test.ts @@ -0,0 +1,84 @@ +import "./isolated-agent.mocks.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; +import { + makeCfg, + makeJob, + withTempCronHome, + writeSessionStoreEntries, +} from "./isolated-agent.test-harness.js"; + +function makeDeps() { + return { + sendMessageSlack: vi.fn(), + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn(), + sendMessageDiscord: vi.fn(), + sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), + }; +} + +function mockEmbeddedOk() { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); +} + +function lastEmbeddedLane(): string | undefined { + const calls = vi.mocked(runEmbeddedPiAgent).mock.calls; + expect(calls.length).toBeGreaterThan(0); + return (calls.at(-1)?.[0] as { lane?: string } | undefined)?.lane; +} + +async function runLaneCase(home: string, lane?: string) { + const storePath = await writeSessionStoreEntries(home, { + "agent:main:main": { + sessionId: "main-session", + updatedAt: Date.now(), + lastProvider: "webchat", + lastTo: "", + }, + }); + mockEmbeddedOk(); + + await runCronIsolatedAgentTurn({ + cfg: makeCfg(home, storePath), + deps: makeDeps(), + job: makeJob({ kind: "agentTurn", message: "do it", deliver: false }), + message: "do it", + sessionKey: "cron:job-1", + ...(lane === undefined ? {} : { lane }), + }); + + return lastEmbeddedLane(); +} + +describe("runCronIsolatedAgentTurn lane selection", () => { + beforeEach(() => { + vi.mocked(runEmbeddedPiAgent).mockClear(); + }); + + it("moves the cron lane to nested for embedded runs", async () => { + await withTempCronHome(async (home) => { + expect(await runLaneCase(home, "cron")).toBe("nested"); + }); + }); + + it("defaults missing lanes to nested for embedded runs", async () => { + await withTempCronHome(async (home) => { + expect(await runLaneCase(home)).toBe("nested"); + }); + }); + + it("preserves non-cron lanes for embedded runs", async () => { + await withTempCronHome(async (home) => { + expect(await runLaneCase(home, "subagent")).toBe("subagent"); + }); + }); +}); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 0666b752e5c..4c7a5c87fe2 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -12,6 +12,7 @@ import { getCliSessionId, setCliSessionId } from "../../agents/cli-session.js"; import { lookupContextTokens } from "../../agents/context.js"; import { resolveCronStyleNow } from "../../agents/current-time.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; +import { resolveNestedAgentLane } from "../../agents/lanes.js"; import { loadModelCatalog } from "../../agents/model-catalog.js"; import { runWithModelFallback } from "../../agents/model-fallback.js"; import { @@ -610,7 +611,7 @@ export async function runCronIsolatedAgentTurn(params: { config: cfgWithAgentDefaults, skillsSnapshot, prompt: promptText, - lane: params.lane ?? "cron", + lane: resolveNestedAgentLane(params.lane), provider: providerOverride, model: modelOverride, authProfileId, diff --git a/src/discord/monitor/allow-list.ts b/src/discord/monitor/allow-list.ts index 5432cb5d128..b736928e276 100644 --- a/src/discord/monitor/allow-list.ts +++ b/src/discord/monitor/allow-list.ts @@ -40,6 +40,7 @@ export type DiscordGuildEntryResolved = { systemPrompt?: string; includeThreadStarter?: boolean; autoThread?: boolean; + autoArchiveDuration?: "60" | "1440" | "4320" | "10080" | 60 | 1440 | 4320 | 10080; } >; }; @@ -55,6 +56,7 @@ export type DiscordChannelConfigResolved = { systemPrompt?: string; includeThreadStarter?: boolean; autoThread?: boolean; + autoArchiveDuration?: "60" | "1440" | "4320" | "10080" | 60 | 1440 | 4320 | 10080; matchKey?: string; matchSource?: ChannelMatchSource; }; @@ -401,6 +403,7 @@ function resolveDiscordChannelConfigEntry( systemPrompt: entry.systemPrompt, includeThreadStarter: entry.includeThreadStarter, autoThread: entry.autoThread, + autoArchiveDuration: entry.autoArchiveDuration, }; return resolved; } diff --git a/src/discord/monitor/exec-approvals.ts b/src/discord/monitor/exec-approvals.ts index f426ae51903..87dc0c9a07d 100644 --- a/src/discord/monitor/exec-approvals.ts +++ b/src/discord/monitor/exec-approvals.ts @@ -13,10 +13,10 @@ import { ButtonStyle, Routes } from "discord-api-types/v10"; import type { OpenClawConfig } from "../../config/config.js"; import { loadSessionStore, resolveStorePath } from "../../config/sessions.js"; import type { DiscordExecApprovalConfig } from "../../config/types.discord.js"; -import { buildGatewayConnectionDetails } from "../../gateway/call.js"; import { GatewayClient } from "../../gateway/client.js"; -import { resolveGatewayConnectionAuth } from "../../gateway/connection-auth.js"; +import { createOperatorApprovalsGatewayClient } from "../../gateway/operator-approvals-client.js"; import type { EventFrame } from "../../gateway/protocol/index.js"; +import { resolveExecApprovalCommandDisplay } from "../../infra/exec-approval-command-display.js"; import { getExecApprovalApproverDmNoticeText } from "../../infra/exec-approval-reply.js"; import type { ExecApprovalDecision, @@ -27,11 +27,7 @@ import { logDebug, logError } from "../../logger.js"; import { normalizeAccountId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import type { RuntimeEnv } from "../../runtime.js"; import { compileSafeRegex, testRegexWithBoundedInput } from "../../security/safe-regex.js"; -import { - GATEWAY_CLIENT_MODES, - GATEWAY_CLIENT_NAMES, - normalizeMessageChannel, -} from "../../utils/message-channel.js"; +import { normalizeMessageChannel } from "../../utils/message-channel.js"; import { createDiscordClient, stripUndefinedFields } from "../send.shared.js"; import { DiscordUiContainer } from "../ui.js"; @@ -110,6 +106,7 @@ type ExecApprovalContainerParams = { title: string; description?: string; commandPreview: string; + commandSecondaryPreview?: string | null; metadataLines?: string[]; actionRow?: Row