Merge branch 'main' into vincentkoc-code/telegram-message-sent-parity

This commit is contained in:
Vincent Koc
2026-03-08 16:40:02 -07:00
committed by GitHub
174 changed files with 7163 additions and 988 deletions

View File

@@ -267,6 +267,12 @@ jobs:
with:
submodules: false
- name: Ensure secrets base commit
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:

View File

@@ -69,6 +69,8 @@ repos:
- '"ap[i]Key": "xxxxx"(,)?'
- --exclude-lines
- 'ap[i]Key: "A[I]za\.\.\.",'
- --exclude-lines
- '"ap[i]Key": "(resolved|normalized|legacy)-key"(,)?'
# Shell script linting
- repo: https://github.com/koalaman/shellcheck-precommit
rev: v0.11.0

View File

@@ -152,7 +152,8 @@
"grep -q 'N[O]DE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache' ~/.bashrc \\|\\| cat >> ~/.bashrc <<'EOF'",
"env: \\{ MISTRAL_API_K[E]Y: \"sk-\\.\\.\\.\" \\},",
"\"ap[i]Key\": \"xxxxx\"(,)?",
"ap[i]Key: \"A[I]za\\.\\.\\.\","
"ap[i]Key: \"A[I]za\\.\\.\\.\",",
"\"ap[i]Key\": \"(resolved|normalized|legacy)-key\"(,)?"
]
},
{
@@ -251,7 +252,7 @@
"filename": "apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift",
"hashed_secret": "19dad5cecb110281417d1db56b60e1b006d55bb4",
"is_verified": false,
"line_number": 66
"line_number": 81
}
],
"apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift": [
@@ -9795,63 +9796,63 @@
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e",
"is_verified": false,
"line_number": 1612
"line_number": 1614
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "bde4db9b4c3be4049adc3b9a69851d7c35119770",
"is_verified": false,
"line_number": 1628
"line_number": 1630
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "7f8aaf142ce0552c260f2e546dda43ddd7c9aef3",
"is_verified": false,
"line_number": 1815
"line_number": 1817
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27",
"is_verified": false,
"line_number": 1988
"line_number": 1990
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0",
"is_verified": false,
"line_number": 2044
"line_number": 2046
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
"is_verified": false,
"line_number": 2276
"line_number": 2278
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6",
"is_verified": false,
"line_number": 2404
"line_number": 2408
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281",
"is_verified": false,
"line_number": 2657
"line_number": 2661
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25",
"is_verified": false,
"line_number": 2659
"line_number": 2663
}
],
"docs/gateway/configuration.md": [
@@ -11481,7 +11482,7 @@
"filename": "src/agents/models-config.e2e-harness.ts",
"hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d",
"is_verified": false,
"line_number": 131
"line_number": 157
}
],
"src/agents/models-config.fills-missing-provider-apikey-from-env-var.e2e.test.ts": [
@@ -11515,14 +11516,14 @@
"filename": "src/agents/models-config.providers.nvidia.test.ts",
"hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f",
"is_verified": false,
"line_number": 13
"line_number": 14
},
{
"type": "Secret Keyword",
"filename": "src/agents/models-config.providers.nvidia.test.ts",
"hashed_secret": "be1a7be9d4d5af417882b267f4db6dddc08507bd",
"is_verified": false,
"line_number": 22
"line_number": 23
}
],
"src/agents/models-config.providers.ollama.e2e.test.ts": [
@@ -11746,7 +11747,7 @@
"filename": "src/auto-reply/status.test.ts",
"hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f",
"is_verified": false,
"line_number": 36
"line_number": 37
}
],
"src/browser/bridge-server.auth.test.ts": [
@@ -11764,14 +11765,14 @@
"filename": "src/browser/browser-utils.test.ts",
"hashed_secret": "4e126c049580d66ca1549fa534d95a7263f27f46",
"is_verified": false,
"line_number": 43
"line_number": 47
},
{
"type": "Basic Auth Credentials",
"filename": "src/browser/browser-utils.test.ts",
"hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684",
"is_verified": false,
"line_number": 164
"line_number": 171
}
],
"src/browser/cdp.test.ts": [
@@ -11780,7 +11781,7 @@
"filename": "src/browser/cdp.test.ts",
"hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684",
"is_verified": false,
"line_number": 243
"line_number": 318
}
],
"src/channels/plugins/plugins-channel.test.ts": [
@@ -12100,21 +12101,21 @@
"filename": "src/config/config.env-vars.test.ts",
"hashed_secret": "a24ef9c1a27cac44823571ceef2e8262718eee36",
"is_verified": false,
"line_number": 13
"line_number": 17
},
{
"type": "Secret Keyword",
"filename": "src/config/config.env-vars.test.ts",
"hashed_secret": "29d5f92e9ee44d4854d6dfaeefc3dc27d779fdf3",
"is_verified": false,
"line_number": 19
"line_number": 23
},
{
"type": "Secret Keyword",
"filename": "src/config/config.env-vars.test.ts",
"hashed_secret": "1672b6a1e7956c6a70f45d699aa42a351b1f8b80",
"is_verified": false,
"line_number": 27
"line_number": 31
}
],
"src/config/config.irc.test.ts": [
@@ -12335,14 +12336,14 @@
"filename": "src/config/schema.help.ts",
"hashed_secret": "9f4cda226d3868676ac7f86f59e4190eb94bd208",
"is_verified": false,
"line_number": 651
"line_number": 653
},
{
"type": "Secret Keyword",
"filename": "src/config/schema.help.ts",
"hashed_secret": "01822c8bbf6a8b136944b14182cb885100ec2eae",
"is_verified": false,
"line_number": 684
"line_number": 686
}
],
"src/config/schema.irc.ts": [
@@ -12381,14 +12382,14 @@
"filename": "src/config/schema.labels.ts",
"hashed_secret": "e73c9fcad85cd4eecc74181ec4bdb31064d68439",
"is_verified": false,
"line_number": 216
"line_number": 217
},
{
"type": "Secret Keyword",
"filename": "src/config/schema.labels.ts",
"hashed_secret": "2eda7cd978f39eebec3bf03e4410a40e14167fff",
"is_verified": false,
"line_number": 325
"line_number": 326
}
],
"src/config/slack-http-config.test.ts": [
@@ -13034,5 +13035,5 @@
}
]
},
"generated_at": "2026-03-08T18:30:57Z"
"generated_at": "2026-03-08T20:41:38Z"
}

View File

@@ -11,9 +11,15 @@ Docs: https://docs.openclaw.ai
- Talk mode: add top-level `talk.silenceTimeoutMs` config so Talk waits a configurable amount of silence before auto-sending the current transcript, while keeping each platform's existing default pause window when unset. (#39607) Thanks @danodoesdesign. Fixes #17147.
- CLI/install: include the short git commit hash in `openclaw --version` output when metadata is available, and keep installer version checks compatible with the decorated format. (#39712) thanks @sourman.
- Docs/Web search: restore $5/month free-credit details, replace defunct "Data for Search"/"Data for AI" plan names with current "Search" plan, and note legacy subscription validity in Brave setup docs. Follows up on #26860. (#40111) Thanks @remusao.
- macOS/onboarding: add a remote gateway token field for remote mode, preserve existing non-plaintext `gateway.remote.token` config values until explicitly replaced, and warn when the loaded token shape cannot be used directly from the macOS app. (#40187, supersedes #34614) Thanks @cgdusek.
- CLI/backup: add `openclaw backup create` and `openclaw backup verify` for local state archives, including `--only-config`, `--no-include-workspace`, manifest/payload validation, and backup guidance in destructive flows. (#40163) thanks @shichangs.
- CLI/backup: improve archive naming for date sorting, add config-only backup mode, and harden backup planning, publication, and verification edge cases. (#40163) Thanks @gumadeiras.
### Breaking
### Fixes
- Docker/runtime image: prune dev dependencies, strip build-only dist metadata for smaller Docker images. (#40307) Thanks @vincentkoc.
- Plugins/channel onboarding: prefer bundled channel plugins over duplicate npm-installed copies during onboarding and release-channel sync, preventing bundled plugins from being shadowed by npm installs with the same plugin ID. (#40092)
- macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1.
- Mattermost replies: keep `root_id` pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda.
@@ -27,9 +33,18 @@ Docs: https://docs.openclaw.ai
- Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera.
- Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii.
- ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky.
- Agents/openai-codex: normalize `gpt-5.4` fallback transport back to `openai-codex-responses` on `chatgpt.com/backend-api` when config drifts to the generic OpenAI responses endpoint. (#38736) Thanks @0xsline.
- Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for `/json/*` tab operations so local `ws://` / `wss://` profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150.
- Browser/CDP: rewrite wildcard `ws://0.0.0.0` and `ws://[::]` debugger URLs from remote `/json/version` responses back to the external CDP host/port, fixing Browserless-style container endpoints. (#17760) Thanks @joeharouni.
- Browser/extension relay: wait briefly for a previously attached Chrome tab to reappear after transient relay drops before failing with `tab not found`, reducing noisy reconnect flakes. (#32461) Thanks @AaronWander.
- Browser/extension relay: add `browser.relayBindHost` so the Chrome relay can bind to an explicit non-loopback address for WSL2 and other cross-namespace setups, while preserving loopback-only defaults. (#39364) Thanks @mvanhorn.
- Docs/browser: add a layered WSL2 + Windows remote Chrome CDP troubleshooting guide, including Control UI origin pitfalls and extension-relay bind-address guidance. (#39407) Thanks @Owlock.
- Context engine registry/bundled builds: share the registry state through a `globalThis` singleton so duplicated bundled module copies can resolve engines registered by each other at runtime, with regression coverage for duplicate-module imports. (#40115) thanks @jalehman.
- macOS/Tailscale gateway discovery: keep Tailscale Serve probing alive when other remote gateways are already discovered, prefer direct transport for resolved `.ts.net` and Tailscale Serve gateways, and set `TERM=dumb` for GUI-launched Tailscale CLI discovery. (#40167) thanks @ngutman.
- Podman/setup: fix `cannot chdir: Permission denied` in `run_as_user` when `setup-podman.sh` is invoked from a directory the target user cannot access, by wrapping user-switch calls in a subshell that cd's to `/tmp` with `/` fallback. (#39435) Thanks @langdon and @jlcbk.
- Podman/SELinux: auto-detect SELinux enforcing/permissive mode and add `:Z` relabel to bind mounts in `run-openclaw-podman.sh` and the Quadlet template, fixing `EACCES` on Fedora/RHEL hosts. Supports `OPENCLAW_BIND_MOUNT_OPTIONS` override. (#39449) Thanks @langdon and @githubbzxs.
- TUI/theme: detect light terminal backgrounds via `COLORFGBG` and pick a WCAG AA-compliant light palette, with `OPENCLAW_THEME=light|dark` override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc.
- Agents/context-engine plugins: bootstrap runtime plugins once at embedded-run, compaction, and subagent boundaries so plugin-provided context engines and hooks load from the active workspace before runtime resolution. (#40232)
## 2026.3.7
@@ -115,6 +130,7 @@ Docs: https://docs.openclaw.ai
- Agents/compaction safeguard pre-check: skip embedded compaction before entering the Pi SDK when a session has no real conversation messages, avoiding unnecessary LLM API calls on idle sessions. (#36451) thanks @Sid-Qin.
- Config/schema cache key stability: build merged schema cache keys with incremental hashing to avoid large single-string serialization and prevent `RangeError: Invalid string length` on high-cardinality plugin/channel metadata. (#36603) Thanks @powermaster888.
- iMessage/cron completion announces: strip leaked inline reply tags (for example `[[reply_to:6100]]`) from user-visible completion text so announcement deliveries do not expose threading metadata. (#24600) Thanks @vincentkoc.
- Cron/manual run enqueue flow: queue `cron.run` requests behind the cron execution lane, return immediate `{ ok: true, enqueued: true, runId }` acknowledgements, preserve `{ ok: true, ran: false, reason }` skip responses for already-running and not-due jobs, and document the asynchronous completion flow. (#40204)
- Control UI/iMessage duplicate reply routing: keep internal webchat turns on dispatcher delivery (instead of origin-channel reroute) so Control UI chats do not duplicate replies into iMessage, while preserving webchat-provider relayed routing for external surfaces. Fixes #33483. Thanks @alicexmolt.
- Sessions/daily reset transcript archival: archive prior transcript files during stale-session scheduled/daily resets by capturing the previous session entry before rollover, preventing orphaned transcript files on disk. (#35493) Thanks @byungsker.
- Feishu/group slash command detection: normalize group mention wrappers before command-authorization probing so mention-prefixed commands (for example `@Bot/model` and `@Bot /reset`) are recognized as gateway commands instead of being forwarded to the agent. (#35994) Thanks @liuxiaopai-ai.

View File

@@ -58,6 +58,15 @@ RUN NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile
COPY . .
# Normalize extension paths now so runtime COPY preserves safe modes
# without adding a second full extensions layer.
RUN for dir in /app/extensions /app/.agent /app/.agents; do \
if [ -d "$dir" ]; then \
find "$dir" -type d -exec chmod 755 {} +; \
find "$dir" -type f -exec chmod 644 {} +; \
fi; \
done
# A2UI bundle may fail under QEMU cross-compilation (e.g. building amd64
# on Apple Silicon). CI builds natively per-arch so this is a no-op there.
# Stub it so local cross-arch builds still succeed.
@@ -67,11 +76,17 @@ RUN pnpm canvas:a2ui:bundle || \
echo "/* A2UI bundle unavailable in this build */" > src/canvas-host/a2ui/a2ui.bundle.js && \
echo "stub" > src/canvas-host/a2ui/.bundle.hash && \
rm -rf vendor/a2ui apps/shared/OpenClawKit/Tools/CanvasA2UI)
RUN pnpm build
RUN pnpm build:docker
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
ENV OPENCLAW_PREFER_PNPM=1
RUN pnpm ui:build
# Prune dev dependencies and strip build-only metadata before copying
# runtime assets into the final image.
FROM build AS runtime-assets
RUN CI=true pnpm prune --prod && \
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete
# ── Runtime base images ─────────────────────────────────────────
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS base-default
ARG OPENCLAW_NODE_BOOKWORM_DIGEST
@@ -110,19 +125,22 @@ RUN apt-get update && \
RUN chown node:node /app
COPY --from=build --chown=node:node /app/dist ./dist
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/package.json .
COPY --from=build --chown=node:node /app/openclaw.mjs .
COPY --from=build --chown=node:node /app/extensions ./extensions
COPY --from=build --chown=node:node /app/skills ./skills
COPY --from=build --chown=node:node /app/docs ./docs
COPY --from=runtime-assets --chown=node:node /app/dist ./dist
COPY --from=runtime-assets --chown=node:node /app/node_modules ./node_modules
COPY --from=runtime-assets --chown=node:node /app/package.json .
COPY --from=runtime-assets --chown=node:node /app/openclaw.mjs .
COPY --from=runtime-assets --chown=node:node /app/extensions ./extensions
COPY --from=runtime-assets --chown=node:node /app/skills ./skills
COPY --from=runtime-assets --chown=node:node /app/docs ./docs
# Docker live-test runners invoke `pnpm` inside the runtime image.
# Activate the exact pinned package manager now so the container does not
# rely on a first-run network fetch or missing shims under the non-root user.
RUN corepack enable && \
corepack prepare "$(node -p "require('./package.json').packageManager")" --activate
# Keep pnpm available in the runtime image for container-local workflows.
# Use a shared Corepack home so the non-root `node` user does not need a
# first-run network fetch when invoking pnpm.
ENV COREPACK_HOME=/usr/local/share/corepack
RUN install -d -m 0755 "$COREPACK_HOME" && \
corepack enable && \
corepack prepare "$(node -p "require('./package.json').packageManager")" --activate && \
chmod -R a+rX "$COREPACK_HOME"
# Install additional system packages needed by your skills or extensions.
# Example: docker build --build-arg OPENCLAW_DOCKER_APT_PACKAGES="python3 wget" .
@@ -182,15 +200,6 @@ RUN if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
fi
# Normalize extension paths so plugin safety checks do not reject
# world-writable directories inherited from source file modes.
RUN for dir in /app/extensions /app/.agent /app/.agents; do \
if [ -d "$dir" ]; then \
find "$dir" -type d -exec chmod 755 {} +; \
find "$dir" -type f -exec chmod 644 {} +; \
fi; \
done
# Expose the CLI binary without requiring npm global writes as non-root.
RUN ln -sf /app/openclaw.mjs /usr/local/bin/openclaw \
&& chmod 755 /app/openclaw.mjs

View File

@@ -20,8 +20,7 @@ RUN apt-get update \
xvfb \
&& rm -rf /var/lib/apt/lists/*
COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser
RUN chmod +x /usr/local/bin/openclaw-sandbox-browser
COPY --chmod=755 scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser
RUN useradd --create-home --shell /bin/bash sandbox
USER sandbox

View File

@@ -1,9 +1,24 @@
import Foundation
import Network
import OpenClawKit
import os
enum A2UIReadyState {
case ready(String)
case hostNotConfigured
case hostUnavailable
}
extension NodeAppModel {
func resolveCanvasHostURL() async -> String? {
guard let raw = await self.gatewaySession.currentCanvasHostUrl() else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
if let host = base.host, LoopbackHost.isLoopback(host) {
return nil
}
return base.appendingPathComponent("__openclaw__/canvas/").absoluteString
}
func _test_resolveA2UIHostURL() async -> String? {
await self.resolveA2UIHostURL()
}
@@ -19,22 +34,14 @@ extension NodeAppModel {
}
func showA2UIOnConnectIfNeeded() async {
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
await MainActor.run {
self.lastAutoA2uiURL = nil
self.screen.showDefaultCanvas()
}
return
}
let current = self.screen.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
if current.isEmpty || current == self.lastAutoA2uiURL {
// Avoid navigating the WKWebView to an unreachable host: it leaves a persistent
// "could not connect to the server" overlay even when the gateway is connected.
if let url = URL(string: a2uiUrl),
if let canvasUrl = await self.resolveCanvasHostURLWithCapabilityRefresh(),
let url = URL(string: canvasUrl),
await Self.probeTCP(url: url, timeoutSeconds: 2.5)
{
self.screen.navigate(to: a2uiUrl)
self.lastAutoA2uiURL = a2uiUrl
self.screen.navigate(to: canvasUrl)
self.lastAutoA2uiURL = canvasUrl
} else {
self.lastAutoA2uiURL = nil
self.screen.showDefaultCanvas()
@@ -42,11 +49,46 @@ extension NodeAppModel {
}
}
func ensureA2UIReadyWithCapabilityRefresh(timeoutMs: Int = 5000) async -> A2UIReadyState {
guard let initialUrl = await self.resolveA2UIHostURLWithCapabilityRefresh() else {
return .hostNotConfigured
}
self.screen.navigate(to: initialUrl)
if await self.screen.waitForA2UIReady(timeoutMs: timeoutMs) {
return .ready(initialUrl)
}
// First render can fail when scoped capability rotates between reconnects.
guard await self.gatewaySession.refreshNodeCanvasCapability() else { return .hostUnavailable }
guard let refreshedUrl = await self.resolveA2UIHostURL() else { return .hostUnavailable }
self.screen.navigate(to: refreshedUrl)
if await self.screen.waitForA2UIReady(timeoutMs: timeoutMs) {
return .ready(refreshedUrl)
}
return .hostUnavailable
}
func showLocalCanvasOnDisconnect() {
self.lastAutoA2uiURL = nil
self.screen.showDefaultCanvas()
}
private func resolveA2UIHostURLWithCapabilityRefresh() async -> String? {
if let url = await self.resolveA2UIHostURL() {
return url
}
guard await self.gatewaySession.refreshNodeCanvasCapability() else { return nil }
return await self.resolveA2UIHostURL()
}
private func resolveCanvasHostURLWithCapabilityRefresh() async -> String? {
if let url = await self.resolveCanvasHostURL() {
return url
}
guard await self.gatewaySession.refreshNodeCanvasCapability() else { return nil }
return await self.resolveCanvasHostURL()
}
private static func probeTCP(url: URL, timeoutSeconds: Double) async -> Bool {
guard let host = url.host, !host.isEmpty else { return false }
let portInt = url.port ?? ((url.scheme ?? "").lowercased() == "wss" ? 443 : 80)

View File

@@ -57,6 +57,7 @@ final class NodeAppModel {
private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink")
private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake")
private let pendingActionLogger = Logger(subsystem: "ai.openclaw.ios", category: "PendingAction")
private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake")
private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply")
enum CameraHUDKind {
@@ -130,6 +131,7 @@ final class NodeAppModel {
private var backgroundReconnectLeaseUntil: Date?
private var lastSignificantLocationWakeAt: Date?
@ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator()
private var pendingForegroundActionDrainInFlight = false
private var gatewayConnected = false
private var operatorConnected = false
@@ -329,6 +331,9 @@ final class NodeAppModel {
}
await self.talkMode.resumeAfterBackground(wasSuspended: suspended, wasKeptActive: keptActive)
}
Task { [weak self] in
await self?.resumePendingForegroundNodeActionsIfNeeded(trigger: "scene_active")
}
}
if phase == .active, self.reconnectAfterBackgroundArmed {
self.reconnectAfterBackgroundArmed = false
@@ -877,16 +882,17 @@ final class NodeAppModel {
let command = req.command
switch command {
case OpenClawCanvasA2UICommand.reset.rawValue:
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
switch await self.ensureA2UIReadyWithCapabilityRefresh(timeoutMs: 5000) {
case .ready:
break
case .hostNotConfigured:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .unavailable,
message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host"))
}
self.screen.navigate(to: a2uiUrl)
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
case .hostUnavailable:
return BridgeInvokeResponse(
id: req.id,
ok: false,
@@ -894,7 +900,6 @@ final class NodeAppModel {
code: .unavailable,
message: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable"))
}
let json = try await self.screen.eval(javaScript: """
(() => {
const host = globalThis.openclawA2UI;
@@ -903,6 +908,7 @@ final class NodeAppModel {
})()
""")
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
case OpenClawCanvasA2UICommand.push.rawValue, OpenClawCanvasA2UICommand.pushJSONL.rawValue:
let messages: [OpenClawKit.AnyCodable]
if command == OpenClawCanvasA2UICommand.pushJSONL.rawValue {
@@ -919,16 +925,17 @@ final class NodeAppModel {
}
}
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
switch await self.ensureA2UIReadyWithCapabilityRefresh(timeoutMs: 5000) {
case .ready:
break
case .hostNotConfigured:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .unavailable,
message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host"))
}
self.screen.navigate(to: a2uiUrl)
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
case .hostUnavailable:
return BridgeInvokeResponse(
id: req.id,
ok: false,
@@ -2098,6 +2105,22 @@ private extension NodeAppModel {
}
extension NodeAppModel {
private struct PendingForegroundNodeAction: Decodable {
var id: String
var command: String
var paramsJSON: String?
var enqueuedAtMs: Int?
}
private struct PendingForegroundNodeActionsResponse: Decodable {
var nodeId: String?
var actions: [PendingForegroundNodeAction]
}
private struct PendingForegroundNodeActionsAckRequest: Encodable {
var ids: [String]
}
private func refreshShareRouteFromGateway() async {
struct Params: Codable {
var includeGlobal: Bool
@@ -2195,6 +2218,83 @@ extension NodeAppModel {
func onNodeGatewayConnected() async {
await self.registerAPNsTokenIfNeeded()
await self.flushQueuedWatchRepliesIfConnected()
await self.resumePendingForegroundNodeActionsIfNeeded(trigger: "node_connected")
}
private func resumePendingForegroundNodeActionsIfNeeded(trigger: String) async {
guard !self.isBackgrounded else { return }
guard await self.isGatewayConnected() else { return }
guard !self.pendingForegroundActionDrainInFlight else { return }
self.pendingForegroundActionDrainInFlight = true
defer { self.pendingForegroundActionDrainInFlight = false }
do {
let payload = try await self.nodeGateway.request(
method: "node.pending.pull",
paramsJSON: "{}",
timeoutSeconds: 6)
let decoded = try JSONDecoder().decode(
PendingForegroundNodeActionsResponse.self,
from: payload)
guard !decoded.actions.isEmpty else { return }
self.pendingActionLogger.info(
"Pending actions pulled trigger=\(trigger, privacy: .public) "
+ "count=\(decoded.actions.count, privacy: .public)")
await self.applyPendingForegroundNodeActions(decoded.actions, trigger: trigger)
} catch {
// Best-effort only.
}
}
private func applyPendingForegroundNodeActions(
_ actions: [PendingForegroundNodeAction],
trigger: String) async
{
for action in actions {
guard !self.isBackgrounded else {
self.pendingActionLogger.info(
"Pending action replay paused trigger=\(trigger, privacy: .public): app backgrounded")
return
}
let req = BridgeInvokeRequest(
id: action.id,
command: action.command,
paramsJSON: action.paramsJSON)
let result = await self.handleInvoke(req)
self.pendingActionLogger.info(
"Pending action replay trigger=\(trigger, privacy: .public) "
+ "id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) "
+ "ok=\(result.ok, privacy: .public)")
guard result.ok else { return }
let acked = await self.ackPendingForegroundNodeAction(
id: action.id,
trigger: trigger,
command: action.command)
guard acked else { return }
}
}
private func ackPendingForegroundNodeAction(
id: String,
trigger: String,
command: String) async -> Bool
{
do {
let payload = try JSONEncoder().encode(PendingForegroundNodeActionsAckRequest(ids: [id]))
let paramsJSON = String(decoding: payload, as: UTF8.self)
_ = try await self.nodeGateway.request(
method: "node.pending.ack",
paramsJSON: paramsJSON,
timeoutSeconds: 6)
return true
} catch {
self.pendingActionLogger.error(
"Pending action ack failed trigger=\(trigger, privacy: .public) "
+ "id=\(id, privacy: .public) command=\(command, privacy: .public) "
+ "error=\(String(describing: error), privacy: .public)")
return false
}
}
private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async {
@@ -2843,6 +2943,19 @@ extension NodeAppModel {
self.gatewayConnected = connected
}
func _test_applyPendingForegroundNodeActions(
_ actions: [(id: String, command: String, paramsJSON: String?)]) async
{
let mapped = actions.map { action in
PendingForegroundNodeAction(
id: action.id,
command: action.command,
paramsJSON: action.paramsJSON,
enqueuedAtMs: nil)
}
await self.applyPendingForegroundNodeActions(mapped, trigger: "test")
}
static func _test_currentDeepLinkKey() -> String {
self.expectedDeepLinkKey()
}

View File

@@ -179,6 +179,41 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
#expect(payload?["result"] as? String == "2")
}
@Test @MainActor func pendingForegroundActionsReplayCanvasNavigate() async throws {
let appModel = NodeAppModel()
let navigateParams = OpenClawCanvasNavigateParams(url: "http://example.com/")
let navData = try JSONEncoder().encode(navigateParams)
let navJSON = String(decoding: navData, as: UTF8.self)
await appModel._test_applyPendingForegroundNodeActions([
(
id: "pending-nav-1",
command: OpenClawCanvasCommand.navigate.rawValue,
paramsJSON: navJSON
),
])
#expect(appModel.screen.urlString == "http://example.com/")
}
@Test @MainActor func pendingForegroundActionsDoNotApplyWhileBackgrounded() async throws {
let appModel = NodeAppModel()
appModel.setScenePhase(.background)
let navigateParams = OpenClawCanvasNavigateParams(url: "http://example.com/")
let navData = try JSONEncoder().encode(navigateParams)
let navJSON = String(decoding: navData, as: UTF8.self)
await appModel._test_applyPendingForegroundNodeActions([
(
id: "pending-nav-bg",
command: OpenClawCanvasCommand.navigate.rawValue,
paramsJSON: navJSON
),
])
#expect(appModel.screen.urlString.isEmpty)
}
@Test @MainActor func handleInvokeA2UICommandsFailWhenHostMissing() async throws {
let appModel = NodeAppModel()

View File

@@ -9,6 +9,7 @@ import SwiftUI
final class AppState {
private let isPreview: Bool
private var isInitializing = true
private var isApplyingRemoteTokenConfig = false
private var configWatcher: ConfigFileWatcher?
private var suppressVoiceWakeGlobalSync = false
private var voiceWakeGlobalSyncTask: Task<Void, Never>?
@@ -213,6 +214,18 @@ final class AppState {
didSet { self.syncGatewayConfigIfNeeded() }
}
var remoteToken: String {
didSet {
guard !self.isApplyingRemoteTokenConfig else { return }
self.remoteTokenDirty = true
self.remoteTokenUnsupported = false
self.syncGatewayConfigIfNeeded()
}
}
private(set) var remoteTokenDirty = false
private(set) var remoteTokenUnsupported = false
var remoteIdentity: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
}
@@ -281,6 +294,7 @@ final class AppState {
let configRoot = OpenClawConfigFile.loadDict()
let configRemoteUrl = GatewayRemoteConfig.resolveUrlString(root: configRoot)
let configRemoteToken = GatewayRemoteConfig.resolveTokenValue(root: configRoot)
let configRemoteTransport = GatewayRemoteConfig.resolveTransport(root: configRoot)
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
self.remoteTransport = configRemoteTransport
@@ -297,6 +311,9 @@ final class AppState {
self.remoteTarget = storedRemoteTarget
}
self.remoteUrl = configRemoteUrl ?? ""
self.remoteToken = configRemoteToken.textFieldValue
self.remoteTokenDirty = false
self.remoteTokenUnsupported = configRemoteToken.isUnsupportedNonString
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
@@ -374,13 +391,29 @@ final class AppState {
return false
}
private func applyRemoteTokenState(_ tokenValue: GatewayRemoteConfig.TokenValue) {
let nextToken = tokenValue.textFieldValue
let unsupported = tokenValue.isUnsupportedNonString
guard self.remoteToken != nextToken || self.remoteTokenDirty || self.remoteTokenUnsupported != unsupported
else {
return
}
self.isApplyingRemoteTokenConfig = true
self.remoteToken = nextToken
self.isApplyingRemoteTokenConfig = false
self.remoteTokenDirty = false
self.remoteTokenUnsupported = unsupported
}
private static func updatedRemoteGatewayConfig(
current: [String: Any],
transport: RemoteTransport,
remoteUrl: String,
remoteHost: String?,
remoteTarget: String,
remoteIdentity: String) -> (remote: [String: Any], changed: Bool)
remoteIdentity: String,
remoteToken: String,
remoteTokenDirty: Bool) -> (remote: [String: Any], changed: Bool)
{
var remote = current
var changed = false
@@ -417,6 +450,10 @@ final class AppState {
changed = Self.updateGatewayString(&remote, key: "sshIdentity", value: remoteIdentity) || changed
}
if remoteTokenDirty {
changed = Self.updateGatewayString(&remote, key: "token", value: remoteToken) || changed
}
return (remote, changed)
}
@@ -439,6 +476,7 @@ final class AppState {
let gateway = root["gateway"] as? [String: Any]
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let remoteUrl = GatewayRemoteConfig.resolveUrlString(root: root)
let remoteToken = GatewayRemoteConfig.resolveTokenValue(root: root)
let hasRemoteUrl = !(remoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true)
@@ -470,6 +508,7 @@ final class AppState {
if remoteUrlText != self.remoteUrl {
self.remoteUrl = remoteUrlText
}
self.applyRemoteTokenState(remoteToken)
let targetMode = desiredMode ?? self.connectionMode
if targetMode == .remote,
@@ -496,14 +535,20 @@ final class AppState {
}
}
private func syncGatewayConfigIfNeeded() {
guard !self.isPreview, !self.isInitializing else { return }
private static func syncedGatewayRoot(
currentRoot: [String: Any],
connectionMode: ConnectionMode,
remoteTransport: RemoteTransport,
remoteTarget: String,
remoteIdentity: String,
remoteUrl: String,
remoteToken: String,
remoteTokenDirty: Bool) -> (root: [String: Any], changed: Bool)
{
var root = currentRoot
var gateway = root["gateway"] as? [String: Any] ?? [:]
var changed = false
let connectionMode = self.connectionMode
let remoteTarget = self.remoteTarget
let remoteIdentity = self.remoteIdentity
let remoteTransport = self.remoteTransport
let remoteUrl = self.remoteUrl
let desiredMode: String? = switch connectionMode {
case .local:
"local"
@@ -512,49 +557,70 @@ final class AppState {
case .unconfigured:
nil
}
let remoteHost = connectionMode == .remote
? CommandResolver.parseSSHTarget(remoteTarget)?.host
: nil
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let desiredMode {
if currentMode != desiredMode {
gateway["mode"] = desiredMode
changed = true
}
} else if currentMode != nil {
gateway.removeValue(forKey: "mode")
changed = true
}
if connectionMode == .remote {
let remoteHost = CommandResolver.parseSSHTarget(remoteTarget)?.host
let currentRemote = gateway["remote"] as? [String: Any] ?? [:]
let updated = Self.updatedRemoteGatewayConfig(
current: currentRemote,
transport: remoteTransport,
remoteUrl: remoteUrl,
remoteHost: remoteHost,
remoteTarget: remoteTarget,
remoteIdentity: remoteIdentity,
remoteToken: remoteToken,
remoteTokenDirty: remoteTokenDirty)
if updated.changed {
gateway["remote"] = updated.remote
changed = true
}
}
guard changed else { return (currentRoot, false) }
if gateway.isEmpty {
root.removeValue(forKey: "gateway")
} else {
root["gateway"] = gateway
}
return (root, true)
}
private func syncGatewayConfigIfNeeded() {
guard !self.isPreview, !self.isInitializing else { return }
let connectionMode = self.connectionMode
let remoteTarget = self.remoteTarget
let remoteIdentity = self.remoteIdentity
let remoteTransport = self.remoteTransport
let remoteUrl = self.remoteUrl
let remoteToken = self.remoteToken
let remoteTokenDirty = self.remoteTokenDirty
Task { @MainActor in
// Keep app-only connection settings local to avoid overwriting remote gateway config.
var root = OpenClawConfigFile.loadDict()
var gateway = root["gateway"] as? [String: Any] ?? [:]
var changed = false
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let desiredMode {
if currentMode != desiredMode {
gateway["mode"] = desiredMode
changed = true
}
} else if currentMode != nil {
gateway.removeValue(forKey: "mode")
changed = true
}
if connectionMode == .remote {
let currentRemote = gateway["remote"] as? [String: Any] ?? [:]
let updated = Self.updatedRemoteGatewayConfig(
current: currentRemote,
transport: remoteTransport,
remoteUrl: remoteUrl,
remoteHost: remoteHost,
remoteTarget: remoteTarget,
remoteIdentity: remoteIdentity)
if updated.changed {
gateway["remote"] = updated.remote
changed = true
}
}
guard changed else { return }
if gateway.isEmpty {
root.removeValue(forKey: "gateway")
} else {
root["gateway"] = gateway
}
OpenClawConfigFile.saveDict(root)
let synced = Self.syncedGatewayRoot(
currentRoot: OpenClawConfigFile.loadDict(),
connectionMode: connectionMode,
remoteTransport: remoteTransport,
remoteTarget: remoteTarget,
remoteIdentity: remoteIdentity,
remoteUrl: remoteUrl,
remoteToken: remoteToken,
remoteTokenDirty: remoteTokenDirty)
guard synced.changed else { return }
OpenClawConfigFile.saveDict(synced.root)
}
}
@@ -697,6 +763,7 @@ extension AppState {
state.canvasEnabled = true
state.remoteTarget = "user@example.com"
state.remoteUrl = "wss://gateway.example.ts.net"
state.remoteToken = "example-token"
state.remoteIdentity = "~/.ssh/id_ed25519"
state.remoteProjectRoot = "~/Projects/openclaw"
state.remoteCliPath = ""
@@ -704,6 +771,53 @@ extension AppState {
}
}
#if DEBUG
@MainActor
extension AppState {
static func _testUpdatedRemoteGatewayConfig(
current: [String: Any],
transport: RemoteTransport,
remoteUrl: String,
remoteHost: String?,
remoteTarget: String,
remoteIdentity: String,
remoteToken: String,
remoteTokenDirty: Bool) -> [String: Any]
{
Self.updatedRemoteGatewayConfig(
current: current,
transport: transport,
remoteUrl: remoteUrl,
remoteHost: remoteHost,
remoteTarget: remoteTarget,
remoteIdentity: remoteIdentity,
remoteToken: remoteToken,
remoteTokenDirty: remoteTokenDirty).remote
}
static func _testSyncedGatewayRoot(
currentRoot: [String: Any],
connectionMode: ConnectionMode,
remoteTransport: RemoteTransport,
remoteTarget: String,
remoteIdentity: String,
remoteUrl: String,
remoteToken: String,
remoteTokenDirty: Bool) -> [String: Any]
{
Self.syncedGatewayRoot(
currentRoot: currentRoot,
connectionMode: connectionMode,
remoteTransport: remoteTransport,
remoteTarget: remoteTarget,
remoteIdentity: remoteIdentity,
remoteUrl: remoteUrl,
remoteToken: remoteToken,
remoteTokenDirty: remoteTokenDirty).root
}
}
#endif
@MainActor
enum AppStateStore {
static let shared = AppState()

View File

@@ -6,11 +6,16 @@ enum GatewayDiscoverySelectionSupport {
gateway: GatewayDiscoveryModel.DiscoveredGateway,
state: AppState)
{
if state.remoteTransport == .direct {
state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
} else {
state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
let preferredTransport = self.preferredTransport(
for: gateway,
current: state.remoteTransport)
if preferredTransport != state.remoteTransport {
state.remoteTransport = preferredTransport
}
state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
OpenClawConfigFile.setRemoteGatewayUrl(
host: endpoint.host,
@@ -19,4 +24,30 @@ enum GatewayDiscoverySelectionSupport {
OpenClawConfigFile.clearRemoteGatewayUrl()
}
}
static func preferredTransport(
for gateway: GatewayDiscoveryModel.DiscoveredGateway,
current: AppState.RemoteTransport) -> AppState.RemoteTransport
{
if self.shouldPreferDirectTransport(for: gateway) {
return .direct
}
return current
}
static func shouldPreferDirectTransport(
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool
{
guard GatewayDiscoveryHelpers.directUrl(for: gateway) != nil else { return false }
if gateway.stableID.hasPrefix("tailscale-serve|") {
return true
}
guard let host = GatewayDiscoveryHelpers.resolvedServiceHost(for: gateway)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
else {
return false
}
return host.hasSuffix(".ts.net")
}
}

View File

@@ -188,13 +188,7 @@ actor GatewayEndpointStore {
private static func resolveConfigToken(isRemote: Bool, root: [String: Any]) -> String? {
if isRemote {
if let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let token = remote["token"] as? String
{
return token.trimmingCharacters(in: .whitespacesAndNewlines)
}
return nil
return GatewayRemoteConfig.resolveTokenString(root: root)
}
if let gateway = root["gateway"] as? [String: Any],

View File

@@ -2,6 +2,28 @@ import Foundation
import OpenClawKit
enum GatewayRemoteConfig {
enum TokenValue: Equatable {
case missing
case plaintext(String)
case unsupportedNonString
var textFieldValue: String {
switch self {
case let .plaintext(token):
token
case .missing, .unsupportedNonString:
""
}
}
var isUnsupportedNonString: Bool {
if case .unsupportedNonString = self {
return true
}
return false
}
}
static func resolveTransport(root: [String: Any]) -> AppState.RemoteTransport {
guard let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
@@ -24,6 +46,29 @@ enum GatewayRemoteConfig {
return trimmed.isEmpty ? nil : trimmed
}
static func resolveTokenValue(root: [String: Any]) -> TokenValue {
guard let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let tokenRaw = remote["token"]
else {
return .missing
}
guard let tokenString = tokenRaw as? String else {
return .unsupportedNonString
}
let trimmed = tokenString.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? .missing : .plaintext(trimmed)
}
static func resolveTokenString(root: [String: Any]) -> String? {
switch self.resolveTokenValue(root: root) {
case let .plaintext(token):
token
case .missing, .unsupportedNonString:
nil
}
}
static func resolveGatewayUrl(root: [String: Any]) -> URL? {
guard let raw = self.resolveUrlString(root: root) else { return nil }
return self.normalizeGatewayUrl(raw)

View File

@@ -149,6 +149,7 @@ struct GeneralSettings: View {
} else {
self.remoteDirectRow
}
self.remoteTokenRow
GatewayDiscoveryInlineList(
discovery: self.gatewayDiscovery,
@@ -291,6 +292,30 @@ struct GeneralSettings: View {
}
}
private var remoteTokenRow: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .center, spacing: 10) {
Text("Gateway token")
.font(.callout.weight(.semibold))
.frame(width: self.remoteLabelWidth, alignment: .leading)
SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
Text("Used when the remote gateway requires token auth.")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, self.remoteLabelWidth + 10)
if self.state.remoteTokenUnsupported {
Text(
"The current gateway.remote.token value is not plain text. OpenClaw for macOS cannot use it directly; enter a plaintext token here to replace it.")
.font(.caption)
.foregroundStyle(.orange)
.padding(.leading, self.remoteLabelWidth + 10)
}
}
}
private func remoteTestButton(disabled: Bool) -> some View {
Button {
Task { await self.testRemote() }
@@ -692,6 +717,7 @@ extension GeneralSettings {
state.remoteTransport = .ssh
state.remoteTarget = "user@host:2222"
state.remoteUrl = "wss://gateway.example.ts.net"
state.remoteToken = "example-token"
state.remoteIdentity = "/tmp/id_ed25519"
state.remoteProjectRoot = "/tmp/openclaw"
state.remoteCliPath = "/tmp/openclaw"

View File

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

View File

@@ -338,13 +338,12 @@ public final class GatewayDiscoveryModel {
var attempt = 0
let startedAt = Date()
while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 {
let hasResults = await MainActor.run {
if self.filterLocalGateways {
return !self.gateways.isEmpty
}
return self.gateways.contains(where: { !$0.isLocal })
let shouldContinue = await MainActor.run {
Self.shouldContinueTailscaleServeDiscovery(
currentGateways: self.gateways,
tailscaleServeGateways: self.tailscaleServeFallbackGateways)
}
if hasResults { return }
if !shouldContinue { return }
let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: 2.4)
if !beacons.isEmpty {
@@ -363,6 +362,15 @@ public final class GatewayDiscoveryModel {
}
}
static func shouldContinueTailscaleServeDiscovery(
currentGateways _: [DiscoveredGateway],
tailscaleServeGateways: [DiscoveredGateway]) -> Bool
{
// Tailscale Serve is a parallel discovery source. DNS-SD results should not suppress the
// probe, otherwise Serve-only gateways disappear as soon as any other remote gateway is found.
tailscaleServeGateways.isEmpty
}
private var hasUsableWideAreaResults: Bool {
guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return false }
guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false }

View File

@@ -203,6 +203,7 @@ enum TailscaleServeGatewayDiscovery {
let process = Process()
process.executableURL = URL(fileURLWithPath: path)
process.arguments = args
process.environment = self.commandEnvironment()
let outPipe = Pipe()
process.standardOutput = outPipe
process.standardError = FileHandle.nullDevice
@@ -227,6 +228,19 @@ enum TailscaleServeGatewayDiscovery {
return output?.isEmpty == false ? output : nil
}
static func commandEnvironment(
base: [String: String] = ProcessInfo.processInfo.environment) -> [String: String]
{
var env = base
let term = env["TERM"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if term.isEmpty {
// The macOS Tailscale app binary exits with CLIError error 3 when TERM is missing,
// which is common for GUI-launched app environments.
env["TERM"] = "dumb"
}
return env
}
private static func parseStatus(_ raw: String) -> TailscaleStatus? {
guard let data = raw.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(TailscaleStatus.self, from: data)

View File

@@ -836,6 +836,20 @@ public struct NodeRenameParams: Codable, Sendable {
public struct NodeListParams: Codable, Sendable {}
public struct NodePendingAckParams: Codable, Sendable {
public let ids: [String]
public init(
ids: [String])
{
self.ids = ids
}
private enum CodingKeys: String, CodingKey {
case ids
}
}
public struct NodeDescribeParams: Codable, Sendable {
public let nodeid: String

View File

@@ -0,0 +1,128 @@
import Testing
@testable import OpenClaw
@Suite(.serialized)
@MainActor
struct AppStateRemoteConfigTests {
@Test
func updatedRemoteGatewayConfigSetsTrimmedToken() {
let remote = AppState._testUpdatedRemoteGatewayConfig(
current: [:],
transport: .ssh,
remoteUrl: "",
remoteHost: "gateway.example",
remoteTarget: "alice@gateway.example",
remoteIdentity: "/tmp/id_ed25519",
remoteToken: " secret-token ",
remoteTokenDirty: true)
#expect(remote["token"] as? String == "secret-token")
}
@Test
func updatedRemoteGatewayConfigClearsTokenWhenBlank() {
let remote = AppState._testUpdatedRemoteGatewayConfig(
current: ["token": "old-token"],
transport: .direct,
remoteUrl: "wss://gateway.example",
remoteHost: nil,
remoteTarget: "",
remoteIdentity: "",
remoteToken: " ",
remoteTokenDirty: true)
#expect((remote["token"] as? String) == nil)
}
@Test
func syncedGatewayRootPreservesObjectTokenAcrossModeAndTransportChangesWhenUntouched() {
let initialRoot: [String: Any] = [
"gateway": [
"mode": "remote",
"remote": [
"transport": "direct",
"url": "wss://old-gateway.example",
"token": [
"$secretRef": "gateway-token", // pragma: allowlist secret
],
],
],
]
let sshRoot = AppState._testSyncedGatewayRoot(
currentRoot: initialRoot,
connectionMode: .remote,
remoteTransport: .ssh,
remoteTarget: "alice@gateway.example",
remoteIdentity: "",
remoteUrl: "",
remoteToken: "",
remoteTokenDirty: false)
let sshRemote = (sshRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any]
#expect((sshRemote?["token"] as? [String: String])?["$secretRef"] == "gateway-token") // pragma: allowlist secret
let localRoot = AppState._testSyncedGatewayRoot(
currentRoot: sshRoot,
connectionMode: .local,
remoteTransport: .ssh,
remoteTarget: "",
remoteIdentity: "",
remoteUrl: "",
remoteToken: "",
remoteTokenDirty: false)
let localGateway = localRoot["gateway"] as? [String: Any]
let localRemote = localGateway?["remote"] as? [String: Any]
#expect(localGateway?["mode"] as? String == "local")
#expect((localRemote?["token"] as? [String: String])?["$secretRef"] == "gateway-token") // pragma: allowlist secret
}
@Test
func updatedRemoteGatewayConfigReplacesObjectTokenWhenUserEntersPlaintext() {
let remote = AppState._testUpdatedRemoteGatewayConfig(
current: [
"token": [
"$secretRef": "gateway-token", // pragma: allowlist secret
],
],
transport: .direct,
remoteUrl: "wss://gateway.example",
remoteHost: nil,
remoteTarget: "",
remoteIdentity: "",
remoteToken: " fresh-token ",
remoteTokenDirty: true)
#expect(remote["token"] as? String == "fresh-token")
}
@Test
func updatedRemoteGatewayConfigClearsObjectTokenOnlyAfterExplicitEdit() {
let current: [String: Any] = [
"token": [
"$secretRef": "gateway-token", // pragma: allowlist secret
],
]
let preserved = AppState._testUpdatedRemoteGatewayConfig(
current: current,
transport: .direct,
remoteUrl: "wss://gateway.example",
remoteHost: nil,
remoteTarget: "",
remoteIdentity: "",
remoteToken: "",
remoteTokenDirty: false)
#expect((preserved["token"] as? [String: String])?["$secretRef"] == "gateway-token") // pragma: allowlist secret
let cleared = AppState._testUpdatedRemoteGatewayConfig(
current: current,
transport: .direct,
remoteUrl: "wss://gateway.example",
remoteHost: nil,
remoteTarget: "",
remoteIdentity: "",
remoteToken: " ",
remoteTokenDirty: true)
#expect((cleared["token"] as? String) == nil)
}
}

View File

@@ -121,6 +121,56 @@ struct GatewayDiscoveryModelTests {
port: 2201) == "peter@studio.local:2201")
}
@Test func `tailscale serve discovery continues when DNS-SD already found a remote gateway`() {
let dnsSdGateway = GatewayDiscoveryModel.DiscoveredGateway(
displayName: "Nearby Gateway",
serviceHost: "nearby-gateway.local",
servicePort: 18789,
lanHost: "nearby-gateway.local",
tailnetDns: nil,
sshPort: 22,
gatewayPort: 18789,
cliPath: nil,
stableID: "bonjour|nearby-gateway",
debugID: "bonjour",
isLocal: false)
#expect(GatewayDiscoveryModel.shouldContinueTailscaleServeDiscovery(
currentGateways: [dnsSdGateway],
tailscaleServeGateways: []))
}
@Test func `tailscale serve discovery stops after serve result is found`() {
let dnsSdGateway = GatewayDiscoveryModel.DiscoveredGateway(
displayName: "Nearby Gateway",
serviceHost: "nearby-gateway.local",
servicePort: 18789,
lanHost: "nearby-gateway.local",
tailnetDns: nil,
sshPort: 22,
gatewayPort: 18789,
cliPath: nil,
stableID: "bonjour|nearby-gateway",
debugID: "bonjour",
isLocal: false)
let serveGateway = GatewayDiscoveryModel.DiscoveredGateway(
displayName: "Tailscale Gateway",
serviceHost: "gateway-host.tailnet-example.ts.net",
servicePort: 443,
lanHost: nil,
tailnetDns: "gateway-host.tailnet-example.ts.net",
sshPort: 22,
gatewayPort: 443,
cliPath: nil,
stableID: "tailscale-serve|gateway-host.tailnet-example.ts.net",
debugID: "serve",
isLocal: false)
#expect(!GatewayDiscoveryModel.shouldContinueTailscaleServeDiscovery(
currentGateways: [dnsSdGateway],
tailscaleServeGateways: [serveGateway]))
}
@Test func `dedupe key prefers resolved endpoint across sources`() {
let wideArea = GatewayDiscoveryModel.DiscoveredGateway(
displayName: "Gateway",

View File

@@ -0,0 +1,90 @@
import Foundation
import OpenClawDiscovery
import Testing
@testable import OpenClaw
@Suite(.serialized)
@MainActor
struct GatewayDiscoverySelectionSupportTests {
private func makeGateway(
serviceHost: String?,
servicePort: Int?,
tailnetDns: String? = nil,
sshPort: Int = 22,
stableID: String) -> GatewayDiscoveryModel.DiscoveredGateway
{
GatewayDiscoveryModel.DiscoveredGateway(
displayName: "Gateway",
serviceHost: serviceHost,
servicePort: servicePort,
lanHost: nil,
tailnetDns: tailnetDns,
sshPort: sshPort,
gatewayPort: servicePort,
cliPath: nil,
stableID: stableID,
debugID: UUID().uuidString,
isLocal: false)
}
@Test func `selecting tailscale serve gateway switches to direct transport`() async {
let tailnetHost = "gateway-host.tailnet-example.ts.net"
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) {
let state = AppState(preview: true)
state.remoteTransport = .ssh
state.remoteTarget = "user@old-host"
GatewayDiscoverySelectionSupport.applyRemoteSelection(
gateway: self.makeGateway(
serviceHost: tailnetHost,
servicePort: 443,
tailnetDns: tailnetHost,
stableID: "tailscale-serve|\(tailnetHost)"),
state: state)
#expect(state.remoteTransport == .direct)
#expect(state.remoteUrl == "wss://\(tailnetHost)")
#expect(CommandResolver.parseSSHTarget(state.remoteTarget)?.host == tailnetHost)
}
}
@Test func `selecting merged tailnet gateway still switches to direct transport`() async {
let tailnetHost = "gateway-host.tailnet-example.ts.net"
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) {
let state = AppState(preview: true)
state.remoteTransport = .ssh
GatewayDiscoverySelectionSupport.applyRemoteSelection(
gateway: self.makeGateway(
serviceHost: tailnetHost,
servicePort: 443,
tailnetDns: tailnetHost,
stableID: "wide-area|openclaw.internal.|gateway-host"),
state: state)
#expect(state.remoteTransport == .direct)
#expect(state.remoteUrl == "wss://\(tailnetHost)")
}
}
@Test func `selecting nearby lan gateway keeps ssh transport`() async {
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) {
let state = AppState(preview: true)
state.remoteTransport = .ssh
state.remoteTarget = "user@old-host"
GatewayDiscoverySelectionSupport.applyRemoteSelection(
gateway: self.makeGateway(
serviceHost: "nearby-gateway.local",
servicePort: 18789,
stableID: "bonjour|nearby-gateway"),
state: state)
#expect(state.remoteTransport == .ssh)
#expect(CommandResolver.parseSSHTarget(state.remoteTarget)?.host == "nearby-gateway.local")
}
}
}

View File

@@ -61,7 +61,22 @@ struct GatewayEndpointStoreTests {
#expect(token == nil)
}
@Test func `resolve gateway password falls back to launchd`() {
@Test func resolveGatewayTokenUsesRemoteConfigToken() {
let token = GatewayEndpointStore._testResolveGatewayToken(
isRemote: true,
root: [
"gateway": [
"remote": [
"token": " remote-token ",
],
],
],
env: [:],
launchdSnapshot: nil)
#expect(token == "remote-token")
}
@Test func resolveGatewayPasswordFallsBackToLaunchd() {
let snapshot = self.makeLaunchAgentSnapshot(
env: ["OPENCLAW_GATEWAY_PASSWORD": "launchd-pass"],
token: nil,

View File

@@ -74,4 +74,25 @@ struct TailscaleServeGatewayDiscoveryTests {
#expect(TailscaleServeGatewayDiscovery
.resolveExecutablePath("definitely-not-here", env: ["PATH": "/tmp"]) == nil)
}
@Test func `adds TERM for GUI-launched tailscale subprocesses`() {
let env = TailscaleServeGatewayDiscovery.commandEnvironment(base: [
"HOME": "/Users/tester",
"PATH": "/usr/bin:/bin",
])
#expect(env["TERM"] == "dumb")
#expect(env["HOME"] == "/Users/tester")
#expect(env["PATH"] == "/usr/bin:/bin")
}
@Test func `preserves existing TERM when building tailscale subprocess environment`() {
let env = TailscaleServeGatewayDiscovery.commandEnvironment(base: [
"TERM": "xterm-256color",
"HOME": "/Users/tester",
])
#expect(env["TERM"] == "xterm-256color")
#expect(env["HOME"] == "/Users/tester")
}
}

View File

@@ -11,6 +11,50 @@ private struct NodeInvokeRequestPayload: Codable, Sendable {
var idempotencyKey: String?
}
private func replaceCanvasCapabilityInScopedHostUrl(scopedUrl: String, capability: String) -> String? {
let marker = "/__openclaw__/cap/"
guard let markerRange = scopedUrl.range(of: marker) else { return nil }
let capabilityStart = markerRange.upperBound
let suffix = scopedUrl[capabilityStart...]
let nextSlash = suffix.firstIndex(of: "/")
let nextQuery = suffix.firstIndex(of: "?")
let nextFragment = suffix.firstIndex(of: "#")
let capabilityEnd = [nextSlash, nextQuery, nextFragment].compactMap { $0 }.min() ?? scopedUrl.endIndex
guard capabilityStart < capabilityEnd else { return nil }
return String(scopedUrl[..<capabilityStart]) + capability + String(scopedUrl[capabilityEnd...])
}
func canonicalizeCanvasHostUrl(raw: String?, activeURL: URL?) -> String? {
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty else { return nil }
guard var parsed = URLComponents(string: trimmed) else { return trimmed }
let parsedHost = parsed.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedIsLoopback = !parsedHost.isEmpty && LoopbackHost.isLoopback(parsedHost)
if !parsedHost.isEmpty, !parsedIsLoopback {
guard let activeURL else { return trimmed }
let isTLS = activeURL.scheme?.lowercased() == "wss"
guard isTLS else { return trimmed }
parsed.scheme = "https"
if parsed.port == nil {
let tlsPort = activeURL.port ?? 443
parsed.port = (tlsPort == 443) ? nil : tlsPort
}
return parsed.string ?? trimmed
}
guard let activeURL, let fallbackHost = activeURL.host, !LoopbackHost.isLoopback(fallbackHost) else {
return trimmed
}
let isTLS = activeURL.scheme?.lowercased() == "wss"
parsed.scheme = isTLS ? "https" : "http"
parsed.host = fallbackHost
let fallbackPort = activeURL.port ?? (isTLS ? 443 : 80)
parsed.port = ((isTLS && fallbackPort == 443) || (!isTLS && fallbackPort == 80)) ? nil : fallbackPort
return parsed.string ?? trimmed
}
public actor GatewayNodeSession {
private let logger = Logger(subsystem: "ai.openclaw", category: "node.gateway")
@@ -223,6 +267,46 @@ public actor GatewayNodeSession {
self.canvasHostUrl
}
public func refreshNodeCanvasCapability(timeoutMs: Int = 8_000) async -> Bool {
guard let channel = self.channel else { return false }
do {
let data = try await channel.request(
method: "node.canvas.capability.refresh",
params: [:],
timeoutMs: Double(max(timeoutMs, 1)))
guard
let payload = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let rawCapability = payload["canvasCapability"] as? String
else {
self.logger.warning("node.canvas.capability.refresh missing canvasCapability")
return false
}
let capability = rawCapability.trimmingCharacters(in: .whitespacesAndNewlines)
guard !capability.isEmpty else {
self.logger.warning("node.canvas.capability.refresh returned empty capability")
return false
}
let scopedUrl = self.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !scopedUrl.isEmpty else {
self.logger.warning("node.canvas.capability.refresh missing local canvasHostUrl")
return false
}
guard let refreshed = replaceCanvasCapabilityInScopedHostUrl(
scopedUrl: scopedUrl,
capability: capability)
else {
self.logger.warning("node.canvas.capability.refresh could not rewrite scoped canvas URL")
return false
}
self.canvasHostUrl = refreshed
return true
} catch {
self.logger.warning(
"node.canvas.capability.refresh failed: \(error.localizedDescription, privacy: .public)")
return false
}
}
public func currentRemoteAddress() -> String? {
guard let url = self.activeURL else { return nil }
guard let host = url.host else { return url.absoluteString }
@@ -275,7 +359,7 @@ public actor GatewayNodeSession {
switch push {
case let .snapshot(ok):
let raw = ok.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines)
self.canvasHostUrl = (raw?.isEmpty == false) ? raw : nil
self.canvasHostUrl = self.normalizeCanvasHostUrl(raw)
if self.hasEverConnected {
self.broadcastServerEvent(
EventFrame(type: "event", event: "seqGap", payload: nil, seq: nil, stateversion: nil))
@@ -342,6 +426,10 @@ public actor GatewayNodeSession {
await self.onConnected?()
}
private func normalizeCanvasHostUrl(_ raw: String?) -> String? {
canonicalizeCanvasHostUrl(raw: raw, activeURL: self.activeURL)
}
private func handleEvent(_ evt: EventFrame) async {
self.broadcastServerEvent(evt)
guard evt.event == "node.invoke.request" else { return }
@@ -350,16 +438,21 @@ public actor GatewayNodeSession {
do {
let request = try self.decodeInvokeRequest(from: payload)
let timeoutLabel = request.timeoutMs.map(String.init) ?? "none"
self.logger.info("node invoke request decoded id=\(request.id, privacy: .public) command=\(request.command, privacy: .public) timeoutMs=\(timeoutLabel, privacy: .public)")
self.logger.info(
"node invoke request decoded id=\(request.id, privacy: .public) command=\(request.command, privacy: .public) timeoutMs=\(timeoutLabel, privacy: .public)")
guard let onInvoke else { return }
let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON)
let req = BridgeInvokeRequest(
id: request.id,
command: request.command,
paramsJSON: request.paramsJSON)
self.logger.info("node invoke executing id=\(request.id, privacy: .public)")
let response = await Self.invokeWithTimeout(
request: req,
timeoutMs: request.timeoutMs,
onInvoke: onInvoke
)
self.logger.info("node invoke completed id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
self.logger.info(
"node invoke completed id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
await self.sendInvokeResult(request: request, response: response)
} catch {
self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)")
@@ -380,7 +473,8 @@ public actor GatewayNodeSession {
private func sendInvokeResult(request: NodeInvokeRequestPayload, response: BridgeInvokeResponse) async {
guard let channel = self.channel else { return }
self.logger.info("node invoke result sending id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
self.logger.info(
"node invoke result sending id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
var params: [String: AnyCodable] = [
"id": AnyCodable(request.id),
"nodeId": AnyCodable(request.nodeId),
@@ -398,7 +492,8 @@ public actor GatewayNodeSession {
do {
try await channel.send(method: "node.invoke.result", params: params)
} catch {
self.logger.error("node invoke result failed id=\(request.id, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
self.logger.error(
"node invoke result failed id=\(request.id, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
}
}

View File

@@ -836,6 +836,20 @@ public struct NodeRenameParams: Codable, Sendable {
public struct NodeListParams: Codable, Sendable {}
public struct NodePendingAckParams: Codable, Sendable {
public let ids: [String]
public init(
ids: [String])
{
self.ids = ids
}
private enum CodingKeys: String, CodingKey {
case ids
}
}
public struct NodeDescribeParams: Codable, Sendable {
public let nodeid: String

View File

@@ -169,6 +169,24 @@ private actor SeqGapProbe {
}
struct GatewayNodeSessionTests {
@Test
func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() {
let normalized = canonicalizeCanvasHostUrl(
raw: "https://canvas.example.com:9443/__openclaw__/cap/token",
activeURL: URL(string: "wss://gateway.example.com")!)
#expect(normalized == "https://canvas.example.com:9443/__openclaw__/cap/token")
}
@Test
func normalizeCanvasHostUrlBackfillsGatewayHostForLoopbackCanvas() {
let normalized = canonicalizeCanvasHostUrl(
raw: "http://127.0.0.1:18789/__openclaw__/cap/token",
activeURL: URL(string: "wss://gateway.example.com:7443")!)
#expect(normalized == "https://gateway.example.com:7443/__openclaw__/cap/token")
}
@Test
func invokeWithTimeoutReturnsUnderlyingResponseBeforeTimeout() async {
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)

View File

@@ -620,6 +620,8 @@ openclaw cron run <jobId>
openclaw cron run <jobId> --due
```
`cron.run` now acknowledges once the manual run is queued, not after the job finishes. Successful queue responses look like `{ ok: true, enqueued: true, runId }`. If the job is already running or `--due` finds nothing due, the response stays `{ ok: true, ran: false, reason }`. Use `openclaw cron runs --id <jobId>` or the `cron.runs` gateway method to inspect the eventual finished entry.
Edit an existing job (patch fields):
```bash

76
docs/cli/backup.md Normal file
View File

@@ -0,0 +1,76 @@
---
summary: "CLI reference for `openclaw backup` (create local backup archives)"
read_when:
- You want a first-class backup archive for local OpenClaw state
- You want to preview which paths would be included before reset or uninstall
title: "backup"
---
# `openclaw backup`
Create a local backup archive for OpenClaw state, config, credentials, sessions, and optionally workspaces.
```bash
openclaw backup create
openclaw backup create --output ~/Backups
openclaw backup create --dry-run --json
openclaw backup create --verify
openclaw backup create --no-include-workspace
openclaw backup create --only-config
openclaw backup verify ./2026-03-09T00-00-00.000Z-openclaw-backup.tar.gz
```
## Notes
- The archive includes a `manifest.json` file with the resolved source paths and archive layout.
- Default output is a timestamped `.tar.gz` archive in the current working directory.
- If the current working directory is inside a backed-up source tree, OpenClaw falls back to your home directory for the default archive location.
- Existing archive files are never overwritten.
- Output paths inside the source state/workspace trees are rejected to avoid self-inclusion.
- `openclaw backup verify <archive>` validates that the archive contains exactly one root manifest, rejects traversal-style archive paths, and checks that every manifest-declared payload exists in the tarball.
- `openclaw backup create --verify` runs that validation immediately after writing the archive.
- `openclaw backup create --only-config` backs up just the active JSON config file.
## What gets backed up
`openclaw backup create` plans backup sources from your local OpenClaw install:
- The state directory returned by OpenClaw's local state resolver, usually `~/.openclaw`
- The active config file path
- The OAuth / credentials directory
- Workspace directories discovered from the current config, unless you pass `--no-include-workspace`
If you use `--only-config`, OpenClaw skips state, credentials, and workspace discovery and archives only the active config file path.
OpenClaw canonicalizes paths before building the archive. If config, credentials, or a workspace already live inside the state directory, they are not duplicated as separate top-level backup sources. Missing paths are skipped.
The archive payload stores file contents from those source trees, and the embedded `manifest.json` records the resolved absolute source paths plus the archive layout used for each asset.
## Invalid config behavior
`openclaw backup` intentionally bypasses the normal config preflight so it can still help during recovery. Because workspace discovery depends on a valid config, `openclaw backup create` now fails fast when the config file exists but is invalid and workspace backup is still enabled.
If you still want a partial backup in that situation, rerun:
```bash
openclaw backup create --no-include-workspace
```
That keeps state, config, and credentials in scope while skipping workspace discovery entirely.
If you only need a copy of the config file itself, `--only-config` also works when the config is malformed because it does not rely on parsing the config for workspace discovery.
## Size and performance
OpenClaw does not enforce a built-in maximum backup size or per-file size limit.
Practical limits come from the local machine and destination filesystem:
- Available space for the temporary archive write plus the final archive
- Time to walk large workspace trees and compress them into a `.tar.gz`
- Time to rescan the archive if you use `openclaw backup create --verify` or run `openclaw backup verify`
- Filesystem behavior at the destination path. OpenClaw prefers a no-overwrite hard-link publish step and falls back to exclusive copy when hard links are unsupported
Large workspaces are usually the main driver of archive size. If you want a smaller or faster backup, use `--no-include-workspace`.
For the smallest archive, use `--only-config`.

View File

@@ -23,6 +23,8 @@ Note: one-shot (`--at`) jobs delete after success by default. Use `--keep-after-
Note: recurring jobs now use exponential retry backoff after consecutive errors (30s → 1m → 5m → 15m → 60m), then return to normal schedule after the next successful run.
Note: `openclaw cron run` now returns as soon as the manual run is queued for execution. Successful responses include `{ ok: true, enqueued: true, runId }`; use `openclaw cron runs --id <job-id>` to follow the eventual outcome.
Note: retention/pruning is controlled in config:
- `cron.sessionRetention` (default `24h`) prunes completed isolated run sessions.

View File

@@ -19,6 +19,7 @@ This page describes the current CLI behavior. If commands change, update this do
- [`completion`](/cli/completion)
- [`doctor`](/cli/doctor)
- [`dashboard`](/cli/dashboard)
- [`backup`](/cli/backup)
- [`reset`](/cli/reset)
- [`uninstall`](/cli/uninstall)
- [`update`](/cli/update)
@@ -103,6 +104,9 @@ openclaw [--dev] [--profile <name>] <command>
completion
doctor
dashboard
backup
create
verify
security
audit
secrets

View File

@@ -11,7 +11,10 @@ title: "reset"
Reset local config/state (keeps the CLI installed).
```bash
openclaw backup create
openclaw reset
openclaw reset --dry-run
openclaw reset --scope config+creds+sessions --yes --non-interactive
```
Run `openclaw backup create` first if you want a restorable snapshot before removing local state.

View File

@@ -11,7 +11,10 @@ title: "uninstall"
Uninstall the gateway service + local data (CLI remains).
```bash
openclaw backup create
openclaw uninstall
openclaw uninstall --all --yes
openclaw uninstall --dry-run
```
Run `openclaw backup create` first if you want a restorable snapshot before removing state or workspaces.

View File

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

View File

@@ -2354,6 +2354,7 @@ See [Plugins](/tools/plugin).
// headless: false,
// noSandbox: false,
// extraArgs: [],
// relayBindHost: "0.0.0.0", // only when the extension relay must be reachable across namespaces (for example WSL2)
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
// attachOnly: false,
},
@@ -2370,6 +2371,7 @@ See [Plugins](/tools/plugin).
- Control service: loopback only (port derived from `gateway.port`, default `18791`).
- `extraArgs` appends extra launch flags to local Chromium startup (for example
`--disable-gpu`, window sizing, or debug flags).
- `relayBindHost` changes where the Chrome extension relay listens. Leave unset for loopback-only access; set an explicit non-loopback bind address such as `0.0.0.0` only when the relay must cross a namespace boundary (for example WSL2) and the host network is already trusted.
---

View File

@@ -68,6 +68,12 @@ OpenClaw also injects context markers into spawned child processes:
These are runtime markers (not required user config). They can be used in shell/profile logic
to apply context-specific rules.
## UI env vars
- `OPENCLAW_THEME=light`: force the light TUI palette when your terminal has a light background.
- `OPENCLAW_THEME=dark`: force the dark TUI palette.
- `COLORFGBG`: if your terminal exports it, OpenClaw uses the background color hint to auto-pick the TUI palette.
## Env var substitution in config
You can reference env vars directly in config string values using `${VAR_NAME}` syntax:

View File

@@ -290,6 +290,7 @@ flowchart TD
- [/gateway/troubleshooting#browser-tool-fails](/gateway/troubleshooting#browser-tool-fails)
- [/tools/browser-linux-troubleshooting](/tools/browser-linux-troubleshooting)
- [/tools/browser-wsl2-windows-remote-cdp-troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting)
- [/tools/chrome-extension](/tools/chrome-extension)
</Accordion>

299
docs/refactor/cluster.md Normal file
View File

@@ -0,0 +1,299 @@
---
summary: "Refactor clusters with highest LOC reduction potential"
read_when:
- You want to reduce total LOC without changing behavior
- You are choosing the next dedupe or extraction pass
title: "Refactor Cluster Backlog"
---
# Refactor Cluster Backlog
Ranked by likely LOC reduction, safety, and breadth.
## 1. Channel plugin config and security scaffolding
Highest-value cluster.
Repeated shapes across many channel plugins:
- `config.listAccountIds`
- `config.resolveAccount`
- `config.defaultAccountId`
- `config.setAccountEnabled`
- `config.deleteAccount`
- `config.describeAccount`
- `security.resolveDmPolicy`
Strong examples:
- `extensions/telegram/src/channel.ts`
- `extensions/googlechat/src/channel.ts`
- `extensions/slack/src/channel.ts`
- `extensions/discord/src/channel.ts`
- `extensions/matrix/src/channel.ts`
- `extensions/irc/src/channel.ts`
- `extensions/signal/src/channel.ts`
- `extensions/mattermost/src/channel.ts`
Likely extraction shape:
- `buildChannelConfigAdapter(...)`
- `buildMultiAccountConfigAdapter(...)`
- `buildDmSecurityAdapter(...)`
Expected savings:
- ~250-450 LOC
Risk:
- Medium. Each channel has slightly different `isConfigured`, warnings, and normalization.
## 2. Extension runtime singleton boilerplate
Very safe.
Nearly every extension has the same runtime holder:
- `let runtime: PluginRuntime | null = null`
- `setXRuntime`
- `getXRuntime`
Strong examples:
- `extensions/telegram/src/runtime.ts`
- `extensions/matrix/src/runtime.ts`
- `extensions/slack/src/runtime.ts`
- `extensions/discord/src/runtime.ts`
- `extensions/whatsapp/src/runtime.ts`
- `extensions/imessage/src/runtime.ts`
- `extensions/twitch/src/runtime.ts`
Special-case variants:
- `extensions/bluebubbles/src/runtime.ts`
- `extensions/line/src/runtime.ts`
- `extensions/synology-chat/src/runtime.ts`
Likely extraction shape:
- `createPluginRuntimeStore<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

@@ -0,0 +1,242 @@
---
summary: "Troubleshoot WSL2 Gateway + Windows Chrome remote CDP and extension-relay setups in layers"
read_when:
- Running OpenClaw Gateway in WSL2 while Chrome lives on Windows
- Seeing overlapping browser/control-ui errors across WSL2 and Windows
- Deciding between raw remote CDP and the Chrome extension relay in split-host setups
title: "WSL2 + Windows + remote Chrome CDP troubleshooting"
---
# WSL2 + Windows + remote Chrome CDP troubleshooting
This guide covers the common split-host setup where:
- OpenClaw Gateway runs inside WSL2
- Chrome runs on Windows
- browser control must cross the WSL2/Windows boundary
It also covers the layered failure pattern from [issue #39369](https://github.com/openclaw/openclaw/issues/39369): several independent problems can show up at once, which makes the wrong layer look broken first.
## Choose the right browser mode first
You have two valid patterns:
### Option 1: Raw remote CDP
Use a remote browser profile that points from WSL2 to a Windows Chrome CDP endpoint.
Choose this when:
- you only need browser control
- you are comfortable exposing Chrome remote debugging to WSL2
- you do not need the Chrome extension relay
### Option 2: Chrome extension relay
Use the built-in `chrome` profile plus the OpenClaw Chrome extension.
Choose this when:
- you want to attach to an existing Windows Chrome tab with the toolbar button
- you want extension-based control instead of raw `--remote-debugging-port`
- the relay itself must be reachable across the WSL2/Windows boundary
If you use the extension relay across namespaces, `browser.relayBindHost` is the important setting introduced in [Browser](/tools/browser) and [Chrome extension](/tools/chrome-extension).
## Working architecture
Reference shape:
- WSL2 runs the Gateway on `127.0.0.1:18789`
- Windows opens the Control UI in a normal browser at `http://127.0.0.1:18789/`
- Windows Chrome exposes a CDP endpoint on port `9222`
- WSL2 can reach that Windows CDP endpoint
- OpenClaw points a browser profile at the address that is reachable from WSL2
## Why this setup is confusing
Several failures can overlap:
- WSL2 cannot reach the Windows CDP endpoint
- the Control UI is opened from a non-secure origin
- `gateway.controlUi.allowedOrigins` does not match the page origin
- token or pairing is missing
- the browser profile points at the wrong address
- the extension relay is still loopback-only when you actually need cross-namespace access
Because of that, fixing one layer can still leave a different error visible.
## Critical rule for the Control UI
When the UI is opened from Windows, use Windows localhost unless you have a deliberate HTTPS setup.
Use:
`http://127.0.0.1:18789/`
Do not default to a LAN IP for the Control UI. Plain HTTP on a LAN or tailnet address can trigger insecure-origin/device-auth behavior that is unrelated to CDP itself. See [Control UI](/web/control-ui).
## Validate in layers
Work top to bottom. Do not skip ahead.
### Layer 1: Verify Chrome is serving CDP on Windows
Start Chrome on Windows with remote debugging enabled:
```powershell
chrome.exe --remote-debugging-port=9222
```
From Windows, verify Chrome itself first:
```powershell
curl http://127.0.0.1:9222/json/version
curl http://127.0.0.1:9222/json/list
```
If this fails on Windows, OpenClaw is not the problem yet.
### Layer 2: Verify WSL2 can reach that Windows endpoint
From WSL2, test the exact address you plan to use in `cdpUrl`:
```bash
curl http://WINDOWS_HOST_OR_IP:9222/json/version
curl http://WINDOWS_HOST_OR_IP:9222/json/list
```
Good result:
- `/json/version` returns JSON with Browser / Protocol-Version metadata
- `/json/list` returns JSON (empty array is fine if no pages are open)
If this fails:
- Windows is not exposing the port to WSL2 yet
- the address is wrong for the WSL2 side
- firewall / port forwarding / local proxying is still missing
Fix that before touching OpenClaw config.
### Layer 3: Configure the correct browser profile
For raw remote CDP, point OpenClaw at the address that is reachable from WSL2:
```json5
{
browser: {
enabled: true,
defaultProfile: "remote",
profiles: {
remote: {
cdpUrl: "http://WINDOWS_HOST_OR_IP:9222",
attachOnly: true,
color: "#00AA00",
},
},
},
}
```
Notes:
- use the WSL2-reachable address, not whatever only works on Windows
- keep `attachOnly: true` for externally managed browsers
- test the same URL with `curl` before expecting OpenClaw to succeed
### Layer 4: If you use the Chrome extension relay instead
If the browser machine and the Gateway are separated by a namespace boundary, the relay may need a non-loopback bind address.
Example:
```json5
{
browser: {
enabled: true,
defaultProfile: "chrome",
relayBindHost: "0.0.0.0",
},
}
```
Use this only when needed:
- default behavior is safer because the relay stays loopback-only
- `0.0.0.0` expands exposure surface
- keep Gateway auth, node pairing, and the surrounding network private
If you do not need the extension relay, prefer the raw remote CDP profile above.
### Layer 5: Verify the Control UI layer separately
Open the UI from Windows:
`http://127.0.0.1:18789/`
Then verify:
- the page origin matches what `gateway.controlUi.allowedOrigins` expects
- token auth or pairing is configured correctly
- you are not debugging a Control UI auth problem as if it were a browser problem
Helpful page:
- [Control UI](/web/control-ui)
### Layer 6: Verify end-to-end browser control
From WSL2:
```bash
openclaw browser open https://example.com --browser-profile remote
openclaw browser tabs --browser-profile remote
```
For the extension relay:
```bash
openclaw browser tabs --browser-profile chrome
```
Good result:
- the tab opens in Windows Chrome
- `openclaw browser tabs` returns the target
- later actions (`snapshot`, `screenshot`, `navigate`) work from the same profile
## Common misleading errors
Treat each message as a layer-specific clue:
- `control-ui-insecure-auth`
- UI origin / secure-context problem, not a CDP transport problem
- `token_missing`
- auth configuration problem
- `pairing required`
- device approval problem
- `Remote CDP for profile "remote" is not reachable`
- WSL2 cannot reach the configured `cdpUrl`
- `gateway timeout after 1500ms`
- often still CDP reachability or a slow/unreachable remote endpoint
- `Chrome extension relay is running, but no tab is connected`
- extension relay profile selected, but no attached tab exists yet
## Fast triage checklist
1. Windows: does `curl http://127.0.0.1:9222/json/version` work?
2. WSL2: does `curl http://WINDOWS_HOST_OR_IP:9222/json/version` work?
3. OpenClaw config: does `browser.profiles.<name>.cdpUrl` use that exact WSL2-reachable address?
4. Control UI: are you opening `http://127.0.0.1:18789/` instead of a LAN IP?
5. Extension relay only: do you actually need `browser.relayBindHost`, and if so is it set explicitly?
## Practical takeaway
The setup is usually viable. The hard part is that browser transport, Control UI origin security, token/pairing, and extension-relay topology can each fail independently while looking similar from the user side.
When in doubt:
- verify the Windows Chrome endpoint locally first
- verify the same endpoint from WSL2 second
- only then debug OpenClaw config or Control UI auth

View File

@@ -328,6 +328,19 @@ Notes:
- This mode relies on Playwright-on-CDP for most operations (screenshots/snapshots/actions).
- Detach by clicking the extension icon again.
- Leave the relay loopback-only by default. If the relay must be reachable from a different network namespace (for example Gateway in WSL2, Chrome on Windows), set `browser.relayBindHost` to an explicit bind address such as `0.0.0.0` while keeping the surrounding network private and authenticated.
WSL2 / cross-namespace example:
```json5
{
browser: {
enabled: true,
relayBindHost: "0.0.0.0",
defaultProfile: "chrome",
},
}
```
## Isolation guarantees
@@ -636,6 +649,9 @@ Strict-mode example (block private/internal destinations by default):
For Linux-specific issues (especially snap Chromium), see
[Browser troubleshooting](/tools/browser-linux-troubleshooting).
For WSL2 Gateway + Windows Chrome split-host setups, see
[WSL2 + Windows + remote Chrome CDP troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting).
## Agent tools + how control works
The agent gets **one tool** for browser automation:

View File

@@ -161,6 +161,7 @@ Debugging: `openclaw sandbox explain`
- Keep the Gateway and node host on the same tailnet; avoid exposing relay ports to LAN or public Internet.
- Pair nodes intentionally; disable browser proxy routing if you 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,6 +531,9 @@ Browser tool:
- `profile` (optional; defaults to `browser.defaultProfile`)
- `target` (`sandbox` | `host` | `node`)
- `node` (optional; pin a specific node id/name)
- Troubleshooting guides:
- Linux startup/CDP issues: [Browser troubleshooting (Linux)](/tools/browser-linux-troubleshooting)
- WSL2 Gateway + Windows remote Chrome CDP: [WSL2 + Windows + remote Chrome CDP troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting)
## Recommended agent flows

View File

@@ -122,6 +122,12 @@ Other Gateway slash commands (for example, `/context`) are forwarded to the Gate
- Ctrl+O toggles between collapsed/expanded views.
- While tools run, partial updates stream into the same card.
## Terminal colors
- The TUI keeps assistant body text in your terminal's default foreground so dark and light terminals both stay readable.
- If your terminal uses a light background and auto-detection is wrong, set `OPENCLAW_THEME=light` before launching `openclaw tui`.
- To force the original dark palette instead, set `OPENCLAW_THEME=dark`.
## History + streaming
- On connect, the TUI loads the latest history (default 200 messages).

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
import {
collectAllowlistProviderGroupPolicyWarnings,
buildAccountScopedDmSecurityPolicy,
@@ -12,7 +13,6 @@ import {
clearAccountEntryFields,
collectTelegramStatusIssues,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
getChatChannelMeta,
inspectTelegramAccount,
listTelegramAccountIds,
@@ -31,7 +31,6 @@ import {
resolveTelegramAccount,
resolveTelegramGroupRequireMention,
resolveTelegramGroupToolPolicy,
setAccountEnabledInConfigSection,
telegramOnboardingAdapter,
TelegramConfigSchema,
type ChannelMessageActionAdapter,
@@ -100,6 +99,15 @@ const telegramConfigAccessors = createScopedAccountConfigAccessors({
resolveDefaultTo: (account: ResolvedTelegramAccount) => account.config.defaultTo,
});
const telegramConfigBase = createScopedChannelConfigBase<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: {
@@ -136,25 +144,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
reload: { configPrefixes: ["channels.telegram"] },
configSchema: buildChannelConfigSchema(TelegramConfigSchema),
config: {
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"],
}),
...telegramConfigBase,
isConfigured: (account, cfg) => {
if (!account.token?.trim()) {
return false;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -224,6 +224,7 @@
"android:test": "cd apps/android && ./gradlew :app:testDebugUnitTest",
"android:test:integration": "OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 vitest run --config vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts",
"build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
"build:docker": "node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
"build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts",
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",

View File

@@ -13,7 +13,6 @@ RUN corepack enable \
&& pnpm install --frozen-lockfile
COPY . .
COPY scripts/docker/cleanup-smoke/run.sh /usr/local/bin/openclaw-cleanup-smoke
RUN chmod +x /usr/local/bin/openclaw-cleanup-smoke
COPY --chmod=755 scripts/docker/cleanup-smoke/run.sh /usr/local/bin/openclaw-cleanup-smoke
ENTRYPOINT ["/usr/local/bin/openclaw-cleanup-smoke"]

View File

@@ -9,8 +9,7 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh
COPY run.sh /usr/local/bin/openclaw-install-e2e
RUN chmod +x /usr/local/bin/openclaw-install-e2e
COPY --chmod=755 run.sh /usr/local/bin/openclaw-install-e2e
RUN useradd --create-home --shell /bin/bash appuser
USER appuser

View File

@@ -28,7 +28,6 @@ ENV NPM_CONFIG_AUDIT=false
COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh
COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh
COPY install-sh-nonroot/run.sh /usr/local/bin/openclaw-install-nonroot
RUN sudo chmod +x /usr/local/bin/openclaw-install-nonroot
COPY --chmod=755 install-sh-nonroot/run.sh /usr/local/bin/openclaw-install-nonroot
ENTRYPOINT ["/usr/local/bin/openclaw-install-nonroot"]

View File

@@ -20,7 +20,6 @@ RUN set -eux; \
COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh
COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh
COPY install-sh-smoke/run.sh /usr/local/bin/openclaw-install-smoke
RUN chmod +x /usr/local/bin/openclaw-install-smoke
COPY --chmod=755 install-sh-smoke/run.sh /usr/local/bin/openclaw-install-smoke
ENTRYPOINT ["/usr/local/bin/openclaw-install-smoke"]

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

View File

@@ -183,14 +183,30 @@ fi
ENV_FILE_ARGS=()
[[ -f "$ENV_FILE" ]] && ENV_FILE_ARGS+=(--env-file "$ENV_FILE")
# On Linux with SELinux enforcing/permissive, add ,Z so Podman relabels the
# bind-mounted directories and the container can access them.
SELINUX_MOUNT_OPTS=""
if [[ -z "${OPENCLAW_BIND_MOUNT_OPTIONS:-}" ]]; then
if [[ "$(uname -s 2>/dev/null)" == "Linux" ]] && command -v getenforce >/dev/null 2>&1; then
_selinux_mode="$(getenforce 2>/dev/null || true)"
if [[ "$_selinux_mode" == "Enforcing" || "$_selinux_mode" == "Permissive" ]]; then
SELINUX_MOUNT_OPTS=",Z"
fi
fi
else
# Honour explicit override (e.g. OPENCLAW_BIND_MOUNT_OPTIONS=":Z" → strip leading colon for inline use).
SELINUX_MOUNT_OPTS="${OPENCLAW_BIND_MOUNT_OPTIONS#:}"
[[ -n "$SELINUX_MOUNT_OPTS" ]] && SELINUX_MOUNT_OPTS=",$SELINUX_MOUNT_OPTS"
fi
if [[ "$RUN_SETUP" == true ]]; then
exec podman run --pull="$PODMAN_PULL" --rm -it \
--init \
"${USERNS_ARGS[@]}" "${RUN_USER_ARGS[@]}" \
-e HOME=/home/node -e TERM=xterm-256color -e BROWSER=echo \
-e OPENCLAW_GATEWAY_TOKEN="$OPENCLAW_GATEWAY_TOKEN" \
-v "$CONFIG_DIR:/home/node/.openclaw:rw" \
-v "$WORKSPACE_DIR:/home/node/.openclaw/workspace:rw" \
-v "$CONFIG_DIR:/home/node/.openclaw:rw${SELINUX_MOUNT_OPTS}" \
-v "$WORKSPACE_DIR:/home/node/.openclaw/workspace:rw${SELINUX_MOUNT_OPTS}" \
"${ENV_FILE_ARGS[@]}" \
"$OPENCLAW_IMAGE" \
node dist/index.js onboard "$@"
@@ -203,8 +219,8 @@ podman run --pull="$PODMAN_PULL" -d --replace \
-e HOME=/home/node -e TERM=xterm-256color \
-e OPENCLAW_GATEWAY_TOKEN="$OPENCLAW_GATEWAY_TOKEN" \
"${ENV_FILE_ARGS[@]}" \
-v "$CONFIG_DIR:/home/node/.openclaw:rw" \
-v "$WORKSPACE_DIR:/home/node/.openclaw/workspace:rw" \
-v "$CONFIG_DIR:/home/node/.openclaw:rw${SELINUX_MOUNT_OPTS}" \
-v "$WORKSPACE_DIR:/home/node/.openclaw/workspace:rw${SELINUX_MOUNT_OPTS}" \
-p "${HOST_GATEWAY_PORT}:18789" \
-p "${HOST_BRIDGE_PORT}:18790" \
"$OPENCLAW_IMAGE" \

View File

@@ -80,12 +80,17 @@ run_root() {
}
run_as_user() {
# When switching users, the caller's cwd may be inaccessible to the target
# user (e.g. a private home dir). Wrap in a subshell that cd's to a
# world-traversable directory so sudo/runuser don't fail with "cannot chdir".
# TODO: replace with fully rootless podman build to eliminate the need for
# user-switching entirely.
local user="$1"
shift
if command -v sudo >/dev/null 2>&1; then
sudo -u "$user" "$@"
( cd /tmp 2>/dev/null || cd /; sudo -u "$user" "$@" )
elif is_root && command -v runuser >/dev/null 2>&1; then
runuser -u "$user" -- "$@"
( cd /tmp 2>/dev/null || cd /; runuser -u "$user" -- "$@" )
else
echo "Need sudo (or root+runuser) to run commands as $user." >&2
exit 1

View File

@@ -22,7 +22,7 @@ describe("models-config", () => {
models: { providers: {} },
env: {
vars: {
OPENROUTER_API_KEY: "from-config",
OPENROUTER_API_KEY: "from-config", // pragma: allowlist secret
[TEST_ENV_VAR]: "from-config",
},
},
@@ -44,13 +44,13 @@ describe("models-config", () => {
it("does not overwrite already-set host env vars while ensuring models.json", async () => {
await withTempHome(async () => {
await withTempEnv(["OPENROUTER_API_KEY", TEST_ENV_VAR], async () => {
process.env.OPENROUTER_API_KEY = "from-host";
process.env.OPENROUTER_API_KEY = "from-host"; // pragma: allowlist secret
process.env[TEST_ENV_VAR] = "from-host";
const cfg: OpenClawConfig = {
models: { providers: {} },
env: {
vars: {
OPENROUTER_API_KEY: "from-config",
OPENROUTER_API_KEY: "from-config", // pragma: allowlist secret
[TEST_ENV_VAR]: "from-config",
},
},

View File

@@ -39,7 +39,7 @@ async function writeAuthProfiles(
const MATRIX_CASES: MatrixCase[] = [
{
name: "env api key injects a simple provider",
env: { NVIDIA_API_KEY: "test-nvidia-key" },
env: { NVIDIA_API_KEY: "test-nvidia-key" }, // pragma: allowlist secret
assertProviders(providers) {
expect(providers?.nvidia?.apiKey).toBe("NVIDIA_API_KEY");
expect(providers?.nvidia?.baseUrl).toBe("https://integrate.api.nvidia.com/v1");
@@ -48,7 +48,7 @@ const MATRIX_CASES: MatrixCase[] = [
},
{
name: "env api key injects paired plan providers",
env: { VOLCANO_ENGINE_API_KEY: "test-volcengine-key" },
env: { VOLCANO_ENGINE_API_KEY: "test-volcengine-key" }, // pragma: allowlist secret
assertProviders(providers) {
expect(providers?.volcengine?.apiKey).toBe("VOLCANO_ENGINE_API_KEY");
expect(providers?.["volcengine-plan"]?.apiKey).toBe("VOLCANO_ENGINE_API_KEY");
@@ -116,7 +116,7 @@ const MATRIX_CASES: MatrixCase[] = [
},
{
name: "explicit vllm config suppresses implicit vllm injection",
env: { VLLM_API_KEY: "test-vllm-key" },
env: { VLLM_API_KEY: "test-vllm-key" }, // pragma: allowlist secret
explicitProviders: {
vllm: {
baseUrl: "http://127.0.0.1:8000/v1",

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const {
hookRunner,
ensureRuntimePluginsLoaded,
resolveModelMock,
sessionCompactImpl,
triggerInternalHook,
@@ -12,6 +13,7 @@ const {
runBeforeCompaction: vi.fn(),
runAfterCompaction: vi.fn(),
},
ensureRuntimePluginsLoaded: vi.fn(),
resolveModelMock: vi.fn(() => ({
model: { provider: "openai", api: "responses", id: "fake", input: [] },
error: null,
@@ -32,6 +34,10 @@ vi.mock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => hookRunner,
}));
vi.mock("../runtime-plugins.js", () => ({
ensureRuntimePluginsLoaded,
}));
vi.mock("../../hooks/internal-hooks.js", async () => {
const actual = await vi.importActual<typeof import("../../hooks/internal-hooks.js")>(
"../../hooks/internal-hooks.js",
@@ -254,6 +260,7 @@ const sessionHook = (action: string) =>
describe("compactEmbeddedPiSessionDirect hooks", () => {
beforeEach(() => {
ensureRuntimePluginsLoaded.mockReset();
triggerInternalHook.mockClear();
hookRunner.hasHooks.mockReset();
hookRunner.runBeforeCompaction.mockReset();
@@ -279,6 +286,19 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
unregisterApiProviders(getCustomApiRegistrySourceId("ollama"));
});
it("bootstraps runtime plugins with the resolved workspace", async () => {
await compactEmbeddedPiSessionDirect({
sessionId: "session-1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp/workspace",
});
expect(ensureRuntimePluginsLoaded).toHaveBeenCalledWith({
config: undefined,
workspaceDir: "/tmp/workspace",
});
});
it("emits internal + plugin compaction hooks with counts", async () => {
hookRunner.hasHooks.mockReturnValue(true);
let sanitizedCount = 0;

View File

@@ -50,6 +50,7 @@ import {
} from "../pi-embedded-helpers.js";
import { createPreparedEmbeddedPiSettingsManager } from "../pi-project-settings.js";
import { createOpenClawCodingTools } from "../pi-tools.js";
import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js";
import { resolveSandboxContext } from "../sandbox.js";
import { repairSessionFileIfNeeded } from "../session-file-repair.js";
import { guardSessionManager } from "../session-tool-result-guard-wrapper.js";
@@ -269,6 +270,10 @@ export async function compactEmbeddedPiSessionDirect(
const maxAttempts = params.maxAttempts ?? 1;
const runId = params.runId ?? params.sessionId;
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
ensureRuntimePluginsLoaded({
config: params.config,
workspaceDir: resolvedWorkspace,
});
const prevCwd = process.cwd();
// Resolve compaction model: prefer config override, then fall back to caller-supplied model
@@ -910,6 +915,10 @@ export async function compactEmbeddedPiSession(
params.enqueue ?? ((task, opts) => enqueueCommandInLane(globalLane, task, opts));
return enqueueCommandInLane(sessionLane, () =>
enqueueGlobal(async () => {
ensureRuntimePluginsLoaded({
config: params.config,
workspaceDir: params.workspaceDir,
});
ensureContextEnginesInitialized();
const contextEngine = await resolveContextEngine(params.config);
try {

View File

@@ -664,6 +664,60 @@ describe("resolveModel", () => {
});
});
it("normalizes openai-codex gpt-5.4 overrides away from /v1/responses", () => {
mockOpenAICodexTemplateModel();
const cfg: OpenClawConfig = {
models: {
providers: {
"openai-codex": {
baseUrl: "https://api.openai.com/v1",
api: "openai-responses",
},
},
},
} as unknown as OpenClawConfig;
expectResolvedForwardCompatFallback({
provider: "openai-codex",
id: "gpt-5.4",
cfg,
expectedModel: {
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
id: "gpt-5.4",
provider: "openai-codex",
},
});
});
it("does not rewrite openai baseUrl when openai-codex api stays non-codex", () => {
mockOpenAICodexTemplateModel();
const cfg: OpenClawConfig = {
models: {
providers: {
"openai-codex": {
baseUrl: "https://api.openai.com/v1",
api: "openai-completions",
},
},
},
} as unknown as OpenClawConfig;
expectResolvedForwardCompatFallback({
provider: "openai-codex",
id: "gpt-5.4",
cfg,
expectedModel: {
api: "openai-completions",
baseUrl: "https://api.openai.com/v1",
id: "gpt-5.4",
provider: "openai-codex",
},
});
});
it("includes auth hint for unknown ollama models (#17328)", () => {
// resetMockDiscoverModels() in beforeEach already sets find → null
const result = resolveModel("ollama", "gemma3:4b", "/tmp/agent");

View File

@@ -23,6 +23,8 @@ type InlineProviderConfig = {
headers?: unknown;
};
const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
function sanitizeModelHeaders(
headers: unknown,
opts?: { stripSecretRefMarkers?: boolean },
@@ -43,6 +45,60 @@ function sanitizeModelHeaders(
return Object.keys(next).length > 0 ? next : undefined;
}
function isOpenAIApiBaseUrl(baseUrl?: string): boolean {
const trimmed = baseUrl?.trim();
if (!trimmed) {
return false;
}
return /^https?:\/\/api\.openai\.com(?:\/v1)?\/?$/i.test(trimmed);
}
function isOpenAICodexBaseUrl(baseUrl?: string): boolean {
const trimmed = baseUrl?.trim();
if (!trimmed) {
return false;
}
return /^https?:\/\/chatgpt\.com\/backend-api\/?$/i.test(trimmed);
}
function normalizeOpenAICodexTransport(params: {
provider: string;
model: Model<Api>;
}): Model<Api> {
if (normalizeProviderId(params.provider) !== "openai-codex") {
return params.model;
}
const useCodexTransport =
!params.model.baseUrl ||
isOpenAIApiBaseUrl(params.model.baseUrl) ||
isOpenAICodexBaseUrl(params.model.baseUrl);
const nextApi =
useCodexTransport && params.model.api === "openai-responses"
? ("openai-codex-responses" as const)
: params.model.api;
const nextBaseUrl =
nextApi === "openai-codex-responses" &&
(!params.model.baseUrl || isOpenAIApiBaseUrl(params.model.baseUrl))
? OPENAI_CODEX_BASE_URL
: params.model.baseUrl;
if (nextApi === params.model.api && nextBaseUrl === params.model.baseUrl) {
return params.model;
}
return {
...params.model,
api: nextApi,
baseUrl: nextBaseUrl,
} as Model<Api>;
}
function normalizeResolvedModel(params: { provider: string; model: Model<Api> }): Model<Api> {
return normalizeModelCompat(normalizeOpenAICodexTransport(params));
}
export { buildModelAliasLines };
function resolveConfiguredProviderConfig(
@@ -145,13 +201,14 @@ export function resolveModelWithRegistry(params: {
const model = modelRegistry.find(provider, modelId) as Model<Api> | null;
if (model) {
return normalizeModelCompat(
applyConfiguredProviderOverrides({
return normalizeResolvedModel({
provider,
model: applyConfiguredProviderOverrides({
discoveredModel: model,
providerConfig,
modelId,
}),
);
});
}
const providers = cfg?.models?.providers ?? {};
@@ -161,64 +218,71 @@ export function resolveModelWithRegistry(params: {
(entry) => normalizeProviderId(entry.provider) === normalizedProvider && entry.id === modelId,
);
if (inlineMatch?.api) {
return normalizeModelCompat(inlineMatch as Model<Api>);
return normalizeResolvedModel({ provider, model: inlineMatch as Model<Api> });
}
// Forward-compat fallbacks must be checked BEFORE the generic providerCfg fallback.
// Otherwise, configured providers can default to a generic API and break specific transports.
const forwardCompat = resolveForwardCompatModel(provider, modelId, modelRegistry);
if (forwardCompat) {
return normalizeModelCompat(
applyConfiguredProviderOverrides({
return normalizeResolvedModel({
provider,
model: applyConfiguredProviderOverrides({
discoveredModel: forwardCompat,
providerConfig,
modelId,
}),
);
});
}
// OpenRouter is a pass-through proxy - any model ID available on OpenRouter
// should work without being pre-registered in the local catalog.
if (normalizedProvider === "openrouter") {
return normalizeModelCompat({
id: modelId,
name: modelId,
api: "openai-completions",
return normalizeResolvedModel({
provider,
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_TOKENS,
// Align with OPENROUTER_DEFAULT_MAX_TOKENS in models-config.providers.ts
maxTokens: 8192,
} as Model<Api>);
model: {
id: modelId,
name: modelId,
api: "openai-completions",
provider,
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_TOKENS,
// Align with OPENROUTER_DEFAULT_MAX_TOKENS in models-config.providers.ts
maxTokens: 8192,
} as Model<Api>,
});
}
const configuredModel = providerConfig?.models?.find((candidate) => candidate.id === modelId);
const providerHeaders = sanitizeModelHeaders(providerConfig?.headers);
const modelHeaders = sanitizeModelHeaders(configuredModel?.headers);
if (providerConfig || modelId.startsWith("mock-")) {
return normalizeModelCompat({
id: modelId,
name: modelId,
api: providerConfig?.api ?? "openai-responses",
return normalizeResolvedModel({
provider,
baseUrl: providerConfig?.baseUrl,
reasoning: configuredModel?.reasoning ?? false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow:
configuredModel?.contextWindow ??
providerConfig?.models?.[0]?.contextWindow ??
DEFAULT_CONTEXT_TOKENS,
maxTokens:
configuredModel?.maxTokens ??
providerConfig?.models?.[0]?.maxTokens ??
DEFAULT_CONTEXT_TOKENS,
headers:
providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined,
} as Model<Api>);
model: {
id: modelId,
name: modelId,
api: providerConfig?.api ?? "openai-responses",
provider,
baseUrl: providerConfig?.baseUrl,
reasoning: configuredModel?.reasoning ?? false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow:
configuredModel?.contextWindow ??
providerConfig?.models?.[0]?.contextWindow ??
DEFAULT_CONTEXT_TOKENS,
maxTokens:
configuredModel?.maxTokens ??
providerConfig?.models?.[0]?.maxTokens ??
DEFAULT_CONTEXT_TOKENS,
headers:
providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined,
} as Model<Api>,
});
}
return undefined;

View File

@@ -54,6 +54,7 @@ import {
pickFallbackThinkingLevel,
type FailoverReason,
} from "../pi-embedded-helpers.js";
import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js";
import { derivePromptTokens, normalizeUsage, type UsageLike } from "../usage.js";
import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js";
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
@@ -287,6 +288,10 @@ export async function runEmbeddedPiAgent(
`[workspace-fallback] caller=runEmbeddedPiAgent reason=${workspaceResolution.fallbackReason} run=${params.runId} session=${redactedSessionId} sessionKey=${redactedSessionKey} agent=${workspaceResolution.agentId} workspace=${redactedWorkspace}`,
);
}
ensureRuntimePluginsLoaded({
config: params.config,
workspaceDir: resolvedWorkspace,
});
const prevCwd = process.cwd();
let provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;

View File

@@ -135,9 +135,15 @@ describe("resolvePromptModeForSession", () => {
expect(resolvePromptModeForSession("agent:main:subagent:child")).toBe("minimal");
});
it("uses full mode for cron sessions", () => {
expect(resolvePromptModeForSession("agent:main:cron:job-1")).toBe("full");
expect(resolvePromptModeForSession("agent:main:cron:job-1:run:run-abc")).toBe("full");
it("uses minimal mode for cron sessions", () => {
expect(resolvePromptModeForSession("agent:main:cron:job-1")).toBe("minimal");
expect(resolvePromptModeForSession("agent:main:cron:job-1:run:run-abc")).toBe("minimal");
});
it("uses full mode for regular and undefined sessions", () => {
expect(resolvePromptModeForSession(undefined)).toBe("full");
expect(resolvePromptModeForSession("agent:main")).toBe("full");
expect(resolvePromptModeForSession("agent:main:thread:abc")).toBe("full");
});
});

View File

@@ -19,7 +19,7 @@ import type {
PluginHookBeforeAgentStartResult,
PluginHookBeforePromptBuildResult,
} from "../../../plugins/types.js";
import { isSubagentSessionKey } from "../../../routing/session-key.js";
import { isCronSessionKey, isSubagentSessionKey } from "../../../routing/session-key.js";
import { joinPresentTextSegments } from "../../../shared/text/join-segments.js";
import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js";
import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js";
@@ -613,7 +613,7 @@ export function resolvePromptModeForSession(sessionKey?: string): "minimal" | "f
if (!sessionKey) {
return "full";
}
return isSubagentSessionKey(sessionKey) ? "minimal" : "full";
return isSubagentSessionKey(sessionKey) || isCronSessionKey(sessionKey) ? "minimal" : "full";
}
export function resolveAttemptFsWorkspaceOnly(params: {

View File

@@ -1,5 +1,14 @@
import "./run.overflow-compaction.mocks.shared.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
const runtimePluginMocks = vi.hoisted(() => ({
ensureRuntimePluginsLoaded: vi.fn(),
}));
vi.mock("../runtime-plugins.js", () => ({
ensureRuntimePluginsLoaded: runtimePluginMocks.ensureRuntimePluginsLoaded,
}));
import { runEmbeddedPiAgent } from "./run.js";
import { runEmbeddedAttempt } from "./run/attempt.js";
@@ -10,6 +19,32 @@ describe("runEmbeddedPiAgent usage reporting", () => {
vi.clearAllMocks();
});
it("bootstraps runtime plugins with the resolved workspace before running", async () => {
mockedRunEmbeddedAttempt.mockResolvedValueOnce({
aborted: false,
promptError: null,
timedOut: false,
sessionIdUsed: "test-session",
assistantTexts: ["Response 1"],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
await runEmbeddedPiAgent({
sessionId: "test-session",
sessionKey: "test-key",
sessionFile: "/tmp/session.json",
workspaceDir: "/tmp/workspace",
prompt: "hello",
timeoutMs: 30000,
runId: "run-plugin-bootstrap",
});
expect(runtimePluginMocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({
config: undefined,
workspaceDir: "/tmp/workspace",
});
});
it("forwards sender identity fields into embedded attempts", async () => {
mockedRunEmbeddedAttempt.mockResolvedValueOnce({
aborted: false,

View File

@@ -0,0 +1,18 @@
import type { OpenClawConfig } from "../config/config.js";
import { loadOpenClawPlugins } from "../plugins/loader.js";
import { resolveUserPath } from "../utils.js";
export function ensureRuntimePluginsLoaded(params: {
config?: OpenClawConfig;
workspaceDir?: string | null;
}): void {
const workspaceDir =
typeof params.workspaceDir === "string" && params.workspaceDir.trim()
? resolveUserPath(params.workspaceDir)
: undefined;
loadOpenClawPlugins({
config: params.config,
workspaceDir,
});
}

View File

@@ -197,6 +197,25 @@ describe("subagent announce timeout config", () => {
expect(internalEvents[0]?.announceType).toBe("cron job");
});
it("regression, keeps child announce internal when requester is a cron run session", async () => {
const cronSessionKey = "agent:main:cron:daily-check:run:run-123";
await runAnnounceFlowForTest("run-cron-internal", {
requesterSessionKey: cronSessionKey,
requesterDisplayKey: cronSessionKey,
requesterOrigin: { channel: "discord", to: "channel:cron-results", accountId: "acct-1" },
});
const directAgentCall = findGatewayCall(
(call) => call.method === "agent" && call.expectFinal === true,
);
expect(directAgentCall?.params?.sessionKey).toBe(cronSessionKey);
expect(directAgentCall?.params?.deliver).toBe(false);
expect(directAgentCall?.params?.channel).toBeUndefined();
expect(directAgentCall?.params?.to).toBeUndefined();
expect(directAgentCall?.params?.accountId).toBeUndefined();
});
it("regression, routes child announce to parent session instead of grandparent when parent session still exists", async () => {
const parentSessionKey = "agent:main:subagent:parent";
requesterDepthResolver = (sessionKey?: string) =>

View File

@@ -14,6 +14,7 @@ import type { ConversationRef } from "../infra/outbound/session-binding-service.
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { normalizeAccountId, normalizeMainKey } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
import { isCronSessionKey } from "../sessions/session-key-utils.js";
import { extractTextFromChatContent } from "../shared/chat-content.js";
import {
type DeliveryContext,
@@ -78,6 +79,10 @@ function resolveSubagentAnnounceTimeoutMs(cfg: ReturnType<typeof loadConfig>): n
return Math.min(Math.max(1, Math.floor(configured)), MAX_TIMER_SAFE_TIMEOUT_MS);
}
function isInternalAnnounceRequesterSession(sessionKey: string | undefined): boolean {
return getSubagentDepthFromSessionStore(sessionKey) >= 1 || isCronSessionKey(sessionKey);
}
function summarizeDeliveryError(error: unknown): string {
if (error instanceof Error) {
return error.message || "error";
@@ -580,8 +585,7 @@ async function resolveSubagentCompletionOrigin(params: {
async function sendAnnounce(item: AnnounceQueueItem) {
const cfg = loadConfig();
const announceTimeoutMs = resolveSubagentAnnounceTimeoutMs(cfg);
const requesterDepth = getSubagentDepthFromSessionStore(item.sessionKey);
const requesterIsSubagent = requesterDepth >= 1;
const requesterIsSubagent = isInternalAnnounceRequesterSession(item.sessionKey);
const origin = item.origin;
const threadId =
origin?.threadId != null && origin.threadId !== "" ? String(origin.threadId) : undefined;
@@ -1216,6 +1220,8 @@ export async function runSubagentAnnounceFlow(params: {
}
let requesterDepth = getSubagentDepthFromSessionStore(targetRequesterSessionKey);
const requesterIsInternalSession = () =>
requesterDepth >= 1 || isCronSessionKey(targetRequesterSessionKey);
let childCompletionFindings: string | undefined;
let subagentRegistryRuntime:
@@ -1339,7 +1345,7 @@ export async function runSubagentAnnounceFlow(params: {
const announceSessionId = childSessionId || "unknown";
const findings = childCompletionFindings || reply || "(no output)";
let requesterIsSubagent = requesterDepth >= 1;
let requesterIsSubagent = requesterIsInternalSession();
if (requesterIsSubagent) {
const {
isSubagentSessionRunActive,
@@ -1363,7 +1369,7 @@ export async function runSubagentAnnounceFlow(params: {
targetRequesterOrigin =
normalizeDeliveryContext(fallback.requesterOrigin) ?? targetRequesterOrigin;
requesterDepth = getSubagentDepthFromSessionStore(targetRequesterSessionKey);
requesterIsSubagent = requesterDepth >= 1;
requesterIsSubagent = requesterIsInternalSession();
}
}
}

View File

@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
ensureRuntimePluginsLoaded: vi.fn(),
ensureContextEnginesInitialized: vi.fn(),
resolveContextEngine: vi.fn(),
onSubagentEnded: vi.fn(async () => {}),
onAgentEvent: vi.fn(() => () => {}),
persistSubagentRunsToDisk: vi.fn(),
}));
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
return {
...actual,
loadConfig: vi.fn(() => ({})),
};
});
vi.mock("../context-engine/init.js", () => ({
ensureContextEnginesInitialized: mocks.ensureContextEnginesInitialized,
}));
vi.mock("../context-engine/registry.js", () => ({
resolveContextEngine: mocks.resolveContextEngine,
}));
vi.mock("../infra/agent-events.js", () => ({
onAgentEvent: mocks.onAgentEvent,
}));
vi.mock("./runtime-plugins.js", () => ({
ensureRuntimePluginsLoaded: mocks.ensureRuntimePluginsLoaded,
}));
vi.mock("./subagent-registry-state.js", () => ({
getSubagentRunsSnapshotForRead: vi.fn((runs: Map<string, unknown>) => new Map(runs)),
persistSubagentRunsToDisk: mocks.persistSubagentRunsToDisk,
restoreSubagentRunsFromDisk: vi.fn(() => 0),
}));
vi.mock("./subagent-announce-queue.js", () => ({
resetAnnounceQueuesForTests: vi.fn(),
}));
vi.mock("./timeout.js", () => ({
resolveAgentTimeoutMs: vi.fn(() => 1_000),
}));
import {
registerSubagentRun,
releaseSubagentRun,
resetSubagentRegistryForTests,
} from "./subagent-registry.js";
describe("subagent-registry context-engine bootstrap", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.resolveContextEngine.mockResolvedValue({
onSubagentEnded: mocks.onSubagentEnded,
});
resetSubagentRegistryForTests({ persist: false });
});
it("reloads runtime plugins with the spawned workspace before subagent end hooks", async () => {
registerSubagentRun({
runId: "run-1",
childSessionKey: "agent:main:session:child",
requesterSessionKey: "agent:main:session:parent",
requesterDisplayKey: "parent",
task: "task",
cleanup: "keep",
workspaceDir: "/tmp/workspace",
});
releaseSubagentRun("run-1");
await vi.waitFor(() => {
expect(mocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({
config: {},
workspaceDir: "/tmp/workspace",
});
});
expect(mocks.ensureContextEnginesInitialized).toHaveBeenCalledTimes(1);
expect(mocks.onSubagentEnded).toHaveBeenCalledWith({
childSessionKey: "agent:main:session:child",
reason: "released",
workspaceDir: "/tmp/workspace",
});
});
});

View File

@@ -16,6 +16,7 @@ import { onAgentEvent } from "../infra/agent-events.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { defaultRuntime } from "../runtime.js";
import { type DeliveryContext, normalizeDeliveryContext } from "../utils/delivery-context.js";
import { ensureRuntimePluginsLoaded } from "./runtime-plugins.js";
import { resetAnnounceQueuesForTests } from "./subagent-announce-queue.js";
import {
captureSubagentCompletionReply,
@@ -313,10 +314,16 @@ function schedulePendingLifecycleError(params: { runId: string; endedAt: number;
async function notifyContextEngineSubagentEnded(params: {
childSessionKey: string;
reason: SubagentEndReason;
workspaceDir?: string;
}) {
try {
const cfg = loadConfig();
ensureRuntimePluginsLoaded({
config: cfg,
workspaceDir: params.workspaceDir,
});
ensureContextEnginesInitialized();
const engine = await resolveContextEngine(loadConfig());
const engine = await resolveContextEngine(cfg);
if (!engine.onSubagentEnded) {
return;
}
@@ -714,6 +721,7 @@ async function sweepSubagentRuns() {
void notifyContextEngineSubagentEnded({
childSessionKey: entry.childSessionKey,
reason: "swept",
workspaceDir: entry.workspaceDir,
});
subagentRuns.delete(runId);
mutated = true;
@@ -963,6 +971,7 @@ function completeCleanupBookkeeping(params: {
void notifyContextEngineSubagentEnded({
childSessionKey: params.entry.childSessionKey,
reason: "deleted",
workspaceDir: params.entry.workspaceDir,
});
subagentRuns.delete(params.runId);
persistSubagentRuns();
@@ -972,6 +981,7 @@ function completeCleanupBookkeeping(params: {
void notifyContextEngineSubagentEnded({
childSessionKey: params.entry.childSessionKey,
reason: "completed",
workspaceDir: params.entry.workspaceDir,
});
params.entry.cleanupCompletedAt = params.completedAt;
persistSubagentRuns();
@@ -1143,6 +1153,7 @@ export function registerSubagentRun(params: {
cleanup: "delete" | "keep";
label?: string;
model?: string;
workspaceDir?: string;
runTimeoutSeconds?: number;
expectsCompletionMessage?: boolean;
spawnMode?: "run" | "session";
@@ -1171,6 +1182,7 @@ export function registerSubagentRun(params: {
spawnMode,
label: params.label,
model: params.model,
workspaceDir: params.workspaceDir,
runTimeoutSeconds,
createdAt: now,
startedAt: now,
@@ -1285,6 +1297,7 @@ export function releaseSubagentRun(runId: string) {
void notifyContextEngineSubagentEnded({
childSessionKey: entry.childSessionKey,
reason: "released",
workspaceDir: entry.workspaceDir,
});
}
const didDelete = subagentRuns.delete(runId);

View File

@@ -13,6 +13,7 @@ export type SubagentRunRecord = {
cleanup: "delete" | "keep";
label?: string;
model?: string;
workspaceDir?: string;
runTimeoutSeconds?: number;
spawnMode?: SpawnSubagentMode;
createdAt: number;

View File

@@ -650,6 +650,7 @@ export async function spawnSubagentDirect(
cleanup,
label: label || undefined,
model: resolvedModel,
workspaceDir: spawnedMetadata.workspaceDir,
runTimeoutSeconds,
expectsCompletionMessage,
spawnMode,

View File

@@ -74,6 +74,17 @@ function stripTargetIdFromActRequest(
return retryRequest as Parameters<typeof browserAct>[1];
}
function canRetryChromeActWithoutTargetId(request: Parameters<typeof browserAct>[1]): boolean {
const typedRequest = request as Partial<Record<"kind" | "action", unknown>>;
const kind =
typeof typedRequest.kind === "string"
? typedRequest.kind
: typeof typedRequest.action === "string"
? typedRequest.action
: "";
return kind === "hover" || kind === "scrollIntoView" || kind === "wait";
}
export async function executeTabsAction(params: {
baseUrl?: string;
profile?: string;
@@ -304,9 +315,18 @@ export async function executeActAction(params: {
} catch (err) {
if (isChromeStaleTargetError(profile, err)) {
const retryRequest = stripTargetIdFromActRequest(request);
const tabs = proxyRequest
? ((
(await proxyRequest({
method: "GET",
path: "/tabs",
profile,
})) as { tabs?: unknown[] }
).tabs ?? [])
: await browserTabs(baseUrl, { profile }).catch(() => []);
// Some Chrome relay targetIds can go stale between snapshots and actions.
// Retry once without targetId to let relay use the currently attached tab.
if (retryRequest) {
// Only retry safe read-only actions, and only when exactly one tab remains attached.
if (retryRequest && canRetryChromeActWithoutTargetId(request) && tabs.length === 1) {
try {
const retryResult = proxyRequest
? await proxyRequest({
@@ -323,15 +343,6 @@ export async function executeActAction(params: {
// Fall through to explicit stale-target guidance.
}
}
const tabs = proxyRequest
? ((
(await proxyRequest({
method: "GET",
path: "/tabs",
profile,
})) as { tabs?: unknown[] }
).tabs ?? [])
: await browserTabs(baseUrl, { profile }).catch(() => []);
if (!tabs.length) {
throw new Error(
"No Chrome tabs are attached via the OpenClaw Browser Relay extension. Click the toolbar icon on the tab you want to control (badge ON), then retry.",

View File

@@ -571,17 +571,18 @@ describe("browser tool external content wrapping", () => {
describe("browser tool act stale target recovery", () => {
registerBrowserToolAfterEachReset();
it("retries chrome act once without targetId when tab id is stale", async () => {
it("retries safe chrome act once without targetId when exactly one tab remains", async () => {
browserActionsMocks.browserAct
.mockRejectedValueOnce(new Error("404: tab not found"))
.mockResolvedValueOnce({ ok: true });
browserClientMocks.browserTabs.mockResolvedValueOnce([{ targetId: "only-tab" }]);
const tool = createBrowserTool();
const result = await tool.execute?.("call-1", {
action: "act",
profile: "chrome",
request: {
action: "click",
kind: "hover",
targetId: "stale-tab",
ref: "btn-1",
},
@@ -591,7 +592,7 @@ describe("browser tool act stale target recovery", () => {
expect(browserActionsMocks.browserAct).toHaveBeenNthCalledWith(
1,
undefined,
expect.objectContaining({ targetId: "stale-tab", action: "click", ref: "btn-1" }),
expect.objectContaining({ targetId: "stale-tab", kind: "hover", ref: "btn-1" }),
expect.objectContaining({ profile: "chrome" }),
);
expect(browserActionsMocks.browserAct).toHaveBeenNthCalledWith(
@@ -602,4 +603,24 @@ describe("browser tool act stale target recovery", () => {
);
expect(result?.details).toMatchObject({ ok: true });
});
it("does not retry mutating chrome act requests without targetId", async () => {
browserActionsMocks.browserAct.mockRejectedValueOnce(new Error("404: tab not found"));
browserClientMocks.browserTabs.mockResolvedValueOnce([{ targetId: "only-tab" }]);
const tool = createBrowserTool();
await expect(
tool.execute?.("call-1", {
action: "act",
profile: "chrome",
request: {
kind: "click",
targetId: "stale-tab",
ref: "btn-1",
},
}),
).rejects.toThrow(/Run action=tabs profile="chrome"/i);
expect(browserActionsMocks.browserAct).toHaveBeenCalledTimes(1);
});
});

View File

@@ -772,7 +772,25 @@ describe("web_search external content wrapping", () => {
expect(mockFetch).not.toHaveBeenCalled();
});
it("rejects date_after/date_before in Brave llm-context mode", async () => {
it.each([
[
"rejects date_after/date_before in Brave llm-context mode",
{
query: "test",
date_after: "2025-01-01",
date_before: "2025-01-31",
},
"unsupported_date_filter",
],
[
"rejects ui_lang in Brave llm-context mode",
{
query: "test",
ui_lang: "de-DE",
},
"unsupported_ui_lang",
],
])("%s", async (_name, input, expectedError) => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const mockFetch = installBraveLlmContextFetch({
title: "unused",
@@ -795,45 +813,9 @@ describe("web_search external content wrapping", () => {
},
sandboxed: true,
});
const result = await tool?.execute?.("call-1", {
query: "test",
date_after: "2025-01-01",
date_before: "2025-01-31",
});
const result = await tool?.execute?.("call-1", input);
expect(result?.details).toMatchObject({ error: "unsupported_date_filter" });
expect(mockFetch).not.toHaveBeenCalled();
});
it("rejects ui_lang in Brave llm-context mode", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const mockFetch = installBraveLlmContextFetch({
title: "unused",
url: "https://example.com",
snippets: ["unused"],
});
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "brave",
brave: {
mode: "llm-context",
},
},
},
},
},
sandboxed: true,
});
const result = await tool?.execute?.("call-1", {
query: "test",
ui_lang: "de-DE",
});
expect(result?.details).toMatchObject({ error: "unsupported_ui_lang" });
expect(result?.details).toMatchObject({ error: expectedError });
expect(mockFetch).not.toHaveBeenCalled();
});

View File

@@ -166,11 +166,23 @@ describe("cdp.helpers", () => {
expect(url).toBe("https://connect.example.com/?token=abc");
});
it("preserves auth and query params when normalizing secure loopback WebSocket CDP URLs", () => {
const url = normalizeCdpHttpBaseForJsonEndpoints(
"wss://user:pass@127.0.0.1:9222/devtools/browser/ABC?token=abc",
);
expect(url).toBe("https://user:pass@127.0.0.1:9222/?token=abc");
});
it("strips a trailing /cdp suffix when normalizing HTTP bases", () => {
const url = normalizeCdpHttpBaseForJsonEndpoints("ws://127.0.0.1:9222/cdp?token=abc");
expect(url).toBe("http://127.0.0.1:9222/?token=abc");
});
it("preserves base prefixes when stripping a trailing /cdp suffix", () => {
const url = normalizeCdpHttpBaseForJsonEndpoints("ws://127.0.0.1:9222/browser/cdp?token=abc");
expect(url).toBe("http://127.0.0.1:9222/browser?token=abc");
});
it("adds basic auth headers when credentials are present", () => {
const headers = getHeadersWithAuth("https://user:pass@example.com");
expect(headers.Authorization).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`);

View File

@@ -336,6 +336,26 @@ describe("cdp", () => {
expect(normalized).toBe("ws://192.168.1.202:18850/devtools/browser/ABC");
});
it("keeps existing websocket query params when appending remote CDP query params", () => {
const normalized = normalizeCdpWsUrl(
"ws://127.0.0.1:9222/devtools/browser/ABC?session=1&token=ws-token",
"http://127.0.0.1:9222?token=cdp-token&apiKey=abc",
);
expect(normalized).toBe(
"ws://127.0.0.1:9222/devtools/browser/ABC?session=1&token=ws-token&apiKey=abc",
);
});
it("rewrites wildcard bind addresses to secure remote CDP hosts without clobbering websocket params", () => {
const normalized = normalizeCdpWsUrl(
"ws://0.0.0.0:3000/devtools/browser/ABC?session=1&token=ws-token",
"https://user:pass@example.com:9443?token=cdp-token&apiKey=abc",
);
expect(normalized).toBe(
"wss://user:pass@example.com:9443/devtools/browser/ABC?session=1&token=ws-token&apiKey=abc",
);
});
it("upgrades ws to wss when CDP uses https", () => {
const normalized = normalizeCdpWsUrl(
"ws://production-sfo.browserless.io",

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