mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-17 19:48:59 +08:00
Compare commits
50 Commits
docs/add-b
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63f82e02b8 | ||
|
|
3da8882a02 | ||
|
|
b2b99f0325 | ||
|
|
a3dc4b5a57 | ||
|
|
211f68f8ad | ||
|
|
3f3f66a5f7 | ||
|
|
bd1fe4d8b4 | ||
|
|
3ea3a1c0ca | ||
|
|
da6592b681 | ||
|
|
abb8f63107 | ||
|
|
e806c479f5 | ||
|
|
38543d8196 | ||
|
|
7dfd77abeb | ||
|
|
5889a2e98e | ||
|
|
09acbe6528 | ||
|
|
64dd23eade | ||
|
|
dadd7f99cd | ||
|
|
0ecfd37b44 | ||
|
|
a075baba84 | ||
|
|
a6131438ea | ||
|
|
92726d9863 | ||
|
|
3d3e8fe78c | ||
|
|
3b7a72bffb | ||
|
|
37e0b01684 | ||
|
|
bd0e6a6efd | ||
|
|
6b338dd283 | ||
|
|
9d467d1620 | ||
|
|
d3111fbbcb | ||
|
|
e883d0b556 | ||
|
|
436ae8a07c | ||
|
|
0692f71c6f | ||
|
|
bcb0d1b8b4 | ||
|
|
dcdce83da7 | ||
|
|
dfa3605bee | ||
|
|
4bfa800cc7 | ||
|
|
9914b48c57 | ||
|
|
4d904e7b7d | ||
|
|
7b58507224 | ||
|
|
c1f6edf48b | ||
|
|
8b2f40f5f6 | ||
|
|
f9c220e261 | ||
|
|
75602014db | ||
|
|
3cf75f760c | ||
|
|
ae39a152d8 | ||
|
|
efa1204183 | ||
|
|
9a4610c641 | ||
|
|
c0a988f692 | ||
|
|
641e1bacb4 | ||
|
|
0252bdc837 | ||
|
|
885199dcaa |
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
17
CHANGELOG.md
17
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,7 +33,17 @@ 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.
|
||||
|
||||
## 2026.3.7
|
||||
|
||||
@@ -113,6 +129,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.
|
||||
|
||||
53
Dockerfile
53
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[..<capabilityStart]) + capability + String(scopedUrl[capabilityEnd...])
|
||||
}
|
||||
|
||||
func canonicalizeCanvasHostUrl(raw: String?, activeURL: URL?) -> 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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -620,6 +620,8 @@ openclaw cron run <jobId>
|
||||
openclaw cron run <jobId> --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 <jobId>` or the `cron.runs` gateway method to inspect the eventual finished entry.
|
||||
|
||||
Edit an existing job (patch fields):
|
||||
|
||||
```bash
|
||||
|
||||
76
docs/cli/backup.md
Normal file
76
docs/cli/backup.md
Normal file
@@ -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 <archive>` 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`.
|
||||
@@ -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 <job-id>` to follow the eventual outcome.
|
||||
|
||||
Note: retention/pruning is controlled in config:
|
||||
|
||||
- `cron.sessionRetention` (default `24h`) prunes completed isolated run sessions.
|
||||
|
||||
@@ -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 <name>] <command>
|
||||
completion
|
||||
doctor
|
||||
dashboard
|
||||
backup
|
||||
create
|
||||
verify
|
||||
security
|
||||
audit
|
||||
secrets
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
</Accordion>
|
||||
|
||||
242
docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md
Normal file
242
docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md
Normal file
@@ -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.<name>.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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<Api>;
|
||||
}): Model<Api> {
|
||||
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<Api>;
|
||||
}
|
||||
|
||||
function normalizeResolvedModel(params: { provider: string; model: Model<Api> }): Model<Api> {
|
||||
return normalizeModelCompat(normalizeOpenAICodexTransport(params));
|
||||
}
|
||||
|
||||
export { buildModelAliasLines };
|
||||
|
||||
function resolveConfiguredProviderConfig(
|
||||
@@ -145,13 +201,14 @@ export function resolveModelWithRegistry(params: {
|
||||
const model = modelRegistry.find(provider, modelId) as Model<Api> | 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<Api>);
|
||||
return normalizeResolvedModel({ provider, model: inlineMatch as Model<Api> });
|
||||
}
|
||||
|
||||
// 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<Api>);
|
||||
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<Api>,
|
||||
});
|
||||
}
|
||||
|
||||
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<Api>);
|
||||
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<Api>,
|
||||
});
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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<typeof loadConfig>): 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,17 @@ function stripTargetIdFromActRequest(
|
||||
return retryRequest as Parameters<typeof browserAct>[1];
|
||||
}
|
||||
|
||||
function canRetryChromeActWithoutTargetId(request: Parameters<typeof browserAct>[1]): boolean {
|
||||
const typedRequest = request as Partial<Record<"kind" | "action", unknown>>;
|
||||
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.",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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")}`);
|
||||
|
||||
@@ -320,6 +320,42 @@ describe("cdp", () => {
|
||||
expect(normalized).toBe("wss://user:pass@example.com/devtools/browser/ABC?token=abc");
|
||||
});
|
||||
|
||||
it("rewrites 0.0.0.0 wildcard bind address to remote CDP host", () => {
|
||||
const normalized = normalizeCdpWsUrl(
|
||||
"ws://0.0.0.0:3000/devtools/browser/ABC",
|
||||
"http://192.168.1.202:18850?token=secret",
|
||||
);
|
||||
expect(normalized).toBe("ws://192.168.1.202:18850/devtools/browser/ABC?token=secret");
|
||||
});
|
||||
|
||||
it("rewrites :: wildcard bind address to remote CDP host", () => {
|
||||
const normalized = normalizeCdpWsUrl(
|
||||
"ws://[::]:3000/devtools/browser/ABC",
|
||||
"http://192.168.1.202:18850",
|
||||
);
|
||||
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",
|
||||
|
||||
@@ -19,7 +19,11 @@ export {
|
||||
export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
|
||||
const ws = new URL(wsUrl);
|
||||
const cdp = new URL(cdpUrl);
|
||||
if (isLoopbackHost(ws.hostname) && !isLoopbackHost(cdp.hostname)) {
|
||||
// Treat 0.0.0.0 and :: as wildcard bind addresses that need rewriting.
|
||||
// Containerized browsers (e.g. browserless) report ws://0.0.0.0:<internal-port>
|
||||
// in /json/version — these must be rewritten to the external cdpUrl host:port.
|
||||
const isWildcardBind = ws.hostname === "0.0.0.0" || ws.hostname === "[::]";
|
||||
if ((isLoopbackHost(ws.hostname) || isWildcardBind) && !isLoopbackHost(cdp.hostname)) {
|
||||
ws.hostname = cdp.hostname;
|
||||
const cdpPort = cdp.port || (cdp.protocol === "https:" ? "443" : "80");
|
||||
if (cdpPort) {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -36,6 +36,7 @@ export type ResolvedBrowserConfig = {
|
||||
profiles: Record<string, BrowserProfileConfig>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
49
src/browser/extension-relay.bind-host.test.ts
Normal file
49
src/browser/extension-relay.bind-host.test.ts
Normal file
@@ -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<typeof captureEnv>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1168,4 +1168,57 @@ describe("chrome extension relay server", () => {
|
||||
);
|
||||
await new Promise<void>((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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<string,
|
||||
|
||||
export async function ensureChromeExtensionRelayServer(opts: {
|
||||
cdpUrl: string;
|
||||
bindHost?: string;
|
||||
}): Promise<ChromeExtensionRelayServer> {
|
||||
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<void>((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`,
|
||||
|
||||
119
src/browser/pw-session.connections.test.ts
Normal file
119
src/browser/pw-session.connections.test.ts
Normal file
@@ -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<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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<import("playwright-core").Browser>((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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ConnectedBrowser> | null = null;
|
||||
const cachedByCdpUrl = new Map<string, ConnectedBrowser>();
|
||||
const connectingByCdpUrl = new Map<string, Promise<ConnectedBrowser>>();
|
||||
|
||||
function normalizeCdpUrl(raw: string) {
|
||||
return raw.replace(/\/$/, "");
|
||||
@@ -328,9 +328,11 @@ function observeBrowser(browser: Browser) {
|
||||
|
||||
async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
|
||||
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<ConnectedBrowser> {
|
||||
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<ConnectedBrowser> {
|
||||
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<Page[]> {
|
||||
@@ -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<void> {
|
||||
const cur = cached;
|
||||
cached = null;
|
||||
connecting = null;
|
||||
if (!cur) {
|
||||
export async function closePlaywrightBrowserConnection(opts?: { cdpUrl?: string }): Promise<void> {
|
||||
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<void> {
|
||||
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(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}.`,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,10 +16,10 @@ type ResetOps = {
|
||||
resetProfile: () => Promise<{ moved: boolean; from: string; to?: string }>;
|
||||
};
|
||||
|
||||
async function closePlaywrightBrowserConnection(): Promise<void> {
|
||||
async function closePlaywrightBrowserConnectionForProfile(cdpUrl?: string): Promise<void> {
|
||||
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 };
|
||||
|
||||
@@ -32,15 +32,28 @@ export function createProfileSelectionOps({
|
||||
const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => {
|
||||
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");
|
||||
|
||||
@@ -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)}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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<typeof import("./config-guard.js")> | undefined;
|
||||
let pluginRegistryModulePromise: Promise<typeof import("../plugin-registry.js")> | undefined;
|
||||
|
||||
104
src/cli/program/register.backup.test.ts
Normal file
104
src/cli/program/register.backup.test.ts
Normal file
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
92
src/cli/program/register.backup.ts
Normal file
92
src/cli/program/register.backup.ts
Normal file
@@ -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 <path>", "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 <archive>")
|
||||
.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),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
254
src/commands/backup-shared.ts
Normal file
254
src/commands/backup-shared.ts
Normal file
@@ -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<string> {
|
||||
try {
|
||||
return await fs.realpath(targetPath);
|
||||
} catch {
|
||||
return path.resolve(targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveBackupPlanFromDisk(
|
||||
params: {
|
||||
includeWorkspace?: boolean;
|
||||
onlyConfig?: boolean;
|
||||
nowMs?: number;
|
||||
} = {},
|
||||
): Promise<BackupPlan> {
|
||||
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<Pick<BackupAssetCandidate, "kind" | "sourcePath">> = [
|
||||
{ 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<string>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
392
src/commands/backup-verify.test.ts
Normal file
392
src/commands/backup-verify.test.ts
Normal file
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
324
src/commands/backup-verify.ts
Normal file
324
src/commands/backup-verify.ts
Normal file
@@ -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<string, unknown> {
|
||||
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<string[]> {
|
||||
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<string> {
|
||||
let manifestContentPromise: Promise<string> | undefined;
|
||||
await tar.t({
|
||||
file: params.archivePath,
|
||||
gzip: true,
|
||||
onentry: (entry) => {
|
||||
if (entry.path !== params.manifestEntryPath) {
|
||||
entry.resume();
|
||||
return;
|
||||
}
|
||||
|
||||
manifestContentPromise = new Promise<string>((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<string>): 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<string>();
|
||||
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<BackupVerifyResult> {
|
||||
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;
|
||||
}
|
||||
133
src/commands/backup.atomic.test.ts
Normal file
133
src/commands/backup.atomic.test.ts
Normal file
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
434
src/commands/backup.test.ts
Normal file
434
src/commands/backup.test.ts
Normal file
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
382
src/commands/backup.ts
Normal file
382
src/commands/backup.ts
Normal file
@@ -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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<BackupCreateResult> {
|
||||
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;
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<typeof import("./list.registry.js")>();
|
||||
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<Record<string, unknown>>) : [],
|
||||
);
|
||||
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<Record<string, unknown>>) : [],
|
||||
);
|
||||
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 () => {
|
||||
|
||||
@@ -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<string>();
|
||||
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;
|
||||
|
||||
69
src/commands/reset.test.ts
Normal file
69
src/commands/reset.test.ts
Normal file
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
66
src/commands/uninstall.test.ts
Normal file
66
src/commands/uninstall.test.ts
Normal file
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user