Compare commits

..

15 Commits

Author SHA1 Message Date
Peter Steinberger
55300ea850 fix: preserve loopback ws cdp tab ops (#31085) (thanks @shrey150) 2026-03-08 18:47:48 +00:00
Shrey Pandya
8e5f702adf style(browser): fix oxfmt formatting in config.ts
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
Shrey Pandya
56d2662f9d chore: remove vendor-specific references from code comments 2026-03-08 18:41:49 +00:00
Shrey Pandya
e2ecd0a321 fix(browser): preserve wss:// cdpUrl in legacy default profile resolution 2026-03-08 18:41:49 +00:00
shrey150
7fce53976e fix(browser): update existing tests for ws/wss protocol support
Two pre-existing tests still expected ws:// URLs to be rejected by
parseHttpUrl, which now accepts them. Switch the invalid-protocol
fixture to ftp:// and tighten the assertion to match the full
"must be http(s) or ws(s)" error message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
shrey150
1cc021251e test+docs: comprehensive coverage and generic framing
- Add 12 new tests covering: isWebSocketUrl detection, parseHttpUrl WSS
  acceptance/rejection, direct WS target creation with query params,
  SSRF enforcement on WS URLs, WS reachability probing bypasses HTTP
- Reframe docs section as generic "Direct WebSocket CDP providers" with
  Browserbase as one example — any WSS-based provider works
- Update security tips to mention WSS alongside HTTPS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
shrey150
a8ad7e42af feat(browser): support direct WebSocket CDP URLs for Browserbase
Browserbase uses direct WebSocket connections (wss://) rather than the
standard HTTP-based /json/version CDP discovery flow used by Browserless.
This change teaches the browser tool to accept ws:// and wss:// URLs as
cdpUrl values: when a WebSocket URL is detected, OpenClaw connects
directly instead of attempting HTTP discovery.

Changes:
- config.ts: accept ws:// and wss:// in cdpUrl validation
- cdp.helpers.ts: add isWebSocketUrl() helper
- cdp.ts: skip /json/version when cdpUrl is already a WebSocket URL
- chrome.ts: probe WSS endpoints via WebSocket handshake instead of HTTP
- cdp.test.ts: add test for direct WebSocket target creation
- docs/tools/browser.md: update Browserbase section with correct URL
  format and notes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
Shrey Pandya
42320281c6 docs: simplify Browserbase section, drop pricing details
Restore platform-level feature description (CAPTCHA solving, stealth
mode, proxies) without plan-specific pricing gating. Keep free tier
note brief.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
Shrey Pandya
f60168b735 docs: fact-check Browserbase section against official docs
- Fix CAPTCHA/stealth/proxy claims: these are Developer plan+ only,
  not available on free tier
- Fix free tier limits: 1 browser hour, 15-min session duration
  (not "60 minutes of monthly usage")
- Add link to pricing page for paid plan details
- Simplify structure to match Browserless section format
- Remove sub-headings to match Browserless section style

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
Shrey Pandya
0a5701f468 docs: restore direct wss://connect.browserbase.com URL
Browserbase exposes a direct WebSocket connect endpoint that
auto-creates a session, similar to how Browserless works. Simplified
the section to use this static URL pattern instead of requiring
manual session creation via the API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
Shrey Pandya
07f65838ed docs: fix Browserbase section to match official docs
Browserbase requires creating a session via their API to get a CDP
connect URL, unlike Browserless which uses a static endpoint. Updated
to show the correct curl-based session creation flow, removed
unverified static WebSocket URL, and added the 5-minute connect
timeout note from official docs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
Shrey Pandya
4d326271f0 docs: fix duplicate heading lint error
Rename "Configuration" sub-heading to "Profile setup" to avoid
MD024/no-duplicate-heading conflict with the existing top-level
"Configuration" heading.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
Shrey Pandya
eb4ff4464e docs: add Browserbase as hosted remote CDP option
Add Browserbase documentation section alongside the existing Browserless
section in the browser docs. Includes signup instructions, CDP connection
configuration, and environment variable setup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
Shrey Pandya
cbcf9d0811 Revert "docs: add Browserbase as hosted remote CDP option"
This reverts commit c469657c97848c7a3e1e5135bf4ce735d07d6614.
2026-03-08 18:41:49 +00:00
Shrey Pandya
83a854bfa0 docs: add Browserbase as hosted remote CDP option
Add Browserbase documentation section alongside the existing Browserless
section in the browser docs. Includes signup instructions, CDP connection
configuration, and environment variable setup for both English and Chinese
(zh-CN) translations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:41:48 +00:00
291 changed files with 2380 additions and 12001 deletions

View File

@@ -267,12 +267,6 @@ 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:

View File

@@ -109,8 +109,6 @@ jobs:
labels: ${{ steps.labels.outputs.value }}
provenance: false
push: true
cache-from: type=gha,scope=docker-release-amd64
cache-to: type=gha,mode=max,scope=docker-release-amd64
- name: Build and push amd64 slim image
id: build-slim
@@ -124,8 +122,6 @@ jobs:
labels: ${{ steps.labels.outputs.value }}
provenance: false
push: true
cache-from: type=gha,scope=docker-release-amd64
cache-to: type=gha,mode=max,scope=docker-release-amd64
# Build arm64 images (default + slim share the build stage cache)
build-arm64:
@@ -214,8 +210,6 @@ jobs:
labels: ${{ steps.labels.outputs.value }}
provenance: false
push: true
cache-from: type=gha,scope=docker-release-arm64
cache-to: type=gha,mode=max,scope=docker-release-arm64
- name: Build and push arm64 slim image
id: build-slim
@@ -229,8 +223,6 @@ jobs:
labels: ${{ steps.labels.outputs.value }}
provenance: false
push: true
cache-from: type=gha,scope=docker-release-arm64
cache-to: type=gha,mode=max,scope=docker-release-arm64
# Create multi-platform manifests
create-manifest:

View File

@@ -69,8 +69,6 @@ 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

View File

@@ -152,8 +152,7 @@
"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\": \"(resolved|normalized|legacy)-key\"(,)?"
"ap[i]Key: \"A[I]za\\.\\.\\.\","
]
},
{
@@ -227,7 +226,7 @@
"filename": "apps/macos/Sources/OpenClawProtocol/GatewayModels.swift",
"hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd",
"is_verified": false,
"line_number": 1763
"line_number": 1749
}
],
"apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift": [
@@ -252,7 +251,7 @@
"filename": "apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift",
"hashed_secret": "19dad5cecb110281417d1db56b60e1b006d55bb4",
"is_verified": false,
"line_number": 81
"line_number": 66
}
],
"apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift": [
@@ -288,7 +287,7 @@
"filename": "apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift",
"hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd",
"is_verified": false,
"line_number": 1763
"line_number": 1749
}
],
"docs/.i18n/zh-CN.tm.jsonl": [
@@ -9796,63 +9795,63 @@
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e",
"is_verified": false,
"line_number": 1614
"line_number": 1612
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "bde4db9b4c3be4049adc3b9a69851d7c35119770",
"is_verified": false,
"line_number": 1630
"line_number": 1628
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "7f8aaf142ce0552c260f2e546dda43ddd7c9aef3",
"is_verified": false,
"line_number": 1817
"line_number": 1815
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27",
"is_verified": false,
"line_number": 1990
"line_number": 1988
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0",
"is_verified": false,
"line_number": 2046
"line_number": 2044
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
"is_verified": false,
"line_number": 2278
"line_number": 2276
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6",
"is_verified": false,
"line_number": 2408
"line_number": 2404
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281",
"is_verified": false,
"line_number": 2661
"line_number": 2657
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25",
"is_verified": false,
"line_number": 2663
"line_number": 2659
}
],
"docs/gateway/configuration.md": [
@@ -11482,7 +11481,7 @@
"filename": "src/agents/models-config.e2e-harness.ts",
"hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d",
"is_verified": false,
"line_number": 157
"line_number": 131
}
],
"src/agents/models-config.fills-missing-provider-apikey-from-env-var.e2e.test.ts": [
@@ -11516,14 +11515,14 @@
"filename": "src/agents/models-config.providers.nvidia.test.ts",
"hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f",
"is_verified": false,
"line_number": 14
"line_number": 13
},
{
"type": "Secret Keyword",
"filename": "src/agents/models-config.providers.nvidia.test.ts",
"hashed_secret": "be1a7be9d4d5af417882b267f4db6dddc08507bd",
"is_verified": false,
"line_number": 23
"line_number": 22
}
],
"src/agents/models-config.providers.ollama.e2e.test.ts": [
@@ -11584,7 +11583,7 @@
"filename": "src/agents/pi-embedded-runner/model.ts",
"hashed_secret": "e774aaeac31c6272107ba89080295e277050fa7c",
"is_verified": false,
"line_number": 279
"line_number": 267
}
],
"src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts": [
@@ -11747,7 +11746,7 @@
"filename": "src/auto-reply/status.test.ts",
"hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f",
"is_verified": false,
"line_number": 37
"line_number": 36
}
],
"src/browser/bridge-server.auth.test.ts": [
@@ -11765,14 +11764,14 @@
"filename": "src/browser/browser-utils.test.ts",
"hashed_secret": "4e126c049580d66ca1549fa534d95a7263f27f46",
"is_verified": false,
"line_number": 47
"line_number": 43
},
{
"type": "Basic Auth Credentials",
"filename": "src/browser/browser-utils.test.ts",
"hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684",
"is_verified": false,
"line_number": 171
"line_number": 164
}
],
"src/browser/cdp.test.ts": [
@@ -11781,7 +11780,7 @@
"filename": "src/browser/cdp.test.ts",
"hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684",
"is_verified": false,
"line_number": 318
"line_number": 243
}
],
"src/channels/plugins/plugins-channel.test.ts": [
@@ -12101,21 +12100,21 @@
"filename": "src/config/config.env-vars.test.ts",
"hashed_secret": "a24ef9c1a27cac44823571ceef2e8262718eee36",
"is_verified": false,
"line_number": 17
"line_number": 13
},
{
"type": "Secret Keyword",
"filename": "src/config/config.env-vars.test.ts",
"hashed_secret": "29d5f92e9ee44d4854d6dfaeefc3dc27d779fdf3",
"is_verified": false,
"line_number": 23
"line_number": 19
},
{
"type": "Secret Keyword",
"filename": "src/config/config.env-vars.test.ts",
"hashed_secret": "1672b6a1e7956c6a70f45d699aa42a351b1f8b80",
"is_verified": false,
"line_number": 31
"line_number": 27
}
],
"src/config/config.irc.test.ts": [
@@ -12336,14 +12335,14 @@
"filename": "src/config/schema.help.ts",
"hashed_secret": "9f4cda226d3868676ac7f86f59e4190eb94bd208",
"is_verified": false,
"line_number": 653
"line_number": 651
},
{
"type": "Secret Keyword",
"filename": "src/config/schema.help.ts",
"hashed_secret": "01822c8bbf6a8b136944b14182cb885100ec2eae",
"is_verified": false,
"line_number": 686
"line_number": 684
}
],
"src/config/schema.irc.ts": [
@@ -12382,14 +12381,14 @@
"filename": "src/config/schema.labels.ts",
"hashed_secret": "e73c9fcad85cd4eecc74181ec4bdb31064d68439",
"is_verified": false,
"line_number": 217
"line_number": 216
},
{
"type": "Secret Keyword",
"filename": "src/config/schema.labels.ts",
"hashed_secret": "2eda7cd978f39eebec3bf03e4410a40e14167fff",
"is_verified": false,
"line_number": 326
"line_number": 325
}
],
"src/config/slack-http-config.test.ts": [
@@ -13035,5 +13034,5 @@
}
]
},
"generated_at": "2026-03-09T01:11:58Z"
"generated_at": "2026-03-08T18:30:57Z"
}

View File

@@ -6,23 +6,14 @@ Docs: https://docs.openclaw.ai
### Changes
- Extensions/ACPX tests: move the shared runtime fixture helper from `src/runtime-internals/` to `src/test-utils/` so the test-only helper no longer looks like shipped runtime code.
- TUI: infer the active agent from the current workspace when launched inside a configured agent workspace, while preserving explicit `agent:` session targets. (#39591) thanks @arceus77-7.
- Tools/Brave web search: add opt-in `tools.web.search.brave.mode: "llm-context"` so `web_search` can call Brave's LLM Context endpoint and return extracted grounding snippets with source metadata, plus config/docs/test coverage. (#33383) Thanks @thirumaleshp.
- 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.
- ACP/Provenance: add optional ACP ingress provenance metadata and visible receipt injection (`openclaw acp --provenance off|meta|meta+receipt`) so OpenClaw agents can retain and report ACP-origin context with session trace IDs. (#40473) thanks @mbelinky.
- Tools/web search: alphabetize provider ordering across runtime selection, onboarding/configure pickers, and config metadata, so provider lists stay neutral and multi-key auto-detect now prefers Grok before Kimi. (#40259) thanks @kesku.
### 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.
@@ -36,24 +27,7 @@ Docs: https://docs.openclaw.ai
- Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera.
- Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii.
- ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky.
- Agents/openai-codex: normalize `gpt-5.4` fallback transport back to `openai-codex-responses` on `chatgpt.com/backend-api` when config drifts to the generic OpenAI responses endpoint. (#38736) Thanks @0xsline.
- Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for `/json/*` tab operations so local `ws://` / `wss://` profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150.
- Browser/CDP: rewrite wildcard `ws://0.0.0.0` and `ws://[::]` debugger URLs from remote `/json/version` responses back to the external CDP host/port, fixing Browserless-style container endpoints. (#17760) Thanks @joeharouni.
- Browser/extension relay: wait briefly for a previously attached Chrome tab to reappear after transient relay drops before failing with `tab not found`, reducing noisy reconnect flakes. (#32461) Thanks @AaronWander.
- Browser/extension relay: add `browser.relayBindHost` so the Chrome relay can bind to an explicit non-loopback address for WSL2 and other cross-namespace setups, while preserving loopback-only defaults. (#39364) Thanks @mvanhorn.
- Docs/browser: add a layered WSL2 + Windows remote Chrome CDP troubleshooting guide, including Control UI origin pitfalls and extension-relay bind-address guidance. (#39407) Thanks @Owlock.
- Context engine registry/bundled builds: share the registry state through a `globalThis` singleton so duplicated bundled module copies can resolve engines registered by each other at runtime, with regression coverage for duplicate-module imports. (#40115) thanks @jalehman.
- macOS/Tailscale gateway discovery: keep Tailscale Serve probing alive when other remote gateways are already discovered, prefer direct transport for resolved `.ts.net` and Tailscale Serve gateways, and set `TERM=dumb` for GUI-launched Tailscale CLI discovery. (#40167) thanks @ngutman.
- Podman/setup: fix `cannot chdir: Permission denied` in `run_as_user` when `setup-podman.sh` is invoked from a directory the target user cannot access, by wrapping user-switch calls in a subshell that cd's to `/tmp` with `/` fallback. (#39435) Thanks @langdon and @jlcbk.
- Podman/SELinux: auto-detect SELinux enforcing/permissive mode and add `:Z` relabel to bind mounts in `run-openclaw-podman.sh` and the Quadlet template, fixing `EACCES` on Fedora/RHEL hosts. Supports `OPENCLAW_BIND_MOUNT_OPTIONS` override. (#39449) Thanks @langdon and @githubbzxs.
- TUI/theme: detect light terminal backgrounds via `COLORFGBG` and pick a WCAG AA-compliant light palette, with `OPENCLAW_THEME=light|dark` override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc.
- Agents/context-engine plugins: bootstrap runtime plugins once at embedded-run, compaction, and subagent boundaries so plugin-provided context engines and hooks load from the active workspace before runtime resolution. (#40232)
- Config/runtime snapshots: keep secrets-runtime-resolved config and auth-profile snapshots intact after config writes so follow-up reads still see file-backed secret values while picking up the persisted config update. (#37313) thanks @bbblending.
- Gateway/Control UI: resolve bundled dashboard assets through symlinked global wrappers and auto-detected package roots, while keeping configured and custom roots on the strict hardlink boundary. (#40385) Thanks @LarytheLord.
- Docs/Changelog: correct the contributor credit for the bundled Control UI global-install fix to @LarytheLord. (#40420) Thanks @velvet-shark.
- Models/openai-codex GPT-5.4 forward-compat: use the GPT-5.4 1,050,000-token context window and 128,000 max tokens for `openai-codex/gpt-5.4` instead of inheriting stale legacy Codex limits in resolver fallbacks and model listing. (#37876) thanks @yuweuii.
- Telegram/media downloads: time out only stalled body reads so polling recovers from hung file downloads without aborting slow downloads that are still streaming data. (#40098) thanks @tysoncung.
- Telegram/DM routing: dedupe inbound Telegram DMs per agent instead of per session key so the same DM cannot trigger duplicate replies when both `agent:main:main` and `agent:main:telegram:direct:<id>` resolve for one agent. Fixes #40005. Supersedes #40116. (#40519) thanks @obviyus.
## 2026.3.7
@@ -139,7 +113,6 @@ 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.
@@ -769,7 +742,6 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/macOS restart: remove self-issued `launchctl kickstart -k` from launchd supervised restart path to prevent race with launchd's async bootout state machine that permanently unloads the LaunchAgent. With `ThrottleInterval=1` (current default), `exit(0)` + `KeepAlive=true` restarts the service within ~1s without the race condition. (#39760) Landed from contributor PR #39763 by @daymade. Thanks @daymade.
- Plugin SDK/bundled subpath contracts: add regression coverage for newly routed bundled-plugin SDK exports so BlueBubbles, Mattermost, Nextcloud Talk, and Twitch subpath symbols stay pinned during future plugin-sdk cleanup. (#39638)
- Exec/system.run env sanitization: block dangerous override-only env pivots such as `GIT_SSH_COMMAND`, editor/pager hooks, and `GIT_CONFIG_` / `NPM_CONFIG_` override prefixes so allowlisted tools cannot smuggle helper command execution through subprocess environment overrides. Thanks @tdjackey and @SnailSploit for reporting.
- Network/fetch guard redirect auth stripping: switch cross-origin redirect handling in `fetchWithSsrFGuard` from a narrow sensitive-header denylist to a safe-header allowlist so custom auth headers like `X-Api-Key` and `Private-Token` no longer leak on origin changes. Thanks @Rickidevs for reporting.
- Security/Sandbox media reads: eliminate sandbox media TOCTOU symlink-retarget escapes by enforcing root-scoped boundary-safe reads at attachment/image load time and consolidating shared safe-read helpers across sandbox media callsites. This ships in the next npm release. Thanks @tdjackey for reporting.

View File

@@ -1,5 +1,3 @@
# syntax=docker/dockerfile:1.7
# Opt-in extension dependencies at build time (space-separated directory names).
# Example: docker build --build-arg OPENCLAW_EXTENSIONS="diagnostics-otel matrix" .
#
@@ -50,25 +48,16 @@ WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY ui/package.json ./ui/package.json
COPY patches ./patches
COPY scripts ./scripts
COPY --from=ext-deps /out/ ./extensions/
# Reduce OOM risk on low-memory hosts during dependency installation.
# Docker builds on small VMs may otherwise fail with "Killed" (exit 137).
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile
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.
@@ -78,17 +67,11 @@ 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:docker
RUN pnpm build
# 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
@@ -119,39 +102,36 @@ WORKDIR /app
# Install system utilities present in bookworm but missing in bookworm-slim.
# On the full bookworm image these are already installed (apt-get is a no-op).
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
apt-get update && \
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
procps hostname curl git openssl
procps hostname curl git openssl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
RUN chown node:node /app
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
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
# 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"
# 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
# Install additional system packages needed by your skills or extensions.
# Example: docker build --build-arg OPENCLAW_DOCKER_APT_PACKAGES="python3 wget" .
ARG OPENCLAW_DOCKER_APT_PACKAGES=""
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES; \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
fi
# Optionally install Chromium and Xvfb for browser automation.
@@ -159,15 +139,15 @@ RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,shar
# Adds ~300MB but eliminates the 60-90s Playwright install on every container start.
# Must run after node_modules COPY so playwright-core is available.
ARG OPENCLAW_INSTALL_BROWSER=""
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \
RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends xvfb && \
mkdir -p /home/node/.cache/ms-playwright && \
PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright \
node /app/node_modules/playwright-core/cli.js install --with-deps chromium && \
chown -R node:node /home/node/.cache/ms-playwright; \
chown -R node:node /home/node/.cache/ms-playwright && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
fi
# Optionally install Docker CLI for sandbox container management.
@@ -176,9 +156,7 @@ RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,shar
# Required for agents.defaults.sandbox to function in Docker deployments.
ARG OPENCLAW_INSTALL_DOCKER_CLI=""
ARG OPENCLAW_DOCKER_GPG_FINGERPRINT="9DC858229FC7DD38854AE2D88D81803C0EBFCD88"
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \
RUN if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates curl gnupg && \
@@ -199,9 +177,20 @@ RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,shar
"$(dpkg --print-architecture)" > /etc/apt/sources.list.d/docker.list && \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
docker-ce-cli docker-compose-plugin; \
docker-ce-cli docker-compose-plugin && \
apt-get clean && \
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

View File

@@ -1,12 +1,8 @@
# syntax=docker/dockerfile:1.7
FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe
ENV DEBIAN_FRONTEND=noninteractive
RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
apt-get update \
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
@@ -14,7 +10,8 @@ RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/
git \
jq \
python3 \
ripgrep
ripgrep \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /bin/bash sandbox
USER sandbox

View File

@@ -1,12 +1,8 @@
# syntax=docker/dockerfile:1.7
FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe
ENV DEBIAN_FRONTEND=noninteractive
RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
apt-get update \
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
@@ -21,9 +17,11 @@ RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/
socat \
websockify \
x11vnc \
xvfb
xvfb \
&& rm -rf /var/lib/apt/lists/*
COPY --chmod=755 scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser
COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser
RUN chmod +x /usr/local/bin/openclaw-sandbox-browser
RUN useradd --create-home --shell /bin/bash sandbox
USER sandbox

View File

@@ -1,5 +1,3 @@
# syntax=docker/dockerfile:1.7
ARG BASE_IMAGE=openclaw-sandbox:bookworm-slim
FROM ${BASE_IMAGE}
@@ -21,10 +19,9 @@ ENV HOMEBREW_CELLAR=${BREW_INSTALL_DIR}/Cellar
ENV HOMEBREW_REPOSITORY=${BREW_INSTALL_DIR}/Homebrew
ENV PATH=${BUN_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/sbin:${PATH}
RUN --mount=type=cache,id=openclaw-sandbox-common-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-sandbox-common-apt-lists,target=/var/lib/apt,sharing=locked \
apt-get update \
&& apt-get install -y --no-install-recommends ${PACKAGES}
RUN apt-get update \
&& apt-get install -y --no-install-recommends ${PACKAGES} \
&& rm -rf /var/lib/apt/lists/*
RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi
@@ -45,3 +42,4 @@ fi
# Default is sandbox, but allow BASE_IMAGE overrides to select another final user.
USER ${FINAL_USER}

View File

@@ -1,24 +1,9 @@
import Foundation
import Network
import OpenClawKit
enum A2UIReadyState {
case ready(String)
case hostNotConfigured
case hostUnavailable
}
import os
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()
}
@@ -34,14 +19,22 @@ 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 {
if let canvasUrl = await self.resolveCanvasHostURLWithCapabilityRefresh(),
let url = URL(string: canvasUrl),
// 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),
await Self.probeTCP(url: url, timeoutSeconds: 2.5)
{
self.screen.navigate(to: canvasUrl)
self.lastAutoA2uiURL = canvasUrl
self.screen.navigate(to: a2uiUrl)
self.lastAutoA2uiURL = a2uiUrl
} else {
self.lastAutoA2uiURL = nil
self.screen.showDefaultCanvas()
@@ -49,46 +42,11 @@ 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)

View File

@@ -57,7 +57,6 @@ 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 {
@@ -131,7 +130,6 @@ 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
@@ -331,9 +329,6 @@ 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
@@ -882,17 +877,16 @@ final class NodeAppModel {
let command = req.command
switch command {
case OpenClawCanvasA2UICommand.reset.rawValue:
switch await self.ensureA2UIReadyWithCapabilityRefresh(timeoutMs: 5000) {
case .ready:
break
case .hostNotConfigured:
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .unavailable,
message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host"))
case .hostUnavailable:
}
self.screen.navigate(to: a2uiUrl)
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
return BridgeInvokeResponse(
id: req.id,
ok: false,
@@ -900,6 +894,7 @@ 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;
@@ -908,7 +903,6 @@ 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 {
@@ -925,17 +919,16 @@ final class NodeAppModel {
}
}
switch await self.ensureA2UIReadyWithCapabilityRefresh(timeoutMs: 5000) {
case .ready:
break
case .hostNotConfigured:
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .unavailable,
message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host"))
case .hostUnavailable:
}
self.screen.navigate(to: a2uiUrl)
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
return BridgeInvokeResponse(
id: req.id,
ok: false,
@@ -2105,22 +2098,6 @@ 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
@@ -2218,83 +2195,6 @@ 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 {
@@ -2943,19 +2843,6 @@ 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()
}

View File

@@ -179,41 +179,6 @@ 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()

View File

@@ -9,7 +9,6 @@ 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>?
@@ -214,18 +213,6 @@ 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) } }
}
@@ -294,7 +281,6 @@ 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
@@ -311,9 +297,6 @@ 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) ?? ""
@@ -391,29 +374,13 @@ 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,
remoteToken: String,
remoteTokenDirty: Bool) -> (remote: [String: Any], changed: Bool)
remoteIdentity: String) -> (remote: [String: Any], changed: Bool)
{
var remote = current
var changed = false
@@ -450,10 +417,6 @@ 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)
}
@@ -476,7 +439,6 @@ 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)
@@ -508,7 +470,6 @@ final class AppState {
if remoteUrlText != self.remoteUrl {
self.remoteUrl = remoteUrlText
}
self.applyRemoteTokenState(remoteToken)
let targetMode = desiredMode ?? self.connectionMode
if targetMode == .remote,
@@ -535,68 +496,6 @@ final class AppState {
}
}
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 desiredMode: String? = switch connectionMode {
case .local:
"local"
case .remote:
"remote"
case .unconfigured:
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 }
@@ -605,22 +504,57 @@ final class AppState {
let remoteIdentity = self.remoteIdentity
let remoteTransport = self.remoteTransport
let remoteUrl = self.remoteUrl
let remoteToken = self.remoteToken
let remoteTokenDirty = self.remoteTokenDirty
let desiredMode: String? = switch connectionMode {
case .local:
"local"
case .remote:
"remote"
case .unconfigured:
nil
}
let remoteHost = connectionMode == .remote
? CommandResolver.parseSSHTarget(remoteTarget)?.host
: nil
Task { @MainActor in
// Keep app-only connection settings local to avoid overwriting remote gateway config.
let synced = Self.syncedGatewayRoot(
currentRoot: OpenClawConfigFile.loadDict(),
connectionMode: connectionMode,
remoteTransport: remoteTransport,
remoteTarget: remoteTarget,
remoteIdentity: remoteIdentity,
remoteUrl: remoteUrl,
remoteToken: remoteToken,
remoteTokenDirty: remoteTokenDirty)
guard synced.changed else { return }
OpenClawConfigFile.saveDict(synced.root)
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)
}
}
@@ -763,7 +697,6 @@ 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 = ""
@@ -771,53 +704,6 @@ 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()

View File

@@ -6,16 +6,11 @@ enum GatewayDiscoverySelectionSupport {
gateway: GatewayDiscoveryModel.DiscoveredGateway,
state: AppState)
{
let preferredTransport = self.preferredTransport(
for: gateway,
current: state.remoteTransport)
if preferredTransport != state.remoteTransport {
state.remoteTransport = preferredTransport
if state.remoteTransport == .direct {
state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
} else {
state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
}
state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
OpenClawConfigFile.setRemoteGatewayUrl(
host: endpoint.host,
@@ -24,30 +19,4 @@ 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")
}
}

View File

@@ -188,7 +188,13 @@ actor GatewayEndpointStore {
private static func resolveConfigToken(isRemote: Bool, root: [String: Any]) -> String? {
if isRemote {
return GatewayRemoteConfig.resolveTokenString(root: root)
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
}
if let gateway = root["gateway"] as? [String: Any],

View File

@@ -2,28 +2,6 @@ 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],
@@ -46,29 +24,6 @@ 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)

View File

@@ -149,7 +149,6 @@ struct GeneralSettings: View {
} else {
self.remoteDirectRow
}
self.remoteTokenRow
GatewayDiscoveryInlineList(
discovery: self.gatewayDiscovery,
@@ -292,30 +291,6 @@ 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() }
@@ -717,7 +692,6 @@ 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"

View File

@@ -199,25 +199,6 @@ extension OnboardingView {
.pickerStyle(.segmented)
.frame(width: fieldWidth)
}
GridRow {
Text("Gateway token")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
if self.state.remoteTokenUnsupported {
GridRow {
Text("")
.frame(width: labelWidth, alignment: .leading)
Text(
"The current gateway.remote.token value is not plain text. OpenClaw for macOS cannot use it directly; enter a plaintext token here to replace it.")
.font(.caption)
.foregroundStyle(.orange)
.frame(width: fieldWidth, alignment: .leading)
}
}
if self.state.remoteTransport == .direct {
GridRow {
Text("Gateway URL")

View File

@@ -338,12 +338,13 @@ public final class GatewayDiscoveryModel {
var attempt = 0
let startedAt = Date()
while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 {
let shouldContinue = await MainActor.run {
Self.shouldContinueTailscaleServeDiscovery(
currentGateways: self.gateways,
tailscaleServeGateways: self.tailscaleServeFallbackGateways)
let hasResults = await MainActor.run {
if self.filterLocalGateways {
return !self.gateways.isEmpty
}
return self.gateways.contains(where: { !$0.isLocal })
}
if !shouldContinue { return }
if hasResults { return }
let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: 2.4)
if !beacons.isEmpty {
@@ -362,15 +363,6 @@ 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 }

View File

@@ -203,7 +203,6 @@ 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
@@ -228,19 +227,6 @@ 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)

View File

@@ -836,20 +836,6 @@ 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
@@ -3257,8 +3243,6 @@ public struct ChatSendParams: Codable, Sendable {
public let deliver: Bool?
public let attachments: [AnyCodable]?
public let timeoutms: Int?
public let systeminputprovenance: [String: AnyCodable]?
public let systemprovenancereceipt: String?
public let idempotencykey: String
public init(
@@ -3268,8 +3252,6 @@ public struct ChatSendParams: Codable, Sendable {
deliver: Bool?,
attachments: [AnyCodable]?,
timeoutms: Int?,
systeminputprovenance: [String: AnyCodable]?,
systemprovenancereceipt: String?,
idempotencykey: String)
{
self.sessionkey = sessionkey
@@ -3278,8 +3260,6 @@ public struct ChatSendParams: Codable, Sendable {
self.deliver = deliver
self.attachments = attachments
self.timeoutms = timeoutms
self.systeminputprovenance = systeminputprovenance
self.systemprovenancereceipt = systemprovenancereceipt
self.idempotencykey = idempotencykey
}
@@ -3290,8 +3270,6 @@ public struct ChatSendParams: Codable, Sendable {
case deliver
case attachments
case timeoutms = "timeoutMs"
case systeminputprovenance = "systemInputProvenance"
case systemprovenancereceipt = "systemProvenanceReceipt"
case idempotencykey = "idempotencyKey"
}
}

View File

@@ -1,128 +0,0 @@
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)
}
}

View File

@@ -121,56 +121,6 @@ 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",

View File

@@ -1,90 +0,0 @@
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")
}
}
}

View File

@@ -61,22 +61,7 @@ struct GatewayEndpointStoreTests {
#expect(token == nil)
}
@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() {
@Test func `resolve gateway password falls back to launchd`() {
let snapshot = self.makeLaunchAgentSnapshot(
env: ["OPENCLAW_GATEWAY_PASSWORD": "launchd-pass"],
token: nil,

View File

@@ -74,25 +74,4 @@ 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")
}
}

View File

@@ -11,50 +11,6 @@ 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")
@@ -267,46 +223,6 @@ 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 }
@@ -359,7 +275,7 @@ public actor GatewayNodeSession {
switch push {
case let .snapshot(ok):
let raw = ok.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines)
self.canvasHostUrl = self.normalizeCanvasHostUrl(raw)
self.canvasHostUrl = (raw?.isEmpty == false) ? raw : nil
if self.hasEverConnected {
self.broadcastServerEvent(
EventFrame(type: "event", event: "seqGap", payload: nil, seq: nil, stateversion: nil))
@@ -426,10 +342,6 @@ 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 }
@@ -438,21 +350,16 @@ 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)")
@@ -473,8 +380,7 @@ 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),
@@ -492,8 +398,7 @@ 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)")
}
}

View File

@@ -836,20 +836,6 @@ 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
@@ -3257,8 +3243,6 @@ public struct ChatSendParams: Codable, Sendable {
public let deliver: Bool?
public let attachments: [AnyCodable]?
public let timeoutms: Int?
public let systeminputprovenance: [String: AnyCodable]?
public let systemprovenancereceipt: String?
public let idempotencykey: String
public init(
@@ -3268,8 +3252,6 @@ public struct ChatSendParams: Codable, Sendable {
deliver: Bool?,
attachments: [AnyCodable]?,
timeoutms: Int?,
systeminputprovenance: [String: AnyCodable]?,
systemprovenancereceipt: String?,
idempotencykey: String)
{
self.sessionkey = sessionkey
@@ -3278,8 +3260,6 @@ public struct ChatSendParams: Codable, Sendable {
self.deliver = deliver
self.attachments = attachments
self.timeoutms = timeoutms
self.systeminputprovenance = systeminputprovenance
self.systemprovenancereceipt = systemprovenancereceipt
self.idempotencykey = idempotencykey
}
@@ -3290,8 +3270,6 @@ public struct ChatSendParams: Codable, Sendable {
case deliver
case attachments
case timeoutms = "timeoutMs"
case systeminputprovenance = "systemInputProvenance"
case systemprovenancereceipt = "systemProvenanceReceipt"
case idempotencykey = "idempotencyKey"
}
}

View File

@@ -169,24 +169,6 @@ 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)

View File

@@ -46,19 +46,3 @@ export function isRetryableReconnectError(err) {
}
return true;
}
export function isMissingTabError(err) {
const message = (err instanceof Error ? err.message : String(err || "")).toLowerCase();
return (
message.includes("no tab with id") ||
message.includes("no tab with given id") ||
message.includes("tab not found")
);
}
export function isLastRemainingTab(allTabs, tabIdToClose) {
if (!Array.isArray(allTabs)) {
return true;
}
return allTabs.filter((tab) => tab && tab.id !== tabIdToClose).length === 0;
}

View File

@@ -1,10 +1,4 @@
import {
buildRelayWsUrl,
isLastRemainingTab,
isMissingTabError,
isRetryableReconnectError,
reconnectDelayMs,
} from './background-utils.js'
import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js'
const DEFAULT_PORT = 18792
@@ -47,9 +41,6 @@ const reattachPending = new Set()
let reconnectAttempt = 0
let reconnectTimer = null
const TAB_VALIDATION_ATTEMPTS = 2
const TAB_VALIDATION_RETRY_DELAY_MS = 1000
function nowStack() {
try {
return new Error().stack || ''
@@ -58,37 +49,6 @@ function nowStack() {
}
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function validateAttachedTab(tabId) {
try {
await chrome.tabs.get(tabId)
} catch {
return false
}
for (let attempt = 0; attempt < TAB_VALIDATION_ATTEMPTS; attempt++) {
try {
await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
expression: '1',
returnByValue: true,
})
return true
} catch (err) {
if (isMissingTabError(err)) {
return false
}
if (attempt < TAB_VALIDATION_ATTEMPTS - 1) {
await sleep(TAB_VALIDATION_RETRY_DELAY_MS)
}
}
}
return false
}
async function getRelayPort() {
const stored = await chrome.storage.local.get(['relayPort'])
const raw = stored.relayPort
@@ -148,11 +108,15 @@ async function rehydrateState() {
tabBySession.set(entry.sessionId, entry.tabId)
setBadge(entry.tabId, 'on')
}
// Retry once so transient busy/navigation states do not permanently drop
// a still-attached tab after a service worker restart.
// Phase 2: validate asynchronously, remove dead tabs.
for (const entry of entries) {
const valid = await validateAttachedTab(entry.tabId)
if (!valid) {
try {
await chrome.tabs.get(entry.tabId)
await chrome.debugger.sendCommand({ tabId: entry.tabId }, 'Runtime.evaluate', {
expression: '1',
returnByValue: true,
})
} catch {
tabs.delete(entry.tabId)
tabBySession.delete(entry.sessionId)
setBadge(entry.tabId, 'off')
@@ -295,10 +259,13 @@ async function reannounceAttachedTabs() {
for (const [tabId, tab] of tabs.entries()) {
if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue
// Retry once here as well; reconnect races can briefly make an otherwise
// healthy tab look unavailable.
const valid = await validateAttachedTab(tabId)
if (!valid) {
// Verify debugger is still attached.
try {
await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
expression: '1',
returnByValue: true,
})
} catch {
tabs.delete(tabId)
if (tab.sessionId) tabBySession.delete(tab.sessionId)
setBadge(tabId, 'off')
@@ -705,11 +672,6 @@ async function handleForwardCdpCommand(msg) {
const toClose = target ? getTabByTargetId(target) : tabId
if (!toClose) return { success: false }
try {
const allTabs = await chrome.tabs.query({})
if (isLastRemainingTab(allTabs, toClose)) {
console.warn('Refusing to close the last tab: this would kill the browser process')
return { success: false, error: 'Cannot close the last tab' }
}
await chrome.tabs.remove(toClose)
} catch {
return { success: false }

View File

@@ -620,8 +620,6 @@ 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

View File

@@ -96,52 +96,6 @@ Each ACP session maps to a single Gateway session key. One agent can have many
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
the key or label.
## Use from `acpx` (Codex, Claude, other ACP clients)
If you want a coding agent such as Codex or Claude Code to talk to your
OpenClaw bot over ACP, use `acpx` with its built-in `openclaw` target.
Typical flow:
1. Run the Gateway and make sure the ACP bridge can reach it.
2. Point `acpx openclaw` at `openclaw acp`.
3. Target the OpenClaw session key you want the coding agent to use.
Examples:
```bash
# One-shot request into your default OpenClaw ACP session
acpx openclaw exec "Summarize the active OpenClaw session state."
# Persistent named session for follow-up turns
acpx openclaw sessions ensure --name codex-bridge
acpx openclaw -s codex-bridge --cwd /path/to/repo \
"Ask my OpenClaw work agent for recent context relevant to this repo."
```
If you want `acpx openclaw` to target a specific Gateway and session key every
time, override the `openclaw` agent command in `~/.acpx/config.json`:
```json
{
"agents": {
"openclaw": {
"command": "env OPENCLAW_HIDE_BANNER=1 OPENCLAW_SUPPRESS_NOTES=1 openclaw acp --url ws://127.0.0.1:18789 --token-file ~/.openclaw/gateway.token --session agent:main:main"
}
}
}
```
For a repo-local OpenClaw checkout, use the direct CLI entrypoint instead of the
dev runner so the ACP stream stays clean. For example:
```bash
env OPENCLAW_HIDE_BANNER=1 OPENCLAW_SUPPRESS_NOTES=1 node openclaw.mjs acp ...
```
This is the easiest way to let Codex, Claude Code, or another ACP-aware client
pull contextual information from an OpenClaw agent without scraping a terminal.
## Zed editor setup
Add a custom ACP agent in `~/.config/zed/settings.json` (or use Zeds Settings UI):

View File

@@ -1,76 +0,0 @@
---
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`.

View File

@@ -23,8 +23,6 @@ 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.

View File

@@ -19,7 +19,6 @@ 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)
@@ -104,9 +103,6 @@ openclaw [--dev] [--profile <name>] <command>
completion
doctor
dashboard
backup
create
verify
security
audit
secrets

View File

@@ -11,10 +11,7 @@ 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.

View File

@@ -11,10 +11,7 @@ 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.

View File

@@ -1013,8 +1013,7 @@
"tools/browser",
"tools/browser-login",
"tools/chrome-extension",
"tools/browser-linux-troubleshooting",
"tools/browser-wsl2-windows-remote-cdp-troubleshooting"
"tools/browser-linux-troubleshooting"
]
},
{

View File

@@ -2354,7 +2354,6 @@ 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,
},
@@ -2371,7 +2370,6 @@ 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.
---

View File

@@ -68,12 +68,6 @@ 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:

View File

@@ -290,7 +290,6 @@ 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>

View File

@@ -1,299 +0,0 @@
---
summary: "Refactor clusters with highest LOC reduction potential"
read_when:
- You want to reduce total LOC without changing behavior
- You are choosing the next dedupe or extraction pass
title: "Refactor Cluster Backlog"
---
# Refactor Cluster Backlog
Ranked by likely LOC reduction, safety, and breadth.
## 1. Channel plugin config and security scaffolding
Highest-value cluster.
Repeated shapes across many channel plugins:
- `config.listAccountIds`
- `config.resolveAccount`
- `config.defaultAccountId`
- `config.setAccountEnabled`
- `config.deleteAccount`
- `config.describeAccount`
- `security.resolveDmPolicy`
Strong examples:
- `extensions/telegram/src/channel.ts`
- `extensions/googlechat/src/channel.ts`
- `extensions/slack/src/channel.ts`
- `extensions/discord/src/channel.ts`
- `extensions/matrix/src/channel.ts`
- `extensions/irc/src/channel.ts`
- `extensions/signal/src/channel.ts`
- `extensions/mattermost/src/channel.ts`
Likely extraction shape:
- `buildChannelConfigAdapter(...)`
- `buildMultiAccountConfigAdapter(...)`
- `buildDmSecurityAdapter(...)`
Expected savings:
- ~250-450 LOC
Risk:
- Medium. Each channel has slightly different `isConfigured`, warnings, and normalization.
## 2. Extension runtime singleton boilerplate
Very safe.
Nearly every extension has the same runtime holder:
- `let runtime: PluginRuntime | null = null`
- `setXRuntime`
- `getXRuntime`
Strong examples:
- `extensions/telegram/src/runtime.ts`
- `extensions/matrix/src/runtime.ts`
- `extensions/slack/src/runtime.ts`
- `extensions/discord/src/runtime.ts`
- `extensions/whatsapp/src/runtime.ts`
- `extensions/imessage/src/runtime.ts`
- `extensions/twitch/src/runtime.ts`
Special-case variants:
- `extensions/bluebubbles/src/runtime.ts`
- `extensions/line/src/runtime.ts`
- `extensions/synology-chat/src/runtime.ts`
Likely extraction shape:
- `createPluginRuntimeStore<T>(errorMessage)`
Expected savings:
- ~180-260 LOC
Risk:
- Low
## 3. Onboarding prompt and config-patch steps
Large surface area.
Many onboarding files repeat:
- resolve account id
- prompt allowlist entries
- merge allowFrom
- set DM policy
- prompt secrets
- patch top-level vs account-scoped config
Strong examples:
- `extensions/bluebubbles/src/onboarding.ts`
- `extensions/googlechat/src/onboarding.ts`
- `extensions/msteams/src/onboarding.ts`
- `extensions/zalo/src/onboarding.ts`
- `extensions/zalouser/src/onboarding.ts`
- `extensions/nextcloud-talk/src/onboarding.ts`
- `extensions/matrix/src/onboarding.ts`
- `extensions/irc/src/onboarding.ts`
Existing helper seam:
- `src/channels/plugins/onboarding/helpers.ts`
Likely extraction shape:
- `promptAllowFromList(...)`
- `buildDmPolicyAdapter(...)`
- `applyScopedAccountPatch(...)`
- `promptSecretFields(...)`
Expected savings:
- ~300-600 LOC
Risk:
- Medium. Easy to over-generalize; keep helpers narrow and composable.
## 4. Multi-account config-schema fragments
Repeated schema fragments across extensions.
Common patterns:
- `const allowFromEntry = z.union([z.string(), z.number()])`
- account schema plus:
- `accounts: z.object({}).catchall(accountSchema).optional()`
- `defaultAccount: z.string().optional()`
- repeated DM/group fields
- repeated markdown/tool policy fields
Strong examples:
- `extensions/bluebubbles/src/config-schema.ts`
- `extensions/zalo/src/config-schema.ts`
- `extensions/zalouser/src/config-schema.ts`
- `extensions/matrix/src/config-schema.ts`
- `extensions/nostr/src/config-schema.ts`
Likely extraction shape:
- `AllowFromEntrySchema`
- `buildMultiAccountChannelSchema(accountSchema)`
- `buildCommonDmGroupFields(...)`
Expected savings:
- ~120-220 LOC
Risk:
- Low to medium. Some schemas are simple, some are special.
## 5. Webhook and monitor lifecycle startup
Good medium-value cluster.
Repeated `startAccount` / monitor setup patterns:
- resolve account
- compute webhook path
- log startup
- start monitor
- wait for abort
- cleanup
- status sink updates
Strong examples:
- `extensions/googlechat/src/channel.ts`
- `extensions/bluebubbles/src/channel.ts`
- `extensions/zalo/src/channel.ts`
- `extensions/telegram/src/channel.ts`
- `extensions/nextcloud-talk/src/channel.ts`
Existing helper seam:
- `src/plugin-sdk/channel-lifecycle.ts`
Likely extraction shape:
- helper for account monitor lifecycle
- helper for webhook-backed account startup
Expected savings:
- ~150-300 LOC
Risk:
- Medium to high. Transport details diverge quickly.
## 6. Small exact-clone cleanup
Low-risk cleanup bucket.
Examples:
- duplicated gateway argv detection:
- `src/infra/gateway-lock.ts`
- `src/cli/daemon-cli/lifecycle.ts`
- duplicated port diagnostics rendering:
- `src/cli/daemon-cli/restart-health.ts`
- duplicated session-key construction:
- `src/web/auto-reply/monitor/broadcast.ts`
Expected savings:
- ~30-60 LOC
Risk:
- Low
## Test clusters
### LINE webhook event fixtures
Strong examples:
- `src/line/bot-handlers.test.ts`
Likely extraction:
- `makeLineEvent(...)`
- `runLineEvent(...)`
- `makeLineAccount(...)`
Expected savings:
- ~120-180 LOC
### Telegram native command auth matrix
Strong examples:
- `src/telegram/bot-native-commands.group-auth.test.ts`
- `src/telegram/bot-native-commands.plugin-auth.test.ts`
Likely extraction:
- forum context builder
- denied-message assertion helper
- table-driven auth cases
Expected savings:
- ~80-140 LOC
### Zalo lifecycle setup
Strong examples:
- `extensions/zalo/src/monitor.lifecycle.test.ts`
Likely extraction:
- shared monitor setup harness
Expected savings:
- ~50-90 LOC
### Brave llm-context unsupported-option tests
Strong examples:
- `src/agents/tools/web-tools.enabled-defaults.test.ts`
Likely extraction:
- `it.each(...)` matrix
Expected savings:
- ~30-50 LOC
## Suggested order
1. Runtime singleton boilerplate
2. Small exact-clone cleanup
3. Config and security builder extraction
4. Test-helper extraction
5. Onboarding step extraction
6. Monitor lifecycle helper extraction

View File

@@ -1,242 +0,0 @@
---
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

View File

@@ -328,19 +328,6 @@ 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
@@ -649,9 +636,6 @@ 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:

View File

@@ -161,7 +161,6 @@ 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 dont 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

View File

@@ -531,9 +531,6 @@ 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

View File

@@ -43,9 +43,9 @@ The table above is alphabetical. If no `provider` is explicitly set, runtime aut
1. **Brave**`BRAVE_API_KEY` env var or `tools.web.search.apiKey` config
2. **Gemini**`GEMINI_API_KEY` env var or `tools.web.search.gemini.apiKey` config
3. **Grok**`XAI_API_KEY` env var or `tools.web.search.grok.apiKey` config
4. **Kimi**`KIMI_API_KEY` / `MOONSHOT_API_KEY` env var or `tools.web.search.kimi.apiKey` config
5. **Perplexity**`PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey` config
3. **Kimi**`KIMI_API_KEY` / `MOONSHOT_API_KEY` env var or `tools.web.search.kimi.apiKey` config
4. **Perplexity**`PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey` config
5. **Grok**`XAI_API_KEY` env var or `tools.web.search.grok.apiKey` config
If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one).
@@ -212,10 +212,10 @@ Search the web using your configured provider.
- `tools.web.search.enabled` must not be `false` (default: enabled)
- API key for your chosen provider:
- **Brave**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
- **Perplexity**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey`
- **Gemini**: `GEMINI_API_KEY` or `tools.web.search.gemini.apiKey`
- **Grok**: `XAI_API_KEY` or `tools.web.search.grok.apiKey`
- **Kimi**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey`
- **Perplexity**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey`
### Config

View File

@@ -122,12 +122,6 @@ 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).

View File

@@ -2,13 +2,13 @@ import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js";
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
import {
cleanupMockRuntimeFixtures,
createMockRuntimeFixture,
NOOP_LOGGER,
readMockRuntimeLogEntries,
} from "./test-utils/runtime-fixtures.js";
} from "./runtime-internals/test-fixtures.js";
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
let sharedFixture: Awaited<ReturnType<typeof createMockRuntimeFixture>> | null = null;
let missingCommandRuntime: AcpxRuntime | null = null;

View File

@@ -1,11 +1,9 @@
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/bluebubbles";
import {
AllowFromEntrySchema,
buildCatchallMultiAccountChannelSchema,
} from "openclaw/plugin-sdk/compat";
import { z } from "zod";
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
const allowFromEntry = z.union([z.string(), z.number()]);
const bluebubblesActionSchema = z
.object({
reactions: z.boolean().default(true),
@@ -36,8 +34,8 @@ const bluebubblesAccountSchema = z
password: buildSecretInputSchema().optional(),
webhookPath: z.string().optional(),
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
allowFrom: z.array(AllowFromEntrySchema).optional(),
groupAllowFrom: z.array(AllowFromEntrySchema).optional(),
allowFrom: z.array(allowFromEntry).optional(),
groupAllowFrom: z.array(allowFromEntry).optional(),
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
@@ -62,8 +60,8 @@ const bluebubblesAccountSchema = z
}
});
export const BlueBubblesConfigSchema = buildCatchallMultiAccountChannelSchema(
bluebubblesAccountSchema,
).extend({
export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({
accounts: z.object({}).catchall(bluebubblesAccountSchema).optional(),
defaultAccount: z.string().optional(),
actions: bluebubblesActionSchema,
});

View File

@@ -1,4 +1,4 @@
import { parseFiniteNumber } from "openclaw/plugin-sdk/bluebubbles";
import { parseFiniteNumber } from "../../../src/infra/parse-finite-number.js";
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
import type { BlueBubblesAttachment } from "./types.js";

View File

@@ -1,26 +1,31 @@
import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
const runtimeStore = createPluginRuntimeStore<PluginRuntime>("BlueBubbles runtime not initialized");
let runtime: PluginRuntime | null = null;
type LegacyRuntimeLogShape = { log?: (message: string) => void };
export const setBlueBubblesRuntime = runtimeStore.setRuntime;
export function setBlueBubblesRuntime(next: PluginRuntime): void {
runtime = next;
}
export function clearBlueBubblesRuntime(): void {
runtimeStore.clearRuntime();
runtime = null;
}
export function tryGetBlueBubblesRuntime(): PluginRuntime | null {
return runtimeStore.tryGetRuntime();
return runtime;
}
export function getBlueBubblesRuntime(): PluginRuntime {
return runtimeStore.getRuntime();
if (!runtime) {
throw new Error("BlueBubbles runtime not initialized");
}
return runtime;
}
export function warnBlueBubbles(message: string): void {
const formatted = `[bluebubbles] ${message}`;
// Backward-compatible with tests/legacy injections that pass { log }.
const log = (runtimeStore.tryGetRuntime() as unknown as LegacyRuntimeLogShape | null)?.log;
const log = (runtime as unknown as LegacyRuntimeLogShape | null)?.log;
if (typeof log === "function") {
log(formatted);
return;

View File

@@ -1,4 +1,3 @@
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
import {
buildAccountScopedDmSecurityPolicy,
collectOpenProviderGroupPolicyWarnings,
@@ -14,6 +13,7 @@ import {
collectDiscordAuditChannelIds,
collectDiscordStatusIssues,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
discordOnboardingAdapter,
DiscordConfigSchema,
getChatChannelMeta,
@@ -33,6 +33,7 @@ import {
resolveDefaultDiscordAccountId,
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
setAccountEnabledInConfigSection,
type ChannelMessageActionAdapter,
type ChannelPlugin,
type ResolvedDiscordAccount,
@@ -62,15 +63,6 @@ const discordConfigAccessors = createScopedAccountConfigAccessors({
resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo,
});
const discordConfigBase = createScopedChannelConfigBase({
sectionKey: "discord",
listAccountIds: listDiscordAccountIds,
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultDiscordAccountId,
clearBaseFields: ["token", "name"],
});
export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
id: "discord",
meta: {
@@ -101,7 +93,25 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
reload: { configPrefixes: ["channels.discord"] },
configSchema: buildChannelConfigSchema(DiscordConfigSchema),
config: {
...discordConfigBase,
listAccountIds: (cfg) => listDiscordAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultDiscordAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "discord",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "discord",
accountId,
clearBaseFields: ["token", "name"],
}),
isConfigured: (account) => Boolean(account.token?.trim()),
describeAccount: (account) => ({
accountId: account.accountId,

View File

@@ -1,6 +1,14 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/discord";
const { setRuntime: setDiscordRuntime, getRuntime: getDiscordRuntime } =
createPluginRuntimeStore<PluginRuntime>("Discord runtime not initialized");
export { getDiscordRuntime, setDiscordRuntime };
let runtime: PluginRuntime | null = null;
export function setDiscordRuntime(next: PluginRuntime) {
runtime = next;
}
export function getDiscordRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Discord runtime not initialized");
}
return runtime;
}

View File

@@ -1,6 +1,14 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/feishu";
const { setRuntime: setFeishuRuntime, getRuntime: getFeishuRuntime } =
createPluginRuntimeStore<PluginRuntime>("Feishu runtime not initialized");
export { getFeishuRuntime, setFeishuRuntime };
let runtime: PluginRuntime | null = null;
export function setFeishuRuntime(next: PluginRuntime) {
runtime = next;
}
export function getFeishuRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Feishu runtime not initialized");
}
return runtime;
}

View File

@@ -1,4 +1,3 @@
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
import {
buildAccountScopedDmSecurityPolicy,
buildOpenGroupPolicyConfigureRouteAllowlistWarning,
@@ -12,6 +11,7 @@ import {
buildComputedAccountStatusSnapshot,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
getChatChannelMeta,
listDirectoryGroupEntriesFromMapKeys,
listDirectoryUserEntriesFromAllowFrom,
@@ -21,6 +21,7 @@ import {
PAIRING_APPROVED_MESSAGE,
resolveChannelMediaMaxBytes,
resolveGoogleChatGroupRequireMention,
setAccountEnabledInConfigSection,
type ChannelDock,
type ChannelMessageActionAdapter,
type ChannelPlugin,
@@ -67,23 +68,6 @@ const googleChatConfigAccessors = createScopedAccountConfigAccessors({
resolveDefaultTo: (account: ResolvedGoogleChatAccount) => account.config.defaultTo,
});
const googleChatConfigBase = createScopedChannelConfigBase<ResolvedGoogleChatAccount>({
sectionKey: "googlechat",
listAccountIds: listGoogleChatAccountIds,
resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultGoogleChatAccountId,
clearBaseFields: [
"serviceAccount",
"serviceAccountFile",
"audienceType",
"audience",
"webhookPath",
"webhookUrl",
"botUser",
"name",
],
});
export const googlechatDock: ChannelDock = {
id: "googlechat",
capabilities: {
@@ -158,7 +142,33 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
reload: { configPrefixes: ["channels.googlechat"] },
configSchema: buildChannelConfigSchema(GoogleChatConfigSchema),
config: {
...googleChatConfigBase,
listAccountIds: (cfg) => listGoogleChatAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg: cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultGoogleChatAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg,
sectionKey: "googlechat",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg,
sectionKey: "googlechat",
accountId,
clearBaseFields: [
"serviceAccount",
"serviceAccountFile",
"audienceType",
"audience",
"webhookPath",
"webhookUrl",
"botUser",
"name",
],
}),
isConfigured: (account) => account.credentialSource !== "none",
describeAccount: (account) => ({
accountId: account.accountId,

View File

@@ -1,6 +1,14 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/googlechat";
const { setRuntime: setGoogleChatRuntime, getRuntime: getGoogleChatRuntime } =
createPluginRuntimeStore<PluginRuntime>("Google Chat runtime not initialized");
export { getGoogleChatRuntime, setGoogleChatRuntime };
let runtime: PluginRuntime | null = null;
export function setGoogleChatRuntime(next: PluginRuntime) {
runtime = next;
}
export function getGoogleChatRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Google Chat runtime not initialized");
}
return runtime;
}

View File

@@ -1,6 +1,14 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/imessage";
const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } =
createPluginRuntimeStore<PluginRuntime>("iMessage runtime not initialized");
export { getIMessageRuntime, setIMessageRuntime };
let runtime: PluginRuntime | null = null;
export function setIMessageRuntime(next: PluginRuntime) {
runtime = next;
}
export function getIMessageRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("iMessage runtime not initialized");
}
return runtime;
}

View File

@@ -1,6 +1,14 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/irc";
const { setRuntime: setIrcRuntime, getRuntime: getIrcRuntime } =
createPluginRuntimeStore<PluginRuntime>("IRC runtime not initialized");
export { getIrcRuntime, setIrcRuntime };
let runtime: PluginRuntime | null = null;
export function setIrcRuntime(next: PluginRuntime) {
runtime = next;
}
export function getIrcRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("IRC runtime not initialized");
}
return runtime;
}

View File

@@ -1,6 +1,14 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/line";
const { setRuntime: setLineRuntime, getRuntime: getLineRuntime } =
createPluginRuntimeStore<PluginRuntime>("LINE runtime not initialized - plugin not registered");
export { getLineRuntime, setLineRuntime };
let runtime: PluginRuntime | null = null;
export function setLineRuntime(r: PluginRuntime): void {
runtime = r;
}
export function getLineRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("LINE runtime not initialized - plugin not registered");
}
return runtime;
}

View File

@@ -1,6 +1,14 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/matrix";
const { setRuntime: setMatrixRuntime, getRuntime: getMatrixRuntime } =
createPluginRuntimeStore<PluginRuntime>("Matrix runtime not initialized");
export { getMatrixRuntime, setMatrixRuntime };
let runtime: PluginRuntime | null = null;
export function setMatrixRuntime(next: PluginRuntime) {
runtime = next;
}
export function getMatrixRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Matrix runtime not initialized");
}
return runtime;
}

View File

@@ -19,7 +19,6 @@ import {
DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntryIfEnabled,
isDangerousNameMatchingEnabled,
parseStrictPositiveInteger,
registerPluginHttpRoute,
resolveControlCommandGate,
readStoreAllowFromForDmPolicy,
@@ -31,6 +30,7 @@ import {
listSkillCommandsForAgents,
type HistoryEntry,
} from "openclaw/plugin-sdk/mattermost";
import { parseStrictPositiveInteger } from "../../../../src/infra/parse-finite-number.js";
import { getMattermostRuntime } from "../runtime.js";
import { resolveMattermostAccount } from "./accounts.js";
import {

View File

@@ -1,6 +1,14 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/mattermost";
const { setRuntime: setMattermostRuntime, getRuntime: getMattermostRuntime } =
createPluginRuntimeStore<PluginRuntime>("Mattermost runtime not initialized");
export { getMattermostRuntime, setMattermostRuntime };
let runtime: PluginRuntime | null = null;
export function setMattermostRuntime(next: PluginRuntime) {
runtime = next;
}
export function getMattermostRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Mattermost runtime not initialized");
}
return runtime;
}

View File

@@ -1,6 +1,14 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/msteams";
const { setRuntime: setMSTeamsRuntime, getRuntime: getMSTeamsRuntime } =
createPluginRuntimeStore<PluginRuntime>("MSTeams runtime not initialized");
export { getMSTeamsRuntime, setMSTeamsRuntime };
let runtime: PluginRuntime | null = null;
export function setMSTeamsRuntime(next: PluginRuntime) {
runtime = next;
}
export function getMSTeamsRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("MSTeams runtime not initialized");
}
return runtime;
}

View File

@@ -15,11 +15,11 @@ import {
deleteAccountFromConfigSection,
normalizeAccountId,
setAccountEnabledInConfigSection,
waitForAbortSignal,
type ChannelPlugin,
type OpenClawConfig,
type ChannelSetupInput,
} from "openclaw/plugin-sdk/nextcloud-talk";
import { waitForAbortSignal } from "../../../src/infra/abort-signal.js";
import {
listNextcloudTalkAccountIds,
resolveDefaultNextcloudTalkAccountId,

View File

@@ -1,6 +1,14 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/nextcloud-talk";
const { setRuntime: setNextcloudTalkRuntime, getRuntime: getNextcloudTalkRuntime } =
createPluginRuntimeStore<PluginRuntime>("Nextcloud Talk runtime not initialized");
export { getNextcloudTalkRuntime, setNextcloudTalkRuntime };
let runtime: PluginRuntime | null = null;
export function setNextcloudTalkRuntime(next: PluginRuntime) {
runtime = next;
}
export function getNextcloudTalkRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Nextcloud Talk runtime not initialized");
}
return runtime;
}

View File

@@ -1,6 +1,14 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/nostr";
const { setRuntime: setNostrRuntime, getRuntime: getNostrRuntime } =
createPluginRuntimeStore<PluginRuntime>("Nostr runtime not initialized");
export { getNostrRuntime, setNostrRuntime };
let runtime: PluginRuntime | null = null;
export function setNostrRuntime(next: PluginRuntime): void {
runtime = next;
}
export function getNostrRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Nostr runtime not initialized");
}
return runtime;
}

View File

@@ -1,6 +1,14 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/signal";
const { setRuntime: setSignalRuntime, getRuntime: getSignalRuntime } =
createPluginRuntimeStore<PluginRuntime>("Signal runtime not initialized");
export { getSignalRuntime, setSignalRuntime };
let runtime: PluginRuntime | null = null;
export function setSignalRuntime(next: PluginRuntime) {
runtime = next;
}
export function getSignalRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Signal runtime not initialized");
}
return runtime;
}

View File

@@ -1,4 +1,3 @@
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
import {
buildAccountScopedDmSecurityPolicy,
collectOpenProviderGroupPolicyWarnings,
@@ -11,6 +10,7 @@ import {
buildComputedAccountStatusSnapshot,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
extractSlackToolSend,
getChatChannelMeta,
handleSlackMessageAction,
@@ -32,6 +32,7 @@ import {
resolveSlackGroupRequireMention,
resolveSlackGroupToolPolicy,
buildSlackThreadingToolContext,
setAccountEnabledInConfigSection,
slackOnboardingAdapter,
SlackConfigSchema,
type ChannelPlugin,
@@ -95,15 +96,6 @@ const slackConfigAccessors = createScopedAccountConfigAccessors({
resolveDefaultTo: (account: ResolvedSlackAccount) => account.config.defaultTo,
});
const slackConfigBase = createScopedChannelConfigBase({
sectionKey: "slack",
listAccountIds: listSlackAccountIds,
resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }),
inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultSlackAccountId,
clearBaseFields: ["botToken", "appToken", "name"],
});
export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
id: "slack",
meta: {
@@ -152,7 +144,25 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
reload: { configPrefixes: ["channels.slack"] },
configSchema: buildChannelConfigSchema(SlackConfigSchema),
config: {
...slackConfigBase,
listAccountIds: (cfg) => listSlackAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }),
inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultSlackAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "slack",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "slack",
accountId,
clearBaseFields: ["botToken", "appToken", "name"],
}),
isConfigured: (account) => isSlackAccountConfigured(account),
describeAccount: (account) => ({
accountId: account.accountId,

View File

@@ -1,6 +1,14 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/slack";
const { setRuntime: setSlackRuntime, getRuntime: getSlackRuntime } =
createPluginRuntimeStore<PluginRuntime>("Slack runtime not initialized");
export { getSlackRuntime, setSlackRuntime };
let runtime: PluginRuntime | null = null;
export function setSlackRuntime(next: PluginRuntime) {
runtime = next;
}
export function getSlackRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Slack runtime not initialized");
}
return runtime;
}

View File

@@ -1,8 +1,20 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
/**
* Plugin runtime singleton.
* Stores the PluginRuntime from api.runtime (set during register()).
* Used by channel.ts to access dispatch functions.
*/
import type { PluginRuntime } from "openclaw/plugin-sdk/synology-chat";
const { setRuntime: setSynologyRuntime, getRuntime: getSynologyRuntime } =
createPluginRuntimeStore<PluginRuntime>(
"Synology Chat runtime not initialized - plugin not registered",
);
export { getSynologyRuntime, setSynologyRuntime };
let runtime: PluginRuntime | null = null;
export function setSynologyRuntime(r: PluginRuntime): void {
runtime = r;
}
export function getSynologyRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Synology Chat runtime not initialized - plugin not registered");
}
return runtime;
}

View File

@@ -1,4 +1,3 @@
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
import {
collectAllowlistProviderGroupPolicyWarnings,
buildAccountScopedDmSecurityPolicy,
@@ -13,6 +12,7 @@ import {
clearAccountEntryFields,
collectTelegramStatusIssues,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
getChatChannelMeta,
inspectTelegramAccount,
listTelegramAccountIds,
@@ -31,6 +31,7 @@ import {
resolveTelegramAccount,
resolveTelegramGroupRequireMention,
resolveTelegramGroupToolPolicy,
setAccountEnabledInConfigSection,
telegramOnboardingAdapter,
TelegramConfigSchema,
type ChannelMessageActionAdapter,
@@ -99,15 +100,6 @@ const telegramConfigAccessors = createScopedAccountConfigAccessors({
resolveDefaultTo: (account: ResolvedTelegramAccount) => account.config.defaultTo,
});
const telegramConfigBase = createScopedChannelConfigBase<ResolvedTelegramAccount>({
sectionKey: "telegram",
listAccountIds: listTelegramAccountIds,
resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }),
inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultTelegramAccountId,
clearBaseFields: ["botToken", "tokenFile", "name"],
});
export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProbe> = {
id: "telegram",
meta: {
@@ -144,7 +136,25 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
reload: { configPrefixes: ["channels.telegram"] },
configSchema: buildChannelConfigSchema(TelegramConfigSchema),
config: {
...telegramConfigBase,
listAccountIds: (cfg) => listTelegramAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }),
inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultTelegramAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "telegram",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "telegram",
accountId,
clearBaseFields: ["botToken", "tokenFile", "name"],
}),
isConfigured: (account, cfg) => {
if (!account.token?.trim()) {
return false;

View File

@@ -1,6 +1,14 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/telegram";
const { setRuntime: setTelegramRuntime, getRuntime: getTelegramRuntime } =
createPluginRuntimeStore<PluginRuntime>("Telegram runtime not initialized");
export { getTelegramRuntime, setTelegramRuntime };
let runtime: PluginRuntime | null = null;
export function setTelegramRuntime(next: PluginRuntime) {
runtime = next;
}
export function getTelegramRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Telegram runtime not initialized");
}
return runtime;
}

View File

@@ -1,6 +1,14 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/tlon";
const { setRuntime: setTlonRuntime, getRuntime: getTlonRuntime } =
createPluginRuntimeStore<PluginRuntime>("Tlon runtime not initialized");
export { getTlonRuntime, setTlonRuntime };
let runtime: PluginRuntime | null = null;
export function setTlonRuntime(next: PluginRuntime) {
runtime = next;
}
export function getTlonRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Tlon runtime not initialized");
}
return runtime;
}

View File

@@ -1,6 +1,14 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/twitch";
const { setRuntime: setTwitchRuntime, getRuntime: getTwitchRuntime } =
createPluginRuntimeStore<PluginRuntime>("Twitch runtime not initialized");
export { getTwitchRuntime, setTwitchRuntime };
let runtime: PluginRuntime | null = null;
export function setTwitchRuntime(next: PluginRuntime) {
runtime = next;
}
export function getTwitchRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Twitch runtime not initialized");
}
return runtime;
}

View File

@@ -9,11 +9,8 @@
* 2. Environment variable: OPENCLAW_TWITCH_ACCESS_TOKEN (default account only)
*/
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
type OpenClawConfig,
} from "openclaw/plugin-sdk/twitch";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
export type TwitchTokenSource = "env" | "config" | "none";

View File

@@ -5,24 +5,26 @@
* from OpenClaw core.
*/
import type {
ChannelGatewayContext,
ChannelOutboundAdapter,
ChannelOutboundContext,
ChannelResolveKind,
ChannelResolveResult,
ChannelStatusAdapter,
} from "../../../src/channels/plugins/types.adapters.js";
import type {
ChannelAccountSnapshot,
ChannelCapabilities,
ChannelGatewayContext,
ChannelLogSink,
ChannelMessageActionAdapter,
ChannelMessageActionContext,
ChannelMeta,
ChannelOutboundAdapter,
ChannelOutboundContext,
ChannelPlugin,
ChannelResolveKind,
ChannelResolveResult,
ChannelStatusAdapter,
OpenClawConfig,
OutboundDeliveryResult,
RuntimeEnv,
} from "openclaw/plugin-sdk/twitch";
} from "../../../src/channels/plugins/types.core.js";
import type { ChannelPlugin } from "../../../src/channels/plugins/types.plugin.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { OutboundDeliveryResult } from "../../../src/infra/outbound/deliver.js";
import type { RuntimeEnv } from "../../../src/runtime.js";
// ============================================================================
// Twitch-Specific Types

View File

@@ -1,6 +1,14 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/whatsapp";
const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } =
createPluginRuntimeStore<PluginRuntime>("WhatsApp runtime not initialized");
export { getWhatsAppRuntime, setWhatsAppRuntime };
let runtime: PluginRuntime | null = null;
export function setWhatsAppRuntime(next: PluginRuntime) {
runtime = next;
}
export function getWhatsAppRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("WhatsApp runtime not initialized");
}
return runtime;
}

View File

@@ -1,11 +1,9 @@
import {
AllowFromEntrySchema,
buildCatchallMultiAccountChannelSchema,
} from "openclaw/plugin-sdk/compat";
import { MarkdownConfigSchema } from "openclaw/plugin-sdk/zalo";
import { z } from "zod";
import { buildSecretInputSchema } from "./secret-input.js";
const allowFromEntry = z.union([z.string(), z.number()]);
const zaloAccountSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
@@ -16,12 +14,15 @@ const zaloAccountSchema = z.object({
webhookSecret: buildSecretInputSchema().optional(),
webhookPath: z.string().optional(),
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
allowFrom: z.array(AllowFromEntrySchema).optional(),
allowFrom: z.array(allowFromEntry).optional(),
groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
groupAllowFrom: z.array(AllowFromEntrySchema).optional(),
groupAllowFrom: z.array(allowFromEntry).optional(),
mediaMaxMb: z.number().optional(),
proxy: z.string().optional(),
responsePrefix: z.string().optional(),
});
export const ZaloConfigSchema = buildCatchallMultiAccountChannelSchema(zaloAccountSchema);
export const ZaloConfigSchema = zaloAccountSchema.extend({
accounts: z.object({}).catchall(zaloAccountSchema).optional(),
defaultAccount: z.string().optional(),
});

View File

@@ -1,6 +1,14 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/zalo";
const { setRuntime: setZaloRuntime, getRuntime: getZaloRuntime } =
createPluginRuntimeStore<PluginRuntime>("Zalo runtime not initialized");
export { getZaloRuntime, setZaloRuntime };
let runtime: PluginRuntime | null = null;
export function setZaloRuntime(next: PluginRuntime): void {
runtime = next;
}
export function getZaloRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Zalo runtime not initialized");
}
return runtime;
}

View File

@@ -1,10 +1,8 @@
import {
AllowFromEntrySchema,
buildCatchallMultiAccountChannelSchema,
} from "openclaw/plugin-sdk/compat";
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/zalouser";
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
const groupConfigSchema = z.object({
allow: z.boolean().optional(),
enabled: z.boolean().optional(),
@@ -18,13 +16,16 @@ const zalouserAccountSchema = z.object({
markdown: MarkdownConfigSchema,
profile: z.string().optional(),
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
allowFrom: z.array(AllowFromEntrySchema).optional(),
allowFrom: z.array(allowFromEntry).optional(),
historyLimit: z.number().int().min(0).optional(),
groupAllowFrom: z.array(AllowFromEntrySchema).optional(),
groupAllowFrom: z.array(allowFromEntry).optional(),
groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
groups: z.object({}).catchall(groupConfigSchema).optional(),
messagePrefix: z.string().optional(),
responsePrefix: z.string().optional(),
});
export const ZalouserConfigSchema = buildCatchallMultiAccountChannelSchema(zalouserAccountSchema);
export const ZalouserConfigSchema = zalouserAccountSchema.extend({
accounts: z.object({}).catchall(zalouserAccountSchema).optional(),
defaultAccount: z.string().optional(),
});

View File

@@ -1,6 +1,14 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/zalouser";
const { setRuntime: setZalouserRuntime, getRuntime: getZalouserRuntime } =
createPluginRuntimeStore<PluginRuntime>("Zalouser runtime not initialized");
export { getZalouserRuntime, setZalouserRuntime };
let runtime: PluginRuntime | null = null;
export function setZalouserRuntime(next: PluginRuntime): void {
runtime = next;
}
export function getZalouserRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Zalouser runtime not initialized");
}
return runtime;
}

View File

@@ -224,7 +224,6 @@
"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",

View File

@@ -1,22 +1,19 @@
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim@sha256:3cfe526ec8dd62013b8843e8e5d4877e297b886e5aace4a59fec25dc20736e45
RUN --mount=type=cache,id=openclaw-cleanup-smoke-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-cleanup-smoke-apt-lists,target=/var/lib/apt,sharing=locked \
apt-get update \
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
git
git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /repo
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
corepack enable \
RUN corepack enable \
&& pnpm install --frozen-lockfile
COPY . .
COPY --chmod=755 scripts/docker/cleanup-smoke/run.sh /usr/local/bin/openclaw-cleanup-smoke
COPY scripts/docker/cleanup-smoke/run.sh /usr/local/bin/openclaw-cleanup-smoke
RUN chmod +x /usr/local/bin/openclaw-cleanup-smoke
ENTRYPOINT ["/usr/local/bin/openclaw-cleanup-smoke"]

View File

@@ -1,18 +1,16 @@
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim@sha256:3cfe526ec8dd62013b8843e8e5d4877e297b886e5aace4a59fec25dc20736e45
RUN --mount=type=cache,id=openclaw-install-sh-e2e-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-install-sh-e2e-apt-lists,target=/var/lib/apt,sharing=locked \
apt-get update \
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git
git \
&& rm -rf /var/lib/apt/lists/*
COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh
COPY --chmod=755 run.sh /usr/local/bin/openclaw-install-e2e
COPY run.sh /usr/local/bin/openclaw-install-e2e
RUN chmod +x /usr/local/bin/openclaw-install-e2e
RUN useradd --create-home --shell /bin/bash appuser
USER appuser

View File

@@ -1,10 +1,6 @@
# syntax=docker/dockerfile:1.7
FROM ubuntu:24.04@sha256:cd1dba651b3080c3686ecf4e3c4220f026b521fb76978881737d24f200828b2b
RUN --mount=type=cache,id=openclaw-install-sh-nonroot-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-install-sh-nonroot-apt-lists,target=/var/lib/apt,sharing=locked \
set -eux; \
RUN set -eux; \
for attempt in 1 2 3; do \
if apt-get update -o Acquire::Retries=3; then break; fi; \
echo "apt-get update failed (attempt ${attempt})" >&2; \
@@ -18,7 +14,8 @@ RUN --mount=type=cache,id=openclaw-install-sh-nonroot-apt-cache,target=/var/cach
g++ \
make \
python3 \
sudo
sudo \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -m -s /bin/bash app \
&& echo "app ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/app
@@ -31,6 +28,7 @@ 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 --chmod=755 install-sh-nonroot/run.sh /usr/local/bin/openclaw-install-nonroot
COPY install-sh-nonroot/run.sh /usr/local/bin/openclaw-install-nonroot
RUN sudo chmod +x /usr/local/bin/openclaw-install-nonroot
ENTRYPOINT ["/usr/local/bin/openclaw-install-nonroot"]

View File

@@ -1,10 +1,6 @@
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim@sha256:3cfe526ec8dd62013b8843e8e5d4877e297b886e5aace4a59fec25dc20736e45
RUN --mount=type=cache,id=openclaw-install-sh-smoke-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-install-sh-smoke-apt-lists,target=/var/lib/apt,sharing=locked \
set -eux; \
RUN set -eux; \
for attempt in 1 2 3; do \
if apt-get update -o Acquire::Retries=3; then break; fi; \
echo "apt-get update failed (attempt ${attempt})" >&2; \
@@ -19,10 +15,12 @@ RUN --mount=type=cache,id=openclaw-install-sh-smoke-apt-cache,target=/var/cache/
g++ \
make \
python3 \
sudo
sudo \
&& rm -rf /var/lib/apt/lists/*
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 --chmod=755 install-sh-smoke/run.sh /usr/local/bin/openclaw-install-smoke
COPY install-sh-smoke/run.sh /usr/local/bin/openclaw-install-smoke
RUN chmod +x /usr/local/bin/openclaw-install-smoke
ENTRYPOINT ["/usr/local/bin/openclaw-install-smoke"]

View File

@@ -1,5 +1,3 @@
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm@sha256:cd7bcd2e7a1e6f72052feb023c7f6b722205d3fcab7bbcbd2d1bfdab10b1e935
RUN corepack enable
@@ -8,26 +6,20 @@ WORKDIR /app
ENV NODE_OPTIONS="--disable-warning=ExperimentalWarning"
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY ui/package.json ./ui/package.json
COPY extensions/memory-core/package.json ./extensions/memory-core/package.json
COPY patches ./patches
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
pnpm install --frozen-lockfile
COPY tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts openclaw.mjs ./
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts openclaw.mjs ./
COPY src ./src
COPY test ./test
COPY scripts ./scripts
COPY docs ./docs
COPY skills ./skills
COPY patches ./patches
COPY ui ./ui
COPY extensions/memory-core ./extensions/memory-core
COPY vendor/a2ui/renderers/lit ./vendor/a2ui/renderers/lit
COPY apps/shared/OpenClawKit/Sources/OpenClawKit/Resources ./apps/shared/OpenClawKit/Sources/OpenClawKit/Resources
COPY apps/shared/OpenClawKit/Tools/CanvasA2UI ./apps/shared/OpenClawKit/Tools/CanvasA2UI
RUN pnpm install --frozen-lockfile
RUN pnpm build
RUN pnpm ui:build

View File

@@ -1,23 +1,13 @@
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm@sha256:cd7bcd2e7a1e6f72052feb023c7f6b722205d3fcab7bbcbd2d1bfdab10b1e935
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY ui/package.json ./ui/package.json
COPY patches ./patches
# This image only exercises the root qrcode-terminal dependency path.
# Keep the pre-install copy set limited to the manifests needed for root
# workspace resolution so unrelated extension edits do not bust the layer.
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
pnpm install --frozen-lockfile
COPY . .
RUN pnpm install --frozen-lockfile
RUN useradd --create-home --shell /bin/bash appuser \
&& chown -R appuser:appuser /app
USER appuser

View File

@@ -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:Z
Volume={{OPENCLAW_HOME}}/.openclaw:/home/node/.openclaw
EnvironmentFile={{OPENCLAW_HOME}}/.openclaw/.env
Environment=HOME=/home/node
Environment=TERM=xterm-256color

View File

@@ -183,30 +183,14 @@ 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${SELINUX_MOUNT_OPTS}" \
-v "$WORKSPACE_DIR:/home/node/.openclaw/workspace:rw${SELINUX_MOUNT_OPTS}" \
-v "$CONFIG_DIR:/home/node/.openclaw:rw" \
-v "$WORKSPACE_DIR:/home/node/.openclaw/workspace:rw" \
"${ENV_FILE_ARGS[@]}" \
"$OPENCLAW_IMAGE" \
node dist/index.js onboard "$@"
@@ -219,8 +203,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${SELINUX_MOUNT_OPTS}" \
-v "$WORKSPACE_DIR:/home/node/.openclaw/workspace:rw${SELINUX_MOUNT_OPTS}" \
-v "$CONFIG_DIR:/home/node/.openclaw:rw" \
-v "$WORKSPACE_DIR:/home/node/.openclaw/workspace:rw" \
-p "${HOST_GATEWAY_PORT}:18789" \
-p "${HOST_BRIDGE_PORT}:18790" \
"$OPENCLAW_IMAGE" \

View File

@@ -10,9 +10,6 @@ BUN_INSTALL_DIR="${BUN_INSTALL_DIR:-/opt/bun}"
INSTALL_BREW="${INSTALL_BREW:-1}"
BREW_INSTALL_DIR="${BREW_INSTALL_DIR:-/home/linuxbrew/.linuxbrew}"
FINAL_USER="${FINAL_USER:-sandbox}"
OPENCLAW_DOCKER_BUILD_USE_BUILDX="${OPENCLAW_DOCKER_BUILD_USE_BUILDX:-0}"
OPENCLAW_DOCKER_BUILD_CACHE_FROM="${OPENCLAW_DOCKER_BUILD_CACHE_FROM:-}"
OPENCLAW_DOCKER_BUILD_CACHE_TO="${OPENCLAW_DOCKER_BUILD_CACHE_TO:-}"
if ! docker image inspect "${BASE_IMAGE}" >/dev/null 2>&1; then
echo "Base image missing: ${BASE_IMAGE}"
@@ -22,18 +19,7 @@ fi
echo "Building ${TARGET_IMAGE} with: ${PACKAGES}"
build_cmd=(docker build)
if [ "${OPENCLAW_DOCKER_BUILD_USE_BUILDX}" = "1" ]; then
build_cmd=(docker buildx build --load)
if [ -n "${OPENCLAW_DOCKER_BUILD_CACHE_FROM}" ]; then
build_cmd+=(--cache-from "${OPENCLAW_DOCKER_BUILD_CACHE_FROM}")
fi
if [ -n "${OPENCLAW_DOCKER_BUILD_CACHE_TO}" ]; then
build_cmd+=(--cache-to "${OPENCLAW_DOCKER_BUILD_CACHE_TO}")
fi
fi
"${build_cmd[@]}" \
docker build \
-t "${TARGET_IMAGE}" \
-f Dockerfile.sandbox-common \
--build-arg BASE_IMAGE="${BASE_IMAGE}" \

View File

@@ -80,17 +80,12 @@ 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
( cd /tmp 2>/dev/null || cd /; sudo -u "$user" "$@" )
sudo -u "$user" "$@"
elif is_root && command -v runuser >/dev/null 2>&1; then
( cd /tmp 2>/dev/null || cd /; runuser -u "$user" -- "$@" )
runuser -u "$user" -- "$@"
else
echo "Need sudo (or root+runuser) to run commands as $user." >&2
exit 1

View File

@@ -10,7 +10,7 @@ import { isMainModule } from "../infra/is-main.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { readSecretFromFile } from "./secret-file.js";
import { AcpGatewayAgent } from "./translator.js";
import { normalizeAcpProvenanceMode, type AcpServerOptions } from "./types.js";
import type { AcpServerOptions } from "./types.js";
export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void> {
const cfg = loadConfig();
@@ -186,15 +186,6 @@ function parseArgs(args: string[]): AcpServerOptions {
opts.prefixCwd = false;
continue;
}
if (arg === "--provenance") {
const provenanceMode = normalizeAcpProvenanceMode(args[i + 1]);
if (!provenanceMode) {
throw new Error("Invalid --provenance value. Use off, meta, or meta+receipt.");
}
opts.provenanceMode = provenanceMode;
i += 1;
continue;
}
if (arg === "--verbose" || arg === "-v") {
opts.verbose = true;
continue;
@@ -235,7 +226,6 @@ Options:
--require-existing Fail if the session key/label does not exist
--reset-session Reset the session key before first use
--no-prefix-cwd Do not prefix prompts with the working directory
--provenance <mode> ACP provenance mode: off, meta, or meta+receipt
--verbose, -v Verbose logging to stderr
--help, -h Show this help message
`);

View File

@@ -81,117 +81,4 @@ describe("acp prompt cwd prefix", () => {
{ expectFinal: true },
);
});
it("injects system provenance metadata when enabled", async () => {
const sessionStore = createInMemorySessionStore();
sessionStore.createSession({
sessionId: "session-1",
sessionKey: "agent:main:main",
cwd: path.join(os.homedir(), "openclaw-test"),
});
const requestSpy = vi.fn(async (method: string) => {
if (method === "chat.send") {
throw new Error("stop-after-send");
}
return {};
});
const agent = new AcpGatewayAgent(
createAcpConnection(),
createAcpGateway(requestSpy as unknown as GatewayClient["request"]),
{
sessionStore,
provenanceMode: "meta",
},
);
await expect(
agent.prompt({
sessionId: "session-1",
prompt: [{ type: "text", text: "hello" }],
_meta: {},
} as unknown as PromptRequest),
).rejects.toThrow("stop-after-send");
expect(requestSpy).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
systemInputProvenance: {
kind: "external_user",
originSessionId: "session-1",
sourceChannel: "acp",
sourceTool: "openclaw_acp",
},
systemProvenanceReceipt: undefined,
}),
{ expectFinal: true },
);
});
it("injects a system provenance receipt when requested", async () => {
const sessionStore = createInMemorySessionStore();
sessionStore.createSession({
sessionId: "session-1",
sessionKey: "agent:main:main",
cwd: path.join(os.homedir(), "openclaw-test"),
});
const requestSpy = vi.fn(async (method: string) => {
if (method === "chat.send") {
throw new Error("stop-after-send");
}
return {};
});
const agent = new AcpGatewayAgent(
createAcpConnection(),
createAcpGateway(requestSpy as unknown as GatewayClient["request"]),
{
sessionStore,
provenanceMode: "meta+receipt",
},
);
await expect(
agent.prompt({
sessionId: "session-1",
prompt: [{ type: "text", text: "hello" }],
_meta: {},
} as unknown as PromptRequest),
).rejects.toThrow("stop-after-send");
expect(requestSpy).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
systemInputProvenance: {
kind: "external_user",
originSessionId: "session-1",
sourceChannel: "acp",
sourceTool: "openclaw_acp",
},
systemProvenanceReceipt: expect.stringContaining("[Source Receipt]"),
}),
{ expectFinal: true },
);
expect(requestSpy).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
systemProvenanceReceipt: expect.stringContaining("bridge=openclaw-acp"),
}),
{ expectFinal: true },
);
expect(requestSpy).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
systemProvenanceReceipt: expect.stringContaining("originSessionId=session-1"),
}),
{ expectFinal: true },
);
expect(requestSpy).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
systemProvenanceReceipt: expect.stringContaining("targetSession=agent:main:main"),
}),
{ expectFinal: true },
);
});
});

View File

@@ -1,5 +1,4 @@
import { randomUUID } from "node:crypto";
import os from "node:os";
import type {
Agent,
AgentSideConnection,
@@ -62,32 +61,6 @@ type AcpGatewayAgentOptions = AcpServerOptions & {
const SESSION_CREATE_RATE_LIMIT_DEFAULT_MAX_REQUESTS = 120;
const SESSION_CREATE_RATE_LIMIT_DEFAULT_WINDOW_MS = 10_000;
function buildSystemInputProvenance(originSessionId: string) {
return {
kind: "external_user" as const,
originSessionId,
sourceChannel: "acp",
sourceTool: "openclaw_acp",
};
}
function buildSystemProvenanceReceipt(params: {
cwd: string;
sessionId: string;
sessionKey: string;
}) {
return [
"[Source Receipt]",
"bridge=openclaw-acp",
`originHost=${os.hostname()}`,
`originCwd=${shortenHomePath(params.cwd)}`,
`acpSessionId=${params.sessionId}`,
`originSessionId=${params.sessionId}`,
`targetSession=${params.sessionKey}`,
"[/Source Receipt]",
].join("\n");
}
export class AcpGatewayAgent implements Agent {
private connection: AgentSideConnection;
private gateway: GatewayClient;
@@ -278,17 +251,6 @@ export class AcpGatewayAgent implements Agent {
const prefixCwd = meta.prefixCwd ?? this.opts.prefixCwd ?? true;
const displayCwd = shortenHomePath(session.cwd);
const message = prefixCwd ? `[Working directory: ${displayCwd}]\n\n${userText}` : userText;
const provenanceMode = this.opts.provenanceMode ?? "off";
const systemInputProvenance =
provenanceMode === "off" ? undefined : buildSystemInputProvenance(params.sessionId);
const systemProvenanceReceipt =
provenanceMode === "meta+receipt"
? buildSystemProvenanceReceipt({
cwd: session.cwd,
sessionId: params.sessionId,
sessionKey: session.sessionKey,
})
: undefined;
// Defense-in-depth: also check the final assembled message (includes cwd prefix)
if (Buffer.byteLength(message, "utf-8") > MAX_PROMPT_BYTES) {
@@ -319,8 +281,6 @@ export class AcpGatewayAgent implements Agent {
thinking: readString(params._meta, ["thinking", "thinkingLevel"]),
deliver: readBool(params._meta, ["deliver"]),
timeoutMs: readNumber(params._meta, ["timeoutMs"]),
systemInputProvenance,
systemProvenanceReceipt,
},
{ expectFinal: true },
)

View File

@@ -1,22 +1,6 @@
import type { SessionId } from "@agentclientprotocol/sdk";
import { VERSION } from "../version.js";
export const ACP_PROVENANCE_MODE_VALUES = ["off", "meta", "meta+receipt"] as const;
export type AcpProvenanceMode = (typeof ACP_PROVENANCE_MODE_VALUES)[number];
export function normalizeAcpProvenanceMode(
value: string | undefined,
): AcpProvenanceMode | undefined {
if (!value) {
return undefined;
}
const normalized = value.trim().toLowerCase();
return (ACP_PROVENANCE_MODE_VALUES as readonly string[]).includes(normalized)
? (normalized as AcpProvenanceMode)
: undefined;
}
export type AcpSession = {
sessionId: SessionId;
sessionKey: string;
@@ -36,7 +20,6 @@ export type AcpServerOptions = {
requireExistingSession?: boolean;
resetSession?: boolean;
prefixCwd?: boolean;
provenanceMode?: AcpProvenanceMode;
sessionCreateRateLimit?: {
maxRequests?: number;
windowMs?: number;

View File

@@ -363,7 +363,7 @@ describe("resolveForwardCompatModel", () => {
expectResolvedForwardCompat(model, { provider: "openai-codex", id: "gpt-5.4" });
expect(model?.api).toBe("openai-codex-responses");
expect(model?.baseUrl).toBe("https://chatgpt.com/backend-api");
expect(model?.contextWindow).toBe(1_050_000);
expect(model?.contextWindow).toBe(272_000);
expect(model?.maxTokens).toBe(128_000);
});

Some files were not shown because too many files have changed in this diff Show More