diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 872228e006f..1d248d5c804 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -267,6 +267,12 @@ jobs: with: submodules: false + - name: Ensure secrets base commit + uses: ./.github/actions/ensure-base-commit + with: + base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }} + fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }} + - name: Setup Node environment uses: ./.github/actions/setup-node-env with: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 95421eb071d..74dc847d487 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -69,6 +69,8 @@ repos: - '"ap[i]Key": "xxxxx"(,)?' - --exclude-lines - 'ap[i]Key: "A[I]za\.\.\.",' + - --exclude-lines + - '"ap[i]Key": "(resolved|normalized|legacy)-key"(,)?' # Shell script linting - repo: https://github.com/koalaman/shellcheck-precommit rev: v0.11.0 diff --git a/.secrets.baseline b/.secrets.baseline index be62e5a4ca3..2f794ecc01b 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -152,7 +152,8 @@ "grep -q 'N[O]DE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache' ~/.bashrc \\|\\| cat >> ~/.bashrc <<'EOF'", "env: \\{ MISTRAL_API_K[E]Y: \"sk-\\.\\.\\.\" \\},", "\"ap[i]Key\": \"xxxxx\"(,)?", - "ap[i]Key: \"A[I]za\\.\\.\\.\"," + "ap[i]Key: \"A[I]za\\.\\.\\.\",", + "\"ap[i]Key\": \"(resolved|normalized|legacy)-key\"(,)?" ] }, { @@ -251,7 +252,7 @@ "filename": "apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift", "hashed_secret": "19dad5cecb110281417d1db56b60e1b006d55bb4", "is_verified": false, - "line_number": 66 + "line_number": 81 } ], "apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift": [ @@ -9795,63 +9796,63 @@ "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e", "is_verified": false, - "line_number": 1612 + "line_number": 1614 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "bde4db9b4c3be4049adc3b9a69851d7c35119770", "is_verified": false, - "line_number": 1628 + "line_number": 1630 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "7f8aaf142ce0552c260f2e546dda43ddd7c9aef3", "is_verified": false, - "line_number": 1815 + "line_number": 1817 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27", "is_verified": false, - "line_number": 1988 + "line_number": 1990 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", "is_verified": false, - "line_number": 2044 + "line_number": 2046 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", "is_verified": false, - "line_number": 2276 + "line_number": 2278 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", "is_verified": false, - "line_number": 2404 + "line_number": 2408 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", "is_verified": false, - "line_number": 2657 + "line_number": 2661 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", "is_verified": false, - "line_number": 2659 + "line_number": 2663 } ], "docs/gateway/configuration.md": [ @@ -11481,7 +11482,7 @@ "filename": "src/agents/models-config.e2e-harness.ts", "hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d", "is_verified": false, - "line_number": 131 + "line_number": 157 } ], "src/agents/models-config.fills-missing-provider-apikey-from-env-var.e2e.test.ts": [ @@ -11515,14 +11516,14 @@ "filename": "src/agents/models-config.providers.nvidia.test.ts", "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, - "line_number": 13 + "line_number": 14 }, { "type": "Secret Keyword", "filename": "src/agents/models-config.providers.nvidia.test.ts", "hashed_secret": "be1a7be9d4d5af417882b267f4db6dddc08507bd", "is_verified": false, - "line_number": 22 + "line_number": 23 } ], "src/agents/models-config.providers.ollama.e2e.test.ts": [ @@ -11746,7 +11747,7 @@ "filename": "src/auto-reply/status.test.ts", "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, - "line_number": 36 + "line_number": 37 } ], "src/browser/bridge-server.auth.test.ts": [ @@ -11764,14 +11765,14 @@ "filename": "src/browser/browser-utils.test.ts", "hashed_secret": "4e126c049580d66ca1549fa534d95a7263f27f46", "is_verified": false, - "line_number": 43 + "line_number": 47 }, { "type": "Basic Auth Credentials", "filename": "src/browser/browser-utils.test.ts", "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 164 + "line_number": 171 } ], "src/browser/cdp.test.ts": [ @@ -11780,7 +11781,7 @@ "filename": "src/browser/cdp.test.ts", "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 243 + "line_number": 318 } ], "src/channels/plugins/plugins-channel.test.ts": [ @@ -12100,21 +12101,21 @@ "filename": "src/config/config.env-vars.test.ts", "hashed_secret": "a24ef9c1a27cac44823571ceef2e8262718eee36", "is_verified": false, - "line_number": 13 + "line_number": 17 }, { "type": "Secret Keyword", "filename": "src/config/config.env-vars.test.ts", "hashed_secret": "29d5f92e9ee44d4854d6dfaeefc3dc27d779fdf3", "is_verified": false, - "line_number": 19 + "line_number": 23 }, { "type": "Secret Keyword", "filename": "src/config/config.env-vars.test.ts", "hashed_secret": "1672b6a1e7956c6a70f45d699aa42a351b1f8b80", "is_verified": false, - "line_number": 27 + "line_number": 31 } ], "src/config/config.irc.test.ts": [ @@ -12335,14 +12336,14 @@ "filename": "src/config/schema.help.ts", "hashed_secret": "9f4cda226d3868676ac7f86f59e4190eb94bd208", "is_verified": false, - "line_number": 651 + "line_number": 653 }, { "type": "Secret Keyword", "filename": "src/config/schema.help.ts", "hashed_secret": "01822c8bbf6a8b136944b14182cb885100ec2eae", "is_verified": false, - "line_number": 684 + "line_number": 686 } ], "src/config/schema.irc.ts": [ @@ -12381,14 +12382,14 @@ "filename": "src/config/schema.labels.ts", "hashed_secret": "e73c9fcad85cd4eecc74181ec4bdb31064d68439", "is_verified": false, - "line_number": 216 + "line_number": 217 }, { "type": "Secret Keyword", "filename": "src/config/schema.labels.ts", "hashed_secret": "2eda7cd978f39eebec3bf03e4410a40e14167fff", "is_verified": false, - "line_number": 325 + "line_number": 326 } ], "src/config/slack-http-config.test.ts": [ @@ -13034,5 +13035,5 @@ } ] }, - "generated_at": "2026-03-08T18:30:57Z" + "generated_at": "2026-03-08T20:41:38Z" } diff --git a/CHANGELOG.md b/CHANGELOG.md index b44611a765d..e68bb2b0469 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,15 @@ Docs: https://docs.openclaw.ai - Talk mode: add top-level `talk.silenceTimeoutMs` config so Talk waits a configurable amount of silence before auto-sending the current transcript, while keeping each platform's existing default pause window when unset. (#39607) Thanks @danodoesdesign. Fixes #17147. - CLI/install: include the short git commit hash in `openclaw --version` output when metadata is available, and keep installer version checks compatible with the decorated format. (#39712) thanks @sourman. - Docs/Web search: restore $5/month free-credit details, replace defunct "Data for Search"/"Data for AI" plan names with current "Search" plan, and note legacy subscription validity in Brave setup docs. Follows up on #26860. (#40111) Thanks @remusao. +- macOS/onboarding: add a remote gateway token field for remote mode, preserve existing non-plaintext `gateway.remote.token` config values until explicitly replaced, and warn when the loaded token shape cannot be used directly from the macOS app. (#40187, supersedes #34614) Thanks @cgdusek. +- CLI/backup: add `openclaw backup create` and `openclaw backup verify` for local state archives, including `--only-config`, `--no-include-workspace`, manifest/payload validation, and backup guidance in destructive flows. (#40163) thanks @shichangs. +- CLI/backup: improve archive naming for date sorting, add config-only backup mode, and harden backup planning, publication, and verification edge cases. (#40163) Thanks @gumadeiras. + +### Breaking ### Fixes +- Docker/runtime image: prune dev dependencies, strip build-only dist metadata for smaller Docker images. (#40307) Thanks @vincentkoc. - Plugins/channel onboarding: prefer bundled channel plugins over duplicate npm-installed copies during onboarding and release-channel sync, preventing bundled plugins from being shadowed by npm installs with the same plugin ID. (#40092) - macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1. - Mattermost replies: keep `root_id` pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda. @@ -27,9 +33,18 @@ Docs: https://docs.openclaw.ai - Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera. - Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii. - ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky. +- Agents/openai-codex: normalize `gpt-5.4` fallback transport back to `openai-codex-responses` on `chatgpt.com/backend-api` when config drifts to the generic OpenAI responses endpoint. (#38736) Thanks @0xsline. - Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for `/json/*` tab operations so local `ws://` / `wss://` profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150. - Browser/CDP: rewrite wildcard `ws://0.0.0.0` and `ws://[::]` debugger URLs from remote `/json/version` responses back to the external CDP host/port, fixing Browserless-style container endpoints. (#17760) Thanks @joeharouni. +- Browser/extension relay: wait briefly for a previously attached Chrome tab to reappear after transient relay drops before failing with `tab not found`, reducing noisy reconnect flakes. (#32461) Thanks @AaronWander. +- Browser/extension relay: add `browser.relayBindHost` so the Chrome relay can bind to an explicit non-loopback address for WSL2 and other cross-namespace setups, while preserving loopback-only defaults. (#39364) Thanks @mvanhorn. +- Docs/browser: add a layered WSL2 + Windows remote Chrome CDP troubleshooting guide, including Control UI origin pitfalls and extension-relay bind-address guidance. (#39407) Thanks @Owlock. - Context engine registry/bundled builds: share the registry state through a `globalThis` singleton so duplicated bundled module copies can resolve engines registered by each other at runtime, with regression coverage for duplicate-module imports. (#40115) thanks @jalehman. +- macOS/Tailscale gateway discovery: keep Tailscale Serve probing alive when other remote gateways are already discovered, prefer direct transport for resolved `.ts.net` and Tailscale Serve gateways, and set `TERM=dumb` for GUI-launched Tailscale CLI discovery. (#40167) thanks @ngutman. +- Podman/setup: fix `cannot chdir: Permission denied` in `run_as_user` when `setup-podman.sh` is invoked from a directory the target user cannot access, by wrapping user-switch calls in a subshell that cd's to `/tmp` with `/` fallback. (#39435) Thanks @langdon and @jlcbk. +- Podman/SELinux: auto-detect SELinux enforcing/permissive mode and add `:Z` relabel to bind mounts in `run-openclaw-podman.sh` and the Quadlet template, fixing `EACCES` on Fedora/RHEL hosts. Supports `OPENCLAW_BIND_MOUNT_OPTIONS` override. (#39449) Thanks @langdon and @githubbzxs. +- TUI/theme: detect light terminal backgrounds via `COLORFGBG` and pick a WCAG AA-compliant light palette, with `OPENCLAW_THEME=light|dark` override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc. +- Agents/context-engine plugins: bootstrap runtime plugins once at embedded-run, compaction, and subagent boundaries so plugin-provided context engines and hooks load from the active workspace before runtime resolution. (#40232) ## 2026.3.7 @@ -115,6 +130,7 @@ Docs: https://docs.openclaw.ai - Agents/compaction safeguard pre-check: skip embedded compaction before entering the Pi SDK when a session has no real conversation messages, avoiding unnecessary LLM API calls on idle sessions. (#36451) thanks @Sid-Qin. - Config/schema cache key stability: build merged schema cache keys with incremental hashing to avoid large single-string serialization and prevent `RangeError: Invalid string length` on high-cardinality plugin/channel metadata. (#36603) Thanks @powermaster888. - iMessage/cron completion announces: strip leaked inline reply tags (for example `[[reply_to:6100]]`) from user-visible completion text so announcement deliveries do not expose threading metadata. (#24600) Thanks @vincentkoc. +- Cron/manual run enqueue flow: queue `cron.run` requests behind the cron execution lane, return immediate `{ ok: true, enqueued: true, runId }` acknowledgements, preserve `{ ok: true, ran: false, reason }` skip responses for already-running and not-due jobs, and document the asynchronous completion flow. (#40204) - Control UI/iMessage duplicate reply routing: keep internal webchat turns on dispatcher delivery (instead of origin-channel reroute) so Control UI chats do not duplicate replies into iMessage, while preserving webchat-provider relayed routing for external surfaces. Fixes #33483. Thanks @alicexmolt. - Sessions/daily reset transcript archival: archive prior transcript files during stale-session scheduled/daily resets by capturing the previous session entry before rollover, preventing orphaned transcript files on disk. (#35493) Thanks @byungsker. - Feishu/group slash command detection: normalize group mention wrappers before command-authorization probing so mention-prefixed commands (for example `@Bot/model` and `@Bot /reset`) are recognized as gateway commands instead of being forwarded to the agent. (#35994) Thanks @liuxiaopai-ai. diff --git a/Dockerfile b/Dockerfile index 6b147441e5e..f1d7163d192 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,6 +58,15 @@ RUN NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile COPY . . +# Normalize extension paths now so runtime COPY preserves safe modes +# without adding a second full extensions layer. +RUN for dir in /app/extensions /app/.agent /app/.agents; do \ + if [ -d "$dir" ]; then \ + find "$dir" -type d -exec chmod 755 {} +; \ + find "$dir" -type f -exec chmod 644 {} +; \ + fi; \ + done + # A2UI bundle may fail under QEMU cross-compilation (e.g. building amd64 # on Apple Silicon). CI builds natively per-arch so this is a no-op there. # Stub it so local cross-arch builds still succeed. @@ -67,11 +76,17 @@ RUN pnpm canvas:a2ui:bundle || \ echo "/* A2UI bundle unavailable in this build */" > src/canvas-host/a2ui/a2ui.bundle.js && \ echo "stub" > src/canvas-host/a2ui/.bundle.hash && \ rm -rf vendor/a2ui apps/shared/OpenClawKit/Tools/CanvasA2UI) -RUN pnpm build +RUN pnpm build:docker # Force pnpm for UI build (Bun may fail on ARM/Synology architectures) ENV OPENCLAW_PREFER_PNPM=1 RUN pnpm ui:build +# Prune dev dependencies and strip build-only metadata before copying +# runtime assets into the final image. +FROM build AS runtime-assets +RUN CI=true pnpm prune --prod && \ + find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete + # ── Runtime base images ───────────────────────────────────────── FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS base-default ARG OPENCLAW_NODE_BOOKWORM_DIGEST @@ -110,19 +125,22 @@ RUN apt-get update && \ RUN chown node:node /app -COPY --from=build --chown=node:node /app/dist ./dist -COPY --from=build --chown=node:node /app/node_modules ./node_modules -COPY --from=build --chown=node:node /app/package.json . -COPY --from=build --chown=node:node /app/openclaw.mjs . -COPY --from=build --chown=node:node /app/extensions ./extensions -COPY --from=build --chown=node:node /app/skills ./skills -COPY --from=build --chown=node:node /app/docs ./docs +COPY --from=runtime-assets --chown=node:node /app/dist ./dist +COPY --from=runtime-assets --chown=node:node /app/node_modules ./node_modules +COPY --from=runtime-assets --chown=node:node /app/package.json . +COPY --from=runtime-assets --chown=node:node /app/openclaw.mjs . +COPY --from=runtime-assets --chown=node:node /app/extensions ./extensions +COPY --from=runtime-assets --chown=node:node /app/skills ./skills +COPY --from=runtime-assets --chown=node:node /app/docs ./docs -# Docker live-test runners invoke `pnpm` inside the runtime image. -# Activate the exact pinned package manager now so the container does not -# rely on a first-run network fetch or missing shims under the non-root user. -RUN corepack enable && \ - corepack prepare "$(node -p "require('./package.json').packageManager")" --activate +# Keep pnpm available in the runtime image for container-local workflows. +# Use a shared Corepack home so the non-root `node` user does not need a +# first-run network fetch when invoking pnpm. +ENV COREPACK_HOME=/usr/local/share/corepack +RUN install -d -m 0755 "$COREPACK_HOME" && \ + corepack enable && \ + corepack prepare "$(node -p "require('./package.json').packageManager")" --activate && \ + chmod -R a+rX "$COREPACK_HOME" # Install additional system packages needed by your skills or extensions. # Example: docker build --build-arg OPENCLAW_DOCKER_APT_PACKAGES="python3 wget" . @@ -182,15 +200,6 @@ RUN if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \ rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ fi -# Normalize extension paths so plugin safety checks do not reject -# world-writable directories inherited from source file modes. -RUN for dir in /app/extensions /app/.agent /app/.agents; do \ - if [ -d "$dir" ]; then \ - find "$dir" -type d -exec chmod 755 {} +; \ - find "$dir" -type f -exec chmod 644 {} +; \ - fi; \ - done - # Expose the CLI binary without requiring npm global writes as non-root. RUN ln -sf /app/openclaw.mjs /usr/local/bin/openclaw \ && chmod 755 /app/openclaw.mjs diff --git a/Dockerfile.sandbox-browser b/Dockerfile.sandbox-browser index ec9faf71113..78b0de98904 100644 --- a/Dockerfile.sandbox-browser +++ b/Dockerfile.sandbox-browser @@ -20,8 +20,7 @@ RUN apt-get update \ xvfb \ && rm -rf /var/lib/apt/lists/* -COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser -RUN chmod +x /usr/local/bin/openclaw-sandbox-browser +COPY --chmod=755 scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser RUN useradd --create-home --shell /bin/bash sandbox USER sandbox diff --git a/apps/ios/Sources/Model/NodeAppModel+Canvas.swift b/apps/ios/Sources/Model/NodeAppModel+Canvas.swift index 922757a6555..73e13fa0992 100644 --- a/apps/ios/Sources/Model/NodeAppModel+Canvas.swift +++ b/apps/ios/Sources/Model/NodeAppModel+Canvas.swift @@ -1,9 +1,24 @@ import Foundation import Network import OpenClawKit -import os + +enum A2UIReadyState { + case ready(String) + case hostNotConfigured + case hostUnavailable +} extension NodeAppModel { + func resolveCanvasHostURL() async -> String? { + guard let raw = await self.gatewaySession.currentCanvasHostUrl() else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil } + if let host = base.host, LoopbackHost.isLoopback(host) { + return nil + } + return base.appendingPathComponent("__openclaw__/canvas/").absoluteString + } + func _test_resolveA2UIHostURL() async -> String? { await self.resolveA2UIHostURL() } @@ -19,22 +34,14 @@ extension NodeAppModel { } func showA2UIOnConnectIfNeeded() async { - guard let a2uiUrl = await self.resolveA2UIHostURL() else { - await MainActor.run { - self.lastAutoA2uiURL = nil - self.screen.showDefaultCanvas() - } - return - } let current = self.screen.urlString.trimmingCharacters(in: .whitespacesAndNewlines) if current.isEmpty || current == self.lastAutoA2uiURL { - // Avoid navigating the WKWebView to an unreachable host: it leaves a persistent - // "could not connect to the server" overlay even when the gateway is connected. - if let url = URL(string: a2uiUrl), + if let canvasUrl = await self.resolveCanvasHostURLWithCapabilityRefresh(), + let url = URL(string: canvasUrl), await Self.probeTCP(url: url, timeoutSeconds: 2.5) { - self.screen.navigate(to: a2uiUrl) - self.lastAutoA2uiURL = a2uiUrl + self.screen.navigate(to: canvasUrl) + self.lastAutoA2uiURL = canvasUrl } else { self.lastAutoA2uiURL = nil self.screen.showDefaultCanvas() @@ -42,11 +49,46 @@ extension NodeAppModel { } } + func ensureA2UIReadyWithCapabilityRefresh(timeoutMs: Int = 5000) async -> A2UIReadyState { + guard let initialUrl = await self.resolveA2UIHostURLWithCapabilityRefresh() else { + return .hostNotConfigured + } + self.screen.navigate(to: initialUrl) + if await self.screen.waitForA2UIReady(timeoutMs: timeoutMs) { + return .ready(initialUrl) + } + + // First render can fail when scoped capability rotates between reconnects. + guard await self.gatewaySession.refreshNodeCanvasCapability() else { return .hostUnavailable } + guard let refreshedUrl = await self.resolveA2UIHostURL() else { return .hostUnavailable } + self.screen.navigate(to: refreshedUrl) + if await self.screen.waitForA2UIReady(timeoutMs: timeoutMs) { + return .ready(refreshedUrl) + } + return .hostUnavailable + } + func showLocalCanvasOnDisconnect() { self.lastAutoA2uiURL = nil self.screen.showDefaultCanvas() } + private func resolveA2UIHostURLWithCapabilityRefresh() async -> String? { + if let url = await self.resolveA2UIHostURL() { + return url + } + guard await self.gatewaySession.refreshNodeCanvasCapability() else { return nil } + return await self.resolveA2UIHostURL() + } + + private func resolveCanvasHostURLWithCapabilityRefresh() async -> String? { + if let url = await self.resolveCanvasHostURL() { + return url + } + guard await self.gatewaySession.refreshNodeCanvasCapability() else { return nil } + return await self.resolveCanvasHostURL() + } + private static func probeTCP(url: URL, timeoutSeconds: Double) async -> Bool { guard let host = url.host, !host.isEmpty else { return false } let portInt = url.port ?? ((url.scheme ?? "").lowercased() == "wss" ? 443 : 80) diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 34b6876822b..e5a8c216161 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -57,6 +57,7 @@ final class NodeAppModel { private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink") private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake") + private let pendingActionLogger = Logger(subsystem: "ai.openclaw.ios", category: "PendingAction") private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake") private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply") enum CameraHUDKind { @@ -130,6 +131,7 @@ final class NodeAppModel { private var backgroundReconnectLeaseUntil: Date? private var lastSignificantLocationWakeAt: Date? @ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator() + private var pendingForegroundActionDrainInFlight = false private var gatewayConnected = false private var operatorConnected = false @@ -329,6 +331,9 @@ final class NodeAppModel { } await self.talkMode.resumeAfterBackground(wasSuspended: suspended, wasKeptActive: keptActive) } + Task { [weak self] in + await self?.resumePendingForegroundNodeActionsIfNeeded(trigger: "scene_active") + } } if phase == .active, self.reconnectAfterBackgroundArmed { self.reconnectAfterBackgroundArmed = false @@ -877,16 +882,17 @@ final class NodeAppModel { let command = req.command switch command { case OpenClawCanvasA2UICommand.reset.rawValue: - guard let a2uiUrl = await self.resolveA2UIHostURL() else { + switch await self.ensureA2UIReadyWithCapabilityRefresh(timeoutMs: 5000) { + case .ready: + break + case .hostNotConfigured: return BridgeInvokeResponse( id: req.id, ok: false, error: OpenClawNodeError( code: .unavailable, message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host")) - } - self.screen.navigate(to: a2uiUrl) - if await !self.screen.waitForA2UIReady(timeoutMs: 5000) { + case .hostUnavailable: return BridgeInvokeResponse( id: req.id, ok: false, @@ -894,7 +900,6 @@ final class NodeAppModel { code: .unavailable, message: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable")) } - let json = try await self.screen.eval(javaScript: """ (() => { const host = globalThis.openclawA2UI; @@ -903,6 +908,7 @@ final class NodeAppModel { })() """) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + case OpenClawCanvasA2UICommand.push.rawValue, OpenClawCanvasA2UICommand.pushJSONL.rawValue: let messages: [OpenClawKit.AnyCodable] if command == OpenClawCanvasA2UICommand.pushJSONL.rawValue { @@ -919,16 +925,17 @@ final class NodeAppModel { } } - guard let a2uiUrl = await self.resolveA2UIHostURL() else { + switch await self.ensureA2UIReadyWithCapabilityRefresh(timeoutMs: 5000) { + case .ready: + break + case .hostNotConfigured: return BridgeInvokeResponse( id: req.id, ok: false, error: OpenClawNodeError( code: .unavailable, message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host")) - } - self.screen.navigate(to: a2uiUrl) - if await !self.screen.waitForA2UIReady(timeoutMs: 5000) { + case .hostUnavailable: return BridgeInvokeResponse( id: req.id, ok: false, @@ -2098,6 +2105,22 @@ private extension NodeAppModel { } extension NodeAppModel { + private struct PendingForegroundNodeAction: Decodable { + var id: String + var command: String + var paramsJSON: String? + var enqueuedAtMs: Int? + } + + private struct PendingForegroundNodeActionsResponse: Decodable { + var nodeId: String? + var actions: [PendingForegroundNodeAction] + } + + private struct PendingForegroundNodeActionsAckRequest: Encodable { + var ids: [String] + } + private func refreshShareRouteFromGateway() async { struct Params: Codable { var includeGlobal: Bool @@ -2195,6 +2218,83 @@ extension NodeAppModel { func onNodeGatewayConnected() async { await self.registerAPNsTokenIfNeeded() await self.flushQueuedWatchRepliesIfConnected() + await self.resumePendingForegroundNodeActionsIfNeeded(trigger: "node_connected") + } + + private func resumePendingForegroundNodeActionsIfNeeded(trigger: String) async { + guard !self.isBackgrounded else { return } + guard await self.isGatewayConnected() else { return } + guard !self.pendingForegroundActionDrainInFlight else { return } + + self.pendingForegroundActionDrainInFlight = true + defer { self.pendingForegroundActionDrainInFlight = false } + + do { + let payload = try await self.nodeGateway.request( + method: "node.pending.pull", + paramsJSON: "{}", + timeoutSeconds: 6) + let decoded = try JSONDecoder().decode( + PendingForegroundNodeActionsResponse.self, + from: payload) + guard !decoded.actions.isEmpty else { return } + self.pendingActionLogger.info( + "Pending actions pulled trigger=\(trigger, privacy: .public) " + + "count=\(decoded.actions.count, privacy: .public)") + await self.applyPendingForegroundNodeActions(decoded.actions, trigger: trigger) + } catch { + // Best-effort only. + } + } + + private func applyPendingForegroundNodeActions( + _ actions: [PendingForegroundNodeAction], + trigger: String) async + { + for action in actions { + guard !self.isBackgrounded else { + self.pendingActionLogger.info( + "Pending action replay paused trigger=\(trigger, privacy: .public): app backgrounded") + return + } + let req = BridgeInvokeRequest( + id: action.id, + command: action.command, + paramsJSON: action.paramsJSON) + let result = await self.handleInvoke(req) + self.pendingActionLogger.info( + "Pending action replay trigger=\(trigger, privacy: .public) " + + "id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) " + + "ok=\(result.ok, privacy: .public)") + guard result.ok else { return } + let acked = await self.ackPendingForegroundNodeAction( + id: action.id, + trigger: trigger, + command: action.command) + guard acked else { return } + } + } + + private func ackPendingForegroundNodeAction( + id: String, + trigger: String, + command: String) async -> Bool + { + do { + let payload = try JSONEncoder().encode(PendingForegroundNodeActionsAckRequest(ids: [id])) + let paramsJSON = String(decoding: payload, as: UTF8.self) + _ = try await self.nodeGateway.request( + method: "node.pending.ack", + paramsJSON: paramsJSON, + timeoutSeconds: 6) + return true + } catch { + self.pendingActionLogger.error( + "Pending action ack failed trigger=\(trigger, privacy: .public) " + + "id=\(id, privacy: .public) command=\(command, privacy: .public) " + + "error=\(String(describing: error), privacy: .public)") + return false + } } private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async { @@ -2843,6 +2943,19 @@ extension NodeAppModel { self.gatewayConnected = connected } + func _test_applyPendingForegroundNodeActions( + _ actions: [(id: String, command: String, paramsJSON: String?)]) async + { + let mapped = actions.map { action in + PendingForegroundNodeAction( + id: action.id, + command: action.command, + paramsJSON: action.paramsJSON, + enqueuedAtMs: nil) + } + await self.applyPendingForegroundNodeActions(mapped, trigger: "test") + } + static func _test_currentDeepLinkKey() -> String { self.expectedDeepLinkKey() } diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index 2875fa31339..7413b0295f9 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -179,6 +179,41 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer #expect(payload?["result"] as? String == "2") } + @Test @MainActor func pendingForegroundActionsReplayCanvasNavigate() async throws { + let appModel = NodeAppModel() + let navigateParams = OpenClawCanvasNavigateParams(url: "http://example.com/") + let navData = try JSONEncoder().encode(navigateParams) + let navJSON = String(decoding: navData, as: UTF8.self) + + await appModel._test_applyPendingForegroundNodeActions([ + ( + id: "pending-nav-1", + command: OpenClawCanvasCommand.navigate.rawValue, + paramsJSON: navJSON + ), + ]) + + #expect(appModel.screen.urlString == "http://example.com/") + } + + @Test @MainActor func pendingForegroundActionsDoNotApplyWhileBackgrounded() async throws { + let appModel = NodeAppModel() + appModel.setScenePhase(.background) + let navigateParams = OpenClawCanvasNavigateParams(url: "http://example.com/") + let navData = try JSONEncoder().encode(navigateParams) + let navJSON = String(decoding: navData, as: UTF8.self) + + await appModel._test_applyPendingForegroundNodeActions([ + ( + id: "pending-nav-bg", + command: OpenClawCanvasCommand.navigate.rawValue, + paramsJSON: navJSON + ), + ]) + + #expect(appModel.screen.urlString.isEmpty) + } + @Test @MainActor func handleInvokeA2UICommandsFailWhenHostMissing() async throws { let appModel = NodeAppModel() diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index ef4917e7768..5e8238ebe92 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -9,6 +9,7 @@ import SwiftUI final class AppState { private let isPreview: Bool private var isInitializing = true + private var isApplyingRemoteTokenConfig = false private var configWatcher: ConfigFileWatcher? private var suppressVoiceWakeGlobalSync = false private var voiceWakeGlobalSyncTask: Task? @@ -213,6 +214,18 @@ final class AppState { didSet { self.syncGatewayConfigIfNeeded() } } + var remoteToken: String { + didSet { + guard !self.isApplyingRemoteTokenConfig else { return } + self.remoteTokenDirty = true + self.remoteTokenUnsupported = false + self.syncGatewayConfigIfNeeded() + } + } + + private(set) var remoteTokenDirty = false + private(set) var remoteTokenUnsupported = false + var remoteIdentity: String { didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } } } @@ -281,6 +294,7 @@ final class AppState { let configRoot = OpenClawConfigFile.loadDict() let configRemoteUrl = GatewayRemoteConfig.resolveUrlString(root: configRoot) + let configRemoteToken = GatewayRemoteConfig.resolveTokenValue(root: configRoot) let configRemoteTransport = GatewayRemoteConfig.resolveTransport(root: configRoot) let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode self.remoteTransport = configRemoteTransport @@ -297,6 +311,9 @@ final class AppState { self.remoteTarget = storedRemoteTarget } self.remoteUrl = configRemoteUrl ?? "" + self.remoteToken = configRemoteToken.textFieldValue + self.remoteTokenDirty = false + self.remoteTokenUnsupported = configRemoteToken.isUnsupportedNonString self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? "" self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? "" @@ -374,13 +391,29 @@ final class AppState { return false } + private func applyRemoteTokenState(_ tokenValue: GatewayRemoteConfig.TokenValue) { + let nextToken = tokenValue.textFieldValue + let unsupported = tokenValue.isUnsupportedNonString + guard self.remoteToken != nextToken || self.remoteTokenDirty || self.remoteTokenUnsupported != unsupported + else { + return + } + self.isApplyingRemoteTokenConfig = true + self.remoteToken = nextToken + self.isApplyingRemoteTokenConfig = false + self.remoteTokenDirty = false + self.remoteTokenUnsupported = unsupported + } + private static func updatedRemoteGatewayConfig( current: [String: Any], transport: RemoteTransport, remoteUrl: String, remoteHost: String?, remoteTarget: String, - remoteIdentity: String) -> (remote: [String: Any], changed: Bool) + remoteIdentity: String, + remoteToken: String, + remoteTokenDirty: Bool) -> (remote: [String: Any], changed: Bool) { var remote = current var changed = false @@ -417,6 +450,10 @@ final class AppState { changed = Self.updateGatewayString(&remote, key: "sshIdentity", value: remoteIdentity) || changed } + if remoteTokenDirty { + changed = Self.updateGatewayString(&remote, key: "token", value: remoteToken) || changed + } + return (remote, changed) } @@ -439,6 +476,7 @@ final class AppState { let gateway = root["gateway"] as? [String: Any] let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let remoteUrl = GatewayRemoteConfig.resolveUrlString(root: root) + let remoteToken = GatewayRemoteConfig.resolveTokenValue(root: root) let hasRemoteUrl = !(remoteUrl? .trimmingCharacters(in: .whitespacesAndNewlines) .isEmpty ?? true) @@ -470,6 +508,7 @@ final class AppState { if remoteUrlText != self.remoteUrl { self.remoteUrl = remoteUrlText } + self.applyRemoteTokenState(remoteToken) let targetMode = desiredMode ?? self.connectionMode if targetMode == .remote, @@ -496,14 +535,20 @@ final class AppState { } } - private func syncGatewayConfigIfNeeded() { - guard !self.isPreview, !self.isInitializing else { return } + private static func syncedGatewayRoot( + currentRoot: [String: Any], + connectionMode: ConnectionMode, + remoteTransport: RemoteTransport, + remoteTarget: String, + remoteIdentity: String, + remoteUrl: String, + remoteToken: String, + remoteTokenDirty: Bool) -> (root: [String: Any], changed: Bool) + { + var root = currentRoot + var gateway = root["gateway"] as? [String: Any] ?? [:] + var changed = false - let connectionMode = self.connectionMode - let remoteTarget = self.remoteTarget - let remoteIdentity = self.remoteIdentity - let remoteTransport = self.remoteTransport - let remoteUrl = self.remoteUrl let desiredMode: String? = switch connectionMode { case .local: "local" @@ -512,49 +557,70 @@ final class AppState { case .unconfigured: nil } - let remoteHost = connectionMode == .remote - ? CommandResolver.parseSSHTarget(remoteTarget)?.host - : nil + + let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + if let desiredMode { + if currentMode != desiredMode { + gateway["mode"] = desiredMode + changed = true + } + } else if currentMode != nil { + gateway.removeValue(forKey: "mode") + changed = true + } + + if connectionMode == .remote { + let remoteHost = CommandResolver.parseSSHTarget(remoteTarget)?.host + let currentRemote = gateway["remote"] as? [String: Any] ?? [:] + let updated = Self.updatedRemoteGatewayConfig( + current: currentRemote, + transport: remoteTransport, + remoteUrl: remoteUrl, + remoteHost: remoteHost, + remoteTarget: remoteTarget, + remoteIdentity: remoteIdentity, + remoteToken: remoteToken, + remoteTokenDirty: remoteTokenDirty) + if updated.changed { + gateway["remote"] = updated.remote + changed = true + } + } + + guard changed else { return (currentRoot, false) } + + if gateway.isEmpty { + root.removeValue(forKey: "gateway") + } else { + root["gateway"] = gateway + } + return (root, true) + } + + private func syncGatewayConfigIfNeeded() { + guard !self.isPreview, !self.isInitializing else { return } + + let connectionMode = self.connectionMode + let remoteTarget = self.remoteTarget + let remoteIdentity = self.remoteIdentity + let remoteTransport = self.remoteTransport + let remoteUrl = self.remoteUrl + let remoteToken = self.remoteToken + let remoteTokenDirty = self.remoteTokenDirty Task { @MainActor in // Keep app-only connection settings local to avoid overwriting remote gateway config. - var root = OpenClawConfigFile.loadDict() - var gateway = root["gateway"] as? [String: Any] ?? [:] - var changed = false - - let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - if let desiredMode { - if currentMode != desiredMode { - gateway["mode"] = desiredMode - changed = true - } - } else if currentMode != nil { - gateway.removeValue(forKey: "mode") - changed = true - } - - if connectionMode == .remote { - let currentRemote = gateway["remote"] as? [String: Any] ?? [:] - let updated = Self.updatedRemoteGatewayConfig( - current: currentRemote, - transport: remoteTransport, - remoteUrl: remoteUrl, - remoteHost: remoteHost, - remoteTarget: remoteTarget, - remoteIdentity: remoteIdentity) - if updated.changed { - gateway["remote"] = updated.remote - changed = true - } - } - - guard changed else { return } - if gateway.isEmpty { - root.removeValue(forKey: "gateway") - } else { - root["gateway"] = gateway - } - OpenClawConfigFile.saveDict(root) + let synced = Self.syncedGatewayRoot( + currentRoot: OpenClawConfigFile.loadDict(), + connectionMode: connectionMode, + remoteTransport: remoteTransport, + remoteTarget: remoteTarget, + remoteIdentity: remoteIdentity, + remoteUrl: remoteUrl, + remoteToken: remoteToken, + remoteTokenDirty: remoteTokenDirty) + guard synced.changed else { return } + OpenClawConfigFile.saveDict(synced.root) } } @@ -697,6 +763,7 @@ extension AppState { state.canvasEnabled = true state.remoteTarget = "user@example.com" state.remoteUrl = "wss://gateway.example.ts.net" + state.remoteToken = "example-token" state.remoteIdentity = "~/.ssh/id_ed25519" state.remoteProjectRoot = "~/Projects/openclaw" state.remoteCliPath = "" @@ -704,6 +771,53 @@ extension AppState { } } +#if DEBUG +@MainActor +extension AppState { + static func _testUpdatedRemoteGatewayConfig( + current: [String: Any], + transport: RemoteTransport, + remoteUrl: String, + remoteHost: String?, + remoteTarget: String, + remoteIdentity: String, + remoteToken: String, + remoteTokenDirty: Bool) -> [String: Any] + { + Self.updatedRemoteGatewayConfig( + current: current, + transport: transport, + remoteUrl: remoteUrl, + remoteHost: remoteHost, + remoteTarget: remoteTarget, + remoteIdentity: remoteIdentity, + remoteToken: remoteToken, + remoteTokenDirty: remoteTokenDirty).remote + } + + static func _testSyncedGatewayRoot( + currentRoot: [String: Any], + connectionMode: ConnectionMode, + remoteTransport: RemoteTransport, + remoteTarget: String, + remoteIdentity: String, + remoteUrl: String, + remoteToken: String, + remoteTokenDirty: Bool) -> [String: Any] + { + Self.syncedGatewayRoot( + currentRoot: currentRoot, + connectionMode: connectionMode, + remoteTransport: remoteTransport, + remoteTarget: remoteTarget, + remoteIdentity: remoteIdentity, + remoteUrl: remoteUrl, + remoteToken: remoteToken, + remoteTokenDirty: remoteTokenDirty).root + } +} +#endif + @MainActor enum AppStateStore { static let shared = AppState() diff --git a/apps/macos/Sources/OpenClaw/GatewayDiscoverySelectionSupport.swift b/apps/macos/Sources/OpenClaw/GatewayDiscoverySelectionSupport.swift index ea7492b2c79..99bb654526b 100644 --- a/apps/macos/Sources/OpenClaw/GatewayDiscoverySelectionSupport.swift +++ b/apps/macos/Sources/OpenClaw/GatewayDiscoverySelectionSupport.swift @@ -6,11 +6,16 @@ enum GatewayDiscoverySelectionSupport { gateway: GatewayDiscoveryModel.DiscoveredGateway, state: AppState) { - if state.remoteTransport == .direct { - state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "" - } else { - state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? "" + let preferredTransport = self.preferredTransport( + for: gateway, + current: state.remoteTransport) + if preferredTransport != state.remoteTransport { + state.remoteTransport = preferredTransport } + + state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "" + state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? "" + if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) { OpenClawConfigFile.setRemoteGatewayUrl( host: endpoint.host, @@ -19,4 +24,30 @@ enum GatewayDiscoverySelectionSupport { OpenClawConfigFile.clearRemoteGatewayUrl() } } + + static func preferredTransport( + for gateway: GatewayDiscoveryModel.DiscoveredGateway, + current: AppState.RemoteTransport) -> AppState.RemoteTransport + { + if self.shouldPreferDirectTransport(for: gateway) { + return .direct + } + return current + } + + static func shouldPreferDirectTransport( + for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool + { + guard GatewayDiscoveryHelpers.directUrl(for: gateway) != nil else { return false } + if gateway.stableID.hasPrefix("tailscale-serve|") { + return true + } + guard let host = GatewayDiscoveryHelpers.resolvedServiceHost(for: gateway)? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + else { + return false + } + return host.hasSuffix(".ts.net") + } } diff --git a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift index 86fa9828baf..2d923a5ea9e 100644 --- a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift +++ b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift @@ -188,13 +188,7 @@ actor GatewayEndpointStore { private static func resolveConfigToken(isRemote: Bool, root: [String: Any]) -> String? { if isRemote { - if let gateway = root["gateway"] as? [String: Any], - let remote = gateway["remote"] as? [String: Any], - let token = remote["token"] as? String - { - return token.trimmingCharacters(in: .whitespacesAndNewlines) - } - return nil + return GatewayRemoteConfig.resolveTokenString(root: root) } if let gateway = root["gateway"] as? [String: Any], diff --git a/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift b/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift index 3d044bcda2f..4eee8165d52 100644 --- a/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift +++ b/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift @@ -2,6 +2,28 @@ import Foundation import OpenClawKit enum GatewayRemoteConfig { + enum TokenValue: Equatable { + case missing + case plaintext(String) + case unsupportedNonString + + var textFieldValue: String { + switch self { + case let .plaintext(token): + token + case .missing, .unsupportedNonString: + "" + } + } + + var isUnsupportedNonString: Bool { + if case .unsupportedNonString = self { + return true + } + return false + } + } + static func resolveTransport(root: [String: Any]) -> AppState.RemoteTransport { guard let gateway = root["gateway"] as? [String: Any], let remote = gateway["remote"] as? [String: Any], @@ -24,6 +46,29 @@ enum GatewayRemoteConfig { return trimmed.isEmpty ? nil : trimmed } + static func resolveTokenValue(root: [String: Any]) -> TokenValue { + guard let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let tokenRaw = remote["token"] + else { + return .missing + } + guard let tokenString = tokenRaw as? String else { + return .unsupportedNonString + } + let trimmed = tokenString.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? .missing : .plaintext(trimmed) + } + + static func resolveTokenString(root: [String: Any]) -> String? { + switch self.resolveTokenValue(root: root) { + case let .plaintext(token): + token + case .missing, .unsupportedNonString: + nil + } + } + static func resolveGatewayUrl(root: [String: Any]) -> URL? { guard let raw = self.resolveUrlString(root: root) else { return nil } return self.normalizeGatewayUrl(raw) diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift index bdf02d94992..b55ed439489 100644 --- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift +++ b/apps/macos/Sources/OpenClaw/GeneralSettings.swift @@ -149,6 +149,7 @@ struct GeneralSettings: View { } else { self.remoteDirectRow } + self.remoteTokenRow GatewayDiscoveryInlineList( discovery: self.gatewayDiscovery, @@ -291,6 +292,30 @@ struct GeneralSettings: View { } } + private var remoteTokenRow: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .center, spacing: 10) { + Text("Gateway token") + .font(.callout.weight(.semibold)) + .frame(width: self.remoteLabelWidth, alignment: .leading) + SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + } + Text("Used when the remote gateway requires token auth.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, self.remoteLabelWidth + 10) + if self.state.remoteTokenUnsupported { + Text( + "The current gateway.remote.token value is not plain text. OpenClaw for macOS cannot use it directly; enter a plaintext token here to replace it.") + .font(.caption) + .foregroundStyle(.orange) + .padding(.leading, self.remoteLabelWidth + 10) + } + } + } + private func remoteTestButton(disabled: Bool) -> some View { Button { Task { await self.testRemote() } @@ -692,6 +717,7 @@ extension GeneralSettings { state.remoteTransport = .ssh state.remoteTarget = "user@host:2222" state.remoteUrl = "wss://gateway.example.ts.net" + state.remoteToken = "example-token" state.remoteIdentity = "/tmp/id_ed25519" state.remoteProjectRoot = "/tmp/openclaw" state.remoteCliPath = "/tmp/openclaw" diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 41d28b49092..8f4d16420bc 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -199,6 +199,25 @@ 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") diff --git a/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift b/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift index 9e9d43388eb..9d3c5953261 100644 --- a/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift +++ b/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift @@ -338,13 +338,12 @@ public final class GatewayDiscoveryModel { var attempt = 0 let startedAt = Date() while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 { - let hasResults = await MainActor.run { - if self.filterLocalGateways { - return !self.gateways.isEmpty - } - return self.gateways.contains(where: { !$0.isLocal }) + let shouldContinue = await MainActor.run { + Self.shouldContinueTailscaleServeDiscovery( + currentGateways: self.gateways, + tailscaleServeGateways: self.tailscaleServeFallbackGateways) } - if hasResults { return } + if !shouldContinue { return } let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: 2.4) if !beacons.isEmpty { @@ -363,6 +362,15 @@ public final class GatewayDiscoveryModel { } } + static func shouldContinueTailscaleServeDiscovery( + currentGateways _: [DiscoveredGateway], + tailscaleServeGateways: [DiscoveredGateway]) -> Bool + { + // Tailscale Serve is a parallel discovery source. DNS-SD results should not suppress the + // probe, otherwise Serve-only gateways disappear as soon as any other remote gateway is found. + tailscaleServeGateways.isEmpty + } + private var hasUsableWideAreaResults: Bool { guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return false } guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false } diff --git a/apps/macos/Sources/OpenClawDiscovery/TailscaleServeGatewayDiscovery.swift b/apps/macos/Sources/OpenClawDiscovery/TailscaleServeGatewayDiscovery.swift index 0994c262ede..5e7f89fdf45 100644 --- a/apps/macos/Sources/OpenClawDiscovery/TailscaleServeGatewayDiscovery.swift +++ b/apps/macos/Sources/OpenClawDiscovery/TailscaleServeGatewayDiscovery.swift @@ -203,6 +203,7 @@ enum TailscaleServeGatewayDiscovery { let process = Process() process.executableURL = URL(fileURLWithPath: path) process.arguments = args + process.environment = self.commandEnvironment() let outPipe = Pipe() process.standardOutput = outPipe process.standardError = FileHandle.nullDevice @@ -227,6 +228,19 @@ enum TailscaleServeGatewayDiscovery { return output?.isEmpty == false ? output : nil } + static func commandEnvironment( + base: [String: String] = ProcessInfo.processInfo.environment) -> [String: String] + { + var env = base + let term = env["TERM"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if term.isEmpty { + // The macOS Tailscale app binary exits with CLIError error 3 when TERM is missing, + // which is common for GUI-launched app environments. + env["TERM"] = "dumb" + } + return env + } + private static func parseStatus(_ raw: String) -> TailscaleStatus? { guard let data = raw.data(using: .utf8) else { return nil } return try? JSONDecoder().decode(TailscaleStatus.self, from: data) diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 6aad2e9a9ac..ea44d030eb0 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -836,6 +836,20 @@ public struct NodeRenameParams: Codable, Sendable { public struct NodeListParams: Codable, Sendable {} +public struct NodePendingAckParams: Codable, Sendable { + public let ids: [String] + + public init( + ids: [String]) + { + self.ids = ids + } + + private enum CodingKeys: String, CodingKey { + case ids + } +} + public struct NodeDescribeParams: Codable, Sendable { public let nodeid: String diff --git a/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift b/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift new file mode 100644 index 00000000000..16fb5eed1a0 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift @@ -0,0 +1,128 @@ +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct AppStateRemoteConfigTests { + @Test + func updatedRemoteGatewayConfigSetsTrimmedToken() { + let remote = AppState._testUpdatedRemoteGatewayConfig( + current: [:], + transport: .ssh, + remoteUrl: "", + remoteHost: "gateway.example", + remoteTarget: "alice@gateway.example", + remoteIdentity: "/tmp/id_ed25519", + remoteToken: " secret-token ", + remoteTokenDirty: true) + + #expect(remote["token"] as? String == "secret-token") + } + + @Test + func updatedRemoteGatewayConfigClearsTokenWhenBlank() { + let remote = AppState._testUpdatedRemoteGatewayConfig( + current: ["token": "old-token"], + transport: .direct, + remoteUrl: "wss://gateway.example", + remoteHost: nil, + remoteTarget: "", + remoteIdentity: "", + remoteToken: " ", + remoteTokenDirty: true) + + #expect((remote["token"] as? String) == nil) + } + + @Test + func syncedGatewayRootPreservesObjectTokenAcrossModeAndTransportChangesWhenUntouched() { + let initialRoot: [String: Any] = [ + "gateway": [ + "mode": "remote", + "remote": [ + "transport": "direct", + "url": "wss://old-gateway.example", + "token": [ + "$secretRef": "gateway-token", // pragma: allowlist secret + ], + ], + ], + ] + + let sshRoot = AppState._testSyncedGatewayRoot( + currentRoot: initialRoot, + connectionMode: .remote, + remoteTransport: .ssh, + remoteTarget: "alice@gateway.example", + remoteIdentity: "", + remoteUrl: "", + remoteToken: "", + remoteTokenDirty: false) + let sshRemote = (sshRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any] + #expect((sshRemote?["token"] as? [String: String])?["$secretRef"] == "gateway-token") // pragma: allowlist secret + + let localRoot = AppState._testSyncedGatewayRoot( + currentRoot: sshRoot, + connectionMode: .local, + remoteTransport: .ssh, + remoteTarget: "", + remoteIdentity: "", + remoteUrl: "", + remoteToken: "", + remoteTokenDirty: false) + let localGateway = localRoot["gateway"] as? [String: Any] + let localRemote = localGateway?["remote"] as? [String: Any] + #expect(localGateway?["mode"] as? String == "local") + #expect((localRemote?["token"] as? [String: String])?["$secretRef"] == "gateway-token") // pragma: allowlist secret + } + + @Test + func updatedRemoteGatewayConfigReplacesObjectTokenWhenUserEntersPlaintext() { + let remote = AppState._testUpdatedRemoteGatewayConfig( + current: [ + "token": [ + "$secretRef": "gateway-token", // pragma: allowlist secret + ], + ], + transport: .direct, + remoteUrl: "wss://gateway.example", + remoteHost: nil, + remoteTarget: "", + remoteIdentity: "", + remoteToken: " fresh-token ", + remoteTokenDirty: true) + + #expect(remote["token"] as? String == "fresh-token") + } + + @Test + func updatedRemoteGatewayConfigClearsObjectTokenOnlyAfterExplicitEdit() { + let current: [String: Any] = [ + "token": [ + "$secretRef": "gateway-token", // pragma: allowlist secret + ], + ] + + let preserved = AppState._testUpdatedRemoteGatewayConfig( + current: current, + transport: .direct, + remoteUrl: "wss://gateway.example", + remoteHost: nil, + remoteTarget: "", + remoteIdentity: "", + remoteToken: "", + remoteTokenDirty: false) + #expect((preserved["token"] as? [String: String])?["$secretRef"] == "gateway-token") // pragma: allowlist secret + + let cleared = AppState._testUpdatedRemoteGatewayConfig( + current: current, + transport: .direct, + remoteUrl: "wss://gateway.example", + remoteHost: nil, + remoteTarget: "", + remoteIdentity: "", + remoteToken: " ", + remoteTokenDirty: true) + #expect((cleared["token"] as? String) == nil) + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryModelTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryModelTests.swift index 96992b324eb..55a6b25f81e 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryModelTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryModelTests.swift @@ -121,6 +121,56 @@ struct GatewayDiscoveryModelTests { port: 2201) == "peter@studio.local:2201") } + @Test func `tailscale serve discovery continues when DNS-SD already found a remote gateway`() { + let dnsSdGateway = GatewayDiscoveryModel.DiscoveredGateway( + displayName: "Nearby Gateway", + serviceHost: "nearby-gateway.local", + servicePort: 18789, + lanHost: "nearby-gateway.local", + tailnetDns: nil, + sshPort: 22, + gatewayPort: 18789, + cliPath: nil, + stableID: "bonjour|nearby-gateway", + debugID: "bonjour", + isLocal: false) + + #expect(GatewayDiscoveryModel.shouldContinueTailscaleServeDiscovery( + currentGateways: [dnsSdGateway], + tailscaleServeGateways: [])) + } + + @Test func `tailscale serve discovery stops after serve result is found`() { + let dnsSdGateway = GatewayDiscoveryModel.DiscoveredGateway( + displayName: "Nearby Gateway", + serviceHost: "nearby-gateway.local", + servicePort: 18789, + lanHost: "nearby-gateway.local", + tailnetDns: nil, + sshPort: 22, + gatewayPort: 18789, + cliPath: nil, + stableID: "bonjour|nearby-gateway", + debugID: "bonjour", + isLocal: false) + let serveGateway = GatewayDiscoveryModel.DiscoveredGateway( + displayName: "Tailscale Gateway", + serviceHost: "gateway-host.tailnet-example.ts.net", + servicePort: 443, + lanHost: nil, + tailnetDns: "gateway-host.tailnet-example.ts.net", + sshPort: 22, + gatewayPort: 443, + cliPath: nil, + stableID: "tailscale-serve|gateway-host.tailnet-example.ts.net", + debugID: "serve", + isLocal: false) + + #expect(!GatewayDiscoveryModel.shouldContinueTailscaleServeDiscovery( + currentGateways: [dnsSdGateway], + tailscaleServeGateways: [serveGateway])) + } + @Test func `dedupe key prefers resolved endpoint across sources`() { let wideArea = GatewayDiscoveryModel.DiscoveredGateway( displayName: "Gateway", diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoverySelectionSupportTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoverySelectionSupportTests.swift new file mode 100644 index 00000000000..fcfad8d9d85 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoverySelectionSupportTests.swift @@ -0,0 +1,90 @@ +import Foundation +import OpenClawDiscovery +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct GatewayDiscoverySelectionSupportTests { + private func makeGateway( + serviceHost: String?, + servicePort: Int?, + tailnetDns: String? = nil, + sshPort: Int = 22, + stableID: String) -> GatewayDiscoveryModel.DiscoveredGateway + { + GatewayDiscoveryModel.DiscoveredGateway( + displayName: "Gateway", + serviceHost: serviceHost, + servicePort: servicePort, + lanHost: nil, + tailnetDns: tailnetDns, + sshPort: sshPort, + gatewayPort: servicePort, + cliPath: nil, + stableID: stableID, + debugID: UUID().uuidString, + isLocal: false) + } + + @Test func `selecting tailscale serve gateway switches to direct transport`() async { + let tailnetHost = "gateway-host.tailnet-example.ts.net" + let configPath = TestIsolation.tempConfigPath() + await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) { + let state = AppState(preview: true) + state.remoteTransport = .ssh + state.remoteTarget = "user@old-host" + + GatewayDiscoverySelectionSupport.applyRemoteSelection( + gateway: self.makeGateway( + serviceHost: tailnetHost, + servicePort: 443, + tailnetDns: tailnetHost, + stableID: "tailscale-serve|\(tailnetHost)"), + state: state) + + #expect(state.remoteTransport == .direct) + #expect(state.remoteUrl == "wss://\(tailnetHost)") + #expect(CommandResolver.parseSSHTarget(state.remoteTarget)?.host == tailnetHost) + } + } + + @Test func `selecting merged tailnet gateway still switches to direct transport`() async { + let tailnetHost = "gateway-host.tailnet-example.ts.net" + let configPath = TestIsolation.tempConfigPath() + await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) { + let state = AppState(preview: true) + state.remoteTransport = .ssh + + GatewayDiscoverySelectionSupport.applyRemoteSelection( + gateway: self.makeGateway( + serviceHost: tailnetHost, + servicePort: 443, + tailnetDns: tailnetHost, + stableID: "wide-area|openclaw.internal.|gateway-host"), + state: state) + + #expect(state.remoteTransport == .direct) + #expect(state.remoteUrl == "wss://\(tailnetHost)") + } + } + + @Test func `selecting nearby lan gateway keeps ssh transport`() async { + let configPath = TestIsolation.tempConfigPath() + await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) { + let state = AppState(preview: true) + state.remoteTransport = .ssh + state.remoteTarget = "user@old-host" + + GatewayDiscoverySelectionSupport.applyRemoteSelection( + gateway: self.makeGateway( + serviceHost: "nearby-gateway.local", + servicePort: 18789, + stableID: "bonjour|nearby-gateway"), + state: state) + + #expect(state.remoteTransport == .ssh) + #expect(CommandResolver.parseSSHTarget(state.remoteTarget)?.host == "nearby-gateway.local") + } + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift index 39ca29b33d5..418780c1a70 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift @@ -61,7 +61,22 @@ struct GatewayEndpointStoreTests { #expect(token == nil) } - @Test func `resolve gateway password falls back to launchd`() { + @Test func resolveGatewayTokenUsesRemoteConfigToken() { + let token = GatewayEndpointStore._testResolveGatewayToken( + isRemote: true, + root: [ + "gateway": [ + "remote": [ + "token": " remote-token ", + ], + ], + ], + env: [:], + launchdSnapshot: nil) + #expect(token == "remote-token") + } + + @Test func resolveGatewayPasswordFallsBackToLaunchd() { let snapshot = self.makeLaunchAgentSnapshot( env: ["OPENCLAW_GATEWAY_PASSWORD": "launchd-pass"], token: nil, diff --git a/apps/macos/Tests/OpenClawIPCTests/TailscaleServeGatewayDiscoveryTests.swift b/apps/macos/Tests/OpenClawIPCTests/TailscaleServeGatewayDiscoveryTests.swift index 46feb9a795e..b557a8494d6 100644 --- a/apps/macos/Tests/OpenClawIPCTests/TailscaleServeGatewayDiscoveryTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/TailscaleServeGatewayDiscoveryTests.swift @@ -74,4 +74,25 @@ struct TailscaleServeGatewayDiscoveryTests { #expect(TailscaleServeGatewayDiscovery .resolveExecutablePath("definitely-not-here", env: ["PATH": "/tmp"]) == nil) } + + @Test func `adds TERM for GUI-launched tailscale subprocesses`() { + let env = TailscaleServeGatewayDiscovery.commandEnvironment(base: [ + "HOME": "/Users/tester", + "PATH": "/usr/bin:/bin", + ]) + + #expect(env["TERM"] == "dumb") + #expect(env["HOME"] == "/Users/tester") + #expect(env["PATH"] == "/usr/bin:/bin") + } + + @Test func `preserves existing TERM when building tailscale subprocess environment`() { + let env = TailscaleServeGatewayDiscovery.commandEnvironment(base: [ + "TERM": "xterm-256color", + "HOME": "/Users/tester", + ]) + + #expect(env["TERM"] == "xterm-256color") + #expect(env["HOME"] == "/Users/tester") + } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift index a3c09ff3504..378ad10e365 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift @@ -11,6 +11,50 @@ private struct NodeInvokeRequestPayload: Codable, Sendable { var idempotencyKey: String? } +private func replaceCanvasCapabilityInScopedHostUrl(scopedUrl: String, capability: String) -> String? { + let marker = "/__openclaw__/cap/" + guard let markerRange = scopedUrl.range(of: marker) else { return nil } + let capabilityStart = markerRange.upperBound + let suffix = scopedUrl[capabilityStart...] + let nextSlash = suffix.firstIndex(of: "/") + let nextQuery = suffix.firstIndex(of: "?") + let nextFragment = suffix.firstIndex(of: "#") + let capabilityEnd = [nextSlash, nextQuery, nextFragment].compactMap { $0 }.min() ?? scopedUrl.endIndex + guard capabilityStart < capabilityEnd else { return nil } + return String(scopedUrl[.. String? { + let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return nil } + guard var parsed = URLComponents(string: trimmed) else { return trimmed } + + let parsedHost = parsed.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let parsedIsLoopback = !parsedHost.isEmpty && LoopbackHost.isLoopback(parsedHost) + + if !parsedHost.isEmpty, !parsedIsLoopback { + guard let activeURL else { return trimmed } + let isTLS = activeURL.scheme?.lowercased() == "wss" + guard isTLS else { return trimmed } + parsed.scheme = "https" + if parsed.port == nil { + let tlsPort = activeURL.port ?? 443 + parsed.port = (tlsPort == 443) ? nil : tlsPort + } + return parsed.string ?? trimmed + } + + guard let activeURL, let fallbackHost = activeURL.host, !LoopbackHost.isLoopback(fallbackHost) else { + return trimmed + } + let isTLS = activeURL.scheme?.lowercased() == "wss" + parsed.scheme = isTLS ? "https" : "http" + parsed.host = fallbackHost + let fallbackPort = activeURL.port ?? (isTLS ? 443 : 80) + parsed.port = ((isTLS && fallbackPort == 443) || (!isTLS && fallbackPort == 80)) ? nil : fallbackPort + return parsed.string ?? trimmed +} + public actor GatewayNodeSession { private let logger = Logger(subsystem: "ai.openclaw", category: "node.gateway") @@ -223,6 +267,46 @@ public actor GatewayNodeSession { self.canvasHostUrl } + public func refreshNodeCanvasCapability(timeoutMs: Int = 8_000) async -> Bool { + guard let channel = self.channel else { return false } + do { + let data = try await channel.request( + method: "node.canvas.capability.refresh", + params: [:], + timeoutMs: Double(max(timeoutMs, 1))) + guard + let payload = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let rawCapability = payload["canvasCapability"] as? String + else { + self.logger.warning("node.canvas.capability.refresh missing canvasCapability") + return false + } + let capability = rawCapability.trimmingCharacters(in: .whitespacesAndNewlines) + guard !capability.isEmpty else { + self.logger.warning("node.canvas.capability.refresh returned empty capability") + return false + } + let scopedUrl = self.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !scopedUrl.isEmpty else { + self.logger.warning("node.canvas.capability.refresh missing local canvasHostUrl") + return false + } + guard let refreshed = replaceCanvasCapabilityInScopedHostUrl( + scopedUrl: scopedUrl, + capability: capability) + else { + self.logger.warning("node.canvas.capability.refresh could not rewrite scoped canvas URL") + return false + } + self.canvasHostUrl = refreshed + return true + } catch { + self.logger.warning( + "node.canvas.capability.refresh failed: \(error.localizedDescription, privacy: .public)") + return false + } + } + public func currentRemoteAddress() -> String? { guard let url = self.activeURL else { return nil } guard let host = url.host else { return url.absoluteString } @@ -275,7 +359,7 @@ public actor GatewayNodeSession { switch push { case let .snapshot(ok): let raw = ok.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines) - self.canvasHostUrl = (raw?.isEmpty == false) ? raw : nil + self.canvasHostUrl = self.normalizeCanvasHostUrl(raw) if self.hasEverConnected { self.broadcastServerEvent( EventFrame(type: "event", event: "seqGap", payload: nil, seq: nil, stateversion: nil)) @@ -342,6 +426,10 @@ public actor GatewayNodeSession { await self.onConnected?() } + private func normalizeCanvasHostUrl(_ raw: String?) -> String? { + canonicalizeCanvasHostUrl(raw: raw, activeURL: self.activeURL) + } + private func handleEvent(_ evt: EventFrame) async { self.broadcastServerEvent(evt) guard evt.event == "node.invoke.request" else { return } @@ -350,16 +438,21 @@ public actor GatewayNodeSession { do { let request = try self.decodeInvokeRequest(from: payload) let timeoutLabel = request.timeoutMs.map(String.init) ?? "none" - self.logger.info("node invoke request decoded id=\(request.id, privacy: .public) command=\(request.command, privacy: .public) timeoutMs=\(timeoutLabel, privacy: .public)") + self.logger.info( + "node invoke request decoded id=\(request.id, privacy: .public) command=\(request.command, privacy: .public) timeoutMs=\(timeoutLabel, privacy: .public)") guard let onInvoke else { return } - let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON) + let req = BridgeInvokeRequest( + id: request.id, + command: request.command, + paramsJSON: request.paramsJSON) self.logger.info("node invoke executing id=\(request.id, privacy: .public)") let response = await Self.invokeWithTimeout( request: req, timeoutMs: request.timeoutMs, onInvoke: onInvoke ) - self.logger.info("node invoke completed id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)") + self.logger.info( + "node invoke completed id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)") await self.sendInvokeResult(request: request, response: response) } catch { self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)") @@ -380,7 +473,8 @@ public actor GatewayNodeSession { private func sendInvokeResult(request: NodeInvokeRequestPayload, response: BridgeInvokeResponse) async { guard let channel = self.channel else { return } - self.logger.info("node invoke result sending id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)") + self.logger.info( + "node invoke result sending id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)") var params: [String: AnyCodable] = [ "id": AnyCodable(request.id), "nodeId": AnyCodable(request.nodeId), @@ -398,7 +492,8 @@ public actor GatewayNodeSession { do { try await channel.send(method: "node.invoke.result", params: params) } catch { - self.logger.error("node invoke result failed id=\(request.id, privacy: .public) error=\(error.localizedDescription, privacy: .public)") + self.logger.error( + "node invoke result failed id=\(request.id, privacy: .public) error=\(error.localizedDescription, privacy: .public)") } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 6aad2e9a9ac..ea44d030eb0 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -836,6 +836,20 @@ public struct NodeRenameParams: Codable, Sendable { public struct NodeListParams: Codable, Sendable {} +public struct NodePendingAckParams: Codable, Sendable { + public let ids: [String] + + public init( + ids: [String]) + { + self.ids = ids + } + + private enum CodingKeys: String, CodingKey { + case ids + } +} + public struct NodeDescribeParams: Codable, Sendable { public let nodeid: String diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index a706e4bdb4c..a48015e1100 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -169,6 +169,24 @@ private actor SeqGapProbe { } struct GatewayNodeSessionTests { + @Test + func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() { + let normalized = canonicalizeCanvasHostUrl( + raw: "https://canvas.example.com:9443/__openclaw__/cap/token", + activeURL: URL(string: "wss://gateway.example.com")!) + + #expect(normalized == "https://canvas.example.com:9443/__openclaw__/cap/token") + } + + @Test + func normalizeCanvasHostUrlBackfillsGatewayHostForLoopbackCanvas() { + let normalized = canonicalizeCanvasHostUrl( + raw: "http://127.0.0.1:18789/__openclaw__/cap/token", + activeURL: URL(string: "wss://gateway.example.com:7443")!) + + #expect(normalized == "https://gateway.example.com:7443/__openclaw__/cap/token") + } + @Test func invokeWithTimeoutReturnsUnderlyingResponseBeforeTimeout() async { let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil) diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index b0798898910..47bae78b86f 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -620,6 +620,8 @@ openclaw cron run openclaw cron run --due ``` +`cron.run` now acknowledges once the manual run is queued, not after the job finishes. Successful queue responses look like `{ ok: true, enqueued: true, runId }`. If the job is already running or `--due` finds nothing due, the response stays `{ ok: true, ran: false, reason }`. Use `openclaw cron runs --id ` or the `cron.runs` gateway method to inspect the eventual finished entry. + Edit an existing job (patch fields): ```bash diff --git a/docs/cli/backup.md b/docs/cli/backup.md new file mode 100644 index 00000000000..a39b0fefac6 --- /dev/null +++ b/docs/cli/backup.md @@ -0,0 +1,76 @@ +--- +summary: "CLI reference for `openclaw backup` (create local backup archives)" +read_when: + - You want a first-class backup archive for local OpenClaw state + - You want to preview which paths would be included before reset or uninstall +title: "backup" +--- + +# `openclaw backup` + +Create a local backup archive for OpenClaw state, config, credentials, sessions, and optionally workspaces. + +```bash +openclaw backup create +openclaw backup create --output ~/Backups +openclaw backup create --dry-run --json +openclaw backup create --verify +openclaw backup create --no-include-workspace +openclaw backup create --only-config +openclaw backup verify ./2026-03-09T00-00-00.000Z-openclaw-backup.tar.gz +``` + +## Notes + +- The archive includes a `manifest.json` file with the resolved source paths and archive layout. +- Default output is a timestamped `.tar.gz` archive in the current working directory. +- If the current working directory is inside a backed-up source tree, OpenClaw falls back to your home directory for the default archive location. +- Existing archive files are never overwritten. +- Output paths inside the source state/workspace trees are rejected to avoid self-inclusion. +- `openclaw backup verify ` validates that the archive contains exactly one root manifest, rejects traversal-style archive paths, and checks that every manifest-declared payload exists in the tarball. +- `openclaw backup create --verify` runs that validation immediately after writing the archive. +- `openclaw backup create --only-config` backs up just the active JSON config file. + +## What gets backed up + +`openclaw backup create` plans backup sources from your local OpenClaw install: + +- The state directory returned by OpenClaw's local state resolver, usually `~/.openclaw` +- The active config file path +- The OAuth / credentials directory +- Workspace directories discovered from the current config, unless you pass `--no-include-workspace` + +If you use `--only-config`, OpenClaw skips state, credentials, and workspace discovery and archives only the active config file path. + +OpenClaw canonicalizes paths before building the archive. If config, credentials, or a workspace already live inside the state directory, they are not duplicated as separate top-level backup sources. Missing paths are skipped. + +The archive payload stores file contents from those source trees, and the embedded `manifest.json` records the resolved absolute source paths plus the archive layout used for each asset. + +## Invalid config behavior + +`openclaw backup` intentionally bypasses the normal config preflight so it can still help during recovery. Because workspace discovery depends on a valid config, `openclaw backup create` now fails fast when the config file exists but is invalid and workspace backup is still enabled. + +If you still want a partial backup in that situation, rerun: + +```bash +openclaw backup create --no-include-workspace +``` + +That keeps state, config, and credentials in scope while skipping workspace discovery entirely. + +If you only need a copy of the config file itself, `--only-config` also works when the config is malformed because it does not rely on parsing the config for workspace discovery. + +## Size and performance + +OpenClaw does not enforce a built-in maximum backup size or per-file size limit. + +Practical limits come from the local machine and destination filesystem: + +- Available space for the temporary archive write plus the final archive +- Time to walk large workspace trees and compress them into a `.tar.gz` +- Time to rescan the archive if you use `openclaw backup create --verify` or run `openclaw backup verify` +- Filesystem behavior at the destination path. OpenClaw prefers a no-overwrite hard-link publish step and falls back to exclusive copy when hard links are unsupported + +Large workspaces are usually the main driver of archive size. If you want a smaller or faster backup, use `--no-include-workspace`. + +For the smallest archive, use `--only-config`. diff --git a/docs/cli/cron.md b/docs/cli/cron.md index 5f5be713de1..28e61e20c99 100644 --- a/docs/cli/cron.md +++ b/docs/cli/cron.md @@ -23,6 +23,8 @@ Note: one-shot (`--at`) jobs delete after success by default. Use `--keep-after- Note: recurring jobs now use exponential retry backoff after consecutive errors (30s → 1m → 5m → 15m → 60m), then return to normal schedule after the next successful run. +Note: `openclaw cron run` now returns as soon as the manual run is queued for execution. Successful responses include `{ ok: true, enqueued: true, runId }`; use `openclaw cron runs --id ` to follow the eventual outcome. + Note: retention/pruning is controlled in config: - `cron.sessionRetention` (default `24h`) prunes completed isolated run sessions. diff --git a/docs/cli/index.md b/docs/cli/index.md index 634e2cdef0e..fdee80038c0 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -19,6 +19,7 @@ This page describes the current CLI behavior. If commands change, update this do - [`completion`](/cli/completion) - [`doctor`](/cli/doctor) - [`dashboard`](/cli/dashboard) +- [`backup`](/cli/backup) - [`reset`](/cli/reset) - [`uninstall`](/cli/uninstall) - [`update`](/cli/update) @@ -103,6 +104,9 @@ openclaw [--dev] [--profile ] completion doctor dashboard + backup + create + verify security audit secrets diff --git a/docs/cli/reset.md b/docs/cli/reset.md index a94da78f3be..df142390866 100644 --- a/docs/cli/reset.md +++ b/docs/cli/reset.md @@ -11,7 +11,10 @@ title: "reset" Reset local config/state (keeps the CLI installed). ```bash +openclaw backup create openclaw reset openclaw reset --dry-run openclaw reset --scope config+creds+sessions --yes --non-interactive ``` + +Run `openclaw backup create` first if you want a restorable snapshot before removing local state. diff --git a/docs/cli/uninstall.md b/docs/cli/uninstall.md index 9c269eeeb35..77333f62651 100644 --- a/docs/cli/uninstall.md +++ b/docs/cli/uninstall.md @@ -11,7 +11,10 @@ title: "uninstall" Uninstall the gateway service + local data (CLI remains). ```bash +openclaw backup create openclaw uninstall openclaw uninstall --all --yes openclaw uninstall --dry-run ``` + +Run `openclaw backup create` first if you want a restorable snapshot before removing state or workspaces. diff --git a/docs/docs.json b/docs/docs.json index 35e2f37a4a7..8592618cd7d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1013,7 +1013,8 @@ "tools/browser", "tools/browser-login", "tools/chrome-extension", - "tools/browser-linux-troubleshooting" + "tools/browser-linux-troubleshooting", + "tools/browser-wsl2-windows-remote-cdp-troubleshooting" ] }, { diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index ca6a3681410..538b80f6138 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2354,6 +2354,7 @@ See [Plugins](/tools/plugin). // headless: false, // noSandbox: false, // extraArgs: [], + // relayBindHost: "0.0.0.0", // only when the extension relay must be reachable across namespaces (for example WSL2) // executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", // attachOnly: false, }, @@ -2370,6 +2371,7 @@ See [Plugins](/tools/plugin). - Control service: loopback only (port derived from `gateway.port`, default `18791`). - `extraArgs` appends extra launch flags to local Chromium startup (for example `--disable-gpu`, window sizing, or debug flags). +- `relayBindHost` changes where the Chrome extension relay listens. Leave unset for loopback-only access; set an explicit non-loopback bind address such as `0.0.0.0` only when the relay must cross a namespace boundary (for example WSL2) and the host network is already trusted. --- diff --git a/docs/help/environment.md b/docs/help/environment.md index 7fa1fdfa6c5..860129bde37 100644 --- a/docs/help/environment.md +++ b/docs/help/environment.md @@ -68,6 +68,12 @@ OpenClaw also injects context markers into spawned child processes: These are runtime markers (not required user config). They can be used in shell/profile logic to apply context-specific rules. +## UI env vars + +- `OPENCLAW_THEME=light`: force the light TUI palette when your terminal has a light background. +- `OPENCLAW_THEME=dark`: force the dark TUI palette. +- `COLORFGBG`: if your terminal exports it, OpenClaw uses the background color hint to auto-pick the TUI palette. + ## Env var substitution in config You can reference env vars directly in config string values using `${VAR_NAME}` syntax: diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md index c2cb1a4312b..e051f77f589 100644 --- a/docs/help/troubleshooting.md +++ b/docs/help/troubleshooting.md @@ -290,6 +290,7 @@ flowchart TD - [/gateway/troubleshooting#browser-tool-fails](/gateway/troubleshooting#browser-tool-fails) - [/tools/browser-linux-troubleshooting](/tools/browser-linux-troubleshooting) + - [/tools/browser-wsl2-windows-remote-cdp-troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting) - [/tools/chrome-extension](/tools/chrome-extension) diff --git a/docs/refactor/cluster.md b/docs/refactor/cluster.md new file mode 100644 index 00000000000..f3b13186972 --- /dev/null +++ b/docs/refactor/cluster.md @@ -0,0 +1,299 @@ +--- +summary: "Refactor clusters with highest LOC reduction potential" +read_when: + - You want to reduce total LOC without changing behavior + - You are choosing the next dedupe or extraction pass +title: "Refactor Cluster Backlog" +--- + +# Refactor Cluster Backlog + +Ranked by likely LOC reduction, safety, and breadth. + +## 1. Channel plugin config and security scaffolding + +Highest-value cluster. + +Repeated shapes across many channel plugins: + +- `config.listAccountIds` +- `config.resolveAccount` +- `config.defaultAccountId` +- `config.setAccountEnabled` +- `config.deleteAccount` +- `config.describeAccount` +- `security.resolveDmPolicy` + +Strong examples: + +- `extensions/telegram/src/channel.ts` +- `extensions/googlechat/src/channel.ts` +- `extensions/slack/src/channel.ts` +- `extensions/discord/src/channel.ts` +- `extensions/matrix/src/channel.ts` +- `extensions/irc/src/channel.ts` +- `extensions/signal/src/channel.ts` +- `extensions/mattermost/src/channel.ts` + +Likely extraction shape: + +- `buildChannelConfigAdapter(...)` +- `buildMultiAccountConfigAdapter(...)` +- `buildDmSecurityAdapter(...)` + +Expected savings: + +- ~250-450 LOC + +Risk: + +- Medium. Each channel has slightly different `isConfigured`, warnings, and normalization. + +## 2. Extension runtime singleton boilerplate + +Very safe. + +Nearly every extension has the same runtime holder: + +- `let runtime: PluginRuntime | null = null` +- `setXRuntime` +- `getXRuntime` + +Strong examples: + +- `extensions/telegram/src/runtime.ts` +- `extensions/matrix/src/runtime.ts` +- `extensions/slack/src/runtime.ts` +- `extensions/discord/src/runtime.ts` +- `extensions/whatsapp/src/runtime.ts` +- `extensions/imessage/src/runtime.ts` +- `extensions/twitch/src/runtime.ts` + +Special-case variants: + +- `extensions/bluebubbles/src/runtime.ts` +- `extensions/line/src/runtime.ts` +- `extensions/synology-chat/src/runtime.ts` + +Likely extraction shape: + +- `createPluginRuntimeStore(errorMessage)` + +Expected savings: + +- ~180-260 LOC + +Risk: + +- Low + +## 3. Onboarding prompt and config-patch steps + +Large surface area. + +Many onboarding files repeat: + +- resolve account id +- prompt allowlist entries +- merge allowFrom +- set DM policy +- prompt secrets +- patch top-level vs account-scoped config + +Strong examples: + +- `extensions/bluebubbles/src/onboarding.ts` +- `extensions/googlechat/src/onboarding.ts` +- `extensions/msteams/src/onboarding.ts` +- `extensions/zalo/src/onboarding.ts` +- `extensions/zalouser/src/onboarding.ts` +- `extensions/nextcloud-talk/src/onboarding.ts` +- `extensions/matrix/src/onboarding.ts` +- `extensions/irc/src/onboarding.ts` + +Existing helper seam: + +- `src/channels/plugins/onboarding/helpers.ts` + +Likely extraction shape: + +- `promptAllowFromList(...)` +- `buildDmPolicyAdapter(...)` +- `applyScopedAccountPatch(...)` +- `promptSecretFields(...)` + +Expected savings: + +- ~300-600 LOC + +Risk: + +- Medium. Easy to over-generalize; keep helpers narrow and composable. + +## 4. Multi-account config-schema fragments + +Repeated schema fragments across extensions. + +Common patterns: + +- `const allowFromEntry = z.union([z.string(), z.number()])` +- account schema plus: + - `accounts: z.object({}).catchall(accountSchema).optional()` + - `defaultAccount: z.string().optional()` +- repeated DM/group fields +- repeated markdown/tool policy fields + +Strong examples: + +- `extensions/bluebubbles/src/config-schema.ts` +- `extensions/zalo/src/config-schema.ts` +- `extensions/zalouser/src/config-schema.ts` +- `extensions/matrix/src/config-schema.ts` +- `extensions/nostr/src/config-schema.ts` + +Likely extraction shape: + +- `AllowFromEntrySchema` +- `buildMultiAccountChannelSchema(accountSchema)` +- `buildCommonDmGroupFields(...)` + +Expected savings: + +- ~120-220 LOC + +Risk: + +- Low to medium. Some schemas are simple, some are special. + +## 5. Webhook and monitor lifecycle startup + +Good medium-value cluster. + +Repeated `startAccount` / monitor setup patterns: + +- resolve account +- compute webhook path +- log startup +- start monitor +- wait for abort +- cleanup +- status sink updates + +Strong examples: + +- `extensions/googlechat/src/channel.ts` +- `extensions/bluebubbles/src/channel.ts` +- `extensions/zalo/src/channel.ts` +- `extensions/telegram/src/channel.ts` +- `extensions/nextcloud-talk/src/channel.ts` + +Existing helper seam: + +- `src/plugin-sdk/channel-lifecycle.ts` + +Likely extraction shape: + +- helper for account monitor lifecycle +- helper for webhook-backed account startup + +Expected savings: + +- ~150-300 LOC + +Risk: + +- Medium to high. Transport details diverge quickly. + +## 6. Small exact-clone cleanup + +Low-risk cleanup bucket. + +Examples: + +- duplicated gateway argv detection: + - `src/infra/gateway-lock.ts` + - `src/cli/daemon-cli/lifecycle.ts` +- duplicated port diagnostics rendering: + - `src/cli/daemon-cli/restart-health.ts` +- duplicated session-key construction: + - `src/web/auto-reply/monitor/broadcast.ts` + +Expected savings: + +- ~30-60 LOC + +Risk: + +- Low + +## Test clusters + +### LINE webhook event fixtures + +Strong examples: + +- `src/line/bot-handlers.test.ts` + +Likely extraction: + +- `makeLineEvent(...)` +- `runLineEvent(...)` +- `makeLineAccount(...)` + +Expected savings: + +- ~120-180 LOC + +### Telegram native command auth matrix + +Strong examples: + +- `src/telegram/bot-native-commands.group-auth.test.ts` +- `src/telegram/bot-native-commands.plugin-auth.test.ts` + +Likely extraction: + +- forum context builder +- denied-message assertion helper +- table-driven auth cases + +Expected savings: + +- ~80-140 LOC + +### Zalo lifecycle setup + +Strong examples: + +- `extensions/zalo/src/monitor.lifecycle.test.ts` + +Likely extraction: + +- shared monitor setup harness + +Expected savings: + +- ~50-90 LOC + +### Brave llm-context unsupported-option tests + +Strong examples: + +- `src/agents/tools/web-tools.enabled-defaults.test.ts` + +Likely extraction: + +- `it.each(...)` matrix + +Expected savings: + +- ~30-50 LOC + +## Suggested order + +1. Runtime singleton boilerplate +2. Small exact-clone cleanup +3. Config and security builder extraction +4. Test-helper extraction +5. Onboarding step extraction +6. Monitor lifecycle helper extraction diff --git a/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md b/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md new file mode 100644 index 00000000000..d63bb891c48 --- /dev/null +++ b/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md @@ -0,0 +1,242 @@ +--- +summary: "Troubleshoot WSL2 Gateway + Windows Chrome remote CDP and extension-relay setups in layers" +read_when: + - Running OpenClaw Gateway in WSL2 while Chrome lives on Windows + - Seeing overlapping browser/control-ui errors across WSL2 and Windows + - Deciding between raw remote CDP and the Chrome extension relay in split-host setups +title: "WSL2 + Windows + remote Chrome CDP troubleshooting" +--- + +# WSL2 + Windows + remote Chrome CDP troubleshooting + +This guide covers the common split-host setup where: + +- OpenClaw Gateway runs inside WSL2 +- Chrome runs on Windows +- browser control must cross the WSL2/Windows boundary + +It also covers the layered failure pattern from [issue #39369](https://github.com/openclaw/openclaw/issues/39369): several independent problems can show up at once, which makes the wrong layer look broken first. + +## Choose the right browser mode first + +You have two valid patterns: + +### Option 1: Raw remote CDP + +Use a remote browser profile that points from WSL2 to a Windows Chrome CDP endpoint. + +Choose this when: + +- you only need browser control +- you are comfortable exposing Chrome remote debugging to WSL2 +- you do not need the Chrome extension relay + +### Option 2: Chrome extension relay + +Use the built-in `chrome` profile plus the OpenClaw Chrome extension. + +Choose this when: + +- you want to attach to an existing Windows Chrome tab with the toolbar button +- you want extension-based control instead of raw `--remote-debugging-port` +- the relay itself must be reachable across the WSL2/Windows boundary + +If you use the extension relay across namespaces, `browser.relayBindHost` is the important setting introduced in [Browser](/tools/browser) and [Chrome extension](/tools/chrome-extension). + +## Working architecture + +Reference shape: + +- WSL2 runs the Gateway on `127.0.0.1:18789` +- Windows opens the Control UI in a normal browser at `http://127.0.0.1:18789/` +- Windows Chrome exposes a CDP endpoint on port `9222` +- WSL2 can reach that Windows CDP endpoint +- OpenClaw points a browser profile at the address that is reachable from WSL2 + +## Why this setup is confusing + +Several failures can overlap: + +- WSL2 cannot reach the Windows CDP endpoint +- the Control UI is opened from a non-secure origin +- `gateway.controlUi.allowedOrigins` does not match the page origin +- token or pairing is missing +- the browser profile points at the wrong address +- the extension relay is still loopback-only when you actually need cross-namespace access + +Because of that, fixing one layer can still leave a different error visible. + +## Critical rule for the Control UI + +When the UI is opened from Windows, use Windows localhost unless you have a deliberate HTTPS setup. + +Use: + +`http://127.0.0.1:18789/` + +Do not default to a LAN IP for the Control UI. Plain HTTP on a LAN or tailnet address can trigger insecure-origin/device-auth behavior that is unrelated to CDP itself. See [Control UI](/web/control-ui). + +## Validate in layers + +Work top to bottom. Do not skip ahead. + +### Layer 1: Verify Chrome is serving CDP on Windows + +Start Chrome on Windows with remote debugging enabled: + +```powershell +chrome.exe --remote-debugging-port=9222 +``` + +From Windows, verify Chrome itself first: + +```powershell +curl http://127.0.0.1:9222/json/version +curl http://127.0.0.1:9222/json/list +``` + +If this fails on Windows, OpenClaw is not the problem yet. + +### Layer 2: Verify WSL2 can reach that Windows endpoint + +From WSL2, test the exact address you plan to use in `cdpUrl`: + +```bash +curl http://WINDOWS_HOST_OR_IP:9222/json/version +curl http://WINDOWS_HOST_OR_IP:9222/json/list +``` + +Good result: + +- `/json/version` returns JSON with Browser / Protocol-Version metadata +- `/json/list` returns JSON (empty array is fine if no pages are open) + +If this fails: + +- Windows is not exposing the port to WSL2 yet +- the address is wrong for the WSL2 side +- firewall / port forwarding / local proxying is still missing + +Fix that before touching OpenClaw config. + +### Layer 3: Configure the correct browser profile + +For raw remote CDP, point OpenClaw at the address that is reachable from WSL2: + +```json5 +{ + browser: { + enabled: true, + defaultProfile: "remote", + profiles: { + remote: { + cdpUrl: "http://WINDOWS_HOST_OR_IP:9222", + attachOnly: true, + color: "#00AA00", + }, + }, + }, +} +``` + +Notes: + +- use the WSL2-reachable address, not whatever only works on Windows +- keep `attachOnly: true` for externally managed browsers +- test the same URL with `curl` before expecting OpenClaw to succeed + +### Layer 4: If you use the Chrome extension relay instead + +If the browser machine and the Gateway are separated by a namespace boundary, the relay may need a non-loopback bind address. + +Example: + +```json5 +{ + browser: { + enabled: true, + defaultProfile: "chrome", + relayBindHost: "0.0.0.0", + }, +} +``` + +Use this only when needed: + +- default behavior is safer because the relay stays loopback-only +- `0.0.0.0` expands exposure surface +- keep Gateway auth, node pairing, and the surrounding network private + +If you do not need the extension relay, prefer the raw remote CDP profile above. + +### Layer 5: Verify the Control UI layer separately + +Open the UI from Windows: + +`http://127.0.0.1:18789/` + +Then verify: + +- the page origin matches what `gateway.controlUi.allowedOrigins` expects +- token auth or pairing is configured correctly +- you are not debugging a Control UI auth problem as if it were a browser problem + +Helpful page: + +- [Control UI](/web/control-ui) + +### Layer 6: Verify end-to-end browser control + +From WSL2: + +```bash +openclaw browser open https://example.com --browser-profile remote +openclaw browser tabs --browser-profile remote +``` + +For the extension relay: + +```bash +openclaw browser tabs --browser-profile chrome +``` + +Good result: + +- the tab opens in Windows Chrome +- `openclaw browser tabs` returns the target +- later actions (`snapshot`, `screenshot`, `navigate`) work from the same profile + +## Common misleading errors + +Treat each message as a layer-specific clue: + +- `control-ui-insecure-auth` + - UI origin / secure-context problem, not a CDP transport problem +- `token_missing` + - auth configuration problem +- `pairing required` + - device approval problem +- `Remote CDP for profile "remote" is not reachable` + - WSL2 cannot reach the configured `cdpUrl` +- `gateway timeout after 1500ms` + - often still CDP reachability or a slow/unreachable remote endpoint +- `Chrome extension relay is running, but no tab is connected` + - extension relay profile selected, but no attached tab exists yet + +## Fast triage checklist + +1. Windows: does `curl http://127.0.0.1:9222/json/version` work? +2. WSL2: does `curl http://WINDOWS_HOST_OR_IP:9222/json/version` work? +3. OpenClaw config: does `browser.profiles..cdpUrl` use that exact WSL2-reachable address? +4. Control UI: are you opening `http://127.0.0.1:18789/` instead of a LAN IP? +5. Extension relay only: do you actually need `browser.relayBindHost`, and if so is it set explicitly? + +## Practical takeaway + +The setup is usually viable. The hard part is that browser transport, Control UI origin security, token/pairing, and extension-relay topology can each fail independently while looking similar from the user side. + +When in doubt: + +- verify the Windows Chrome endpoint locally first +- verify the same endpoint from WSL2 second +- only then debug OpenClaw config or Control UI auth diff --git a/docs/tools/browser.md b/docs/tools/browser.md index e1372a08b9d..d632e713068 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -328,6 +328,19 @@ Notes: - This mode relies on Playwright-on-CDP for most operations (screenshots/snapshots/actions). - Detach by clicking the extension icon again. +- Leave the relay loopback-only by default. If the relay must be reachable from a different network namespace (for example Gateway in WSL2, Chrome on Windows), set `browser.relayBindHost` to an explicit bind address such as `0.0.0.0` while keeping the surrounding network private and authenticated. + +WSL2 / cross-namespace example: + +```json5 +{ + browser: { + enabled: true, + relayBindHost: "0.0.0.0", + defaultProfile: "chrome", + }, +} +``` ## Isolation guarantees @@ -636,6 +649,9 @@ Strict-mode example (block private/internal destinations by default): For Linux-specific issues (especially snap Chromium), see [Browser troubleshooting](/tools/browser-linux-troubleshooting). +For WSL2 Gateway + Windows Chrome split-host setups, see +[WSL2 + Windows + remote Chrome CDP troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting). + ## Agent tools + how control works The agent gets **one tool** for browser automation: diff --git a/docs/tools/chrome-extension.md b/docs/tools/chrome-extension.md index 964eb40f37b..ce4b271ae9c 100644 --- a/docs/tools/chrome-extension.md +++ b/docs/tools/chrome-extension.md @@ -161,6 +161,7 @@ Debugging: `openclaw sandbox explain` - Keep the Gateway and node host on the same tailnet; avoid exposing relay ports to LAN or public Internet. - Pair nodes intentionally; disable browser proxy routing if you don’t want remote control (`gateway.nodes.browser.mode="off"`). +- Leave the relay on loopback unless you have a real cross-namespace need. For WSL2 or similar split-host setups, set `browser.relayBindHost` to an explicit bind address such as `0.0.0.0`, then keep access constrained with Gateway auth, node pairing, and a private network. ## How “extension path” works diff --git a/docs/tools/index.md b/docs/tools/index.md index 0f311516dcd..6552d6f9118 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -531,6 +531,9 @@ Browser tool: - `profile` (optional; defaults to `browser.defaultProfile`) - `target` (`sandbox` | `host` | `node`) - `node` (optional; pin a specific node id/name) +- Troubleshooting guides: + - Linux startup/CDP issues: [Browser troubleshooting (Linux)](/tools/browser-linux-troubleshooting) + - WSL2 Gateway + Windows remote Chrome CDP: [WSL2 + Windows + remote Chrome CDP troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting) ## Recommended agent flows diff --git a/docs/web/tui.md b/docs/web/tui.md index 1553fd5d668..0c09cb1f877 100644 --- a/docs/web/tui.md +++ b/docs/web/tui.md @@ -122,6 +122,12 @@ Other Gateway slash commands (for example, `/context`) are forwarded to the Gate - Ctrl+O toggles between collapsed/expanded views. - While tools run, partial updates stream into the same card. +## Terminal colors + +- The TUI keeps assistant body text in your terminal's default foreground so dark and light terminals both stay readable. +- If your terminal uses a light background and auto-detection is wrong, set `OPENCLAW_THEME=light` before launching `openclaw tui`. +- To force the original dark palette instead, set `OPENCLAW_THEME=dark`. + ## History + streaming - On connect, the TUI loads the latest history (default 200 messages). diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index bc4ec0e3f67..94a0661afb7 100644 --- a/extensions/bluebubbles/src/config-schema.ts +++ b/extensions/bluebubbles/src/config-schema.ts @@ -1,9 +1,8 @@ +import { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema } from "openclaw/plugin-sdk"; import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/bluebubbles"; import { z } from "zod"; import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js"; -const allowFromEntry = z.union([z.string(), z.number()]); - const bluebubblesActionSchema = z .object({ reactions: z.boolean().default(true), @@ -34,8 +33,8 @@ const bluebubblesAccountSchema = z password: buildSecretInputSchema().optional(), webhookPath: z.string().optional(), dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), - allowFrom: z.array(allowFromEntry).optional(), - groupAllowFrom: z.array(allowFromEntry).optional(), + allowFrom: z.array(AllowFromEntrySchema).optional(), + groupAllowFrom: z.array(AllowFromEntrySchema).optional(), groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(), historyLimit: z.number().int().min(0).optional(), dmHistoryLimit: z.number().int().min(0).optional(), @@ -60,8 +59,8 @@ const bluebubblesAccountSchema = z } }); -export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({ - accounts: z.object({}).catchall(bluebubblesAccountSchema).optional(), - defaultAccount: z.string().optional(), +export const BlueBubblesConfigSchema = buildCatchallMultiAccountChannelSchema( + bluebubblesAccountSchema, +).extend({ actions: bluebubblesActionSchema, }); diff --git a/extensions/bluebubbles/src/runtime.ts b/extensions/bluebubbles/src/runtime.ts index 89ee04cf8a4..e1c0254e1c0 100644 --- a/extensions/bluebubbles/src/runtime.ts +++ b/extensions/bluebubbles/src/runtime.ts @@ -1,31 +1,26 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles"; -let runtime: PluginRuntime | null = null; +const runtimeStore = createPluginRuntimeStore("BlueBubbles runtime not initialized"); type LegacyRuntimeLogShape = { log?: (message: string) => void }; - -export function setBlueBubblesRuntime(next: PluginRuntime): void { - runtime = next; -} +export const setBlueBubblesRuntime = runtimeStore.setRuntime; export function clearBlueBubblesRuntime(): void { - runtime = null; + runtimeStore.clearRuntime(); } export function tryGetBlueBubblesRuntime(): PluginRuntime | null { - return runtime; + return runtimeStore.tryGetRuntime(); } export function getBlueBubblesRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("BlueBubbles runtime not initialized"); - } - return runtime; + return runtimeStore.getRuntime(); } export function warnBlueBubbles(message: string): void { const formatted = `[bluebubbles] ${message}`; // Backward-compatible with tests/legacy injections that pass { log }. - const log = (runtime as unknown as LegacyRuntimeLogShape | null)?.log; + const log = (runtimeStore.tryGetRuntime() as unknown as LegacyRuntimeLogShape | null)?.log; if (typeof log === "function") { log(formatted); return; diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index cd3483bce00..23a4a2ffae8 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -1,3 +1,4 @@ +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk"; import { buildAccountScopedDmSecurityPolicy, collectOpenProviderGroupPolicyWarnings, @@ -13,7 +14,6 @@ import { collectDiscordAuditChannelIds, collectDiscordStatusIssues, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, discordOnboardingAdapter, DiscordConfigSchema, getChatChannelMeta, @@ -33,7 +33,6 @@ import { resolveDefaultDiscordAccountId, resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, - setAccountEnabledInConfigSection, type ChannelMessageActionAdapter, type ChannelPlugin, type ResolvedDiscordAccount, @@ -63,6 +62,15 @@ const discordConfigAccessors = createScopedAccountConfigAccessors({ resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo, }); +const discordConfigBase = createScopedChannelConfigBase({ + sectionKey: "discord", + listAccountIds: listDiscordAccountIds, + resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultDiscordAccountId, + clearBaseFields: ["token", "name"], +}); + export const discordPlugin: ChannelPlugin = { id: "discord", meta: { @@ -93,25 +101,7 @@ export const discordPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.discord"] }, configSchema: buildChannelConfigSchema(DiscordConfigSchema), config: { - listAccountIds: (cfg) => listDiscordAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }), - inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultDiscordAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "discord", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "discord", - accountId, - clearBaseFields: ["token", "name"], - }), + ...discordConfigBase, isConfigured: (account) => Boolean(account.token?.trim()), describeAccount: (account) => ({ accountId: account.accountId, diff --git a/extensions/discord/src/runtime.ts b/extensions/discord/src/runtime.ts index 506a81085ee..9a23266edda 100644 --- a/extensions/discord/src/runtime.ts +++ b/extensions/discord/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/discord"; -let runtime: PluginRuntime | null = null; - -export function setDiscordRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getDiscordRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Discord runtime not initialized"); - } - return runtime; -} +const { setRuntime: setDiscordRuntime, getRuntime: getDiscordRuntime } = + createPluginRuntimeStore("Discord runtime not initialized"); +export { getDiscordRuntime, setDiscordRuntime }; diff --git a/extensions/feishu/src/runtime.ts b/extensions/feishu/src/runtime.ts index b66579e8775..c1a4b65c50a 100644 --- a/extensions/feishu/src/runtime.ts +++ b/extensions/feishu/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/feishu"; -let runtime: PluginRuntime | null = null; - -export function setFeishuRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getFeishuRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Feishu runtime not initialized"); - } - return runtime; -} +const { setRuntime: setFeishuRuntime, getRuntime: getFeishuRuntime } = + createPluginRuntimeStore("Feishu runtime not initialized"); +export { getFeishuRuntime, setFeishuRuntime }; diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 775145f5d54..f0c5dace9f0 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -1,3 +1,4 @@ +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk"; import { buildAccountScopedDmSecurityPolicy, buildOpenGroupPolicyConfigureRouteAllowlistWarning, @@ -11,7 +12,6 @@ import { buildComputedAccountStatusSnapshot, buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, getChatChannelMeta, listDirectoryGroupEntriesFromMapKeys, listDirectoryUserEntriesFromAllowFrom, @@ -21,7 +21,6 @@ import { PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, resolveGoogleChatGroupRequireMention, - setAccountEnabledInConfigSection, type ChannelDock, type ChannelMessageActionAdapter, type ChannelPlugin, @@ -68,6 +67,23 @@ const googleChatConfigAccessors = createScopedAccountConfigAccessors({ resolveDefaultTo: (account: ResolvedGoogleChatAccount) => account.config.defaultTo, }); +const googleChatConfigBase = createScopedChannelConfigBase({ + sectionKey: "googlechat", + listAccountIds: listGoogleChatAccountIds, + resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultGoogleChatAccountId, + clearBaseFields: [ + "serviceAccount", + "serviceAccountFile", + "audienceType", + "audience", + "webhookPath", + "webhookUrl", + "botUser", + "name", + ], +}); + export const googlechatDock: ChannelDock = { id: "googlechat", capabilities: { @@ -142,33 +158,7 @@ export const googlechatPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.googlechat"] }, configSchema: buildChannelConfigSchema(GoogleChatConfigSchema), config: { - listAccountIds: (cfg) => listGoogleChatAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg: cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultGoogleChatAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg: cfg, - sectionKey: "googlechat", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg: cfg, - sectionKey: "googlechat", - accountId, - clearBaseFields: [ - "serviceAccount", - "serviceAccountFile", - "audienceType", - "audience", - "webhookPath", - "webhookUrl", - "botUser", - "name", - ], - }), + ...googleChatConfigBase, isConfigured: (account) => account.credentialSource !== "none", describeAccount: (account) => ({ accountId: account.accountId, diff --git a/extensions/googlechat/src/runtime.ts b/extensions/googlechat/src/runtime.ts index 55af03db04d..2276eb7dcfa 100644 --- a/extensions/googlechat/src/runtime.ts +++ b/extensions/googlechat/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/googlechat"; -let runtime: PluginRuntime | null = null; - -export function setGoogleChatRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getGoogleChatRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Google Chat runtime not initialized"); - } - return runtime; -} +const { setRuntime: setGoogleChatRuntime, getRuntime: getGoogleChatRuntime } = + createPluginRuntimeStore("Google Chat runtime not initialized"); +export { getGoogleChatRuntime, setGoogleChatRuntime }; diff --git a/extensions/imessage/src/runtime.ts b/extensions/imessage/src/runtime.ts index 866d9c8d380..a4b2f1a98de 100644 --- a/extensions/imessage/src/runtime.ts +++ b/extensions/imessage/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/imessage"; -let runtime: PluginRuntime | null = null; - -export function setIMessageRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getIMessageRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("iMessage runtime not initialized"); - } - return runtime; -} +const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } = + createPluginRuntimeStore("iMessage runtime not initialized"); +export { getIMessageRuntime, setIMessageRuntime }; diff --git a/extensions/irc/src/runtime.ts b/extensions/irc/src/runtime.ts index 51fcdd7c454..b5597236b7a 100644 --- a/extensions/irc/src/runtime.ts +++ b/extensions/irc/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/irc"; -let runtime: PluginRuntime | null = null; - -export function setIrcRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getIrcRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("IRC runtime not initialized"); - } - return runtime; -} +const { setRuntime: setIrcRuntime, getRuntime: getIrcRuntime } = + createPluginRuntimeStore("IRC runtime not initialized"); +export { getIrcRuntime, setIrcRuntime }; diff --git a/extensions/line/src/runtime.ts b/extensions/line/src/runtime.ts index 4f1a4fc121a..38ed57e7875 100644 --- a/extensions/line/src/runtime.ts +++ b/extensions/line/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/line"; -let runtime: PluginRuntime | null = null; - -export function setLineRuntime(r: PluginRuntime): void { - runtime = r; -} - -export function getLineRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("LINE runtime not initialized - plugin not registered"); - } - return runtime; -} +const { setRuntime: setLineRuntime, getRuntime: getLineRuntime } = + createPluginRuntimeStore("LINE runtime not initialized - plugin not registered"); +export { getLineRuntime, setLineRuntime }; diff --git a/extensions/matrix/src/runtime.ts b/extensions/matrix/src/runtime.ts index 4d94aacf99d..90fe7d1f8e9 100644 --- a/extensions/matrix/src/runtime.ts +++ b/extensions/matrix/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/matrix"; -let runtime: PluginRuntime | null = null; - -export function setMatrixRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getMatrixRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Matrix runtime not initialized"); - } - return runtime; -} +const { setRuntime: setMatrixRuntime, getRuntime: getMatrixRuntime } = + createPluginRuntimeStore("Matrix runtime not initialized"); +export { getMatrixRuntime, setMatrixRuntime }; diff --git a/extensions/mattermost/src/runtime.ts b/extensions/mattermost/src/runtime.ts index f6e5e83f270..8fe131f2335 100644 --- a/extensions/mattermost/src/runtime.ts +++ b/extensions/mattermost/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/mattermost"; -let runtime: PluginRuntime | null = null; - -export function setMattermostRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getMattermostRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Mattermost runtime not initialized"); - } - return runtime; -} +const { setRuntime: setMattermostRuntime, getRuntime: getMattermostRuntime } = + createPluginRuntimeStore("Mattermost runtime not initialized"); +export { getMattermostRuntime, setMattermostRuntime }; diff --git a/extensions/msteams/src/runtime.ts b/extensions/msteams/src/runtime.ts index 97d2272c101..04444a29fc1 100644 --- a/extensions/msteams/src/runtime.ts +++ b/extensions/msteams/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/msteams"; -let runtime: PluginRuntime | null = null; - -export function setMSTeamsRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getMSTeamsRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("MSTeams runtime not initialized"); - } - return runtime; -} +const { setRuntime: setMSTeamsRuntime, getRuntime: getMSTeamsRuntime } = + createPluginRuntimeStore("MSTeams runtime not initialized"); +export { getMSTeamsRuntime, setMSTeamsRuntime }; diff --git a/extensions/nextcloud-talk/src/runtime.ts b/extensions/nextcloud-talk/src/runtime.ts index 2a7718e1661..d4870a74839 100644 --- a/extensions/nextcloud-talk/src/runtime.ts +++ b/extensions/nextcloud-talk/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/nextcloud-talk"; -let runtime: PluginRuntime | null = null; - -export function setNextcloudTalkRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getNextcloudTalkRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Nextcloud Talk runtime not initialized"); - } - return runtime; -} +const { setRuntime: setNextcloudTalkRuntime, getRuntime: getNextcloudTalkRuntime } = + createPluginRuntimeStore("Nextcloud Talk runtime not initialized"); +export { getNextcloudTalkRuntime, setNextcloudTalkRuntime }; diff --git a/extensions/nostr/src/runtime.ts b/extensions/nostr/src/runtime.ts index dbcffde4979..1063bd8d6d3 100644 --- a/extensions/nostr/src/runtime.ts +++ b/extensions/nostr/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/nostr"; -let runtime: PluginRuntime | null = null; - -export function setNostrRuntime(next: PluginRuntime): void { - runtime = next; -} - -export function getNostrRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Nostr runtime not initialized"); - } - return runtime; -} +const { setRuntime: setNostrRuntime, getRuntime: getNostrRuntime } = + createPluginRuntimeStore("Nostr runtime not initialized"); +export { getNostrRuntime, setNostrRuntime }; diff --git a/extensions/signal/src/runtime.ts b/extensions/signal/src/runtime.ts index 21f90071ad8..fd6c5fbdae6 100644 --- a/extensions/signal/src/runtime.ts +++ b/extensions/signal/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/signal"; -let runtime: PluginRuntime | null = null; - -export function setSignalRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getSignalRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Signal runtime not initialized"); - } - return runtime; -} +const { setRuntime: setSignalRuntime, getRuntime: getSignalRuntime } = + createPluginRuntimeStore("Signal runtime not initialized"); +export { getSignalRuntime, setSignalRuntime }; diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 98010e907f4..1fdf4018f28 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -1,3 +1,4 @@ +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk"; import { buildAccountScopedDmSecurityPolicy, collectOpenProviderGroupPolicyWarnings, @@ -10,7 +11,6 @@ import { buildComputedAccountStatusSnapshot, buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, extractSlackToolSend, getChatChannelMeta, handleSlackMessageAction, @@ -32,7 +32,6 @@ import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, buildSlackThreadingToolContext, - setAccountEnabledInConfigSection, slackOnboardingAdapter, SlackConfigSchema, type ChannelPlugin, @@ -96,6 +95,15 @@ const slackConfigAccessors = createScopedAccountConfigAccessors({ resolveDefaultTo: (account: ResolvedSlackAccount) => account.config.defaultTo, }); +const slackConfigBase = createScopedChannelConfigBase({ + sectionKey: "slack", + listAccountIds: listSlackAccountIds, + resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultSlackAccountId, + clearBaseFields: ["botToken", "appToken", "name"], +}); + export const slackPlugin: ChannelPlugin = { id: "slack", meta: { @@ -144,25 +152,7 @@ export const slackPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.slack"] }, configSchema: buildChannelConfigSchema(SlackConfigSchema), config: { - listAccountIds: (cfg) => listSlackAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), - inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultSlackAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "slack", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "slack", - accountId, - clearBaseFields: ["botToken", "appToken", "name"], - }), + ...slackConfigBase, isConfigured: (account) => isSlackAccountConfigured(account), describeAccount: (account) => ({ accountId: account.accountId, diff --git a/extensions/slack/src/runtime.ts b/extensions/slack/src/runtime.ts index 02222d2b073..9ba83fcb4c8 100644 --- a/extensions/slack/src/runtime.ts +++ b/extensions/slack/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/slack"; -let runtime: PluginRuntime | null = null; - -export function setSlackRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getSlackRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Slack runtime not initialized"); - } - return runtime; -} +const { setRuntime: setSlackRuntime, getRuntime: getSlackRuntime } = + createPluginRuntimeStore("Slack runtime not initialized"); +export { getSlackRuntime, setSlackRuntime }; diff --git a/extensions/synology-chat/src/runtime.ts b/extensions/synology-chat/src/runtime.ts index f7ef39ff65f..6abb71d8188 100644 --- a/extensions/synology-chat/src/runtime.ts +++ b/extensions/synology-chat/src/runtime.ts @@ -1,20 +1,8 @@ -/** - * Plugin runtime singleton. - * Stores the PluginRuntime from api.runtime (set during register()). - * Used by channel.ts to access dispatch functions. - */ - +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/synology-chat"; -let runtime: PluginRuntime | null = null; - -export function setSynologyRuntime(r: PluginRuntime): void { - runtime = r; -} - -export function getSynologyRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Synology Chat runtime not initialized - plugin not registered"); - } - return runtime; -} +const { setRuntime: setSynologyRuntime, getRuntime: getSynologyRuntime } = + createPluginRuntimeStore( + "Synology Chat runtime not initialized - plugin not registered", + ); +export { getSynologyRuntime, setSynologyRuntime }; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 2cd2bf8ff51..d8879ab5858 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -1,3 +1,4 @@ +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk"; import { collectAllowlistProviderGroupPolicyWarnings, buildAccountScopedDmSecurityPolicy, @@ -12,7 +13,6 @@ import { clearAccountEntryFields, collectTelegramStatusIssues, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, getChatChannelMeta, inspectTelegramAccount, listTelegramAccountIds, @@ -31,7 +31,6 @@ import { resolveTelegramAccount, resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, - setAccountEnabledInConfigSection, telegramOnboardingAdapter, TelegramConfigSchema, type ChannelMessageActionAdapter, @@ -100,6 +99,15 @@ const telegramConfigAccessors = createScopedAccountConfigAccessors({ resolveDefaultTo: (account: ResolvedTelegramAccount) => account.config.defaultTo, }); +const telegramConfigBase = createScopedChannelConfigBase({ + sectionKey: "telegram", + listAccountIds: listTelegramAccountIds, + resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultTelegramAccountId, + clearBaseFields: ["botToken", "tokenFile", "name"], +}); + export const telegramPlugin: ChannelPlugin = { id: "telegram", meta: { @@ -136,25 +144,7 @@ export const telegramPlugin: ChannelPlugin listTelegramAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }), - inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultTelegramAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "telegram", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "telegram", - accountId, - clearBaseFields: ["botToken", "tokenFile", "name"], - }), + ...telegramConfigBase, isConfigured: (account, cfg) => { if (!account.token?.trim()) { return false; diff --git a/extensions/telegram/src/runtime.ts b/extensions/telegram/src/runtime.ts index dd1e3f9f2b8..4effcb7b5bf 100644 --- a/extensions/telegram/src/runtime.ts +++ b/extensions/telegram/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/telegram"; -let runtime: PluginRuntime | null = null; - -export function setTelegramRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getTelegramRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Telegram runtime not initialized"); - } - return runtime; -} +const { setRuntime: setTelegramRuntime, getRuntime: getTelegramRuntime } = + createPluginRuntimeStore("Telegram runtime not initialized"); +export { getTelegramRuntime, setTelegramRuntime }; diff --git a/extensions/tlon/src/runtime.ts b/extensions/tlon/src/runtime.ts index 0400d636b57..1551ea38f3f 100644 --- a/extensions/tlon/src/runtime.ts +++ b/extensions/tlon/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/tlon"; -let runtime: PluginRuntime | null = null; - -export function setTlonRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getTlonRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Tlon runtime not initialized"); - } - return runtime; -} +const { setRuntime: setTlonRuntime, getRuntime: getTlonRuntime } = + createPluginRuntimeStore("Tlon runtime not initialized"); +export { getTlonRuntime, setTlonRuntime }; diff --git a/extensions/twitch/src/runtime.ts b/extensions/twitch/src/runtime.ts index 5dfdd225c4c..f82e4313f81 100644 --- a/extensions/twitch/src/runtime.ts +++ b/extensions/twitch/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/twitch"; -let runtime: PluginRuntime | null = null; - -export function setTwitchRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getTwitchRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Twitch runtime not initialized"); - } - return runtime; -} +const { setRuntime: setTwitchRuntime, getRuntime: getTwitchRuntime } = + createPluginRuntimeStore("Twitch runtime not initialized"); +export { getTwitchRuntime, setTwitchRuntime }; diff --git a/extensions/whatsapp/src/runtime.ts b/extensions/whatsapp/src/runtime.ts index 490c7873219..c5044db6a29 100644 --- a/extensions/whatsapp/src/runtime.ts +++ b/extensions/whatsapp/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/whatsapp"; -let runtime: PluginRuntime | null = null; - -export function setWhatsAppRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getWhatsAppRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("WhatsApp runtime not initialized"); - } - return runtime; -} +const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } = + createPluginRuntimeStore("WhatsApp runtime not initialized"); +export { getWhatsAppRuntime, setWhatsAppRuntime }; diff --git a/extensions/zalo/src/config-schema.ts b/extensions/zalo/src/config-schema.ts index 7f2c0f360ba..f2e5c5803e7 100644 --- a/extensions/zalo/src/config-schema.ts +++ b/extensions/zalo/src/config-schema.ts @@ -1,9 +1,8 @@ +import { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema } from "openclaw/plugin-sdk"; import { MarkdownConfigSchema } from "openclaw/plugin-sdk/zalo"; import { z } from "zod"; import { buildSecretInputSchema } from "./secret-input.js"; -const allowFromEntry = z.union([z.string(), z.number()]); - const zaloAccountSchema = z.object({ name: z.string().optional(), enabled: z.boolean().optional(), @@ -14,15 +13,12 @@ const zaloAccountSchema = z.object({ webhookSecret: buildSecretInputSchema().optional(), webhookPath: z.string().optional(), dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), - allowFrom: z.array(allowFromEntry).optional(), + allowFrom: z.array(AllowFromEntrySchema).optional(), groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(), - groupAllowFrom: z.array(allowFromEntry).optional(), + groupAllowFrom: z.array(AllowFromEntrySchema).optional(), mediaMaxMb: z.number().optional(), proxy: z.string().optional(), responsePrefix: z.string().optional(), }); -export const ZaloConfigSchema = zaloAccountSchema.extend({ - accounts: z.object({}).catchall(zaloAccountSchema).optional(), - defaultAccount: z.string().optional(), -}); +export const ZaloConfigSchema = buildCatchallMultiAccountChannelSchema(zaloAccountSchema); diff --git a/extensions/zalo/src/runtime.ts b/extensions/zalo/src/runtime.ts index 5d96660a7d3..74542043913 100644 --- a/extensions/zalo/src/runtime.ts +++ b/extensions/zalo/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/zalo"; -let runtime: PluginRuntime | null = null; - -export function setZaloRuntime(next: PluginRuntime): void { - runtime = next; -} - -export function getZaloRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Zalo runtime not initialized"); - } - return runtime; -} +const { setRuntime: setZaloRuntime, getRuntime: getZaloRuntime } = + createPluginRuntimeStore("Zalo runtime not initialized"); +export { getZaloRuntime, setZaloRuntime }; diff --git a/extensions/zalouser/src/config-schema.ts b/extensions/zalouser/src/config-schema.ts index 9d6b0bcec4a..dd0f9c51fbe 100644 --- a/extensions/zalouser/src/config-schema.ts +++ b/extensions/zalouser/src/config-schema.ts @@ -1,8 +1,7 @@ +import { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema } from "openclaw/plugin-sdk"; import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/zalouser"; import { z } from "zod"; -const allowFromEntry = z.union([z.string(), z.number()]); - const groupConfigSchema = z.object({ allow: z.boolean().optional(), enabled: z.boolean().optional(), @@ -16,16 +15,13 @@ const zalouserAccountSchema = z.object({ markdown: MarkdownConfigSchema, profile: z.string().optional(), dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), - allowFrom: z.array(allowFromEntry).optional(), + allowFrom: z.array(AllowFromEntrySchema).optional(), historyLimit: z.number().int().min(0).optional(), - groupAllowFrom: z.array(allowFromEntry).optional(), + groupAllowFrom: z.array(AllowFromEntrySchema).optional(), groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(), groups: z.object({}).catchall(groupConfigSchema).optional(), messagePrefix: z.string().optional(), responsePrefix: z.string().optional(), }); -export const ZalouserConfigSchema = zalouserAccountSchema.extend({ - accounts: z.object({}).catchall(zalouserAccountSchema).optional(), - defaultAccount: z.string().optional(), -}); +export const ZalouserConfigSchema = buildCatchallMultiAccountChannelSchema(zalouserAccountSchema); diff --git a/extensions/zalouser/src/runtime.ts b/extensions/zalouser/src/runtime.ts index 42cb9def444..473df2b8fbe 100644 --- a/extensions/zalouser/src/runtime.ts +++ b/extensions/zalouser/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/zalouser"; -let runtime: PluginRuntime | null = null; - -export function setZalouserRuntime(next: PluginRuntime): void { - runtime = next; -} - -export function getZalouserRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Zalouser runtime not initialized"); - } - return runtime; -} +const { setRuntime: setZalouserRuntime, getRuntime: getZalouserRuntime } = + createPluginRuntimeStore("Zalouser runtime not initialized"); +export { getZalouserRuntime, setZalouserRuntime }; diff --git a/package.json b/package.json index 93692b174ae..753fe15a059 100644 --- a/package.json +++ b/package.json @@ -224,6 +224,7 @@ "android:test": "cd apps/android && ./gradlew :app:testDebugUnitTest", "android:test:integration": "OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 vitest run --config vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts", "build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", + "build:docker": "node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", diff --git a/scripts/docker/cleanup-smoke/Dockerfile b/scripts/docker/cleanup-smoke/Dockerfile index 1d9288b0df5..34ce3327ac7 100644 --- a/scripts/docker/cleanup-smoke/Dockerfile +++ b/scripts/docker/cleanup-smoke/Dockerfile @@ -13,7 +13,6 @@ RUN corepack enable \ && pnpm install --frozen-lockfile COPY . . -COPY scripts/docker/cleanup-smoke/run.sh /usr/local/bin/openclaw-cleanup-smoke -RUN chmod +x /usr/local/bin/openclaw-cleanup-smoke +COPY --chmod=755 scripts/docker/cleanup-smoke/run.sh /usr/local/bin/openclaw-cleanup-smoke ENTRYPOINT ["/usr/local/bin/openclaw-cleanup-smoke"] diff --git a/scripts/docker/install-sh-e2e/Dockerfile b/scripts/docker/install-sh-e2e/Dockerfile index 26b69b0b7ef..839d637a04b 100644 --- a/scripts/docker/install-sh-e2e/Dockerfile +++ b/scripts/docker/install-sh-e2e/Dockerfile @@ -9,8 +9,7 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh -COPY run.sh /usr/local/bin/openclaw-install-e2e -RUN chmod +x /usr/local/bin/openclaw-install-e2e +COPY --chmod=755 run.sh /usr/local/bin/openclaw-install-e2e RUN useradd --create-home --shell /bin/bash appuser USER appuser diff --git a/scripts/docker/install-sh-nonroot/Dockerfile b/scripts/docker/install-sh-nonroot/Dockerfile index 5543ef84882..9b7912323a4 100644 --- a/scripts/docker/install-sh-nonroot/Dockerfile +++ b/scripts/docker/install-sh-nonroot/Dockerfile @@ -28,7 +28,6 @@ ENV NPM_CONFIG_AUDIT=false COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh -COPY install-sh-nonroot/run.sh /usr/local/bin/openclaw-install-nonroot -RUN sudo chmod +x /usr/local/bin/openclaw-install-nonroot +COPY --chmod=755 install-sh-nonroot/run.sh /usr/local/bin/openclaw-install-nonroot ENTRYPOINT ["/usr/local/bin/openclaw-install-nonroot"] diff --git a/scripts/docker/install-sh-smoke/Dockerfile b/scripts/docker/install-sh-smoke/Dockerfile index ee3221607fb..eb2dcfe5226 100644 --- a/scripts/docker/install-sh-smoke/Dockerfile +++ b/scripts/docker/install-sh-smoke/Dockerfile @@ -20,7 +20,6 @@ RUN set -eux; \ COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh -COPY install-sh-smoke/run.sh /usr/local/bin/openclaw-install-smoke -RUN chmod +x /usr/local/bin/openclaw-install-smoke +COPY --chmod=755 install-sh-smoke/run.sh /usr/local/bin/openclaw-install-smoke ENTRYPOINT ["/usr/local/bin/openclaw-install-smoke"] diff --git a/scripts/podman/openclaw.container.in b/scripts/podman/openclaw.container.in index db643ca42bc..e0ad2ac8bde 100644 --- a/scripts/podman/openclaw.container.in +++ b/scripts/podman/openclaw.container.in @@ -11,7 +11,7 @@ ContainerName=openclaw UserNS=keep-id # Keep container UID/GID aligned with the invoking user so mounted config is readable. User=%U:%G -Volume={{OPENCLAW_HOME}}/.openclaw:/home/node/.openclaw +Volume={{OPENCLAW_HOME}}/.openclaw:/home/node/.openclaw:Z EnvironmentFile={{OPENCLAW_HOME}}/.openclaw/.env Environment=HOME=/home/node Environment=TERM=xterm-256color diff --git a/scripts/run-openclaw-podman.sh b/scripts/run-openclaw-podman.sh index 33e9f6d7d94..68b64915479 100755 --- a/scripts/run-openclaw-podman.sh +++ b/scripts/run-openclaw-podman.sh @@ -183,14 +183,30 @@ fi ENV_FILE_ARGS=() [[ -f "$ENV_FILE" ]] && ENV_FILE_ARGS+=(--env-file "$ENV_FILE") +# On Linux with SELinux enforcing/permissive, add ,Z so Podman relabels the +# bind-mounted directories and the container can access them. +SELINUX_MOUNT_OPTS="" +if [[ -z "${OPENCLAW_BIND_MOUNT_OPTIONS:-}" ]]; then + if [[ "$(uname -s 2>/dev/null)" == "Linux" ]] && command -v getenforce >/dev/null 2>&1; then + _selinux_mode="$(getenforce 2>/dev/null || true)" + if [[ "$_selinux_mode" == "Enforcing" || "$_selinux_mode" == "Permissive" ]]; then + SELINUX_MOUNT_OPTS=",Z" + fi + fi +else + # Honour explicit override (e.g. OPENCLAW_BIND_MOUNT_OPTIONS=":Z" → strip leading colon for inline use). + SELINUX_MOUNT_OPTS="${OPENCLAW_BIND_MOUNT_OPTIONS#:}" + [[ -n "$SELINUX_MOUNT_OPTS" ]] && SELINUX_MOUNT_OPTS=",$SELINUX_MOUNT_OPTS" +fi + if [[ "$RUN_SETUP" == true ]]; then exec podman run --pull="$PODMAN_PULL" --rm -it \ --init \ "${USERNS_ARGS[@]}" "${RUN_USER_ARGS[@]}" \ -e HOME=/home/node -e TERM=xterm-256color -e BROWSER=echo \ -e OPENCLAW_GATEWAY_TOKEN="$OPENCLAW_GATEWAY_TOKEN" \ - -v "$CONFIG_DIR:/home/node/.openclaw:rw" \ - -v "$WORKSPACE_DIR:/home/node/.openclaw/workspace:rw" \ + -v "$CONFIG_DIR:/home/node/.openclaw:rw${SELINUX_MOUNT_OPTS}" \ + -v "$WORKSPACE_DIR:/home/node/.openclaw/workspace:rw${SELINUX_MOUNT_OPTS}" \ "${ENV_FILE_ARGS[@]}" \ "$OPENCLAW_IMAGE" \ node dist/index.js onboard "$@" @@ -203,8 +219,8 @@ podman run --pull="$PODMAN_PULL" -d --replace \ -e HOME=/home/node -e TERM=xterm-256color \ -e OPENCLAW_GATEWAY_TOKEN="$OPENCLAW_GATEWAY_TOKEN" \ "${ENV_FILE_ARGS[@]}" \ - -v "$CONFIG_DIR:/home/node/.openclaw:rw" \ - -v "$WORKSPACE_DIR:/home/node/.openclaw/workspace:rw" \ + -v "$CONFIG_DIR:/home/node/.openclaw:rw${SELINUX_MOUNT_OPTS}" \ + -v "$WORKSPACE_DIR:/home/node/.openclaw/workspace:rw${SELINUX_MOUNT_OPTS}" \ -p "${HOST_GATEWAY_PORT}:18789" \ -p "${HOST_BRIDGE_PORT}:18790" \ "$OPENCLAW_IMAGE" \ diff --git a/setup-podman.sh b/setup-podman.sh index 95a4415487c..5b904684ffa 100755 --- a/setup-podman.sh +++ b/setup-podman.sh @@ -80,12 +80,17 @@ run_root() { } run_as_user() { + # When switching users, the caller's cwd may be inaccessible to the target + # user (e.g. a private home dir). Wrap in a subshell that cd's to a + # world-traversable directory so sudo/runuser don't fail with "cannot chdir". + # TODO: replace with fully rootless podman build to eliminate the need for + # user-switching entirely. local user="$1" shift if command -v sudo >/dev/null 2>&1; then - sudo -u "$user" "$@" + ( cd /tmp 2>/dev/null || cd /; sudo -u "$user" "$@" ) elif is_root && command -v runuser >/dev/null 2>&1; then - runuser -u "$user" -- "$@" + ( cd /tmp 2>/dev/null || cd /; runuser -u "$user" -- "$@" ) else echo "Need sudo (or root+runuser) to run commands as $user." >&2 exit 1 diff --git a/src/agents/models-config.applies-config-env-vars.test.ts b/src/agents/models-config.applies-config-env-vars.test.ts index fa4adb86168..4de78975cdb 100644 --- a/src/agents/models-config.applies-config-env-vars.test.ts +++ b/src/agents/models-config.applies-config-env-vars.test.ts @@ -22,7 +22,7 @@ describe("models-config", () => { models: { providers: {} }, env: { vars: { - OPENROUTER_API_KEY: "from-config", + OPENROUTER_API_KEY: "from-config", // pragma: allowlist secret [TEST_ENV_VAR]: "from-config", }, }, @@ -44,13 +44,13 @@ describe("models-config", () => { it("does not overwrite already-set host env vars while ensuring models.json", async () => { await withTempHome(async () => { await withTempEnv(["OPENROUTER_API_KEY", TEST_ENV_VAR], async () => { - process.env.OPENROUTER_API_KEY = "from-host"; + process.env.OPENROUTER_API_KEY = "from-host"; // pragma: allowlist secret process.env[TEST_ENV_VAR] = "from-host"; const cfg: OpenClawConfig = { models: { providers: {} }, env: { vars: { - OPENROUTER_API_KEY: "from-config", + OPENROUTER_API_KEY: "from-config", // pragma: allowlist secret [TEST_ENV_VAR]: "from-config", }, }, diff --git a/src/agents/models-config.providers.matrix.test.ts b/src/agents/models-config.providers.matrix.test.ts index ec2b743afb6..942cb68ab35 100644 --- a/src/agents/models-config.providers.matrix.test.ts +++ b/src/agents/models-config.providers.matrix.test.ts @@ -39,7 +39,7 @@ async function writeAuthProfiles( const MATRIX_CASES: MatrixCase[] = [ { name: "env api key injects a simple provider", - env: { NVIDIA_API_KEY: "test-nvidia-key" }, + env: { NVIDIA_API_KEY: "test-nvidia-key" }, // pragma: allowlist secret assertProviders(providers) { expect(providers?.nvidia?.apiKey).toBe("NVIDIA_API_KEY"); expect(providers?.nvidia?.baseUrl).toBe("https://integrate.api.nvidia.com/v1"); @@ -48,7 +48,7 @@ const MATRIX_CASES: MatrixCase[] = [ }, { name: "env api key injects paired plan providers", - env: { VOLCANO_ENGINE_API_KEY: "test-volcengine-key" }, + env: { VOLCANO_ENGINE_API_KEY: "test-volcengine-key" }, // pragma: allowlist secret assertProviders(providers) { expect(providers?.volcengine?.apiKey).toBe("VOLCANO_ENGINE_API_KEY"); expect(providers?.["volcengine-plan"]?.apiKey).toBe("VOLCANO_ENGINE_API_KEY"); @@ -116,7 +116,7 @@ const MATRIX_CASES: MatrixCase[] = [ }, { name: "explicit vllm config suppresses implicit vllm injection", - env: { VLLM_API_KEY: "test-vllm-key" }, + env: { VLLM_API_KEY: "test-vllm-key" }, // pragma: allowlist secret explicitProviders: { vllm: { baseUrl: "http://127.0.0.1:8000/v1", diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index c6eb54b0501..9ef2a3efe76 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const { hookRunner, + ensureRuntimePluginsLoaded, resolveModelMock, sessionCompactImpl, triggerInternalHook, @@ -12,6 +13,7 @@ const { runBeforeCompaction: vi.fn(), runAfterCompaction: vi.fn(), }, + ensureRuntimePluginsLoaded: vi.fn(), resolveModelMock: vi.fn(() => ({ model: { provider: "openai", api: "responses", id: "fake", input: [] }, error: null, @@ -32,6 +34,10 @@ vi.mock("../../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: () => hookRunner, })); +vi.mock("../runtime-plugins.js", () => ({ + ensureRuntimePluginsLoaded, +})); + vi.mock("../../hooks/internal-hooks.js", async () => { const actual = await vi.importActual( "../../hooks/internal-hooks.js", @@ -254,6 +260,7 @@ const sessionHook = (action: string) => describe("compactEmbeddedPiSessionDirect hooks", () => { beforeEach(() => { + ensureRuntimePluginsLoaded.mockReset(); triggerInternalHook.mockClear(); hookRunner.hasHooks.mockReset(); hookRunner.runBeforeCompaction.mockReset(); @@ -279,6 +286,19 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { unregisterApiProviders(getCustomApiRegistrySourceId("ollama")); }); + it("bootstraps runtime plugins with the resolved workspace", async () => { + await compactEmbeddedPiSessionDirect({ + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp/workspace", + }); + + expect(ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ + config: undefined, + workspaceDir: "/tmp/workspace", + }); + }); + it("emits internal + plugin compaction hooks with counts", async () => { hookRunner.hasHooks.mockReturnValue(true); let sanitizedCount = 0; diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index ad5cecd8bd2..91f99571db4 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -50,6 +50,7 @@ import { } from "../pi-embedded-helpers.js"; import { createPreparedEmbeddedPiSettingsManager } from "../pi-project-settings.js"; import { createOpenClawCodingTools } from "../pi-tools.js"; +import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js"; import { resolveSandboxContext } from "../sandbox.js"; import { repairSessionFileIfNeeded } from "../session-file-repair.js"; import { guardSessionManager } from "../session-tool-result-guard-wrapper.js"; @@ -269,6 +270,10 @@ export async function compactEmbeddedPiSessionDirect( const maxAttempts = params.maxAttempts ?? 1; const runId = params.runId ?? params.sessionId; const resolvedWorkspace = resolveUserPath(params.workspaceDir); + ensureRuntimePluginsLoaded({ + config: params.config, + workspaceDir: resolvedWorkspace, + }); const prevCwd = process.cwd(); // Resolve compaction model: prefer config override, then fall back to caller-supplied model @@ -910,6 +915,10 @@ export async function compactEmbeddedPiSession( params.enqueue ?? ((task, opts) => enqueueCommandInLane(globalLane, task, opts)); return enqueueCommandInLane(sessionLane, () => enqueueGlobal(async () => { + ensureRuntimePluginsLoaded({ + config: params.config, + workspaceDir: params.workspaceDir, + }); ensureContextEnginesInitialized(); const contextEngine = await resolveContextEngine(params.config); try { diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index 0dfde212a0a..e67fb2c2898 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -664,6 +664,60 @@ describe("resolveModel", () => { }); }); + it("normalizes openai-codex gpt-5.4 overrides away from /v1/responses", () => { + mockOpenAICodexTemplateModel(); + + const cfg: OpenClawConfig = { + models: { + providers: { + "openai-codex": { + baseUrl: "https://api.openai.com/v1", + api: "openai-responses", + }, + }, + }, + } as unknown as OpenClawConfig; + + expectResolvedForwardCompatFallback({ + provider: "openai-codex", + id: "gpt-5.4", + cfg, + expectedModel: { + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + id: "gpt-5.4", + provider: "openai-codex", + }, + }); + }); + + it("does not rewrite openai baseUrl when openai-codex api stays non-codex", () => { + mockOpenAICodexTemplateModel(); + + const cfg: OpenClawConfig = { + models: { + providers: { + "openai-codex": { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + }, + }, + }, + } as unknown as OpenClawConfig; + + expectResolvedForwardCompatFallback({ + provider: "openai-codex", + id: "gpt-5.4", + cfg, + expectedModel: { + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + id: "gpt-5.4", + provider: "openai-codex", + }, + }); + }); + it("includes auth hint for unknown ollama models (#17328)", () => { // resetMockDiscoverModels() in beforeEach already sets find → null const result = resolveModel("ollama", "gemma3:4b", "/tmp/agent"); diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index 38d554a2bab..5995bb40099 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -23,6 +23,8 @@ type InlineProviderConfig = { headers?: unknown; }; +const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; + function sanitizeModelHeaders( headers: unknown, opts?: { stripSecretRefMarkers?: boolean }, @@ -43,6 +45,60 @@ function sanitizeModelHeaders( return Object.keys(next).length > 0 ? next : undefined; } +function isOpenAIApiBaseUrl(baseUrl?: string): boolean { + const trimmed = baseUrl?.trim(); + if (!trimmed) { + return false; + } + return /^https?:\/\/api\.openai\.com(?:\/v1)?\/?$/i.test(trimmed); +} + +function isOpenAICodexBaseUrl(baseUrl?: string): boolean { + const trimmed = baseUrl?.trim(); + if (!trimmed) { + return false; + } + return /^https?:\/\/chatgpt\.com\/backend-api\/?$/i.test(trimmed); +} + +function normalizeOpenAICodexTransport(params: { + provider: string; + model: Model; +}): Model { + if (normalizeProviderId(params.provider) !== "openai-codex") { + return params.model; + } + + const useCodexTransport = + !params.model.baseUrl || + isOpenAIApiBaseUrl(params.model.baseUrl) || + isOpenAICodexBaseUrl(params.model.baseUrl); + + const nextApi = + useCodexTransport && params.model.api === "openai-responses" + ? ("openai-codex-responses" as const) + : params.model.api; + const nextBaseUrl = + nextApi === "openai-codex-responses" && + (!params.model.baseUrl || isOpenAIApiBaseUrl(params.model.baseUrl)) + ? OPENAI_CODEX_BASE_URL + : params.model.baseUrl; + + if (nextApi === params.model.api && nextBaseUrl === params.model.baseUrl) { + return params.model; + } + + return { + ...params.model, + api: nextApi, + baseUrl: nextBaseUrl, + } as Model; +} + +function normalizeResolvedModel(params: { provider: string; model: Model }): Model { + return normalizeModelCompat(normalizeOpenAICodexTransport(params)); +} + export { buildModelAliasLines }; function resolveConfiguredProviderConfig( @@ -145,13 +201,14 @@ export function resolveModelWithRegistry(params: { const model = modelRegistry.find(provider, modelId) as Model | null; if (model) { - return normalizeModelCompat( - applyConfiguredProviderOverrides({ + return normalizeResolvedModel({ + provider, + model: applyConfiguredProviderOverrides({ discoveredModel: model, providerConfig, modelId, }), - ); + }); } const providers = cfg?.models?.providers ?? {}; @@ -161,64 +218,71 @@ export function resolveModelWithRegistry(params: { (entry) => normalizeProviderId(entry.provider) === normalizedProvider && entry.id === modelId, ); if (inlineMatch?.api) { - return normalizeModelCompat(inlineMatch as Model); + return normalizeResolvedModel({ provider, model: inlineMatch as Model }); } // Forward-compat fallbacks must be checked BEFORE the generic providerCfg fallback. // Otherwise, configured providers can default to a generic API and break specific transports. const forwardCompat = resolveForwardCompatModel(provider, modelId, modelRegistry); if (forwardCompat) { - return normalizeModelCompat( - applyConfiguredProviderOverrides({ + return normalizeResolvedModel({ + provider, + model: applyConfiguredProviderOverrides({ discoveredModel: forwardCompat, providerConfig, modelId, }), - ); + }); } // OpenRouter is a pass-through proxy - any model ID available on OpenRouter // should work without being pre-registered in the local catalog. if (normalizedProvider === "openrouter") { - return normalizeModelCompat({ - id: modelId, - name: modelId, - api: "openai-completions", + return normalizeResolvedModel({ provider, - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: DEFAULT_CONTEXT_TOKENS, - // Align with OPENROUTER_DEFAULT_MAX_TOKENS in models-config.providers.ts - maxTokens: 8192, - } as Model); + model: { + id: modelId, + name: modelId, + api: "openai-completions", + provider, + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: DEFAULT_CONTEXT_TOKENS, + // Align with OPENROUTER_DEFAULT_MAX_TOKENS in models-config.providers.ts + maxTokens: 8192, + } as Model, + }); } const configuredModel = providerConfig?.models?.find((candidate) => candidate.id === modelId); const providerHeaders = sanitizeModelHeaders(providerConfig?.headers); const modelHeaders = sanitizeModelHeaders(configuredModel?.headers); if (providerConfig || modelId.startsWith("mock-")) { - return normalizeModelCompat({ - id: modelId, - name: modelId, - api: providerConfig?.api ?? "openai-responses", + return normalizeResolvedModel({ provider, - baseUrl: providerConfig?.baseUrl, - reasoning: configuredModel?.reasoning ?? false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: - configuredModel?.contextWindow ?? - providerConfig?.models?.[0]?.contextWindow ?? - DEFAULT_CONTEXT_TOKENS, - maxTokens: - configuredModel?.maxTokens ?? - providerConfig?.models?.[0]?.maxTokens ?? - DEFAULT_CONTEXT_TOKENS, - headers: - providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined, - } as Model); + model: { + id: modelId, + name: modelId, + api: providerConfig?.api ?? "openai-responses", + provider, + baseUrl: providerConfig?.baseUrl, + reasoning: configuredModel?.reasoning ?? false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: + configuredModel?.contextWindow ?? + providerConfig?.models?.[0]?.contextWindow ?? + DEFAULT_CONTEXT_TOKENS, + maxTokens: + configuredModel?.maxTokens ?? + providerConfig?.models?.[0]?.maxTokens ?? + DEFAULT_CONTEXT_TOKENS, + headers: + providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined, + } as Model, + }); } return undefined; diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index c96089a9f55..21b29fe2cb6 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -54,6 +54,7 @@ import { pickFallbackThinkingLevel, type FailoverReason, } from "../pi-embedded-helpers.js"; +import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js"; import { derivePromptTokens, normalizeUsage, type UsageLike } from "../usage.js"; import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js"; import { resolveGlobalLane, resolveSessionLane } from "./lanes.js"; @@ -287,6 +288,10 @@ export async function runEmbeddedPiAgent( `[workspace-fallback] caller=runEmbeddedPiAgent reason=${workspaceResolution.fallbackReason} run=${params.runId} session=${redactedSessionId} sessionKey=${redactedSessionKey} agent=${workspaceResolution.agentId} workspace=${redactedWorkspace}`, ); } + ensureRuntimePluginsLoaded({ + config: params.config, + workspaceDir: resolvedWorkspace, + }); const prevCwd = process.cwd(); let provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 76c4253aa4b..70bd3242f7c 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -135,9 +135,15 @@ describe("resolvePromptModeForSession", () => { expect(resolvePromptModeForSession("agent:main:subagent:child")).toBe("minimal"); }); - it("uses full mode for cron sessions", () => { - expect(resolvePromptModeForSession("agent:main:cron:job-1")).toBe("full"); - expect(resolvePromptModeForSession("agent:main:cron:job-1:run:run-abc")).toBe("full"); + it("uses minimal mode for cron sessions", () => { + expect(resolvePromptModeForSession("agent:main:cron:job-1")).toBe("minimal"); + expect(resolvePromptModeForSession("agent:main:cron:job-1:run:run-abc")).toBe("minimal"); + }); + + it("uses full mode for regular and undefined sessions", () => { + expect(resolvePromptModeForSession(undefined)).toBe("full"); + expect(resolvePromptModeForSession("agent:main")).toBe("full"); + expect(resolvePromptModeForSession("agent:main:thread:abc")).toBe("full"); }); }); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index b57159e52aa..e480eb77797 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -19,7 +19,7 @@ import type { PluginHookBeforeAgentStartResult, PluginHookBeforePromptBuildResult, } from "../../../plugins/types.js"; -import { isSubagentSessionKey } from "../../../routing/session-key.js"; +import { isCronSessionKey, isSubagentSessionKey } from "../../../routing/session-key.js"; import { joinPresentTextSegments } from "../../../shared/text/join-segments.js"; import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js"; import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js"; @@ -613,7 +613,7 @@ export function resolvePromptModeForSession(sessionKey?: string): "minimal" | "f if (!sessionKey) { return "full"; } - return isSubagentSessionKey(sessionKey) ? "minimal" : "full"; + return isSubagentSessionKey(sessionKey) || isCronSessionKey(sessionKey) ? "minimal" : "full"; } export function resolveAttemptFsWorkspaceOnly(params: { diff --git a/src/agents/pi-embedded-runner/usage-reporting.test.ts b/src/agents/pi-embedded-runner/usage-reporting.test.ts index f4d6f5cbe44..48cb586e727 100644 --- a/src/agents/pi-embedded-runner/usage-reporting.test.ts +++ b/src/agents/pi-embedded-runner/usage-reporting.test.ts @@ -1,5 +1,14 @@ import "./run.overflow-compaction.mocks.shared.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; + +const runtimePluginMocks = vi.hoisted(() => ({ + ensureRuntimePluginsLoaded: vi.fn(), +})); + +vi.mock("../runtime-plugins.js", () => ({ + ensureRuntimePluginsLoaded: runtimePluginMocks.ensureRuntimePluginsLoaded, +})); + import { runEmbeddedPiAgent } from "./run.js"; import { runEmbeddedAttempt } from "./run/attempt.js"; @@ -10,6 +19,32 @@ describe("runEmbeddedPiAgent usage reporting", () => { vi.clearAllMocks(); }); + it("bootstraps runtime plugins with the resolved workspace before running", async () => { + mockedRunEmbeddedAttempt.mockResolvedValueOnce({ + aborted: false, + promptError: null, + timedOut: false, + sessionIdUsed: "test-session", + assistantTexts: ["Response 1"], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + await runEmbeddedPiAgent({ + sessionId: "test-session", + sessionKey: "test-key", + sessionFile: "/tmp/session.json", + workspaceDir: "/tmp/workspace", + prompt: "hello", + timeoutMs: 30000, + runId: "run-plugin-bootstrap", + }); + + expect(runtimePluginMocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ + config: undefined, + workspaceDir: "/tmp/workspace", + }); + }); + it("forwards sender identity fields into embedded attempts", async () => { mockedRunEmbeddedAttempt.mockResolvedValueOnce({ aborted: false, diff --git a/src/agents/runtime-plugins.ts b/src/agents/runtime-plugins.ts new file mode 100644 index 00000000000..ace53258e0f --- /dev/null +++ b/src/agents/runtime-plugins.ts @@ -0,0 +1,18 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { loadOpenClawPlugins } from "../plugins/loader.js"; +import { resolveUserPath } from "../utils.js"; + +export function ensureRuntimePluginsLoaded(params: { + config?: OpenClawConfig; + workspaceDir?: string | null; +}): void { + const workspaceDir = + typeof params.workspaceDir === "string" && params.workspaceDir.trim() + ? resolveUserPath(params.workspaceDir) + : undefined; + + loadOpenClawPlugins({ + config: params.config, + workspaceDir, + }); +} diff --git a/src/agents/subagent-announce.timeout.test.ts b/src/agents/subagent-announce.timeout.test.ts index 346989f493e..1c4925d9272 100644 --- a/src/agents/subagent-announce.timeout.test.ts +++ b/src/agents/subagent-announce.timeout.test.ts @@ -197,6 +197,25 @@ describe("subagent announce timeout config", () => { expect(internalEvents[0]?.announceType).toBe("cron job"); }); + it("regression, keeps child announce internal when requester is a cron run session", async () => { + const cronSessionKey = "agent:main:cron:daily-check:run:run-123"; + + await runAnnounceFlowForTest("run-cron-internal", { + requesterSessionKey: cronSessionKey, + requesterDisplayKey: cronSessionKey, + requesterOrigin: { channel: "discord", to: "channel:cron-results", accountId: "acct-1" }, + }); + + const directAgentCall = findGatewayCall( + (call) => call.method === "agent" && call.expectFinal === true, + ); + expect(directAgentCall?.params?.sessionKey).toBe(cronSessionKey); + expect(directAgentCall?.params?.deliver).toBe(false); + expect(directAgentCall?.params?.channel).toBeUndefined(); + expect(directAgentCall?.params?.to).toBeUndefined(); + expect(directAgentCall?.params?.accountId).toBeUndefined(); + }); + it("regression, routes child announce to parent session instead of grandparent when parent session still exists", async () => { const parentSessionKey = "agent:main:subagent:parent"; requesterDepthResolver = (sessionKey?: string) => diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index 83391755e9c..62b2cc6f0d3 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -14,6 +14,7 @@ import type { ConversationRef } from "../infra/outbound/session-binding-service. import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; import { normalizeAccountId, normalizeMainKey } from "../routing/session-key.js"; import { defaultRuntime } from "../runtime.js"; +import { isCronSessionKey } from "../sessions/session-key-utils.js"; import { extractTextFromChatContent } from "../shared/chat-content.js"; import { type DeliveryContext, @@ -78,6 +79,10 @@ function resolveSubagentAnnounceTimeoutMs(cfg: ReturnType): n return Math.min(Math.max(1, Math.floor(configured)), MAX_TIMER_SAFE_TIMEOUT_MS); } +function isInternalAnnounceRequesterSession(sessionKey: string | undefined): boolean { + return getSubagentDepthFromSessionStore(sessionKey) >= 1 || isCronSessionKey(sessionKey); +} + function summarizeDeliveryError(error: unknown): string { if (error instanceof Error) { return error.message || "error"; @@ -580,8 +585,7 @@ async function resolveSubagentCompletionOrigin(params: { async function sendAnnounce(item: AnnounceQueueItem) { const cfg = loadConfig(); const announceTimeoutMs = resolveSubagentAnnounceTimeoutMs(cfg); - const requesterDepth = getSubagentDepthFromSessionStore(item.sessionKey); - const requesterIsSubagent = requesterDepth >= 1; + const requesterIsSubagent = isInternalAnnounceRequesterSession(item.sessionKey); const origin = item.origin; const threadId = origin?.threadId != null && origin.threadId !== "" ? String(origin.threadId) : undefined; @@ -1216,6 +1220,8 @@ export async function runSubagentAnnounceFlow(params: { } let requesterDepth = getSubagentDepthFromSessionStore(targetRequesterSessionKey); + const requesterIsInternalSession = () => + requesterDepth >= 1 || isCronSessionKey(targetRequesterSessionKey); let childCompletionFindings: string | undefined; let subagentRegistryRuntime: @@ -1339,7 +1345,7 @@ export async function runSubagentAnnounceFlow(params: { const announceSessionId = childSessionId || "unknown"; const findings = childCompletionFindings || reply || "(no output)"; - let requesterIsSubagent = requesterDepth >= 1; + let requesterIsSubagent = requesterIsInternalSession(); if (requesterIsSubagent) { const { isSubagentSessionRunActive, @@ -1363,7 +1369,7 @@ export async function runSubagentAnnounceFlow(params: { targetRequesterOrigin = normalizeDeliveryContext(fallback.requesterOrigin) ?? targetRequesterOrigin; requesterDepth = getSubagentDepthFromSessionStore(targetRequesterSessionKey); - requesterIsSubagent = requesterDepth >= 1; + requesterIsSubagent = requesterIsInternalSession(); } } } diff --git a/src/agents/subagent-registry.context-engine.test.ts b/src/agents/subagent-registry.context-engine.test.ts new file mode 100644 index 00000000000..59eea1bd4c7 --- /dev/null +++ b/src/agents/subagent-registry.context-engine.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + ensureRuntimePluginsLoaded: vi.fn(), + ensureContextEnginesInitialized: vi.fn(), + resolveContextEngine: vi.fn(), + onSubagentEnded: vi.fn(async () => {}), + onAgentEvent: vi.fn(() => () => {}), + persistSubagentRunsToDisk: vi.fn(), +})); + +vi.mock("../config/config.js", async () => { + const actual = await vi.importActual("../config/config.js"); + return { + ...actual, + loadConfig: vi.fn(() => ({})), + }; +}); + +vi.mock("../context-engine/init.js", () => ({ + ensureContextEnginesInitialized: mocks.ensureContextEnginesInitialized, +})); + +vi.mock("../context-engine/registry.js", () => ({ + resolveContextEngine: mocks.resolveContextEngine, +})); + +vi.mock("../infra/agent-events.js", () => ({ + onAgentEvent: mocks.onAgentEvent, +})); + +vi.mock("./runtime-plugins.js", () => ({ + ensureRuntimePluginsLoaded: mocks.ensureRuntimePluginsLoaded, +})); + +vi.mock("./subagent-registry-state.js", () => ({ + getSubagentRunsSnapshotForRead: vi.fn((runs: Map) => new Map(runs)), + persistSubagentRunsToDisk: mocks.persistSubagentRunsToDisk, + restoreSubagentRunsFromDisk: vi.fn(() => 0), +})); + +vi.mock("./subagent-announce-queue.js", () => ({ + resetAnnounceQueuesForTests: vi.fn(), +})); + +vi.mock("./timeout.js", () => ({ + resolveAgentTimeoutMs: vi.fn(() => 1_000), +})); + +import { + registerSubagentRun, + releaseSubagentRun, + resetSubagentRegistryForTests, +} from "./subagent-registry.js"; + +describe("subagent-registry context-engine bootstrap", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveContextEngine.mockResolvedValue({ + onSubagentEnded: mocks.onSubagentEnded, + }); + resetSubagentRegistryForTests({ persist: false }); + }); + + it("reloads runtime plugins with the spawned workspace before subagent end hooks", async () => { + registerSubagentRun({ + runId: "run-1", + childSessionKey: "agent:main:session:child", + requesterSessionKey: "agent:main:session:parent", + requesterDisplayKey: "parent", + task: "task", + cleanup: "keep", + workspaceDir: "/tmp/workspace", + }); + + releaseSubagentRun("run-1"); + + await vi.waitFor(() => { + expect(mocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ + config: {}, + workspaceDir: "/tmp/workspace", + }); + }); + expect(mocks.ensureContextEnginesInitialized).toHaveBeenCalledTimes(1); + expect(mocks.onSubagentEnded).toHaveBeenCalledWith({ + childSessionKey: "agent:main:session:child", + reason: "released", + workspaceDir: "/tmp/workspace", + }); + }); +}); diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index e2453bcc0fd..9ef58933f35 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -16,6 +16,7 @@ import { onAgentEvent } from "../infra/agent-events.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { defaultRuntime } from "../runtime.js"; import { type DeliveryContext, normalizeDeliveryContext } from "../utils/delivery-context.js"; +import { ensureRuntimePluginsLoaded } from "./runtime-plugins.js"; import { resetAnnounceQueuesForTests } from "./subagent-announce-queue.js"; import { captureSubagentCompletionReply, @@ -313,10 +314,16 @@ function schedulePendingLifecycleError(params: { runId: string; endedAt: number; async function notifyContextEngineSubagentEnded(params: { childSessionKey: string; reason: SubagentEndReason; + workspaceDir?: string; }) { try { + const cfg = loadConfig(); + ensureRuntimePluginsLoaded({ + config: cfg, + workspaceDir: params.workspaceDir, + }); ensureContextEnginesInitialized(); - const engine = await resolveContextEngine(loadConfig()); + const engine = await resolveContextEngine(cfg); if (!engine.onSubagentEnded) { return; } @@ -714,6 +721,7 @@ async function sweepSubagentRuns() { void notifyContextEngineSubagentEnded({ childSessionKey: entry.childSessionKey, reason: "swept", + workspaceDir: entry.workspaceDir, }); subagentRuns.delete(runId); mutated = true; @@ -963,6 +971,7 @@ function completeCleanupBookkeeping(params: { void notifyContextEngineSubagentEnded({ childSessionKey: params.entry.childSessionKey, reason: "deleted", + workspaceDir: params.entry.workspaceDir, }); subagentRuns.delete(params.runId); persistSubagentRuns(); @@ -972,6 +981,7 @@ function completeCleanupBookkeeping(params: { void notifyContextEngineSubagentEnded({ childSessionKey: params.entry.childSessionKey, reason: "completed", + workspaceDir: params.entry.workspaceDir, }); params.entry.cleanupCompletedAt = params.completedAt; persistSubagentRuns(); @@ -1143,6 +1153,7 @@ export function registerSubagentRun(params: { cleanup: "delete" | "keep"; label?: string; model?: string; + workspaceDir?: string; runTimeoutSeconds?: number; expectsCompletionMessage?: boolean; spawnMode?: "run" | "session"; @@ -1171,6 +1182,7 @@ export function registerSubagentRun(params: { spawnMode, label: params.label, model: params.model, + workspaceDir: params.workspaceDir, runTimeoutSeconds, createdAt: now, startedAt: now, @@ -1285,6 +1297,7 @@ export function releaseSubagentRun(runId: string) { void notifyContextEngineSubagentEnded({ childSessionKey: entry.childSessionKey, reason: "released", + workspaceDir: entry.workspaceDir, }); } const didDelete = subagentRuns.delete(runId); diff --git a/src/agents/subagent-registry.types.ts b/src/agents/subagent-registry.types.ts index a97ed780723..a153ddbadd7 100644 --- a/src/agents/subagent-registry.types.ts +++ b/src/agents/subagent-registry.types.ts @@ -13,6 +13,7 @@ export type SubagentRunRecord = { cleanup: "delete" | "keep"; label?: string; model?: string; + workspaceDir?: string; runTimeoutSeconds?: number; spawnMode?: SpawnSubagentMode; createdAt: number; diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 8f7c41866fe..f2a63552189 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -650,6 +650,7 @@ export async function spawnSubagentDirect( cleanup, label: label || undefined, model: resolvedModel, + workspaceDir: spawnedMetadata.workspaceDir, runTimeoutSeconds, expectsCompletionMessage, spawnMode, diff --git a/src/agents/tools/browser-tool.actions.ts b/src/agents/tools/browser-tool.actions.ts index 95768891264..6c156e0cf2d 100644 --- a/src/agents/tools/browser-tool.actions.ts +++ b/src/agents/tools/browser-tool.actions.ts @@ -74,6 +74,17 @@ function stripTargetIdFromActRequest( return retryRequest as Parameters[1]; } +function canRetryChromeActWithoutTargetId(request: Parameters[1]): boolean { + const typedRequest = request as Partial>; + const kind = + typeof typedRequest.kind === "string" + ? typedRequest.kind + : typeof typedRequest.action === "string" + ? typedRequest.action + : ""; + return kind === "hover" || kind === "scrollIntoView" || kind === "wait"; +} + export async function executeTabsAction(params: { baseUrl?: string; profile?: string; @@ -304,9 +315,18 @@ export async function executeActAction(params: { } catch (err) { if (isChromeStaleTargetError(profile, err)) { const retryRequest = stripTargetIdFromActRequest(request); + const tabs = proxyRequest + ? (( + (await proxyRequest({ + method: "GET", + path: "/tabs", + profile, + })) as { tabs?: unknown[] } + ).tabs ?? []) + : await browserTabs(baseUrl, { profile }).catch(() => []); // Some Chrome relay targetIds can go stale between snapshots and actions. - // Retry once without targetId to let relay use the currently attached tab. - if (retryRequest) { + // Only retry safe read-only actions, and only when exactly one tab remains attached. + if (retryRequest && canRetryChromeActWithoutTargetId(request) && tabs.length === 1) { try { const retryResult = proxyRequest ? await proxyRequest({ @@ -323,15 +343,6 @@ export async function executeActAction(params: { // Fall through to explicit stale-target guidance. } } - const tabs = proxyRequest - ? (( - (await proxyRequest({ - method: "GET", - path: "/tabs", - profile, - })) as { tabs?: unknown[] } - ).tabs ?? []) - : await browserTabs(baseUrl, { profile }).catch(() => []); if (!tabs.length) { throw new Error( "No Chrome tabs are attached via the OpenClaw Browser Relay extension. Click the toolbar icon on the tab you want to control (badge ON), then retry.", diff --git a/src/agents/tools/browser-tool.test.ts b/src/agents/tools/browser-tool.test.ts index 3c54cb63633..79358cf1665 100644 --- a/src/agents/tools/browser-tool.test.ts +++ b/src/agents/tools/browser-tool.test.ts @@ -571,17 +571,18 @@ describe("browser tool external content wrapping", () => { describe("browser tool act stale target recovery", () => { registerBrowserToolAfterEachReset(); - it("retries chrome act once without targetId when tab id is stale", async () => { + it("retries safe chrome act once without targetId when exactly one tab remains", async () => { browserActionsMocks.browserAct .mockRejectedValueOnce(new Error("404: tab not found")) .mockResolvedValueOnce({ ok: true }); + browserClientMocks.browserTabs.mockResolvedValueOnce([{ targetId: "only-tab" }]); const tool = createBrowserTool(); const result = await tool.execute?.("call-1", { action: "act", profile: "chrome", request: { - action: "click", + kind: "hover", targetId: "stale-tab", ref: "btn-1", }, @@ -591,7 +592,7 @@ describe("browser tool act stale target recovery", () => { expect(browserActionsMocks.browserAct).toHaveBeenNthCalledWith( 1, undefined, - expect.objectContaining({ targetId: "stale-tab", action: "click", ref: "btn-1" }), + expect.objectContaining({ targetId: "stale-tab", kind: "hover", ref: "btn-1" }), expect.objectContaining({ profile: "chrome" }), ); expect(browserActionsMocks.browserAct).toHaveBeenNthCalledWith( @@ -602,4 +603,24 @@ describe("browser tool act stale target recovery", () => { ); expect(result?.details).toMatchObject({ ok: true }); }); + + it("does not retry mutating chrome act requests without targetId", async () => { + browserActionsMocks.browserAct.mockRejectedValueOnce(new Error("404: tab not found")); + browserClientMocks.browserTabs.mockResolvedValueOnce([{ targetId: "only-tab" }]); + + const tool = createBrowserTool(); + await expect( + tool.execute?.("call-1", { + action: "act", + profile: "chrome", + request: { + kind: "click", + targetId: "stale-tab", + ref: "btn-1", + }, + }), + ).rejects.toThrow(/Run action=tabs profile="chrome"/i); + + expect(browserActionsMocks.browserAct).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index e9da69080d2..54485908b8b 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -772,7 +772,25 @@ describe("web_search external content wrapping", () => { expect(mockFetch).not.toHaveBeenCalled(); }); - it("rejects date_after/date_before in Brave llm-context mode", async () => { + it.each([ + [ + "rejects date_after/date_before in Brave llm-context mode", + { + query: "test", + date_after: "2025-01-01", + date_before: "2025-01-31", + }, + "unsupported_date_filter", + ], + [ + "rejects ui_lang in Brave llm-context mode", + { + query: "test", + ui_lang: "de-DE", + }, + "unsupported_ui_lang", + ], + ])("%s", async (_name, input, expectedError) => { vi.stubEnv("BRAVE_API_KEY", "test-key"); const mockFetch = installBraveLlmContextFetch({ title: "unused", @@ -795,45 +813,9 @@ describe("web_search external content wrapping", () => { }, sandboxed: true, }); - const result = await tool?.execute?.("call-1", { - query: "test", - date_after: "2025-01-01", - date_before: "2025-01-31", - }); + const result = await tool?.execute?.("call-1", input); - expect(result?.details).toMatchObject({ error: "unsupported_date_filter" }); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it("rejects ui_lang in Brave llm-context mode", async () => { - vi.stubEnv("BRAVE_API_KEY", "test-key"); - const mockFetch = installBraveLlmContextFetch({ - title: "unused", - url: "https://example.com", - snippets: ["unused"], - }); - - const tool = createWebSearchTool({ - config: { - tools: { - web: { - search: { - provider: "brave", - brave: { - mode: "llm-context", - }, - }, - }, - }, - }, - sandboxed: true, - }); - const result = await tool?.execute?.("call-1", { - query: "test", - ui_lang: "de-DE", - }); - - expect(result?.details).toMatchObject({ error: "unsupported_ui_lang" }); + expect(result?.details).toMatchObject({ error: expectedError }); expect(mockFetch).not.toHaveBeenCalled(); }); diff --git a/src/browser/browser-utils.test.ts b/src/browser/browser-utils.test.ts index d5c081668e7..ab6c13d55aa 100644 --- a/src/browser/browser-utils.test.ts +++ b/src/browser/browser-utils.test.ts @@ -166,11 +166,23 @@ describe("cdp.helpers", () => { expect(url).toBe("https://connect.example.com/?token=abc"); }); + it("preserves auth and query params when normalizing secure loopback WebSocket CDP URLs", () => { + const url = normalizeCdpHttpBaseForJsonEndpoints( + "wss://user:pass@127.0.0.1:9222/devtools/browser/ABC?token=abc", + ); + expect(url).toBe("https://user:pass@127.0.0.1:9222/?token=abc"); + }); + it("strips a trailing /cdp suffix when normalizing HTTP bases", () => { const url = normalizeCdpHttpBaseForJsonEndpoints("ws://127.0.0.1:9222/cdp?token=abc"); expect(url).toBe("http://127.0.0.1:9222/?token=abc"); }); + it("preserves base prefixes when stripping a trailing /cdp suffix", () => { + const url = normalizeCdpHttpBaseForJsonEndpoints("ws://127.0.0.1:9222/browser/cdp?token=abc"); + expect(url).toBe("http://127.0.0.1:9222/browser?token=abc"); + }); + it("adds basic auth headers when credentials are present", () => { const headers = getHeadersWithAuth("https://user:pass@example.com"); expect(headers.Authorization).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`); diff --git a/src/browser/cdp.test.ts b/src/browser/cdp.test.ts index 44dbdd70e30..524dfe13bb5 100644 --- a/src/browser/cdp.test.ts +++ b/src/browser/cdp.test.ts @@ -336,6 +336,26 @@ describe("cdp", () => { expect(normalized).toBe("ws://192.168.1.202:18850/devtools/browser/ABC"); }); + it("keeps existing websocket query params when appending remote CDP query params", () => { + const normalized = normalizeCdpWsUrl( + "ws://127.0.0.1:9222/devtools/browser/ABC?session=1&token=ws-token", + "http://127.0.0.1:9222?token=cdp-token&apiKey=abc", + ); + expect(normalized).toBe( + "ws://127.0.0.1:9222/devtools/browser/ABC?session=1&token=ws-token&apiKey=abc", + ); + }); + + it("rewrites wildcard bind addresses to secure remote CDP hosts without clobbering websocket params", () => { + const normalized = normalizeCdpWsUrl( + "ws://0.0.0.0:3000/devtools/browser/ABC?session=1&token=ws-token", + "https://user:pass@example.com:9443?token=cdp-token&apiKey=abc", + ); + expect(normalized).toBe( + "wss://user:pass@example.com:9443/devtools/browser/ABC?session=1&token=ws-token&apiKey=abc", + ); + }); + it("upgrades ws to wss when CDP uses https", () => { const normalized = normalizeCdpWsUrl( "ws://production-sfo.browserless.io", diff --git a/src/browser/config.test.ts b/src/browser/config.test.ts index eb77dc5341d..d2643a6784b 100644 --- a/src/browser/config.test.ts +++ b/src/browser/config.test.ts @@ -176,6 +176,28 @@ describe("browser config", () => { expect(profile?.cdpIsLoopback).toBe(false); }); + it("preserves loopback direct WebSocket cdpUrl for explicit profiles", () => { + const resolved = resolveBrowserConfig({ + profiles: { + localws: { + cdpUrl: "ws://127.0.0.1:9222/devtools/browser/ABC?token=test-key", + color: "#0066CC", + }, + }, + }); + const profile = resolveProfile(resolved, "localws"); + expect(profile?.cdpUrl).toBe("ws://127.0.0.1:9222/devtools/browser/ABC?token=test-key"); + expect(profile?.cdpPort).toBe(9222); + expect(profile?.cdpIsLoopback).toBe(true); + }); + + it("trims relayBindHost when configured", () => { + const resolved = resolveBrowserConfig({ + relayBindHost: " 0.0.0.0 ", + }); + expect(resolved.relayBindHost).toBe("0.0.0.0"); + }); + it("rejects unsupported protocols", () => { expect(() => resolveBrowserConfig({ cdpUrl: "ftp://127.0.0.1:18791" })).toThrow( "must be http(s) or ws(s)", diff --git a/src/browser/config.ts b/src/browser/config.ts index f6b6e1b6d01..6d24a07a287 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -36,6 +36,7 @@ export type ResolvedBrowserConfig = { profiles: Record; ssrfPolicy?: SsrFPolicy; extraArgs: string[]; + relayBindHost?: string; }; export type ResolvedBrowserProfile = { @@ -291,6 +292,7 @@ export function resolveBrowserConfig( ? cfg.extraArgs.filter((a): a is string => typeof a === "string" && a.trim().length > 0) : []; const ssrfPolicy = resolveBrowserSsrFPolicy(cfg); + const relayBindHost = cfg?.relayBindHost?.trim() || undefined; return { enabled, @@ -312,6 +314,7 @@ export function resolveBrowserConfig( profiles, ssrfPolicy, extraArgs, + relayBindHost, }; } diff --git a/src/browser/extension-relay.bind-host.test.ts b/src/browser/extension-relay.bind-host.test.ts new file mode 100644 index 00000000000..a029a2f1a95 --- /dev/null +++ b/src/browser/extension-relay.bind-host.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; +import { + ensureChromeExtensionRelayServer, + stopChromeExtensionRelayServer, +} from "./extension-relay.js"; +import { getFreePort } from "./test-port.js"; + +describe("chrome extension relay bindHost coordination", () => { + let cdpUrl = ""; + let envSnapshot: ReturnType; + + beforeEach(() => { + envSnapshot = captureEnv(["OPENCLAW_GATEWAY_TOKEN"]); + process.env.OPENCLAW_GATEWAY_TOKEN = "test-gateway-token"; + }); + + afterEach(async () => { + if (cdpUrl) { + await stopChromeExtensionRelayServer({ cdpUrl }).catch(() => {}); + cdpUrl = ""; + } + envSnapshot.restore(); + }); + + it("rebinds the relay when concurrent callers request different bind hosts", async () => { + const port = await getFreePort(); + cdpUrl = `http://127.0.0.1:${port}`; + + const [first, second] = await Promise.all([ + ensureChromeExtensionRelayServer({ cdpUrl }), + ensureChromeExtensionRelayServer({ cdpUrl, bindHost: "0.0.0.0" }), + ]); + + const settled = await ensureChromeExtensionRelayServer({ + cdpUrl, + bindHost: "0.0.0.0", + }); + + expect(first.port).toBe(port); + expect(second.port).toBe(port); + expect(second).not.toBe(first); + expect(second.bindHost).toBe("0.0.0.0"); + expect(settled).toBe(second); + + const res = await fetch(`http://127.0.0.1:${port}/`); + expect(res.status).toBe(200); + }); +}); diff --git a/src/browser/extension-relay.test.ts b/src/browser/extension-relay.test.ts index b1478feabd4..f6e14ee8803 100644 --- a/src/browser/extension-relay.test.ts +++ b/src/browser/extension-relay.test.ts @@ -1168,4 +1168,57 @@ describe("chrome extension relay server", () => { ); await new Promise((resolve) => blocker.close(() => resolve())); }); + + it( + "respects bindHost override to bind on a non-loopback address", + async () => { + const port = await getFreePort(); + cdpUrl = `http://127.0.0.1:${port}`; + const relay = await ensureChromeExtensionRelayServer({ + cdpUrl, + bindHost: "0.0.0.0", + }); + expect(relay.port).toBe(port); + // Verify the server actually bound to 0.0.0.0, not the cdpUrl host. + expect(relay.bindHost).toBe("0.0.0.0"); + + const res = await fetch(`http://127.0.0.1:${port}/`); + expect(res.status).toBe(200); + }, + RELAY_TEST_TIMEOUT_MS, + ); + + it( + "defaults bindHost to cdpUrl host when not specified", + async () => { + const port = await getFreePort(); + cdpUrl = `http://127.0.0.1:${port}`; + const relay = await ensureChromeExtensionRelayServer({ cdpUrl }); + expect(relay.host).toBe("127.0.0.1"); + expect(relay.bindHost).toBe("127.0.0.1"); + + const res = await fetch(`http://127.0.0.1:${port}/`); + expect(res.status).toBe(200); + }, + RELAY_TEST_TIMEOUT_MS, + ); + + it( + "restarts the relay when bindHost changes for the same port", + async () => { + const port = await getFreePort(); + cdpUrl = `http://127.0.0.1:${port}`; + + const initial = await ensureChromeExtensionRelayServer({ cdpUrl }); + expect(initial.bindHost).toBe("127.0.0.1"); + + const rebound = await ensureChromeExtensionRelayServer({ + cdpUrl, + bindHost: "0.0.0.0", + }); + expect(rebound.bindHost).toBe("0.0.0.0"); + expect(rebound.port).toBe(port); + }, + RELAY_TEST_TIMEOUT_MS, + ); }); diff --git a/src/browser/extension-relay.ts b/src/browser/extension-relay.ts index 126bfc8f682..5a87670605e 100644 --- a/src/browser/extension-relay.ts +++ b/src/browser/extension-relay.ts @@ -113,6 +113,7 @@ function getRelayAuthTokenFromRequest(req: IncomingMessage, url?: URL): string | export type ChromeExtensionRelayServer = { host: string; + bindHost: string; port: number; baseUrl: string; cdpWsUrl: string; @@ -223,20 +224,30 @@ export function getChromeExtensionRelayAuthHeaders(url: string): Record { const info = parseBaseUrl(opts.cdpUrl); if (!isLoopbackHost(info.host)) { throw new Error(`extension relay requires loopback cdpUrl host (got ${info.host})`); } + const bindHost = opts.bindHost ?? info.host; const existing = relayRuntimeByPort.get(info.port); if (existing) { - return existing.server; + if (existing.server.bindHost !== bindHost) { + await existing.server.stop(); + } else { + return existing.server; + } } const inFlight = relayInitByPort.get(info.port); if (inFlight) { - return await inFlight; + const server = await inFlight; + if (server.bindHost === bindHost) { + return server; + } + await server.stop(); } const extensionReconnectGraceMs = envMsOrDefault( @@ -682,7 +693,9 @@ export async function ensureChromeExtensionRelayServer(opts: { const pathname = url.pathname; const remote = req.socket.remoteAddress; - if (!isLoopbackAddress(remote)) { + // When bindHost is explicitly non-loopback (e.g. 0.0.0.0 for WSL2), + // allow non-loopback connections; otherwise enforce loopback-only. + if (!isLoopbackAddress(remote) && isLoopbackHost(bindHost)) { rejectUpgrade(socket, 403, "Forbidden"); return; } @@ -962,7 +975,7 @@ export async function ensureChromeExtensionRelayServer(opts: { try { await new Promise((resolve, reject) => { - server.listen(info.port, info.host, () => resolve()); + server.listen(info.port, bindHost, () => resolve()); server.once("error", reject); }); } catch (err) { @@ -976,6 +989,7 @@ export async function ensureChromeExtensionRelayServer(opts: { ) { const existingRelay: ChromeExtensionRelayServer = { host: info.host, + bindHost, port: info.port, baseUrl: info.baseUrl, cdpWsUrl: `ws://${info.host}:${info.port}/cdp`, @@ -992,11 +1006,13 @@ export async function ensureChromeExtensionRelayServer(opts: { const addr = server.address() as AddressInfo | null; const port = addr?.port ?? info.port; + const actualBindHost = addr?.address || bindHost; const host = info.host; const baseUrl = `${new URL(info.baseUrl).protocol}//${host}:${port}`; const relay: ChromeExtensionRelayServer = { host, + bindHost: actualBindHost, port, baseUrl, cdpWsUrl: `ws://${host}:${port}/cdp`, diff --git a/src/browser/pw-session.connections.test.ts b/src/browser/pw-session.connections.test.ts new file mode 100644 index 00000000000..abb6946d610 --- /dev/null +++ b/src/browser/pw-session.connections.test.ts @@ -0,0 +1,119 @@ +import { chromium } from "playwright-core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as chromeModule from "./chrome.js"; +import { closePlaywrightBrowserConnection, listPagesViaPlaywright } from "./pw-session.js"; + +const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP"); +const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl"); + +type BrowserMockBundle = { + browser: import("playwright-core").Browser; + browserClose: ReturnType; +}; + +function makeBrowser(targetId: string, url: string): BrowserMockBundle { + let context: import("playwright-core").BrowserContext; + const browserClose = vi.fn(async () => {}); + const page = { + on: vi.fn(), + context: () => context, + title: vi.fn(async () => `title:${targetId}`), + url: vi.fn(() => url), + } as unknown as import("playwright-core").Page; + + context = { + pages: () => [page], + on: vi.fn(), + newCDPSession: vi.fn(async () => ({ + send: vi.fn(async (method: string) => + method === "Target.getTargetInfo" ? { targetInfo: { targetId } } : {}, + ), + detach: vi.fn(async () => {}), + })), + } as unknown as import("playwright-core").BrowserContext; + + const browser = { + contexts: () => [context], + on: vi.fn(), + off: vi.fn(), + close: browserClose, + } as unknown as import("playwright-core").Browser; + + return { browser, browserClose }; +} + +afterEach(async () => { + connectOverCdpSpy.mockReset(); + getChromeWebSocketUrlSpy.mockReset(); + await closePlaywrightBrowserConnection().catch(() => {}); +}); + +describe("pw-session connection scoping", () => { + it("does not share in-flight connectOverCDP promises across different cdpUrls", async () => { + const browserA = makeBrowser("A", "https://a.example"); + const browserB = makeBrowser("B", "https://b.example"); + let resolveA: ((value: import("playwright-core").Browser) => void) | undefined; + + connectOverCdpSpy.mockImplementation((async (...args: unknown[]) => { + const endpointText = String(args[0]); + if (endpointText === "http://127.0.0.1:9222") { + return await new Promise((resolve) => { + resolveA = resolve; + }); + } + if (endpointText === "http://127.0.0.1:9333") { + return browserB.browser; + } + throw new Error(`unexpected endpoint: ${endpointText}`); + }) as never); + getChromeWebSocketUrlSpy.mockResolvedValue(null); + + const pendingA = listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9222" }); + await Promise.resolve(); + const pendingB = listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9333" }); + + await vi.waitFor(() => { + expect(connectOverCdpSpy).toHaveBeenCalledTimes(2); + }); + expect(connectOverCdpSpy).toHaveBeenNthCalledWith( + 1, + "http://127.0.0.1:9222", + expect.any(Object), + ); + expect(connectOverCdpSpy).toHaveBeenNthCalledWith( + 2, + "http://127.0.0.1:9333", + expect.any(Object), + ); + + resolveA?.(browserA.browser); + const [pagesA, pagesB] = await Promise.all([pendingA, pendingB]); + expect(pagesA.map((page) => page.targetId)).toEqual(["A"]); + expect(pagesB.map((page) => page.targetId)).toEqual(["B"]); + }); + + it("closes only the requested scoped connection", async () => { + const browserA = makeBrowser("A", "https://a.example"); + const browserB = makeBrowser("B", "https://b.example"); + + connectOverCdpSpy.mockImplementation((async (...args: unknown[]) => { + const endpointText = String(args[0]); + if (endpointText === "http://127.0.0.1:9222") { + return browserA.browser; + } + if (endpointText === "http://127.0.0.1:9333") { + return browserB.browser; + } + throw new Error(`unexpected endpoint: ${endpointText}`); + }) as never); + getChromeWebSocketUrlSpy.mockResolvedValue(null); + + await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9222" }); + await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9333" }); + + await closePlaywrightBrowserConnection({ cdpUrl: "http://127.0.0.1:9222" }); + + expect(browserA.browserClose).toHaveBeenCalledTimes(1); + expect(browserB.browserClose).not.toHaveBeenCalled(); + }); +}); diff --git a/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts b/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts index b9908c5f22d..f1909ad33fb 100644 --- a/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts +++ b/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts @@ -1,11 +1,17 @@ import { chromium } from "playwright-core"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import * as chromeModule from "./chrome.js"; import { closePlaywrightBrowserConnection, getPageForTargetId } from "./pw-session.js"; const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP"); const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl"); +afterEach(async () => { + connectOverCdpSpy.mockClear(); + getChromeWebSocketUrlSpy.mockClear(); + await closePlaywrightBrowserConnection().catch(() => {}); +}); + describe("pw-session getPageForTargetId", () => { it("falls back to the only page when CDP session attachment is blocked (extension relays)", async () => { connectOverCdpSpy.mockClear(); @@ -50,4 +56,63 @@ describe("pw-session getPageForTargetId", () => { await closePlaywrightBrowserConnection(); expect(browserClose).toHaveBeenCalled(); }); + + it("uses the shared HTTP-base normalization when falling back to /json/list for direct WebSocket CDP URLs", async () => { + const pageOn = vi.fn(); + const contextOn = vi.fn(); + const browserOn = vi.fn(); + const browserClose = vi.fn(async () => {}); + + const context = { + pages: () => [], + on: contextOn, + newCDPSession: vi.fn(async () => { + throw new Error("Not allowed"); + }), + } as unknown as import("playwright-core").BrowserContext; + + const pageA = { + on: pageOn, + context: () => context, + url: () => "https://alpha.example", + } as unknown as import("playwright-core").Page; + const pageB = { + on: pageOn, + context: () => context, + url: () => "https://beta.example", + } as unknown as import("playwright-core").Page; + + (context as unknown as { pages: () => unknown[] }).pages = () => [pageA, pageB]; + + const browser = { + contexts: () => [context], + on: browserOn, + close: browserClose, + } as unknown as import("playwright-core").Browser; + + connectOverCdpSpy.mockResolvedValue(browser); + getChromeWebSocketUrlSpy.mockResolvedValue(null); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => [ + { id: "TARGET_A", url: "https://alpha.example" }, + { id: "TARGET_B", url: "https://beta.example" }, + ], + } as Response); + + try { + const resolved = await getPageForTargetId({ + cdpUrl: "ws://127.0.0.1:18792/devtools/browser/SESSION?token=abc", + targetId: "TARGET_B", + }); + expect(resolved).toBe(pageB); + expect(fetchSpy).toHaveBeenCalledWith( + "http://127.0.0.1:18792/json/list?token=abc", + expect.any(Object), + ); + } finally { + fetchSpy.mockRestore(); + } + }); }); diff --git a/src/browser/pw-session.ts b/src/browser/pw-session.ts index 55d41c60c0a..a5f1b11ec02 100644 --- a/src/browser/pw-session.ts +++ b/src/browser/pw-session.ts @@ -113,8 +113,8 @@ const MAX_CONSOLE_MESSAGES = 500; const MAX_PAGE_ERRORS = 200; const MAX_NETWORK_REQUESTS = 500; -let cached: ConnectedBrowser | null = null; -let connecting: Promise | null = null; +const cachedByCdpUrl = new Map(); +const connectingByCdpUrl = new Map>(); function normalizeCdpUrl(raw: string) { return raw.replace(/\/$/, ""); @@ -328,9 +328,11 @@ function observeBrowser(browser: Browser) { async function connectBrowser(cdpUrl: string): Promise { const normalized = normalizeCdpUrl(cdpUrl); - if (cached?.cdpUrl === normalized) { + const cached = cachedByCdpUrl.get(normalized); + if (cached) { return cached; } + const connecting = connectingByCdpUrl.get(normalized); if (connecting) { return await connecting; } @@ -348,12 +350,13 @@ async function connectBrowser(cdpUrl: string): Promise { chromium.connectOverCDP(endpoint, { timeout, headers }), ); const onDisconnected = () => { - if (cached?.browser === browser) { - cached = null; + const current = cachedByCdpUrl.get(normalized); + if (current?.browser === browser) { + cachedByCdpUrl.delete(normalized); } }; const connected: ConnectedBrowser = { browser, cdpUrl: normalized, onDisconnected }; - cached = connected; + cachedByCdpUrl.set(normalized, connected); browser.on("disconnected", onDisconnected); observeBrowser(browser); return connected; @@ -370,11 +373,12 @@ async function connectBrowser(cdpUrl: string): Promise { throw new Error(message); }; - connecting = connectWithRetry().finally(() => { - connecting = null; + const pending = connectWithRetry().finally(() => { + connectingByCdpUrl.delete(normalized); }); + connectingByCdpUrl.set(normalized, pending); - return await connecting; + return await pending; } async function getAllPages(browser: Browser): Promise { @@ -423,34 +427,29 @@ async function findPageByTargetId( // fall back to URL-based matching using the /json/list endpoint if (cdpUrl) { try { - const baseUrl = cdpUrl - .replace(/\/+$/, "") - .replace(/^ws:/, "http:") - .replace(/\/cdp$/, ""); - const listUrl = `${baseUrl}/json/list`; - const response = await fetch(listUrl, { headers: getHeadersWithAuth(listUrl) }); - if (response.ok) { - const targets = (await response.json()) as Array<{ + const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(cdpUrl); + const targets = await fetchJson< + Array<{ id: string; url: string; title?: string; - }>; - const target = targets.find((t) => t.id === targetId); - if (target) { - // Try to find a page with matching URL - const urlMatch = pages.filter((p) => p.url() === target.url); - if (urlMatch.length === 1) { - return urlMatch[0]; - } - // If multiple URL matches, use index-based matching as fallback - // This works when Playwright and the relay enumerate tabs in the same order - if (urlMatch.length > 1) { - const sameUrlTargets = targets.filter((t) => t.url === target.url); - if (sameUrlTargets.length === urlMatch.length) { - const idx = sameUrlTargets.findIndex((t) => t.id === targetId); - if (idx >= 0 && idx < urlMatch.length) { - return urlMatch[idx]; - } + }> + >(appendCdpPath(cdpHttpBase, "/json/list"), 2000); + const target = targets.find((t) => t.id === targetId); + if (target) { + // Try to find a page with matching URL + const urlMatch = pages.filter((p) => p.url() === target.url); + if (urlMatch.length === 1) { + return urlMatch[0]; + } + // If multiple URL matches, use index-based matching as fallback + // This works when Playwright and the relay enumerate tabs in the same order + if (urlMatch.length > 1) { + const sameUrlTargets = targets.filter((t) => t.url === target.url); + if (sameUrlTargets.length === urlMatch.length) { + const idx = sameUrlTargets.findIndex((t) => t.id === targetId); + if (idx >= 0 && idx < urlMatch.length) { + return urlMatch[idx]; } } } @@ -539,17 +538,32 @@ export function refLocator(page: Page, ref: string) { return page.locator(`aria-ref=${normalized}`); } -export async function closePlaywrightBrowserConnection(): Promise { - const cur = cached; - cached = null; - connecting = null; - if (!cur) { +export async function closePlaywrightBrowserConnection(opts?: { cdpUrl?: string }): Promise { + const normalized = opts?.cdpUrl ? normalizeCdpUrl(opts.cdpUrl) : null; + + if (normalized) { + const cur = cachedByCdpUrl.get(normalized); + cachedByCdpUrl.delete(normalized); + connectingByCdpUrl.delete(normalized); + if (!cur) { + return; + } + if (cur.onDisconnected && typeof cur.browser.off === "function") { + cur.browser.off("disconnected", cur.onDisconnected); + } + await cur.browser.close().catch(() => {}); return; } - if (cur.onDisconnected && typeof cur.browser.off === "function") { - cur.browser.off("disconnected", cur.onDisconnected); + + const connections = Array.from(cachedByCdpUrl.values()); + cachedByCdpUrl.clear(); + connectingByCdpUrl.clear(); + for (const cur of connections) { + if (cur.onDisconnected && typeof cur.browser.off === "function") { + cur.browser.off("disconnected", cur.onDisconnected); + } + await cur.browser.close().catch(() => {}); } - await cur.browser.close().catch(() => {}); } function cdpSocketNeedsAttach(wsUrl: string): boolean { @@ -655,31 +669,29 @@ export async function forceDisconnectPlaywrightForTarget(opts: { reason?: string; }): Promise { const normalized = normalizeCdpUrl(opts.cdpUrl); - if (cached?.cdpUrl !== normalized) { + const cur = cachedByCdpUrl.get(normalized); + if (!cur) { return; } - const cur = cached; - cached = null; - // Also clear `connecting` so the next call does a fresh connectOverCDP + cachedByCdpUrl.delete(normalized); + // Also clear the per-url in-flight connect so the next call does a fresh connectOverCDP // rather than awaiting a stale promise. - connecting = null; - if (cur) { - // Remove the "disconnected" listener to prevent the old browser's teardown - // from racing with a fresh connection and nulling the new `cached`. - if (cur.onDisconnected && typeof cur.browser.off === "function") { - cur.browser.off("disconnected", cur.onDisconnected); - } - - // Best-effort: kill any stuck JS to unblock the target's execution context before we - // disconnect Playwright's CDP connection. - const targetId = opts.targetId?.trim() || ""; - if (targetId) { - await tryTerminateExecutionViaCdp({ cdpUrl: normalized, targetId }).catch(() => {}); - } - - // Fire-and-forget: don't await because browser.close() may hang on the stuck CDP pipe. - cur.browser.close().catch(() => {}); + connectingByCdpUrl.delete(normalized); + // Remove the "disconnected" listener to prevent the old browser's teardown + // from racing with a fresh connection and nulling the new cached entry. + if (cur.onDisconnected && typeof cur.browser.off === "function") { + cur.browser.off("disconnected", cur.onDisconnected); } + + // Best-effort: kill any stuck JS to unblock the target's execution context before we + // disconnect Playwright's CDP connection. + const targetId = opts.targetId?.trim() || ""; + if (targetId) { + await tryTerminateExecutionViaCdp({ cdpUrl: normalized, targetId }).catch(() => {}); + } + + // Fire-and-forget: don't await because browser.close() may hang on the stuck CDP pipe. + cur.browser.close().catch(() => {}); } /** diff --git a/src/browser/server-context.availability.ts b/src/browser/server-context.availability.ts index 47865903b96..07772c6b598 100644 --- a/src/browser/server-context.availability.ts +++ b/src/browser/server-context.availability.ts @@ -117,7 +117,10 @@ export function createProfileAvailability({ if (isExtension) { if (!httpReachable) { - await ensureChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }); + await ensureChromeExtensionRelayServer({ + cdpUrl: profile.cdpUrl, + bindHost: current.resolved.relayBindHost, + }); if (!(await isHttpReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS))) { throw new Error( `Chrome extension relay for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`, diff --git a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts index 81f71cc21d3..13c5f82e31d 100644 --- a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts +++ b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts @@ -99,7 +99,7 @@ describe("browser server-context ensureTabAvailable", () => { expect(second.targetId).toBe("A"); }); - it("falls back to the only attached tab when an invalid targetId is provided (extension)", async () => { + it("rejects invalid targetId even when only one extension tab remains", async () => { const responses = [ [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], @@ -109,8 +109,7 @@ describe("browser server-context ensureTabAvailable", () => { const ctx = createBrowserRouteContext({ getState: () => state }); const chrome = ctx.forProfile("chrome"); - const chosen = await chrome.ensureTabAvailable("NOT_A_TAB"); - expect(chosen.targetId).toBe("A"); + await expect(chrome.ensureTabAvailable("NOT_A_TAB")).rejects.toThrow(/tab not found/i); }); it("returns a descriptive message when no extension tabs are attached", async () => { @@ -122,4 +121,58 @@ describe("browser server-context ensureTabAvailable", () => { const chrome = ctx.forProfile("chrome"); await expect(chrome.ensureTabAvailable()).rejects.toThrow(/no attached Chrome tabs/i); }); + + it("waits briefly for extension tabs to reappear when a previous target exists", async () => { + vi.useFakeTimers(); + try { + const responses = [ + // First call: select tab A and store lastTargetId. + [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], + [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], + // Second call: transient drop, then the extension re-announces attached tab A. + [], + [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], + [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], + ]; + stubChromeJsonList(responses); + const state = makeBrowserState(); + + const ctx = createBrowserRouteContext({ getState: () => state }); + const chrome = ctx.forProfile("chrome"); + const first = await chrome.ensureTabAvailable(); + expect(first.targetId).toBe("A"); + + const secondPromise = chrome.ensureTabAvailable(); + await vi.advanceTimersByTimeAsync(250); + const second = await secondPromise; + expect(second.targetId).toBe("A"); + } finally { + vi.useRealTimers(); + } + }); + + it("still fails after the extension-tab grace window expires", async () => { + vi.useFakeTimers(); + try { + const responses = [ + [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], + [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], + ...Array.from({ length: 20 }, () => []), + ]; + stubChromeJsonList(responses); + const state = makeBrowserState(); + + const ctx = createBrowserRouteContext({ getState: () => state }); + const chrome = ctx.forProfile("chrome"); + await chrome.ensureTabAvailable(); + + const pending = expect(chrome.ensureTabAvailable()).rejects.toThrow( + /no attached Chrome tabs/i, + ); + await vi.advanceTimersByTimeAsync(3_500); + await pending; + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/browser/server-context.loopback-direct-ws.test.ts b/src/browser/server-context.loopback-direct-ws.test.ts index 9f6512fab4e..127b329a7e8 100644 --- a/src/browser/server-context.loopback-direct-ws.test.ts +++ b/src/browser/server-context.loopback-direct-ws.test.ts @@ -97,4 +97,46 @@ describe("browser server-context loopback direct WebSocket profiles", () => { expect.any(Object), ); }); + + it("uses an HTTPS /json base for secure direct WebSocket profiles with a /cdp suffix", async () => { + const fetchMock = vi.fn(async (url: unknown) => { + const u = String(url); + if (u === "https://127.0.0.1:18800/json/list?token=abc") { + return { + ok: true, + json: async () => [ + { + id: "T2", + title: "Secure Tab", + url: "https://example.com", + webSocketDebuggerUrl: "wss://127.0.0.1/devtools/page/T2", + type: "page", + }, + ], + } as unknown as Response; + } + if (u === "https://127.0.0.1:18800/json/activate/T2?token=abc") { + return { ok: true, json: async () => ({}) } as unknown as Response; + } + if (u === "https://127.0.0.1:18800/json/close/T2?token=abc") { + return { ok: true, json: async () => ({}) } as unknown as Response; + } + throw new Error(`unexpected fetch: ${u}`); + }); + + global.fetch = withFetchPreconnect(fetchMock); + const state = makeState("openclaw"); + state.resolved.profiles.openclaw = { + cdpUrl: "wss://127.0.0.1:18800/cdp?token=abc", + color: "#FF4500", + }; + const ctx = createBrowserRouteContext({ getState: () => state }); + const openclaw = ctx.forProfile("openclaw"); + + const tabs = await openclaw.listTabs(); + expect(tabs.map((tab) => tab.targetId)).toEqual(["T2"]); + + await openclaw.focusTab("T2"); + await openclaw.closeTab("T2"); + }); }); diff --git a/src/browser/server-context.remote-profile-tab-ops.suite.ts b/src/browser/server-context.remote-profile-tab-ops.suite.ts index 746a8c87f53..e0bd5815199 100644 --- a/src/browser/server-context.remote-profile-tab-ops.suite.ts +++ b/src/browser/server-context.remote-profile-tab-ops.suite.ts @@ -139,7 +139,7 @@ describe("browser server-context remote profile tab operations", () => { expect(second.targetId).toBe("A"); }); - it("falls back to the only tab for remote profiles when targetId is stale", async () => { + it("rejects stale targetId for remote profiles even when only one tab remains", async () => { const responses = [ [{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" }], [{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" }], @@ -151,8 +151,7 @@ describe("browser server-context remote profile tab operations", () => { } as unknown as Awaited>); const { remote } = createRemoteRouteHarness(); - const chosen = await remote.ensureTabAvailable("STALE_TARGET"); - expect(chosen.targetId).toBe("T1"); + await expect(remote.ensureTabAvailable("STALE_TARGET")).rejects.toThrow(/tab not found/i); }); it("keeps rejecting stale targetId for remote profiles when multiple tabs exist", async () => { diff --git a/src/browser/server-context.reset.test.ts b/src/browser/server-context.reset.test.ts index 09a20b48edf..7e74ffd3881 100644 --- a/src/browser/server-context.reset.test.ts +++ b/src/browser/server-context.reset.test.ts @@ -112,7 +112,9 @@ describe("createProfileResetOps", () => { }); expect(isHttpReachable).toHaveBeenCalledWith(300); expect(stopRunningBrowser).toHaveBeenCalledTimes(1); - expect(pwAiMocks.closePlaywrightBrowserConnection).toHaveBeenCalledTimes(1); + expect(pwAiMocks.closePlaywrightBrowserConnection).toHaveBeenCalledWith({ + cdpUrl: "http://127.0.0.1:18800", + }); expect(trashMocks.movePathToTrash).toHaveBeenCalledWith(profileDir); }); @@ -132,5 +134,11 @@ describe("createProfileResetOps", () => { await ops.resetProfile(); expect(stopRunningBrowser).not.toHaveBeenCalled(); expect(pwAiMocks.closePlaywrightBrowserConnection).toHaveBeenCalledTimes(2); + expect(pwAiMocks.closePlaywrightBrowserConnection).toHaveBeenNthCalledWith(1, { + cdpUrl: "http://127.0.0.1:18800", + }); + expect(pwAiMocks.closePlaywrightBrowserConnection).toHaveBeenNthCalledWith(2, { + cdpUrl: "http://127.0.0.1:18800", + }); }); }); diff --git a/src/browser/server-context.reset.ts b/src/browser/server-context.reset.ts index 134db475f61..7f890a2184c 100644 --- a/src/browser/server-context.reset.ts +++ b/src/browser/server-context.reset.ts @@ -16,10 +16,10 @@ type ResetOps = { resetProfile: () => Promise<{ moved: boolean; from: string; to?: string }>; }; -async function closePlaywrightBrowserConnection(): Promise { +async function closePlaywrightBrowserConnectionForProfile(cdpUrl?: string): Promise { try { const mod = await import("./pw-ai.js"); - await mod.closePlaywrightBrowserConnection(); + await mod.closePlaywrightBrowserConnection(cdpUrl ? { cdpUrl } : undefined); } catch { // ignore } @@ -48,14 +48,14 @@ export function createProfileResetOps({ const httpReachable = await isHttpReachable(300); if (httpReachable && !profileState.running) { // Port in use but not by us - kill it. - await closePlaywrightBrowserConnection(); + await closePlaywrightBrowserConnectionForProfile(profile.cdpUrl); } if (profileState.running) { await stopRunningBrowser(); } - await closePlaywrightBrowserConnection(); + await closePlaywrightBrowserConnectionForProfile(profile.cdpUrl); if (!fs.existsSync(userDataDir)) { return { moved: false, from: userDataDir }; diff --git a/src/browser/server-context.selection.ts b/src/browser/server-context.selection.ts index 740a99db2b8..7afeca36c5c 100644 --- a/src/browser/server-context.selection.ts +++ b/src/browser/server-context.selection.ts @@ -32,15 +32,28 @@ export function createProfileSelectionOps({ const ensureTabAvailable = async (targetId?: string): Promise => { await ensureBrowserAvailable(); const profileState = getProfileState(); - const tabs1 = await listTabs(); + let tabs1 = await listTabs(); if (tabs1.length === 0) { if (profile.driver === "extension") { - throw new Error( - `tab not found (no attached Chrome tabs for profile "${profile.name}"). ` + - "Click the OpenClaw Browser Relay toolbar icon on the tab you want to control (badge ON).", - ); + // Chrome extension relay can briefly drop its WebSocket connection (MV3 service worker + // lifecycle, relay restart). If we previously had a target selected, wait briefly for + // the extension to reconnect and re-announce its attached tabs before failing. + if (profileState.lastTargetId?.trim()) { + const deadlineAt = Date.now() + 3_000; + while (tabs1.length === 0 && Date.now() < deadlineAt) { + await new Promise((resolve) => setTimeout(resolve, 200)); + tabs1 = await listTabs(); + } + } + if (tabs1.length === 0) { + throw new Error( + `tab not found (no attached Chrome tabs for profile "${profile.name}"). ` + + "Click the OpenClaw Browser Relay toolbar icon on the tab you want to control (badge ON).", + ); + } + } else { + await openTab("about:blank"); } - await openTab("about:blank"); } const tabs = await listTabs(); @@ -73,16 +86,7 @@ export function createProfileSelectionOps({ return page ?? candidates.at(0) ?? null; }; - let chosen = targetId ? resolveById(targetId) : pickDefault(); - if ( - !chosen && - (profile.driver === "extension" || !profile.cdpIsLoopback) && - candidates.length === 1 - ) { - // If an agent passes a stale/foreign targetId but only one candidate remains, - // recover by using that tab instead of failing hard. - chosen = candidates[0] ?? null; - } + const chosen = targetId ? resolveById(targetId) : pickDefault(); if (chosen === "AMBIGUOUS") { throw new Error("ambiguous target id prefix"); diff --git a/src/browser/server-lifecycle.ts b/src/browser/server-lifecycle.ts index 64d10cb7b9f..10a4569095a 100644 --- a/src/browser/server-lifecycle.ts +++ b/src/browser/server-lifecycle.ts @@ -16,7 +16,10 @@ export async function ensureExtensionRelayForProfiles(params: { if (!profile || profile.driver !== "extension") { continue; } - await ensureChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch((err) => { + await ensureChromeExtensionRelayServer({ + cdpUrl: profile.cdpUrl, + bindHost: params.resolved.relayBindHost, + }).catch((err) => { params.onWarn(`Chrome extension relay init failed for profile "${name}": ${String(err)}`); }); } diff --git a/src/channels/plugins/config-schema.ts b/src/channels/plugins/config-schema.ts index 75074ae569d..35be4c9d388 100644 --- a/src/channels/plugins/config-schema.ts +++ b/src/channels/plugins/config-schema.ts @@ -1,10 +1,25 @@ -import type { ZodTypeAny } from "zod"; +import { z, type ZodTypeAny } from "zod"; import type { ChannelConfigSchema } from "./types.plugin.js"; type ZodSchemaWithToJsonSchema = ZodTypeAny & { toJSONSchema?: (params?: Record) => unknown; }; +type ExtendableZodObject = ZodTypeAny & { + extend: (shape: Record) => ZodTypeAny; +}; + +export const AllowFromEntrySchema = z.union([z.string(), z.number()]); + +export function buildCatchallMultiAccountChannelSchema( + accountSchema: T, +): T { + return accountSchema.extend({ + accounts: z.object({}).catchall(accountSchema).optional(), + defaultAccount: z.string().optional(), + }) as T; +} + export function buildChannelConfigSchema(schema: ZodTypeAny): ChannelConfigSchema { const schemaWithJson = schema as ZodSchemaWithToJsonSchema; if (typeof schemaWithJson.toJSONSchema === "function") { diff --git a/src/cli/command-secret-gateway.test.ts b/src/cli/command-secret-gateway.test.ts index 9f1f6c402e5..7929cdbdafc 100644 --- a/src/cli/command-secret-gateway.test.ts +++ b/src/cli/command-secret-gateway.test.ts @@ -273,22 +273,17 @@ describe("resolveCommandSecretRefsViaGateway", () => { }); it("fails when configured refs remain unresolved after gateway assignments are applied", async () => { + const envKey = "TALK_API_KEY_STRICT_UNRESOLVED"; callGateway.mockResolvedValueOnce({ assignments: [], diagnostics: [], }); - await expect( - resolveCommandSecretRefsViaGateway({ - config: { - talk: { - apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" }, - }, - } as OpenClawConfig, - commandName: "memory status", - targetIds: new Set(["talk.apiKey"]), - }), - ).rejects.toThrow(/talk\.apiKey is unresolved in the active runtime snapshot/i); + await withEnvValue(envKey, undefined, async () => { + await expect(resolveTalkApiKey({ envKey })).rejects.toThrow( + /talk\.apiKey is unresolved in the active runtime snapshot/i, + ); + }); }); it("allows unresolved refs when gateway diagnostics mark the target as inactive", async () => { diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts index 562a239385d..a6b20ca5b3d 100644 --- a/src/cli/cron-cli.test.ts +++ b/src/cli/cron-cli.test.ts @@ -156,7 +156,11 @@ async function expectCronEditWithScheduleLookupExit( ).rejects.toThrow("__exit__:1"); } -async function runCronRunAndCaptureExit(params: { ran: boolean; args?: string[] }) { +async function runCronRunAndCaptureExit(params: { + ran?: boolean; + enqueued?: boolean; + args?: string[]; +}) { resetGatewayMock(); callGatewayFromCli.mockImplementation( async (method: string, _opts: unknown, callParams?: unknown) => { @@ -164,7 +168,12 @@ async function runCronRunAndCaptureExit(params: { ran: boolean; args?: string[] return { enabled: true }; } if (method === "cron.run") { - return { ok: true, params: callParams, ran: params.ran }; + return { + ok: true, + params: callParams, + ...(typeof params.ran === "boolean" ? { ran: params.ran } : {}), + ...(typeof params.enqueued === "boolean" ? { enqueued: params.enqueued } : {}), + }; } return { ok: true, params: callParams }; }, @@ -195,13 +204,18 @@ describe("cron cli", () => { ran: true, expectedExitCode: 0, }, + { + name: "exits 0 for cron run when job is queued successfully", + enqueued: true, + expectedExitCode: 0, + }, { name: "exits 1 for cron run when job does not execute", ran: false, expectedExitCode: 1, }, - ])("$name", async ({ ran, expectedExitCode }) => { - const { exitSpy } = await runCronRunAndCaptureExit({ ran }); + ])("$name", async ({ ran, enqueued, expectedExitCode }) => { + const { exitSpy } = await runCronRunAndCaptureExit({ ran, enqueued }); expect(exitSpy).toHaveBeenCalledWith(expectedExitCode); }); diff --git a/src/cli/cron-cli/register.cron-simple.ts b/src/cli/cron-cli/register.cron-simple.ts index ae05ff1fa69..891d8691968 100644 --- a/src/cli/cron-cli/register.cron-simple.ts +++ b/src/cli/cron-cli/register.cron-simple.ts @@ -99,8 +99,8 @@ export function registerCronSimpleCommands(cron: Command) { mode: opts.due ? "due" : "force", }); printCronJson(res); - const result = res as { ok?: boolean; ran?: boolean } | undefined; - defaultRuntime.exit(result?.ok && result?.ran ? 0 : 1); + const result = res as { ok?: boolean; ran?: boolean; enqueued?: boolean } | undefined; + defaultRuntime.exit(result?.ok && (result?.ran || result?.enqueued) ? 0 : 1); } catch (err) { handleCronCliError(err); } diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index cd54df8035a..7fa7396d0b0 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -5,6 +5,7 @@ import { readBestEffortConfig, resolveGatewayPort } from "../../config/config.js import { parseCmdScriptCommandLine } from "../../daemon/cmd-argv.js"; import { resolveGatewayService } from "../../daemon/service.js"; import { probeGateway } from "../../gateway/probe.js"; +import { isGatewayArgv, parseProcCmdline } from "../../infra/gateway-process-argv.js"; import { findGatewayPidsOnPortSync } from "../../infra/restart.js"; import { defaultRuntime } from "../../runtime.js"; import { theme } from "../../terminal/theme.js"; @@ -42,17 +43,6 @@ async function resolveGatewayLifecyclePort(service = resolveGatewayService()) { return portFromArgs ?? resolveGatewayPort(await readBestEffortConfig(), mergedEnv); } -function normalizeProcArg(arg: string): string { - return arg.replaceAll("\\", "/").toLowerCase(); -} - -function parseProcCmdline(raw: string): string[] { - return raw - .split("\0") - .map((entry) => entry.trim()) - .filter(Boolean); -} - function extractWindowsCommandLine(raw: string): string | null { const lines = raw .split(/\r?\n/) @@ -68,31 +58,6 @@ function extractWindowsCommandLine(raw: string): string | null { return lines.find((line) => line.toLowerCase() !== "commandline") ?? null; } -function stripExecutableExtension(value: string): string { - return value.replace(/\.(bat|cmd|exe)$/i, ""); -} - -function isGatewayArgv(args: string[]): boolean { - const normalized = args.map(normalizeProcArg); - if (!normalized.includes("gateway")) { - return false; - } - - const entryCandidates = [ - "dist/index.js", - "dist/entry.js", - "openclaw.mjs", - "scripts/run-node.mjs", - "src/index.ts", - ]; - if (normalized.some((arg) => entryCandidates.some((entry) => arg.endsWith(entry)))) { - return true; - } - - const exe = stripExecutableExtension(normalized[0] ?? ""); - return exe.endsWith("/openclaw") || exe === "openclaw" || exe.endsWith("/openclaw-gateway"); -} - function readGatewayProcessArgsSync(pid: number): string[] | null { if (process.platform === "linux") { try { @@ -135,7 +100,7 @@ function resolveGatewayListenerPids(port: number): number[] { .filter((pid): pid is number => Number.isFinite(pid) && pid > 0) .filter((pid) => { const args = readGatewayProcessArgsSync(pid); - return args != null && isGatewayArgv(args); + return args != null && isGatewayArgv(args, { allowGatewayBinary: true }); }); } @@ -147,7 +112,7 @@ function resolveGatewayPortFallback(): Promise { function signalGatewayPid(pid: number, signal: "SIGTERM" | "SIGUSR1") { const args = readGatewayProcessArgsSync(pid); - if (!args || !isGatewayArgv(args)) { + if (!args || !isGatewayArgv(args, { allowGatewayBinary: true })) { throw new Error(`refusing to signal non-gateway process pid ${pid}`); } process.kill(pid, signal); diff --git a/src/cli/daemon-cli/restart-health.ts b/src/cli/daemon-cli/restart-health.ts index 00b6b4e98b3..13741d2e9c4 100644 --- a/src/cli/daemon-cli/restart-health.ts +++ b/src/cli/daemon-cli/restart-health.ts @@ -242,6 +242,22 @@ export async function waitForGatewayHealthyListener(params: { return snapshot; } +function renderPortUsageDiagnostics(snapshot: GatewayPortHealthSnapshot): string[] { + const lines: string[] = []; + + if (snapshot.portUsage.status === "busy") { + lines.push(...formatPortDiagnostics(snapshot.portUsage)); + } else { + lines.push(`Gateway port ${snapshot.portUsage.port} status: ${snapshot.portUsage.status}.`); + } + + if (snapshot.portUsage.errors?.length) { + lines.push(`Port diagnostics errors: ${snapshot.portUsage.errors.join("; ")}`); + } + + return lines; +} + export function renderRestartDiagnostics(snapshot: GatewayRestartSnapshot): string[] { const lines: string[] = []; const runtimeSummary = [ @@ -257,33 +273,13 @@ export function renderRestartDiagnostics(snapshot: GatewayRestartSnapshot): stri lines.push(`Service runtime: ${runtimeSummary}`); } - if (snapshot.portUsage.status === "busy") { - lines.push(...formatPortDiagnostics(snapshot.portUsage)); - } else { - lines.push(`Gateway port ${snapshot.portUsage.port} status: ${snapshot.portUsage.status}.`); - } - - if (snapshot.portUsage.errors?.length) { - lines.push(`Port diagnostics errors: ${snapshot.portUsage.errors.join("; ")}`); - } + lines.push(...renderPortUsageDiagnostics(snapshot)); return lines; } export function renderGatewayPortHealthDiagnostics(snapshot: GatewayPortHealthSnapshot): string[] { - const lines: string[] = []; - - if (snapshot.portUsage.status === "busy") { - lines.push(...formatPortDiagnostics(snapshot.portUsage)); - } else { - lines.push(`Gateway port ${snapshot.portUsage.port} status: ${snapshot.portUsage.status}.`); - } - - if (snapshot.portUsage.errors?.length) { - lines.push(`Port diagnostics errors: ${snapshot.portUsage.errors.join("; ")}`); - } - - return lines; + return renderPortUsageDiagnostics(snapshot); } export async function terminateStaleGatewayPids(pids: number[]): Promise { diff --git a/src/cli/program/command-registry.test.ts b/src/cli/program/command-registry.test.ts index 3fc44592ce9..329a28a659f 100644 --- a/src/cli/program/command-registry.test.ts +++ b/src/cli/program/command-registry.test.ts @@ -11,6 +11,13 @@ vi.mock("./register.agent.js", () => ({ }, })); +vi.mock("./register.backup.js", () => ({ + registerBackupCommand: (program: Command) => { + const backup = program.command("backup"); + backup.command("create"); + }, +})); + vi.mock("./register.maintenance.js", () => ({ registerMaintenanceCommands: (program: Command) => { program.command("doctor"); @@ -67,6 +74,7 @@ describe("command-registry", () => { expect(names).toContain("config"); expect(names).toContain("memory"); expect(names).toContain("agents"); + expect(names).toContain("backup"); expect(names).toContain("browser"); expect(names).toContain("sessions"); expect(names).not.toContain("agent"); diff --git a/src/cli/program/command-registry.ts b/src/cli/program/command-registry.ts index 16416c87e0a..3e2338f3475 100644 --- a/src/cli/program/command-registry.ts +++ b/src/cli/program/command-registry.ts @@ -92,6 +92,19 @@ const coreEntries: CoreCliEntry[] = [ mod.registerConfigCli(program); }, }, + { + commands: [ + { + name: "backup", + description: "Create and verify local backup archives for OpenClaw state", + hasSubcommands: true, + }, + ], + register: async ({ program }) => { + const mod = await import("./register.backup.js"); + mod.registerBackupCommand(program); + }, + }, { commands: [ { diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index f99b9f5b291..4353b8a0d18 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -80,6 +80,11 @@ describe("registerPreActionHooks", () => { function buildProgram() { const program = new Command().name("openclaw"); program.command("status").action(() => {}); + program + .command("backup") + .command("create") + .option("--json") + .action(() => {}); program.command("doctor").action(() => {}); program.command("completion").action(() => {}); program.command("secrets").action(() => {}); @@ -226,6 +231,15 @@ describe("registerPreActionHooks", () => { expect(ensureConfigReadyMock).not.toHaveBeenCalled(); }); + it("bypasses config guard for backup create", async () => { + await runPreAction({ + parseArgv: ["backup", "create"], + processArgv: ["node", "openclaw", "backup", "create", "--json"], + }); + + expect(ensureConfigReadyMock).not.toHaveBeenCalled(); + }); + beforeAll(() => { program = buildProgram(); const hooks = ( diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index e1ce076a528..5e029c84858 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -36,7 +36,7 @@ const PLUGIN_REQUIRED_COMMANDS = new Set([ "status", "health", ]); -const CONFIG_GUARD_BYPASS_COMMANDS = new Set(["doctor", "completion", "secrets"]); +const CONFIG_GUARD_BYPASS_COMMANDS = new Set(["backup", "doctor", "completion", "secrets"]); const JSON_PARSE_ONLY_COMMANDS = new Set(["config set"]); let configGuardModulePromise: Promise | undefined; let pluginRegistryModulePromise: Promise | undefined; diff --git a/src/cli/program/register.backup.test.ts b/src/cli/program/register.backup.test.ts new file mode 100644 index 00000000000..b0f62cb97bc --- /dev/null +++ b/src/cli/program/register.backup.test.ts @@ -0,0 +1,104 @@ +import { Command } from "commander"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const backupCreateCommand = vi.fn(); +const backupVerifyCommand = vi.fn(); + +const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), +}; + +vi.mock("../../commands/backup.js", () => ({ + backupCreateCommand, +})); + +vi.mock("../../commands/backup-verify.js", () => ({ + backupVerifyCommand, +})); + +vi.mock("../../runtime.js", () => ({ + defaultRuntime: runtime, +})); + +let registerBackupCommand: typeof import("./register.backup.js").registerBackupCommand; + +beforeAll(async () => { + ({ registerBackupCommand } = await import("./register.backup.js")); +}); + +describe("registerBackupCommand", () => { + async function runCli(args: string[]) { + const program = new Command(); + registerBackupCommand(program); + await program.parseAsync(args, { from: "user" }); + } + + beforeEach(() => { + vi.clearAllMocks(); + backupCreateCommand.mockResolvedValue(undefined); + backupVerifyCommand.mockResolvedValue(undefined); + }); + + it("runs backup create with forwarded options", async () => { + await runCli(["backup", "create", "--output", "/tmp/backups", "--json", "--dry-run"]); + + expect(backupCreateCommand).toHaveBeenCalledWith( + runtime, + expect.objectContaining({ + output: "/tmp/backups", + json: true, + dryRun: true, + verify: false, + onlyConfig: false, + includeWorkspace: true, + }), + ); + }); + + it("honors --no-include-workspace", async () => { + await runCli(["backup", "create", "--no-include-workspace"]); + + expect(backupCreateCommand).toHaveBeenCalledWith( + runtime, + expect.objectContaining({ + includeWorkspace: false, + }), + ); + }); + + it("forwards --verify to backup create", async () => { + await runCli(["backup", "create", "--verify"]); + + expect(backupCreateCommand).toHaveBeenCalledWith( + runtime, + expect.objectContaining({ + verify: true, + }), + ); + }); + + it("forwards --only-config to backup create", async () => { + await runCli(["backup", "create", "--only-config"]); + + expect(backupCreateCommand).toHaveBeenCalledWith( + runtime, + expect.objectContaining({ + onlyConfig: true, + }), + ); + }); + + it("runs backup verify with forwarded options", async () => { + await runCli(["backup", "verify", "/tmp/openclaw-backup.tar.gz", "--json"]); + + expect(backupVerifyCommand).toHaveBeenCalledWith( + runtime, + expect.objectContaining({ + archive: "/tmp/openclaw-backup.tar.gz", + json: true, + }), + ); + }); +}); diff --git a/src/cli/program/register.backup.ts b/src/cli/program/register.backup.ts new file mode 100644 index 00000000000..fc928f0ff3a --- /dev/null +++ b/src/cli/program/register.backup.ts @@ -0,0 +1,92 @@ +import type { Command } from "commander"; +import { backupVerifyCommand } from "../../commands/backup-verify.js"; +import { backupCreateCommand } from "../../commands/backup.js"; +import { defaultRuntime } from "../../runtime.js"; +import { formatDocsLink } from "../../terminal/links.js"; +import { theme } from "../../terminal/theme.js"; +import { runCommandWithRuntime } from "../cli-utils.js"; +import { formatHelpExamples } from "../help-format.js"; + +export function registerBackupCommand(program: Command) { + const backup = program + .command("backup") + .description("Create and verify local backup archives for OpenClaw state") + .addHelpText( + "after", + () => + `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/backup", "docs.openclaw.ai/cli/backup")}\n`, + ); + + backup + .command("create") + .description("Write a backup archive for config, credentials, sessions, and workspaces") + .option("--output ", "Archive path or destination directory") + .option("--json", "Output JSON", false) + .option("--dry-run", "Print the backup plan without writing the archive", false) + .option("--verify", "Verify the archive after writing it", false) + .option("--only-config", "Back up only the active JSON config file", false) + .option("--no-include-workspace", "Exclude workspace directories from the backup") + .addHelpText( + "after", + () => + `\n${theme.heading("Examples:")}\n${formatHelpExamples([ + ["openclaw backup create", "Create a timestamped backup in the current directory."], + [ + "openclaw backup create --output ~/Backups", + "Write the archive into an existing backup directory.", + ], + [ + "openclaw backup create --dry-run --json", + "Preview the archive plan without writing any files.", + ], + [ + "openclaw backup create --verify", + "Create the archive and immediately validate its manifest and payload layout.", + ], + [ + "openclaw backup create --no-include-workspace", + "Back up state/config without agent workspace files.", + ], + ["openclaw backup create --only-config", "Back up only the active JSON config file."], + ])}`, + ) + .action(async (opts) => { + await runCommandWithRuntime(defaultRuntime, async () => { + await backupCreateCommand(defaultRuntime, { + output: opts.output as string | undefined, + json: Boolean(opts.json), + dryRun: Boolean(opts.dryRun), + verify: Boolean(opts.verify), + onlyConfig: Boolean(opts.onlyConfig), + includeWorkspace: opts.includeWorkspace as boolean, + }); + }); + }); + + backup + .command("verify ") + .description("Validate a backup archive and its embedded manifest") + .option("--json", "Output JSON", false) + .addHelpText( + "after", + () => + `\n${theme.heading("Examples:")}\n${formatHelpExamples([ + [ + "openclaw backup verify ./2026-03-09T00-00-00.000Z-openclaw-backup.tar.gz", + "Check that the archive structure and manifest are intact.", + ], + [ + "openclaw backup verify ~/Backups/latest.tar.gz --json", + "Emit machine-readable verification output.", + ], + ])}`, + ) + .action(async (archive, opts) => { + await runCommandWithRuntime(defaultRuntime, async () => { + await backupVerifyCommand(defaultRuntime, { + archive: archive as string, + json: Boolean(opts.json), + }); + }); + }); +} diff --git a/src/commands/backup-shared.ts b/src/commands/backup-shared.ts new file mode 100644 index 00000000000..b4b6961bbaa --- /dev/null +++ b/src/commands/backup-shared.ts @@ -0,0 +1,254 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { + readConfigFileSnapshot, + resolveConfigPath, + resolveOAuthDir, + resolveStateDir, +} from "../config/config.js"; +import { formatSessionArchiveTimestamp } from "../config/sessions/artifacts.js"; +import { pathExists, shortenHomePath } from "../utils.js"; +import { buildCleanupPlan, isPathWithin } from "./cleanup-utils.js"; + +export type BackupAssetKind = "state" | "config" | "credentials" | "workspace"; +export type BackupSkipReason = "covered" | "missing"; + +export type BackupAsset = { + kind: BackupAssetKind; + sourcePath: string; + displayPath: string; + archivePath: string; +}; + +export type SkippedBackupAsset = { + kind: BackupAssetKind; + sourcePath: string; + displayPath: string; + reason: BackupSkipReason; + coveredBy?: string; +}; + +export type BackupPlan = { + stateDir: string; + configPath: string; + oauthDir: string; + workspaceDirs: string[]; + included: BackupAsset[]; + skipped: SkippedBackupAsset[]; +}; + +type BackupAssetCandidate = { + kind: BackupAssetKind; + sourcePath: string; + canonicalPath: string; + exists: boolean; +}; + +function backupAssetPriority(kind: BackupAssetKind): number { + switch (kind) { + case "state": + return 0; + case "config": + return 1; + case "credentials": + return 2; + case "workspace": + return 3; + } +} + +export function buildBackupArchiveRoot(nowMs = Date.now()): string { + return `${formatSessionArchiveTimestamp(nowMs)}-openclaw-backup`; +} + +export function buildBackupArchiveBasename(nowMs = Date.now()): string { + return `${buildBackupArchiveRoot(nowMs)}.tar.gz`; +} + +export function encodeAbsolutePathForBackupArchive(sourcePath: string): string { + const normalized = sourcePath.replaceAll("\\", "/"); + const windowsMatch = normalized.match(/^([A-Za-z]):\/(.*)$/); + if (windowsMatch) { + const drive = windowsMatch[1]?.toUpperCase() ?? "UNKNOWN"; + const rest = windowsMatch[2] ?? ""; + return path.posix.join("windows", drive, rest); + } + if (normalized.startsWith("/")) { + return path.posix.join("posix", normalized.slice(1)); + } + return path.posix.join("relative", normalized); +} + +export function buildBackupArchivePath(archiveRoot: string, sourcePath: string): string { + return path.posix.join(archiveRoot, "payload", encodeAbsolutePathForBackupArchive(sourcePath)); +} + +function compareCandidates(left: BackupAssetCandidate, right: BackupAssetCandidate): number { + const depthDelta = left.canonicalPath.length - right.canonicalPath.length; + if (depthDelta !== 0) { + return depthDelta; + } + const priorityDelta = backupAssetPriority(left.kind) - backupAssetPriority(right.kind); + if (priorityDelta !== 0) { + return priorityDelta; + } + return left.canonicalPath.localeCompare(right.canonicalPath); +} + +async function canonicalizeExistingPath(targetPath: string): Promise { + try { + return await fs.realpath(targetPath); + } catch { + return path.resolve(targetPath); + } +} + +export async function resolveBackupPlanFromDisk( + params: { + includeWorkspace?: boolean; + onlyConfig?: boolean; + nowMs?: number; + } = {}, +): Promise { + const includeWorkspace = params.includeWorkspace ?? true; + const onlyConfig = params.onlyConfig ?? false; + const stateDir = resolveStateDir(); + const configPath = resolveConfigPath(); + const oauthDir = resolveOAuthDir(); + const archiveRoot = buildBackupArchiveRoot(params.nowMs); + + if (onlyConfig) { + const resolvedConfigPath = path.resolve(configPath); + if (!(await pathExists(resolvedConfigPath))) { + return { + stateDir, + configPath, + oauthDir, + workspaceDirs: [], + included: [], + skipped: [ + { + kind: "config", + sourcePath: resolvedConfigPath, + displayPath: shortenHomePath(resolvedConfigPath), + reason: "missing", + }, + ], + }; + } + + const canonicalConfigPath = await canonicalizeExistingPath(resolvedConfigPath); + return { + stateDir, + configPath, + oauthDir, + workspaceDirs: [], + included: [ + { + kind: "config", + sourcePath: canonicalConfigPath, + displayPath: shortenHomePath(canonicalConfigPath), + archivePath: buildBackupArchivePath(archiveRoot, canonicalConfigPath), + }, + ], + skipped: [], + }; + } + + const configSnapshot = await readConfigFileSnapshot(); + if (includeWorkspace && configSnapshot.exists && !configSnapshot.valid) { + throw new Error( + `Config invalid at ${shortenHomePath(configSnapshot.path)}. OpenClaw cannot reliably discover custom workspaces for backup. Fix the config or rerun with --no-include-workspace for a partial backup.`, + ); + } + const cleanupPlan = buildCleanupPlan({ + cfg: configSnapshot.config, + stateDir, + configPath, + oauthDir, + }); + const workspaceDirs = includeWorkspace ? cleanupPlan.workspaceDirs : []; + + const rawCandidates: Array> = [ + { kind: "state", sourcePath: path.resolve(stateDir) }, + ...(cleanupPlan.configInsideState + ? [] + : [{ kind: "config" as const, sourcePath: path.resolve(configPath) }]), + ...(cleanupPlan.oauthInsideState + ? [] + : [{ kind: "credentials" as const, sourcePath: path.resolve(oauthDir) }]), + ...(includeWorkspace + ? workspaceDirs.map((workspaceDir) => ({ + kind: "workspace" as const, + sourcePath: path.resolve(workspaceDir), + })) + : []), + ]; + + const candidates: BackupAssetCandidate[] = await Promise.all( + rawCandidates.map(async (candidate) => { + const exists = await pathExists(candidate.sourcePath); + return { + ...candidate, + exists, + canonicalPath: exists + ? await canonicalizeExistingPath(candidate.sourcePath) + : path.resolve(candidate.sourcePath), + }; + }), + ); + + const uniqueCandidates: BackupAssetCandidate[] = []; + const seenCanonicalPaths = new Set(); + for (const candidate of [...candidates].toSorted(compareCandidates)) { + if (seenCanonicalPaths.has(candidate.canonicalPath)) { + continue; + } + seenCanonicalPaths.add(candidate.canonicalPath); + uniqueCandidates.push(candidate); + } + const included: BackupAsset[] = []; + const skipped: SkippedBackupAsset[] = []; + + for (const candidate of uniqueCandidates) { + if (!candidate.exists) { + skipped.push({ + kind: candidate.kind, + sourcePath: candidate.sourcePath, + displayPath: shortenHomePath(candidate.sourcePath), + reason: "missing", + }); + continue; + } + + const coveredBy = included.find((asset) => + isPathWithin(candidate.canonicalPath, asset.sourcePath), + ); + if (coveredBy) { + skipped.push({ + kind: candidate.kind, + sourcePath: candidate.canonicalPath, + displayPath: shortenHomePath(candidate.canonicalPath), + reason: "covered", + coveredBy: coveredBy.displayPath, + }); + continue; + } + + included.push({ + kind: candidate.kind, + sourcePath: candidate.canonicalPath, + displayPath: shortenHomePath(candidate.canonicalPath), + archivePath: buildBackupArchivePath(archiveRoot, candidate.canonicalPath), + }); + } + + return { + stateDir, + configPath, + oauthDir, + workspaceDirs: workspaceDirs.map((entry) => path.resolve(entry)), + included, + skipped, + }; +} diff --git a/src/commands/backup-verify.test.ts b/src/commands/backup-verify.test.ts new file mode 100644 index 00000000000..9288d2fb8c1 --- /dev/null +++ b/src/commands/backup-verify.test.ts @@ -0,0 +1,392 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import * as tar from "tar"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js"; +import { buildBackupArchiveRoot } from "./backup-shared.js"; +import { backupVerifyCommand } from "./backup-verify.js"; +import { backupCreateCommand } from "./backup.js"; + +describe("backupVerifyCommand", () => { + let tempHome: TempHomeEnv; + + beforeEach(async () => { + tempHome = await createTempHomeEnv("openclaw-backup-verify-test-"); + }); + + afterEach(async () => { + await tempHome.restore(); + }); + + it("verifies an archive created by backup create", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const archiveDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-verify-out-")); + try { + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.writeFile(path.join(stateDir, "state.txt"), "hello\n", "utf8"); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const nowMs = Date.UTC(2026, 2, 9, 0, 0, 0); + const created = await backupCreateCommand(runtime, { output: archiveDir, nowMs }); + const verified = await backupVerifyCommand(runtime, { archive: created.archivePath }); + + expect(verified.ok).toBe(true); + expect(verified.archiveRoot).toBe(buildBackupArchiveRoot(nowMs)); + expect(verified.assetCount).toBeGreaterThan(0); + } finally { + await fs.rm(archiveDir, { recursive: true, force: true }); + } + }); + + it("fails when the archive does not contain a manifest", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-no-manifest-")); + const archivePath = path.join(tempDir, "broken.tar.gz"); + try { + const root = path.join(tempDir, "root"); + await fs.mkdir(path.join(root, "payload"), { recursive: true }); + await fs.writeFile(path.join(root, "payload", "data.txt"), "x\n", "utf8"); + await tar.c({ file: archivePath, gzip: true, cwd: tempDir }, ["root"]); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await expect(backupVerifyCommand(runtime, { archive: archivePath })).rejects.toThrow( + /expected exactly one backup manifest entry/i, + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("fails when the manifest references a missing asset payload", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-missing-asset-")); + const archivePath = path.join(tempDir, "broken.tar.gz"); + try { + const rootName = "2026-03-09T00-00-00.000Z-openclaw-backup"; + const root = path.join(tempDir, rootName); + await fs.mkdir(root, { recursive: true }); + const manifest = { + schemaVersion: 1, + createdAt: "2026-03-09T00:00:00.000Z", + archiveRoot: rootName, + runtimeVersion: "test", + platform: process.platform, + nodeVersion: process.version, + assets: [ + { + kind: "state", + sourcePath: "/tmp/.openclaw", + archivePath: `${rootName}/payload/posix/tmp/.openclaw`, + }, + ], + }; + await fs.writeFile( + path.join(root, "manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + ); + await tar.c({ file: archivePath, gzip: true, cwd: tempDir }, [rootName]); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await expect(backupVerifyCommand(runtime, { archive: archivePath })).rejects.toThrow( + /missing payload for manifest asset/i, + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("fails when archive paths contain traversal segments", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-traversal-")); + const archivePath = path.join(tempDir, "broken.tar.gz"); + const manifestPath = path.join(tempDir, "manifest.json"); + const payloadPath = path.join(tempDir, "payload.txt"); + try { + const rootName = "2026-03-09T00-00-00.000Z-openclaw-backup"; + const traversalPath = `${rootName}/payload/../escaped.txt`; + const manifest = { + schemaVersion: 1, + createdAt: "2026-03-09T00:00:00.000Z", + archiveRoot: rootName, + runtimeVersion: "test", + platform: process.platform, + nodeVersion: process.version, + assets: [ + { + kind: "state", + sourcePath: "/tmp/.openclaw", + archivePath: traversalPath, + }, + ], + }; + await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + await fs.writeFile(payloadPath, "payload\n", "utf8"); + await tar.c( + { + file: archivePath, + gzip: true, + portable: true, + preservePaths: true, + onWriteEntry: (entry) => { + if (entry.path === manifestPath) { + entry.path = `${rootName}/manifest.json`; + return; + } + if (entry.path === payloadPath) { + entry.path = traversalPath; + } + }, + }, + [manifestPath, payloadPath], + ); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await expect(backupVerifyCommand(runtime, { archive: archivePath })).rejects.toThrow( + /path traversal segments/i, + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("fails when archive paths contain backslashes", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-backslash-")); + const archivePath = path.join(tempDir, "broken.tar.gz"); + const manifestPath = path.join(tempDir, "manifest.json"); + const payloadPath = path.join(tempDir, "payload.txt"); + try { + const rootName = "2026-03-09T00-00-00.000Z-openclaw-backup"; + const invalidPath = `${rootName}/payload\\..\\escaped.txt`; + const manifest = { + schemaVersion: 1, + createdAt: "2026-03-09T00:00:00.000Z", + archiveRoot: rootName, + runtimeVersion: "test", + platform: process.platform, + nodeVersion: process.version, + assets: [ + { + kind: "state", + sourcePath: "/tmp/.openclaw", + archivePath: invalidPath, + }, + ], + }; + await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + await fs.writeFile(payloadPath, "payload\n", "utf8"); + await tar.c( + { + file: archivePath, + gzip: true, + portable: true, + preservePaths: true, + onWriteEntry: (entry) => { + if (entry.path === manifestPath) { + entry.path = `${rootName}/manifest.json`; + return; + } + if (entry.path === payloadPath) { + entry.path = invalidPath; + } + }, + }, + [manifestPath, payloadPath], + ); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await expect(backupVerifyCommand(runtime, { archive: archivePath })).rejects.toThrow( + /forward slashes/i, + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("ignores payload manifest.json files when locating the backup manifest", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const externalWorkspace = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-")); + const configPath = path.join(tempHome.home, "custom-config.json"); + const archiveDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-verify-out-")); + try { + process.env.OPENCLAW_CONFIG_PATH = configPath; + await fs.writeFile( + configPath, + JSON.stringify({ + agents: { + defaults: { + workspace: externalWorkspace, + }, + }, + }), + "utf8", + ); + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.writeFile(path.join(stateDir, "state.txt"), "hello\n", "utf8"); + await fs.writeFile( + path.join(externalWorkspace, "manifest.json"), + JSON.stringify({ name: "workspace-payload" }), + "utf8", + ); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const created = await backupCreateCommand(runtime, { + output: archiveDir, + includeWorkspace: true, + nowMs: Date.UTC(2026, 2, 9, 2, 0, 0), + }); + const verified = await backupVerifyCommand(runtime, { archive: created.archivePath }); + + expect(verified.ok).toBe(true); + expect(verified.assetCount).toBeGreaterThanOrEqual(2); + } finally { + delete process.env.OPENCLAW_CONFIG_PATH; + await fs.rm(externalWorkspace, { recursive: true, force: true }); + await fs.rm(archiveDir, { recursive: true, force: true }); + } + }); + + it("fails when the archive contains duplicate root manifest entries", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-duplicate-manifest-")); + const archivePath = path.join(tempDir, "broken.tar.gz"); + const manifestPath = path.join(tempDir, "manifest.json"); + const payloadPath = path.join(tempDir, "payload.txt"); + try { + const rootName = "2026-03-09T00-00-00.000Z-openclaw-backup"; + const manifest = { + schemaVersion: 1, + createdAt: "2026-03-09T00:00:00.000Z", + archiveRoot: rootName, + runtimeVersion: "test", + platform: process.platform, + nodeVersion: process.version, + assets: [ + { + kind: "state", + sourcePath: "/tmp/.openclaw", + archivePath: `${rootName}/payload/posix/tmp/.openclaw/payload.txt`, + }, + ], + }; + await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + await fs.writeFile(payloadPath, "payload\n", "utf8"); + await tar.c( + { + file: archivePath, + gzip: true, + portable: true, + preservePaths: true, + onWriteEntry: (entry) => { + if (entry.path === manifestPath) { + entry.path = `${rootName}/manifest.json`; + return; + } + if (entry.path === payloadPath) { + entry.path = `${rootName}/payload/posix/tmp/.openclaw/payload.txt`; + } + }, + }, + [manifestPath, manifestPath, payloadPath], + ); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await expect(backupVerifyCommand(runtime, { archive: archivePath })).rejects.toThrow( + /expected exactly one backup manifest entry, found 2/i, + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("fails when the archive contains duplicate payload entries", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-duplicate-payload-")); + const archivePath = path.join(tempDir, "broken.tar.gz"); + const manifestPath = path.join(tempDir, "manifest.json"); + const payloadPathA = path.join(tempDir, "payload-a.txt"); + const payloadPathB = path.join(tempDir, "payload-b.txt"); + try { + const rootName = "2026-03-09T00-00-00.000Z-openclaw-backup"; + const payloadArchivePath = `${rootName}/payload/posix/tmp/.openclaw/payload.txt`; + const manifest = { + schemaVersion: 1, + createdAt: "2026-03-09T00:00:00.000Z", + archiveRoot: rootName, + runtimeVersion: "test", + platform: process.platform, + nodeVersion: process.version, + assets: [ + { + kind: "state", + sourcePath: "/tmp/.openclaw", + archivePath: payloadArchivePath, + }, + ], + }; + await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + await fs.writeFile(payloadPathA, "payload-a\n", "utf8"); + await fs.writeFile(payloadPathB, "payload-b\n", "utf8"); + await tar.c( + { + file: archivePath, + gzip: true, + portable: true, + preservePaths: true, + onWriteEntry: (entry) => { + if (entry.path === manifestPath) { + entry.path = `${rootName}/manifest.json`; + return; + } + if (entry.path === payloadPathA || entry.path === payloadPathB) { + entry.path = payloadArchivePath; + } + }, + }, + [manifestPath, payloadPathA, payloadPathB], + ); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await expect(backupVerifyCommand(runtime, { archive: archivePath })).rejects.toThrow( + /duplicate entry path/i, + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/commands/backup-verify.ts b/src/commands/backup-verify.ts new file mode 100644 index 00000000000..0199c8de259 --- /dev/null +++ b/src/commands/backup-verify.ts @@ -0,0 +1,324 @@ +import path from "node:path"; +import * as tar from "tar"; +import type { RuntimeEnv } from "../runtime.js"; +import { resolveUserPath } from "../utils.js"; + +const WINDOWS_ABSOLUTE_ARCHIVE_PATH_RE = /^[A-Za-z]:[\\/]/; + +type BackupManifestAsset = { + kind: string; + sourcePath: string; + archivePath: string; +}; + +type BackupManifest = { + schemaVersion: number; + createdAt: string; + archiveRoot: string; + runtimeVersion: string; + platform: string; + nodeVersion: string; + options?: { + includeWorkspace?: boolean; + }; + paths?: { + stateDir?: string; + configPath?: string; + oauthDir?: string; + workspaceDirs?: string[]; + }; + assets: BackupManifestAsset[]; + skipped?: Array<{ + kind?: string; + sourcePath?: string; + reason?: string; + coveredBy?: string; + }>; +}; + +export type BackupVerifyOptions = { + archive: string; + json?: boolean; +}; + +export type BackupVerifyResult = { + ok: true; + archivePath: string; + archiveRoot: string; + createdAt: string; + runtimeVersion: string; + assetCount: number; + entryCount: number; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stripTrailingSlashes(value: string): string { + return value.replace(/\/+$/u, ""); +} + +function normalizeArchivePath(entryPath: string, label: string): string { + const trimmed = stripTrailingSlashes(entryPath.trim()); + if (!trimmed) { + throw new Error(`${label} is empty.`); + } + if (trimmed.startsWith("/") || WINDOWS_ABSOLUTE_ARCHIVE_PATH_RE.test(trimmed)) { + throw new Error(`${label} must be relative: ${entryPath}`); + } + if (trimmed.includes("\\")) { + throw new Error(`${label} must use forward slashes: ${entryPath}`); + } + if (trimmed.split("/").some((segment) => segment === "." || segment === "..")) { + throw new Error(`${label} contains path traversal segments: ${entryPath}`); + } + + const normalized = stripTrailingSlashes(path.posix.normalize(trimmed)); + if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) { + throw new Error(`${label} resolves outside the archive root: ${entryPath}`); + } + return normalized; +} + +function normalizeArchiveRoot(rootName: string): string { + const normalized = normalizeArchivePath(rootName, "Backup manifest archiveRoot"); + if (normalized.includes("/")) { + throw new Error(`Backup manifest archiveRoot must be a single path segment: ${rootName}`); + } + return normalized; +} + +function isArchivePathWithin(child: string, parent: string): boolean { + const relative = path.posix.relative(parent, child); + return relative === "" || (!relative.startsWith("../") && relative !== ".."); +} + +function parseManifest(raw: string): BackupManifest { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new Error(`Backup manifest is not valid JSON: ${String(err)}`, { cause: err }); + } + + if (!isRecord(parsed)) { + throw new Error("Backup manifest must be an object."); + } + if (parsed.schemaVersion !== 1) { + throw new Error(`Unsupported backup manifest schemaVersion: ${String(parsed.schemaVersion)}`); + } + if (typeof parsed.archiveRoot !== "string" || !parsed.archiveRoot.trim()) { + throw new Error("Backup manifest is missing archiveRoot."); + } + if (typeof parsed.createdAt !== "string" || !parsed.createdAt.trim()) { + throw new Error("Backup manifest is missing createdAt."); + } + if (!Array.isArray(parsed.assets)) { + throw new Error("Backup manifest is missing assets."); + } + + const assets: BackupManifestAsset[] = []; + for (const asset of parsed.assets) { + if (!isRecord(asset)) { + throw new Error("Backup manifest contains a non-object asset."); + } + if (typeof asset.kind !== "string" || !asset.kind.trim()) { + throw new Error("Backup manifest asset is missing kind."); + } + if (typeof asset.sourcePath !== "string" || !asset.sourcePath.trim()) { + throw new Error("Backup manifest asset is missing sourcePath."); + } + if (typeof asset.archivePath !== "string" || !asset.archivePath.trim()) { + throw new Error("Backup manifest asset is missing archivePath."); + } + assets.push({ + kind: asset.kind, + sourcePath: asset.sourcePath, + archivePath: asset.archivePath, + }); + } + + return { + schemaVersion: 1, + archiveRoot: parsed.archiveRoot, + createdAt: parsed.createdAt, + runtimeVersion: + typeof parsed.runtimeVersion === "string" && parsed.runtimeVersion.trim() + ? parsed.runtimeVersion + : "unknown", + platform: typeof parsed.platform === "string" ? parsed.platform : "unknown", + nodeVersion: typeof parsed.nodeVersion === "string" ? parsed.nodeVersion : "unknown", + options: isRecord(parsed.options) + ? { includeWorkspace: parsed.options.includeWorkspace as boolean | undefined } + : undefined, + paths: isRecord(parsed.paths) + ? { + stateDir: typeof parsed.paths.stateDir === "string" ? parsed.paths.stateDir : undefined, + configPath: + typeof parsed.paths.configPath === "string" ? parsed.paths.configPath : undefined, + oauthDir: typeof parsed.paths.oauthDir === "string" ? parsed.paths.oauthDir : undefined, + workspaceDirs: Array.isArray(parsed.paths.workspaceDirs) + ? parsed.paths.workspaceDirs.filter( + (entry): entry is string => typeof entry === "string", + ) + : undefined, + } + : undefined, + assets, + skipped: Array.isArray(parsed.skipped) ? parsed.skipped : undefined, + }; +} + +async function listArchiveEntries(archivePath: string): Promise { + const entries: string[] = []; + await tar.t({ + file: archivePath, + gzip: true, + onentry: (entry) => { + entries.push(entry.path); + }, + }); + return entries; +} + +async function extractManifest(params: { + archivePath: string; + manifestEntryPath: string; +}): Promise { + let manifestContentPromise: Promise | undefined; + await tar.t({ + file: params.archivePath, + gzip: true, + onentry: (entry) => { + if (entry.path !== params.manifestEntryPath) { + entry.resume(); + return; + } + + manifestContentPromise = new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + entry.on("data", (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + entry.on("error", reject); + entry.on("end", () => { + resolve(Buffer.concat(chunks).toString("utf8")); + }); + }); + }, + }); + + if (!manifestContentPromise) { + throw new Error(`Archive is missing manifest entry: ${params.manifestEntryPath}`); + } + return await manifestContentPromise; +} + +function isRootManifestEntry(entryPath: string): boolean { + const parts = entryPath.split("/"); + return parts.length === 2 && parts[0] !== "" && parts[1] === "manifest.json"; +} + +function verifyManifestAgainstEntries(manifest: BackupManifest, entries: Set): void { + const archiveRoot = normalizeArchiveRoot(manifest.archiveRoot); + const manifestEntryPath = path.posix.join(archiveRoot, "manifest.json"); + const normalizedEntries = [...entries]; + const normalizedEntrySet = new Set(normalizedEntries); + + if (!normalizedEntrySet.has(manifestEntryPath)) { + throw new Error(`Archive is missing manifest entry: ${manifestEntryPath}`); + } + + for (const entry of normalizedEntries) { + if (!isArchivePathWithin(entry, archiveRoot)) { + throw new Error(`Archive entry is outside the declared archive root: ${entry}`); + } + } + + const payloadRoot = path.posix.join(archiveRoot, "payload"); + for (const asset of manifest.assets) { + const assetArchivePath = normalizeArchivePath(asset.archivePath, "Backup manifest asset path"); + if (!isArchivePathWithin(assetArchivePath, payloadRoot)) { + throw new Error(`Manifest asset path is outside payload root: ${asset.archivePath}`); + } + const exact = normalizedEntrySet.has(assetArchivePath); + const nested = normalizedEntries.some( + (entry) => entry !== assetArchivePath && isArchivePathWithin(entry, assetArchivePath), + ); + if (!exact && !nested) { + throw new Error(`Archive is missing payload for manifest asset: ${assetArchivePath}`); + } + } +} + +function formatResult(result: BackupVerifyResult): string { + return [ + `Backup archive OK: ${result.archivePath}`, + `Archive root: ${result.archiveRoot}`, + `Created at: ${result.createdAt}`, + `Runtime version: ${result.runtimeVersion}`, + `Assets verified: ${result.assetCount}`, + `Archive entries scanned: ${result.entryCount}`, + ].join("\n"); +} + +function findDuplicateNormalizedEntryPath( + entries: Array<{ normalized: string }>, +): string | undefined { + const seen = new Set(); + for (const entry of entries) { + if (seen.has(entry.normalized)) { + return entry.normalized; + } + seen.add(entry.normalized); + } + return undefined; +} + +export async function backupVerifyCommand( + runtime: RuntimeEnv, + opts: BackupVerifyOptions, +): Promise { + const archivePath = resolveUserPath(opts.archive); + const rawEntries = await listArchiveEntries(archivePath); + if (rawEntries.length === 0) { + throw new Error("Backup archive is empty."); + } + + const entries = rawEntries.map((entry) => ({ + raw: entry, + normalized: normalizeArchivePath(entry, "Archive entry"), + })); + const normalizedEntrySet = new Set(entries.map((entry) => entry.normalized)); + + const manifestMatches = entries.filter((entry) => isRootManifestEntry(entry.normalized)); + if (manifestMatches.length !== 1) { + throw new Error(`Expected exactly one backup manifest entry, found ${manifestMatches.length}.`); + } + const duplicateEntryPath = findDuplicateNormalizedEntryPath(entries); + if (duplicateEntryPath) { + throw new Error(`Archive contains duplicate entry path: ${duplicateEntryPath}`); + } + const manifestEntryPath = manifestMatches[0]?.raw; + if (!manifestEntryPath) { + throw new Error("Backup archive manifest entry could not be resolved."); + } + + const manifestRaw = await extractManifest({ archivePath, manifestEntryPath }); + const manifest = parseManifest(manifestRaw); + verifyManifestAgainstEntries(manifest, normalizedEntrySet); + + const result: BackupVerifyResult = { + ok: true, + archivePath, + archiveRoot: manifest.archiveRoot, + createdAt: manifest.createdAt, + runtimeVersion: manifest.runtimeVersion, + assetCount: manifest.assets.length, + entryCount: rawEntries.length, + }; + + runtime.log(opts.json ? JSON.stringify(result, null, 2) : formatResult(result)); + return result; +} diff --git a/src/commands/backup.atomic.test.ts b/src/commands/backup.atomic.test.ts new file mode 100644 index 00000000000..53303ef53fe --- /dev/null +++ b/src/commands/backup.atomic.test.ts @@ -0,0 +1,133 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js"; + +const tarCreateMock = vi.hoisted(() => vi.fn()); +const backupVerifyCommandMock = vi.hoisted(() => vi.fn()); + +vi.mock("tar", () => ({ + c: tarCreateMock, +})); + +vi.mock("./backup-verify.js", () => ({ + backupVerifyCommand: backupVerifyCommandMock, +})); + +const { backupCreateCommand } = await import("./backup.js"); + +describe("backupCreateCommand atomic archive write", () => { + let tempHome: TempHomeEnv; + + beforeEach(async () => { + tempHome = await createTempHomeEnv("openclaw-backup-atomic-test-"); + tarCreateMock.mockReset(); + backupVerifyCommandMock.mockReset(); + }); + + afterEach(async () => { + await tempHome.restore(); + }); + + it("does not leave a partial final archive behind when tar creation fails", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const archiveDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-failure-")); + try { + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.writeFile(path.join(stateDir, "state.txt"), "state\n", "utf8"); + + tarCreateMock.mockRejectedValueOnce(new Error("disk full")); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + const outputPath = path.join(archiveDir, "backup.tar.gz"); + + await expect( + backupCreateCommand(runtime, { + output: outputPath, + }), + ).rejects.toThrow(/disk full/i); + + await expect(fs.access(outputPath)).rejects.toThrow(); + const remaining = await fs.readdir(archiveDir); + expect(remaining).toEqual([]); + } finally { + await fs.rm(archiveDir, { recursive: true, force: true }); + } + }); + + it("does not overwrite an archive created after readiness checks complete", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const archiveDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-race-")); + const realLink = fs.link.bind(fs); + const linkSpy = vi.spyOn(fs, "link"); + try { + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.writeFile(path.join(stateDir, "state.txt"), "state\n", "utf8"); + + tarCreateMock.mockImplementationOnce(async ({ file }: { file: string }) => { + await fs.writeFile(file, "archive-bytes", "utf8"); + }); + linkSpy.mockImplementationOnce(async (existingPath, newPath) => { + await fs.writeFile(newPath, "concurrent-archive", "utf8"); + return await realLink(existingPath, newPath); + }); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + const outputPath = path.join(archiveDir, "backup.tar.gz"); + + await expect( + backupCreateCommand(runtime, { + output: outputPath, + }), + ).rejects.toThrow(/refusing to overwrite existing backup archive/i); + + expect(await fs.readFile(outputPath, "utf8")).toBe("concurrent-archive"); + } finally { + linkSpy.mockRestore(); + await fs.rm(archiveDir, { recursive: true, force: true }); + } + }); + + it("falls back to exclusive copy when hard-link publication is unsupported", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const archiveDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-copy-fallback-")); + const linkSpy = vi.spyOn(fs, "link"); + try { + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.writeFile(path.join(stateDir, "state.txt"), "state\n", "utf8"); + + tarCreateMock.mockImplementationOnce(async ({ file }: { file: string }) => { + await fs.writeFile(file, "archive-bytes", "utf8"); + }); + linkSpy.mockRejectedValueOnce( + Object.assign(new Error("hard links not supported"), { code: "EOPNOTSUPP" }), + ); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + const outputPath = path.join(archiveDir, "backup.tar.gz"); + + const result = await backupCreateCommand(runtime, { + output: outputPath, + }); + + expect(result.archivePath).toBe(outputPath); + expect(await fs.readFile(outputPath, "utf8")).toBe("archive-bytes"); + } finally { + linkSpy.mockRestore(); + await fs.rm(archiveDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/commands/backup.test.ts b/src/commands/backup.test.ts new file mode 100644 index 00000000000..349714e4d15 --- /dev/null +++ b/src/commands/backup.test.ts @@ -0,0 +1,434 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import * as tar from "tar"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js"; +import { + buildBackupArchiveRoot, + encodeAbsolutePathForBackupArchive, + resolveBackupPlanFromDisk, +} from "./backup-shared.js"; +import { backupCreateCommand } from "./backup.js"; + +const backupVerifyCommandMock = vi.hoisted(() => vi.fn()); + +vi.mock("./backup-verify.js", () => ({ + backupVerifyCommand: backupVerifyCommandMock, +})); + +describe("backup commands", () => { + let tempHome: TempHomeEnv; + let previousCwd: string; + + beforeEach(async () => { + tempHome = await createTempHomeEnv("openclaw-backup-test-"); + previousCwd = process.cwd(); + backupVerifyCommandMock.mockReset(); + backupVerifyCommandMock.mockResolvedValue({ + ok: true, + archivePath: "/tmp/fake.tar.gz", + archiveRoot: "fake", + createdAt: new Date().toISOString(), + runtimeVersion: "test", + assetCount: 1, + entryCount: 2, + }); + }); + + afterEach(async () => { + process.chdir(previousCwd); + await tempHome.restore(); + }); + + it("collapses default config, credentials, and workspace into the state backup root", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.mkdir(path.join(stateDir, "credentials"), { recursive: true }); + await fs.writeFile(path.join(stateDir, "credentials", "oauth.json"), "{}", "utf8"); + await fs.mkdir(path.join(stateDir, "workspace"), { recursive: true }); + await fs.writeFile(path.join(stateDir, "workspace", "SOUL.md"), "# soul\n", "utf8"); + + const plan = await resolveBackupPlanFromDisk({ includeWorkspace: true, nowMs: 123 }); + + expect(plan.included).toHaveLength(1); + expect(plan.included[0]?.kind).toBe("state"); + expect(plan.skipped).toEqual( + expect.arrayContaining([expect.objectContaining({ kind: "workspace", reason: "covered" })]), + ); + }); + + it("orders coverage checks by canonical path so symlinked workspaces do not duplicate state", async () => { + if (process.platform === "win32") { + return; + } + + const stateDir = path.join(tempHome.home, ".openclaw"); + const workspaceDir = path.join(stateDir, "workspace"); + const symlinkDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-link-")); + const workspaceLink = path.join(symlinkDir, "ws-link"); + try { + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "# soul\n", "utf8"); + await fs.symlink(workspaceDir, workspaceLink); + await fs.writeFile( + path.join(stateDir, "openclaw.json"), + JSON.stringify({ + agents: { + defaults: { + workspace: workspaceLink, + }, + }, + }), + "utf8", + ); + + const plan = await resolveBackupPlanFromDisk({ includeWorkspace: true, nowMs: 123 }); + + expect(plan.included).toHaveLength(1); + expect(plan.included[0]?.kind).toBe("state"); + expect(plan.skipped).toEqual( + expect.arrayContaining([expect.objectContaining({ kind: "workspace", reason: "covered" })]), + ); + } finally { + await fs.rm(symlinkDir, { recursive: true, force: true }); + } + }); + + it("creates an archive with a manifest and external workspace payload", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const externalWorkspace = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-")); + const configPath = path.join(tempHome.home, "custom-config.json"); + const backupDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backups-")); + try { + process.env.OPENCLAW_CONFIG_PATH = configPath; + await fs.writeFile( + configPath, + JSON.stringify({ + agents: { + defaults: { + workspace: externalWorkspace, + }, + }, + }), + "utf8", + ); + await fs.writeFile(path.join(stateDir, "state.txt"), "state\n", "utf8"); + await fs.writeFile(path.join(externalWorkspace, "SOUL.md"), "# external\n", "utf8"); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const nowMs = Date.UTC(2026, 2, 9, 0, 0, 0); + const result = await backupCreateCommand(runtime, { + output: backupDir, + includeWorkspace: true, + nowMs, + }); + + expect(result.archivePath).toBe( + path.join(backupDir, `${buildBackupArchiveRoot(nowMs)}.tar.gz`), + ); + + const extractDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-extract-")); + try { + await tar.x({ file: result.archivePath, cwd: extractDir, gzip: true }); + const archiveRoot = path.join(extractDir, buildBackupArchiveRoot(nowMs)); + const manifest = JSON.parse( + await fs.readFile(path.join(archiveRoot, "manifest.json"), "utf8"), + ) as { + assets: Array<{ kind: string; archivePath: string }>; + }; + + expect(manifest.assets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "state" }), + expect.objectContaining({ kind: "config" }), + expect.objectContaining({ kind: "workspace" }), + ]), + ); + + const stateAsset = result.assets.find((asset) => asset.kind === "state"); + const workspaceAsset = result.assets.find((asset) => asset.kind === "workspace"); + expect(stateAsset).toBeDefined(); + expect(workspaceAsset).toBeDefined(); + + const encodedStatePath = path.join( + archiveRoot, + "payload", + encodeAbsolutePathForBackupArchive(stateAsset!.sourcePath), + "state.txt", + ); + const encodedWorkspacePath = path.join( + archiveRoot, + "payload", + encodeAbsolutePathForBackupArchive(workspaceAsset!.sourcePath), + "SOUL.md", + ); + expect(await fs.readFile(encodedStatePath, "utf8")).toBe("state\n"); + expect(await fs.readFile(encodedWorkspacePath, "utf8")).toBe("# external\n"); + } finally { + await fs.rm(extractDir, { recursive: true, force: true }); + } + } finally { + delete process.env.OPENCLAW_CONFIG_PATH; + await fs.rm(externalWorkspace, { recursive: true, force: true }); + await fs.rm(backupDir, { recursive: true, force: true }); + } + }); + + it("optionally verifies the archive after writing it", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const archiveDir = await fs.mkdtemp( + path.join(os.tmpdir(), "openclaw-backup-verify-on-create-"), + ); + try { + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.writeFile(path.join(stateDir, "state.txt"), "state\n", "utf8"); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const result = await backupCreateCommand(runtime, { + output: archiveDir, + verify: true, + }); + + expect(result.verified).toBe(true); + expect(backupVerifyCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ log: expect.any(Function) }), + expect.objectContaining({ archive: result.archivePath, json: false }), + ); + } finally { + await fs.rm(archiveDir, { recursive: true, force: true }); + } + }); + + it("rejects output paths that would be created inside a backed-up directory", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await expect( + backupCreateCommand(runtime, { + output: path.join(stateDir, "backups"), + }), + ).rejects.toThrow(/must not be written inside a source path/i); + }); + + it("rejects symlinked output paths even when intermediate directories do not exist yet", async () => { + if (process.platform === "win32") { + return; + } + + const stateDir = path.join(tempHome.home, ".openclaw"); + const symlinkDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-link-")); + const symlinkPath = path.join(symlinkDir, "linked-state"); + try { + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.symlink(stateDir, symlinkPath); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await expect( + backupCreateCommand(runtime, { + output: path.join(symlinkPath, "new", "subdir", "backup.tar.gz"), + }), + ).rejects.toThrow(/must not be written inside a source path/i); + } finally { + await fs.rm(symlinkDir, { recursive: true, force: true }); + } + }); + + it("falls back to the home directory when cwd is inside a backed-up source tree", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const workspaceDir = path.join(stateDir, "workspace"); + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "# soul\n", "utf8"); + process.chdir(workspaceDir); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const nowMs = Date.UTC(2026, 2, 9, 1, 2, 3); + const result = await backupCreateCommand(runtime, { nowMs }); + + expect(result.archivePath).toBe( + path.join(tempHome.home, `${buildBackupArchiveRoot(nowMs)}.tar.gz`), + ); + await fs.rm(result.archivePath, { force: true }); + }); + + it("falls back to the home directory when cwd is a symlink into a backed-up source tree", async () => { + if (process.platform === "win32") { + return; + } + + const stateDir = path.join(tempHome.home, ".openclaw"); + const workspaceDir = path.join(stateDir, "workspace"); + const linkParent = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-cwd-link-")); + const workspaceLink = path.join(linkParent, "workspace-link"); + try { + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "# soul\n", "utf8"); + await fs.symlink(workspaceDir, workspaceLink); + process.chdir(workspaceLink); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const nowMs = Date.UTC(2026, 2, 9, 1, 3, 4); + const result = await backupCreateCommand(runtime, { nowMs }); + + expect(result.archivePath).toBe( + path.join(tempHome.home, `${buildBackupArchiveRoot(nowMs)}.tar.gz`), + ); + await fs.rm(result.archivePath, { force: true }); + } finally { + await fs.rm(linkParent, { recursive: true, force: true }); + } + }); + + it("allows dry-run preview even when the target archive already exists", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const existingArchive = path.join(tempHome.home, "existing-backup.tar.gz"); + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.writeFile(existingArchive, "already here", "utf8"); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const result = await backupCreateCommand(runtime, { + output: existingArchive, + dryRun: true, + }); + + expect(result.dryRun).toBe(true); + expect(result.verified).toBe(false); + expect(result.archivePath).toBe(existingArchive); + expect(await fs.readFile(existingArchive, "utf8")).toBe("already here"); + }); + + it("fails fast when config is invalid and workspace backup is enabled", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const configPath = path.join(tempHome.home, "custom-config.json"); + process.env.OPENCLAW_CONFIG_PATH = configPath; + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.writeFile(configPath, '{"agents": { defaults: { workspace: ', "utf8"); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + try { + await expect(backupCreateCommand(runtime, { dryRun: true })).rejects.toThrow( + /--no-include-workspace/i, + ); + } finally { + delete process.env.OPENCLAW_CONFIG_PATH; + } + }); + + it("allows explicit partial backups when config is invalid", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const configPath = path.join(tempHome.home, "custom-config.json"); + process.env.OPENCLAW_CONFIG_PATH = configPath; + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.writeFile(configPath, '{"agents": { defaults: { workspace: ', "utf8"); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + try { + const result = await backupCreateCommand(runtime, { + dryRun: true, + includeWorkspace: false, + }); + + expect(result.includeWorkspace).toBe(false); + expect(result.assets.some((asset) => asset.kind === "workspace")).toBe(false); + } finally { + delete process.env.OPENCLAW_CONFIG_PATH; + } + }); + + it("backs up only the active config file when --only-config is requested", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const configPath = path.join(stateDir, "openclaw.json"); + await fs.mkdir(path.join(stateDir, "credentials"), { recursive: true }); + await fs.writeFile(configPath, JSON.stringify({ theme: "config-only" }), "utf8"); + await fs.writeFile(path.join(stateDir, "state.txt"), "state\n", "utf8"); + await fs.writeFile(path.join(stateDir, "credentials", "oauth.json"), "{}", "utf8"); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const result = await backupCreateCommand(runtime, { + dryRun: true, + onlyConfig: true, + }); + + expect(result.onlyConfig).toBe(true); + expect(result.includeWorkspace).toBe(false); + expect(result.assets).toHaveLength(1); + expect(result.assets[0]?.kind).toBe("config"); + }); + + it("allows config-only backups even when the config file is invalid", async () => { + const configPath = path.join(tempHome.home, "custom-config.json"); + process.env.OPENCLAW_CONFIG_PATH = configPath; + await fs.writeFile(configPath, '{"agents": { defaults: { workspace: ', "utf8"); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + try { + const result = await backupCreateCommand(runtime, { + dryRun: true, + onlyConfig: true, + }); + + expect(result.assets).toHaveLength(1); + expect(result.assets[0]?.kind).toBe("config"); + } finally { + delete process.env.OPENCLAW_CONFIG_PATH; + } + }); +}); diff --git a/src/commands/backup.ts b/src/commands/backup.ts new file mode 100644 index 00000000000..15f0f505d76 --- /dev/null +++ b/src/commands/backup.ts @@ -0,0 +1,382 @@ +import { randomUUID } from "node:crypto"; +import { constants as fsConstants } from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import * as tar from "tar"; +import type { RuntimeEnv } from "../runtime.js"; +import { resolveHomeDir, resolveUserPath } from "../utils.js"; +import { resolveRuntimeServiceVersion } from "../version.js"; +import { + buildBackupArchiveBasename, + buildBackupArchiveRoot, + buildBackupArchivePath, + type BackupAsset, + resolveBackupPlanFromDisk, +} from "./backup-shared.js"; +import { backupVerifyCommand } from "./backup-verify.js"; +import { isPathWithin } from "./cleanup-utils.js"; + +export type BackupCreateOptions = { + output?: string; + dryRun?: boolean; + includeWorkspace?: boolean; + onlyConfig?: boolean; + verify?: boolean; + json?: boolean; + nowMs?: number; +}; + +type BackupManifestAsset = { + kind: BackupAsset["kind"]; + sourcePath: string; + archivePath: string; +}; + +type BackupManifest = { + schemaVersion: 1; + createdAt: string; + archiveRoot: string; + runtimeVersion: string; + platform: NodeJS.Platform; + nodeVersion: string; + options: { + includeWorkspace: boolean; + onlyConfig?: boolean; + }; + paths: { + stateDir: string; + configPath: string; + oauthDir: string; + workspaceDirs: string[]; + }; + assets: BackupManifestAsset[]; + skipped: Array<{ + kind: string; + sourcePath: string; + reason: string; + coveredBy?: string; + }>; +}; + +export type BackupCreateResult = { + createdAt: string; + archiveRoot: string; + archivePath: string; + dryRun: boolean; + includeWorkspace: boolean; + onlyConfig: boolean; + verified: boolean; + assets: BackupAsset[]; + skipped: Array<{ + kind: string; + sourcePath: string; + displayPath: string; + reason: string; + coveredBy?: string; + }>; +}; + +async function resolveOutputPath(params: { + output?: string; + nowMs: number; + includedAssets: BackupAsset[]; + stateDir: string; +}): Promise { + const basename = buildBackupArchiveBasename(params.nowMs); + const rawOutput = params.output?.trim(); + if (!rawOutput) { + const cwd = path.resolve(process.cwd()); + const canonicalCwd = await fs.realpath(cwd).catch(() => cwd); + const cwdInsideSource = params.includedAssets.some((asset) => + isPathWithin(canonicalCwd, asset.sourcePath), + ); + const defaultDir = cwdInsideSource ? (resolveHomeDir() ?? path.dirname(params.stateDir)) : cwd; + return path.resolve(defaultDir, basename); + } + + const resolved = resolveUserPath(rawOutput); + if (rawOutput.endsWith("/") || rawOutput.endsWith("\\")) { + return path.join(resolved, basename); + } + + try { + const stat = await fs.stat(resolved); + if (stat.isDirectory()) { + return path.join(resolved, basename); + } + } catch { + // Treat as a file path when the target does not exist yet. + } + + return resolved; +} + +async function assertOutputPathReady(outputPath: string): Promise { + try { + await fs.access(outputPath); + throw new Error(`Refusing to overwrite existing backup archive: ${outputPath}`); + } catch (err) { + const code = (err as NodeJS.ErrnoException | undefined)?.code; + if (code === "ENOENT") { + return; + } + throw err; + } +} + +function buildTempArchivePath(outputPath: string): string { + return `${outputPath}.${randomUUID()}.tmp`; +} + +function isLinkUnsupportedError(code: string | undefined): boolean { + return code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EPERM"; +} + +async function publishTempArchive(params: { + tempArchivePath: string; + outputPath: string; +}): Promise { + try { + await fs.link(params.tempArchivePath, params.outputPath); + } catch (err) { + const code = (err as NodeJS.ErrnoException | undefined)?.code; + if (code === "EEXIST") { + throw new Error(`Refusing to overwrite existing backup archive: ${params.outputPath}`, { + cause: err, + }); + } + if (!isLinkUnsupportedError(code)) { + throw err; + } + + try { + // Some backup targets support ordinary files but not hard links. + await fs.copyFile(params.tempArchivePath, params.outputPath, fsConstants.COPYFILE_EXCL); + } catch (copyErr) { + const copyCode = (copyErr as NodeJS.ErrnoException | undefined)?.code; + if (copyCode !== "EEXIST") { + await fs.rm(params.outputPath, { force: true }).catch(() => undefined); + } + if (copyCode === "EEXIST") { + throw new Error(`Refusing to overwrite existing backup archive: ${params.outputPath}`, { + cause: copyErr, + }); + } + throw copyErr; + } + } + await fs.rm(params.tempArchivePath, { force: true }); +} + +async function canonicalizePathForContainment(targetPath: string): Promise { + const resolved = path.resolve(targetPath); + const suffix: string[] = []; + let probe = resolved; + + while (true) { + try { + const realProbe = await fs.realpath(probe); + return suffix.length === 0 ? realProbe : path.join(realProbe, ...suffix.toReversed()); + } catch { + const parent = path.dirname(probe); + if (parent === probe) { + return resolved; + } + suffix.push(path.basename(probe)); + probe = parent; + } + } +} + +function buildManifest(params: { + createdAt: string; + archiveRoot: string; + includeWorkspace: boolean; + onlyConfig: boolean; + assets: BackupAsset[]; + skipped: BackupCreateResult["skipped"]; + stateDir: string; + configPath: string; + oauthDir: string; + workspaceDirs: string[]; +}): BackupManifest { + return { + schemaVersion: 1, + createdAt: params.createdAt, + archiveRoot: params.archiveRoot, + runtimeVersion: resolveRuntimeServiceVersion(), + platform: process.platform, + nodeVersion: process.version, + options: { + includeWorkspace: params.includeWorkspace, + onlyConfig: params.onlyConfig, + }, + paths: { + stateDir: params.stateDir, + configPath: params.configPath, + oauthDir: params.oauthDir, + workspaceDirs: params.workspaceDirs, + }, + assets: params.assets.map((asset) => ({ + kind: asset.kind, + sourcePath: asset.sourcePath, + archivePath: asset.archivePath, + })), + skipped: params.skipped.map((entry) => ({ + kind: entry.kind, + sourcePath: entry.sourcePath, + reason: entry.reason, + coveredBy: entry.coveredBy, + })), + }; +} + +function formatTextSummary(result: BackupCreateResult): string[] { + const lines = [`Backup archive: ${result.archivePath}`]; + lines.push(`Included ${result.assets.length} path${result.assets.length === 1 ? "" : "s"}:`); + for (const asset of result.assets) { + lines.push(`- ${asset.kind}: ${asset.displayPath}`); + } + if (result.skipped.length > 0) { + lines.push(`Skipped ${result.skipped.length} path${result.skipped.length === 1 ? "" : "s"}:`); + for (const entry of result.skipped) { + if (entry.reason === "covered" && entry.coveredBy) { + lines.push(`- ${entry.kind}: ${entry.displayPath} (${entry.reason} by ${entry.coveredBy})`); + } else { + lines.push(`- ${entry.kind}: ${entry.displayPath} (${entry.reason})`); + } + } + } + if (result.dryRun) { + lines.push("Dry run only; archive was not written."); + } else { + lines.push(`Created ${result.archivePath}`); + if (result.verified) { + lines.push("Archive verification: passed"); + } + } + return lines; +} + +function remapArchiveEntryPath(params: { + entryPath: string; + manifestPath: string; + archiveRoot: string; +}): string { + const normalizedEntry = path.resolve(params.entryPath); + if (normalizedEntry === params.manifestPath) { + return path.posix.join(params.archiveRoot, "manifest.json"); + } + return buildBackupArchivePath(params.archiveRoot, normalizedEntry); +} + +export async function backupCreateCommand( + runtime: RuntimeEnv, + opts: BackupCreateOptions = {}, +): Promise { + const nowMs = opts.nowMs ?? Date.now(); + const archiveRoot = buildBackupArchiveRoot(nowMs); + const onlyConfig = Boolean(opts.onlyConfig); + const includeWorkspace = onlyConfig ? false : (opts.includeWorkspace ?? true); + const plan = await resolveBackupPlanFromDisk({ includeWorkspace, onlyConfig, nowMs }); + const outputPath = await resolveOutputPath({ + output: opts.output, + nowMs, + includedAssets: plan.included, + stateDir: plan.stateDir, + }); + + if (plan.included.length === 0) { + throw new Error( + onlyConfig + ? "No OpenClaw config file was found to back up." + : "No local OpenClaw state was found to back up.", + ); + } + + const canonicalOutputPath = await canonicalizePathForContainment(outputPath); + const overlappingAsset = plan.included.find((asset) => + isPathWithin(canonicalOutputPath, asset.sourcePath), + ); + if (overlappingAsset) { + throw new Error( + `Backup output must not be written inside a source path: ${outputPath} is inside ${overlappingAsset.sourcePath}`, + ); + } + + if (!opts.dryRun) { + await assertOutputPathReady(outputPath); + } + + const createdAt = new Date(nowMs).toISOString(); + const result: BackupCreateResult = { + createdAt, + archiveRoot, + archivePath: outputPath, + dryRun: Boolean(opts.dryRun), + includeWorkspace, + onlyConfig, + verified: false, + assets: plan.included, + skipped: plan.skipped, + }; + + if (!opts.dryRun) { + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-")); + const manifestPath = path.join(tempDir, "manifest.json"); + const tempArchivePath = buildTempArchivePath(outputPath); + try { + const manifest = buildManifest({ + createdAt, + archiveRoot, + includeWorkspace, + onlyConfig, + assets: result.assets, + skipped: result.skipped, + stateDir: plan.stateDir, + configPath: plan.configPath, + oauthDir: plan.oauthDir, + workspaceDirs: plan.workspaceDirs, + }); + await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + + await tar.c( + { + file: tempArchivePath, + gzip: true, + portable: true, + preservePaths: true, + onWriteEntry: (entry) => { + entry.path = remapArchiveEntryPath({ + entryPath: entry.path, + manifestPath, + archiveRoot, + }); + }, + }, + [manifestPath, ...result.assets.map((asset) => asset.sourcePath)], + ); + await publishTempArchive({ tempArchivePath, outputPath }); + } finally { + await fs.rm(tempArchivePath, { force: true }).catch(() => undefined); + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); + } + + if (opts.verify) { + await backupVerifyCommand( + { + ...runtime, + log: () => {}, + }, + { archive: outputPath, json: false }, + ); + result.verified = true; + } + } + + const output = opts.json ? JSON.stringify(result, null, 2) : formatTextSummary(result).join("\n"); + runtime.log(output); + return result; +} diff --git a/src/commands/models.list.e2e.test.ts b/src/commands/models.list.e2e.test.ts index 171893134a1..e7d55e00b3c 100644 --- a/src/commands/models.list.e2e.test.ts +++ b/src/commands/models.list.e2e.test.ts @@ -354,8 +354,8 @@ describe("models list/status", () => { await modelsListCommand({ all: true, json: true }, runtime); - expect(ensureOpenClawModelsJson).toHaveBeenCalledTimes(1); - expect(ensureOpenClawModelsJson).toHaveBeenCalledWith(resolvedConfig); + expect(ensureOpenClawModelsJson).toHaveBeenCalled(); + expect(ensureOpenClawModelsJson.mock.calls[0]?.[0]).toEqual(resolvedConfig); }); it("toModelRow does not crash without cfg/authStore when availability is undefined", async () => { diff --git a/src/commands/models/list.list-command.forward-compat.test.ts b/src/commands/models/list.list-command.forward-compat.test.ts index 4cef137d88a..d33ceb2aab1 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -38,6 +38,7 @@ const mocks = vi.hoisted(() => { loadModelRegistry: vi .fn() .mockResolvedValue({ models: [], availableKeys: new Set(), registry: {} }), + loadModelCatalog: vi.fn().mockResolvedValue([]), resolveConfiguredEntries: vi.fn().mockReturnValue({ entries: [ { @@ -66,6 +67,8 @@ const mocks = vi.hoisted(() => { vi.mock("../../config/config.js", () => ({ loadConfig: mocks.loadConfig, + getRuntimeConfigSnapshot: vi.fn().mockReturnValue(null), + getRuntimeConfigSourceSnapshot: vi.fn().mockReturnValue(null), })); vi.mock("../../agents/auth-profiles.js", async (importOriginal) => { @@ -77,6 +80,10 @@ vi.mock("../../agents/auth-profiles.js", async (importOriginal) => { }; }); +vi.mock("../../agents/model-catalog.js", () => ({ + loadModelCatalog: mocks.loadModelCatalog, +})); + vi.mock("./list.registry.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -177,25 +184,163 @@ describe("modelsListCommand forward-compat", () => { availableKeys: new Set(), registry: {}, }); - mocks.listProfilesForProvider.mockImplementationOnce((_: unknown, provider: string) => + mocks.listProfilesForProvider.mockImplementation((_: unknown, provider: string) => provider === "openai-codex" ? ([{ id: "profile-1" }] as Array>) : [], ); const runtime = { log: vi.fn(), error: vi.fn() }; - await modelsListCommand({ json: true }, runtime as never); + try { + await modelsListCommand({ json: true }, runtime as never); + + expect(mocks.printModelTable).toHaveBeenCalled(); + const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{ + key: string; + available: boolean; + }>; + + expect(rows).toContainEqual( + expect.objectContaining({ + key: "openai-codex/gpt-5.4", + available: true, + }), + ); + } finally { + mocks.listProfilesForProvider.mockReturnValue([]); + } + }); + + it("includes synthetic codex gpt-5.4 in --all output when catalog supports it", async () => { + mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); + mocks.loadModelRegistry.mockResolvedValueOnce({ + models: [ + { + provider: "openai-codex", + id: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + input: ["text"], + contextWindow: 272000, + maxTokens: 128000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ], + availableKeys: new Set(["openai-codex/gpt-5.3-codex"]), + registry: {}, + }); + mocks.loadModelCatalog.mockResolvedValueOnce([ + { + provider: "openai-codex", + id: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + input: ["text"], + contextWindow: 272000, + }, + { + provider: "openai-codex", + id: "gpt-5.4", + name: "GPT-5.4", + input: ["text"], + contextWindow: 272000, + }, + ]); + mocks.listProfilesForProvider.mockImplementation((_: unknown, provider: string) => + provider === "openai-codex" ? ([{ id: "profile-1" }] as Array>) : [], + ); + mocks.resolveModelWithRegistry.mockImplementation( + ({ provider, modelId }: { provider: string; modelId: string }) => { + if (provider !== "openai-codex") { + return undefined; + } + if (modelId === "gpt-5.3-codex") { + return { + provider: "openai-codex", + id: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + input: ["text"], + contextWindow: 272000, + maxTokens: 128000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }; + } + if (modelId === "gpt-5.4") { + return { + provider: "openai-codex", + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + input: ["text"], + contextWindow: 272000, + maxTokens: 128000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }; + } + return undefined; + }, + ); + const runtime = { log: vi.fn(), error: vi.fn() }; + + try { + await modelsListCommand( + { all: true, provider: "openai-codex", json: true }, + runtime as never, + ); + + expect(mocks.printModelTable).toHaveBeenCalled(); + const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{ + key: string; + available: boolean; + }>; + + expect(rows).toEqual([ + expect.objectContaining({ + key: "openai-codex/gpt-5.3-codex", + }), + expect.objectContaining({ + key: "openai-codex/gpt-5.4", + available: true, + }), + ]); + } finally { + mocks.listProfilesForProvider.mockReturnValue([]); + } + }); + + it("keeps discovered rows in --all output when catalog lookup is empty", async () => { + mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); + mocks.loadModelRegistry.mockResolvedValueOnce({ + models: [ + { + provider: "openai-codex", + id: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + input: ["text"], + contextWindow: 272000, + maxTokens: 128000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ], + availableKeys: new Set(["openai-codex/gpt-5.3-codex"]), + registry: {}, + }); + mocks.loadModelCatalog.mockResolvedValueOnce([]); + const runtime = { log: vi.fn(), error: vi.fn() }; + + await modelsListCommand({ all: true, provider: "openai-codex", json: true }, runtime as never); expect(mocks.printModelTable).toHaveBeenCalled(); - const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{ - key: string; - available: boolean; - }>; + const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{ key: string }>; - expect(rows).toContainEqual( + expect(rows).toEqual([ expect.objectContaining({ - key: "openai-codex/gpt-5.4", - available: true, + key: "openai-codex/gpt-5.3-codex", }), - ); + ]); }); it("exits with an error when configured-mode listing has no model registry", async () => { diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index acb6c95761f..c19d18d9d11 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -1,5 +1,6 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; +import { loadModelCatalog } from "../../agents/model-catalog.js"; import { parseModelRef } from "../../agents/model-selection.js"; import { resolveModelWithRegistry } from "../../agents/pi-embedded-runner/model.js"; import type { RuntimeEnv } from "../../runtime.js"; @@ -69,6 +70,7 @@ export async function modelsListCommand( const rows: ModelRow[] = []; if (opts.all) { + const seenKeys = new Set(); const sorted = [...models].toSorted((a, b) => { const p = a.provider.localeCompare(b.provider); if (p !== 0) { @@ -97,6 +99,46 @@ export async function modelsListCommand( authStore, }), ); + seenKeys.add(key); + } + + if (modelRegistry) { + const catalog = await loadModelCatalog({ config: cfg }); + for (const entry of catalog) { + if (providerFilter && entry.provider.toLowerCase() !== providerFilter) { + continue; + } + const key = modelKey(entry.provider, entry.id); + if (seenKeys.has(key)) { + continue; + } + const model = resolveModelWithRegistry({ + provider: entry.provider, + modelId: entry.id, + modelRegistry, + cfg, + }); + if (!model) { + continue; + } + if (opts.local && !isLocalBaseUrl(model.baseUrl)) { + continue; + } + const configured = configuredByKey.get(key); + rows.push( + toModelRow({ + model, + key, + tags: configured ? Array.from(configured.tags) : [], + aliases: configured?.aliases ?? [], + availableKeys, + cfg, + authStore, + allowProviderAvailabilityFallback: !discoveredKeys.has(key), + }), + ); + seenKeys.add(key); + } } } else { const registry = modelRegistry; diff --git a/src/commands/reset.test.ts b/src/commands/reset.test.ts new file mode 100644 index 00000000000..b97545a4371 --- /dev/null +++ b/src/commands/reset.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createNonExitingRuntime } from "../runtime.js"; + +const resolveCleanupPlanFromDisk = vi.fn(); +const removePath = vi.fn(); +const listAgentSessionDirs = vi.fn(); +const removeStateAndLinkedPaths = vi.fn(); +const removeWorkspaceDirs = vi.fn(); + +vi.mock("../config/config.js", () => ({ + isNixMode: false, +})); + +vi.mock("./cleanup-plan.js", () => ({ + resolveCleanupPlanFromDisk, +})); + +vi.mock("./cleanup-utils.js", () => ({ + removePath, + listAgentSessionDirs, + removeStateAndLinkedPaths, + removeWorkspaceDirs, +})); + +const { resetCommand } = await import("./reset.js"); + +describe("resetCommand", () => { + const runtime = createNonExitingRuntime(); + + beforeEach(() => { + vi.clearAllMocks(); + resolveCleanupPlanFromDisk.mockReturnValue({ + stateDir: "/tmp/.openclaw", + configPath: "/tmp/.openclaw/openclaw.json", + oauthDir: "/tmp/.openclaw/credentials", + configInsideState: true, + oauthInsideState: true, + workspaceDirs: ["/tmp/.openclaw/workspace"], + }); + removePath.mockResolvedValue({ ok: true }); + listAgentSessionDirs.mockResolvedValue(["/tmp/.openclaw/agents/main/sessions"]); + removeStateAndLinkedPaths.mockResolvedValue(undefined); + removeWorkspaceDirs.mockResolvedValue(undefined); + vi.spyOn(runtime, "log").mockImplementation(() => {}); + vi.spyOn(runtime, "error").mockImplementation(() => {}); + }); + + it("recommends creating a backup before state-destructive reset scopes", async () => { + await resetCommand(runtime, { + scope: "config+creds+sessions", + yes: true, + nonInteractive: true, + dryRun: true, + }); + + expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("openclaw backup create")); + }); + + it("does not recommend backup for config-only reset", async () => { + await resetCommand(runtime, { + scope: "config", + yes: true, + nonInteractive: true, + dryRun: true, + }); + + expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining("openclaw backup create")); + }); +}); diff --git a/src/commands/reset.ts b/src/commands/reset.ts index 1f9ba9a7997..596d80a139a 100644 --- a/src/commands/reset.ts +++ b/src/commands/reset.ts @@ -44,6 +44,10 @@ async function stopGatewayIfRunning(runtime: RuntimeEnv) { } } +function logBackupRecommendation(runtime: RuntimeEnv) { + runtime.log(`Recommended first: ${formatCliCommand("openclaw backup create")}`); +} + export async function resetCommand(runtime: RuntimeEnv, opts: ResetOptions) { const interactive = !opts.nonInteractive; if (!interactive && !opts.yes) { @@ -110,6 +114,7 @@ export async function resetCommand(runtime: RuntimeEnv, opts: ResetOptions) { resolveCleanupPlanFromDisk(); if (scope !== "config") { + logBackupRecommendation(runtime); if (dryRun) { runtime.log("[dry-run] stop gateway service"); } else { diff --git a/src/commands/uninstall.test.ts b/src/commands/uninstall.test.ts new file mode 100644 index 00000000000..bdf0efe1354 --- /dev/null +++ b/src/commands/uninstall.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createNonExitingRuntime } from "../runtime.js"; + +const resolveCleanupPlanFromDisk = vi.fn(); +const removePath = vi.fn(); +const removeStateAndLinkedPaths = vi.fn(); +const removeWorkspaceDirs = vi.fn(); + +vi.mock("../config/config.js", () => ({ + isNixMode: false, +})); + +vi.mock("./cleanup-plan.js", () => ({ + resolveCleanupPlanFromDisk, +})); + +vi.mock("./cleanup-utils.js", () => ({ + removePath, + removeStateAndLinkedPaths, + removeWorkspaceDirs, +})); + +const { uninstallCommand } = await import("./uninstall.js"); + +describe("uninstallCommand", () => { + const runtime = createNonExitingRuntime(); + + beforeEach(() => { + vi.clearAllMocks(); + resolveCleanupPlanFromDisk.mockReturnValue({ + stateDir: "/tmp/.openclaw", + configPath: "/tmp/.openclaw/openclaw.json", + oauthDir: "/tmp/.openclaw/credentials", + configInsideState: true, + oauthInsideState: true, + workspaceDirs: ["/tmp/.openclaw/workspace"], + }); + removePath.mockResolvedValue({ ok: true }); + removeStateAndLinkedPaths.mockResolvedValue(undefined); + removeWorkspaceDirs.mockResolvedValue(undefined); + vi.spyOn(runtime, "log").mockImplementation(() => {}); + vi.spyOn(runtime, "error").mockImplementation(() => {}); + }); + + it("recommends creating a backup before removing state or workspaces", async () => { + await uninstallCommand(runtime, { + state: true, + yes: true, + nonInteractive: true, + dryRun: true, + }); + + expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("openclaw backup create")); + }); + + it("does not recommend backup for service-only uninstall", async () => { + await uninstallCommand(runtime, { + service: true, + yes: true, + nonInteractive: true, + dryRun: true, + }); + + expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining("openclaw backup create")); + }); +}); diff --git a/src/commands/uninstall.ts b/src/commands/uninstall.ts index aa91a321d00..5f03eb1cefa 100644 --- a/src/commands/uninstall.ts +++ b/src/commands/uninstall.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { cancel, confirm, isCancel, multiselect } from "@clack/prompts"; +import { formatCliCommand } from "../cli/command-format.js"; import { isNixMode } from "../config/config.js"; import { resolveGatewayService } from "../daemon/service.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -92,6 +93,10 @@ async function removeMacApp(runtime: RuntimeEnv, dryRun?: boolean) { }); } +function logBackupRecommendation(runtime: RuntimeEnv) { + runtime.log(`Recommended first: ${formatCliCommand("openclaw backup create")}`); +} + export async function uninstallCommand(runtime: RuntimeEnv, opts: UninstallOptions) { const { scopes, hadExplicit } = buildScopeSelection(opts); const interactive = !opts.nonInteractive; @@ -155,6 +160,10 @@ export async function uninstallCommand(runtime: RuntimeEnv, opts: UninstallOptio const { stateDir, configPath, oauthDir, configInsideState, oauthInsideState, workspaceDirs } = resolveCleanupPlanFromDisk(); + if (scopes.has("state") || scopes.has("workspace")) { + logBackupRecommendation(runtime); + } + if (scopes.has("service")) { if (dryRun) { runtime.log("[dry-run] remove gateway service"); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 50d9502ffea..ec02d1d106f 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -250,6 +250,8 @@ export const FIELD_HELP: Record = { "Starting local CDP port used for auto-allocated browser profile ports. Increase this when host-level port defaults conflict with other local services.", "browser.defaultProfile": "Default browser profile name selected when callers do not explicitly choose a profile. Use a stable low-privilege profile as the default to reduce accidental cross-context state use.", + "browser.relayBindHost": + "Bind IP address for the Chrome extension relay listener. Leave unset for loopback-only access, or set an explicit non-loopback IP such as 0.0.0.0 only when the relay must be reachable across network namespaces (for example WSL2) and the surrounding network is already trusted.", "browser.profiles": "Named browser profile connection map used for explicit routing to CDP ports or URLs with optional metadata. Keep profile names consistent and avoid overlapping endpoint definitions.", "browser.profiles.*.cdpPort": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index f8961d9e8dd..ec9e8eb0c52 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -118,6 +118,7 @@ export const FIELD_LABELS: Record = { "browser.attachOnly": "Browser Attach-only Mode", "browser.cdpPortRangeStart": "Browser CDP Port Range Start", "browser.defaultProfile": "Browser Default Profile", + "browser.relayBindHost": "Browser Relay Bind Address", "browser.profiles": "Browser Profiles", "browser.profiles.*.cdpPort": "Browser Profile CDP Port", "browser.profiles.*.cdpUrl": "Browser Profile CDP URL", diff --git a/src/config/types.browser.ts b/src/config/types.browser.ts index 192fd700bff..57d036bd88c 100644 --- a/src/config/types.browser.ts +++ b/src/config/types.browser.ts @@ -66,4 +66,10 @@ export type BrowserConfig = { * Example: ["--window-size=1920,1080", "--disable-infobars"] */ extraArgs?: string[]; + /** + * Bind address for the Chrome extension relay server. + * Default: "127.0.0.1". Set to "0.0.0.0" for WSL2 or other environments where + * the relay must be reachable from a different network namespace. + */ + relayBindHost?: string; }; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 62b7f2f1513..c35d1191b6f 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -372,6 +372,7 @@ export const OpenClawSchema = z ) .optional(), extraArgs: z.array(z.string()).optional(), + relayBindHost: z.union([z.string().ipv4(), z.string().ipv6()]).optional(), }) .strict() .optional(), diff --git a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts index fdb77fc22ba..abaf1ae5349 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -208,6 +208,29 @@ describe("dispatchCronDelivery — double-announce guard", () => { expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); }); + it("consolidates descendant output into the cron announce path", async () => { + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(true); + vi.mocked(readDescendantSubagentFallbackReply).mockResolvedValue( + "Detailed child result, everything finished successfully.", + ); + vi.mocked(runSubagentAnnounceFlow).mockResolvedValue(true); + + const params = makeBaseParams({ synthesizedText: "on it" }); + const state = await dispatchCronDelivery(params); + + expect(state.deliveryAttempted).toBe(true); + expect(state.delivered).toBe(true); + expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); + expect(runSubagentAnnounceFlow).toHaveBeenCalledWith( + expect.objectContaining({ + roundOneReply: "Detailed child result, everything finished successfully.", + expectsCompletionMessage: true, + announceType: "cron job", + }), + ); + }); + it("normal announce success delivers exactly once and sets deliveryAttempted=true", async () => { vi.mocked(countActiveDescendantRuns).mockReturnValue(0); vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); diff --git a/src/cron/service.issue-regressions.test.ts b/src/cron/service.issue-regressions.test.ts index b2276aeb398..dac28f4b0c9 100644 --- a/src/cron/service.issue-regressions.test.ts +++ b/src/cron/service.issue-regressions.test.ts @@ -1,6 +1,8 @@ import fs from "node:fs/promises"; import { describe, expect, it, vi } from "vitest"; import type { HeartbeatRunResult } from "../infra/heartbeat-wake.js"; +import { clearCommandLane, setCommandLaneConcurrency } from "../process/command-queue.js"; +import { CommandLane } from "../process/lanes.js"; import * as schedule from "./schedule.js"; import { createAbortAwareIsolatedRunner, @@ -15,9 +17,13 @@ import { writeCronStoreSnapshot, } from "./service.issue-regressions.test-helpers.js"; import { CronService } from "./service.js"; -import { createDeferred, createRunningCronServiceState } from "./service.test-harness.js"; +import { + createDeferred, + createNoopLogger, + createRunningCronServiceState, +} from "./service.test-harness.js"; import { computeJobNextRunAtMs } from "./service/jobs.js"; -import { run } from "./service/ops.js"; +import { enqueueRun, run } from "./service/ops.js"; import { createCronServiceState, type CronEvent } from "./service/state.js"; import { DEFAULT_JOB_TIMEOUT_MS, @@ -1486,6 +1492,110 @@ describe("Cron issue regressions", () => { expect(jobs.find((job) => job.id === second.id)?.state.lastStatus).toBe("ok"); }); + it("queues manual cron.run requests behind the cron execution lane", async () => { + vi.useRealTimers(); + clearCommandLane(CommandLane.Cron); + setCommandLaneConcurrency(CommandLane.Cron, 1); + + const store = makeStorePath(); + const dueAt = Date.parse("2026-02-06T10:05:02.000Z"); + const first = createDueIsolatedJob({ id: "queued-first", nowMs: dueAt, nextRunAtMs: dueAt }); + const second = createDueIsolatedJob({ + id: "queued-second", + nowMs: dueAt, + nextRunAtMs: dueAt, + }); + await fs.writeFile( + store.storePath, + JSON.stringify({ version: 1, jobs: [first, second] }), + "utf-8", + ); + + let now = dueAt; + let activeRuns = 0; + let peakActiveRuns = 0; + const firstRun = createDeferred<{ status: "ok"; summary: string }>(); + const secondRun = createDeferred<{ status: "ok"; summary: string }>(); + const secondStarted = createDeferred(); + const runIsolatedAgentJob = vi.fn(async (params: { job: { id: string } }) => { + activeRuns += 1; + peakActiveRuns = Math.max(peakActiveRuns, activeRuns); + if (params.job.id === second.id) { + secondStarted.resolve(); + } + try { + const result = + params.job.id === first.id ? await firstRun.promise : await secondRun.promise; + now += 10; + return result; + } finally { + activeRuns -= 1; + } + }); + const state = createCronServiceState({ + cronEnabled: true, + storePath: store.storePath, + cronConfig: { maxConcurrentRuns: 1 }, + log: createNoopLogger(), + nowMs: () => now, + enqueueSystemEvent: vi.fn(), + requestHeartbeatNow: vi.fn(), + runIsolatedAgentJob, + }); + + const firstAck = await enqueueRun(state, first.id, "force"); + const secondAck = await enqueueRun(state, second.id, "force"); + expect(firstAck).toEqual({ ok: true, enqueued: true, runId: expect.any(String) }); + expect(secondAck).toEqual({ ok: true, enqueued: true, runId: expect.any(String) }); + + await vi.waitFor(() => expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1)); + expect(runIsolatedAgentJob.mock.calls[0]?.[0]).toMatchObject({ job: { id: first.id } }); + expect(peakActiveRuns).toBe(1); + + firstRun.resolve({ status: "ok", summary: "first queued run" }); + await secondStarted.promise; + expect(runIsolatedAgentJob).toHaveBeenCalledTimes(2); + expect(runIsolatedAgentJob.mock.calls[1]?.[0]).toMatchObject({ job: { id: second.id } }); + expect(peakActiveRuns).toBe(1); + + secondRun.resolve({ status: "ok", summary: "second queued run" }); + await vi.waitFor(() => { + const jobs = state.store?.jobs ?? []; + expect(jobs.find((job) => job.id === first.id)?.state.lastStatus).toBe("ok"); + expect(jobs.find((job) => job.id === second.id)?.state.lastStatus).toBe("ok"); + }); + + clearCommandLane(CommandLane.Cron); + }); + + it("logs unexpected queued manual run background failures once", async () => { + vi.useRealTimers(); + clearCommandLane(CommandLane.Cron); + setCommandLaneConcurrency(CommandLane.Cron, 1); + + const dueAt = Date.parse("2026-02-06T10:05:03.000Z"); + const job = createDueIsolatedJob({ id: "queued-failure", nowMs: dueAt, nextRunAtMs: dueAt }); + const log = createNoopLogger(); + const badStore = `${makeStorePath().storePath}.dir`; + await fs.mkdir(badStore, { recursive: true }); + const state = createRunningCronServiceState({ + storePath: badStore, + log, + nowMs: () => dueAt, + jobs: [job], + }); + + const result = await enqueueRun(state, job.id, "force"); + expect(result).toEqual({ ok: true, enqueued: true, runId: expect.any(String) }); + + await vi.waitFor(() => expect(log.error).toHaveBeenCalledTimes(1)); + expect(log.error.mock.calls[0]?.[1]).toBe( + "cron: queued manual run background execution failed", + ); + + clearCommandLane(CommandLane.Cron); + }); + // Regression: isolated cron runs must not abort at 1/3 of configured timeoutSeconds. // The bug (issue #29774) caused the CLI-provider resume watchdog (ratio 0.3, maxMs 180 s) // to be applied on fresh sessions because a persisted cliSessionId was passed to diff --git a/src/cron/service.ts b/src/cron/service.ts index 7ccc1cc59e0..a221cb68b15 100644 --- a/src/cron/service.ts +++ b/src/cron/service.ts @@ -46,6 +46,10 @@ export class CronService { return await ops.run(this.state, id, mode); } + async enqueueRun(id: string, mode?: "due" | "force") { + return await ops.enqueueRun(this.state, id, mode); + } + getJob(id: string): CronJob | undefined { return this.state.store?.jobs.find((job) => job.id === id); } diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index 9f575134c23..c027c8d553f 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -1,3 +1,5 @@ +import { enqueueCommandInLane } from "../../process/command-queue.js"; +import { CommandLane } from "../../process/lanes.js"; import type { CronJob, CronJobCreate, CronJobPatch } from "../types.js"; import { normalizeCronCreateDeliveryInput } from "./initial-delivery.js"; import { @@ -339,8 +341,58 @@ export async function remove(state: CronServiceState, id: string) { }); } -export async function run(state: CronServiceState, id: string, mode?: "due" | "force") { - const prepared = await locked(state, async () => { +type PreparedManualRun = + | { + ok: true; + ran: false; + reason: "already-running" | "not-due"; + } + | { + ok: true; + ran: true; + jobId: string; + startedAt: number; + executionJob: CronJob; + } + | { ok: false }; + +type ManualRunDisposition = + | Extract + | { ok: true; runnable: true }; + +let nextManualRunId = 1; + +async function inspectManualRunDisposition( + state: CronServiceState, + id: string, + mode?: "due" | "force", +): Promise { + return await locked(state, async () => { + warnIfDisabled(state, "run"); + await ensureLoaded(state, { skipRecompute: true }); + // Normalize job tick state (clears stale runningAtMs markers) before + // checking if already running, so a stale marker from a crashed Phase-1 + // persist does not block manual triggers for up to STUCK_RUN_MS (#17554). + recomputeNextRunsForMaintenance(state); + const job = findJobOrThrow(state, id); + if (typeof job.state.runningAtMs === "number") { + return { ok: true, ran: false, reason: "already-running" as const }; + } + const now = state.deps.nowMs(); + const due = isJobDue(job, now, { forced: mode === "force" }); + if (!due) { + return { ok: true, ran: false, reason: "not-due" as const }; + } + return { ok: true, runnable: true } as const; + }); +} + +async function prepareManualRun( + state: CronServiceState, + id: string, + mode?: "due" | "force", +): Promise { + return await locked(state, async () => { warnIfDisabled(state, "run"); await ensureLoaded(state, { skipRecompute: true }); // Normalize job tick state (clears stale runningAtMs markers) before @@ -365,7 +417,7 @@ export async function run(state: CronServiceState, id: string, mode?: "due" | "f // force-reload from disk cannot start the same job concurrently. await persist(state); emit(state, { jobId: job.id, action: "started", runAtMs: now }); - const executionJob = JSON.parse(JSON.stringify(job)) as typeof job; + const executionJob = JSON.parse(JSON.stringify(job)) as CronJob; return { ok: true, ran: true, @@ -374,13 +426,13 @@ export async function run(state: CronServiceState, id: string, mode?: "due" | "f executionJob, } as const; }); +} - if (!prepared.ran) { - return prepared; - } - if (!prepared.executionJob || typeof prepared.startedAt !== "number") { - return { ok: false } as const; - } +async function finishPreparedManualRun( + state: CronServiceState, + prepared: Extract, + mode?: "due" | "force", +): Promise { const executionJob = prepared.executionJob; const startedAt = prepared.startedAt; const jobId = prepared.jobId; @@ -461,10 +513,54 @@ export async function run(state: CronServiceState, id: string, mode?: "due" | "f await persist(state); armTimer(state); }); +} +export async function run(state: CronServiceState, id: string, mode?: "due" | "force") { + const prepared = await prepareManualRun(state, id, mode); + if (!prepared.ok || !prepared.ran) { + return prepared; + } + await finishPreparedManualRun(state, prepared, mode); return { ok: true, ran: true } as const; } +export async function enqueueRun(state: CronServiceState, id: string, mode?: "due" | "force") { + const disposition = await inspectManualRunDisposition(state, id, mode); + if (!disposition.ok || !("runnable" in disposition && disposition.runnable)) { + return disposition; + } + + const runId = `manual:${id}:${state.deps.nowMs()}:${nextManualRunId++}`; + void enqueueCommandInLane( + CommandLane.Cron, + async () => { + const result = await run(state, id, mode); + if (result.ok && "ran" in result && !result.ran) { + state.deps.log.info( + { jobId: id, runId, reason: result.reason }, + "cron: queued manual run skipped before execution", + ); + } + return result; + }, + { + warnAfterMs: 5_000, + onWait: (waitMs, queuedAhead) => { + state.deps.log.warn( + { jobId: id, runId, waitMs, queuedAhead }, + "cron: queued manual run waiting for an execution slot", + ); + }, + }, + ).catch((err) => { + state.deps.log.error( + { jobId: id, runId, err: String(err) }, + "cron: queued manual run background execution failed", + ); + }); + return { ok: true, enqueued: true, runId } as const; +} + export function wakeNow( state: CronServiceState, opts: { mode: "now" | "next-heartbeat"; text: string }, diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts index b65d0ebaa14..1e42ae089cd 100644 --- a/src/cron/service/state.ts +++ b/src/cron/service/state.ts @@ -142,6 +142,7 @@ export type CronStatusSummary = { export type CronRunResult = | { ok: true; ran: true } + | { ok: true; enqueued: true; runId: string } | { ok: true; ran: false; reason: "not-due" } | { ok: true; ran: false; reason: "already-running" } | { ok: false }; diff --git a/src/docker-setup.e2e.test.ts b/src/docker-setup.e2e.test.ts index 6fd42e256ed..6890e7d55a8 100644 --- a/src/docker-setup.e2e.test.ts +++ b/src/docker-setup.e2e.test.ts @@ -163,7 +163,7 @@ describe("docker-setup.sh", () => { sandbox = null; }); - it("handles env defaults, home-volume mounts, and apt build args", async () => { + it("handles env defaults, home-volume mounts, and Docker build args", async () => { const activeSandbox = requireSandbox(sandbox); const result = runDockerSetup(activeSandbox, { diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index 4dd41398e5a..a23b7e8e083 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -37,6 +37,15 @@ describe("Dockerfile", () => { expect(dockerfile).toContain("apt-get install -y --no-install-recommends xvfb"); }); + it("prunes runtime dependencies after the build stage", async () => { + const dockerfile = await readFile(dockerfilePath, "utf8"); + expect(dockerfile).toContain("FROM build AS runtime-assets"); + expect(dockerfile).toContain("CI=true pnpm prune --prod"); + expect(dockerfile).toContain( + "COPY --from=runtime-assets --chown=node:node /app/node_modules ./node_modules", + ); + }); + it("normalizes plugin and agent paths permissions in image layers", async () => { const dockerfile = await readFile(dockerfilePath, "utf8"); expect(dockerfile).toContain("for dir in /app/extensions /app/.agent /app/.agents"); @@ -49,4 +58,12 @@ describe("Dockerfile", () => { expect(dockerfile).toContain('== "fpr" {'); expect(dockerfile).not.toContain('\\"fpr\\"'); }); + + it("keeps runtime pnpm available", async () => { + const dockerfile = await readFile(dockerfilePath, "utf8"); + expect(dockerfile).toContain("ENV COREPACK_HOME=/usr/local/share/corepack"); + expect(dockerfile).toContain( + 'corepack prepare "$(node -p "require(\'./package.json\').packageManager")" --activate', + ); + }); }); diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index 04f3b756567..91b20baacb0 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -23,6 +23,8 @@ const NODE_ROLE_METHODS = new Set([ "node.invoke.result", "node.event", "node.canvas.capability.refresh", + "node.pending.pull", + "node.pending.ack", "skills.bins", ]); diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 7d3e5a8cb51..95306f27f12 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -146,6 +146,8 @@ import { NodeInvokeResultParamsSchema, type NodeListParams, NodeListParamsSchema, + type NodePendingAckParams, + NodePendingAckParamsSchema, type NodePairApproveParams, NodePairApproveParamsSchema, type NodePairListParams, @@ -285,6 +287,9 @@ export const validateNodePairVerifyParams = ajv.compile( ); export const validateNodeRenameParams = ajv.compile(NodeRenameParamsSchema); export const validateNodeListParams = ajv.compile(NodeListParamsSchema); +export const validateNodePendingAckParams = ajv.compile( + NodePendingAckParamsSchema, +); export const validateNodeDescribeParams = ajv.compile(NodeDescribeParamsSchema); export const validateNodeInvokeParams = ajv.compile(NodeInvokeParamsSchema); export const validateNodeInvokeResultParams = ajv.compile( @@ -465,6 +470,7 @@ export { NodePairRejectParamsSchema, NodePairVerifyParamsSchema, NodeListParamsSchema, + NodePendingAckParamsSchema, NodeInvokeParamsSchema, SessionsListParamsSchema, SessionsPreviewParamsSchema, diff --git a/src/gateway/protocol/schema/nodes.ts b/src/gateway/protocol/schema/nodes.ts index 4eaccb8d7fa..7ce5a4fed0a 100644 --- a/src/gateway/protocol/schema/nodes.ts +++ b/src/gateway/protocol/schema/nodes.ts @@ -43,6 +43,13 @@ export const NodeRenameParamsSchema = Type.Object( export const NodeListParamsSchema = Type.Object({}, { additionalProperties: false }); +export const NodePendingAckParamsSchema = Type.Object( + { + ids: Type.Array(NonEmptyString, { minItems: 1 }), + }, + { additionalProperties: false }, +); + export const NodeDescribeParamsSchema = Type.Object( { nodeId: NonEmptyString }, { additionalProperties: false }, diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 0c55f5f2927..7ccd6cb2d1a 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -118,6 +118,7 @@ import { NodeInvokeResultParamsSchema, NodeInvokeRequestEventSchema, NodeListParamsSchema, + NodePendingAckParamsSchema, NodePairApproveParamsSchema, NodePairListParamsSchema, NodePairRejectParamsSchema, @@ -180,6 +181,7 @@ export const ProtocolSchemas = { NodePairVerifyParams: NodePairVerifyParamsSchema, NodeRenameParams: NodeRenameParamsSchema, NodeListParams: NodeListParamsSchema, + NodePendingAckParams: NodePendingAckParamsSchema, NodeDescribeParams: NodeDescribeParamsSchema, NodeInvokeParams: NodeInvokeParamsSchema, NodeInvokeResultParams: NodeInvokeResultParamsSchema, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index f828bdbc418..cc15b80fd1a 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -27,6 +27,7 @@ export type NodePairRejectParams = SchemaType<"NodePairRejectParams">; export type NodePairVerifyParams = SchemaType<"NodePairVerifyParams">; export type NodeRenameParams = SchemaType<"NodeRenameParams">; export type NodeListParams = SchemaType<"NodeListParams">; +export type NodePendingAckParams = SchemaType<"NodePendingAckParams">; export type NodeDescribeParams = SchemaType<"NodeDescribeParams">; export type NodeInvokeParams = SchemaType<"NodeInvokeParams">; export type NodeInvokeResultParams = SchemaType<"NodeInvokeResultParams">; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index c026492568c..5c5433ae2f7 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -77,6 +77,8 @@ const BASE_METHODS = [ "node.list", "node.describe", "node.invoke", + "node.pending.pull", + "node.pending.ack", "node.invoke.result", "node.event", "node.canvas.capability.refresh", diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index a6549c503f6..830d12c9509 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -212,7 +212,7 @@ export const cronHandlers: GatewayRequestHandlers = { ); return; } - const result = await context.cron.run(jobId, p.mode ?? "force"); + const result = await context.cron.enqueueRun(jobId, p.mode ?? "force"); respond(true, result, undefined); }, "cron.runs": async ({ params, respond, context }) => { diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts index 6e3ced97d6f..1f606e925dc 100644 --- a/src/gateway/server-methods/nodes.invoke-wake.test.ts +++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts @@ -49,6 +49,7 @@ type RespondCall = [ type TestNodeSession = { nodeId: string; commands: string[]; + platform?: string; }; const WAKE_WAIT_TIMEOUT_MS = 3_001; @@ -102,6 +103,54 @@ async function invokeNode(params: { return respond; } +async function pullPending(nodeId: string) { + const respond = vi.fn(); + await nodeHandlers["node.pending.pull"]({ + params: {}, + respond: respond as never, + context: {} as never, + client: { + connect: { + role: "node", + client: { + id: nodeId, + mode: "node", + name: "ios-test", + platform: "iOS 26.4.0", + version: "test", + }, + }, + } as never, + req: { type: "req", id: "req-node-pending", method: "node.pending.pull" }, + isWebchatConnect: () => false, + }); + return respond; +} + +async function ackPending(nodeId: string, ids: string[]) { + const respond = vi.fn(); + await nodeHandlers["node.pending.ack"]({ + params: { ids }, + respond: respond as never, + context: {} as never, + client: { + connect: { + role: "node", + client: { + id: nodeId, + mode: "node", + name: "ios-test", + platform: "iOS 26.4.0", + version: "test", + }, + }, + } as never, + req: { type: "req", id: "req-node-pending-ack", method: "node.pending.ack" }, + isWebchatConnect: () => false, + }); + return respond; +} + function mockSuccessfulWakeConfig(nodeId: string) { mocks.loadApnsRegistration.mockResolvedValue({ nodeId, @@ -229,4 +278,138 @@ describe("node.invoke APNs wake path", () => { expect(mocks.sendApnsBackgroundWake).toHaveBeenCalledTimes(2); expect(nodeRegistry.invoke).not.toHaveBeenCalled(); }); + + it("queues iOS foreground-only command failures and keeps them until acked", async () => { + mocks.loadApnsRegistration.mockResolvedValue(null); + + const nodeRegistry = { + get: vi.fn(() => ({ + nodeId: "ios-node-queued", + commands: ["canvas.navigate"], + platform: "iOS 26.4.0", + })), + invoke: vi.fn().mockResolvedValue({ + ok: false, + error: { + code: "NODE_BACKGROUND_UNAVAILABLE", + message: "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground", + }, + }), + }; + + const respond = await invokeNode({ + nodeRegistry, + requestParams: { + nodeId: "ios-node-queued", + command: "canvas.navigate", + params: { url: "http://example.com/" }, + idempotencyKey: "idem-queued", + }, + }); + const call = respond.mock.calls[0] as RespondCall | undefined; + expect(call?.[0]).toBe(false); + expect(call?.[2]?.code).toBe(ErrorCodes.UNAVAILABLE); + expect(call?.[2]?.message).toBe("node command queued until iOS returns to foreground"); + expect(mocks.sendApnsBackgroundWake).not.toHaveBeenCalled(); + + const pullRespond = await pullPending("ios-node-queued"); + const pullCall = pullRespond.mock.calls[0] as RespondCall | undefined; + expect(pullCall?.[0]).toBe(true); + expect(pullCall?.[1]).toMatchObject({ + nodeId: "ios-node-queued", + actions: [ + expect.objectContaining({ + command: "canvas.navigate", + paramsJSON: JSON.stringify({ url: "http://example.com/" }), + }), + ], + }); + + const repeatedPullRespond = await pullPending("ios-node-queued"); + const repeatedPullCall = repeatedPullRespond.mock.calls[0] as RespondCall | undefined; + expect(repeatedPullCall?.[0]).toBe(true); + expect(repeatedPullCall?.[1]).toMatchObject({ + nodeId: "ios-node-queued", + actions: [ + expect.objectContaining({ + command: "canvas.navigate", + paramsJSON: JSON.stringify({ url: "http://example.com/" }), + }), + ], + }); + + const queuedActionId = (pullCall?.[1] as { actions?: Array<{ id?: string }> } | undefined) + ?.actions?.[0]?.id; + expect(queuedActionId).toBeTruthy(); + + const ackRespond = await ackPending("ios-node-queued", [queuedActionId!]); + const ackCall = ackRespond.mock.calls[0] as RespondCall | undefined; + expect(ackCall?.[0]).toBe(true); + expect(ackCall?.[1]).toMatchObject({ + nodeId: "ios-node-queued", + ackedIds: [queuedActionId], + remainingCount: 0, + }); + + const emptyPullRespond = await pullPending("ios-node-queued"); + const emptyPullCall = emptyPullRespond.mock.calls[0] as RespondCall | undefined; + expect(emptyPullCall?.[0]).toBe(true); + expect(emptyPullCall?.[1]).toMatchObject({ + nodeId: "ios-node-queued", + actions: [], + }); + }); + + it("dedupes queued foreground actions by idempotency key", async () => { + mocks.loadApnsRegistration.mockResolvedValue(null); + + const nodeRegistry = { + get: vi.fn(() => ({ + nodeId: "ios-node-dedupe", + commands: ["canvas.navigate"], + platform: "iPadOS 26.4.0", + })), + invoke: vi.fn().mockResolvedValue({ + ok: false, + error: { + code: "NODE_BACKGROUND_UNAVAILABLE", + message: "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground", + }, + }), + }; + + await invokeNode({ + nodeRegistry, + requestParams: { + nodeId: "ios-node-dedupe", + command: "canvas.navigate", + params: { url: "http://example.com/first" }, + idempotencyKey: "idem-dedupe", + }, + }); + await invokeNode({ + nodeRegistry, + requestParams: { + nodeId: "ios-node-dedupe", + command: "canvas.navigate", + params: { url: "http://example.com/first" }, + idempotencyKey: "idem-dedupe", + }, + }); + + const pullRespond = await pullPending("ios-node-dedupe"); + const pullCall = pullRespond.mock.calls[0] as RespondCall | undefined; + expect(pullCall?.[0]).toBe(true); + expect(pullCall?.[1]).toMatchObject({ + nodeId: "ios-node-dedupe", + actions: [ + expect.objectContaining({ + command: "canvas.navigate", + paramsJSON: JSON.stringify({ url: "http://example.com/first" }), + }), + ], + }); + const actions = (pullCall?.[1] as { actions?: unknown[] } | undefined)?.actions ?? []; + expect(actions).toHaveLength(1); + }); }); diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 848fa0dfea5..22e3c0912e4 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { loadConfig } from "../../config/config.js"; import { listDevicePairing } from "../../infra/device-pairing.js"; import { @@ -28,6 +29,7 @@ import { validateNodeEventParams, validateNodeInvokeParams, validateNodeListParams, + validateNodePendingAckParams, validateNodePairApproveParams, validateNodePairListParams, validateNodePairRejectParams, @@ -50,6 +52,8 @@ const NODE_WAKE_RECONNECT_RETRY_WAIT_MS = 12_000; const NODE_WAKE_RECONNECT_POLL_MS = 150; const NODE_WAKE_THROTTLE_MS = 15_000; const NODE_WAKE_NUDGE_THROTTLE_MS = 10 * 60_000; +const NODE_PENDING_ACTION_TTL_MS = 10 * 60_000; +const NODE_PENDING_ACTION_MAX_PER_NODE = 64; type NodeWakeState = { lastWakeAtMs: number; @@ -77,6 +81,17 @@ type NodeWakeNudgeAttempt = { apnsReason?: string; }; +type PendingNodeAction = { + id: string; + nodeId: string; + command: string; + paramsJSON?: string; + idempotencyKey: string; + enqueuedAtMs: number; +}; + +const pendingNodeActionsById = new Map(); + function isNodeEntry(entry: { role?: string; roles?: string[] }) { if (entry.role === "node") { return true; @@ -91,6 +106,108 @@ async function delayMs(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } +function isForegroundRestrictedIosCommand(command: string): boolean { + return ( + command === "canvas.present" || + command === "canvas.navigate" || + command.startsWith("canvas.") || + command.startsWith("camera.") || + command.startsWith("screen.") || + command.startsWith("talk.") + ); +} + +function shouldQueueAsPendingForegroundAction(params: { + platform?: string; + command: string; + error: unknown; +}): boolean { + const platform = (params.platform ?? "").trim().toLowerCase(); + if (!platform.startsWith("ios") && !platform.startsWith("ipados")) { + return false; + } + if (!isForegroundRestrictedIosCommand(params.command)) { + return false; + } + const error = + params.error && typeof params.error === "object" + ? (params.error as { code?: unknown; message?: unknown }) + : null; + const code = typeof error?.code === "string" ? error.code.trim().toUpperCase() : ""; + const message = typeof error?.message === "string" ? error.message.trim().toUpperCase() : ""; + return code === "NODE_BACKGROUND_UNAVAILABLE" || message.includes("BACKGROUND_UNAVAILABLE"); +} + +function prunePendingNodeActions(nodeId: string, nowMs: number): PendingNodeAction[] { + const queue = pendingNodeActionsById.get(nodeId) ?? []; + const minTimestampMs = nowMs - NODE_PENDING_ACTION_TTL_MS; + const live = queue.filter((entry) => entry.enqueuedAtMs >= minTimestampMs); + if (live.length === 0) { + pendingNodeActionsById.delete(nodeId); + return []; + } + pendingNodeActionsById.set(nodeId, live); + return live; +} + +function enqueuePendingNodeAction(params: { + nodeId: string; + command: string; + paramsJSON?: string; + idempotencyKey: string; +}): PendingNodeAction { + const nowMs = Date.now(); + const queue = prunePendingNodeActions(params.nodeId, nowMs); + const existing = queue.find((entry) => entry.idempotencyKey === params.idempotencyKey); + if (existing) { + return existing; + } + const entry: PendingNodeAction = { + id: randomUUID(), + nodeId: params.nodeId, + command: params.command, + paramsJSON: params.paramsJSON, + idempotencyKey: params.idempotencyKey, + enqueuedAtMs: nowMs, + }; + queue.push(entry); + if (queue.length > NODE_PENDING_ACTION_MAX_PER_NODE) { + queue.splice(0, queue.length - NODE_PENDING_ACTION_MAX_PER_NODE); + } + pendingNodeActionsById.set(params.nodeId, queue); + return entry; +} + +function listPendingNodeActions(nodeId: string): PendingNodeAction[] { + return prunePendingNodeActions(nodeId, Date.now()); +} + +function ackPendingNodeActions(nodeId: string, ids: string[]): PendingNodeAction[] { + if (ids.length === 0) { + return listPendingNodeActions(nodeId); + } + const pending = prunePendingNodeActions(nodeId, Date.now()); + const idSet = new Set(ids); + const remaining = pending.filter((entry) => !idSet.has(entry.id)); + if (remaining.length === 0) { + pendingNodeActionsById.delete(nodeId); + return []; + } + pendingNodeActionsById.set(nodeId, remaining); + return remaining; +} + +function toPendingParamsJSON(params: unknown): string | undefined { + if (params === undefined) { + return undefined; + } + try { + return JSON.stringify(params); + } catch { + return undefined; + } +} + async function maybeWakeNodeWithApns( nodeId: string, opts?: { force?: boolean }, @@ -596,6 +713,66 @@ export const nodeHandlers: GatewayRequestHandlers = { undefined, ); }, + "node.pending.pull": async ({ params, respond, client }) => { + if (!validateNodeListParams(params)) { + respondInvalidParams({ + respond, + method: "node.pending.pull", + validator: validateNodeListParams, + }); + return; + } + const nodeId = client?.connect?.device?.id ?? client?.connect?.client?.id; + const trimmedNodeId = String(nodeId ?? "").trim(); + if (!trimmedNodeId) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required")); + return; + } + + const pending = listPendingNodeActions(trimmedNodeId); + respond( + true, + { + nodeId: trimmedNodeId, + actions: pending.map((entry) => ({ + id: entry.id, + command: entry.command, + paramsJSON: entry.paramsJSON ?? null, + enqueuedAtMs: entry.enqueuedAtMs, + })), + }, + undefined, + ); + }, + "node.pending.ack": async ({ params, respond, client }) => { + if (!validateNodePendingAckParams(params)) { + respondInvalidParams({ + respond, + method: "node.pending.ack", + validator: validateNodePendingAckParams, + }); + return; + } + const nodeId = client?.connect?.device?.id ?? client?.connect?.client?.id; + const trimmedNodeId = String(nodeId ?? "").trim(); + if (!trimmedNodeId) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required")); + return; + } + const ackIds = Array.from( + new Set((params.ids ?? []).map((value) => String(value ?? "").trim()).filter(Boolean)), + ); + const remaining = ackPendingNodeActions(trimmedNodeId, ackIds); + respond( + true, + { + nodeId: trimmedNodeId, + ackedIds: ackIds, + remainingCount: remaining.length, + }, + undefined, + ); + }, "node.invoke": async ({ params, respond, context, client, req }) => { if (!validateNodeInvokeParams(params)) { respondInvalidParams({ @@ -759,7 +936,56 @@ export const nodeHandlers: GatewayRequestHandlers = { timeoutMs: p.timeoutMs, idempotencyKey: p.idempotencyKey, }); - if (!respondUnavailableOnNodeInvokeError(respond, res)) { + if (!res.ok) { + if ( + shouldQueueAsPendingForegroundAction({ + platform: nodeSession.platform, + command, + error: res.error, + }) + ) { + const paramsJSON = toPendingParamsJSON(forwardedParams.params); + const queued = enqueuePendingNodeAction({ + nodeId, + command, + paramsJSON, + idempotencyKey: p.idempotencyKey, + }); + const wake = await maybeWakeNodeWithApns(nodeId); + context.logGateway.info( + `node pending queued node=${nodeId} req=${req.id} command=${command} ` + + `queuedId=${queued.id} wakePath=${wake.path} wakeAvailable=${wake.available}`, + ); + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + "node command queued until iOS returns to foreground", + { + retryable: true, + details: { + code: "QUEUED_UNTIL_FOREGROUND", + queuedActionId: queued.id, + nodeId, + command, + wake: { + path: wake.path, + available: wake.available, + throttled: wake.throttled, + apnsStatus: wake.apnsStatus, + apnsReason: wake.apnsReason, + }, + nodeError: res.error ?? null, + }, + }, + ), + ); + return; + } + if (!respondUnavailableOnNodeInvokeError(respond, res)) { + return; + } return; } const payload = res.payloadJSON ? safeParseJson(res.payloadJSON) : res.payload; diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index 4a21354605d..ccaf5441237 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -9,6 +9,7 @@ import { connectOk, cronIsolatedRun, installGatewayTestHooks, + onceMessage, rpcReq, startServerWithClient, testState, @@ -35,7 +36,6 @@ vi.mock("../infra/net/fetch-guard.js", () => ({ })); installGatewayTestHooks({ scope: "suite" }); -const CRON_WAIT_INTERVAL_MS = 5; const CRON_WAIT_TIMEOUT_MS = 3_000; const EMPTY_CRON_STORE_CONTENT = JSON.stringify({ version: 1, jobs: [] }); let cronSuiteTempRootPromise: Promise | null = null; @@ -69,16 +69,20 @@ async function rmTempDir(dir: string) { await fs.rm(dir, { recursive: true, force: true }); } -async function waitForCondition(check: () => boolean | Promise, timeoutMs = 2000) { - await vi.waitFor( - async () => { - const ok = await check(); - if (!ok) { - throw new Error("condition not met"); - } +async function waitForCronEvent( + ws: WebSocket, + check: (payload: Record | null) => boolean, + timeoutMs = CRON_WAIT_TIMEOUT_MS, +) { + const message = await onceMessage( + ws, + (obj) => { + const payload = obj.payload ?? null; + return obj.type === "event" && obj.event === "cron" && check(payload); }, - { timeout: timeoutMs, interval: CRON_WAIT_INTERVAL_MS }, + timeoutMs, ); + return message.payload ?? null; } async function createCronCasePaths(tempPrefix: string): Promise<{ @@ -178,6 +182,8 @@ async function addWebhookCronJob(params: { async function runCronJobForce(ws: WebSocket, id: string) { const response = await rpcReq(ws, "cron.run", { id, mode: "force" }, 20_000); expect(response.ok).toBe(true); + expect(response.payload).toEqual({ ok: true, enqueued: true, runId: expect.any(String) }); + return response; } function getWebhookCall(index: number) { @@ -263,6 +269,7 @@ describe("gateway server cron", () => { const runRes = await rpcReq(ws, "cron.run", { id: routeJobId, mode: "force" }, 20_000); expect(runRes.ok).toBe(true); + expect(runRes.payload).toEqual({ ok: true, enqueued: true, runId: expect.any(String) }); const events = await waitForSystemEvent(); expect(events.some((event) => event.includes("cron route check"))).toBe(true); @@ -441,7 +448,7 @@ describe("gateway server cron", () => { }); test("writes cron run history and auto-runs due jobs", async () => { - const { prevSkipCron, dir } = await setupCronTestRun({ + const { prevSkipCron } = await setupCronTestRun({ tempPrefix: "openclaw-gw-cron-log-", }); @@ -463,31 +470,21 @@ describe("gateway server cron", () => { const jobId = typeof jobIdValue === "string" ? jobIdValue : ""; expect(jobId.length > 0).toBe(true); + const finishedRun = waitForCronEvent( + ws, + (payload) => payload?.jobId === jobId && payload?.action === "finished", + ); const runRes = await rpcReq(ws, "cron.run", { id: jobId, mode: "force" }, 20_000); expect(runRes.ok).toBe(true); - const logPath = path.join(dir, "cron", "runs", `${jobId}.jsonl`); - let raw = ""; - await waitForCondition(async () => { - raw = await fs.readFile(logPath, "utf-8").catch(() => ""); - return raw.trim().length > 0; - }, CRON_WAIT_TIMEOUT_MS); - const line = raw - .split("\n") - .map((l) => l.trim()) - .filter(Boolean) - .at(-1); - const last = JSON.parse(line ?? "{}") as { - jobId?: unknown; - action?: unknown; - status?: unknown; - summary?: unknown; - deliveryStatus?: unknown; - }; - expect(last.action).toBe("finished"); - expect(last.jobId).toBe(jobId); - expect(last.status).toBe("ok"); - expect(last.summary).toBe("hello"); - expect(last.deliveryStatus).toBe("not-requested"); + expect(runRes.payload).toEqual({ ok: true, enqueued: true, runId: expect.any(String) }); + const finishedPayload = await finishedRun; + expect(finishedPayload).toMatchObject({ + jobId, + action: "finished", + status: "ok", + summary: "hello", + deliveryStatus: "not-requested", + }); const runsRes = await rpcReq(ws, "cron.runs", { id: jobId, limit: 50 }); expect(runsRes.ok).toBe(true); @@ -522,7 +519,7 @@ describe("gateway server cron", () => { const autoRes = await rpcReq(ws, "cron.add", { name: "auto run test", enabled: true, - schedule: { kind: "at", at: new Date(Date.now() + 50).toISOString() }, + schedule: { kind: "at", at: new Date(Date.now() + 200).toISOString() }, sessionTarget: "main", wakeMode: "next-heartbeat", payload: { kind: "systemEvent", text: "auto" }, @@ -532,11 +529,10 @@ describe("gateway server cron", () => { const autoJobId = typeof autoJobIdValue === "string" ? autoJobIdValue : ""; expect(autoJobId.length > 0).toBe(true); - await waitForCondition(async () => { - const runsRes = await rpcReq(ws, "cron.runs", { id: autoJobId, limit: 10 }); - const runsPayload = runsRes.payload as { entries?: unknown } | undefined; - return Array.isArray(runsPayload?.entries) && runsPayload.entries.length > 0; - }, CRON_WAIT_TIMEOUT_MS); + await waitForCronEvent( + ws, + (payload) => payload?.jobId === autoJobId && payload?.action === "finished", + ); const autoEntries = (await rpcReq(ws, "cron.runs", { id: autoJobId, limit: 10 })).payload as | { entries?: Array<{ jobId?: unknown }> } | undefined; @@ -548,6 +544,162 @@ describe("gateway server cron", () => { } }, 45_000); + test("returns from cron.run immediately while isolated work continues in background", async () => { + const { prevSkipCron } = await setupCronTestRun({ + tempPrefix: "openclaw-gw-cron-run-detached-", + }); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + let resolveRun: ((value: { status: "ok"; summary: string }) => void) | undefined; + cronIsolatedRun.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRun = resolve as (value: { status: "ok"; summary: string }) => void; + }), + ); + + try { + const addRes = await rpcReq(ws, "cron.add", { + name: "detached run test", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "do work" }, + delivery: { mode: "none" }, + }); + expect(addRes.ok).toBe(true); + const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id; + const jobId = typeof jobIdValue === "string" ? jobIdValue : ""; + expect(jobId.length > 0).toBe(true); + + const startedRun = waitForCronEvent( + ws, + (payload) => payload?.jobId === jobId && payload?.action === "started", + ); + const finishedRun = waitForCronEvent( + ws, + (payload) => payload?.jobId === jobId && payload?.action === "finished", + ); + const runRes = await rpcReq(ws, "cron.run", { id: jobId, mode: "force" }, 1_000); + expect(runRes.ok).toBe(true); + expect(runRes.payload).toEqual({ ok: true, enqueued: true, runId: expect.any(String) }); + await startedRun; + expect(cronIsolatedRun).toHaveBeenCalledTimes(1); + + resolveRun?.({ status: "ok", summary: "background finished" }); + const finishedPayload = await finishedRun; + expect(finishedPayload).toMatchObject({ + jobId, + action: "finished", + status: "ok", + summary: "background finished", + }); + } finally { + await cleanupCronTestRun({ ws, server, prevSkipCron }); + } + }); + + test("returns already-running without starting background work", async () => { + const now = Date.now(); + let resolveRun: ((result: { status: "ok"; summary: string }) => void) | undefined; + cronIsolatedRun.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRun = resolve; + }), + ); + + const { prevSkipCron } = await setupCronTestRun({ + tempPrefix: "openclaw-gw-cron-run-busy-", + jobs: [ + { + id: "busy-job", + name: "busy job", + enabled: true, + createdAtMs: now - 60_000, + updatedAtMs: now - 60_000, + schedule: { kind: "at", at: new Date(now + 60_000).toISOString() }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "still busy" }, + delivery: { mode: "none" }, + state: { + nextRunAtMs: now + 60_000, + }, + }, + ], + }); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + try { + const startedRun = waitForCronEvent( + ws, + (payload) => payload?.jobId === "busy-job" && payload?.action === "started", + ); + const firstRunRes = await rpcReq(ws, "cron.run", { id: "busy-job", mode: "force" }, 1_000); + expect(firstRunRes.ok).toBe(true); + expect(firstRunRes.payload).toEqual({ ok: true, enqueued: true, runId: expect.any(String) }); + await startedRun; + expect(cronIsolatedRun).toHaveBeenCalledTimes(1); + + const secondRunRes = await rpcReq(ws, "cron.run", { id: "busy-job", mode: "force" }, 1_000); + expect(secondRunRes.ok).toBe(true); + expect(secondRunRes.payload).toEqual({ ok: true, ran: false, reason: "already-running" }); + expect(cronIsolatedRun).toHaveBeenCalledTimes(1); + + const finishedRun = waitForCronEvent( + ws, + (payload) => payload?.jobId === "busy-job" && payload?.action === "finished", + ); + resolveRun?.({ status: "ok", summary: "busy done" }); + await finishedRun; + } finally { + await cleanupCronTestRun({ ws, server, prevSkipCron }); + } + }); + + test("returns not-due without starting background work", async () => { + const now = Date.now(); + const { prevSkipCron } = await setupCronTestRun({ + tempPrefix: "openclaw-gw-cron-run-not-due-", + jobs: [ + { + id: "future-job", + name: "future job", + enabled: true, + createdAtMs: now - 60_000, + updatedAtMs: now - 60_000, + schedule: { kind: "at", at: new Date(now + 60_000).toISOString() }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "not yet" }, + delivery: { mode: "none" }, + state: { + nextRunAtMs: now + 60_000, + }, + }, + ], + }); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + cronIsolatedRun.mockClear(); + + try { + const runRes = await rpcReq(ws, "cron.run", { id: "future-job", mode: "due" }, 1_000); + expect(runRes.ok).toBe(true); + expect(runRes.payload).toEqual({ ok: true, ran: false, reason: "not-due" }); + expect(cronIsolatedRun).not.toHaveBeenCalled(); + } finally { + await cleanupCronTestRun({ ws, server, prevSkipCron }); + } + }); + test("posts webhooks for delivery mode and legacy notify fallback only when summary exists", async () => { const legacyNotifyJob = { id: "legacy-notify-job", @@ -608,12 +760,12 @@ describe("gateway server cron", () => { name: "webhook enabled", delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" }, }); - await runCronJobForce(ws, notifyJobId); - - await waitForCondition( - () => fetchWithSsrFGuardMock.mock.calls.length === 1, - CRON_WAIT_TIMEOUT_MS, + const notifyFinished = waitForCronEvent( + ws, + (payload) => payload?.jobId === notifyJobId && payload?.action === "finished", ); + await runCronJobForce(ws, notifyJobId); + await notifyFinished; const notifyCall = getWebhookCall(0); expect(notifyCall.url).toBe("https://example.invalid/cron-finished"); expect(notifyCall.init.method).toBe("POST"); @@ -623,6 +775,10 @@ describe("gateway server cron", () => { expect(notifyBody.action).toBe("finished"); expect(notifyBody.jobId).toBe(notifyJobId); + const legacyFinished = waitForCronEvent( + ws, + (payload) => payload?.jobId === "legacy-notify-job" && payload?.action === "finished", + ); const legacyRunRes = await rpcReq( ws, "cron.run", @@ -630,10 +786,8 @@ describe("gateway server cron", () => { 20_000, ); expect(legacyRunRes.ok).toBe(true); - await waitForCondition( - () => fetchWithSsrFGuardMock.mock.calls.length === 2, - CRON_WAIT_TIMEOUT_MS, - ); + expect(legacyRunRes.payload).toEqual({ ok: true, enqueued: true, runId: expect.any(String) }); + await legacyFinished; const legacyCall = getWebhookCall(1); expect(legacyCall.url).toBe("https://legacy.example.invalid/cron-finished"); expect(legacyCall.init.method).toBe("POST"); @@ -655,10 +809,14 @@ describe("gateway server cron", () => { const silentJobId = typeof silentJobIdValue === "string" ? silentJobIdValue : ""; expect(silentJobId.length > 0).toBe(true); + const silentFinished = waitForCronEvent( + ws, + (payload) => payload?.jobId === silentJobId && payload?.action === "finished", + ); const silentRunRes = await rpcReq(ws, "cron.run", { id: silentJobId, mode: "force" }, 20_000); expect(silentRunRes.ok).toBe(true); - await yieldToEventLoop(); - await yieldToEventLoop(); + expect(silentRunRes.payload).toEqual({ ok: true, enqueued: true, runId: expect.any(String) }); + await silentFinished; expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(2); fetchWithSsrFGuardMock.mockClear(); @@ -677,11 +835,12 @@ describe("gateway server cron", () => { }, }, }); - await runCronJobForce(ws, failureDestJobId); - await waitForCondition( - () => fetchWithSsrFGuardMock.mock.calls.length === 1, - CRON_WAIT_TIMEOUT_MS, + const failureDestFinished = waitForCronEvent( + ws, + (payload) => payload?.jobId === failureDestJobId && payload?.action === "finished", ); + await runCronJobForce(ws, failureDestJobId); + await failureDestFinished; const failureDestCall = getWebhookCall(0); expect(failureDestCall.url).toBe("https://example.invalid/failure-destination"); const failureDestBody = failureDestCall.body; @@ -696,9 +855,12 @@ describe("gateway server cron", () => { sessionTarget: "isolated", delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" }, }); + const noSummaryFinished = waitForCronEvent( + ws, + (payload) => payload?.jobId === noSummaryJobId && payload?.action === "finished", + ); await runCronJobForce(ws, noSummaryJobId); - await yieldToEventLoop(); - await yieldToEventLoop(); + await noSummaryFinished; expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(1); } finally { await cleanupCronTestRun({ ws, server, prevSkipCron }); @@ -741,12 +903,12 @@ describe("gateway server cron", () => { name: "webhook secretinput object", delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" }, }); - await runCronJobForce(ws, notifyJobId); - - await waitForCondition( - () => fetchWithSsrFGuardMock.mock.calls.length === 1, - CRON_WAIT_TIMEOUT_MS, + const notifyFinished = waitForCronEvent( + ws, + (payload) => payload?.jobId === notifyJobId && payload?.action === "finished", ); + await runCronJobForce(ws, notifyJobId); + await notifyFinished; const [notifyArgs] = fetchWithSsrFGuardMock.mock.calls[0] as unknown as [ { url?: string; diff --git a/src/infra/gateway-lock.ts b/src/infra/gateway-lock.ts index 6e6b71cf2d1..502e06dec3a 100644 --- a/src/infra/gateway-lock.ts +++ b/src/infra/gateway-lock.ts @@ -5,6 +5,7 @@ import net from "node:net"; import path from "node:path"; import { resolveConfigPath, resolveGatewayLockDir, resolveStateDir } from "../config/paths.js"; import { isPidAlive } from "../shared/pid-alive.js"; +import { isGatewayArgv, parseProcCmdline } from "./gateway-process-argv.js"; const DEFAULT_TIMEOUT_MS = 5000; const DEFAULT_POLL_INTERVAL_MS = 100; @@ -46,38 +47,6 @@ export class GatewayLockError extends Error { type LockOwnerStatus = "alive" | "dead" | "unknown"; -function normalizeProcArg(arg: string): string { - return arg.replaceAll("\\", "/").toLowerCase(); -} - -function parseProcCmdline(raw: string): string[] { - return raw - .split("\0") - .map((entry) => entry.trim()) - .filter(Boolean); -} - -function isGatewayArgv(args: string[]): boolean { - const normalized = args.map(normalizeProcArg); - if (!normalized.includes("gateway")) { - return false; - } - - const entryCandidates = [ - "dist/index.js", - "dist/entry.js", - "openclaw.mjs", - "scripts/run-node.mjs", - "src/index.ts", - ]; - if (normalized.some((arg) => entryCandidates.some((entry) => arg.endsWith(entry)))) { - return true; - } - - const exe = normalized[0] ?? ""; - return exe.endsWith("/openclaw") || exe === "openclaw"; -} - function readLinuxCmdline(pid: number): string[] | null { try { const raw = fsSync.readFileSync(`/proc/${pid}/cmdline`, "utf8"); diff --git a/src/infra/gateway-process-argv.ts b/src/infra/gateway-process-argv.ts new file mode 100644 index 00000000000..59f042ead88 --- /dev/null +++ b/src/infra/gateway-process-argv.ts @@ -0,0 +1,35 @@ +function normalizeProcArg(arg: string): string { + return arg.replaceAll("\\", "/").toLowerCase(); +} + +export function parseProcCmdline(raw: string): string[] { + return raw + .split("\0") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +export function isGatewayArgv(args: string[], opts?: { allowGatewayBinary?: boolean }): boolean { + const normalized = args.map(normalizeProcArg); + if (!normalized.includes("gateway")) { + return false; + } + + const entryCandidates = [ + "dist/index.js", + "dist/entry.js", + "openclaw.mjs", + "scripts/run-node.mjs", + "src/index.ts", + ]; + if (normalized.some((arg) => entryCandidates.some((entry) => arg.endsWith(entry)))) { + return true; + } + + const exe = (normalized[0] ?? "").replace(/\.(bat|cmd|exe)$/i, ""); + return ( + exe.endsWith("/openclaw") || + exe === "openclaw" || + (opts?.allowGatewayBinary === true && exe.endsWith("/openclaw-gateway")) + ); +} diff --git a/src/infra/git-commit.test.ts b/src/infra/git-commit.test.ts index be89c5444b4..26be4322ad8 100644 --- a/src/infra/git-commit.test.ts +++ b/src/infra/git-commit.test.ts @@ -65,7 +65,9 @@ describe("git commit resolution", () => { const repoHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], { cwd: repoRoot, encoding: "utf-8", - }).trim(); + }) + .trim() + .slice(0, 7); const temp = await makeTempDir("git-commit-cwd"); const otherRepo = path.join(temp, "other"); @@ -81,7 +83,9 @@ describe("git commit resolution", () => { const otherHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], { cwd: otherRepo, encoding: "utf-8", - }).trim(); + }) + .trim() + .slice(0, 7); process.chdir(otherRepo); const { resolveCommitHash } = await import("./git-commit.js"); @@ -95,7 +99,9 @@ describe("git commit resolution", () => { const repoHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], { cwd: repoRoot, encoding: "utf-8", - }).trim(); + }) + .trim() + .slice(0, 7); const { resolveCommitHash } = await import("./git-commit.js"); const entryModuleUrl = pathToFileURL(path.join(repoRoot, "src", "entry.ts")).href; @@ -161,7 +167,9 @@ describe("git commit resolution", () => { const repoHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], { cwd: repoRoot, encoding: "utf-8", - }).trim(); + }) + .trim() + .slice(0, 7); const { resolveCommitHash } = await import("./git-commit.js"); diff --git a/src/plugin-sdk/channel-config-helpers.ts b/src/plugin-sdk/channel-config-helpers.ts index 754e2a57c1a..afcd312f1c8 100644 --- a/src/plugin-sdk/channel-config-helpers.ts +++ b/src/plugin-sdk/channel-config-helpers.ts @@ -1,3 +1,7 @@ +import { + deleteAccountFromConfigSection, + setAccountEnabledInConfigSection, +} from "../channels/plugins/config-helpers.js"; import { normalizeWhatsAppAllowFromEntries } from "../channels/plugins/normalize/whatsapp.js"; import type { ChannelConfigAdapter } from "../channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../config/config.js"; @@ -55,6 +59,51 @@ export function createScopedAccountConfigAccessors(params: { }; } +export function createScopedChannelConfigBase< + ResolvedAccount, + Config extends OpenClawConfig = OpenClawConfig, +>(params: { + sectionKey: string; + listAccountIds: (cfg: Config) => string[]; + resolveAccount: (cfg: Config, accountId?: string | null) => ResolvedAccount; + defaultAccountId: (cfg: Config) => string; + inspectAccount?: (cfg: Config, accountId?: string | null) => unknown; + clearBaseFields: string[]; + allowTopLevel?: boolean; +}): Pick< + ChannelConfigAdapter, + | "listAccountIds" + | "resolveAccount" + | "inspectAccount" + | "defaultAccountId" + | "setAccountEnabled" + | "deleteAccount" +> { + return { + listAccountIds: (cfg) => params.listAccountIds(cfg as Config), + resolveAccount: (cfg, accountId) => params.resolveAccount(cfg as Config, accountId), + inspectAccount: params.inspectAccount + ? (cfg, accountId) => params.inspectAccount?.(cfg as Config, accountId) + : undefined, + defaultAccountId: (cfg) => params.defaultAccountId(cfg as Config), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg: cfg as Config, + sectionKey: params.sectionKey, + accountId, + enabled, + allowTopLevel: params.allowTopLevel ?? true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg: cfg as Config, + sectionKey: params.sectionKey, + accountId, + clearBaseFields: params.clearBaseFields, + }), + }; +} + export function resolveWhatsAppConfigAllowFrom(params: { cfg: OpenClawConfig; accountId?: string | null; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index ca3f54a479b..3e1ba0f03ab 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -194,6 +194,12 @@ export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; export { buildChannelSendResult } from "./channel-send-result.js"; export type { ChannelSendRawResult } from "./channel-send-result.js"; +export { createPluginRuntimeStore } from "./runtime-store.js"; +export { createScopedChannelConfigBase } from "./channel-config-helpers.js"; +export { + AllowFromEntrySchema, + buildCatchallMultiAccountChannelSchema, +} from "../channels/plugins/config-schema.js"; export type { ChannelDock } from "../channels/dock.js"; export { getChatChannelMeta } from "../channels/registry.js"; export { resolveAllowlistMatchByCandidates } from "../channels/allowlist-match.js"; diff --git a/src/plugin-sdk/root-alias.cjs b/src/plugin-sdk/root-alias.cjs index 9e3c2e5d1a8..12d98caf8a8 100644 --- a/src/plugin-sdk/root-alias.cjs +++ b/src/plugin-sdk/root-alias.cjs @@ -108,26 +108,94 @@ const fastExports = { resolveControlCommandGate, }; -const monolithic = tryLoadMonolithicSdk(); -const rootExports = - monolithic && typeof monolithic === "object" - ? { - ...monolithic, - ...fastExports, - } - : { ...fastExports }; +const target = { ...fastExports }; +let rootExports = null; -Object.defineProperty(rootExports, "__esModule", { +function getMonolithicSdk() { + const loaded = tryLoadMonolithicSdk(); + if (loaded && typeof loaded === "object") { + return loaded; + } + return null; +} + +function getExportValue(prop) { + if (Reflect.has(target, prop)) { + return Reflect.get(target, prop); + } + const monolithic = getMonolithicSdk(); + if (!monolithic) { + return undefined; + } + return Reflect.get(monolithic, prop); +} + +function getExportDescriptor(prop) { + const ownDescriptor = Reflect.getOwnPropertyDescriptor(target, prop); + if (ownDescriptor) { + return ownDescriptor; + } + + const monolithic = getMonolithicSdk(); + if (!monolithic) { + return undefined; + } + + const descriptor = Reflect.getOwnPropertyDescriptor(monolithic, prop); + if (!descriptor) { + return undefined; + } + + // Proxy invariants require descriptors returned for dynamic properties to be configurable. + return { + ...descriptor, + configurable: true, + }; +} + +rootExports = new Proxy(target, { + get(_target, prop, receiver) { + if (Reflect.has(target, prop)) { + return Reflect.get(target, prop, receiver); + } + return getExportValue(prop); + }, + has(_target, prop) { + if (Reflect.has(target, prop)) { + return true; + } + const monolithic = getMonolithicSdk(); + return monolithic ? Reflect.has(monolithic, prop) : false; + }, + ownKeys() { + const keys = new Set(Reflect.ownKeys(target)); + const monolithic = getMonolithicSdk(); + if (monolithic) { + for (const key of Reflect.ownKeys(monolithic)) { + if (!keys.has(key)) { + keys.add(key); + } + } + } + return [...keys]; + }, + getOwnPropertyDescriptor(_target, prop) { + return getExportDescriptor(prop); + }, +}); + +Object.defineProperty(target, "__esModule", { configurable: true, enumerable: false, writable: false, value: true, }); -Object.defineProperty(rootExports, "default", { +Object.defineProperty(target, "default", { configurable: true, enumerable: false, - writable: false, - value: rootExports, + get() { + return rootExports; + }, }); module.exports = rootExports; diff --git a/src/plugin-sdk/root-alias.test.ts b/src/plugin-sdk/root-alias.test.ts index 8757d3ce34c..4822c247323 100644 --- a/src/plugin-sdk/root-alias.test.ts +++ b/src/plugin-sdk/root-alias.test.ts @@ -1,8 +1,14 @@ +import fs from "node:fs"; import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import vm from "node:vm"; import { describe, expect, it } from "vitest"; const require = createRequire(import.meta.url); const rootSdk = require("./root-alias.cjs") as Record; +const rootAliasPath = fileURLToPath(new URL("./root-alias.cjs", import.meta.url)); +const rootAliasSource = fs.readFileSync(rootAliasPath, "utf-8"); type EmptySchema = { safeParse: (value: unknown) => @@ -13,6 +19,64 @@ type EmptySchema = { }; }; +function loadRootAliasWithStubs(options?: { + distExists?: boolean; + monolithicExports?: Record; +}) { + let createJitiCalls = 0; + let jitiLoadCalls = 0; + const loadedSpecifiers: string[] = []; + const monolithicExports = options?.monolithicExports ?? { + slowHelper: () => "loaded", + }; + const wrapper = vm.runInNewContext( + `(function (exports, require, module, __filename, __dirname) {${rootAliasSource}\n})`, + {}, + { filename: rootAliasPath }, + ) as ( + exports: Record, + require: NodeJS.Require, + module: { exports: Record }, + __filename: string, + __dirname: string, + ) => void; + const module = { exports: {} as Record }; + const localRequire = ((id: string) => { + if (id === "node:path") { + return path; + } + if (id === "node:fs") { + return { + existsSync: () => options?.distExists ?? false, + }; + } + if (id === "jiti") { + return { + createJiti() { + createJitiCalls += 1; + return (specifier: string) => { + jitiLoadCalls += 1; + loadedSpecifiers.push(specifier); + return monolithicExports; + }; + }, + }; + } + throw new Error(`unexpected require: ${id}`); + }) as NodeJS.Require; + wrapper(module.exports, localRequire, module, rootAliasPath, path.dirname(rootAliasPath)); + return { + moduleExports: module.exports, + get createJitiCalls() { + return createJitiCalls; + }, + get jitiLoadCalls() { + return jitiLoadCalls; + }, + loadedSpecifiers, + }; +} + describe("plugin-sdk root alias", () => { it("exposes the fast empty config schema helper", () => { const factory = rootSdk.emptyPluginConfigSchema as (() => EmptySchema) | undefined; @@ -27,6 +91,36 @@ describe("plugin-sdk root alias", () => { expect(parsed.success).toBe(false); }); + it("does not load the monolithic sdk for fast helpers", () => { + const lazyModule = loadRootAliasWithStubs(); + const lazyRootSdk = lazyModule.moduleExports; + const factory = lazyRootSdk.emptyPluginConfigSchema as (() => EmptySchema) | undefined; + + expect(lazyModule.createJitiCalls).toBe(0); + expect(lazyModule.jitiLoadCalls).toBe(0); + expect(typeof factory).toBe("function"); + expect(factory?.().safeParse({})).toEqual({ success: true, data: {} }); + expect(lazyModule.createJitiCalls).toBe(0); + expect(lazyModule.jitiLoadCalls).toBe(0); + }); + + it("loads legacy root exports on demand and preserves reflection", () => { + const lazyModule = loadRootAliasWithStubs({ + monolithicExports: { + slowHelper: () => "loaded", + }, + }); + const lazyRootSdk = lazyModule.moduleExports; + + expect(lazyModule.createJitiCalls).toBe(0); + expect("slowHelper" in lazyRootSdk).toBe(true); + expect(lazyModule.createJitiCalls).toBe(1); + expect(lazyModule.jitiLoadCalls).toBe(1); + expect((lazyRootSdk.slowHelper as () => string)()).toBe("loaded"); + expect(Object.keys(lazyRootSdk)).toContain("slowHelper"); + expect(Object.getOwnPropertyDescriptor(lazyRootSdk, "slowHelper")).toBeDefined(); + }); + it("loads legacy root exports through the merged root wrapper", { timeout: 240_000 }, () => { expect(typeof rootSdk.resolveControlCommandGate).toBe("function"); expect(typeof rootSdk.default).toBe("object"); diff --git a/src/plugin-sdk/runtime-store.ts b/src/plugin-sdk/runtime-store.ts new file mode 100644 index 00000000000..de0d84131e1 --- /dev/null +++ b/src/plugin-sdk/runtime-store.ts @@ -0,0 +1,26 @@ +export function createPluginRuntimeStore(errorMessage: string): { + setRuntime: (next: T) => void; + clearRuntime: () => void; + tryGetRuntime: () => T | null; + getRuntime: () => T; +} { + let runtime: T | null = null; + + return { + setRuntime(next: T) { + runtime = next; + }, + clearRuntime() { + runtime = null; + }, + tryGetRuntime() { + return runtime; + }, + getRuntime() { + if (!runtime) { + throw new Error(errorMessage); + } + return runtime; + }, + }; +} diff --git a/src/tui/theme/syntax-theme.ts b/src/tui/theme/syntax-theme.ts index ba29d5012db..d0aea2d5a9c 100644 --- a/src/tui/theme/syntax-theme.ts +++ b/src/tui/theme/syntax-theme.ts @@ -6,7 +6,55 @@ type HighlightTheme = Record string>; * Syntax highlighting theme for code blocks. * Uses chalk functions to style different token types. */ -export function createSyntaxTheme(fallback: (text: string) => string): HighlightTheme { +export function createSyntaxTheme( + fallback: (text: string) => string, + light = false, +): HighlightTheme { + if (light) { + return { + keyword: chalk.hex("#AF00DB"), + built_in: chalk.hex("#267F99"), + type: chalk.hex("#267F99"), + literal: chalk.hex("#0000FF"), + number: chalk.hex("#098658"), + string: chalk.hex("#A31515"), + regexp: chalk.hex("#811F3F"), + symbol: chalk.hex("#098658"), + class: chalk.hex("#267F99"), + function: chalk.hex("#795E26"), + title: chalk.hex("#795E26"), + params: chalk.hex("#001080"), + comment: chalk.hex("#008000"), + doctag: chalk.hex("#008000"), + meta: chalk.hex("#001080"), + "meta-keyword": chalk.hex("#AF00DB"), + "meta-string": chalk.hex("#A31515"), + section: chalk.hex("#795E26"), + tag: chalk.hex("#800000"), + name: chalk.hex("#001080"), + attr: chalk.hex("#C50000"), + attribute: chalk.hex("#C50000"), + variable: chalk.hex("#001080"), + bullet: chalk.hex("#795E26"), + code: chalk.hex("#A31515"), + emphasis: chalk.italic, + strong: chalk.bold, + formula: chalk.hex("#AF00DB"), + link: chalk.hex("#267F99"), + quote: chalk.hex("#008000"), + addition: chalk.hex("#098658"), + deletion: chalk.hex("#A31515"), + "selector-tag": chalk.hex("#800000"), + "selector-id": chalk.hex("#800000"), + "selector-class": chalk.hex("#800000"), + "selector-attr": chalk.hex("#800000"), + "selector-pseudo": chalk.hex("#800000"), + "template-tag": chalk.hex("#AF00DB"), + "template-variable": chalk.hex("#001080"), + default: fallback, + }; + } + return { keyword: chalk.hex("#C586C0"), // purple - if, const, function, etc. built_in: chalk.hex("#4EC9B0"), // teal - console, Math, etc. diff --git a/src/tui/theme/theme.test.ts b/src/tui/theme/theme.test.ts index dd692304599..50aa349b689 100644 --- a/src/tui/theme/theme.test.ts +++ b/src/tui/theme/theme.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const cliHighlightMocks = vi.hoisted(() => ({ highlight: vi.fn((code: string) => code), @@ -13,6 +13,25 @@ const { markdownTheme, searchableSelectListTheme, selectListTheme, theme } = const stripAnsi = (str: string) => str.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"), ""); +function relativeLuminance(hex: string): number { + const channels = hex + .replace("#", "") + .match(/.{2}/g) + ?.map((part) => Number.parseInt(part, 16) / 255) + .map((channel) => (channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4)); + if (!channels || channels.length !== 3) { + throw new Error(`invalid color: ${hex}`); + } + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; +} + +function contrastRatio(foreground: string, background: string): number { + const [lighter, darker] = [relativeLuminance(foreground), relativeLuminance(background)].toSorted( + (a, b) => b - a, + ); + return (lighter + 0.05) / (darker + 0.05); +} + describe("markdownTheme", () => { describe("highlightCode", () => { beforeEach(() => { @@ -61,6 +80,207 @@ describe("theme", () => { }); }); +describe("light background detection", () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + vi.resetModules(); + }); + + async function importThemeWithEnv(env: Record) { + vi.resetModules(); + for (const [key, value] of Object.entries(env)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + return import("./theme.js"); + } + + it("uses dark palette by default", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: undefined, + }); + expect(mod.lightMode).toBe(false); + }); + + it("selects light palette when OPENCLAW_THEME=light", async () => { + const mod = await importThemeWithEnv({ OPENCLAW_THEME: "light" }); + expect(mod.lightMode).toBe(true); + }); + + it("selects dark palette when OPENCLAW_THEME=dark", async () => { + const mod = await importThemeWithEnv({ OPENCLAW_THEME: "dark" }); + expect(mod.lightMode).toBe(false); + }); + + it("treats OPENCLAW_THEME case-insensitively", async () => { + const mod = await importThemeWithEnv({ OPENCLAW_THEME: "LiGhT" }); + expect(mod.lightMode).toBe(true); + }); + + it("detects light background from COLORFGBG", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: "0;15", + }); + expect(mod.lightMode).toBe(true); + }); + + it("treats COLORFGBG bg=7 (silver) as light", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: "0;7", + }); + expect(mod.lightMode).toBe(true); + }); + + it("treats COLORFGBG bg=8 (bright black / dark gray) as dark", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: "15;8", + }); + expect(mod.lightMode).toBe(false); + }); + + it("treats COLORFGBG bg < 7 as dark", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: "15;0", + }); + expect(mod.lightMode).toBe(false); + }); + + it("treats 256-color COLORFGBG bg=232 (near-black greyscale) as dark", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: "15;232", + }); + expect(mod.lightMode).toBe(false); + }); + + it("treats 256-color COLORFGBG bg=255 (near-white greyscale) as light", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: "0;255", + }); + expect(mod.lightMode).toBe(true); + }); + + it("treats 256-color COLORFGBG bg=231 (white cube entry) as light", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: "0;231", + }); + expect(mod.lightMode).toBe(true); + }); + + it("treats 256-color COLORFGBG bg=16 (black cube entry) as dark", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: "15;16", + }); + expect(mod.lightMode).toBe(false); + }); + + it("treats bright 256-color green backgrounds as light when dark text contrasts better", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: "15;34", + }); + expect(mod.lightMode).toBe(true); + }); + + it("treats bright 256-color cyan backgrounds as light when dark text contrasts better", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: "15;39", + }); + expect(mod.lightMode).toBe(true); + }); + + it("falls back to dark mode for invalid COLORFGBG values", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: "garbage", + }); + expect(mod.lightMode).toBe(false); + }); + + it("ignores pathological COLORFGBG values", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: "0;".repeat(40), + }); + expect(mod.lightMode).toBe(false); + }); + + it("OPENCLAW_THEME overrides COLORFGBG", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: "dark", + COLORFGBG: "0;15", + }); + expect(mod.lightMode).toBe(false); + }); + + it("keeps assistantText as identity in both modes", async () => { + const lightMod = await importThemeWithEnv({ OPENCLAW_THEME: "light" }); + const darkMod = await importThemeWithEnv({ OPENCLAW_THEME: "dark" }); + expect(lightMod.theme.assistantText("hello")).toBe("hello"); + expect(darkMod.theme.assistantText("hello")).toBe("hello"); + }); +}); + +describe("light palette accessibility", () => { + it("keeps light theme text colors at WCAG AA contrast or better", async () => { + vi.resetModules(); + process.env.OPENCLAW_THEME = "light"; + const mod = await import("./theme.js"); + const backgrounds = { + page: "#FFFFFF", + user: mod.lightPalette.userBg, + pending: mod.lightPalette.toolPendingBg, + success: mod.lightPalette.toolSuccessBg, + error: mod.lightPalette.toolErrorBg, + code: mod.lightPalette.codeBlock, + }; + + const textPairs = [ + [mod.lightPalette.text, backgrounds.page], + [mod.lightPalette.dim, backgrounds.page], + [mod.lightPalette.accent, backgrounds.page], + [mod.lightPalette.accentSoft, backgrounds.page], + [mod.lightPalette.systemText, backgrounds.page], + [mod.lightPalette.link, backgrounds.page], + [mod.lightPalette.quote, backgrounds.page], + [mod.lightPalette.error, backgrounds.page], + [mod.lightPalette.success, backgrounds.page], + [mod.lightPalette.userText, backgrounds.user], + [mod.lightPalette.dim, backgrounds.pending], + [mod.lightPalette.dim, backgrounds.success], + [mod.lightPalette.dim, backgrounds.error], + [mod.lightPalette.toolTitle, backgrounds.pending], + [mod.lightPalette.toolTitle, backgrounds.success], + [mod.lightPalette.toolTitle, backgrounds.error], + [mod.lightPalette.toolOutput, backgrounds.pending], + [mod.lightPalette.toolOutput, backgrounds.success], + [mod.lightPalette.toolOutput, backgrounds.error], + [mod.lightPalette.code, backgrounds.code], + [mod.lightPalette.border, backgrounds.page], + [mod.lightPalette.quoteBorder, backgrounds.page], + [mod.lightPalette.codeBorder, backgrounds.page], + ] as const; + + for (const [foreground, background] of textPairs) { + expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(4.5); + } + }); +}); + describe("list themes", () => { it("reuses shared select-list styles in searchable list theme", () => { expect(searchableSelectListTheme.selectedPrefix(">")).toBe(selectListTheme.selectedPrefix(">")); diff --git a/src/tui/theme/theme.ts b/src/tui/theme/theme.ts index 9b2f1ad27c7..1af4154095e 100644 --- a/src/tui/theme/theme.ts +++ b/src/tui/theme/theme.ts @@ -9,7 +9,76 @@ import { highlight, supportsLanguage } from "cli-highlight"; import type { SearchableSelectListTheme } from "../components/searchable-select-list.js"; import { createSyntaxTheme } from "./syntax-theme.js"; -const palette = { +const DARK_TEXT = "#E8E3D5"; +const LIGHT_TEXT = "#1E1E1E"; +const XTERM_LEVELS = [0, 95, 135, 175, 215, 255] as const; + +function channelToSrgb(value: number): number { + const normalized = value / 255; + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; +} + +function relativeLuminanceRgb(r: number, g: number, b: number): number { + const red = channelToSrgb(r); + const green = channelToSrgb(g); + const blue = channelToSrgb(b); + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; +} + +function relativeLuminanceHex(hex: string): number { + return relativeLuminanceRgb( + Number.parseInt(hex.slice(1, 3), 16), + Number.parseInt(hex.slice(3, 5), 16), + Number.parseInt(hex.slice(5, 7), 16), + ); +} + +function contrastRatio(background: number, foregroundHex: string): number { + const foreground = relativeLuminanceHex(foregroundHex); + const lighter = Math.max(background, foreground); + const darker = Math.min(background, foreground); + return (lighter + 0.05) / (darker + 0.05); +} + +function pickHigherContrastText(r: number, g: number, b: number): boolean { + const background = relativeLuminanceRgb(r, g, b); + return contrastRatio(background, LIGHT_TEXT) >= contrastRatio(background, DARK_TEXT); +} + +function isLightBackground(): boolean { + const explicit = process.env.OPENCLAW_THEME?.toLowerCase(); + if (explicit === "light") { + return true; + } + if (explicit === "dark") { + return false; + } + + const colorfgbg = process.env.COLORFGBG; + if (colorfgbg && colorfgbg.length <= 64) { + const sep = colorfgbg.lastIndexOf(";"); + const bg = Number.parseInt(sep >= 0 ? colorfgbg.slice(sep + 1) : colorfgbg, 10); + if (bg >= 0 && bg <= 255) { + if (bg <= 15) { + return bg === 7 || bg === 15; + } + if (bg >= 232) { + return bg >= 244; + } + const cubeIndex = bg - 16; + const bVal = XTERM_LEVELS[cubeIndex % 6]; + const gVal = XTERM_LEVELS[Math.floor(cubeIndex / 6) % 6]; + const rVal = XTERM_LEVELS[Math.floor(cubeIndex / 36)]; + return pickHigherContrastText(rVal, gVal, bVal); + } + } + return false; +} + +/** Whether the terminal has a light background. Exported for testing only. */ +export const lightMode = isLightBackground(); + +export const darkPalette = { text: "#E8E3D5", dim: "#7B7F87", accent: "#F6C453", @@ -31,12 +100,38 @@ const palette = { link: "#7DD3A5", error: "#F97066", success: "#7DD3A5", -}; +} as const; + +export const lightPalette = { + text: "#1E1E1E", + dim: "#5B6472", + accent: "#B45309", + accentSoft: "#C2410C", + border: "#5B6472", + userBg: "#F3F0E8", + userText: "#1E1E1E", + systemText: "#4B5563", + toolPendingBg: "#EFF6FF", + toolSuccessBg: "#ECFDF5", + toolErrorBg: "#FEF2F2", + toolTitle: "#B45309", + toolOutput: "#374151", + quote: "#1D4ED8", + quoteBorder: "#2563EB", + code: "#92400E", + codeBlock: "#F9FAFB", + codeBorder: "#92400E", + link: "#047857", + error: "#DC2626", + success: "#047857", +} as const; + +export const palette = lightMode ? lightPalette : darkPalette; const fg = (hex: string) => (text: string) => chalk.hex(hex)(text); const bg = (hex: string) => (text: string) => chalk.bgHex(hex)(text); -const syntaxTheme = createSyntaxTheme(fg(palette.code)); +const syntaxTheme = createSyntaxTheme(fg(palette.code), lightMode); /** * Highlight code with syntax coloring. diff --git a/src/web/auto-reply/monitor/broadcast.ts b/src/web/auto-reply/monitor/broadcast.ts index d220c9a829c..1dc51bef179 100644 --- a/src/web/auto-reply/monitor/broadcast.ts +++ b/src/web/auto-reply/monitor/broadcast.ts @@ -11,6 +11,39 @@ import { whatsappInboundLog } from "../loggers.js"; import type { WebInboundMsg } from "../types.js"; import type { GroupHistoryEntry } from "./process-message.js"; +function buildBroadcastRouteKeys(params: { + cfg: ReturnType; + msg: WebInboundMsg; + route: ReturnType; + peerId: string; + agentId: string; +}) { + const sessionKey = buildAgentSessionKey({ + agentId: params.agentId, + channel: "whatsapp", + accountId: params.route.accountId, + peer: { + kind: params.msg.chatType === "group" ? "group" : "direct", + id: params.peerId, + }, + dmScope: params.cfg.session?.dmScope, + identityLinks: params.cfg.session?.identityLinks, + }); + const mainSessionKey = buildAgentMainSessionKey({ + agentId: params.agentId, + mainKey: DEFAULT_MAIN_KEY, + }); + + return { + sessionKey, + mainSessionKey, + lastRoutePolicy: deriveLastRoutePolicy({ + sessionKey, + mainSessionKey, + }), + }; +} + export async function maybeBroadcastMessage(params: { cfg: ReturnType; msg: WebInboundMsg; @@ -52,41 +85,17 @@ export async function maybeBroadcastMessage(params: { whatsappInboundLog.warn(`Broadcast agent ${agentId} not found in agents.list; skipping`); return false; } + const routeKeys = buildBroadcastRouteKeys({ + cfg: params.cfg, + msg: params.msg, + route: params.route, + peerId: params.peerId, + agentId: normalizedAgentId, + }); const agentRoute = { ...params.route, agentId: normalizedAgentId, - sessionKey: buildAgentSessionKey({ - agentId: normalizedAgentId, - channel: "whatsapp", - accountId: params.route.accountId, - peer: { - kind: params.msg.chatType === "group" ? "group" : "direct", - id: params.peerId, - }, - dmScope: params.cfg.session?.dmScope, - identityLinks: params.cfg.session?.identityLinks, - }), - mainSessionKey: buildAgentMainSessionKey({ - agentId: normalizedAgentId, - mainKey: DEFAULT_MAIN_KEY, - }), - lastRoutePolicy: deriveLastRoutePolicy({ - sessionKey: buildAgentSessionKey({ - agentId: normalizedAgentId, - channel: "whatsapp", - accountId: params.route.accountId, - peer: { - kind: params.msg.chatType === "group" ? "group" : "direct", - id: params.peerId, - }, - dmScope: params.cfg.session?.dmScope, - identityLinks: params.cfg.session?.identityLinks, - }), - mainSessionKey: buildAgentMainSessionKey({ - agentId: normalizedAgentId, - mainKey: DEFAULT_MAIN_KEY, - }), - }), + ...routeKeys, }; try {