Merge remote-tracking branch 'origin/main' into pr-42501-prep
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
c57b1f8ba2
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@ -1 +0,0 @@
|
||||
custom: ["https://github.com/sponsors/steipete"]
|
||||
35
CHANGELOG.md
35
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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.3.9</string>
|
||||
<string>$(OPENCLAW_MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260308</string>
|
||||
<string>$(OPENCLAW_BUILD_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
|
||||
@ -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.
|
||||
|
||||
8
apps/ios/Config/Version.xcconfig
Normal file
8
apps/ios/Config/Version.xcconfig
Normal file
@ -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"
|
||||
@ -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.
|
||||
|
||||
@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.3.9</string>
|
||||
<string>$(OPENCLAW_MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260308</string>
|
||||
<string>$(OPENCLAW_BUILD_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.3.9</string>
|
||||
<string>$(OPENCLAW_MARKETING_VERSION)</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@ -36,7 +36,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260308</string>
|
||||
<string>$(OPENCLAW_BUILD_VERSION)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,8 +17,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.3.9</string>
|
||||
<string>$(OPENCLAW_MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260308</string>
|
||||
<string>$(OPENCLAW_BUILD_VERSION)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.3.9</string>
|
||||
<string>$(OPENCLAW_MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260308</string>
|
||||
<string>$(OPENCLAW_BUILD_VERSION)</string>
|
||||
<key>WKCompanionAppBundleIdentifier</key>
|
||||
<string>$(OPENCLAW_APP_BUNDLE_ID)</string>
|
||||
<key>WKWatchKitApp</key>
|
||||
|
||||
@ -15,9 +15,9 @@
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.3.9</string>
|
||||
<string>$(OPENCLAW_MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260308</string>
|
||||
<string>$(OPENCLAW_BUILD_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
|
||||
@ -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)"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
|
||||
@ -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)"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"
|
||||
|
||||
222
apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift
Normal file
222
apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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]?,
|
||||
|
||||
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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?
|
||||
}
|
||||
|
||||
@ -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<OpenClawChatTransportEvent>
|
||||
@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Void, Never>?
|
||||
@ -42,6 +49,17 @@ public final class OpenClawChatViewModel {
|
||||
@ObservationIgnored
|
||||
private nonisolated(unsafe) var pendingRunTimeoutTasks: [String: Task<Void, Never>] = [:]
|
||||
private let pendingRunTimeoutMs: UInt64 = 120_000
|
||||
// Session switches can overlap in-flight picker patches, so stale completions
|
||||
// must compare against the latest request and latest desired value for that session.
|
||||
private var nextModelSelectionRequestID: UInt64 = 0
|
||||
private var latestModelSelectionRequestIDsBySession: [String: UInt64] = [:]
|
||||
private var latestModelSelectionIDsBySession: [String: String] = [:]
|
||||
private var lastSuccessfulModelSelectionIDsBySession: [String: String] = [:]
|
||||
private var inFlightModelPatchCountsBySession: [String: Int] = [:]
|
||||
private var modelPatchWaitersBySession: [String: [CheckedContinuation<Void, Never>]] = [:]
|
||||
private var nextThinkingSelectionRequestID: UInt64 = 0
|
||||
private var latestThinkingSelectionRequestIDsBySession: [String: UInt64] = [:]
|
||||
private var latestThinkingLevelsBySession: [String: String] = [:]
|
||||
|
||||
private var pendingToolCallsById: [String: OpenClawChatPendingToolCall] = [:] {
|
||||
didSet {
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Void, Never>?
|
||||
private var tickTask: Task<Void, Never>?
|
||||
private var keepaliveTask: Task<Void, Never>?
|
||||
private var pendingDeviceTokenRetry = false
|
||||
private var deviceTokenRetryBudgetUsed = false
|
||||
private var reconnectPausedForAuthFailure = false
|
||||
private let defaultRequestTimeoutMs: Double = 15000
|
||||
private let pushHandler: (@Sendable (GatewayPush) async -> Void)?
|
||||
private let connectOptions: GatewayConnectOptions?
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]?,
|
||||
|
||||
@ -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<Void, Never>?
|
||||
|
||||
func wait() async {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.continuation = continuation
|
||||
}
|
||||
}
|
||||
|
||||
func open() {
|
||||
self.continuation?.resume()
|
||||
self.continuation = nil
|
||||
}
|
||||
}
|
||||
|
||||
private actor TestChatTransportState {
|
||||
var historyCallCount: Int = 0
|
||||
var sessionsCallCount: Int = 0
|
||||
var modelsCallCount: Int = 0
|
||||
var sentRunIds: [String] = []
|
||||
var sentThinkingLevels: [String] = []
|
||||
var abortedRunIds: [String] = []
|
||||
var patchedModels: [String?] = []
|
||||
var patchedThinkingLevels: [String] = []
|
||||
}
|
||||
|
||||
private final class TestChatTransport: @unchecked Sendable, OpenClawChatTransport {
|
||||
private let state = TestChatTransportState()
|
||||
private let historyResponses: [OpenClawChatHistoryPayload]
|
||||
private let sessionsResponses: [OpenClawChatSessionsListResponse]
|
||||
private let modelResponses: [[OpenClawChatModelChoice]]
|
||||
private let setSessionModelHook: (@Sendable (String?) async throws -> Void)?
|
||||
private let setSessionThinkingHook: (@Sendable (String) async throws -> Void)?
|
||||
|
||||
private let stream: AsyncStream<OpenClawChatTransportEvent>
|
||||
private let continuation: AsyncStream<OpenClawChatTransportEvent>.Continuation
|
||||
|
||||
init(
|
||||
historyResponses: [OpenClawChatHistoryPayload],
|
||||
sessionsResponses: [OpenClawChatSessionsListResponse] = [])
|
||||
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<OpenClawChatTransportEvent>.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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -87,6 +87,8 @@ Token/secret files:
|
||||
}
|
||||
```
|
||||
|
||||
`tokenFile` and `secretFile` must point to regular files. Symlinks are rejected.
|
||||
|
||||
Multiple accounts:
|
||||
|
||||
```json5
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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.<id>.botToken`: per-account token.
|
||||
- `channels.zalo.accounts.<id>.tokenFile`: per-account token file.
|
||||
- `channels.zalo.accounts.<id>.tokenFile`: per-account regular token file. Symlinks are rejected.
|
||||
- `channels.zalo.accounts.<id>.name`: display name.
|
||||
- `channels.zalo.accounts.<id>.enabled`: enable/disable account.
|
||||
- `channels.zalo.accounts.<id>.dmPolicy`: per-account DM policy.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 <deviceId> --role operator
|
||||
```
|
||||
|
||||
4. If rotation is not enough, remove stale pairing and approve again:
|
||||
|
||||
```bash
|
||||
openclaw devices remove <deviceId>
|
||||
openclaw devices list
|
||||
openclaw devices approve <requestId>
|
||||
```
|
||||
|
||||
5. Retry client connection with the current shared token/password.
|
||||
|
||||
Related:
|
||||
|
||||
- [Dashboard auth troubleshooting](/web/dashboard#if-you-see-unauthorized-1008)
|
||||
- [Gateway troubleshooting](/gateway/troubleshooting#dashboard-control-ui-connectivity)
|
||||
|
||||
@ -337,7 +337,7 @@ Options:
|
||||
- `--non-interactive`
|
||||
- `--mode <local|remote>`
|
||||
- `--flow <quickstart|advanced|manual>` (manual is an alias for advanced)
|
||||
- `--auth-choice <setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|mistral-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|custom-api-key|skip>`
|
||||
- `--auth-choice <setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|mistral-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|opencode-go|custom-api-key|skip>`
|
||||
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
|
||||
- `--token <token>` (non-interactive; used with `--auth-choice token`)
|
||||
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
|
||||
@ -354,6 +354,7 @@ Options:
|
||||
- `--zai-api-key <key>`
|
||||
- `--minimax-api-key <key>`
|
||||
- `--opencode-zen-api-key <key>`
|
||||
- `--opencode-go-api-key <key>`
|
||||
- `--custom-base-url <url>` (non-interactive; used with `--auth-choice custom-api-key`)
|
||||
- `--custom-model-id <id>` (non-interactive; used with `--auth-choice custom-api-key`)
|
||||
- `--custom-api-key <key>` (non-interactive; optional; used with `--auth-choice custom-api-key`; falls back to `CUSTOM_API_KEY` when omitted)
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 `! <cmd>` for host shell. Requires `tools.elevated.enabled` and sender in `tools.elevated.allowFrom.<channel>`.
|
||||
- `config: true` enables `/config` (reads/writes `openclaw.json`). For gateway `chat.send` clients, persistent `/config set|unset` writes also require `operator.admin`; read-only `/config show` stays available to normal write-scoped operator clients.
|
||||
- `channels.<provider>.configWrites` gates config mutations per channel (default: true).
|
||||
- For multi-account channels, `channels.<provider>.accounts.<id>.configWrites` also gates writes that target that account (for example `/allowlist --config --account <id>` or `/config set channels.<provider>.accounts.<id>...`).
|
||||
- `allowFrom` is per-provider. When set, it is the **only** authorization source (channel allowlists/pairing and `useAccessGroups` are ignored).
|
||||
- `useAccessGroups: false` allows commands to bypass access-group policies when `allowFrom` is not set.
|
||||
|
||||
@ -2078,7 +2079,7 @@ Use `cerebras/zai-glm-4.7` for Cerebras; `zai/glm-4.7` for Z.AI direct.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="OpenCode Zen">
|
||||
<Accordion title="OpenCode">
|
||||
|
||||
```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`.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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.<channel>.accounts` entries are configured without `channels.<channel>.defaultAccount` or `accounts.default`, doctor warns that fallback routing can pick an unexpected account.
|
||||
- If `channels.<channel>.defaultAccount` is set to an unknown account ID, doctor warns and lists configured account IDs.
|
||||
|
||||
### 2b) OpenCode 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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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/<accountId>/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.
|
||||
|
||||
@ -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 <requestId>`. |
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -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 <id> --role operator`
|
||||
- Still stuck? Run `openclaw status --all` and follow [Troubleshooting](/gateway/troubleshooting). See [Dashboard](/web/dashboard) for auth details.
|
||||
|
||||
### I set gatewaybind tailnet but it can't bind nothing listens
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
45
docs/providers/opencode-go.md
Normal file
45
docs/providers/opencode-go.md
Normal file
@ -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.
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
```
|
||||
</Accordion>
|
||||
<Accordion title="OpenCode Zen example">
|
||||
<Accordion title="OpenCode example">
|
||||
```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.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
|
||||
@ -127,7 +127,7 @@ openclaw health
|
||||
Use this when debugging auth or deciding what to back up:
|
||||
|
||||
- **WhatsApp**: `~/.openclaw/credentials/whatsapp/<accountId>/creds.json`
|
||||
- **Telegram bot token**: config/env or `channels.telegram.tokenFile`
|
||||
- **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**:
|
||||
|
||||
@ -123,7 +123,7 @@ openclaw onboard --non-interactive \
|
||||
--gateway-bind loopback
|
||||
```
|
||||
</Accordion>
|
||||
<Accordion title="OpenCode Zen example">
|
||||
<Accordion title="OpenCode example">
|
||||
```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.
|
||||
</Accordion>
|
||||
<Accordion title="Custom provider example">
|
||||
```bash
|
||||
|
||||
@ -155,8 +155,8 @@ What you set:
|
||||
<Accordion title="xAI (Grok) API key">
|
||||
Prompts for `XAI_API_KEY` and configures xAI as a model provider.
|
||||
</Accordion>
|
||||
<Accordion title="OpenCode Zen">
|
||||
Prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`).
|
||||
<Accordion title="OpenCode">
|
||||
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).
|
||||
</Accordion>
|
||||
<Accordion title="API key (generic)">
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -123,6 +123,7 @@ Notes:
|
||||
- `/new <model>` accepts a model alias, `provider/model`, or a provider name (fuzzy match); if no match, the text is treated as the message body.
|
||||
- For full provider usage breakdown, use `openclaw status --usage`.
|
||||
- `/allowlist add|remove` requires `commands.config=true` and honors channel `configWrites`.
|
||||
- In multi-account channels, config-targeted `/allowlist --account <id>` and `/config set channels.<provider>.accounts.<id>...` also honor the target account's `configWrites`.
|
||||
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from OpenClaw session logs.
|
||||
- `/restart` is enabled by default; set `commands.restart: false` to disable it.
|
||||
- Discord-only native command: `/vc join|leave|status` controls voice channels (requires `channels.discord.voice` and native commands; not available as text).
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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:**
|
||||
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -102,6 +102,7 @@ export async function checkAcpxVersion(params: {
|
||||
command: string;
|
||||
cwd?: string;
|
||||
expectedVersion?: string;
|
||||
stripProviderAuthEnvVars?: boolean;
|
||||
spawnOptions?: SpawnCommandOptions;
|
||||
}): Promise<AcpxVersionCheckResult> {
|
||||
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<ReturnType<typeof spawnAndCollect>>;
|
||||
try {
|
||||
@ -198,6 +200,7 @@ export async function ensureAcpx(params: {
|
||||
pluginRoot?: string;
|
||||
expectedVersion?: string;
|
||||
allowInstall?: boolean;
|
||||
stripProviderAuthEnvVars?: boolean;
|
||||
spawnOptions?: SpawnCommandOptions;
|
||||
}): Promise<void> {
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
@ -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("\\\\\\");
|
||||
});
|
||||
});
|
||||
@ -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<string, string> {
|
||||
async function loadAgentOverrides(params: {
|
||||
acpxCommand: string;
|
||||
cwd: string;
|
||||
stripProviderAuthEnvVars?: boolean;
|
||||
spawnOptions?: SpawnCommandOptions;
|
||||
}): Promise<Record<string, string>> {
|
||||
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<string> {
|
||||
const normalizedAgent = normalizeAgentName(params.agent);
|
||||
const overrides = await loadAgentOverrides({
|
||||
acpxCommand: params.acpxCommand,
|
||||
cwd: params.cwd,
|
||||
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
|
||||
spawnOptions: params.spawnOptions,
|
||||
});
|
||||
return overrides[normalizedAgent] ?? ACPX_BUILTIN_AGENT_COMMANDS[normalizedAgent] ?? params.agent;
|
||||
|
||||
@ -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<string> {
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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?: {
|
||||
|
||||
@ -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
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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,
|
||||
{
|
||||
|
||||
@ -89,6 +89,11 @@ describe("createAcpxRuntimeService", () => {
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(ensureAcpxSpy).toHaveBeenCalledOnce();
|
||||
expect(ensureAcpxSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stripProviderAuthEnvVars: true,
|
||||
}),
|
||||
);
|
||||
expect(probeAvailabilitySpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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<void> {
|
||||
delete process.env.MOCK_ACPX_LOG;
|
||||
delete process.env.MOCK_ACPX_CONFIG_SHOW_AGENTS;
|
||||
sharedMockCliScriptPath = null;
|
||||
logFileSequence = 0;
|
||||
while (tempDirs.length > 0) {
|
||||
|
||||
@ -21,6 +21,7 @@ import {
|
||||
import {
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
collectOpenGroupPolicyRestrictSendersWarnings,
|
||||
createAccountStatusSink,
|
||||
formatNormalizedAllowFromEntries,
|
||||
mapAllowFromEntries,
|
||||
} from "openclaw/plugin-sdk/compat";
|
||||
@ -369,8 +370,11 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
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<ResolvedBlueBubblesAccount> = {
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
||||
statusSink,
|
||||
webhookPath,
|
||||
});
|
||||
},
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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[] {
|
||||
|
||||
@ -7,6 +7,9 @@
|
||||
"dependencies": {
|
||||
"google-auth-library": "^10.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.7"
|
||||
},
|
||||
|
||||
@ -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<ResolvedGoogleChatAcc
|
||||
],
|
||||
});
|
||||
|
||||
const resolveGoogleChatDmPolicy = createScopedDmSecurityResolver<ResolvedGoogleChatAccount>({
|
||||
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<ResolvedGoogleChatAccount> = {
|
||||
...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<ResolvedGoogleChatAccount> = {
|
||||
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<void>((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(),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@ -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<string, unknown>;
|
||||
}): 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() },
|
||||
});
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 = {
|
||||
|
||||
67
extensions/irc/src/channel.startup.test.ts
Normal file
67
extensions/irc/src/channel.startup.test.ts
Normal file
@ -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<typeof import("./monitor.js")>("./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();
|
||||
});
|
||||
});
|
||||
@ -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<ResolvedIrcAccount, IrcProbe> = {
|
||||
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<ResolvedIrcAccount, IrcProbe> = {
|
||||
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 };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatDocsLink,
|
||||
patchScopedAccountConfig,
|
||||
promptChannelAccessConfig,
|
||||
resolveAccountIdForConfigure,
|
||||
setTopLevelChannelAllowFrom,
|
||||
@ -59,35 +60,14 @@ function updateIrcAccountConfig(
|
||||
accountId: string,
|
||||
patch: Partial<IrcAccountConfig>,
|
||||
): 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 {
|
||||
|
||||
@ -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<ResolvedLineAccount, OpenClawConfig>({
|
||||
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<ResolvedLineAccount>({
|
||||
channelKey: "line",
|
||||
resolvePolicy: (account) => account.config.dmPolicy,
|
||||
resolveAllowFrom: (account) => account.config.allowFrom,
|
||||
policyPathSuffix: "dmPolicy",
|
||||
approveHint: "openclaw pairing approve line <code>",
|
||||
normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""),
|
||||
});
|
||||
|
||||
function patchLineAccountConfig(
|
||||
cfg: OpenClawConfig,
|
||||
lineConfig: LineConfig,
|
||||
@ -113,40 +132,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||
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<ResolvedLineAccount> = {
|
||||
...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 <code>",
|
||||
normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""),
|
||||
});
|
||||
},
|
||||
resolveDmPolicy: resolveLineDmPolicy,
|
||||
collectWarnings: ({ account, cfg }) => {
|
||||
return collectAllowlistProviderRestrictSendersWarnings({
|
||||
cfg,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user