From e8197404d0984289cf7aaee8a61007ce59fe9d53 Mon Sep 17 00:00:00 2001 From: pandego <7780875+pandego@users.noreply.github.com> Date: Wed, 25 Feb 2026 08:35:48 +0100 Subject: [PATCH 01/35] Docker/docs: reduce docker build OOM risk on small GCP hosts --- Dockerfile | 3 +++ docs/install/docker.md | 1 + docs/install/gcp.md | 13 ++++++++----- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 255340cb02b..c5f7b1dc277 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,9 @@ COPY --chown=node:node patches ./patches COPY --chown=node:node scripts ./scripts USER node +# Reduce OOM risk on low-memory hosts during dependency installation. +# Docker builds on small VMs may otherwise fail with "Killed" (exit 137). +ENV NODE_OPTIONS=--max-old-space-size=2048 RUN pnpm install --frozen-lockfile # Optionally install Chromium and Xvfb for browser automation. diff --git a/docs/install/docker.md b/docs/install/docker.md index decd1d779ee..42cefd4be01 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -26,6 +26,7 @@ Sandboxing details: [Sandboxing](/gateway/sandboxing) ## Requirements - Docker Desktop (or Docker Engine) + Docker Compose v2 +- At least 2 GB RAM for image build (`pnpm install` may be OOM-killed on 1 GB hosts with exit 137) - Enough disk for images + logs ## Containerized Gateway (Docker Compose) diff --git a/docs/install/gcp.md b/docs/install/gcp.md index b0ec51a75dd..a4485611402 100644 --- a/docs/install/gcp.md +++ b/docs/install/gcp.md @@ -114,10 +114,11 @@ gcloud services enable compute.googleapis.com **Machine types:** -| Type | Specs | Cost | Notes | -| -------- | ------------------------ | ------------------ | ------------------ | -| e2-small | 2 vCPU, 2GB RAM | ~$12/mo | Recommended | -| e2-micro | 2 vCPU (shared), 1GB RAM | Free tier eligible | May OOM under load | +| Type | Specs | Cost | Notes | +| --------- | ------------------------ | ------------------ | -------------------------------------------- | +| e2-medium | 2 vCPU, 4GB RAM | ~$25/mo | Most reliable for local Docker builds | +| e2-small | 2 vCPU, 2GB RAM | ~$12/mo | Minimum recommended for Docker build | +| e2-micro | 2 vCPU (shared), 1GB RAM | Free tier eligible | Often fails with Docker build OOM (exit 137) | **CLI:** @@ -350,6 +351,8 @@ docker compose build docker compose up -d openclaw-gateway ``` +If build fails with `Killed` / `exit code 137` during `pnpm install --frozen-lockfile`, the VM is out of memory. Use `e2-small` minimum, or `e2-medium` for more reliable first builds. + Verify binaries: ```bash @@ -449,7 +452,7 @@ Ensure your account has the required IAM permissions (Compute OS Login or Comput **Out of memory (OOM)** -If using e2-micro and hitting OOM, upgrade to e2-small or e2-medium: +If Docker build fails with `Killed` and `exit code 137`, the VM was OOM-killed. Upgrade to e2-small (minimum) or e2-medium (recommended for reliable local builds): ```bash # Stop the VM first From 35976da7a0785cda4002f7379395a6515d12f0c1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 05:45:57 +0100 Subject: [PATCH 02/35] fix: harden Docker/GCP onboarding flow (#26253) (thanks @pandego) --- CHANGELOG.md | 1 + Dockerfile | 3 +- docker-setup.sh | 82 +++++++++++++++++++++++++++++++++++++++- docs/install/gcp.md | 23 ++++++++++- src/docker-setup.test.ts | 21 ++++++++++ src/dockerfile.test.ts | 2 +- 6 files changed, 127 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfb74303243..0162138f63a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Docker/GCP onboarding: reduce first-build OOM risk by capping Node heap during `pnpm install`, reuse existing gateway token during `docker-setup.sh` reruns so `.env` stays aligned with config, auto-bootstrap Control UI allowed origins for non-loopback Docker binds, and add GCP docs guidance for tokenized dashboard links + pairing recovery commands. (#26253) Thanks @pandego. - Agents/Subagents delivery: refactor subagent completion announce dispatch into an explicit queue/direct/fallback state machine, recover outbound channel-plugin resolution in cold/stale plugin-registry states across announce/message/gateway send paths, finalize cleanup bookkeeping when announce flow rejects, and treat Telegram sends without `message_id` as delivery failures (instead of false-success `"unknown"` IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808. - Telegram/Webhook: pre-initialize webhook bots, switch webhook processing to callback-mode JSON handling, and preserve full near-limit payload reads under delayed handlers to prevent webhook request hangs and dropped updates. (#26156) - Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable `session.parentForkMaxTokens` (default `100000`, `0` disables). (#26912) Thanks @markshields-tl. diff --git a/Dockerfile b/Dockerfile index c5f7b1dc277..2229a299a56 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,8 +25,7 @@ COPY --chown=node:node scripts ./scripts USER node # Reduce OOM risk on low-memory hosts during dependency installation. # Docker builds on small VMs may otherwise fail with "Killed" (exit 137). -ENV NODE_OPTIONS=--max-old-space-size=2048 -RUN pnpm install --frozen-lockfile +RUN NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile # Optionally install Chromium and Xvfb for browser automation. # Build with: docker build --build-arg OPENCLAW_INSTALL_BROWSER=1 ... diff --git a/docker-setup.sh b/docker-setup.sh index c0cd925c4c3..1f6e51cd75d 100755 --- a/docker-setup.sh +++ b/docker-setup.sh @@ -20,6 +20,78 @@ require_cmd() { fi } +read_config_gateway_token() { + local config_path="$OPENCLAW_CONFIG_DIR/openclaw.json" + if [[ ! -f "$config_path" ]]; then + return 0 + fi + if command -v python3 >/dev/null 2>&1; then + python3 - "$config_path" <<'PY' +import json +import sys + +path = sys.argv[1] +try: + with open(path, "r", encoding="utf-8") as f: + cfg = json.load(f) +except Exception: + raise SystemExit(0) + +gateway = cfg.get("gateway") +if not isinstance(gateway, dict): + raise SystemExit(0) +auth = gateway.get("auth") +if not isinstance(auth, dict): + raise SystemExit(0) +token = auth.get("token") +if isinstance(token, str): + token = token.strip() + if token: + print(token) +PY + return 0 + fi + if command -v node >/dev/null 2>&1; then + node - "$config_path" <<'NODE' +const fs = require("node:fs"); +const configPath = process.argv[2]; +try { + const cfg = JSON.parse(fs.readFileSync(configPath, "utf8")); + const token = cfg?.gateway?.auth?.token; + if (typeof token === "string" && token.trim().length > 0) { + process.stdout.write(token.trim()); + } +} catch { + // Keep docker-setup resilient when config parsing fails. +} +NODE + fi +} + +ensure_control_ui_allowed_origins() { + if [[ "${OPENCLAW_GATEWAY_BIND}" == "loopback" ]]; then + return 0 + fi + + local allowed_origin_json + local current_allowed_origins + allowed_origin_json="$(printf '["http://127.0.0.1:%s"]' "$OPENCLAW_GATEWAY_PORT")" + current_allowed_origins="$( + docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \ + config get gateway.controlUi.allowedOrigins 2>/dev/null || true + )" + current_allowed_origins="${current_allowed_origins//$'\r'/}" + + if [[ -n "$current_allowed_origins" && "$current_allowed_origins" != "null" && "$current_allowed_origins" != "[]" ]]; then + echo "Control UI allowlist already configured; leaving gateway.controlUi.allowedOrigins unchanged." + return 0 + fi + + docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \ + config set gateway.controlUi.allowedOrigins "$allowed_origin_json" --strict-json >/dev/null + echo "Set gateway.controlUi.allowedOrigins to $allowed_origin_json for non-loopback bind." +} + contains_disallowed_chars() { local value="$1" [[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]] @@ -97,7 +169,11 @@ export OPENCLAW_EXTRA_MOUNTS="$EXTRA_MOUNTS" export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME" if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then - if command -v openssl >/dev/null 2>&1; then + EXISTING_CONFIG_TOKEN="$(read_config_gateway_token || true)" + if [[ -n "$EXISTING_CONFIG_TOKEN" ]]; then + OPENCLAW_GATEWAY_TOKEN="$EXISTING_CONFIG_TOKEN" + echo "Reusing gateway token from $OPENCLAW_CONFIG_DIR/openclaw.json" + elif command -v openssl >/dev/null 2>&1; then OPENCLAW_GATEWAY_TOKEN="$(openssl rand -hex 32)" else OPENCLAW_GATEWAY_TOKEN="$(python3 - <<'PY' @@ -273,6 +349,10 @@ echo " - Install Gateway daemon: No" echo "" docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli onboard --no-install-daemon +echo "" +echo "==> Control UI origin allowlist" +ensure_control_ui_allowed_origins + echo "" echo "==> Provider setup (optional)" echo "WhatsApp (QR):" diff --git a/docs/install/gcp.md b/docs/install/gcp.md index a4485611402..2c6bdd8ac1f 100644 --- a/docs/install/gcp.md +++ b/docs/install/gcp.md @@ -353,6 +353,14 @@ docker compose up -d openclaw-gateway If build fails with `Killed` / `exit code 137` during `pnpm install --frozen-lockfile`, the VM is out of memory. Use `e2-small` minimum, or `e2-medium` for more reliable first builds. +When binding to LAN (`OPENCLAW_GATEWAY_BIND=lan`), configure a trusted browser origin before continuing: + +```bash +docker compose run --rm openclaw-cli config set gateway.controlUi.allowedOrigins '["http://127.0.0.1:18789"]' --strict-json +``` + +If you changed the gateway port, replace `18789` with your configured port. + Verify binaries: ```bash @@ -397,7 +405,20 @@ Open in your browser: `http://127.0.0.1:18789/` -Paste your gateway token. +Fetch a fresh tokenized dashboard link: + +```bash +docker compose run --rm openclaw-cli dashboard --no-open +``` + +Paste the token from that URL. + +If Control UI shows `unauthorized` or `disconnected (1008): pairing required`, approve the browser device: + +```bash +docker compose run --rm openclaw-cli devices list +docker compose run --rm openclaw-cli devices approve +``` --- diff --git a/src/docker-setup.test.ts b/src/docker-setup.test.ts index 20f754990e3..8737ff5a793 100644 --- a/src/docker-setup.test.ts +++ b/src/docker-setup.test.ts @@ -168,6 +168,27 @@ describe("docker-setup.sh", () => { expect(identityDirStat.isDirectory()).toBe(true); }); + it("reuses existing config token when OPENCLAW_GATEWAY_TOKEN is unset", async () => { + const activeSandbox = requireSandbox(sandbox); + const configDir = join(activeSandbox.rootDir, "config-token-reuse"); + const workspaceDir = join(activeSandbox.rootDir, "workspace-token-reuse"); + await mkdir(configDir, { recursive: true }); + await writeFile( + join(configDir, "openclaw.json"), + JSON.stringify({ gateway: { auth: { mode: "token", token: "config-token-123" } } }), + ); + + const result = runDockerSetup(activeSandbox, { + OPENCLAW_GATEWAY_TOKEN: undefined, + OPENCLAW_CONFIG_DIR: configDir, + OPENCLAW_WORKSPACE_DIR: workspaceDir, + }); + + expect(result.status).toBe(0); + const envFile = await readFile(join(activeSandbox.rootDir, ".env"), "utf8"); + expect(envFile).toContain("OPENCLAW_GATEWAY_TOKEN=config-token-123"); + }); + it("rejects injected multiline OPENCLAW_EXTRA_MOUNTS values", async () => { const activeSandbox = requireSandbox(sandbox); diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index 4e75caeb420..5cd55d9b53f 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -9,7 +9,7 @@ const dockerfilePath = join(repoRoot, "Dockerfile"); describe("Dockerfile", () => { it("installs optional browser dependencies after pnpm install", async () => { const dockerfile = await readFile(dockerfilePath, "utf8"); - const installIndex = dockerfile.indexOf("RUN pnpm install --frozen-lockfile"); + const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile"); const browserArgIndex = dockerfile.indexOf("ARG OPENCLAW_INSTALL_BROWSER"); expect(installIndex).toBeGreaterThan(-1); From cf8d01bc5a3d5fcaf539c81c264ca2a591f49610 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 25 Feb 2026 23:48:43 -0500 Subject: [PATCH 03/35] pairing: isolate account-scoped allowlist and pending requests --- src/pairing/pairing-store.test.ts | 30 ++++++++++++++++++++++++++++-- src/pairing/pairing-store.ts | 21 +++++++++++++++++++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/pairing/pairing-store.test.ts b/src/pairing/pairing-store.test.ts index e44dd391eaf..3d42546f6c1 100644 --- a/src/pairing/pairing-store.test.ts +++ b/src/pairing/pairing-store.test.ts @@ -257,7 +257,7 @@ describe("pairing store", () => { }); }); - it("reads sync allowFrom with scoped + legacy dedupe and wildcard filtering", async () => { + it("reads sync allowFrom with account-scoped isolation and wildcard filtering", async () => { await withTempStateDir(async (stateDir) => { await writeAllowFromFixture({ stateDir, @@ -273,11 +273,37 @@ describe("pairing store", () => { const scoped = readChannelAllowFromStoreSync("telegram", process.env, "yy"); const channelScoped = readChannelAllowFromStoreSync("telegram"); - expect(scoped).toEqual(["1002", "1001"]); + expect(scoped).toEqual(["1002", "1001", "1002"]); expect(channelScoped).toEqual(["1001", "1001"]); }); }); + it("does not reuse pairing requests across accounts for the same sender id", async () => { + await withTempStateDir(async () => { + const first = await upsertChannelPairingRequest({ + channel: "telegram", + accountId: "alpha", + id: "12345", + }); + const second = await upsertChannelPairingRequest({ + channel: "telegram", + accountId: "beta", + id: "12345", + }); + + expect(first.created).toBe(true); + expect(second.created).toBe(true); + expect(second.code).not.toBe(first.code); + + const alpha = await listChannelPairingRequests("telegram", process.env, "alpha"); + const beta = await listChannelPairingRequests("telegram", process.env, "beta"); + expect(alpha).toHaveLength(1); + expect(beta).toHaveLength(1); + expect(alpha[0]?.code).toBe(first.code); + expect(beta[0]?.code).toBe(second.code); + }); + }); + it("reads legacy channel-scoped allowFrom for default account", async () => { await withTempStateDir(async (stateDir) => { await writeAllowFromFixture({ stateDir, channel: "telegram", allowFrom: ["1001"] }); diff --git a/src/pairing/pairing-store.ts b/src/pairing/pairing-store.ts index eb0b52b308b..0f46d53b479 100644 --- a/src/pairing/pairing-store.ts +++ b/src/pairing/pairing-store.ts @@ -218,6 +218,12 @@ function requestMatchesAccountId(entry: PairingRequest, normalizedAccountId: str ); } +function shouldIncludeLegacyAllowFromEntries(normalizedAccountId: string): boolean { + // Keep backward compatibility for legacy channel-scoped allowFrom only on default account. + // Non-default accounts should remain isolated to avoid cross-account implicit approvals. + return !normalizedAccountId || normalizedAccountId === "default"; +} + function normalizeId(value: string | number): string { return String(value).trim(); } @@ -344,8 +350,11 @@ export async function readChannelAllowFromStore( const scopedPath = resolveAllowFromPath(channel, env, accountId); const scopedEntries = await readAllowFromStateForPath(channel, scopedPath); + if (!shouldIncludeLegacyAllowFromEntries(normalizedAccountId)) { + return scopedEntries; + } // Backward compatibility: legacy channel-level allowFrom store was unscoped. - // Keep honoring it alongside account-scoped files to prevent re-pair prompts after upgrades. + // Keep honoring it for default account to prevent re-pair prompts after upgrades. const legacyPath = resolveAllowFromPath(channel, env); const legacyEntries = await readAllowFromStateForPath(channel, legacyPath); return dedupePreserveOrder([...scopedEntries, ...legacyEntries]); @@ -364,6 +373,9 @@ export function readChannelAllowFromStoreSync( const scopedPath = resolveAllowFromPath(channel, env, accountId); const scopedEntries = readAllowFromStateForPathSync(channel, scopedPath); + if (!shouldIncludeLegacyAllowFromEntries(normalizedAccountId)) { + return scopedEntries; + } const legacyPath = resolveAllowFromPath(channel, env); const legacyEntries = readAllowFromStateForPathSync(channel, legacyPath); return dedupePreserveOrder([...scopedEntries, ...legacyEntries]); @@ -503,7 +515,12 @@ export async function upsertChannelPairingRequest(params: { nowMs, ); reqs = prunedExpired; - const existingIdx = reqs.findIndex((r) => r.id === id); + const existingIdx = reqs.findIndex((r) => { + if (r.id !== id) { + return false; + } + return requestMatchesAccountId(r, normalizePairingAccountId(normalizedAccountId)); + }); const existingCodes = new Set( reqs.map((req) => String(req.code ?? "") From d9b19e5970bdd34a0182e7e0e8f82094d1984fdf Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 26 Feb 2026 00:00:00 -0500 Subject: [PATCH 04/35] plugin-sdk: export shared timezone formatting helpers (#27196) --- src/plugin-sdk/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 6e25d50740b..828ec089903 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -244,6 +244,11 @@ export type { PersistentDedupeOptions, } from "./persistent-dedupe.js"; export { formatErrorMessage } from "../infra/errors.js"; +export { + formatUtcTimestamp, + formatZonedTimestamp, + resolveTimezone, +} from "../infra/format-time/format-datetime.js"; export { DEFAULT_WEBHOOK_BODY_TIMEOUT_MS, DEFAULT_WEBHOOK_MAX_BODY_BYTES, From 91a3f0a3fe8af55218273b029f5183184d4819f5 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 26 Feb 2026 00:31:24 -0500 Subject: [PATCH 05/35] pairing: enforce strict account-scoped state --- CHANGELOG.md | 1 + docs/channels/pairing.md | 9 +++- docs/gateway/security/index.md | 6 ++- docs/start/setup.md | 4 +- src/pairing/pairing-store.test.ts | 70 +++++++++++++++++++++++++- src/pairing/pairing-store.ts | 81 ++++++++++++++++++++++++++----- 6 files changed, 152 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0162138f63a..5b9a53cecfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai - Agents/Subagents delivery: refactor subagent completion announce dispatch into an explicit queue/direct/fallback state machine, recover outbound channel-plugin resolution in cold/stale plugin-registry states across announce/message/gateway send paths, finalize cleanup bookkeeping when announce flow rejects, and treat Telegram sends without `message_id` as delivery failures (instead of false-success `"unknown"` IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808. - Telegram/Webhook: pre-initialize webhook bots, switch webhook processing to callback-mode JSON handling, and preserve full near-limit payload reads under delayed handlers to prevent webhook request hangs and dropped updates. (#26156) - Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable `session.parentForkMaxTokens` (default `100000`, `0` disables). (#26912) Thanks @markshields-tl. +- Pairing/Multi-account isolation: keep non-default account pairing allowlists and pending requests strictly account-scoped, while default account continues to use channel-scoped pairing allowlist storage. - Cron/Message multi-account routing: honor explicit `delivery.accountId` for isolated cron delivery resolution, and when `message.send` omits `accountId`, fall back to the sending agent's bound channel account instead of defaulting to the global account. (#27015, #26975) Thanks @lbo728 and @stakeswky. - Gateway/Message media roots: thread `agentId` through gateway `send` RPC and prefer explicit `agentId` over session/default resolution so non-default agent workspace media sends no longer fail with `LocalMediaAccessError`; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin. - Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin. diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index 4b575eb87c7..d402de16662 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -43,7 +43,14 @@ Supported channels: `telegram`, `whatsapp`, `signal`, `imessage`, `discord`, `sl Stored under `~/.openclaw/credentials/`: - Pending requests: `-pairing.json` -- Approved allowlist store: `-allowFrom.json` +- Approved allowlist store: + - Default account: `-allowFrom.json` + - Non-default account: `--allowFrom.json` + +Account scoping behavior: + +- Non-default accounts read/write only their scoped allowlist file. +- Default account uses the channel-scoped unscoped allowlist file. Treat these as sensitive (they gate access to your assistant). diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index a61a81eab1e..32a2a55329d 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -202,7 +202,9 @@ Use this when auditing access or deciding what to back up: - **Telegram bot token**: config/env or `channels.telegram.tokenFile` - **Discord bot token**: config/env (token file not yet supported) - **Slack tokens**: config/env (`channels.slack.*`) -- **Pairing allowlists**: `~/.openclaw/credentials/-allowFrom.json` +- **Pairing allowlists**: + - `~/.openclaw/credentials/-allowFrom.json` (default account) + - `~/.openclaw/credentials/--allowFrom.json` (non-default accounts) - **Model auth profiles**: `~/.openclaw/agents//agent/auth-profiles.json` - **Legacy OAuth import**: `~/.openclaw/credentials/oauth.json` @@ -488,7 +490,7 @@ If you run multiple accounts on the same channel, use `per-account-channel-peer` OpenClaw has two separate “who can trigger me?” layers: - **DM allowlist** (`allowFrom` / `channels.discord.allowFrom` / `channels.slack.allowFrom`; legacy: `channels.discord.dm.allowFrom`, `channels.slack.dm.allowFrom`): who is allowed to talk to the bot in direct messages. - - When `dmPolicy="pairing"`, approvals are written to `~/.openclaw/credentials/-allowFrom.json` (merged with config allowlists). + - When `dmPolicy="pairing"`, approvals are written to the account-scoped pairing allowlist store under `~/.openclaw/credentials/` (`-allowFrom.json` for default account, `--allowFrom.json` for non-default accounts), merged with config allowlists. - **Group allowlist** (channel-specific): which groups/channels/guilds the bot will accept messages from at all. - Common patterns: - `channels.whatsapp.groups`, `channels.telegram.groups`, `channels.imessage.groups`: per-group defaults like `requireMention`; when set, it also acts as a group allowlist (include `"*"` to keep allow-all behavior). diff --git a/docs/start/setup.md b/docs/start/setup.md index ee50e02afd4..7eef5bce714 100644 --- a/docs/start/setup.md +++ b/docs/start/setup.md @@ -130,7 +130,9 @@ Use this when debugging auth or deciding what to back up: - **Telegram bot token**: config/env or `channels.telegram.tokenFile` - **Discord bot token**: config/env (token file not yet supported) - **Slack tokens**: config/env (`channels.slack.*`) -- **Pairing allowlists**: `~/.openclaw/credentials/-allowFrom.json` +- **Pairing allowlists**: + - `~/.openclaw/credentials/-allowFrom.json` (default account) + - `~/.openclaw/credentials/--allowFrom.json` (non-default accounts) - **Model auth profiles**: `~/.openclaw/agents//agent/auth-profiles.json` - **Legacy OAuth import**: `~/.openclaw/credentials/oauth.json` More detail: [Security](/gateway/security#credential-storage-map). diff --git a/src/pairing/pairing-store.test.ts b/src/pairing/pairing-store.test.ts index 3d42546f6c1..130a8dc3807 100644 --- a/src/pairing/pairing-store.test.ts +++ b/src/pairing/pairing-store.test.ts @@ -35,6 +35,7 @@ async function withTempStateDir(fn: (stateDir: string) => Promise) { } async function writeJsonFixture(filePath: string, value: unknown) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } @@ -42,6 +43,11 @@ function resolvePairingFilePath(stateDir: string, channel: string) { return path.join(resolveOAuthDir(process.env, stateDir), `${channel}-pairing.json`); } +function resolveAllowFromFilePath(stateDir: string, channel: string, accountId?: string) { + const suffix = accountId ? `-${accountId}` : ""; + return path.join(resolveOAuthDir(process.env, stateDir), `${channel}${suffix}-allowFrom.json`); +} + async function writeAllowFromFixture(params: { stateDir: string; channel: string; @@ -273,8 +279,68 @@ describe("pairing store", () => { const scoped = readChannelAllowFromStoreSync("telegram", process.env, "yy"); const channelScoped = readChannelAllowFromStoreSync("telegram"); - expect(scoped).toEqual(["1002", "1001", "1002"]); - expect(channelScoped).toEqual(["1001", "1001"]); + expect(scoped).toEqual(["1002", "1001"]); + expect(channelScoped).toEqual(["1001"]); + }); + }); + + it("does not read legacy channel-scoped allowFrom for non-default account ids", async () => { + await withTempStateDir(async (stateDir) => { + await writeAllowFromFixture({ + stateDir, + channel: "telegram", + allowFrom: ["1001", "*", "1002", "1001"], + }); + await writeAllowFromFixture({ + stateDir, + channel: "telegram", + accountId: "yy", + allowFrom: ["1003"], + }); + + const asyncScoped = await readChannelAllowFromStore("telegram", process.env, "yy"); + const syncScoped = readChannelAllowFromStoreSync("telegram", process.env, "yy"); + expect(asyncScoped).toEqual(["1003"]); + expect(syncScoped).toEqual(["1003"]); + }); + }); + + it("does not fall back to legacy allowFrom when scoped file exists but is empty", async () => { + await withTempStateDir(async (stateDir) => { + await writeAllowFromFixture({ + stateDir, + channel: "telegram", + allowFrom: ["1001"], + }); + await writeAllowFromFixture({ + stateDir, + channel: "telegram", + accountId: "yy", + allowFrom: [], + }); + + const asyncScoped = await readChannelAllowFromStore("telegram", process.env, "yy"); + const syncScoped = readChannelAllowFromStoreSync("telegram", process.env, "yy"); + expect(asyncScoped).toEqual([]); + expect(syncScoped).toEqual([]); + }); + }); + + it("keeps async and sync reads aligned for malformed scoped allowFrom files", async () => { + await withTempStateDir(async (stateDir) => { + await writeAllowFromFixture({ + stateDir, + channel: "telegram", + allowFrom: ["1001"], + }); + const malformedScopedPath = resolveAllowFromFilePath(stateDir, "telegram", "yy"); + await fs.mkdir(path.dirname(malformedScopedPath), { recursive: true }); + await fs.writeFile(malformedScopedPath, "{ this is not json\n", "utf8"); + + const asyncScoped = await readChannelAllowFromStore("telegram", process.env, "yy"); + const syncScoped = readChannelAllowFromStoreSync("telegram", process.env, "yy"); + expect(asyncScoped).toEqual([]); + expect(syncScoped).toEqual([]); }); }); diff --git a/src/pairing/pairing-store.ts b/src/pairing/pairing-store.ts index 0f46d53b479..d6a8b9e6c8e 100644 --- a/src/pairing/pairing-store.ts +++ b/src/pairing/pairing-store.ts @@ -243,7 +243,9 @@ function normalizeAllowEntry(channel: PairingChannel, entry: string): string { function normalizeAllowFromList(channel: PairingChannel, store: AllowFromStore): string[] { const list = Array.isArray(store.allowFrom) ? store.allowFrom : []; - return list.map((v) => normalizeAllowEntry(channel, String(v))).filter(Boolean); + return dedupePreserveOrder( + list.map((v) => normalizeAllowEntry(channel, String(v))).filter(Boolean), + ); } function normalizeAllowFromInput(channel: PairingChannel, entry: string | number): string { @@ -268,20 +270,46 @@ async function readAllowFromStateForPath( channel: PairingChannel, filePath: string, ): Promise { - const { value } = await readJsonFile(filePath, { + return (await readAllowFromStateForPathWithExists(channel, filePath)).entries; +} + +async function readAllowFromStateForPathWithExists( + channel: PairingChannel, + filePath: string, +): Promise<{ entries: string[]; exists: boolean }> { + const { value, exists } = await readJsonFile(filePath, { version: 1, allowFrom: [], }); - return normalizeAllowFromList(channel, value); + const entries = normalizeAllowFromList(channel, value); + return { entries, exists }; } function readAllowFromStateForPathSync(channel: PairingChannel, filePath: string): string[] { + return readAllowFromStateForPathSyncWithExists(channel, filePath).entries; +} + +function readAllowFromStateForPathSyncWithExists( + channel: PairingChannel, + filePath: string, +): { entries: string[]; exists: boolean } { + let raw = ""; + try { + raw = fs.readFileSync(filePath, "utf8"); + } catch (err) { + const code = (err as { code?: string }).code; + if (code === "ENOENT") { + return { entries: [], exists: false }; + } + return { entries: [], exists: false }; + } try { - const raw = fs.readFileSync(filePath, "utf8"); const parsed = JSON.parse(raw) as AllowFromStore; - return normalizeAllowFromList(channel, parsed); + const entries = normalizeAllowFromList(channel, parsed); + return { entries, exists: true }; } catch { - return []; + // Keep parity with async reads: malformed JSON still means the file exists. + return { entries: [], exists: true }; } } @@ -306,6 +334,24 @@ async function writeAllowFromState(filePath: string, allowFrom: string[]): Promi } satisfies AllowFromStore); } +async function readNonDefaultAccountAllowFrom(params: { + channel: PairingChannel; + env: NodeJS.ProcessEnv; + accountId: string; +}): Promise { + const scopedPath = resolveAllowFromPath(params.channel, params.env, params.accountId); + return await readAllowFromStateForPath(params.channel, scopedPath); +} + +function readNonDefaultAccountAllowFromSync(params: { + channel: PairingChannel; + env: NodeJS.ProcessEnv; + accountId: string; +}): string[] { + const scopedPath = resolveAllowFromPath(params.channel, params.env, params.accountId); + return readAllowFromStateForPathSync(params.channel, scopedPath); +} + async function updateAllowFromStoreEntry(params: { channel: PairingChannel; entry: string | number; @@ -348,11 +394,15 @@ export async function readChannelAllowFromStore( return await readAllowFromStateForPath(channel, filePath); } + if (!shouldIncludeLegacyAllowFromEntries(normalizedAccountId)) { + return await readNonDefaultAccountAllowFrom({ + channel, + env, + accountId: normalizedAccountId, + }); + } const scopedPath = resolveAllowFromPath(channel, env, accountId); const scopedEntries = await readAllowFromStateForPath(channel, scopedPath); - if (!shouldIncludeLegacyAllowFromEntries(normalizedAccountId)) { - return scopedEntries; - } // Backward compatibility: legacy channel-level allowFrom store was unscoped. // Keep honoring it for default account to prevent re-pair prompts after upgrades. const legacyPath = resolveAllowFromPath(channel, env); @@ -371,11 +421,15 @@ export function readChannelAllowFromStoreSync( return readAllowFromStateForPathSync(channel, filePath); } + if (!shouldIncludeLegacyAllowFromEntries(normalizedAccountId)) { + return readNonDefaultAccountAllowFromSync({ + channel, + env, + accountId: normalizedAccountId, + }); + } const scopedPath = resolveAllowFromPath(channel, env, accountId); const scopedEntries = readAllowFromStateForPathSync(channel, scopedPath); - if (!shouldIncludeLegacyAllowFromEntries(normalizedAccountId)) { - return scopedEntries; - } const legacyPath = resolveAllowFromPath(channel, env); const legacyEntries = readAllowFromStateForPathSync(channel, legacyPath); return dedupePreserveOrder([...scopedEntries, ...legacyEntries]); @@ -515,11 +569,12 @@ export async function upsertChannelPairingRequest(params: { nowMs, ); reqs = prunedExpired; + const normalizedMatchingAccountId = normalizePairingAccountId(normalizedAccountId); const existingIdx = reqs.findIndex((r) => { if (r.id !== id) { return false; } - return requestMatchesAccountId(r, normalizePairingAccountId(normalizedAccountId)); + return requestMatchesAccountId(r, normalizedMatchingAccountId); }); const existingCodes = new Set( reqs.map((req) => From 39a1c13635b4a770711e0fa9661448a39079c15e Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 26 Feb 2026 00:39:18 -0500 Subject: [PATCH 06/35] chore(ci): fix cross-platform symlink path assertions in agents file tests --- src/gateway/server-methods/agents-mutate.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts index a4fddea633a..26503db553c 100644 --- a/src/gateway/server-methods/agents-mutate.test.ts +++ b/src/gateway/server-methods/agents-mutate.test.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { describe, expect, it, vi, beforeEach } from "vitest"; /* ------------------------------------------------------------------ */ @@ -515,7 +516,7 @@ describe("agents.files.get/set symlink safety", () => { it("rejects agents.files.get when allowlisted file symlink escapes workspace", async () => { const workspace = "/workspace/test-agent"; - const candidate = `${workspace}/AGENTS.md`; + const candidate = path.resolve(workspace, "AGENTS.md"); mocks.fsRealpath.mockImplementation(async (p: string) => { if (p === workspace) { return workspace; @@ -548,7 +549,7 @@ describe("agents.files.get/set symlink safety", () => { it("rejects agents.files.set when allowlisted file symlink escapes workspace", async () => { const workspace = "/workspace/test-agent"; - const candidate = `${workspace}/AGENTS.md`; + const candidate = path.resolve(workspace, "AGENTS.md"); mocks.fsRealpath.mockImplementation(async (p: string) => { if (p === workspace) { return workspace; @@ -583,8 +584,8 @@ describe("agents.files.get/set symlink safety", () => { it("allows in-workspace symlink targets for get/set", async () => { const workspace = "/workspace/test-agent"; - const candidate = `${workspace}/AGENTS.md`; - const target = `${workspace}/policies/AGENTS.md`; + const candidate = path.resolve(workspace, "AGENTS.md"); + const target = path.resolve(workspace, "policies", "AGENTS.md"); const targetStat = makeFileStat({ size: 7, mtimeMs: 1700, dev: 9, ino: 42 }); mocks.fsRealpath.mockImplementation(async (p: string) => { From f08fe02a1b19791fe5f18f3fce1792ccbc9bfb1d Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 26 Feb 2026 01:14:57 -0500 Subject: [PATCH 07/35] Onboarding: support plugin-owned interactive channel flows (#27191) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 53872cf8e75562db012b66f888928524daff08d2 Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + docs/tools/plugin.md | 23 ++ src/channels/plugins/onboarding-types.ts | 13 + src/commands/channel-test-helpers.ts | 24 ++ src/commands/onboard-channels.test.ts | 308 ++++++++++++++++++++++- src/commands/onboard-channels.ts | 64 ++++- 6 files changed, 426 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b9a53cecfb..5f2ce6018e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai - UI/Chat compose: add mobile stacked layout for compose action buttons on small screens to improve send/session controls usability. (#11167) Thanks @junyiz. - Heartbeat/Config: replace heartbeat DM toggle with `agents.defaults.heartbeat.directPolicy` (`allow` | `block`; also supported per-agent via `agents.list[].heartbeat.directPolicy`) for clearer delivery semantics. - Onboarding/Security: clarify onboarding security notices that OpenClaw is personal-by-default (single trusted operator boundary) and shared/multi-user setups require explicit lock-down/hardening. +- Onboarding/Plugins: let channel plugins own interactive onboarding flows with optional `configureInteractive` and `configureWhenConfigured` hooks while preserving the generic fallback path. (#27191) thanks @gumadeiras. - Branding/Docs + Apple surfaces: replace remaining `bot.molt` launchd label, bundle-id, logging subsystem, and command examples with `ai.openclaw` across docs, iOS app surfaces, helper scripts, and CLI test fixtures. - Agents/Config: remind agents to call `config.schema` before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow. - Dependencies: update workspace dependency pins and lockfile (Bedrock SDK `3.998.0`, `@mariozechner/pi-*` `0.55.1`, TypeScript native preview `7.0.0-dev.20260225.1`) while keeping `@buape/carbon` pinned. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 9250501f2d9..3dc575088eb 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -452,6 +452,29 @@ Notes: - `meta.preferOver` lists channel ids to skip auto-enable when both are configured. - `meta.detailLabel` and `meta.systemImage` let UIs show richer channel labels/icons. +### Channel onboarding hooks + +Channel plugins can define optional onboarding hooks on `plugin.onboarding`: + +- `configure(ctx)` is the baseline setup flow. +- `configureInteractive(ctx)` can fully own interactive setup for both configured and unconfigured states. +- `configureWhenConfigured(ctx)` can override behavior only for already configured channels. + +Hook precedence in the wizard: + +1. `configureInteractive` (if present) +2. `configureWhenConfigured` (only when channel status is already configured) +3. fallback to `configure` + +Context details: + +- `configureInteractive` and `configureWhenConfigured` receive: + - `configured` (`true` or `false`) + - `label` (user-facing channel name used by prompts) + - plus the shared config/runtime/prompter/options fields +- Returning `"skip"` leaves selection and account tracking unchanged. +- Returning `{ cfg, accountId? }` applies config updates and records account selection. + ### Write a new messaging channel (step‑by‑step) Use this when you want a **new chat surface** (a "messaging channel"), not a model provider. diff --git a/src/channels/plugins/onboarding-types.ts b/src/channels/plugins/onboarding-types.ts index 897487a49c6..342f29bf5b5 100644 --- a/src/channels/plugins/onboarding-types.ts +++ b/src/channels/plugins/onboarding-types.ts @@ -62,6 +62,13 @@ export type ChannelOnboardingResult = { accountId?: string; }; +export type ChannelOnboardingConfiguredResult = ChannelOnboardingResult | "skip"; + +export type ChannelOnboardingInteractiveContext = ChannelOnboardingConfigureContext & { + configured: boolean; + label: string; +}; + export type ChannelOnboardingDmPolicy = { label: string; channel: ChannelId; @@ -80,6 +87,12 @@ export type ChannelOnboardingAdapter = { channel: ChannelId; getStatus: (ctx: ChannelOnboardingStatusContext) => Promise; configure: (ctx: ChannelOnboardingConfigureContext) => Promise; + configureInteractive?: ( + ctx: ChannelOnboardingInteractiveContext, + ) => Promise; + configureWhenConfigured?: ( + ctx: ChannelOnboardingInteractiveContext, + ) => Promise; dmPolicy?: ChannelOnboardingDmPolicy; onAccountRecorded?: (accountId: string, options?: SetupChannelsOptions) => void; disable?: (cfg: OpenClawConfig) => OpenClawConfig; diff --git a/src/commands/channel-test-helpers.ts b/src/commands/channel-test-helpers.ts index fd7e6f36278..65745a55d5e 100644 --- a/src/commands/channel-test-helpers.ts +++ b/src/commands/channel-test-helpers.ts @@ -6,6 +6,9 @@ import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import type { ChannelChoice } from "./onboard-types.js"; +import { getChannelOnboardingAdapter } from "./onboarding/registry.js"; +import type { ChannelOnboardingAdapter } from "./onboarding/types.js"; export function setDefaultChannelPluginRegistryForTests(): void { const channels = [ @@ -18,3 +21,24 @@ export function setDefaultChannelPluginRegistryForTests(): void { ] as unknown as Parameters[0]; setActivePluginRegistry(createTestRegistry(channels)); } + +export function patchChannelOnboardingAdapter( + channel: ChannelChoice, + patch: Pick, +): () => void { + const adapter = getChannelOnboardingAdapter(channel); + if (!adapter) { + throw new Error(`missing onboarding adapter for ${channel}`); + } + const keys = Object.keys(patch) as K[]; + const previous = {} as Pick; + for (const key of keys) { + previous[key] = adapter[key]; + adapter[key] = patch[key]; + } + return () => { + for (const key of keys) { + adapter[key] = previous[key]; + } + }; +} diff --git a/src/commands/onboard-channels.test.ts b/src/commands/onboard-channels.test.ts index d6c0669e4fd..cd146b82c09 100644 --- a/src/commands/onboard-channels.test.ts +++ b/src/commands/onboard-channels.test.ts @@ -3,7 +3,10 @@ import type { OpenClawConfig } from "../config/config.js"; import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; -import { setDefaultChannelPluginRegistryForTests } from "./channel-test-helpers.js"; +import { + patchChannelOnboardingAdapter, + setDefaultChannelPluginRegistryForTests, +} from "./channel-test-helpers.js"; import { setupChannels } from "./onboard-channels.js"; import { createExitThrowingRuntime, createWizardPrompter } from "./test-wizard-helpers.js"; @@ -249,4 +252,307 @@ describe("setupChannels", () => { expect(select).toHaveBeenCalledWith(expect.objectContaining({ message: "Select a channel" })); expect(multiselect).not.toHaveBeenCalled(); }); + + it("uses configureInteractive skip without mutating selection/account state", async () => { + const select = vi.fn(async ({ message }: { message: string }) => { + if (message === "Select channel (QuickStart)") { + return "telegram"; + } + return "__done__"; + }); + const selection = vi.fn(); + const onAccountId = vi.fn(); + const configureInteractive = vi.fn(async () => "skip" as const); + const restore = patchChannelOnboardingAdapter("telegram", { + getStatus: vi.fn(async ({ cfg }) => ({ + channel: "telegram", + configured: Boolean(cfg.channels?.telegram?.botToken), + statusLines: [], + })), + configureInteractive, + }); + const { multiselect, text } = createUnexpectedPromptGuards(); + + const prompter = createPrompter({ + select: select as unknown as WizardPrompter["select"], + multiselect, + text, + }); + + const runtime = createExitThrowingRuntime(); + try { + const cfg = await setupChannels({} as OpenClawConfig, runtime, prompter, { + skipConfirm: true, + quickstartDefaults: true, + onSelection: selection, + onAccountId, + }); + + expect(configureInteractive).toHaveBeenCalledWith( + expect.objectContaining({ configured: false, label: expect.any(String) }), + ); + expect(selection).toHaveBeenCalledWith([]); + expect(onAccountId).not.toHaveBeenCalled(); + expect(cfg.channels?.telegram?.botToken).toBeUndefined(); + } finally { + restore(); + } + }); + + it("applies configureInteractive result cfg/account updates", async () => { + const select = vi.fn(async ({ message }: { message: string }) => { + if (message === "Select channel (QuickStart)") { + return "telegram"; + } + return "__done__"; + }); + const selection = vi.fn(); + const onAccountId = vi.fn(); + const configureInteractive = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ + cfg: { + ...cfg, + channels: { + ...cfg.channels, + telegram: { ...cfg.channels?.telegram, botToken: "new-token" }, + }, + } as OpenClawConfig, + accountId: "acct-1", + })); + const configure = vi.fn(async () => { + throw new Error("configure should not be called when configureInteractive is present"); + }); + const restore = patchChannelOnboardingAdapter("telegram", { + getStatus: vi.fn(async ({ cfg }) => ({ + channel: "telegram", + configured: Boolean(cfg.channels?.telegram?.botToken), + statusLines: [], + })), + configureInteractive, + configure, + }); + const { multiselect, text } = createUnexpectedPromptGuards(); + + const prompter = createPrompter({ + select: select as unknown as WizardPrompter["select"], + multiselect, + text, + }); + + const runtime = createExitThrowingRuntime(); + try { + const cfg = await setupChannels({} as OpenClawConfig, runtime, prompter, { + skipConfirm: true, + quickstartDefaults: true, + onSelection: selection, + onAccountId, + }); + + expect(configureInteractive).toHaveBeenCalledTimes(1); + expect(configure).not.toHaveBeenCalled(); + expect(selection).toHaveBeenCalledWith(["telegram"]); + expect(onAccountId).toHaveBeenCalledWith("telegram", "acct-1"); + expect(cfg.channels?.telegram?.botToken).toBe("new-token"); + } finally { + restore(); + } + }); + + it("uses configureWhenConfigured when channel is already configured", async () => { + const select = vi.fn(async ({ message }: { message: string }) => { + if (message === "Select channel (QuickStart)") { + return "telegram"; + } + return "__done__"; + }); + const selection = vi.fn(); + const onAccountId = vi.fn(); + const configureWhenConfigured = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ + cfg: { + ...cfg, + channels: { + ...cfg.channels, + telegram: { ...cfg.channels?.telegram, botToken: "updated-token" }, + }, + } as OpenClawConfig, + accountId: "acct-2", + })); + const configure = vi.fn(async () => { + throw new Error( + "configure should not be called when configureWhenConfigured handles updates", + ); + }); + const restore = patchChannelOnboardingAdapter("telegram", { + getStatus: vi.fn(async ({ cfg }) => ({ + channel: "telegram", + configured: Boolean(cfg.channels?.telegram?.botToken), + statusLines: [], + })), + configureInteractive: undefined, + configureWhenConfigured, + configure, + }); + const { multiselect, text } = createUnexpectedPromptGuards(); + + const prompter = createPrompter({ + select: select as unknown as WizardPrompter["select"], + multiselect, + text, + }); + + const runtime = createExitThrowingRuntime(); + try { + const cfg = await setupChannels( + { + channels: { + telegram: { + botToken: "old-token", + }, + }, + } as OpenClawConfig, + runtime, + prompter, + { + skipConfirm: true, + quickstartDefaults: true, + onSelection: selection, + onAccountId, + }, + ); + + expect(configureWhenConfigured).toHaveBeenCalledTimes(1); + expect(configureWhenConfigured).toHaveBeenCalledWith( + expect.objectContaining({ configured: true, label: expect.any(String) }), + ); + expect(configure).not.toHaveBeenCalled(); + expect(selection).toHaveBeenCalledWith(["telegram"]); + expect(onAccountId).toHaveBeenCalledWith("telegram", "acct-2"); + expect(cfg.channels?.telegram?.botToken).toBe("updated-token"); + } finally { + restore(); + } + }); + + it("respects configureWhenConfigured skip without mutating selection or account state", async () => { + const select = vi.fn(async ({ message }: { message: string }) => { + if (message === "Select channel (QuickStart)") { + return "telegram"; + } + throw new Error(`unexpected select prompt: ${message}`); + }); + const selection = vi.fn(); + const onAccountId = vi.fn(); + const configureWhenConfigured = vi.fn(async () => "skip" as const); + const configure = vi.fn(async () => { + throw new Error("configure should not run when configureWhenConfigured handles skip"); + }); + const restore = patchChannelOnboardingAdapter("telegram", { + getStatus: vi.fn(async ({ cfg }) => ({ + channel: "telegram", + configured: Boolean(cfg.channels?.telegram?.botToken), + statusLines: [], + })), + configureInteractive: undefined, + configureWhenConfigured, + configure, + }); + const { multiselect, text } = createUnexpectedPromptGuards(); + + const prompter = createPrompter({ + select: select as unknown as WizardPrompter["select"], + multiselect, + text, + }); + + const runtime = createExitThrowingRuntime(); + try { + const cfg = await setupChannels( + { + channels: { + telegram: { + botToken: "old-token", + }, + }, + } as OpenClawConfig, + runtime, + prompter, + { + skipConfirm: true, + quickstartDefaults: true, + onSelection: selection, + onAccountId, + }, + ); + + expect(configureWhenConfigured).toHaveBeenCalledWith( + expect.objectContaining({ configured: true, label: expect.any(String) }), + ); + expect(configure).not.toHaveBeenCalled(); + expect(selection).toHaveBeenCalledWith([]); + expect(onAccountId).not.toHaveBeenCalled(); + expect(cfg.channels?.telegram?.botToken).toBe("old-token"); + } finally { + restore(); + } + }); + + it("prefers configureInteractive over configureWhenConfigured when both hooks exist", async () => { + const select = vi.fn(async ({ message }: { message: string }) => { + if (message === "Select channel (QuickStart)") { + return "telegram"; + } + throw new Error(`unexpected select prompt: ${message}`); + }); + const selection = vi.fn(); + const onAccountId = vi.fn(); + const configureInteractive = vi.fn(async () => "skip" as const); + const configureWhenConfigured = vi.fn(async () => { + throw new Error("configureWhenConfigured should not run when configureInteractive exists"); + }); + const restore = patchChannelOnboardingAdapter("telegram", { + getStatus: vi.fn(async ({ cfg }) => ({ + channel: "telegram", + configured: Boolean(cfg.channels?.telegram?.botToken), + statusLines: [], + })), + configureInteractive, + configureWhenConfigured, + }); + const { multiselect, text } = createUnexpectedPromptGuards(); + + const prompter = createPrompter({ + select: select as unknown as WizardPrompter["select"], + multiselect, + text, + }); + + const runtime = createExitThrowingRuntime(); + try { + await setupChannels( + { + channels: { + telegram: { + botToken: "old-token", + }, + }, + } as OpenClawConfig, + runtime, + prompter, + { + skipConfirm: true, + quickstartDefaults: true, + onSelection: selection, + onAccountId, + }, + ); + + expect(configureInteractive).toHaveBeenCalledWith( + expect.objectContaining({ configured: true, label: expect.any(String) }), + ); + expect(configureWhenConfigured).not.toHaveBeenCalled(); + expect(selection).toHaveBeenCalledWith([]); + expect(onAccountId).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); }); diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 32510c29f39..6e79379e1f1 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -27,7 +27,9 @@ import { listChannelOnboardingAdapters, } from "./onboarding/registry.js"; import type { + ChannelOnboardingConfiguredResult, ChannelOnboardingDmPolicy, + ChannelOnboardingResult, ChannelOnboardingStatus, SetupChannelsOptions, } from "./onboarding/types.js"; @@ -488,6 +490,26 @@ export async function setupChannels( return true; }; + const applyOnboardingResult = async (channel: ChannelChoice, result: ChannelOnboardingResult) => { + next = result.cfg; + if (result.accountId) { + recordAccount(channel, result.accountId); + } + addSelection(channel); + await refreshStatus(channel); + }; + + const applyCustomOnboardingResult = async ( + channel: ChannelChoice, + result: ChannelOnboardingConfiguredResult, + ) => { + if (result === "skip") { + return false; + } + await applyOnboardingResult(channel, result); + return true; + }; + const configureChannel = async (channel: ChannelChoice) => { const adapter = getChannelOnboardingAdapter(channel); if (!adapter) { @@ -503,17 +525,29 @@ export async function setupChannels( shouldPromptAccountIds, forceAllowFrom: forceAllowFromChannels.has(channel), }); - next = result.cfg; - if (result.accountId) { - recordAccount(channel, result.accountId); - } - addSelection(channel); - await refreshStatus(channel); + await applyOnboardingResult(channel, result); }; const handleConfiguredChannel = async (channel: ChannelChoice, label: string) => { const plugin = getChannelPlugin(channel); const adapter = getChannelOnboardingAdapter(channel); + if (adapter?.configureWhenConfigured) { + const custom = await adapter.configureWhenConfigured({ + cfg: next, + runtime, + prompter, + options, + accountOverrides, + shouldPromptAccountIds, + forceAllowFrom: forceAllowFromChannels.has(channel), + configured: true, + label, + }); + if (!(await applyCustomOnboardingResult(channel, custom))) { + return; + } + return; + } const supportsDisable = Boolean( options?.allowDisable && (plugin?.config.setAccountEnabled || adapter?.disable), ); @@ -615,9 +649,27 @@ export async function setupChannels( } const plugin = getChannelPlugin(channel); + const adapter = getChannelOnboardingAdapter(channel); const label = plugin?.meta.label ?? catalogEntry?.meta.label ?? channel; const status = statusByChannel.get(channel); const configured = status?.configured ?? false; + if (adapter?.configureInteractive) { + const custom = await adapter.configureInteractive({ + cfg: next, + runtime, + prompter, + options, + accountOverrides, + shouldPromptAccountIds, + forceAllowFrom: forceAllowFromChannels.has(channel), + configured, + label, + }); + if (!(await applyCustomOnboardingResult(channel, custom))) { + return; + } + return; + } if (configured) { await handleConfiguredChannel(channel, label); return; From 72adf2458bb538b5568bbabe68ff141419cf92a3 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Thu, 26 Feb 2026 00:33:36 -0600 Subject: [PATCH 08/35] CI: shard Windows test lane for faster CI critical path (#27234) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: f7c41089e0d5c36f59addd643d2038502bafe933 Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com> Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com> Reviewed-by: @joshavant --- .github/workflows/ci.yml | 19 ++++++++++++++++++- CHANGELOG.md | 6 ++++++ scripts/test-parallel.mjs | 33 ++++++++++++++++++++++++++++----- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8de4f3882c8..e7bef285a7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -418,12 +418,23 @@ jobs: include: - runtime: node task: lint + shard_index: 0 + shard_count: 1 command: pnpm lint - runtime: node task: test + shard_index: 1 + shard_count: 2 + command: pnpm canvas:a2ui:bundle && pnpm test + - runtime: node + task: test + shard_index: 2 + shard_count: 2 command: pnpm canvas:a2ui:bundle && pnpm test - runtime: node task: protocol + shard_index: 0 + shard_count: 1 command: pnpm protocol:check steps: - name: Checkout @@ -495,6 +506,12 @@ jobs: pnpm -v pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true + - name: Configure test shard (Windows) + if: matrix.task == 'test' + run: | + echo "OPENCLAW_TEST_SHARDS=${{ matrix.shard_count }}" >> "$GITHUB_ENV" + echo "OPENCLAW_TEST_SHARD_INDEX=${{ matrix.shard_index }}" >> "$GITHUB_ENV" + - name: Configure vitest JSON reports if: matrix.task == 'test' run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV" @@ -512,7 +529,7 @@ jobs: if: matrix.task == 'test' uses: actions/upload-artifact@v4 with: - name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }} + name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}-shard${{ matrix.shard_index }}of${{ matrix.shard_count }} path: | ${{ env.OPENCLAW_VITEST_REPORT_DIR }} ${{ runner.temp }}/vitest-slowest.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f2ce6018e9..ce9d381407f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ Docs: https://docs.openclaw.ai +## 2026.2.26 (Unreleased) + +### Fixes + +- CI/Windows: shard the Windows `checks-windows` test lane into two matrix jobs and honor explicit shard index overrides in `scripts/test-parallel.mjs` to reduce CI critical-path wall time. (#27234) Thanks @joshavant. + ## 2026.2.25 ### Changes diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 35afef83c3f..e866ef712ab 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -160,11 +160,31 @@ const runs = [ }, ]; const shardOverride = Number.parseInt(process.env.OPENCLAW_TEST_SHARDS ?? "", 10); -const shardCount = isWindowsCi - ? Number.isFinite(shardOverride) && shardOverride > 1 - ? shardOverride - : 2 - : 1; +const configuredShardCount = + Number.isFinite(shardOverride) && shardOverride > 1 ? shardOverride : null; +const shardCount = configuredShardCount ?? (isWindowsCi ? 2 : 1); +const shardIndexOverride = (() => { + const parsed = Number.parseInt(process.env.OPENCLAW_TEST_SHARD_INDEX ?? "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +})(); + +if (shardIndexOverride !== null && shardCount <= 1) { + console.error( + `[test-parallel] OPENCLAW_TEST_SHARD_INDEX=${String( + shardIndexOverride, + )} requires OPENCLAW_TEST_SHARDS>1.`, + ); + process.exit(2); +} + +if (shardIndexOverride !== null && shardIndexOverride > shardCount) { + console.error( + `[test-parallel] OPENCLAW_TEST_SHARD_INDEX=${String( + shardIndexOverride, + )} exceeds OPENCLAW_TEST_SHARDS=${String(shardCount)}.`, + ); + process.exit(2); +} const windowsCiArgs = isWindowsCi ? ["--dangerouslyIgnoreUnhandledErrors"] : []; const silentArgs = process.env.OPENCLAW_TEST_SHOW_PASSED_LOGS === "1" ? [] : ["--silent=passed-only"]; @@ -391,6 +411,9 @@ const run = async (entry) => { if (shardCount <= 1) { return runOnce(entry); } + if (shardIndexOverride !== null) { + return runOnce(entry, ["--shard", `${shardIndexOverride}/${shardCount}`]); + } for (let shardIndex = 1; shardIndex <= shardCount; shardIndex += 1) { // eslint-disable-next-line no-await-in-loop const code = await runOnce(entry, ["--shard", `${shardIndex}/${shardCount}`]); From bee0c564cfa1033e397501bd84765ba2549d8e3d Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 11:35:00 +0530 Subject: [PATCH 09/35] test(android): add GatewaySession invoke roundtrip test --- apps/android/app/build.gradle.kts | 1 + .../android/gateway/GatewaySession.kt | 6 +- .../gateway/GatewaySessionInvokeTest.kt | 163 ++++++++++++++++++ 3 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index dda17320625..da82e9e1ea9 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -146,6 +146,7 @@ dependencies { testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") testImplementation("io.kotest:kotest-runner-junit5-jvm:6.1.3") testImplementation("io.kotest:kotest-assertions-core-jvm:6.1.3") + testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2") testImplementation("org.robolectric:robolectric:4.16.1") testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2") } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt index 92acf968954..ad34ca4f1c1 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt @@ -55,7 +55,7 @@ data class GatewayConnectOptions( class GatewaySession( private val scope: CoroutineScope, private val identityStore: DeviceIdentityStore, - private val deviceAuthStore: DeviceAuthStore, + private val deviceAuthStore: DeviceAuthStore? = null, private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit, private val onDisconnected: (message: String) -> Unit, private val onEvent: (event: String, payloadJson: String?) -> Unit, @@ -305,7 +305,7 @@ class GatewaySession( private suspend fun sendConnect(connectNonce: String) { val identity = identityStore.loadOrCreate() - val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role) + val storedToken = deviceAuthStore?.loadToken(identity.deviceId, options.role) val trimmedToken = token?.trim().orEmpty() // QR/setup/manual shared token must take precedence; stale role tokens can survive re-onboarding. val authToken = if (trimmedToken.isNotBlank()) trimmedToken else storedToken.orEmpty() @@ -327,7 +327,7 @@ class GatewaySession( val deviceToken = authObj?.get("deviceToken").asStringOrNull() val authRole = authObj?.get("role").asStringOrNull() ?: options.role if (!deviceToken.isNullOrBlank()) { - deviceAuthStore.saveToken(deviceId, authRole, deviceToken) + deviceAuthStore?.saveToken(deviceId, authRole, deviceToken) } val rawCanvas = obj["canvasHostUrl"].asStringOrNull() canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint, isTlsConnection = tls != null) diff --git a/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt new file mode 100644 index 00000000000..e0dded486d5 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt @@ -0,0 +1,163 @@ +package ai.openclaw.android.gateway + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.util.concurrent.atomic.AtomicReference + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class GatewaySessionInvokeTest { + @Test + fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking { + val json = Json { ignoreUnknownKeys = true } + val connected = CompletableDeferred() + val invokeRequest = CompletableDeferred() + val invokeResultParams = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + MockWebServer().apply { + dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + return MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + webSocket.send( + """{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""", + ) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + val frame = json.parseToJsonElement(text).jsonObject + if (frame["type"]?.jsonPrimitive?.content != "req") return + val id = frame["id"]?.jsonPrimitive?.content ?: return + val method = frame["method"]?.jsonPrimitive?.content ?: return + when (method) { + "connect" -> { + webSocket.send( + """{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""", + ) + webSocket.send( + """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-1","nodeId":"node-1","command":"debug.ping","params":{"ping":"pong"},"timeoutMs":5000}}""", + ) + } + "node.invoke.result" -> { + if (!invokeResultParams.isCompleted) { + invokeResultParams.complete(frame["params"]?.toString().orEmpty()) + } + webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""") + webSocket.close(1000, "done") + } + } + } + }, + ) + } + } + start() + } + + val app = RuntimeEnvironment.getApplication() + val sessionJob = SupervisorJob() + val session = + GatewaySession( + scope = CoroutineScope(sessionJob + Dispatchers.Default), + identityStore = DeviceIdentityStore(app), + deviceAuthStore = null, + onConnected = { _, _, _ -> + if (!connected.isCompleted) connected.complete(Unit) + }, + onDisconnected = { message -> + lastDisconnect.set(message) + }, + onEvent = { _, _ -> }, + onInvoke = { req -> + if (!invokeRequest.isCompleted) invokeRequest.complete(req) + GatewaySession.InvokeResult.ok("""{"handled":true}""") + }, + ) + + try { + session.connect( + endpoint = + GatewayEndpoint( + stableId = "manual|127.0.0.1|${server.port}", + name = "test", + host = "127.0.0.1", + port = server.port, + tlsEnabled = false, + ), + token = "test-token", + password = null, + options = + GatewayConnectOptions( + role = "node", + scopes = listOf("node:invoke"), + caps = emptyList(), + commands = emptyList(), + permissions = emptyMap(), + client = + GatewayClientInfo( + id = "openclaw-android-test", + displayName = "Android Test", + version = "1.0.0-test", + platform = "android", + mode = "node", + instanceId = "android-test-instance", + deviceFamily = "android", + modelIdentifier = "test", + ), + ), + tls = null, + ) + + val connectedWithinTimeout = withTimeoutOrNull(8_000) { + connected.await() + true + } == true + if (!connectedWithinTimeout) { + throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}") + } + val req = withTimeout(8_000) { invokeRequest.await() } + val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() } + val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject + + assertEquals("invoke-1", req.id) + assertEquals("node-1", req.nodeId) + assertEquals("debug.ping", req.command) + assertEquals("""{"ping":"pong"}""", req.paramsJson) + assertEquals("invoke-1", resultParams["id"]?.jsonPrimitive?.content) + assertEquals("node-1", resultParams["nodeId"]?.jsonPrimitive?.content) + assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict()) + assertEquals( + true, + resultParams["payload"]?.jsonObject?.get("handled")?.jsonPrimitive?.content?.toBooleanStrict(), + ) + } finally { + session.disconnect() + sessionJob.cancelAndJoin() + } + } +} From 8117a13dd646a7c043b48ab6c1093a4e9fb76f20 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 11:35:05 +0530 Subject: [PATCH 10/35] fix(nodes): default camera snap to front high-quality image --- .../android/node/CameraCaptureManager.kt | 4 +-- src/agents/openclaw-tools.camera.test.ts | 35 +++++++++++++++++++ src/agents/tools/nodes-tool.ts | 6 ++-- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/CameraCaptureManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/CameraCaptureManager.kt index 65bac915eff..aa038ad9a94 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/CameraCaptureManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/CameraCaptureManager.kt @@ -81,8 +81,8 @@ class CameraCaptureManager(private val context: Context) { ensureCameraPermission() val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready") val facing = parseFacing(paramsJson) ?: "front" - val quality = (parseQuality(paramsJson) ?: 0.5).coerceIn(0.1, 1.0) - val maxWidth = parseMaxWidth(paramsJson) ?: 800 + val quality = (parseQuality(paramsJson) ?: 0.95).coerceIn(0.1, 1.0) + val maxWidth = parseMaxWidth(paramsJson) ?: 1600 val provider = context.cameraProvider() val capture = ImageCapture.Builder().build() diff --git a/src/agents/openclaw-tools.camera.test.ts b/src/agents/openclaw-tools.camera.test.ts index 3082c849609..6b1d2e35c33 100644 --- a/src/agents/openclaw-tools.camera.test.ts +++ b/src/agents/openclaw-tools.camera.test.ts @@ -43,6 +43,41 @@ beforeEach(() => { }); describe("nodes camera_snap", () => { + it("uses front/high-quality defaults when params are omitted", async () => { + callGateway.mockImplementation(async ({ method, params }) => { + if (method === "node.list") { + return mockNodeList(); + } + if (method === "node.invoke") { + expect(params).toMatchObject({ + command: "camera.snap", + params: { + facing: "front", + maxWidth: 1600, + quality: 0.95, + }, + }); + return { + payload: { + format: "jpg", + base64: "aGVsbG8=", + width: 1, + height: 1, + }, + }; + } + return unexpectedGatewayMethod(method); + }); + + const result = await executeNodes({ + action: "camera_snap", + node: NODE_ID, + }); + + const images = (result.content ?? []).filter((block) => block.type === "image"); + expect(images).toHaveLength(1); + }); + it("maps jpg payloads to image/jpeg", async () => { callGateway.mockImplementation(async ({ method }) => { if (method === "node.list") { diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index 4cfd84dc474..3006b9cfddc 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -186,7 +186,7 @@ export function createNodesTool(options?: { const node = readStringParam(params, "node", { required: true }); const nodeId = await resolveNodeId(gatewayOpts, node); const facingRaw = - typeof params.facing === "string" ? params.facing.toLowerCase() : "both"; + typeof params.facing === "string" ? params.facing.toLowerCase() : "front"; const facings: CameraFacing[] = facingRaw === "both" ? ["front", "back"] @@ -198,11 +198,11 @@ export function createNodesTool(options?: { const maxWidth = typeof params.maxWidth === "number" && Number.isFinite(params.maxWidth) ? params.maxWidth - : undefined; + : 1600; const quality = typeof params.quality === "number" && Number.isFinite(params.quality) ? params.quality - : undefined; + : 0.95; const delayMs = typeof params.delayMs === "number" && Number.isFinite(params.delayMs) ? params.delayMs From d4ae8a8d3493a10669b5daddca8915376f8651b4 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 11:41:07 +0530 Subject: [PATCH 11/35] test(android): cover invoke paramsJSON and error mapping --- .../gateway/GatewaySessionInvokeTest.kt | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) diff --git a/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt index e0dded486d5..a04bcb1606f 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt @@ -158,6 +158,268 @@ class GatewaySessionInvokeTest { } finally { session.disconnect() sessionJob.cancelAndJoin() + server.shutdown() + } + } + + @Test + fun nodeInvokeRequest_usesParamsJsonWhenProvided() = runBlocking { + val json = Json { ignoreUnknownKeys = true } + val connected = CompletableDeferred() + val invokeRequest = CompletableDeferred() + val invokeResultParams = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + MockWebServer().apply { + dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + return MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + webSocket.send( + """{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""", + ) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + val frame = json.parseToJsonElement(text).jsonObject + if (frame["type"]?.jsonPrimitive?.content != "req") return + val id = frame["id"]?.jsonPrimitive?.content ?: return + val method = frame["method"]?.jsonPrimitive?.content ?: return + when (method) { + "connect" -> { + webSocket.send( + """{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""", + ) + webSocket.send( + """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-2","nodeId":"node-2","command":"debug.raw","paramsJSON":"{\"raw\":true}","params":{"ignored":1},"timeoutMs":5000}}""", + ) + } + "node.invoke.result" -> { + if (!invokeResultParams.isCompleted) { + invokeResultParams.complete(frame["params"]?.toString().orEmpty()) + } + webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""") + webSocket.close(1000, "done") + } + } + } + }, + ) + } + } + start() + } + + val app = RuntimeEnvironment.getApplication() + val sessionJob = SupervisorJob() + val session = + GatewaySession( + scope = CoroutineScope(sessionJob + Dispatchers.Default), + identityStore = DeviceIdentityStore(app), + deviceAuthStore = null, + onConnected = { _, _, _ -> + if (!connected.isCompleted) connected.complete(Unit) + }, + onDisconnected = { message -> + lastDisconnect.set(message) + }, + onEvent = { _, _ -> }, + onInvoke = { req -> + if (!invokeRequest.isCompleted) invokeRequest.complete(req) + GatewaySession.InvokeResult.ok("""{"handled":true}""") + }, + ) + + try { + session.connect( + endpoint = + GatewayEndpoint( + stableId = "manual|127.0.0.1|${server.port}", + name = "test", + host = "127.0.0.1", + port = server.port, + tlsEnabled = false, + ), + token = "test-token", + password = null, + options = + GatewayConnectOptions( + role = "node", + scopes = listOf("node:invoke"), + caps = emptyList(), + commands = emptyList(), + permissions = emptyMap(), + client = + GatewayClientInfo( + id = "openclaw-android-test", + displayName = "Android Test", + version = "1.0.0-test", + platform = "android", + mode = "node", + instanceId = "android-test-instance", + deviceFamily = "android", + modelIdentifier = "test", + ), + ), + tls = null, + ) + + val connectedWithinTimeout = withTimeoutOrNull(8_000) { + connected.await() + true + } == true + if (!connectedWithinTimeout) { + throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}") + } + + val req = withTimeout(8_000) { invokeRequest.await() } + val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() } + val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject + + assertEquals("invoke-2", req.id) + assertEquals("node-2", req.nodeId) + assertEquals("debug.raw", req.command) + assertEquals("""{"raw":true}""", req.paramsJson) + assertEquals("invoke-2", resultParams["id"]?.jsonPrimitive?.content) + assertEquals("node-2", resultParams["nodeId"]?.jsonPrimitive?.content) + assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict()) + } finally { + session.disconnect() + sessionJob.cancelAndJoin() + server.shutdown() + } + } + + @Test + fun nodeInvokeRequest_mapsCodePrefixedErrorsIntoInvokeResult() = runBlocking { + val json = Json { ignoreUnknownKeys = true } + val connected = CompletableDeferred() + val invokeResultParams = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + MockWebServer().apply { + dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + return MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + webSocket.send( + """{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""", + ) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + val frame = json.parseToJsonElement(text).jsonObject + if (frame["type"]?.jsonPrimitive?.content != "req") return + val id = frame["id"]?.jsonPrimitive?.content ?: return + val method = frame["method"]?.jsonPrimitive?.content ?: return + when (method) { + "connect" -> { + webSocket.send( + """{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""", + ) + webSocket.send( + """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-3","nodeId":"node-3","command":"camera.snap","params":{"facing":"front"},"timeoutMs":5000}}""", + ) + } + "node.invoke.result" -> { + if (!invokeResultParams.isCompleted) { + invokeResultParams.complete(frame["params"]?.toString().orEmpty()) + } + webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""") + webSocket.close(1000, "done") + } + } + } + }, + ) + } + } + start() + } + + val app = RuntimeEnvironment.getApplication() + val sessionJob = SupervisorJob() + val session = + GatewaySession( + scope = CoroutineScope(sessionJob + Dispatchers.Default), + identityStore = DeviceIdentityStore(app), + deviceAuthStore = null, + onConnected = { _, _, _ -> + if (!connected.isCompleted) connected.complete(Unit) + }, + onDisconnected = { message -> + lastDisconnect.set(message) + }, + onEvent = { _, _ -> }, + onInvoke = { + throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission") + }, + ) + + try { + session.connect( + endpoint = + GatewayEndpoint( + stableId = "manual|127.0.0.1|${server.port}", + name = "test", + host = "127.0.0.1", + port = server.port, + tlsEnabled = false, + ), + token = "test-token", + password = null, + options = + GatewayConnectOptions( + role = "node", + scopes = listOf("node:invoke"), + caps = emptyList(), + commands = emptyList(), + permissions = emptyMap(), + client = + GatewayClientInfo( + id = "openclaw-android-test", + displayName = "Android Test", + version = "1.0.0-test", + platform = "android", + mode = "node", + instanceId = "android-test-instance", + deviceFamily = "android", + modelIdentifier = "test", + ), + ), + tls = null, + ) + + val connectedWithinTimeout = withTimeoutOrNull(8_000) { + connected.await() + true + } == true + if (!connectedWithinTimeout) { + throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}") + } + + val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() } + val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject + + assertEquals("invoke-3", resultParams["id"]?.jsonPrimitive?.content) + assertEquals("node-3", resultParams["nodeId"]?.jsonPrimitive?.content) + assertEquals(false, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict()) + assertEquals( + "CAMERA_PERMISSION_REQUIRED", + resultParams["error"]?.jsonObject?.get("code")?.jsonPrimitive?.content, + ) + assertEquals( + "grant Camera permission", + resultParams["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content, + ) + } finally { + session.disconnect() + sessionJob.cancelAndJoin() + server.shutdown() } } } From 18fc4c113bf9209268cec0c886776987b186b7e8 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 11:46:00 +0530 Subject: [PATCH 12/35] refactor(android): centralize invoke command registry --- .../android/node/ConnectionManager.kt | 38 +----- .../android/node/InvokeCommandRegistry.kt | 114 ++++++++++++++++++ .../android/node/InvokeCommandRegistryTest.kt | 48 ++++++++ 3 files changed, 168 insertions(+), 32 deletions(-) create mode 100644 apps/android/app/src/main/java/ai/openclaw/android/node/InvokeCommandRegistry.kt create mode 100644 apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt index 9b449fc85f3..de30b8af8fe 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt @@ -7,12 +7,6 @@ import ai.openclaw.android.gateway.GatewayClientInfo import ai.openclaw.android.gateway.GatewayConnectOptions import ai.openclaw.android.gateway.GatewayEndpoint import ai.openclaw.android.gateway.GatewayTlsParams -import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand -import ai.openclaw.android.protocol.OpenClawCanvasCommand -import ai.openclaw.android.protocol.OpenClawCameraCommand -import ai.openclaw.android.protocol.OpenClawLocationCommand -import ai.openclaw.android.protocol.OpenClawScreenCommand -import ai.openclaw.android.protocol.OpenClawSmsCommand import ai.openclaw.android.protocol.OpenClawCapability import ai.openclaw.android.LocationMode import ai.openclaw.android.VoiceWakeMode @@ -80,32 +74,12 @@ class ConnectionManager( } fun buildInvokeCommands(): List = - buildList { - add(OpenClawCanvasCommand.Present.rawValue) - add(OpenClawCanvasCommand.Hide.rawValue) - add(OpenClawCanvasCommand.Navigate.rawValue) - add(OpenClawCanvasCommand.Eval.rawValue) - add(OpenClawCanvasCommand.Snapshot.rawValue) - add(OpenClawCanvasA2UICommand.Push.rawValue) - add(OpenClawCanvasA2UICommand.PushJSONL.rawValue) - add(OpenClawCanvasA2UICommand.Reset.rawValue) - add(OpenClawScreenCommand.Record.rawValue) - if (cameraEnabled()) { - add(OpenClawCameraCommand.Snap.rawValue) - add(OpenClawCameraCommand.Clip.rawValue) - } - if (locationMode() != LocationMode.Off) { - add(OpenClawLocationCommand.Get.rawValue) - } - if (smsAvailable()) { - add(OpenClawSmsCommand.Send.rawValue) - } - if (BuildConfig.DEBUG) { - add("debug.logs") - add("debug.ed25519") - } - add("app.update") - } + InvokeCommandRegistry.advertisedCommands( + cameraEnabled = cameraEnabled(), + locationEnabled = locationMode() != LocationMode.Off, + smsAvailable = smsAvailable(), + debugBuild = BuildConfig.DEBUG, + ) fun buildCapabilities(): List = buildList { diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeCommandRegistry.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeCommandRegistry.kt new file mode 100644 index 00000000000..812ecf2ba4e --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeCommandRegistry.kt @@ -0,0 +1,114 @@ +package ai.openclaw.android.node + +import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand +import ai.openclaw.android.protocol.OpenClawCanvasCommand +import ai.openclaw.android.protocol.OpenClawCameraCommand +import ai.openclaw.android.protocol.OpenClawLocationCommand +import ai.openclaw.android.protocol.OpenClawScreenCommand +import ai.openclaw.android.protocol.OpenClawSmsCommand + +enum class InvokeCommandAvailability { + Always, + CameraEnabled, + LocationEnabled, + SmsAvailable, + DebugBuild, +} + +data class InvokeCommandSpec( + val name: String, + val requiresForeground: Boolean = false, + val availability: InvokeCommandAvailability = InvokeCommandAvailability.Always, +) + +object InvokeCommandRegistry { + val all: List = + listOf( + InvokeCommandSpec( + name = OpenClawCanvasCommand.Present.rawValue, + requiresForeground = true, + ), + InvokeCommandSpec( + name = OpenClawCanvasCommand.Hide.rawValue, + requiresForeground = true, + ), + InvokeCommandSpec( + name = OpenClawCanvasCommand.Navigate.rawValue, + requiresForeground = true, + ), + InvokeCommandSpec( + name = OpenClawCanvasCommand.Eval.rawValue, + requiresForeground = true, + ), + InvokeCommandSpec( + name = OpenClawCanvasCommand.Snapshot.rawValue, + requiresForeground = true, + ), + InvokeCommandSpec( + name = OpenClawCanvasA2UICommand.Push.rawValue, + requiresForeground = true, + ), + InvokeCommandSpec( + name = OpenClawCanvasA2UICommand.PushJSONL.rawValue, + requiresForeground = true, + ), + InvokeCommandSpec( + name = OpenClawCanvasA2UICommand.Reset.rawValue, + requiresForeground = true, + ), + InvokeCommandSpec( + name = OpenClawScreenCommand.Record.rawValue, + requiresForeground = true, + ), + InvokeCommandSpec( + name = OpenClawCameraCommand.Snap.rawValue, + requiresForeground = true, + availability = InvokeCommandAvailability.CameraEnabled, + ), + InvokeCommandSpec( + name = OpenClawCameraCommand.Clip.rawValue, + requiresForeground = true, + availability = InvokeCommandAvailability.CameraEnabled, + ), + InvokeCommandSpec( + name = OpenClawLocationCommand.Get.rawValue, + availability = InvokeCommandAvailability.LocationEnabled, + ), + InvokeCommandSpec( + name = OpenClawSmsCommand.Send.rawValue, + availability = InvokeCommandAvailability.SmsAvailable, + ), + InvokeCommandSpec( + name = "debug.logs", + availability = InvokeCommandAvailability.DebugBuild, + ), + InvokeCommandSpec( + name = "debug.ed25519", + availability = InvokeCommandAvailability.DebugBuild, + ), + InvokeCommandSpec(name = "app.update"), + ) + + private val byNameInternal: Map = all.associateBy { it.name } + + fun find(command: String): InvokeCommandSpec? = byNameInternal[command] + + fun advertisedCommands( + cameraEnabled: Boolean, + locationEnabled: Boolean, + smsAvailable: Boolean, + debugBuild: Boolean, + ): List { + return all + .filter { spec -> + when (spec.availability) { + InvokeCommandAvailability.Always -> true + InvokeCommandAvailability.CameraEnabled -> cameraEnabled + InvokeCommandAvailability.LocationEnabled -> locationEnabled + InvokeCommandAvailability.SmsAvailable -> smsAvailable + InvokeCommandAvailability.DebugBuild -> debugBuild + } + } + .map { it.name } + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt new file mode 100644 index 00000000000..65b18656708 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt @@ -0,0 +1,48 @@ +package ai.openclaw.android.node + +import ai.openclaw.android.protocol.OpenClawCameraCommand +import ai.openclaw.android.protocol.OpenClawLocationCommand +import ai.openclaw.android.protocol.OpenClawSmsCommand +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class InvokeCommandRegistryTest { + @Test + fun advertisedCommands_respectsFeatureAvailability() { + val commands = + InvokeCommandRegistry.advertisedCommands( + cameraEnabled = false, + locationEnabled = false, + smsAvailable = false, + debugBuild = false, + ) + + assertFalse(commands.contains(OpenClawCameraCommand.Snap.rawValue)) + assertFalse(commands.contains(OpenClawCameraCommand.Clip.rawValue)) + assertFalse(commands.contains(OpenClawLocationCommand.Get.rawValue)) + assertFalse(commands.contains(OpenClawSmsCommand.Send.rawValue)) + assertFalse(commands.contains("debug.logs")) + assertFalse(commands.contains("debug.ed25519")) + assertTrue(commands.contains("app.update")) + } + + @Test + fun advertisedCommands_includesFeatureCommandsWhenEnabled() { + val commands = + InvokeCommandRegistry.advertisedCommands( + cameraEnabled = true, + locationEnabled = true, + smsAvailable = true, + debugBuild = true, + ) + + assertTrue(commands.contains(OpenClawCameraCommand.Snap.rawValue)) + assertTrue(commands.contains(OpenClawCameraCommand.Clip.rawValue)) + assertTrue(commands.contains(OpenClawLocationCommand.Get.rawValue)) + assertTrue(commands.contains(OpenClawSmsCommand.Send.rawValue)) + assertTrue(commands.contains("debug.logs")) + assertTrue(commands.contains("debug.ed25519")) + assertTrue(commands.contains("app.update")) + } +} From 39d362aeff550a23045595786bad78f298b42900 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 11:47:39 +0530 Subject: [PATCH 13/35] refactor(android): distill invoke dispatcher command flow --- .../openclaw/android/node/InvokeDispatcher.kt | 139 +++++++++--------- 1 file changed, 66 insertions(+), 73 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt index 91e9da8add1..0e58517f2f6 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt @@ -24,31 +24,25 @@ class InvokeDispatcher( private val onCanvasA2uiReset: () -> Unit, ) { suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult { - // Check foreground requirement for canvas/camera/screen commands - if ( - command.startsWith(OpenClawCanvasCommand.NamespacePrefix) || - command.startsWith(OpenClawCanvasA2UICommand.NamespacePrefix) || - command.startsWith(OpenClawCameraCommand.NamespacePrefix) || - command.startsWith(OpenClawScreenCommand.NamespacePrefix) - ) { - if (!isForeground()) { - return GatewaySession.InvokeResult.error( - code = "NODE_BACKGROUND_UNAVAILABLE", - message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground", + val spec = + InvokeCommandRegistry.find(command) + ?: return GatewaySession.InvokeResult.error( + code = "INVALID_REQUEST", + message = "INVALID_REQUEST: unknown command", ) - } + if (spec.requiresForeground && !isForeground()) { + return GatewaySession.InvokeResult.error( + code = "NODE_BACKGROUND_UNAVAILABLE", + message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground", + ) } - - // Check camera enabled - if (command.startsWith(OpenClawCameraCommand.NamespacePrefix) && !cameraEnabled()) { + if (spec.availability == InvokeCommandAvailability.CameraEnabled && !cameraEnabled()) { return GatewaySession.InvokeResult.error( code = "CAMERA_DISABLED", message = "CAMERA_DISABLED: enable Camera in Settings", ) } - - // Check location enabled - if (command.startsWith(OpenClawLocationCommand.NamespacePrefix) && !locationEnabled()) { + if (spec.availability == InvokeCommandAvailability.LocationEnabled && !locationEnabled()) { return GatewaySession.InvokeResult.error( code = "LOCATION_DISABLED", message = "LOCATION_DISABLED: enable Location in Settings", @@ -75,53 +69,33 @@ class InvokeDispatcher( code = "INVALID_REQUEST", message = "INVALID_REQUEST: javaScript required", ) - val result = - try { - canvas.eval(js) - } catch (err: Throwable) { - return GatewaySession.InvokeResult.error( - code = "NODE_BACKGROUND_UNAVAILABLE", - message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable", - ) - } - GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""") + withCanvasAvailable { + val result = canvas.eval(js) + GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""") + } } OpenClawCanvasCommand.Snapshot.rawValue -> { val snapshotParams = CanvasController.parseSnapshotParams(paramsJson) - val base64 = - try { + withCanvasAvailable { + val base64 = canvas.snapshotBase64( format = snapshotParams.format, quality = snapshotParams.quality, maxWidth = snapshotParams.maxWidth, ) - } catch (err: Throwable) { - return GatewaySession.InvokeResult.error( - code = "NODE_BACKGROUND_UNAVAILABLE", - message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable", - ) - } - GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""") + GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""") + } } // A2UI commands - OpenClawCanvasA2UICommand.Reset.rawValue -> { - val a2uiUrl = a2uiHandler.resolveA2uiHostUrl() - ?: return GatewaySession.InvokeResult.error( - code = "A2UI_HOST_NOT_CONFIGURED", - message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host", - ) - val ready = a2uiHandler.ensureA2uiReady(a2uiUrl) - if (!ready) { - return GatewaySession.InvokeResult.error( - code = "A2UI_HOST_UNAVAILABLE", - message = "A2UI host not reachable", - ) + OpenClawCanvasA2UICommand.Reset.rawValue -> + withReadyA2ui { + withCanvasAvailable { + val res = canvas.eval(A2UIHandler.a2uiResetJS) + onCanvasA2uiReset() + GatewaySession.InvokeResult.ok(res) + } } - val res = canvas.eval(A2UIHandler.a2uiResetJS) - onCanvasA2uiReset() - GatewaySession.InvokeResult.ok(res) - } OpenClawCanvasA2UICommand.Push.rawValue, OpenClawCanvasA2UICommand.PushJSONL.rawValue -> { val messages = try { @@ -132,22 +106,14 @@ class InvokeDispatcher( message = err.message ?: "invalid A2UI payload" ) } - val a2uiUrl = a2uiHandler.resolveA2uiHostUrl() - ?: return GatewaySession.InvokeResult.error( - code = "A2UI_HOST_NOT_CONFIGURED", - message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host", - ) - val ready = a2uiHandler.ensureA2uiReady(a2uiUrl) - if (!ready) { - return GatewaySession.InvokeResult.error( - code = "A2UI_HOST_UNAVAILABLE", - message = "A2UI host not reachable", - ) + withReadyA2ui { + withCanvasAvailable { + val js = A2UIHandler.a2uiApplyMessagesJS(messages) + val res = canvas.eval(js) + onCanvasA2uiPush() + GatewaySession.InvokeResult.ok(res) + } } - val js = A2UIHandler.a2uiApplyMessagesJS(messages) - val res = canvas.eval(js) - onCanvasA2uiPush() - GatewaySession.InvokeResult.ok(res) } // Camera commands @@ -170,11 +136,38 @@ class InvokeDispatcher( // App update "app.update" -> appUpdateHandler.handleUpdate(paramsJson) - else -> - GatewaySession.InvokeResult.error( - code = "INVALID_REQUEST", - message = "INVALID_REQUEST: unknown command", - ) + else -> GatewaySession.InvokeResult.error(code = "INVALID_REQUEST", message = "INVALID_REQUEST: unknown command") + } + } + + private suspend fun withReadyA2ui( + block: suspend () -> GatewaySession.InvokeResult, + ): GatewaySession.InvokeResult { + val a2uiUrl = a2uiHandler.resolveA2uiHostUrl() + ?: return GatewaySession.InvokeResult.error( + code = "A2UI_HOST_NOT_CONFIGURED", + message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host", + ) + val ready = a2uiHandler.ensureA2uiReady(a2uiUrl) + if (!ready) { + return GatewaySession.InvokeResult.error( + code = "A2UI_HOST_UNAVAILABLE", + message = "A2UI host not reachable", + ) + } + return block() + } + + private suspend fun withCanvasAvailable( + block: suspend () -> GatewaySession.InvokeResult, + ): GatewaySession.InvokeResult { + return try { + block() + } catch (_: Throwable) { + GatewaySession.InvokeResult.error( + code = "NODE_BACKGROUND_UNAVAILABLE", + message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable", + ) } } } From c3f54fcddd62853e51c1969eeff6dc24b802eb04 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 11:48:50 +0530 Subject: [PATCH 14/35] refactor(android): unify invoke error parsing --- .../android/gateway/GatewaySession.kt | 12 +----- .../android/gateway/InvokeErrorParser.kt | 39 +++++++++++++++++++ .../ai/openclaw/android/node/NodeUtils.kt | 12 ++---- .../android/gateway/InvokeErrorParserTest.kt | 33 ++++++++++++++++ 4 files changed, 78 insertions(+), 18 deletions(-) create mode 100644 apps/android/app/src/main/java/ai/openclaw/android/gateway/InvokeErrorParser.kt create mode 100644 apps/android/app/src/test/java/ai/openclaw/android/gateway/InvokeErrorParserTest.kt diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt index ad34ca4f1c1..0c6d14721e0 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt @@ -535,16 +535,8 @@ class GatewaySession( } private fun invokeErrorFromThrowable(err: Throwable): InvokeResult { - val msg = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: err::class.java.simpleName - val parts = msg.split(":", limit = 2) - if (parts.size == 2) { - val code = parts[0].trim() - val rest = parts[1].trim() - if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) { - return InvokeResult.error(code = code, message = rest.ifEmpty { msg }) - } - } - return InvokeResult.error(code = "UNAVAILABLE", message = msg) + val parsed = parseInvokeErrorFromThrowable(err, fallbackMessage = err::class.java.simpleName) + return InvokeResult.error(code = parsed.code, message = parsed.message) } private fun failPending() { diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/InvokeErrorParser.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/InvokeErrorParser.kt new file mode 100644 index 00000000000..7242f4a5533 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/InvokeErrorParser.kt @@ -0,0 +1,39 @@ +package ai.openclaw.android.gateway + +data class ParsedInvokeError( + val code: String, + val message: String, + val hadExplicitCode: Boolean, +) { + val prefixedMessage: String + get() = "$code: $message" +} + +fun parseInvokeErrorMessage(raw: String): ParsedInvokeError { + val trimmed = raw.trim() + if (trimmed.isEmpty()) { + return ParsedInvokeError(code = "UNAVAILABLE", message = "error", hadExplicitCode = false) + } + + val parts = trimmed.split(":", limit = 2) + if (parts.size == 2) { + val code = parts[0].trim() + val rest = parts[1].trim() + if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) { + return ParsedInvokeError( + code = code, + message = rest.ifEmpty { trimmed }, + hadExplicitCode = true, + ) + } + } + return ParsedInvokeError(code = "UNAVAILABLE", message = trimmed, hadExplicitCode = false) +} + +fun parseInvokeErrorFromThrowable( + err: Throwable, + fallbackMessage: String = "error", +): ParsedInvokeError { + val raw = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: fallbackMessage + return parseInvokeErrorMessage(raw) +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/NodeUtils.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/NodeUtils.kt index 8ba5ad276d5..c3f463174a4 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/NodeUtils.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/NodeUtils.kt @@ -1,5 +1,6 @@ package ai.openclaw.android.node +import ai.openclaw.android.gateway.parseInvokeErrorFromThrowable import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject @@ -37,14 +38,9 @@ fun parseHexColorArgb(raw: String?): Long? { } fun invokeErrorFromThrowable(err: Throwable): Pair { - val raw = (err.message ?: "").trim() - if (raw.isEmpty()) return "UNAVAILABLE" to "UNAVAILABLE: error" - - val idx = raw.indexOf(':') - if (idx <= 0) return "UNAVAILABLE" to raw - val code = raw.substring(0, idx).trim().ifEmpty { "UNAVAILABLE" } - val message = raw.substring(idx + 1).trim().ifEmpty { raw } - return code to "$code: $message" + val parsed = parseInvokeErrorFromThrowable(err, fallbackMessage = "UNAVAILABLE: error") + val message = if (parsed.hadExplicitCode) parsed.prefixedMessage else parsed.message + return parsed.code to message } fun normalizeMainKey(raw: String?): String? { diff --git a/apps/android/app/src/test/java/ai/openclaw/android/gateway/InvokeErrorParserTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/gateway/InvokeErrorParserTest.kt new file mode 100644 index 00000000000..ca8e8f21424 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/android/gateway/InvokeErrorParserTest.kt @@ -0,0 +1,33 @@ +package ai.openclaw.android.gateway + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class InvokeErrorParserTest { + @Test + fun parseInvokeErrorMessage_parsesUppercaseCodePrefix() { + val parsed = parseInvokeErrorMessage("CAMERA_PERMISSION_REQUIRED: grant Camera permission") + assertEquals("CAMERA_PERMISSION_REQUIRED", parsed.code) + assertEquals("grant Camera permission", parsed.message) + assertTrue(parsed.hadExplicitCode) + assertEquals("CAMERA_PERMISSION_REQUIRED: grant Camera permission", parsed.prefixedMessage) + } + + @Test + fun parseInvokeErrorMessage_rejectsNonCanonicalCodePrefix() { + val parsed = parseInvokeErrorMessage("IllegalStateException: boom") + assertEquals("UNAVAILABLE", parsed.code) + assertEquals("IllegalStateException: boom", parsed.message) + assertFalse(parsed.hadExplicitCode) + } + + @Test + fun parseInvokeErrorFromThrowable_usesFallbackWhenMessageMissing() { + val parsed = parseInvokeErrorFromThrowable(IllegalStateException(), fallbackMessage = "fallback") + assertEquals("UNAVAILABLE", parsed.code) + assertEquals("fallback", parsed.message) + assertFalse(parsed.hadExplicitCode) + } +} From f7865527afa172a6812f3223550477ed325e6335 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 11:56:04 +0530 Subject: [PATCH 15/35] fix(android): omit websocket Origin for native gateway connect --- .../main/java/ai/openclaw/android/gateway/GatewaySession.kt | 4 +--- .../ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt | 4 ++++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt index 0c6d14721e0..0ec3132336f 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt @@ -200,9 +200,7 @@ class GatewaySession( suspend fun connect() { val scheme = if (tls != null) "wss" else "ws" val url = "$scheme://${endpoint.host}:${endpoint.port}" - val httpScheme = if (tls != null) "https" else "http" - val origin = "$httpScheme://${endpoint.host}:${endpoint.port}" - val request = Request.Builder().url(url).header("Origin", origin).build() + val request = Request.Builder().url(url).build() socket = client.newWebSocket(request, Listener()) try { connectDeferred.await() diff --git a/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt index a04bcb1606f..e5f98a0b653 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt @@ -19,6 +19,7 @@ import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.RecordedRequest import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -35,12 +36,14 @@ class GatewaySessionInvokeTest { val connected = CompletableDeferred() val invokeRequest = CompletableDeferred() val invokeResultParams = CompletableDeferred() + val handshakeOrigin = AtomicReference(null) val lastDisconnect = AtomicReference("") val server = MockWebServer().apply { dispatcher = object : Dispatcher() { override fun dispatch(request: RecordedRequest): MockResponse { + handshakeOrigin.compareAndSet(null, request.getHeader("Origin")) return MockResponse().withWebSocketUpgrade( object : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { @@ -148,6 +151,7 @@ class GatewaySessionInvokeTest { assertEquals("node-1", req.nodeId) assertEquals("debug.ping", req.command) assertEquals("""{"ping":"pong"}""", req.paramsJson) + assertNull(handshakeOrigin.get()) assertEquals("invoke-1", resultParams["id"]?.jsonPrimitive?.content) assertEquals("node-1", resultParams["nodeId"]?.jsonPrimitive?.content) assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict()) From a87d961ebc252512299a158a43946fc90e8315f4 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 12:07:09 +0530 Subject: [PATCH 16/35] fix(android): require gateway device auth store --- .../android/gateway/DeviceAuthStore.kt | 11 ++++++++--- .../android/gateway/GatewaySession.kt | 6 +++--- .../gateway/GatewaySessionInvokeTest.kt | 19 ++++++++++++++++--- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthStore.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthStore.kt index 810e029fba8..8ace62e087c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthStore.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthStore.kt @@ -2,13 +2,18 @@ package ai.openclaw.android.gateway import ai.openclaw.android.SecurePrefs -class DeviceAuthStore(private val prefs: SecurePrefs) { - fun loadToken(deviceId: String, role: String): String? { +interface DeviceAuthTokenStore { + fun loadToken(deviceId: String, role: String): String? + fun saveToken(deviceId: String, role: String, token: String) +} + +class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore { + override fun loadToken(deviceId: String, role: String): String? { val key = tokenKey(deviceId, role) return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() } } - fun saveToken(deviceId: String, role: String, token: String) { + override fun saveToken(deviceId: String, role: String, token: String) { val key = tokenKey(deviceId, role) prefs.putString(key, token.trim()) } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt index 0ec3132336f..e0aea39768e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt @@ -55,7 +55,7 @@ data class GatewayConnectOptions( class GatewaySession( private val scope: CoroutineScope, private val identityStore: DeviceIdentityStore, - private val deviceAuthStore: DeviceAuthStore? = null, + private val deviceAuthStore: DeviceAuthTokenStore, private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit, private val onDisconnected: (message: String) -> Unit, private val onEvent: (event: String, payloadJson: String?) -> Unit, @@ -303,7 +303,7 @@ class GatewaySession( private suspend fun sendConnect(connectNonce: String) { val identity = identityStore.loadOrCreate() - val storedToken = deviceAuthStore?.loadToken(identity.deviceId, options.role) + val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role) val trimmedToken = token?.trim().orEmpty() // QR/setup/manual shared token must take precedence; stale role tokens can survive re-onboarding. val authToken = if (trimmedToken.isNotBlank()) trimmedToken else storedToken.orEmpty() @@ -325,7 +325,7 @@ class GatewaySession( val deviceToken = authObj?.get("deviceToken").asStringOrNull() val authRole = authObj?.get("role").asStringOrNull() ?: options.role if (!deviceToken.isNullOrBlank()) { - deviceAuthStore?.saveToken(deviceId, authRole, deviceToken) + deviceAuthStore.saveToken(deviceId, authRole, deviceToken) } val rawCanvas = obj["canvasHostUrl"].asStringOrNull() canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint, isTlsConnection = tls != null) diff --git a/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt index e5f98a0b653..e8a37aef21b 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt @@ -27,6 +27,16 @@ import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config import java.util.concurrent.atomic.AtomicReference +private class InMemoryDeviceAuthStore : DeviceAuthTokenStore { + private val tokens = mutableMapOf() + + override fun loadToken(deviceId: String, role: String): String? = tokens["${deviceId.trim()}|${role.trim()}"]?.trim()?.takeIf { it.isNotEmpty() } + + override fun saveToken(deviceId: String, role: String, token: String) { + tokens["${deviceId.trim()}|${role.trim()}"] = token.trim() + } +} + @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class GatewaySessionInvokeTest { @@ -84,11 +94,12 @@ class GatewaySessionInvokeTest { val app = RuntimeEnvironment.getApplication() val sessionJob = SupervisorJob() + val deviceAuthStore = InMemoryDeviceAuthStore() val session = GatewaySession( scope = CoroutineScope(sessionJob + Dispatchers.Default), identityStore = DeviceIdentityStore(app), - deviceAuthStore = null, + deviceAuthStore = deviceAuthStore, onConnected = { _, _, _ -> if (!connected.isCompleted) connected.complete(Unit) }, @@ -218,11 +229,12 @@ class GatewaySessionInvokeTest { val app = RuntimeEnvironment.getApplication() val sessionJob = SupervisorJob() + val deviceAuthStore = InMemoryDeviceAuthStore() val session = GatewaySession( scope = CoroutineScope(sessionJob + Dispatchers.Default), identityStore = DeviceIdentityStore(app), - deviceAuthStore = null, + deviceAuthStore = deviceAuthStore, onConnected = { _, _, _ -> if (!connected.isCompleted) connected.complete(Unit) }, @@ -347,11 +359,12 @@ class GatewaySessionInvokeTest { val app = RuntimeEnvironment.getApplication() val sessionJob = SupervisorJob() + val deviceAuthStore = InMemoryDeviceAuthStore() val session = GatewaySession( scope = CoroutineScope(sessionJob + Dispatchers.Default), identityStore = DeviceIdentityStore(app), - deviceAuthStore = null, + deviceAuthStore = deviceAuthStore, onConnected = { _, _, _ -> if (!connected.isCompleted) connected.complete(Unit) }, From ac6539ed03ace4aadfa3e1b52bcf63bb3e9e257b Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 12:07:51 +0530 Subject: [PATCH 17/35] refactor(android): unify invoke availability gating --- .../java/ai/openclaw/android/NodeRuntime.kt | 2 + .../openclaw/android/node/InvokeDispatcher.kt | 57 +++++++++++++++---- 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt index 02e9b136091..8cb2b0b47dc 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt @@ -131,6 +131,8 @@ class NodeRuntime(context: Context) { isForeground = { _isForeground.value }, cameraEnabled = { cameraEnabled.value }, locationEnabled = { locationMode.value != LocationMode.Off }, + smsAvailable = { sms.canSendSms() }, + debugBuild = { BuildConfig.DEBUG }, onCanvasA2uiPush = { _canvasA2uiHydrated.value = true _canvasRehydratePending.value = false diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt index 0e58517f2f6..d293df76668 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt @@ -20,6 +20,8 @@ class InvokeDispatcher( private val isForeground: () -> Boolean, private val cameraEnabled: () -> Boolean, private val locationEnabled: () -> Boolean, + private val smsAvailable: () -> Boolean, + private val debugBuild: () -> Boolean, private val onCanvasA2uiPush: () -> Unit, private val onCanvasA2uiReset: () -> Unit, ) { @@ -36,18 +38,7 @@ class InvokeDispatcher( message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground", ) } - if (spec.availability == InvokeCommandAvailability.CameraEnabled && !cameraEnabled()) { - return GatewaySession.InvokeResult.error( - code = "CAMERA_DISABLED", - message = "CAMERA_DISABLED: enable Camera in Settings", - ) - } - if (spec.availability == InvokeCommandAvailability.LocationEnabled && !locationEnabled()) { - return GatewaySession.InvokeResult.error( - code = "LOCATION_DISABLED", - message = "LOCATION_DISABLED: enable Location in Settings", - ) - } + availabilityError(spec.availability)?.let { return it } return when (command) { // Canvas commands @@ -170,4 +161,46 @@ class InvokeDispatcher( ) } } + + private fun availabilityError(availability: InvokeCommandAvailability): GatewaySession.InvokeResult? { + return when (availability) { + InvokeCommandAvailability.Always -> null + InvokeCommandAvailability.CameraEnabled -> + if (cameraEnabled()) { + null + } else { + GatewaySession.InvokeResult.error( + code = "CAMERA_DISABLED", + message = "CAMERA_DISABLED: enable Camera in Settings", + ) + } + InvokeCommandAvailability.LocationEnabled -> + if (locationEnabled()) { + null + } else { + GatewaySession.InvokeResult.error( + code = "LOCATION_DISABLED", + message = "LOCATION_DISABLED: enable Location in Settings", + ) + } + InvokeCommandAvailability.SmsAvailable -> + if (smsAvailable()) { + null + } else { + GatewaySession.InvokeResult.error( + code = "SMS_UNAVAILABLE", + message = "SMS_UNAVAILABLE: SMS not available on this device", + ) + } + InvokeCommandAvailability.DebugBuild -> + if (debugBuild()) { + null + } else { + GatewaySession.InvokeResult.error( + code = "INVALID_REQUEST", + message = "INVALID_REQUEST: unknown command", + ) + } + } + } } From c5d040bbea3b199ea8c2c7f323aa9fd143958178 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 12:17:14 +0530 Subject: [PATCH 18/35] fix: update changelog for android invoke distill (#27257) (thanks @obviyus) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce9d381407f..1a60109a094 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Android/Node invoke: remove native gateway WebSocket `Origin` header to avoid false origin rejections, unify invoke command registry/policy/error parsing paths, and keep command availability checks centralized to reduce dispatcher/advertisement drift. (#27257) Thanks @obviyus. - CI/Windows: shard the Windows `checks-windows` test lane into two matrix jobs and honor explicit shard index overrides in `scripts/test-parallel.mjs` to reduce CI critical-path wall time. (#27234) Thanks @joshavant. ## 2026.2.25 From 96c77025263d44a9c5c5cad9b5713b98b340fcbe Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 26 Feb 2026 02:36:56 -0500 Subject: [PATCH 19/35] Agents: add account-scoped bind and routing commands (#27195) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: ad35a458a55427614a35c9d0713a7386172464ad Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 4 + docs/cli/agents.md | 50 +++- docs/cli/channels.md | 10 + docs/cli/index.md | 32 ++- docs/concepts/multi-agent.md | 6 + src/channels/plugins/types.adapters.ts | 11 +- src/cli/program/preaction.test.ts | 10 + src/cli/program/preaction.ts | 1 + src/cli/program/register.agent.test.ts | 64 +++++ src/cli/program/register.agent.ts | 65 +++++ src/commands/agents.bind.commands.test.ts | 200 +++++++++++++ src/commands/agents.bindings.ts | 194 +++++++++++-- src/commands/agents.commands.add.ts | 3 +- src/commands/agents.commands.bind.ts | 324 ++++++++++++++++++++++ src/commands/agents.test.ts | 109 ++++++++ src/commands/agents.ts | 1 + src/commands/channels/add.ts | 73 ++++- 17 files changed, 1133 insertions(+), 24 deletions(-) create mode 100644 src/commands/agents.bind.commands.test.ts create mode 100644 src/commands/agents.commands.bind.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a60109a094..b890896f0d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Docs: https://docs.openclaw.ai ## 2026.2.26 (Unreleased) +### Changes + +- Agents/Routing CLI: add `openclaw agents bindings`, `openclaw agents bind`, and `openclaw agents unbind` for account-scoped route management, including channel-only to account-scoped binding upgrades, role-aware binding identity handling, plugin-resolved binding account IDs, and optional account-binding prompts in `openclaw channels add`. (#27195) thanks @gumadeiras. + ### Fixes - Android/Node invoke: remove native gateway WebSocket `Origin` header to avoid false origin rejections, unify invoke command registry/policy/error parsing paths, and keep command availability checks centralized to reduce dispatcher/advertisement drift. (#27257) Thanks @obviyus. diff --git a/docs/cli/agents.md b/docs/cli/agents.md index 39679265f14..5bdc8a68bf2 100644 --- a/docs/cli/agents.md +++ b/docs/cli/agents.md @@ -1,5 +1,5 @@ --- -summary: "CLI reference for `openclaw agents` (list/add/delete/set identity)" +summary: "CLI reference for `openclaw agents` (list/add/delete/bindings/bind/unbind/set identity)" read_when: - You want multiple isolated agents (workspaces + routing + auth) title: "agents" @@ -19,11 +19,59 @@ Related: ```bash openclaw agents list openclaw agents add work --workspace ~/.openclaw/workspace-work +openclaw agents bindings +openclaw agents bind --agent work --bind telegram:ops +openclaw agents unbind --agent work --bind telegram:ops openclaw agents set-identity --workspace ~/.openclaw/workspace --from-identity openclaw agents set-identity --agent main --avatar avatars/openclaw.png openclaw agents delete work ``` +## Routing bindings + +Use routing bindings to pin inbound channel traffic to a specific agent. + +List bindings: + +```bash +openclaw agents bindings +openclaw agents bindings --agent work +openclaw agents bindings --json +``` + +Add bindings: + +```bash +openclaw agents bind --agent work --bind telegram:ops --bind discord:guild-a +``` + +If you omit `accountId` (`--bind `), OpenClaw resolves it from channel defaults and plugin setup hooks when available. + +### Binding scope behavior + +- A binding without `accountId` matches the channel default account only. +- `accountId: "*"` is the channel-wide fallback (all accounts) and is less specific than an explicit account binding. +- If the same agent already has a matching channel binding without `accountId`, and you later bind with an explicit or resolved `accountId`, OpenClaw upgrades that existing binding in place instead of adding a duplicate. + +Example: + +```bash +# initial channel-only binding +openclaw agents bind --agent work --bind telegram + +# later upgrade to account-scoped binding +openclaw agents bind --agent work --bind telegram:ops +``` + +After the upgrade, routing for that binding is scoped to `telegram:ops`. If you also want default-account routing, add it explicitly (for example `--bind telegram:default`). + +Remove bindings: + +```bash +openclaw agents unbind --agent work --bind telegram:ops +openclaw agents unbind --agent work --all +``` + ## Identity files Each agent workspace can include an `IDENTITY.md` at the workspace root: diff --git a/docs/cli/channels.md b/docs/cli/channels.md index 4213efb3eb7..0f9c3fecb77 100644 --- a/docs/cli/channels.md +++ b/docs/cli/channels.md @@ -35,6 +35,16 @@ openclaw channels remove --channel telegram --delete Tip: `openclaw channels add --help` shows per-channel flags (token, app token, signal-cli paths, etc). +When you run `openclaw channels add` without flags, the interactive wizard can prompt: + +- account ids per selected channel +- optional display names for those accounts +- `Bind configured channel accounts to agents now?` + +If you confirm bind now, the wizard asks which agent should own each configured channel account and writes account-scoped routing bindings. + +You can also manage the same routing rules later with `openclaw agents bindings`, `openclaw agents bind`, and `openclaw agents unbind` (see [agents](/cli/agents)). + ## Login / logout (interactive) ```bash diff --git a/docs/cli/index.md b/docs/cli/index.md index 32eb31b5eb3..1394d83db0e 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -574,7 +574,37 @@ Options: - `--non-interactive` - `--json` -Binding specs use `channel[:accountId]`. When `accountId` is omitted for WhatsApp, the default account id is used. +Binding specs use `channel[:accountId]`. When `accountId` is omitted, OpenClaw may resolve account scope via channel defaults/plugin hooks; otherwise it is a channel binding without explicit account scope. + +#### `agents bindings` + +List routing bindings. + +Options: + +- `--agent ` +- `--json` + +#### `agents bind` + +Add routing bindings for an agent. + +Options: + +- `--agent ` +- `--bind ` (repeatable) +- `--json` + +#### `agents unbind` + +Remove routing bindings for an agent. + +Options: + +- `--agent ` +- `--bind ` (repeatable) +- `--all` +- `--json` #### `agents delete ` diff --git a/docs/concepts/multi-agent.md b/docs/concepts/multi-agent.md index 069fcfb6367..842531cc2a6 100644 --- a/docs/concepts/multi-agent.md +++ b/docs/concepts/multi-agent.md @@ -185,6 +185,12 @@ Bindings are **deterministic** and **most-specific wins**: If multiple bindings match in the same tier, the first one in config order wins. If a binding sets multiple match fields (for example `peer` + `guildId`), all specified fields are required (`AND` semantics). +Important account-scope detail: + +- A binding that omits `accountId` matches the default account only. +- Use `accountId: "*"` for a channel-wide fallback across all accounts. +- If you later add the same binding for the same agent with an explicit account id, OpenClaw upgrades the existing channel-only binding to account-scoped instead of duplicating it. + ## Multiple accounts / phone numbers Channels that support **multiple accounts** (e.g. WhatsApp) use `accountId` to identify diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 113df6ad5cd..ead7f68b2fa 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -21,7 +21,16 @@ import type { } from "./types.core.js"; export type ChannelSetupAdapter = { - resolveAccountId?: (params: { cfg: OpenClawConfig; accountId?: string }) => string; + resolveAccountId?: (params: { + cfg: OpenClawConfig; + accountId?: string; + input?: ChannelSetupInput; + }) => string; + resolveBindingAccountId?: (params: { + cfg: OpenClawConfig; + agentId: string; + accountId?: string; + }) => string | undefined; applyAccountName?: (params: { cfg: OpenClawConfig; accountId: string; diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index bf4184d362a..caa9dd24869 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -80,6 +80,7 @@ describe("registerPreActionHooks", () => { program.command("update").action(async () => {}); program.command("channels").action(async () => {}); program.command("directory").action(async () => {}); + program.command("agents").action(async () => {}); program.command("configure").action(async () => {}); program.command("onboard").action(async () => {}); program @@ -145,6 +146,15 @@ describe("registerPreActionHooks", () => { expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledTimes(1); }); + it("loads plugin registry for agents command", async () => { + await runCommand({ + parseArgv: ["agents"], + processArgv: ["node", "openclaw", "agents"], + }); + + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledTimes(1); + }); + it("skips config guard for doctor and completion commands", async () => { await runCommand({ parseArgv: ["doctor"], diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 6a9abc3e99e..6a232386b14 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -25,6 +25,7 @@ const PLUGIN_REQUIRED_COMMANDS = new Set([ "message", "channels", "directory", + "agents", "configure", "onboard", ]); diff --git a/src/cli/program/register.agent.test.ts b/src/cli/program/register.agent.test.ts index 9ad1fa19d52..2d37e56a702 100644 --- a/src/cli/program/register.agent.test.ts +++ b/src/cli/program/register.agent.test.ts @@ -3,9 +3,12 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const agentCliCommandMock = vi.fn(); const agentsAddCommandMock = vi.fn(); +const agentsBindingsCommandMock = vi.fn(); +const agentsBindCommandMock = vi.fn(); const agentsDeleteCommandMock = vi.fn(); const agentsListCommandMock = vi.fn(); const agentsSetIdentityCommandMock = vi.fn(); +const agentsUnbindCommandMock = vi.fn(); const setVerboseMock = vi.fn(); const createDefaultDepsMock = vi.fn(() => ({ deps: true })); @@ -21,9 +24,12 @@ vi.mock("../../commands/agent-via-gateway.js", () => ({ vi.mock("../../commands/agents.js", () => ({ agentsAddCommand: agentsAddCommandMock, + agentsBindingsCommand: agentsBindingsCommandMock, + agentsBindCommand: agentsBindCommandMock, agentsDeleteCommand: agentsDeleteCommandMock, agentsListCommand: agentsListCommandMock, agentsSetIdentityCommand: agentsSetIdentityCommandMock, + agentsUnbindCommand: agentsUnbindCommandMock, })); vi.mock("../../globals.js", () => ({ @@ -55,9 +61,12 @@ describe("registerAgentCommands", () => { vi.clearAllMocks(); agentCliCommandMock.mockResolvedValue(undefined); agentsAddCommandMock.mockResolvedValue(undefined); + agentsBindingsCommandMock.mockResolvedValue(undefined); + agentsBindCommandMock.mockResolvedValue(undefined); agentsDeleteCommandMock.mockResolvedValue(undefined); agentsListCommandMock.mockResolvedValue(undefined); agentsSetIdentityCommandMock.mockResolvedValue(undefined); + agentsUnbindCommandMock.mockResolvedValue(undefined); createDefaultDepsMock.mockReturnValue({ deps: true }); }); @@ -147,6 +156,61 @@ describe("registerAgentCommands", () => { ); }); + it("forwards agents bindings options", async () => { + await runCli(["agents", "bindings", "--agent", "ops", "--json"]); + expect(agentsBindingsCommandMock).toHaveBeenCalledWith( + { + agent: "ops", + json: true, + }, + runtime, + ); + }); + + it("forwards agents bind options", async () => { + await runCli([ + "agents", + "bind", + "--agent", + "ops", + "--bind", + "matrix-js:ops", + "--bind", + "telegram", + "--json", + ]); + expect(agentsBindCommandMock).toHaveBeenCalledWith( + { + agent: "ops", + bind: ["matrix-js:ops", "telegram"], + json: true, + }, + runtime, + ); + }); + + it("documents bind accountId resolution behavior in help text", () => { + const program = new Command(); + registerAgentCommands(program, { agentChannelOptions: "last|telegram|discord" }); + const agents = program.commands.find((command) => command.name() === "agents"); + const bind = agents?.commands.find((command) => command.name() === "bind"); + const help = bind?.helpInformation() ?? ""; + expect(help).toContain("accountId is resolved by channel defaults/hooks"); + }); + + it("forwards agents unbind options", async () => { + await runCli(["agents", "unbind", "--agent", "ops", "--all", "--json"]); + expect(agentsUnbindCommandMock).toHaveBeenCalledWith( + { + agent: "ops", + bind: [], + all: true, + json: true, + }, + runtime, + ); + }); + it("forwards agents delete options", async () => { await runCli(["agents", "delete", "worker-a", "--force", "--json"]); expect(agentsDeleteCommandMock).toHaveBeenCalledWith( diff --git a/src/cli/program/register.agent.ts b/src/cli/program/register.agent.ts index 4f112403c14..fdb45a0960a 100644 --- a/src/cli/program/register.agent.ts +++ b/src/cli/program/register.agent.ts @@ -2,9 +2,12 @@ import type { Command } from "commander"; import { agentCliCommand } from "../../commands/agent-via-gateway.js"; import { agentsAddCommand, + agentsBindingsCommand, + agentsBindCommand, agentsDeleteCommand, agentsListCommand, agentsSetIdentityCommand, + agentsUnbindCommand, } from "../../commands/agents.js"; import { setVerbose } from "../../globals.js"; import { defaultRuntime } from "../../runtime.js"; @@ -102,6 +105,68 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.openclaw.ai/cli/age }); }); + agents + .command("bindings") + .description("List routing bindings") + .option("--agent ", "Filter by agent id") + .option("--json", "Output JSON instead of text", false) + .action(async (opts) => { + await runCommandWithRuntime(defaultRuntime, async () => { + await agentsBindingsCommand( + { + agent: opts.agent as string | undefined, + json: Boolean(opts.json), + }, + defaultRuntime, + ); + }); + }); + + agents + .command("bind") + .description("Add routing bindings for an agent") + .option("--agent ", "Agent id (defaults to current default agent)") + .option( + "--bind ", + "Binding to add (repeatable). If omitted, accountId is resolved by channel defaults/hooks.", + collectOption, + [], + ) + .option("--json", "Output JSON summary", false) + .action(async (opts) => { + await runCommandWithRuntime(defaultRuntime, async () => { + await agentsBindCommand( + { + agent: opts.agent as string | undefined, + bind: Array.isArray(opts.bind) ? (opts.bind as string[]) : undefined, + json: Boolean(opts.json), + }, + defaultRuntime, + ); + }); + }); + + agents + .command("unbind") + .description("Remove routing bindings for an agent") + .option("--agent ", "Agent id (defaults to current default agent)") + .option("--bind ", "Binding to remove (repeatable)", collectOption, []) + .option("--all", "Remove all bindings for this agent", false) + .option("--json", "Output JSON summary", false) + .action(async (opts) => { + await runCommandWithRuntime(defaultRuntime, async () => { + await agentsUnbindCommand( + { + agent: opts.agent as string | undefined, + bind: Array.isArray(opts.bind) ? (opts.bind as string[]) : undefined, + all: Boolean(opts.all), + json: Boolean(opts.json), + }, + defaultRuntime, + ); + }); + }); + agents .command("add [name]") .description("Add a new isolated agent") diff --git a/src/commands/agents.bind.commands.test.ts b/src/commands/agents.bind.commands.test.ts new file mode 100644 index 00000000000..0fe03173be6 --- /dev/null +++ b/src/commands/agents.bind.commands.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js"; + +const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); +const writeConfigFileMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); + +vi.mock("../config/config.js", async (importOriginal) => ({ + ...(await importOriginal()), + readConfigFileSnapshot: readConfigFileSnapshotMock, + writeConfigFile: writeConfigFileMock, +})); + +vi.mock("../channels/plugins/index.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getChannelPlugin: (channel: string) => { + if (channel === "matrix-js") { + return { + id: "matrix-js", + setup: { + resolveBindingAccountId: ({ agentId }: { agentId: string }) => agentId.toLowerCase(), + }, + }; + } + return actual.getChannelPlugin(channel); + }, + normalizeChannelId: (channel: string) => { + if (channel.trim().toLowerCase() === "matrix-js") { + return "matrix-js"; + } + return actual.normalizeChannelId(channel); + }, + }; +}); + +import { agentsBindCommand, agentsBindingsCommand, agentsUnbindCommand } from "./agents.js"; + +const runtime = createTestRuntime(); + +describe("agents bind/unbind commands", () => { + beforeEach(() => { + readConfigFileSnapshotMock.mockClear(); + writeConfigFileMock.mockClear(); + runtime.log.mockClear(); + runtime.error.mockClear(); + runtime.exit.mockClear(); + }); + + it("lists all bindings by default", async () => { + readConfigFileSnapshotMock.mockResolvedValue({ + ...baseConfigSnapshot, + config: { + bindings: [ + { agentId: "main", match: { channel: "matrix-js" } }, + { agentId: "ops", match: { channel: "telegram", accountId: "work" } }, + ], + }, + }); + + await agentsBindingsCommand({}, runtime); + + expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("main <- matrix-js")); + expect(runtime.log).toHaveBeenCalledWith( + expect.stringContaining("ops <- telegram accountId=work"), + ); + }); + + it("binds routes to default agent when --agent is omitted", async () => { + readConfigFileSnapshotMock.mockResolvedValue({ + ...baseConfigSnapshot, + config: {}, + }); + + await agentsBindCommand({ bind: ["telegram"] }, runtime); + + expect(writeConfigFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + bindings: [{ agentId: "main", match: { channel: "telegram" } }], + }), + ); + expect(runtime.exit).not.toHaveBeenCalled(); + }); + + it("defaults matrix-js accountId to the target agent id when omitted", async () => { + readConfigFileSnapshotMock.mockResolvedValue({ + ...baseConfigSnapshot, + config: {}, + }); + + await agentsBindCommand({ agent: "main", bind: ["matrix-js"] }, runtime); + + expect(writeConfigFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + bindings: [{ agentId: "main", match: { channel: "matrix-js", accountId: "main" } }], + }), + ); + expect(runtime.exit).not.toHaveBeenCalled(); + }); + + it("upgrades existing channel-only binding when accountId is later provided", async () => { + readConfigFileSnapshotMock.mockResolvedValue({ + ...baseConfigSnapshot, + config: { + bindings: [{ agentId: "main", match: { channel: "telegram" } }], + }, + }); + + await agentsBindCommand({ bind: ["telegram:work"] }, runtime); + + expect(writeConfigFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + bindings: [{ agentId: "main", match: { channel: "telegram", accountId: "work" } }], + }), + ); + expect(runtime.log).toHaveBeenCalledWith("Updated bindings:"); + expect(runtime.exit).not.toHaveBeenCalled(); + }); + + it("unbinds all routes for an agent", async () => { + readConfigFileSnapshotMock.mockResolvedValue({ + ...baseConfigSnapshot, + config: { + agents: { list: [{ id: "ops", workspace: "/tmp/ops" }] }, + bindings: [ + { agentId: "main", match: { channel: "matrix-js" } }, + { agentId: "ops", match: { channel: "telegram", accountId: "work" } }, + ], + }, + }); + + await agentsUnbindCommand({ agent: "ops", all: true }, runtime); + + expect(writeConfigFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + bindings: [{ agentId: "main", match: { channel: "matrix-js" } }], + }), + ); + expect(runtime.exit).not.toHaveBeenCalled(); + }); + + it("reports ownership conflicts during unbind and exits 1", async () => { + readConfigFileSnapshotMock.mockResolvedValue({ + ...baseConfigSnapshot, + config: { + agents: { list: [{ id: "ops", workspace: "/tmp/ops" }] }, + bindings: [{ agentId: "main", match: { channel: "telegram", accountId: "ops" } }], + }, + }); + + await agentsUnbindCommand({ agent: "ops", bind: ["telegram:ops"] }, runtime); + + expect(writeConfigFileMock).not.toHaveBeenCalled(); + expect(runtime.error).toHaveBeenCalledWith("Bindings are owned by another agent:"); + expect(runtime.exit).toHaveBeenCalledWith(1); + }); + + it("keeps role-based bindings when removing channel-level discord binding", async () => { + readConfigFileSnapshotMock.mockResolvedValue({ + ...baseConfigSnapshot, + config: { + bindings: [ + { + agentId: "main", + match: { + channel: "discord", + accountId: "guild-a", + roles: ["111", "222"], + }, + }, + { + agentId: "main", + match: { + channel: "discord", + accountId: "guild-a", + }, + }, + ], + }, + }); + + await agentsUnbindCommand({ bind: ["discord:guild-a"] }, runtime); + + expect(writeConfigFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + bindings: [ + { + agentId: "main", + match: { + channel: "discord", + accountId: "guild-a", + roles: ["111", "222"], + }, + }, + ], + }), + ); + expect(runtime.exit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/agents.bindings.ts b/src/commands/agents.bindings.ts index f0eaf959e1e..ca0c0ee649c 100644 --- a/src/commands/agents.bindings.ts +++ b/src/commands/agents.bindings.ts @@ -8,16 +8,51 @@ import type { ChannelChoice } from "./onboard-types.js"; function bindingMatchKey(match: AgentBinding["match"]) { const accountId = match.accountId?.trim() || DEFAULT_ACCOUNT_ID; + const identityKey = bindingMatchIdentityKey(match); + return [identityKey, accountId].join("|"); +} + +function bindingMatchIdentityKey(match: AgentBinding["match"]) { + const roles = Array.isArray(match.roles) + ? Array.from( + new Set( + match.roles + .map((role) => role.trim()) + .filter(Boolean) + .toSorted(), + ), + ) + : []; return [ match.channel, - accountId, match.peer?.kind ?? "", match.peer?.id ?? "", match.guildId ?? "", match.teamId ?? "", + roles.join(","), ].join("|"); } +function canUpgradeBindingAccountScope(params: { + existing: AgentBinding; + incoming: AgentBinding; + normalizedIncomingAgentId: string; +}): boolean { + if (!params.incoming.match.accountId?.trim()) { + return false; + } + if (params.existing.match.accountId?.trim()) { + return false; + } + if (normalizeAgentId(params.existing.agentId) !== params.normalizedIncomingAgentId) { + return false; + } + return ( + bindingMatchIdentityKey(params.existing.match) === + bindingMatchIdentityKey(params.incoming.match) + ); +} + export function describeBinding(binding: AgentBinding) { const match = binding.match; const parts = [match.channel]; @@ -42,10 +77,11 @@ export function applyAgentBindings( ): { config: OpenClawConfig; added: AgentBinding[]; + updated: AgentBinding[]; skipped: AgentBinding[]; conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>; } { - const existing = cfg.bindings ?? []; + const existing = [...(cfg.bindings ?? [])]; const existingMatchMap = new Map(); for (const binding of existing) { const key = bindingMatchKey(binding.match); @@ -55,6 +91,7 @@ export function applyAgentBindings( } const added: AgentBinding[] = []; + const updated: AgentBinding[] = []; const skipped: AgentBinding[] = []; const conflicts: Array<{ binding: AgentBinding; existingAgentId: string }> = []; @@ -70,12 +107,41 @@ export function applyAgentBindings( } continue; } + + const upgradeIndex = existing.findIndex((candidate) => + canUpgradeBindingAccountScope({ + existing: candidate, + incoming: binding, + normalizedIncomingAgentId: agentId, + }), + ); + if (upgradeIndex >= 0) { + const current = existing[upgradeIndex]; + if (!current) { + continue; + } + const previousKey = bindingMatchKey(current.match); + const upgradedBinding: AgentBinding = { + ...current, + agentId, + match: { + ...current.match, + accountId: binding.match.accountId?.trim(), + }, + }; + existing[upgradeIndex] = upgradedBinding; + existingMatchMap.delete(previousKey); + existingMatchMap.set(bindingMatchKey(upgradedBinding.match), agentId); + updated.push(upgradedBinding); + continue; + } + existingMatchMap.set(key, agentId); added.push({ ...binding, agentId }); } - if (added.length === 0) { - return { config: cfg, added, skipped, conflicts }; + if (added.length === 0 && updated.length === 0) { + return { config: cfg, added, updated, skipped, conflicts }; } return { @@ -84,11 +150,78 @@ export function applyAgentBindings( bindings: [...existing, ...added], }, added, + updated, skipped, conflicts, }; } +export function removeAgentBindings( + cfg: OpenClawConfig, + bindings: AgentBinding[], +): { + config: OpenClawConfig; + removed: AgentBinding[]; + missing: AgentBinding[]; + conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>; +} { + const existing = cfg.bindings ?? []; + const removeIndexes = new Set(); + const removed: AgentBinding[] = []; + const missing: AgentBinding[] = []; + const conflicts: Array<{ binding: AgentBinding; existingAgentId: string }> = []; + + for (const binding of bindings) { + const desiredAgentId = normalizeAgentId(binding.agentId); + const key = bindingMatchKey(binding.match); + let matchedIndex = -1; + let conflictingAgentId: string | null = null; + for (let i = 0; i < existing.length; i += 1) { + if (removeIndexes.has(i)) { + continue; + } + const current = existing[i]; + if (!current || bindingMatchKey(current.match) !== key) { + continue; + } + const currentAgentId = normalizeAgentId(current.agentId); + if (currentAgentId === desiredAgentId) { + matchedIndex = i; + break; + } + conflictingAgentId = currentAgentId; + } + if (matchedIndex >= 0) { + const matched = existing[matchedIndex]; + if (matched) { + removeIndexes.add(matchedIndex); + removed.push(matched); + } + continue; + } + if (conflictingAgentId) { + conflicts.push({ binding, existingAgentId: conflictingAgentId }); + continue; + } + missing.push(binding); + } + + if (removeIndexes.size === 0) { + return { config: cfg, removed, missing, conflicts }; + } + + const nextBindings = existing.filter((_, index) => !removeIndexes.has(index)); + return { + config: { + ...cfg, + bindings: nextBindings.length > 0 ? nextBindings : undefined, + }, + removed, + missing, + conflicts, + }; +} + function resolveDefaultAccountId(cfg: OpenClawConfig, provider: ChannelId): string { const plugin = getChannelPlugin(provider); if (!plugin) { @@ -97,6 +230,33 @@ function resolveDefaultAccountId(cfg: OpenClawConfig, provider: ChannelId): stri return resolveChannelDefaultAccountId({ plugin, cfg }); } +function resolveBindingAccountId(params: { + channel: ChannelId; + config: OpenClawConfig; + agentId: string; + explicitAccountId?: string; +}): string | undefined { + const explicitAccountId = params.explicitAccountId?.trim(); + if (explicitAccountId) { + return explicitAccountId; + } + + const plugin = getChannelPlugin(params.channel); + const pluginAccountId = plugin?.setup?.resolveBindingAccountId?.({ + cfg: params.config, + agentId: params.agentId, + }); + if (pluginAccountId?.trim()) { + return pluginAccountId.trim(); + } + + if (plugin?.meta.forceAccountBinding) { + return resolveDefaultAccountId(params.config, params.channel); + } + + return undefined; +} + export function buildChannelBindings(params: { agentId: string; selection: ChannelChoice[]; @@ -107,14 +267,14 @@ export function buildChannelBindings(params: { const agentId = normalizeAgentId(params.agentId); for (const channel of params.selection) { const match: AgentBinding["match"] = { channel }; - const accountId = params.accountIds?.[channel]?.trim(); + const accountId = resolveBindingAccountId({ + channel, + config: params.config, + agentId, + explicitAccountId: params.accountIds?.[channel], + }); if (accountId) { match.accountId = accountId; - } else { - const plugin = getChannelPlugin(channel); - if (plugin?.meta.forceAccountBinding) { - match.accountId = resolveDefaultAccountId(params.config, channel); - } } bindings.push({ agentId, match }); } @@ -141,17 +301,17 @@ export function parseBindingSpecs(params: { errors.push(`Unknown channel "${channelRaw}".`); continue; } - let accountId = accountRaw?.trim(); + let accountId: string | undefined = accountRaw?.trim(); if (accountRaw !== undefined && !accountId) { errors.push(`Invalid binding "${trimmed}" (empty account id).`); continue; } - if (!accountId) { - const plugin = getChannelPlugin(channel); - if (plugin?.meta.forceAccountBinding) { - accountId = resolveDefaultAccountId(params.config, channel); - } - } + accountId = resolveBindingAccountId({ + channel, + config: params.config, + agentId, + explicitAccountId: accountId, + }); const match: AgentBinding["match"] = { channel }; if (accountId) { match.accountId = accountId; diff --git a/src/commands/agents.commands.add.ts b/src/commands/agents.commands.add.ts index 807ecca0b20..61c45392f59 100644 --- a/src/commands/agents.commands.add.ts +++ b/src/commands/agents.commands.add.ts @@ -125,7 +125,7 @@ export async function agentsAddCommand( const bindingResult = bindingParse.bindings.length > 0 ? applyAgentBindings(nextConfig, bindingParse.bindings) - : { config: nextConfig, added: [], skipped: [], conflicts: [] }; + : { config: nextConfig, added: [], updated: [], skipped: [], conflicts: [] }; await writeConfigFile(bindingResult.config); if (!opts.json) { @@ -145,6 +145,7 @@ export async function agentsAddCommand( model, bindings: { added: bindingResult.added.map(describeBinding), + updated: bindingResult.updated.map(describeBinding), skipped: bindingResult.skipped.map(describeBinding), conflicts: bindingResult.conflicts.map( (conflict) => `${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`, diff --git a/src/commands/agents.commands.bind.ts b/src/commands/agents.commands.bind.ts new file mode 100644 index 00000000000..b7a021053c6 --- /dev/null +++ b/src/commands/agents.commands.bind.ts @@ -0,0 +1,324 @@ +import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { writeConfigFile } from "../config/config.js"; +import { logConfigUpdated } from "../config/logging.js"; +import type { AgentBinding } from "../config/types.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { defaultRuntime } from "../runtime.js"; +import { + applyAgentBindings, + describeBinding, + parseBindingSpecs, + removeAgentBindings, +} from "./agents.bindings.js"; +import { requireValidConfig } from "./agents.command-shared.js"; +import { buildAgentSummaries } from "./agents.config.js"; + +type AgentsBindingsListOptions = { + agent?: string; + json?: boolean; +}; + +type AgentsBindOptions = { + agent?: string; + bind?: string[]; + json?: boolean; +}; + +type AgentsUnbindOptions = { + agent?: string; + bind?: string[]; + all?: boolean; + json?: boolean; +}; + +function resolveAgentId( + cfg: Awaited>, + agentInput: string | undefined, + params?: { fallbackToDefault?: boolean }, +): string | null { + if (!cfg) { + return null; + } + if (agentInput?.trim()) { + return normalizeAgentId(agentInput); + } + if (params?.fallbackToDefault) { + return resolveDefaultAgentId(cfg); + } + return null; +} + +function hasAgent(cfg: Awaited>, agentId: string): boolean { + if (!cfg) { + return false; + } + return buildAgentSummaries(cfg).some((summary) => summary.id === agentId); +} + +function formatBindingOwnerLine(binding: AgentBinding): string { + return `${normalizeAgentId(binding.agentId)} <- ${describeBinding(binding)}`; +} + +export async function agentsBindingsCommand( + opts: AgentsBindingsListOptions, + runtime: RuntimeEnv = defaultRuntime, +) { + const cfg = await requireValidConfig(runtime); + if (!cfg) { + return; + } + + const filterAgentId = resolveAgentId(cfg, opts.agent?.trim()); + if (opts.agent && !filterAgentId) { + runtime.error("Agent id is required."); + runtime.exit(1); + return; + } + if (filterAgentId && !hasAgent(cfg, filterAgentId)) { + runtime.error(`Agent "${filterAgentId}" not found.`); + runtime.exit(1); + return; + } + + const filtered = (cfg.bindings ?? []).filter( + (binding) => !filterAgentId || normalizeAgentId(binding.agentId) === filterAgentId, + ); + if (opts.json) { + runtime.log( + JSON.stringify( + filtered.map((binding) => ({ + agentId: normalizeAgentId(binding.agentId), + match: binding.match, + description: describeBinding(binding), + })), + null, + 2, + ), + ); + return; + } + + if (filtered.length === 0) { + runtime.log( + filterAgentId ? `No routing bindings for agent "${filterAgentId}".` : "No routing bindings.", + ); + return; + } + + runtime.log( + [ + "Routing bindings:", + ...filtered.map((binding) => `- ${formatBindingOwnerLine(binding)}`), + ].join("\n"), + ); +} + +export async function agentsBindCommand( + opts: AgentsBindOptions, + runtime: RuntimeEnv = defaultRuntime, +) { + const cfg = await requireValidConfig(runtime); + if (!cfg) { + return; + } + + const agentId = resolveAgentId(cfg, opts.agent?.trim(), { fallbackToDefault: true }); + if (!agentId) { + runtime.error("Unable to resolve agent id."); + runtime.exit(1); + return; + } + if (!hasAgent(cfg, agentId)) { + runtime.error(`Agent "${agentId}" not found.`); + runtime.exit(1); + return; + } + + const specs = (opts.bind ?? []).map((value) => value.trim()).filter(Boolean); + if (specs.length === 0) { + runtime.error("Provide at least one --bind ."); + runtime.exit(1); + return; + } + + const parsed = parseBindingSpecs({ agentId, specs, config: cfg }); + if (parsed.errors.length > 0) { + runtime.error(parsed.errors.join("\n")); + runtime.exit(1); + return; + } + + const result = applyAgentBindings(cfg, parsed.bindings); + if (result.added.length > 0 || result.updated.length > 0) { + await writeConfigFile(result.config); + if (!opts.json) { + logConfigUpdated(runtime); + } + } + + const payload = { + agentId, + added: result.added.map(describeBinding), + updated: result.updated.map(describeBinding), + skipped: result.skipped.map(describeBinding), + conflicts: result.conflicts.map( + (conflict) => `${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`, + ), + }; + if (opts.json) { + runtime.log(JSON.stringify(payload, null, 2)); + if (result.conflicts.length > 0) { + runtime.exit(1); + } + return; + } + + if (result.added.length > 0) { + runtime.log("Added bindings:"); + for (const binding of result.added) { + runtime.log(`- ${describeBinding(binding)}`); + } + } else if (result.updated.length === 0) { + runtime.log("No new bindings added."); + } + + if (result.updated.length > 0) { + runtime.log("Updated bindings:"); + for (const binding of result.updated) { + runtime.log(`- ${describeBinding(binding)}`); + } + } + + if (result.skipped.length > 0) { + runtime.log("Already present:"); + for (const binding of result.skipped) { + runtime.log(`- ${describeBinding(binding)}`); + } + } + + if (result.conflicts.length > 0) { + runtime.error("Skipped bindings already claimed by another agent:"); + for (const conflict of result.conflicts) { + runtime.error(`- ${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`); + } + runtime.exit(1); + } +} + +export async function agentsUnbindCommand( + opts: AgentsUnbindOptions, + runtime: RuntimeEnv = defaultRuntime, +) { + const cfg = await requireValidConfig(runtime); + if (!cfg) { + return; + } + + const agentId = resolveAgentId(cfg, opts.agent?.trim(), { fallbackToDefault: true }); + if (!agentId) { + runtime.error("Unable to resolve agent id."); + runtime.exit(1); + return; + } + if (!hasAgent(cfg, agentId)) { + runtime.error(`Agent "${agentId}" not found.`); + runtime.exit(1); + return; + } + if (opts.all && (opts.bind?.length ?? 0) > 0) { + runtime.error("Use either --all or --bind, not both."); + runtime.exit(1); + return; + } + + if (opts.all) { + const existing = cfg.bindings ?? []; + const removed = existing.filter((binding) => normalizeAgentId(binding.agentId) === agentId); + const kept = existing.filter((binding) => normalizeAgentId(binding.agentId) !== agentId); + if (removed.length === 0) { + runtime.log(`No bindings to remove for agent "${agentId}".`); + return; + } + const next = { + ...cfg, + bindings: kept.length > 0 ? kept : undefined, + }; + await writeConfigFile(next); + if (!opts.json) { + logConfigUpdated(runtime); + } + const payload = { + agentId, + removed: removed.map(describeBinding), + missing: [] as string[], + conflicts: [] as string[], + }; + if (opts.json) { + runtime.log(JSON.stringify(payload, null, 2)); + return; + } + runtime.log(`Removed ${removed.length} binding(s) for "${agentId}".`); + return; + } + + const specs = (opts.bind ?? []).map((value) => value.trim()).filter(Boolean); + if (specs.length === 0) { + runtime.error("Provide at least one --bind or use --all."); + runtime.exit(1); + return; + } + + const parsed = parseBindingSpecs({ agentId, specs, config: cfg }); + if (parsed.errors.length > 0) { + runtime.error(parsed.errors.join("\n")); + runtime.exit(1); + return; + } + + const result = removeAgentBindings(cfg, parsed.bindings); + if (result.removed.length > 0) { + await writeConfigFile(result.config); + if (!opts.json) { + logConfigUpdated(runtime); + } + } + + const payload = { + agentId, + removed: result.removed.map(describeBinding), + missing: result.missing.map(describeBinding), + conflicts: result.conflicts.map( + (conflict) => `${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`, + ), + }; + if (opts.json) { + runtime.log(JSON.stringify(payload, null, 2)); + if (result.conflicts.length > 0) { + runtime.exit(1); + } + return; + } + + if (result.removed.length > 0) { + runtime.log("Removed bindings:"); + for (const binding of result.removed) { + runtime.log(`- ${describeBinding(binding)}`); + } + } else { + runtime.log("No bindings removed."); + } + if (result.missing.length > 0) { + runtime.log("Not found:"); + for (const binding of result.missing) { + runtime.log(`- ${describeBinding(binding)}`); + } + } + if (result.conflicts.length > 0) { + runtime.error("Bindings are owned by another agent:"); + for (const conflict of result.conflicts) { + runtime.error(`- ${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`); + } + runtime.exit(1); + } +} diff --git a/src/commands/agents.test.ts b/src/commands/agents.test.ts index 1becb77548f..dfb339e4384 100644 --- a/src/commands/agents.test.ts +++ b/src/commands/agents.test.ts @@ -8,6 +8,7 @@ import { applyAgentConfig, buildAgentSummaries, pruneAgentConfig, + removeAgentBindings, } from "./agents.js"; describe("agents helpers", () => { @@ -111,6 +112,114 @@ describe("agents helpers", () => { expect(result.config.bindings).toHaveLength(2); }); + it("applyAgentBindings upgrades channel-only binding to account-specific binding for same agent", () => { + const cfg: OpenClawConfig = { + bindings: [ + { + agentId: "main", + match: { channel: "telegram" }, + }, + ], + }; + + const result = applyAgentBindings(cfg, [ + { + agentId: "main", + match: { channel: "telegram", accountId: "work" }, + }, + ]); + + expect(result.added).toHaveLength(0); + expect(result.updated).toHaveLength(1); + expect(result.conflicts).toHaveLength(0); + expect(result.config.bindings).toEqual([ + { + agentId: "main", + match: { channel: "telegram", accountId: "work" }, + }, + ]); + }); + + it("applyAgentBindings treats role-based bindings as distinct routes", () => { + const cfg: OpenClawConfig = { + bindings: [ + { + agentId: "main", + match: { + channel: "discord", + accountId: "guild-a", + guildId: "123", + roles: ["111", "222"], + }, + }, + ], + }; + + const result = applyAgentBindings(cfg, [ + { + agentId: "work", + match: { + channel: "discord", + accountId: "guild-a", + guildId: "123", + }, + }, + ]); + + expect(result.added).toHaveLength(1); + expect(result.conflicts).toHaveLength(0); + expect(result.config.bindings).toHaveLength(2); + }); + + it("removeAgentBindings does not remove role-based bindings when removing channel-level routes", () => { + const cfg: OpenClawConfig = { + bindings: [ + { + agentId: "main", + match: { + channel: "discord", + accountId: "guild-a", + guildId: "123", + roles: ["111", "222"], + }, + }, + { + agentId: "main", + match: { + channel: "discord", + accountId: "guild-a", + guildId: "123", + }, + }, + ], + }; + + const result = removeAgentBindings(cfg, [ + { + agentId: "main", + match: { + channel: "discord", + accountId: "guild-a", + guildId: "123", + }, + }, + ]); + + expect(result.removed).toHaveLength(1); + expect(result.conflicts).toHaveLength(0); + expect(result.config.bindings).toEqual([ + { + agentId: "main", + match: { + channel: "discord", + accountId: "guild-a", + guildId: "123", + roles: ["111", "222"], + }, + }, + ]); + }); + it("pruneAgentConfig removes agent, bindings, and allowlist entries", () => { const cfg: OpenClawConfig = { agents: { diff --git a/src/commands/agents.ts b/src/commands/agents.ts index 6679bb853da..5f5bdcd3c7b 100644 --- a/src/commands/agents.ts +++ b/src/commands/agents.ts @@ -1,4 +1,5 @@ export * from "./agents.bindings.js"; +export * from "./agents.commands.bind.js"; export * from "./agents.commands.add.js"; export * from "./agents.commands.delete.js"; export * from "./agents.commands.identity.js"; diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index a23fb2428e2..eaa6fc53397 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -8,6 +8,8 @@ import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { resolveTelegramAccount } from "../../telegram/accounts.js"; import { deleteTelegramUpdateOffset } from "../../telegram/update-offset-store.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; +import { applyAgentBindings, describeBinding } from "../agents.bindings.js"; +import { buildAgentSummaries } from "../agents.config.js"; import { setupChannels } from "../onboard-channels.js"; import type { ChannelChoice } from "../onboard-types.js"; import { @@ -111,6 +113,68 @@ export async function channelsAddCommand( } } + const bindTargets = selection + .map((channel) => ({ + channel, + accountId: accountIds[channel]?.trim(), + })) + .filter( + ( + value, + ): value is { + channel: ChannelChoice; + accountId: string; + } => Boolean(value.accountId), + ); + if (bindTargets.length > 0) { + const bindNow = await prompter.confirm({ + message: "Bind configured channel accounts to agents now?", + initialValue: true, + }); + if (bindNow) { + const agentSummaries = buildAgentSummaries(nextConfig); + const defaultAgentId = resolveDefaultAgentId(nextConfig); + for (const target of bindTargets) { + const targetAgentId = await prompter.select({ + message: `Route ${target.channel} account "${target.accountId}" to agent`, + options: agentSummaries.map((agent) => ({ + value: agent.id, + label: agent.isDefault ? `${agent.id} (default)` : agent.id, + })), + initialValue: defaultAgentId, + }); + const bindingResult = applyAgentBindings(nextConfig, [ + { + agentId: targetAgentId, + match: { channel: target.channel, accountId: target.accountId }, + }, + ]); + nextConfig = bindingResult.config; + if (bindingResult.added.length > 0 || bindingResult.updated.length > 0) { + await prompter.note( + [ + ...bindingResult.added.map((binding) => `Added: ${describeBinding(binding)}`), + ...bindingResult.updated.map((binding) => `Updated: ${describeBinding(binding)}`), + ].join("\n"), + "Routing bindings", + ); + } + if (bindingResult.conflicts.length > 0) { + await prompter.note( + [ + "Skipped bindings already claimed by another agent:", + ...bindingResult.conflicts.map( + (conflict) => + `- ${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`, + ), + ].join("\n"), + "Routing bindings", + ); + } + } + } + } + await writeConfigFile(nextConfig); await prompter.outro("Channels updated."); return; @@ -153,9 +217,6 @@ export async function channelsAddCommand( runtime.exit(1); return; } - const accountId = - plugin.setup.resolveAccountId?.({ cfg: nextConfig, accountId: opts.account }) ?? - normalizeAccountId(opts.account); const useEnv = opts.useEnv === true; const initialSyncLimit = typeof opts.initialSyncLimit === "number" @@ -199,6 +260,12 @@ export async function channelsAddCommand( dmAllowlist, autoDiscoverChannels: opts.autoDiscoverChannels, }; + const accountId = + plugin.setup.resolveAccountId?.({ + cfg: nextConfig, + accountId: opts.account, + input, + }) ?? normalizeAccountId(opts.account); const validationError = plugin.setup.validateInput?.({ cfg: nextConfig, From b9757114290f1939f07718b18727417cee3cfaa2 Mon Sep 17 00:00:00 2001 From: Frank Yang Date: Wed, 25 Feb 2026 23:40:48 -0800 Subject: [PATCH 20/35] fix(daemon): stabilize LaunchAgent restart and proxy env passthrough (#27276) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: b08797a99561f3d849443f77fda4fe086c508b49 Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + src/daemon/launchd-plist.ts | 4 +- src/daemon/launchd.integration.test.ts | 112 +++++++++++++++++++++++++ src/daemon/launchd.test.ts | 86 +++++++++++++++++++ src/daemon/launchd.ts | 70 +++++++++++++++- src/daemon/service-env.test.ts | 33 ++++++++ src/daemon/service-env.ts | 33 ++++++++ 7 files changed, 334 insertions(+), 5 deletions(-) create mode 100644 src/daemon/launchd.integration.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b890896f0d3..21e75f9ab6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Daemon/macOS launchd: forward proxy env vars into supervised service environments, switch LaunchAgent keepalive policy to crash-only with throttling, and harden restart sequencing to `print -> bootout -> wait old pid exit -> bootstrap -> kickstart`. (#27276) thanks @frankekn. - Android/Node invoke: remove native gateway WebSocket `Origin` header to avoid false origin rejections, unify invoke command registry/policy/error parsing paths, and keep command availability checks centralized to reduce dispatcher/advertisement drift. (#27257) Thanks @obviyus. - CI/Windows: shard the Windows `checks-windows` test lane into two matrix jobs and honor explicit shard index overrides in `scripts/test-parallel.mjs` to reduce CI critical-path wall time. (#27234) Thanks @joshavant. diff --git a/src/daemon/launchd-plist.ts b/src/daemon/launchd-plist.ts index e685cd9941c..7918e1c8a37 100644 --- a/src/daemon/launchd-plist.ts +++ b/src/daemon/launchd-plist.ts @@ -1,5 +1,7 @@ import fs from "node:fs/promises"; +const LAUNCHD_THROTTLE_INTERVAL_SECONDS = 5; + const plistEscape = (value: string): string => value .replaceAll("&", "&") @@ -106,5 +108,5 @@ export function buildLaunchAgentPlist({ ? `\n Comment\n ${plistEscape(comment.trim())}` : ""; const envXml = renderEnvDict(environment); - return `\n\n\n \n Label\n ${plistEscape(label)}\n ${commentXml}\n RunAtLoad\n \n KeepAlive\n \n ProgramArguments\n ${argsXml}\n \n ${workingDirXml}\n StandardOutPath\n ${plistEscape(stdoutPath)}\n StandardErrorPath\n ${plistEscape(stderrPath)}${envXml}\n \n\n`; + return `\n\n\n \n Label\n ${plistEscape(label)}\n ${commentXml}\n RunAtLoad\n \n KeepAlive\n \n SuccessfulExit\n \n \n ThrottleInterval\n ${LAUNCHD_THROTTLE_INTERVAL_SECONDS}\n ProgramArguments\n ${argsXml}\n \n ${workingDirXml}\n StandardOutPath\n ${plistEscape(stdoutPath)}\n StandardErrorPath\n ${plistEscape(stderrPath)}${envXml}\n \n\n`; } diff --git a/src/daemon/launchd.integration.test.ts b/src/daemon/launchd.integration.test.ts new file mode 100644 index 00000000000..423ab3fa1a1 --- /dev/null +++ b/src/daemon/launchd.integration.test.ts @@ -0,0 +1,112 @@ +import { spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { PassThrough } from "node:stream"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + installLaunchAgent, + readLaunchAgentRuntime, + restartLaunchAgent, + resolveLaunchAgentPlistPath, + uninstallLaunchAgent, +} from "./launchd.js"; +import type { GatewayServiceEnv } from "./service-types.js"; + +const WAIT_INTERVAL_MS = 200; +const WAIT_TIMEOUT_MS = 15_000; + +function canRunLaunchdIntegration(): boolean { + if (process.platform !== "darwin") { + return false; + } + if (typeof process.getuid !== "function") { + return false; + } + const domain = `gui/${process.getuid()}`; + const probe = spawnSync("launchctl", ["print", domain], { encoding: "utf8" }); + if (probe.error) { + return false; + } + return probe.status === 0; +} + +const describeLaunchdIntegration = canRunLaunchdIntegration() ? describe : describe.skip; + +async function waitForRunningRuntime(params: { + env: GatewayServiceEnv; + pidNot?: number; + timeoutMs?: number; +}): Promise<{ pid: number }> { + const timeoutMs = params.timeoutMs ?? WAIT_TIMEOUT_MS; + const deadline = Date.now() + timeoutMs; + let lastStatus = "unknown"; + let lastPid: number | undefined; + while (Date.now() < deadline) { + const runtime = await readLaunchAgentRuntime(params.env); + lastStatus = runtime.status; + lastPid = runtime.pid; + if ( + runtime.status === "running" && + typeof runtime.pid === "number" && + runtime.pid > 1 && + (params.pidNot === undefined || runtime.pid !== params.pidNot) + ) { + return { pid: runtime.pid }; + } + await new Promise((resolve) => { + setTimeout(resolve, WAIT_INTERVAL_MS); + }); + } + throw new Error( + `Timed out waiting for launchd runtime (status=${lastStatus}, pid=${lastPid ?? "none"})`, + ); +} + +describeLaunchdIntegration("launchd integration", () => { + let env: GatewayServiceEnv | undefined; + let homeDir = ""; + const stdout = new PassThrough(); + + beforeAll(async () => { + const testId = randomUUID().slice(0, 8); + homeDir = await fs.mkdtemp(path.join(os.tmpdir(), `openclaw-launchd-int-${testId}-`)); + env = { + HOME: homeDir, + OPENCLAW_LAUNCHD_LABEL: `ai.openclaw.launchd-int-${testId}`, + OPENCLAW_LOG_PREFIX: `gateway-launchd-int-${testId}`, + }; + await installLaunchAgent({ + env, + stdout, + programArguments: [process.execPath, "-e", "setInterval(() => {}, 1000);"], + }); + await waitForRunningRuntime({ env }); + }, 30_000); + + afterAll(async () => { + if (env) { + try { + await uninstallLaunchAgent({ env, stdout }); + } catch { + // Best-effort cleanup in case launchctl state already changed. + } + } + if (homeDir) { + await fs.rm(homeDir, { recursive: true, force: true }); + } + }, 30_000); + + it("restarts launchd service and keeps it running with a new pid", async () => { + if (!env) { + throw new Error("launchd integration env was not initialized"); + } + const before = await waitForRunningRuntime({ env }); + await restartLaunchAgent({ env, stdout }); + const after = await waitForRunningRuntime({ env, pidNot: before.pid }); + expect(after.pid).toBeGreaterThan(1); + expect(after.pid).not.toBe(before.pid); + await fs.access(resolveLaunchAgentPlistPath(env)); + }, 30_000); +}); diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index b68774cb19f..7465666a158 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -5,12 +5,14 @@ import { isLaunchAgentListed, parseLaunchctlPrint, repairLaunchAgentBootstrap, + restartLaunchAgent, resolveLaunchAgentPlistPath, } from "./launchd.js"; const state = vi.hoisted(() => ({ launchctlCalls: [] as string[][], listOutput: "", + printOutput: "", bootstrapError: "", dirs: new Set(), files: new Map(), @@ -35,6 +37,9 @@ vi.mock("./exec-file.js", () => ({ if (call[0] === "list") { return { stdout: state.listOutput, stderr: "", code: 0 }; } + if (call[0] === "print") { + return { stdout: state.printOutput, stderr: "", code: 0 }; + } if (call[0] === "bootstrap" && state.bootstrapError) { return { stdout: "", stderr: state.bootstrapError, code: 1 }; } @@ -71,6 +76,7 @@ vi.mock("node:fs/promises", async (importOriginal) => { beforeEach(() => { state.launchctlCalls.length = 0; state.listOutput = ""; + state.printOutput = ""; state.bootstrapError = ""; state.dirs.clear(); state.files.clear(); @@ -179,6 +185,86 @@ describe("launchd install", () => { expect(plist).toContain(`${tmpDir}`); }); + it("writes crash-only KeepAlive policy with throttle interval", async () => { + const env = createDefaultLaunchdEnv(); + await installLaunchAgent({ + env, + stdout: new PassThrough(), + programArguments: defaultProgramArguments, + }); + + const plistPath = resolveLaunchAgentPlistPath(env); + const plist = state.files.get(plistPath) ?? ""; + expect(plist).toContain("KeepAlive"); + expect(plist).toContain("SuccessfulExit"); + expect(plist).toContain(""); + expect(plist).toContain("ThrottleInterval"); + expect(plist).toContain("5"); + }); + + it("restarts LaunchAgent with bootout-bootstrap-kickstart order", async () => { + const env = createDefaultLaunchdEnv(); + await restartLaunchAgent({ + env, + stdout: new PassThrough(), + }); + + const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; + const label = "ai.openclaw.gateway"; + const plistPath = resolveLaunchAgentPlistPath(env); + const bootoutIndex = state.launchctlCalls.findIndex( + (c) => c[0] === "bootout" && c[1] === `${domain}/${label}`, + ); + const bootstrapIndex = state.launchctlCalls.findIndex( + (c) => c[0] === "bootstrap" && c[1] === domain && c[2] === plistPath, + ); + const kickstartIndex = state.launchctlCalls.findIndex( + (c) => c[0] === "kickstart" && c[1] === "-k" && c[2] === `${domain}/${label}`, + ); + + expect(bootoutIndex).toBeGreaterThanOrEqual(0); + expect(bootstrapIndex).toBeGreaterThanOrEqual(0); + expect(kickstartIndex).toBeGreaterThanOrEqual(0); + expect(bootoutIndex).toBeLessThan(bootstrapIndex); + expect(bootstrapIndex).toBeLessThan(kickstartIndex); + }); + + it("waits for previous launchd pid to exit before bootstrapping", async () => { + const env = createDefaultLaunchdEnv(); + state.printOutput = ["state = running", "pid = 4242"].join("\n"); + const killSpy = vi.spyOn(process, "kill"); + killSpy + .mockImplementationOnce(() => true) + .mockImplementationOnce(() => { + const err = new Error("no such process") as NodeJS.ErrnoException; + err.code = "ESRCH"; + throw err; + }); + + vi.useFakeTimers(); + try { + const restartPromise = restartLaunchAgent({ + env, + stdout: new PassThrough(), + }); + await vi.advanceTimersByTimeAsync(250); + await restartPromise; + expect(killSpy).toHaveBeenCalledWith(4242, 0); + const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; + const label = "ai.openclaw.gateway"; + const bootoutIndex = state.launchctlCalls.findIndex( + (c) => c[0] === "bootout" && c[1] === `${domain}/${label}`, + ); + const bootstrapIndex = state.launchctlCalls.findIndex((c) => c[0] === "bootstrap"); + expect(bootoutIndex).toBeGreaterThanOrEqual(0); + expect(bootstrapIndex).toBeGreaterThanOrEqual(0); + expect(bootoutIndex).toBeLessThan(bootstrapIndex); + } finally { + vi.useRealTimers(); + killSpy.mockRestore(); + } + }); + it("shows actionable guidance when launchctl gui domain does not support bootstrap", async () => { state.bootstrapError = "Bootstrap failed: 125: Domain does not support specified action"; const env = createDefaultLaunchdEnv(); diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index dded364858b..5326413b73d 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -331,6 +331,34 @@ function isUnsupportedGuiDomain(detail: string): boolean { ); } +const RESTART_PID_WAIT_TIMEOUT_MS = 10_000; +const RESTART_PID_WAIT_INTERVAL_MS = 200; + +async function sleepMs(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function waitForPidExit(pid: number): Promise { + if (!Number.isFinite(pid) || pid <= 1) { + return; + } + const deadline = Date.now() + RESTART_PID_WAIT_TIMEOUT_MS; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ESRCH" || code === "EPERM") { + return; + } + return; + } + await sleepMs(RESTART_PID_WAIT_INTERVAL_MS); + } +} + export async function stopLaunchAgent({ stdout, env }: GatewayServiceControlArgs): Promise { const domain = resolveGuiDomain(); const label = resolveLaunchAgentLabel({ env }); @@ -418,11 +446,45 @@ export async function restartLaunchAgent({ stdout, env, }: GatewayServiceControlArgs): Promise { + const serviceEnv = env ?? (process.env as GatewayServiceEnv); const domain = resolveGuiDomain(); - const label = resolveLaunchAgentLabel({ env }); - const res = await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]); - if (res.code !== 0) { - throw new Error(`launchctl kickstart failed: ${res.stderr || res.stdout}`.trim()); + const label = resolveLaunchAgentLabel({ env: serviceEnv }); + const plistPath = resolveLaunchAgentPlistPath(serviceEnv); + + const runtime = await execLaunchctl(["print", `${domain}/${label}`]); + const previousPid = + runtime.code === 0 + ? parseLaunchctlPrint(runtime.stdout || runtime.stderr || "").pid + : undefined; + + const stop = await execLaunchctl(["bootout", `${domain}/${label}`]); + if (stop.code !== 0 && !isLaunchctlNotLoaded(stop)) { + throw new Error(`launchctl bootout failed: ${stop.stderr || stop.stdout}`.trim()); + } + if (typeof previousPid === "number") { + await waitForPidExit(previousPid); + } + + const boot = await execLaunchctl(["bootstrap", domain, plistPath]); + if (boot.code !== 0) { + const detail = (boot.stderr || boot.stdout).trim(); + if (isUnsupportedGuiDomain(detail)) { + throw new Error( + [ + `launchctl bootstrap failed: ${detail}`, + `LaunchAgent restart requires a logged-in macOS GUI session for this user (${domain}).`, + "This usually means you are running from SSH/headless context or as the wrong user (including sudo).", + "Fix: sign in to the macOS desktop as the target user and rerun `openclaw gateway restart`.", + "Headless deployments should use a dedicated logged-in user session or a custom LaunchDaemon (not shipped): https://docs.openclaw.ai/gateway", + ].join("\n"), + ); + } + throw new Error(`launchctl bootstrap failed: ${detail}`); + } + + const start = await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]); + if (start.code !== 0) { + throw new Error(`launchctl kickstart failed: ${start.stderr || start.stdout}`.trim()); } try { stdout.write(`${formatLine("Restarted LaunchAgent", `${domain}/${label}`)}\n`); diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index 31a46c49909..2cfa4cce1de 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -309,6 +309,26 @@ describe("buildServiceEnvironment", () => { expect(env.OPENCLAW_LAUNCHD_LABEL).toBe("ai.openclaw.work"); } }); + + it("forwards proxy environment variables for launchd/systemd runtime", () => { + const env = buildServiceEnvironment({ + env: { + HOME: "/home/user", + HTTP_PROXY: " http://proxy.local:7890 ", + HTTPS_PROXY: "https://proxy.local:7890", + NO_PROXY: "localhost,127.0.0.1", + http_proxy: "http://proxy.local:7890", + all_proxy: "socks5://proxy.local:1080", + }, + port: 18789, + }); + + expect(env.HTTP_PROXY).toBe("http://proxy.local:7890"); + expect(env.HTTPS_PROXY).toBe("https://proxy.local:7890"); + expect(env.NO_PROXY).toBe("localhost,127.0.0.1"); + expect(env.http_proxy).toBe("http://proxy.local:7890"); + expect(env.all_proxy).toBe("socks5://proxy.local:1080"); + }); }); describe("buildNodeServiceEnvironment", () => { @@ -319,6 +339,19 @@ describe("buildNodeServiceEnvironment", () => { expect(env.HOME).toBe("/home/user"); }); + it("forwards proxy environment variables for node services", () => { + const env = buildNodeServiceEnvironment({ + env: { + HOME: "/home/user", + HTTPS_PROXY: " https://proxy.local:7890 ", + no_proxy: "localhost,127.0.0.1", + }, + }); + + expect(env.HTTPS_PROXY).toBe("https://proxy.local:7890"); + expect(env.no_proxy).toBe("localhost,127.0.0.1"); + }); + it("forwards TMPDIR for node services", () => { const env = buildNodeServiceEnvironment({ env: { HOME: "/home/user", TMPDIR: "/tmp/custom" }, diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index 4925a337611..458ca515c1d 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -25,6 +25,35 @@ type BuildServicePathOptions = MinimalServicePathOptions & { env?: Record; }; +const SERVICE_PROXY_ENV_KEYS = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + "all_proxy", +] as const; + +function readServiceProxyEnvironment( + env: Record, +): Record { + const out: Record = {}; + for (const key of SERVICE_PROXY_ENV_KEYS) { + const value = env[key]; + if (typeof value !== "string") { + continue; + } + const trimmed = value.trim(); + if (!trimmed) { + continue; + } + out[key] = trimmed; + } + return out; +} + function addNonEmptyDir(dirs: string[], dir: string | undefined): void { if (dir) { dirs.push(dir); @@ -218,10 +247,12 @@ export function buildServiceEnvironment(params: { const configPath = env.OPENCLAW_CONFIG_PATH; // Keep a usable temp directory for supervised services even when the host env omits TMPDIR. const tmpDir = env.TMPDIR?.trim() || os.tmpdir(); + const proxyEnv = readServiceProxyEnvironment(env); return { HOME: env.HOME, TMPDIR: tmpDir, PATH: buildMinimalServicePath({ env }), + ...proxyEnv, OPENCLAW_PROFILE: profile, OPENCLAW_STATE_DIR: stateDir, OPENCLAW_CONFIG_PATH: configPath, @@ -242,10 +273,12 @@ export function buildNodeServiceEnvironment(params: { const stateDir = env.OPENCLAW_STATE_DIR; const configPath = env.OPENCLAW_CONFIG_PATH; const tmpDir = env.TMPDIR?.trim() || os.tmpdir(); + const proxyEnv = readServiceProxyEnvironment(env); return { HOME: env.HOME, TMPDIR: tmpDir, PATH: buildMinimalServicePath({ env }), + ...proxyEnv, OPENCLAW_STATE_DIR: stateDir, OPENCLAW_CONFIG_PATH: configPath, OPENCLAW_LAUNCHD_LABEL: resolveNodeLaunchAgentLabel(), From 4ebefe647a7c1800a27acf8359734504de724fbe Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 26 Feb 2026 02:52:00 -0500 Subject: [PATCH 21/35] fix(daemon): keep launchd KeepAlive while preserving restart hardening --- CHANGELOG.md | 2 +- src/daemon/launchd-plist.ts | 4 +--- src/daemon/launchd.test.ts | 9 ++++----- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21e75f9ab6d..90f019acc78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes -- Daemon/macOS launchd: forward proxy env vars into supervised service environments, switch LaunchAgent keepalive policy to crash-only with throttling, and harden restart sequencing to `print -> bootout -> wait old pid exit -> bootstrap -> kickstart`. (#27276) thanks @frankekn. +- Daemon/macOS launchd: forward proxy env vars into supervised service environments, keep LaunchAgent `KeepAlive=true` semantics, and harden restart sequencing to `print -> bootout -> wait old pid exit -> bootstrap -> kickstart`. (#27276) thanks @frankekn. - Android/Node invoke: remove native gateway WebSocket `Origin` header to avoid false origin rejections, unify invoke command registry/policy/error parsing paths, and keep command availability checks centralized to reduce dispatcher/advertisement drift. (#27257) Thanks @obviyus. - CI/Windows: shard the Windows `checks-windows` test lane into two matrix jobs and honor explicit shard index overrides in `scripts/test-parallel.mjs` to reduce CI critical-path wall time. (#27234) Thanks @joshavant. diff --git a/src/daemon/launchd-plist.ts b/src/daemon/launchd-plist.ts index 7918e1c8a37..e685cd9941c 100644 --- a/src/daemon/launchd-plist.ts +++ b/src/daemon/launchd-plist.ts @@ -1,7 +1,5 @@ import fs from "node:fs/promises"; -const LAUNCHD_THROTTLE_INTERVAL_SECONDS = 5; - const plistEscape = (value: string): string => value .replaceAll("&", "&") @@ -108,5 +106,5 @@ export function buildLaunchAgentPlist({ ? `\n Comment\n ${plistEscape(comment.trim())}` : ""; const envXml = renderEnvDict(environment); - return `\n\n\n \n Label\n ${plistEscape(label)}\n ${commentXml}\n RunAtLoad\n \n KeepAlive\n \n SuccessfulExit\n \n \n ThrottleInterval\n ${LAUNCHD_THROTTLE_INTERVAL_SECONDS}\n ProgramArguments\n ${argsXml}\n \n ${workingDirXml}\n StandardOutPath\n ${plistEscape(stdoutPath)}\n StandardErrorPath\n ${plistEscape(stderrPath)}${envXml}\n \n\n`; + return `\n\n\n \n Label\n ${plistEscape(label)}\n ${commentXml}\n RunAtLoad\n \n KeepAlive\n \n ProgramArguments\n ${argsXml}\n \n ${workingDirXml}\n StandardOutPath\n ${plistEscape(stdoutPath)}\n StandardErrorPath\n ${plistEscape(stderrPath)}${envXml}\n \n\n`; } diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 7465666a158..ac092536c5a 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -185,7 +185,7 @@ describe("launchd install", () => { expect(plist).toContain(`${tmpDir}`); }); - it("writes crash-only KeepAlive policy with throttle interval", async () => { + it("writes KeepAlive=true policy", async () => { const env = createDefaultLaunchdEnv(); await installLaunchAgent({ env, @@ -196,10 +196,9 @@ describe("launchd install", () => { const plistPath = resolveLaunchAgentPlistPath(env); const plist = state.files.get(plistPath) ?? ""; expect(plist).toContain("KeepAlive"); - expect(plist).toContain("SuccessfulExit"); - expect(plist).toContain(""); - expect(plist).toContain("ThrottleInterval"); - expect(plist).toContain("5"); + expect(plist).toContain(""); + expect(plist).not.toContain("SuccessfulExit"); + expect(plist).not.toContain("ThrottleInterval"); }); it("restarts LaunchAgent with bootout-bootstrap-kickstart order", async () => { From 39d725f4d3e2dcc4894142433e5999142864bbc5 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 26 Feb 2026 03:24:48 -0500 Subject: [PATCH 22/35] Daemon tests: guard undefined runtime status --- src/daemon/launchd.integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/daemon/launchd.integration.test.ts b/src/daemon/launchd.integration.test.ts index 423ab3fa1a1..7afa30ac4bd 100644 --- a/src/daemon/launchd.integration.test.ts +++ b/src/daemon/launchd.integration.test.ts @@ -45,7 +45,7 @@ async function waitForRunningRuntime(params: { let lastPid: number | undefined; while (Date.now() < deadline) { const runtime = await readLaunchAgentRuntime(params.env); - lastStatus = runtime.status; + lastStatus = runtime.status ?? "unknown"; lastPid = runtime.pid; if ( runtime.status === "running" && From 92c309f2e171331a6945c2bc1ff1c019b5c5285b Mon Sep 17 00:00:00 2001 From: yinghaosang Date: Thu, 26 Feb 2026 12:07:29 +0800 Subject: [PATCH 23/35] docs: fix wrong Providers link in configuration examples --- docs/gateway/configuration-examples.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md index abc010ce8fe..0639dc36e92 100644 --- a/docs/gateway/configuration-examples.md +++ b/docs/gateway/configuration-examples.md @@ -628,4 +628,4 @@ Only enable direct mutable name/email/nick matching with each channel's `dangero - If you set `dmPolicy: "open"`, the matching `allowFrom` list must include `"*"`. - Provider IDs differ (phone numbers, user IDs, channel IDs). Use the provider docs to confirm the format. - Optional sections to add later: `web`, `browser`, `ui`, `discovery`, `canvasHost`, `talk`, `signal`, `imessage`. -- See [Providers](/channels/whatsapp) and [Troubleshooting](/gateway/troubleshooting) for deeper setup notes. +- See [Providers](/providers) and [Troubleshooting](/gateway/troubleshooting) for deeper setup notes. From c289b5ff9f15de7e58174aec34a5af05ac9ceae9 Mon Sep 17 00:00:00 2001 From: Sid Date: Thu, 26 Feb 2026 16:46:36 +0800 Subject: [PATCH 24/35] fix(config): preserve agent-level apiKey/baseUrl during models.json merge (#27293) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 6b4b37b03d74e40e14afc2c55fef24a1e59fb0b3 Co-authored-by: Sid-Qin <201593046+Sid-Qin@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + docs/concepts/models.md | 6 + docs/gateway/configuration-reference.md | 4 + ...ssing-provider-apikey-from-env-var.test.ts | 110 ++++++++++++++++++ src/agents/models-config.ts | 25 +++- src/config/schema.help.ts | 2 +- 6 files changed, 146 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90f019acc78..2e4a3c5c17b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - Daemon/macOS launchd: forward proxy env vars into supervised service environments, keep LaunchAgent `KeepAlive=true` semantics, and harden restart sequencing to `print -> bootout -> wait old pid exit -> bootstrap -> kickstart`. (#27276) thanks @frankekn. - Android/Node invoke: remove native gateway WebSocket `Origin` header to avoid false origin rejections, unify invoke command registry/policy/error parsing paths, and keep command availability checks centralized to reduce dispatcher/advertisement drift. (#27257) Thanks @obviyus. - CI/Windows: shard the Windows `checks-windows` test lane into two matrix jobs and honor explicit shard index overrides in `scripts/test-parallel.mjs` to reduce CI critical-path wall time. (#27234) Thanks @joshavant. +- Agents/Models config: preserve agent-level provider `apiKey` and `baseUrl` during merge-mode `models.json` updates when agent values are present. (#27293) thanks @Sid-Qin. ## 2026.2.25 diff --git a/docs/concepts/models.md b/docs/concepts/models.md index ee8f06ecb3d..b4317273d5c 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -207,3 +207,9 @@ mode, pass `--yes` to accept defaults. Custom providers in `models.providers` are written into `models.json` under the agent directory (default `~/.openclaw/agents//models.json`). This file is merged by default unless `models.mode` is set to `replace`. + +Merge mode precedence for matching provider IDs: + +- Non-empty `apiKey`/`baseUrl` already present in the agent `models.json` win. +- Empty or missing agent `apiKey`/`baseUrl` fall back to config `models.providers`. +- Other provider fields are refreshed from config and normalized catalog data. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 8d147b23fd7..c548fc973a5 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1741,6 +1741,10 @@ OpenClaw uses the pi-coding-agent model catalog. Add custom providers via `model - Use `authHeader: true` + `headers` for custom auth needs. - Override agent config root with `OPENCLAW_AGENT_DIR` (or `PI_CODING_AGENT_DIR`). +- Merge precedence for matching provider IDs: + - Non-empty agent `models.json` `apiKey`/`baseUrl` win. + - Empty or missing agent `apiKey`/`baseUrl` fall back to `models.providers` in config. + - Use `models.mode: "replace"` when you want config to fully rewrite `models.json`. ### Provider examples diff --git a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts index c26142158e8..4abfa4f1ab4 100644 --- a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts +++ b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts @@ -134,6 +134,116 @@ describe("models-config", () => { }); }); + it("preserves non-empty agent apiKey/baseUrl for matching providers in merge mode", async () => { + await withTempHome(async () => { + const agentDir = resolveOpenClawAgentDir(); + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile( + path.join(agentDir, "models.json"), + JSON.stringify( + { + providers: { + custom: { + baseUrl: "https://agent.example/v1", + apiKey: "AGENT_KEY", + api: "openai-responses", + models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + await ensureOpenClawModelsJson({ + models: { + mode: "merge", + providers: { + custom: { + baseUrl: "https://config.example/v1", + apiKey: "CONFIG_KEY", + api: "openai-responses", + models: [ + { + id: "config-model", + name: "Config model", + input: ["text"], + reasoning: false, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8192, + maxTokens: 2048, + }, + ], + }, + }, + }, + }); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.custom?.apiKey).toBe("AGENT_KEY"); + expect(parsed.providers.custom?.baseUrl).toBe("https://agent.example/v1"); + }); + }); + + it("uses config apiKey/baseUrl when existing agent values are empty", async () => { + await withTempHome(async () => { + const agentDir = resolveOpenClawAgentDir(); + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile( + path.join(agentDir, "models.json"), + JSON.stringify( + { + providers: { + custom: { + baseUrl: "", + apiKey: "", + api: "openai-responses", + models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + await ensureOpenClawModelsJson({ + models: { + mode: "merge", + providers: { + custom: { + baseUrl: "https://config.example/v1", + apiKey: "CONFIG_KEY", + api: "openai-responses", + models: [ + { + id: "config-model", + name: "Config model", + input: ["text"], + reasoning: false, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8192, + maxTokens: 2048, + }, + ], + }, + }, + }, + }); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.custom?.apiKey).toBe("CONFIG_KEY"); + expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1"); + }); + }); + it("refreshes stale explicit moonshot model capabilities from implicit catalog", async () => { await withTempHome(async () => { const prevKey = process.env.MOONSHOT_API_KEY; diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 4b38b824398..3b02737eb4c 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -142,7 +142,30 @@ export async function ensureOpenClawModelsJson( string, NonNullable[string] >; - mergedProviders = { ...existingProviders, ...providers }; + mergedProviders = {}; + for (const [key, entry] of Object.entries(existingProviders)) { + mergedProviders[key] = entry; + } + for (const [key, newEntry] of Object.entries(providers)) { + const existing = existingProviders[key] as + | (NonNullable[string] & { + apiKey?: string; + baseUrl?: string; + }) + | undefined; + if (existing) { + const preserved: Record = {}; + if (typeof existing.apiKey === "string" && existing.apiKey) { + preserved.apiKey = existing.apiKey; + } + if (typeof existing.baseUrl === "string" && existing.baseUrl) { + preserved.baseUrl = existing.baseUrl; + } + mergedProviders[key] = { ...newEntry, ...preserved }; + } else { + mergedProviders[key] = newEntry; + } + } } } diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index f32433e1333..c16e25df84a 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -608,7 +608,7 @@ export const FIELD_HELP: Record = { models: "Model catalog root for provider definitions, merge/replace behavior, and optional Bedrock discovery integration. Keep provider definitions explicit and validated before relying on production failover paths.", "models.mode": - 'Controls provider catalog behavior: "merge" keeps built-ins and overlays your custom providers, while "replace" uses only your configured providers. Keep "merge" unless you intentionally want a strict custom list.', + 'Controls provider catalog behavior: "merge" keeps built-ins and overlays your custom providers, while "replace" uses only your configured providers. In "merge", matching provider IDs preserve non-empty agent models.json apiKey/baseUrl values and fall back to config when agent values are empty or missing.', "models.providers": "Provider map keyed by provider ID containing connection/auth settings and concrete model definitions. Use stable provider keys so references from agents and tooling remain portable across environments.", "models.providers.*.baseUrl": From cf4fe4195767f7bff4b4af73a8a918f9dc187ffa Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 14:02:39 +0530 Subject: [PATCH 25/35] feat(android): add notifications.list node command --- apps/android/app/src/main/AndroidManifest.xml | 9 + .../java/ai/openclaw/android/NodeRuntime.kt | 5 + .../node/DeviceNotificationListenerService.kt | 171 ++++++++++++++++++ .../android/node/InvokeCommandRegistry.kt | 4 + .../openclaw/android/node/InvokeDispatcher.kt | 5 + .../android/node/NotificationsHandler.kt | 57 ++++++ .../protocol/OpenClawProtocolConstants.kt | 9 + .../android/node/InvokeCommandRegistryTest.kt | 3 + .../protocol/OpenClawProtocolConstantsTest.kt | 5 + 9 files changed, 268 insertions(+) create mode 100644 apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt create mode 100644 apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 6b8dd7eedba..3d0b27f39e6 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -38,6 +38,15 @@ android:name=".NodeForegroundService" android:exported="false" android:foregroundServiceType="dataSync|microphone|mediaProjection" /> + + + + + , +) + +private object DeviceNotificationStore { + private val lock = Any() + private var connected = false + private val byKey = LinkedHashMap() + + fun replace(entries: List) { + synchronized(lock) { + byKey.clear() + for (entry in entries) { + byKey[entry.key] = entry + } + } + } + + fun upsert(entry: DeviceNotificationEntry) { + synchronized(lock) { + byKey[entry.key] = entry + } + } + + fun remove(key: String) { + synchronized(lock) { + byKey.remove(key) + } + } + + fun setConnected(value: Boolean) { + synchronized(lock) { + connected = value + if (!value) { + byKey.clear() + } + } + } + + fun snapshot(enabled: Boolean): DeviceNotificationSnapshot { + val (isConnected, entries) = + synchronized(lock) { + connected to byKey.values.sortedByDescending { it.postTimeMs } + } + return DeviceNotificationSnapshot( + enabled = enabled, + connected = isConnected, + notifications = entries, + ) + } +} + +class DeviceNotificationListenerService : NotificationListenerService() { + override fun onListenerConnected() { + super.onListenerConnected() + DeviceNotificationStore.setConnected(true) + refreshActiveNotifications() + } + + override fun onListenerDisconnected() { + DeviceNotificationStore.setConnected(false) + super.onListenerDisconnected() + } + + override fun onNotificationPosted(sbn: StatusBarNotification?) { + super.onNotificationPosted(sbn) + val entry = sbn?.toEntry() ?: return + DeviceNotificationStore.upsert(entry) + } + + override fun onNotificationRemoved(sbn: StatusBarNotification?) { + super.onNotificationRemoved(sbn) + val key = sbn?.key ?: return + DeviceNotificationStore.remove(key) + } + + private fun refreshActiveNotifications() { + val entries = + runCatching { + activeNotifications + ?.mapNotNull { it.toEntry() } + ?: emptyList() + }.getOrElse { emptyList() } + DeviceNotificationStore.replace(entries) + } + + private fun StatusBarNotification.toEntry(): DeviceNotificationEntry { + val extras = notification.extras + val keyValue = key.takeIf { it.isNotBlank() } ?: "$packageName:$id:$postTime" + val title = sanitizeText(extras?.getCharSequence(Notification.EXTRA_TITLE)) + val body = + sanitizeText(extras?.getCharSequence(Notification.EXTRA_BIG_TEXT)) + ?: sanitizeText(extras?.getCharSequence(Notification.EXTRA_TEXT)) + val subText = sanitizeText(extras?.getCharSequence(Notification.EXTRA_SUB_TEXT)) + return DeviceNotificationEntry( + key = keyValue, + packageName = packageName, + title = title, + text = body, + subText = subText, + category = notification.category?.trim()?.ifEmpty { null }, + channelId = notification.channelId?.trim()?.ifEmpty { null }, + postTimeMs = postTime, + isOngoing = isOngoing, + isClearable = isClearable, + ) + } + + private fun sanitizeText(value: CharSequence?): String? { + val normalized = value?.toString()?.trim().orEmpty() + if (normalized.isEmpty()) { + return null + } + return if (normalized.length <= MAX_NOTIFICATION_TEXT_CHARS) { + normalized + } else { + normalized.take(MAX_NOTIFICATION_TEXT_CHARS) + } + } + + companion object { + private fun serviceComponent(context: Context): ComponentName { + return ComponentName(context, DeviceNotificationListenerService::class.java) + } + + fun isAccessEnabled(context: Context): Boolean { + val manager = context.getSystemService(NotificationManager::class.java) ?: return false + return manager.isNotificationListenerAccessGranted(serviceComponent(context)) + } + + fun snapshot(context: Context): DeviceNotificationSnapshot { + return DeviceNotificationStore.snapshot(enabled = isAccessEnabled(context)) + } + + fun requestServiceRebind(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return + } + runCatching { + NotificationListenerService.requestRebind(serviceComponent(context)) + } + } + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeCommandRegistry.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeCommandRegistry.kt index 812ecf2ba4e..ce87525904f 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeCommandRegistry.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeCommandRegistry.kt @@ -4,6 +4,7 @@ import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand import ai.openclaw.android.protocol.OpenClawCanvasCommand import ai.openclaw.android.protocol.OpenClawCameraCommand import ai.openclaw.android.protocol.OpenClawLocationCommand +import ai.openclaw.android.protocol.OpenClawNotificationsCommand import ai.openclaw.android.protocol.OpenClawScreenCommand import ai.openclaw.android.protocol.OpenClawSmsCommand @@ -74,6 +75,9 @@ object InvokeCommandRegistry { name = OpenClawLocationCommand.Get.rawValue, availability = InvokeCommandAvailability.LocationEnabled, ), + InvokeCommandSpec( + name = OpenClawNotificationsCommand.List.rawValue, + ), InvokeCommandSpec( name = OpenClawSmsCommand.Send.rawValue, availability = InvokeCommandAvailability.SmsAvailable, diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt index d293df76668..936ad7b3d11 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt @@ -5,6 +5,7 @@ import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand import ai.openclaw.android.protocol.OpenClawCanvasCommand import ai.openclaw.android.protocol.OpenClawCameraCommand import ai.openclaw.android.protocol.OpenClawLocationCommand +import ai.openclaw.android.protocol.OpenClawNotificationsCommand import ai.openclaw.android.protocol.OpenClawScreenCommand import ai.openclaw.android.protocol.OpenClawSmsCommand @@ -12,6 +13,7 @@ class InvokeDispatcher( private val canvas: CanvasController, private val cameraHandler: CameraHandler, private val locationHandler: LocationHandler, + private val notificationsHandler: NotificationsHandler, private val screenHandler: ScreenHandler, private val smsHandler: SmsHandler, private val a2uiHandler: A2UIHandler, @@ -114,6 +116,9 @@ class InvokeDispatcher( // Location command OpenClawLocationCommand.Get.rawValue -> locationHandler.handleLocationGet(paramsJson) + // Notifications command + OpenClawNotificationsCommand.List.rawValue -> notificationsHandler.handleNotificationsList(paramsJson) + // Screen command OpenClawScreenCommand.Record.rawValue -> screenHandler.handleScreenRecord(paramsJson) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt new file mode 100644 index 00000000000..17123d93674 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt @@ -0,0 +1,57 @@ +package ai.openclaw.android.node + +import android.content.Context +import ai.openclaw.android.gateway.GatewaySession +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +class NotificationsHandler( + private val appContext: Context, +) { + suspend fun handleNotificationsList(_paramsJson: String?): GatewaySession.InvokeResult { + if (!DeviceNotificationListenerService.isAccessEnabled(appContext)) { + return GatewaySession.InvokeResult.error( + code = "NOTIFICATION_LISTENER_DISABLED", + message = + "NOTIFICATION_LISTENER_DISABLED: enable Notification Access for OpenClaw in system settings", + ) + } + val snapshot = DeviceNotificationListenerService.snapshot(appContext) + if (!snapshot.connected) { + DeviceNotificationListenerService.requestServiceRebind(appContext) + return GatewaySession.InvokeResult.error( + code = "NOTIFICATION_LISTENER_UNAVAILABLE", + message = "NOTIFICATION_LISTENER_UNAVAILABLE: listener is reconnecting; retry shortly", + ) + } + + val payload = + buildJsonObject { + put("enabled", JsonPrimitive(snapshot.enabled)) + put("connected", JsonPrimitive(snapshot.connected)) + put("count", JsonPrimitive(snapshot.notifications.size)) + put( + "notifications", + JsonArray( + snapshot.notifications.map { entry -> + buildJsonObject { + put("key", JsonPrimitive(entry.key)) + put("packageName", JsonPrimitive(entry.packageName)) + put("postTimeMs", JsonPrimitive(entry.postTimeMs)) + put("isOngoing", JsonPrimitive(entry.isOngoing)) + put("isClearable", JsonPrimitive(entry.isClearable)) + entry.title?.let { put("title", JsonPrimitive(it)) } + entry.text?.let { put("text", JsonPrimitive(it)) } + entry.subText?.let { put("subText", JsonPrimitive(it)) } + entry.category?.let { put("category", JsonPrimitive(it)) } + entry.channelId?.let { put("channelId", JsonPrimitive(it)) } + } + }, + ), + ) + } + return GatewaySession.InvokeResult.ok(payload.toString()) + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt b/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt index ccca40c4c35..d73c61d233b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt @@ -69,3 +69,12 @@ enum class OpenClawLocationCommand(val rawValue: String) { const val NamespacePrefix: String = "location." } } + +enum class OpenClawNotificationsCommand(val rawValue: String) { + List("notifications.list"), + ; + + companion object { + const val NamespacePrefix: String = "notifications." + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt index 65b18656708..88795b0d9ce 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt @@ -2,6 +2,7 @@ package ai.openclaw.android.node import ai.openclaw.android.protocol.OpenClawCameraCommand import ai.openclaw.android.protocol.OpenClawLocationCommand +import ai.openclaw.android.protocol.OpenClawNotificationsCommand import ai.openclaw.android.protocol.OpenClawSmsCommand import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -21,6 +22,7 @@ class InvokeCommandRegistryTest { assertFalse(commands.contains(OpenClawCameraCommand.Snap.rawValue)) assertFalse(commands.contains(OpenClawCameraCommand.Clip.rawValue)) assertFalse(commands.contains(OpenClawLocationCommand.Get.rawValue)) + assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue)) assertFalse(commands.contains(OpenClawSmsCommand.Send.rawValue)) assertFalse(commands.contains("debug.logs")) assertFalse(commands.contains("debug.ed25519")) @@ -40,6 +42,7 @@ class InvokeCommandRegistryTest { assertTrue(commands.contains(OpenClawCameraCommand.Snap.rawValue)) assertTrue(commands.contains(OpenClawCameraCommand.Clip.rawValue)) assertTrue(commands.contains(OpenClawLocationCommand.Get.rawValue)) + assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue)) assertTrue(commands.contains(OpenClawSmsCommand.Send.rawValue)) assertTrue(commands.contains("debug.logs")) assertTrue(commands.contains("debug.ed25519")) diff --git a/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawProtocolConstantsTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawProtocolConstantsTest.kt index 10ab733ae53..71eec189509 100644 --- a/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawProtocolConstantsTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawProtocolConstantsTest.kt @@ -32,4 +32,9 @@ class OpenClawProtocolConstantsTest { fun screenCommandsUseStableStrings() { assertEquals("screen.record", OpenClawScreenCommand.Record.rawValue) } + + @Test + fun notificationsCommandsUseStableStrings() { + assertEquals("notifications.list", OpenClawNotificationsCommand.List.rawValue) + } } From e6a5d5784ca248aacca547c62b7e580b26dbae61 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 14:02:43 +0530 Subject: [PATCH 26/35] feat(gateway): allow notifications.list for android nodes --- src/gateway/gateway-misc.test.ts | 13 +++++++++++++ src/gateway/node-command-policy.ts | 2 ++ 2 files changed, 15 insertions(+) diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index a202e4b2915..e6f65ed1b77 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -334,6 +334,19 @@ describe("resolveNodeCommandAllowlist", () => { } }); + it("includes Android notifications.list by default", () => { + const allow = resolveNodeCommandAllowlist( + {}, + { + platform: "android 16", + deviceFamily: "Android", + }, + ); + + expect(allow.has("notifications.list")).toBe(true); + expect(allow.has("system.notify")).toBe(false); + }); + it("can explicitly allow dangerous commands via allowCommands", () => { const allow = resolveNodeCommandAllowlist( { diff --git a/src/gateway/node-command-policy.ts b/src/gateway/node-command-policy.ts index ec829b0c5f6..68eb8bb2835 100644 --- a/src/gateway/node-command-policy.ts +++ b/src/gateway/node-command-policy.ts @@ -18,6 +18,7 @@ const CAMERA_DANGEROUS_COMMANDS = ["camera.snap", "camera.clip"]; const SCREEN_DANGEROUS_COMMANDS = ["screen.record"]; const LOCATION_COMMANDS = ["location.get"]; +const NOTIFICATION_COMMANDS = ["notifications.list"]; const DEVICE_COMMANDS = ["device.info", "device.status"]; @@ -69,6 +70,7 @@ const PLATFORM_DEFAULTS: Record = { ...CANVAS_COMMANDS, ...CAMERA_COMMANDS, ...LOCATION_COMMANDS, + ...NOTIFICATION_COMMANDS, ...DEVICE_COMMANDS, ...CONTACTS_COMMANDS, ...CALENDAR_COMMANDS, From c0073b3d47b2794b1e3b8f30f587160c0acf2eab Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 14:02:51 +0530 Subject: [PATCH 27/35] feat(agents): add nodes notifications_list action --- src/agents/openclaw-tools.camera.test.ts | 36 ++++++++++++++++++++++++ src/agents/tools/nodes-tool.ts | 14 ++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/agents/openclaw-tools.camera.test.ts b/src/agents/openclaw-tools.camera.test.ts index 6b1d2e35c33..96be774b297 100644 --- a/src/agents/openclaw-tools.camera.test.ts +++ b/src/agents/openclaw-tools.camera.test.ts @@ -138,6 +138,42 @@ describe("nodes camera_snap", () => { }); }); +describe("nodes notifications_list", () => { + it("invokes notifications.list and returns payload", async () => { + callGateway.mockImplementation(async ({ method, params }) => { + if (method === "node.list") { + return mockNodeList(["notifications.list"]); + } + if (method === "node.invoke") { + expect(params).toMatchObject({ + nodeId: NODE_ID, + command: "notifications.list", + params: {}, + }); + return { + payload: { + enabled: true, + connected: true, + count: 1, + notifications: [{ key: "n1", packageName: "com.example.app" }], + }, + }; + } + return unexpectedGatewayMethod(method); + }); + + const result = await executeNodes({ + action: "notifications_list", + node: NODE_ID, + }); + + expect(result.content?.[0]).toMatchObject({ + type: "text", + text: expect.stringContaining('"notifications"'), + }); + }); +}); + describe("nodes run", () => { it("passes invoke and command timeouts", async () => { callGateway.mockImplementation(async ({ method, params }) => { diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index 3006b9cfddc..b0dfef3eeed 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -40,6 +40,7 @@ const NODES_TOOL_ACTIONS = [ "camera_clip", "screen_record", "location_get", + "notifications_list", "run", "invoke", ] as const; @@ -122,7 +123,7 @@ export function createNodesTool(options?: { label: "Nodes", name: "nodes", description: - "Discover and control paired nodes (status/describe/pairing/notify/camera/screen/location/run/invoke).", + "Discover and control paired nodes (status/describe/pairing/notify/camera/screen/location/notifications/run/invoke).", parameters: NodesToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; @@ -406,6 +407,17 @@ export function createNodesTool(options?: { }); return jsonResult(raw?.payload ?? {}); } + case "notifications_list": { + const node = readStringParam(params, "node", { required: true }); + const nodeId = await resolveNodeId(gatewayOpts, node); + const raw = await callGatewayTool<{ payload: unknown }>("node.invoke", gatewayOpts, { + nodeId, + command: "notifications.list", + params: {}, + idempotencyKey: crypto.randomUUID(), + }); + return jsonResult(raw?.payload ?? {}); + } case "run": { const node = readStringParam(params, "node", { required: true }); const nodes = await listNodes(gatewayOpts); From 05817187fee1bd5a82f05a44c932c82ac1649253 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 14:16:31 +0530 Subject: [PATCH 28/35] refactor(android): unify notifications.list status flow --- .../node/DeviceNotificationListenerService.kt | 29 ++-- .../android/node/NotificationsHandler.kt | 114 ++++++++------ .../android/node/NotificationsHandlerTest.kt | 146 ++++++++++++++++++ 3 files changed, 226 insertions(+), 63 deletions(-) create mode 100644 apps/android/app/src/test/java/ai/openclaw/android/node/NotificationsHandlerTest.kt diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt index e585e5643b6..709e9af5ec5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt @@ -10,6 +10,11 @@ import android.service.notification.StatusBarNotification private const val MAX_NOTIFICATION_TEXT_CHARS = 512 +internal fun sanitizeNotificationText(value: CharSequence?): String? { + val normalized = value?.toString()?.trim().orEmpty() + return normalized.take(MAX_NOTIFICATION_TEXT_CHARS).ifEmpty { null } +} + data class DeviceNotificationEntry( val key: String, val packageName: String, @@ -114,11 +119,11 @@ class DeviceNotificationListenerService : NotificationListenerService() { private fun StatusBarNotification.toEntry(): DeviceNotificationEntry { val extras = notification.extras val keyValue = key.takeIf { it.isNotBlank() } ?: "$packageName:$id:$postTime" - val title = sanitizeText(extras?.getCharSequence(Notification.EXTRA_TITLE)) + val title = sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_TITLE)) val body = - sanitizeText(extras?.getCharSequence(Notification.EXTRA_BIG_TEXT)) - ?: sanitizeText(extras?.getCharSequence(Notification.EXTRA_TEXT)) - val subText = sanitizeText(extras?.getCharSequence(Notification.EXTRA_SUB_TEXT)) + sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_BIG_TEXT)) + ?: sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_TEXT)) + val subText = sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_SUB_TEXT)) return DeviceNotificationEntry( key = keyValue, packageName = packageName, @@ -133,18 +138,6 @@ class DeviceNotificationListenerService : NotificationListenerService() { ) } - private fun sanitizeText(value: CharSequence?): String? { - val normalized = value?.toString()?.trim().orEmpty() - if (normalized.isEmpty()) { - return null - } - return if (normalized.length <= MAX_NOTIFICATION_TEXT_CHARS) { - normalized - } else { - normalized.take(MAX_NOTIFICATION_TEXT_CHARS) - } - } - companion object { private fun serviceComponent(context: Context): ComponentName { return ComponentName(context, DeviceNotificationListenerService::class.java) @@ -155,8 +148,8 @@ class DeviceNotificationListenerService : NotificationListenerService() { return manager.isNotificationListenerAccessGranted(serviceComponent(context)) } - fun snapshot(context: Context): DeviceNotificationSnapshot { - return DeviceNotificationStore.snapshot(enabled = isAccessEnabled(context)) + fun snapshot(context: Context, enabled: Boolean = isAccessEnabled(context)): DeviceNotificationSnapshot { + return DeviceNotificationStore.snapshot(enabled = enabled) } fun requestServiceRebind(context: Context) { diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt index 17123d93674..0216e19208c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt @@ -7,51 +7,75 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put -class NotificationsHandler( - private val appContext: Context, -) { - suspend fun handleNotificationsList(_paramsJson: String?): GatewaySession.InvokeResult { - if (!DeviceNotificationListenerService.isAccessEnabled(appContext)) { - return GatewaySession.InvokeResult.error( - code = "NOTIFICATION_LISTENER_DISABLED", - message = - "NOTIFICATION_LISTENER_DISABLED: enable Notification Access for OpenClaw in system settings", - ) - } - val snapshot = DeviceNotificationListenerService.snapshot(appContext) - if (!snapshot.connected) { - DeviceNotificationListenerService.requestServiceRebind(appContext) - return GatewaySession.InvokeResult.error( - code = "NOTIFICATION_LISTENER_UNAVAILABLE", - message = "NOTIFICATION_LISTENER_UNAVAILABLE: listener is reconnecting; retry shortly", - ) - } +internal interface NotificationsStateProvider { + fun readSnapshot(context: Context): DeviceNotificationSnapshot - val payload = - buildJsonObject { - put("enabled", JsonPrimitive(snapshot.enabled)) - put("connected", JsonPrimitive(snapshot.connected)) - put("count", JsonPrimitive(snapshot.notifications.size)) - put( - "notifications", - JsonArray( - snapshot.notifications.map { entry -> - buildJsonObject { - put("key", JsonPrimitive(entry.key)) - put("packageName", JsonPrimitive(entry.packageName)) - put("postTimeMs", JsonPrimitive(entry.postTimeMs)) - put("isOngoing", JsonPrimitive(entry.isOngoing)) - put("isClearable", JsonPrimitive(entry.isClearable)) - entry.title?.let { put("title", JsonPrimitive(it)) } - entry.text?.let { put("text", JsonPrimitive(it)) } - entry.subText?.let { put("subText", JsonPrimitive(it)) } - entry.category?.let { put("category", JsonPrimitive(it)) } - entry.channelId?.let { put("channelId", JsonPrimitive(it)) } - } - }, - ), - ) - } - return GatewaySession.InvokeResult.ok(payload.toString()) + fun requestServiceRebind(context: Context) +} + +private object SystemNotificationsStateProvider : NotificationsStateProvider { + override fun readSnapshot(context: Context): DeviceNotificationSnapshot { + val enabled = DeviceNotificationListenerService.isAccessEnabled(context) + if (!enabled) { + return DeviceNotificationSnapshot( + enabled = false, + connected = false, + notifications = emptyList(), + ) + } + return DeviceNotificationListenerService.snapshot(context, enabled = true) + } + + override fun requestServiceRebind(context: Context) { + DeviceNotificationListenerService.requestServiceRebind(context) + } +} + +class NotificationsHandler private constructor( + private val appContext: Context, + private val stateProvider: NotificationsStateProvider, +) { + constructor(appContext: Context) : this(appContext = appContext, stateProvider = SystemNotificationsStateProvider) + + suspend fun handleNotificationsList(_paramsJson: String?): GatewaySession.InvokeResult { + val snapshot = stateProvider.readSnapshot(appContext) + if (snapshot.enabled && !snapshot.connected) { + stateProvider.requestServiceRebind(appContext) + } + return GatewaySession.InvokeResult.ok(snapshotPayloadJson(snapshot)) + } + + private fun snapshotPayloadJson(snapshot: DeviceNotificationSnapshot): String { + return buildJsonObject { + put("enabled", JsonPrimitive(snapshot.enabled)) + put("connected", JsonPrimitive(snapshot.connected)) + put("count", JsonPrimitive(snapshot.notifications.size)) + put( + "notifications", + JsonArray( + snapshot.notifications.map { entry -> + buildJsonObject { + put("key", JsonPrimitive(entry.key)) + put("packageName", JsonPrimitive(entry.packageName)) + put("postTimeMs", JsonPrimitive(entry.postTimeMs)) + put("isOngoing", JsonPrimitive(entry.isOngoing)) + put("isClearable", JsonPrimitive(entry.isClearable)) + entry.title?.let { put("title", JsonPrimitive(it)) } + entry.text?.let { put("text", JsonPrimitive(it)) } + entry.subText?.let { put("subText", JsonPrimitive(it)) } + entry.category?.let { put("category", JsonPrimitive(it)) } + entry.channelId?.let { put("channelId", JsonPrimitive(it)) } + } + }, + ), + ) + }.toString() + } + + companion object { + internal fun forTesting( + appContext: Context, + stateProvider: NotificationsStateProvider, + ): NotificationsHandler = NotificationsHandler(appContext = appContext, stateProvider = stateProvider) } } diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/NotificationsHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/NotificationsHandlerTest.kt new file mode 100644 index 00000000000..7768e6e25da --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/android/node/NotificationsHandlerTest.kt @@ -0,0 +1,146 @@ +package ai.openclaw.android.node + +import android.content.Context +import ai.openclaw.android.gateway.GatewaySession +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class NotificationsHandlerTest { + @Test + fun notificationsListReturnsStatusPayloadWhenDisabled() = + runTest { + val provider = + FakeNotificationsStateProvider( + DeviceNotificationSnapshot( + enabled = false, + connected = false, + notifications = emptyList(), + ), + ) + val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider) + + val result = handler.handleNotificationsList(null) + + assertTrue(result.ok) + assertNull(result.error) + val payload = parsePayload(result) + assertFalse(payload.getValue("enabled").jsonPrimitive.boolean) + assertFalse(payload.getValue("connected").jsonPrimitive.boolean) + assertEquals(0, payload.getValue("count").jsonPrimitive.int) + assertEquals(0, payload.getValue("notifications").jsonArray.size) + assertEquals(0, provider.rebindRequests) + } + + @Test + fun notificationsListRequestsRebindWhenEnabledButDisconnected() = + runTest { + val provider = + FakeNotificationsStateProvider( + DeviceNotificationSnapshot( + enabled = true, + connected = false, + notifications = listOf(sampleEntry("n1")), + ), + ) + val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider) + + val result = handler.handleNotificationsList(null) + + assertTrue(result.ok) + assertNull(result.error) + val payload = parsePayload(result) + assertTrue(payload.getValue("enabled").jsonPrimitive.boolean) + assertFalse(payload.getValue("connected").jsonPrimitive.boolean) + assertEquals(1, payload.getValue("count").jsonPrimitive.int) + assertEquals(1, payload.getValue("notifications").jsonArray.size) + assertEquals(1, provider.rebindRequests) + } + + @Test + fun notificationsListDoesNotRequestRebindWhenConnected() = + runTest { + val provider = + FakeNotificationsStateProvider( + DeviceNotificationSnapshot( + enabled = true, + connected = true, + notifications = listOf(sampleEntry("n2")), + ), + ) + val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider) + + val result = handler.handleNotificationsList(null) + + assertTrue(result.ok) + assertNull(result.error) + val payload = parsePayload(result) + assertTrue(payload.getValue("enabled").jsonPrimitive.boolean) + assertTrue(payload.getValue("connected").jsonPrimitive.boolean) + assertEquals(1, payload.getValue("count").jsonPrimitive.int) + assertEquals(0, provider.rebindRequests) + } + + @Test + fun sanitizeNotificationTextReturnsNullForBlankInput() { + assertNull(sanitizeNotificationText(null)) + assertNull(sanitizeNotificationText(" ")) + } + + @Test + fun sanitizeNotificationTextTrimsAndTruncates() { + val value = " ${"x".repeat(600)} " + val sanitized = sanitizeNotificationText(value) + + assertEquals(512, sanitized?.length) + assertTrue((sanitized ?: "").all { it == 'x' }) + } + + private fun parsePayload(result: GatewaySession.InvokeResult): JsonObject { + val payloadJson = result.payloadJson ?: error("expected payload") + return Json.parseToJsonElement(payloadJson).jsonObject + } + + private fun appContext(): Context = RuntimeEnvironment.getApplication() + + private fun sampleEntry(key: String): DeviceNotificationEntry = + DeviceNotificationEntry( + key = key, + packageName = "com.example.app", + title = "Title", + text = "Text", + subText = null, + category = null, + channelId = null, + postTimeMs = 123L, + isOngoing = false, + isClearable = true, + ) +} + +private class FakeNotificationsStateProvider( + private val snapshot: DeviceNotificationSnapshot, +) : NotificationsStateProvider { + var rebindRequests: Int = 0 + private set + + override fun readSnapshot(context: Context): DeviceNotificationSnapshot = snapshot + + override fun requestServiceRebind(context: Context) { + rebindRequests += 1 + } +} From a0cf753b2e7e8b8b459450353b4b8c46a7580b61 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 14:16:34 +0530 Subject: [PATCH 29/35] refactor(agents): dedupe node read invoke commands --- src/agents/tools/nodes-tool.ts | 48 +++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index b0dfef3eeed..25b19403352 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -49,6 +49,23 @@ const NOTIFY_PRIORITIES = ["passive", "active", "timeSensitive"] as const; const NOTIFY_DELIVERIES = ["system", "overlay", "auto"] as const; const CAMERA_FACING = ["front", "back", "both"] as const; const LOCATION_ACCURACY = ["coarse", "balanced", "precise"] as const; +type GatewayCallOptions = ReturnType; + +async function invokeNodeCommandPayload(params: { + gatewayOpts: GatewayCallOptions; + node: string; + command: string; + commandParams?: Record; +}): Promise { + const nodeId = await resolveNodeId(params.gatewayOpts, params.node); + const raw = await callGatewayTool<{ payload: unknown }>("node.invoke", params.gatewayOpts, { + nodeId, + command: params.command, + params: params.commandParams ?? {}, + idempotencyKey: crypto.randomUUID(), + }); + return raw?.payload ?? {}; +} function isPairingRequiredMessage(message: string): boolean { const lower = message.toLowerCase(); @@ -273,15 +290,13 @@ export function createNodesTool(options?: { } case "camera_list": { const node = readStringParam(params, "node", { required: true }); - const nodeId = await resolveNodeId(gatewayOpts, node); - const raw = await callGatewayTool<{ payload: unknown }>("node.invoke", gatewayOpts, { - nodeId, + const payloadRaw = await invokeNodeCommandPayload({ + gatewayOpts, + node, command: "camera.list", - params: {}, - idempotencyKey: crypto.randomUUID(), }); const payload = - raw && typeof raw.payload === "object" && raw.payload !== null ? raw.payload : {}; + payloadRaw && typeof payloadRaw === "object" && payloadRaw !== null ? payloadRaw : {}; return jsonResult(payload); } case "camera_clip": { @@ -379,7 +394,6 @@ export function createNodesTool(options?: { } case "location_get": { const node = readStringParam(params, "node", { required: true }); - const nodeId = await resolveNodeId(gatewayOpts, node); const maxAgeMs = typeof params.maxAgeMs === "number" && Number.isFinite(params.maxAgeMs) ? params.maxAgeMs @@ -395,28 +409,26 @@ export function createNodesTool(options?: { Number.isFinite(params.locationTimeoutMs) ? params.locationTimeoutMs : undefined; - const raw = await callGatewayTool<{ payload: unknown }>("node.invoke", gatewayOpts, { - nodeId, + const payload = await invokeNodeCommandPayload({ + gatewayOpts, + node, command: "location.get", - params: { + commandParams: { maxAgeMs, desiredAccuracy, timeoutMs: locationTimeoutMs, }, - idempotencyKey: crypto.randomUUID(), }); - return jsonResult(raw?.payload ?? {}); + return jsonResult(payload); } case "notifications_list": { const node = readStringParam(params, "node", { required: true }); - const nodeId = await resolveNodeId(gatewayOpts, node); - const raw = await callGatewayTool<{ payload: unknown }>("node.invoke", gatewayOpts, { - nodeId, + const payload = await invokeNodeCommandPayload({ + gatewayOpts, + node, command: "notifications.list", - params: {}, - idempotencyKey: crypto.randomUUID(), }); - return jsonResult(raw?.payload ?? {}); + return jsonResult(payload); } case "run": { const node = readStringParam(params, "node", { required: true }); From da6a96ed334610968c7179e4b74ea55984cb8827 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 14:33:00 +0530 Subject: [PATCH 30/35] fix: update changelog for notifications list land (#27344) (thanks @obviyus) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e4a3c5c17b..6490add5587 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Agents/Routing CLI: add `openclaw agents bindings`, `openclaw agents bind`, and `openclaw agents unbind` for account-scoped route management, including channel-only to account-scoped binding upgrades, role-aware binding identity handling, plugin-resolved binding account IDs, and optional account-binding prompts in `openclaw channels add`. (#27195) thanks @gumadeiras. +- Android/Nodes: add `notifications.list` support on Android nodes and expose `nodes notifications_list` in agent tooling for listing active device notifications. (#27344) thanks @obviyus. ### Fixes From dfa0b5b4fc724432f37e9830269cd2558fd36df6 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 26 Feb 2026 04:06:03 -0500 Subject: [PATCH 31/35] Channels: move single-account config into accounts.default (#27334) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 50b57718085368d302680ec93fab67f5ed6140a4 Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + docs/cli/channels.md | 10 ++ docs/cli/index.md | 2 + docs/gateway/configuration-reference.md | 3 + docs/gateway/doctor.md | 1 + .../plugins/onboarding/helpers.test.ts | 33 ++++++ src/channels/plugins/onboarding/helpers.ts | 19 ++- src/channels/plugins/setup-helpers.ts | 112 ++++++++++++++++++ ....adds-non-default-telegram-account.test.ts | 90 ++++++++++++++ src/commands/channels/add.ts | 8 ++ ...fault-account-bindings.integration.test.ts | 56 +++++++++ ...w.missing-default-account-bindings.test.ts | 89 ++++++++++++++ src/commands/doctor-config-flow.ts | 105 ++++++++++++++++ .../doctor-legacy-config.migrations.test.ts | 44 ++++++- src/commands/doctor-legacy-config.ts | 73 ++++++++++++ 15 files changed, 639 insertions(+), 7 deletions(-) create mode 100644 src/commands/doctor-config-flow.missing-default-account-bindings.integration.test.ts create mode 100644 src/commands/doctor-config-flow.missing-default-account-bindings.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6490add5587..844fe8eb636 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Channels/Multi-account config: when adding a non-default channel account to a single-account top-level channel setup, move existing account-scoped top-level single-account values into `channels..accounts.default` before writing the new account so the original account keeps working without duplicated account values at channel root; `openclaw doctor --fix` now repairs previously mixed channel account shapes the same way. (#27334) thanks @gumadeiras. - Daemon/macOS launchd: forward proxy env vars into supervised service environments, keep LaunchAgent `KeepAlive=true` semantics, and harden restart sequencing to `print -> bootout -> wait old pid exit -> bootstrap -> kickstart`. (#27276) thanks @frankekn. - Android/Node invoke: remove native gateway WebSocket `Origin` header to avoid false origin rejections, unify invoke command registry/policy/error parsing paths, and keep command availability checks centralized to reduce dispatcher/advertisement drift. (#27257) Thanks @obviyus. - CI/Windows: shard the Windows `checks-windows` test lane into two matrix jobs and honor explicit shard index overrides in `scripts/test-parallel.mjs` to reduce CI critical-path wall time. (#27234) Thanks @joshavant. diff --git a/docs/cli/channels.md b/docs/cli/channels.md index 0f9c3fecb77..23e0b2cfd4b 100644 --- a/docs/cli/channels.md +++ b/docs/cli/channels.md @@ -45,6 +45,16 @@ If you confirm bind now, the wizard asks which agent should own each configured You can also manage the same routing rules later with `openclaw agents bindings`, `openclaw agents bind`, and `openclaw agents unbind` (see [agents](/cli/agents)). +When you add a non-default account to a channel that is still using single-account top-level settings (no `channels..accounts` entries yet), OpenClaw moves account-scoped single-account top-level values into `channels..accounts.default`, then writes the new account. This preserves the original account behavior while moving to the multi-account shape. + +Routing behavior stays consistent: + +- Existing channel-only bindings (no `accountId`) continue to match the default account. +- `channels add` does not auto-create or rewrite bindings in non-interactive mode. +- Interactive setup can optionally add account-scoped bindings. + +If your config was already in a mixed state (named accounts present, missing `default`, and top-level single-account values still set), run `openclaw doctor --fix` to move account-scoped values into `accounts.default`. + ## Login / logout (interactive) ```bash diff --git a/docs/cli/index.md b/docs/cli/index.md index 1394d83db0e..a780dfd2a5e 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -400,6 +400,8 @@ Subcommands: - Tip: `channels status` prints warnings with suggested fixes when it can detect common misconfigurations (then points you to `openclaw doctor`). - `channels logs`: show recent channel logs from the gateway log file. - `channels add`: wizard-style setup when no flags are passed; flags switch to non-interactive mode. + - When adding a non-default account to a channel still using single-account top-level config, OpenClaw moves account-scoped values into `channels..accounts.default` before writing the new account. + - Non-interactive `channels add` does not auto-create/upgrade bindings; channel-only bindings continue to match the default account. - `channels remove`: disable by default; pass `--delete` to remove config entries without prompts. - `channels login`: interactive channel login (WhatsApp Web only). - `channels logout`: log out of a channel session (if supported). diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index c548fc973a5..a715ec89ba6 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -505,6 +505,9 @@ Run multiple accounts per channel (each with its own `accountId`): - Env tokens only apply to the **default** account. - Base channel settings apply to all accounts unless overridden per account. - Use `bindings[].match.accountId` to route each account to a different agent. +- If you add a non-default account via `openclaw channels add` (or channel onboarding) while still on a single-account top-level channel config, OpenClaw moves account-scoped top-level single-account values into `channels..accounts.default` first so the original account keeps working. +- Existing channel-only bindings (no `accountId`) keep matching the default account; account-scoped bindings remain optional. +- `openclaw doctor --fix` also repairs mixed shapes by moving account-scoped top-level single-account values into `accounts.default` when named accounts exist but `default` is missing. ### Group chat mention gating diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 4647cb8b411..4ecc10b4c66 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -121,6 +121,7 @@ Current migrations: - `routing.agentToAgent` → `tools.agentToAgent` - `routing.transcribeAudio` → `tools.media.audio.models` - `bindings[].match.accountID` → `bindings[].match.accountId` +- For channels with named `accounts` but missing `accounts.default`, move account-scoped top-level single-account channel values into `channels..accounts.default` when present - `identity` → `agents.list[].identity` - `agent.*` → `agents.defaults` + `tools.*` (tools/elevated/exec/sandbox/subagents) - `agent.model`/`allowedModels`/`modelAliases`/`modelFallbacks`/`imageModelFallbacks` diff --git a/src/channels/plugins/onboarding/helpers.test.ts b/src/channels/plugins/onboarding/helpers.test.ts index cecb5518154..b209be558f5 100644 --- a/src/channels/plugins/onboarding/helpers.test.ts +++ b/src/channels/plugins/onboarding/helpers.test.ts @@ -554,6 +554,39 @@ describe("patchChannelConfigForAccount", () => { expect(next.channels?.slack?.accounts?.work?.appToken).toBe("new-app"); }); + it("moves single-account config into default account when patching non-default", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + enabled: true, + botToken: "legacy-token", + allowFrom: ["100"], + groupPolicy: "allowlist", + streaming: "partial", + }, + }, + }; + + const next = patchChannelConfigForAccount({ + cfg, + channel: "telegram", + accountId: "work", + patch: { botToken: "work-token" }, + }); + + expect(next.channels?.telegram?.accounts?.default).toEqual({ + botToken: "legacy-token", + allowFrom: ["100"], + groupPolicy: "allowlist", + streaming: "partial", + }); + expect(next.channels?.telegram?.botToken).toBeUndefined(); + expect(next.channels?.telegram?.allowFrom).toBeUndefined(); + expect(next.channels?.telegram?.groupPolicy).toBeUndefined(); + expect(next.channels?.telegram?.streaming).toBeUndefined(); + expect(next.channels?.telegram?.accounts?.work?.botToken).toBe("work-token"); + }); + it("supports imessage/signal account-scoped channel patches", () => { const cfg: OpenClawConfig = { channels: { diff --git a/src/channels/plugins/onboarding/helpers.ts b/src/channels/plugins/onboarding/helpers.ts index 258aa7b6782..7a1b92001ad 100644 --- a/src/channels/plugins/onboarding/helpers.ts +++ b/src/channels/plugins/onboarding/helpers.ts @@ -4,6 +4,7 @@ import { promptAccountId as promptAccountIdSdk } from "../../../plugin-sdk/onboa import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js"; import type { WizardPrompter } from "../../../wizard/prompts.js"; import type { PromptAccountId, PromptAccountIdParams } from "../onboarding-types.js"; +import { moveSingleAccountChannelSectionToDefaultAccount } from "../setup-helpers.js"; export const promptAccountId: PromptAccountId = async (params: PromptAccountIdParams) => { return await promptAccountIdSdk(params); @@ -282,13 +283,21 @@ function patchConfigForScopedAccount(params: { ensureEnabled: boolean; }): OpenClawConfig { const { cfg, channel, accountId, patch, ensureEnabled } = params; - const channelConfig = (cfg.channels?.[channel] as Record | undefined) ?? {}; + const seededCfg = + accountId === DEFAULT_ACCOUNT_ID + ? cfg + : moveSingleAccountChannelSectionToDefaultAccount({ + cfg, + channelKey: channel, + }); + const channelConfig = + (seededCfg.channels?.[channel] as Record | undefined) ?? {}; if (accountId === DEFAULT_ACCOUNT_ID) { return { - ...cfg, + ...seededCfg, channels: { - ...cfg.channels, + ...seededCfg.channels, [channel]: { ...channelConfig, ...(ensureEnabled ? { enabled: true } : {}), @@ -303,9 +312,9 @@ function patchConfigForScopedAccount(params: { const existingAccount = accounts[accountId] ?? {}; return { - ...cfg, + ...seededCfg, channels: { - ...cfg.channels, + ...seededCfg.channels, [channel]: { ...channelConfig, ...(ensureEnabled ? { enabled: true } : {}), diff --git a/src/channels/plugins/setup-helpers.ts b/src/channels/plugins/setup-helpers.ts index c6a695b1e8d..72b3163a62e 100644 --- a/src/channels/plugins/setup-helpers.ts +++ b/src/channels/plugins/setup-helpers.ts @@ -119,3 +119,115 @@ export function migrateBaseNameToDefaultAccount(params: { }, } as OpenClawConfig; } + +type ChannelSectionRecord = Record & { + accounts?: Record>; +}; + +const COMMON_SINGLE_ACCOUNT_KEYS_TO_MOVE = new Set([ + "name", + "token", + "tokenFile", + "botToken", + "appToken", + "account", + "signalNumber", + "authDir", + "cliPath", + "dbPath", + "httpUrl", + "httpHost", + "httpPort", + "webhookPath", + "webhookUrl", + "webhookSecret", + "service", + "region", + "homeserver", + "userId", + "accessToken", + "password", + "deviceName", + "url", + "code", + "dmPolicy", + "allowFrom", + "groupPolicy", + "groupAllowFrom", + "defaultTo", +]); + +const SINGLE_ACCOUNT_KEYS_TO_MOVE_BY_CHANNEL: Record> = { + telegram: new Set(["streaming"]), +}; + +export function shouldMoveSingleAccountChannelKey(params: { + channelKey: string; + key: string; +}): boolean { + if (COMMON_SINGLE_ACCOUNT_KEYS_TO_MOVE.has(params.key)) { + return true; + } + return SINGLE_ACCOUNT_KEYS_TO_MOVE_BY_CHANNEL[params.channelKey]?.has(params.key) ?? false; +} + +function cloneIfObject(value: T): T { + if (value && typeof value === "object") { + return structuredClone(value); + } + return value; +} + +// When promoting a single-account channel config to multi-account, +// move top-level account settings into accounts.default so the original +// account keeps working without duplicate account values at channel root. +export function moveSingleAccountChannelSectionToDefaultAccount(params: { + cfg: OpenClawConfig; + channelKey: string; +}): OpenClawConfig { + const channels = params.cfg.channels as Record | undefined; + const baseConfig = channels?.[params.channelKey]; + const base = + typeof baseConfig === "object" && baseConfig ? (baseConfig as ChannelSectionRecord) : undefined; + if (!base) { + return params.cfg; + } + + const accounts = base.accounts ?? {}; + if (Object.keys(accounts).length > 0) { + return params.cfg; + } + + const keysToMove = Object.entries(base) + .filter( + ([key, value]) => + key !== "accounts" && + key !== "enabled" && + value !== undefined && + shouldMoveSingleAccountChannelKey({ channelKey: params.channelKey, key }), + ) + .map(([key]) => key); + const defaultAccount: Record = {}; + for (const key of keysToMove) { + const value = base[key]; + defaultAccount[key] = cloneIfObject(value); + } + const nextChannel: ChannelSectionRecord = { ...base }; + for (const key of keysToMove) { + delete nextChannel[key]; + } + + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + [params.channelKey]: { + ...nextChannel, + accounts: { + ...accounts, + [DEFAULT_ACCOUNT_ID]: defaultAccount, + }, + }, + }, + } as OpenClawConfig; +} diff --git a/src/commands/channels.adds-non-default-telegram-account.test.ts b/src/commands/channels.adds-non-default-telegram-account.test.ts index 0187675788d..3df9fc11061 100644 --- a/src/commands/channels.adds-non-default-telegram-account.test.ts +++ b/src/commands/channels.adds-non-default-telegram-account.test.ts @@ -66,6 +66,96 @@ describe("channels command", () => { expect(next.channels?.telegram?.accounts?.alerts?.botToken).toBe("123:abc"); }); + it("moves single-account telegram config into accounts.default when adding non-default", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: { + channels: { + telegram: { + enabled: true, + botToken: "legacy-token", + dmPolicy: "allowlist", + allowFrom: ["111"], + groupPolicy: "allowlist", + streaming: "partial", + }, + }, + }, + }); + + await channelsAddCommand( + { channel: "telegram", account: "alerts", token: "alerts-token" }, + runtime, + { hasFlags: true }, + ); + + const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as { + channels?: { + telegram?: { + botToken?: string; + dmPolicy?: string; + allowFrom?: string[]; + groupPolicy?: string; + streaming?: string; + accounts?: Record< + string, + { + botToken?: string; + dmPolicy?: string; + allowFrom?: string[]; + groupPolicy?: string; + streaming?: string; + } + >; + }; + }; + }; + expect(next.channels?.telegram?.accounts?.default).toEqual({ + botToken: "legacy-token", + dmPolicy: "allowlist", + allowFrom: ["111"], + groupPolicy: "allowlist", + streaming: "partial", + }); + expect(next.channels?.telegram?.botToken).toBeUndefined(); + expect(next.channels?.telegram?.dmPolicy).toBeUndefined(); + expect(next.channels?.telegram?.allowFrom).toBeUndefined(); + expect(next.channels?.telegram?.groupPolicy).toBeUndefined(); + expect(next.channels?.telegram?.streaming).toBeUndefined(); + expect(next.channels?.telegram?.accounts?.alerts?.botToken).toBe("alerts-token"); + }); + + it("seeds accounts.default for env-only single-account telegram config when adding non-default", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: { + channels: { + telegram: { + enabled: true, + }, + }, + }, + }); + + await channelsAddCommand( + { channel: "telegram", account: "alerts", token: "alerts-token" }, + runtime, + { hasFlags: true }, + ); + + const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as { + channels?: { + telegram?: { + enabled?: boolean; + accounts?: Record; + }; + }; + }; + expect(next.channels?.telegram?.enabled).toBe(true); + expect(next.channels?.telegram?.accounts?.default).toEqual({}); + expect(next.channels?.telegram?.accounts?.alerts?.botToken).toBe("alerts-token"); + }); + it("adds a default slack account with tokens", async () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); await channelsAddCommand( diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index eaa6fc53397..882e7f16ca5 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -1,6 +1,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; +import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/plugins/setup-helpers.js"; import type { ChannelId, ChannelSetupInput } from "../../channels/plugins/types.js"; import { writeConfigFile, type OpenClawConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; @@ -283,6 +284,13 @@ export async function channelsAddCommand( ? resolveTelegramAccount({ cfg: nextConfig, accountId }).token.trim() : ""; + if (accountId !== DEFAULT_ACCOUNT_ID) { + nextConfig = moveSingleAccountChannelSectionToDefaultAccount({ + cfg: nextConfig, + channelKey: channel, + }); + } + nextConfig = applyChannelAccountConfig({ cfg: nextConfig, channel, diff --git a/src/commands/doctor-config-flow.missing-default-account-bindings.integration.test.ts b/src/commands/doctor-config-flow.missing-default-account-bindings.integration.test.ts new file mode 100644 index 00000000000..0856b3aa9b5 --- /dev/null +++ b/src/commands/doctor-config-flow.missing-default-account-bindings.integration.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; +import { withEnvAsync } from "../test-utils/env.js"; +import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js"; + +const { noteSpy } = vi.hoisted(() => ({ + noteSpy: vi.fn(), +})); + +vi.mock("../terminal/note.js", () => ({ + note: noteSpy, +})); + +vi.mock("./doctor-legacy-config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + normalizeLegacyConfigValues: (cfg: unknown) => ({ + config: cfg, + changes: [], + }), + }; +}); + +import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; + +describe("doctor missing default account binding warning", () => { + it("emits a doctor warning when named accounts have no valid account-scoped bindings", async () => { + await withEnvAsync( + { + TELEGRAM_BOT_TOKEN: undefined, + TELEGRAM_BOT_TOKEN_FILE: undefined, + }, + async () => { + await runDoctorConfigWithInput({ + config: { + channels: { + telegram: { + accounts: { + alerts: {}, + work: {}, + }, + }, + }, + bindings: [{ agentId: "ops", match: { channel: "telegram" } }], + }, + run: loadAndMaybeMigrateDoctorConfig, + }); + }, + ); + + expect(noteSpy).toHaveBeenCalledWith( + expect.stringContaining("channels.telegram: accounts.default is missing"), + "Doctor warnings", + ); + }); +}); diff --git a/src/commands/doctor-config-flow.missing-default-account-bindings.test.ts b/src/commands/doctor-config-flow.missing-default-account-bindings.test.ts new file mode 100644 index 00000000000..6a47ab1f962 --- /dev/null +++ b/src/commands/doctor-config-flow.missing-default-account-bindings.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { collectMissingDefaultAccountBindingWarnings } from "./doctor-config-flow.js"; + +describe("collectMissingDefaultAccountBindingWarnings", () => { + it("warns when named accounts exist without default and no valid binding exists", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + accounts: { + alerts: { botToken: "a" }, + work: { botToken: "w" }, + }, + }, + }, + bindings: [{ agentId: "ops", match: { channel: "telegram" } }], + }; + + const warnings = collectMissingDefaultAccountBindingWarnings(cfg); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("channels.telegram"); + expect(warnings[0]).toContain("alerts, work"); + }); + + it("does not warn when an explicit account binding exists", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + accounts: { + alerts: { botToken: "a" }, + }, + }, + }, + bindings: [{ agentId: "ops", match: { channel: "telegram", accountId: "alerts" } }], + }; + + expect(collectMissingDefaultAccountBindingWarnings(cfg)).toEqual([]); + }); + + it("warns when bindings cover only a subset of configured accounts", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + accounts: { + alerts: { botToken: "a" }, + work: { botToken: "w" }, + }, + }, + }, + bindings: [{ agentId: "ops", match: { channel: "telegram", accountId: "alerts" } }], + }; + + const warnings = collectMissingDefaultAccountBindingWarnings(cfg); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("subset"); + expect(warnings[0]).toContain("Uncovered accounts: work"); + }); + + it("does not warn when wildcard account binding exists", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + accounts: { + alerts: { botToken: "a" }, + }, + }, + }, + bindings: [{ agentId: "ops", match: { channel: "telegram", accountId: "*" } }], + }; + + expect(collectMissingDefaultAccountBindingWarnings(cfg)).toEqual([]); + }); + + it("does not warn when default account is present", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + accounts: { + default: { botToken: "d" }, + alerts: { botToken: "a" }, + }, + }, + }, + bindings: [{ agentId: "ops", match: { channel: "telegram" } }], + }; + + expect(collectMissingDefaultAccountBindingWarnings(cfg)).toEqual([]); + }); +}); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index f4a7e4132a8..fffa67bff4d 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { ZodIssue } from "zod"; +import { normalizeChatChannelId } from "../channels/registry.js"; import { isNumericTelegramUserId, normalizeTelegramAllowFromEntry, @@ -27,6 +28,7 @@ import { isTrustedSafeBinPath, normalizeTrustedSafeBinDirs, } from "../infra/exec-safe-bin-trust.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import { isDiscordMutableAllowEntry, isGoogleChatMutableAllowEntry, @@ -207,6 +209,103 @@ function asObjectRecord(value: unknown): Record | null { return value as Record; } +function normalizeBindingChannelKey(raw?: string | null): string { + const normalized = normalizeChatChannelId(raw); + if (normalized) { + return normalized; + } + return (raw ?? "").trim().toLowerCase(); +} + +export function collectMissingDefaultAccountBindingWarnings(cfg: OpenClawConfig): string[] { + const channels = asObjectRecord(cfg.channels); + if (!channels) { + return []; + } + + const bindings = Array.isArray(cfg.bindings) ? cfg.bindings : []; + const warnings: string[] = []; + + for (const [channelKey, rawChannel] of Object.entries(channels)) { + const channel = asObjectRecord(rawChannel); + if (!channel) { + continue; + } + const accounts = asObjectRecord(channel.accounts); + if (!accounts) { + continue; + } + + const normalizedAccountIds = Array.from( + new Set( + Object.keys(accounts) + .map((accountId) => normalizeAccountId(accountId)) + .filter(Boolean), + ), + ); + if (normalizedAccountIds.length === 0 || normalizedAccountIds.includes(DEFAULT_ACCOUNT_ID)) { + continue; + } + const accountIdSet = new Set(normalizedAccountIds); + const channelPattern = normalizeBindingChannelKey(channelKey); + + let hasWildcardBinding = false; + const coveredAccountIds = new Set(); + for (const binding of bindings) { + const bindingRecord = asObjectRecord(binding); + if (!bindingRecord) { + continue; + } + const match = asObjectRecord(bindingRecord.match); + if (!match) { + continue; + } + + const matchChannel = + typeof match.channel === "string" ? normalizeBindingChannelKey(match.channel) : ""; + if (!matchChannel || matchChannel !== channelPattern) { + continue; + } + + const rawAccountId = typeof match.accountId === "string" ? match.accountId.trim() : ""; + if (!rawAccountId) { + continue; + } + if (rawAccountId === "*") { + hasWildcardBinding = true; + continue; + } + const normalizedBindingAccountId = normalizeAccountId(rawAccountId); + if (accountIdSet.has(normalizedBindingAccountId)) { + coveredAccountIds.add(normalizedBindingAccountId); + } + } + + if (hasWildcardBinding) { + continue; + } + + const uncoveredAccountIds = normalizedAccountIds.filter( + (accountId) => !coveredAccountIds.has(accountId), + ); + if (uncoveredAccountIds.length === 0) { + continue; + } + if (coveredAccountIds.size > 0) { + warnings.push( + `- channels.${channelKey}: accounts.default is missing and account bindings only cover a subset of configured accounts. Uncovered accounts: ${uncoveredAccountIds.join(", ")}. Add bindings[].match.accountId for uncovered accounts (or "*"), or add channels.${channelKey}.accounts.default.`, + ); + continue; + } + + warnings.push( + `- channels.${channelKey}: accounts.default is missing and no valid account-scoped binding exists for configured accounts (${normalizedAccountIds.join(", ")}). Channel-only bindings (no accountId) match only default. Add bindings[].match.accountId for one of these accounts (or "*"), or add channels.${channelKey}.accounts.default.`, + ); + } + + return warnings; +} + function collectTelegramAccountScopes( cfg: OpenClawConfig, ): Array<{ prefix: string; account: Record }> { @@ -1421,6 +1520,12 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { } } + const missingDefaultAccountBindingWarnings = + collectMissingDefaultAccountBindingWarnings(candidate); + if (missingDefaultAccountBindingWarnings.length > 0) { + note(missingDefaultAccountBindingWarnings.join("\n"), "Doctor warnings"); + } + if (shouldRepair) { const repair = await maybeRepairTelegramAllowFromUsernames(candidate); if (repair.changes.length > 0) { diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index a626371c8e3..775966bae1d 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -164,10 +164,12 @@ describe("normalizeLegacyConfigValues", () => { expect(res.config.channels?.discord?.streamMode).toBeUndefined(); expect(res.config.channels?.discord?.accounts?.work?.streaming).toBe("off"); expect(res.config.channels?.discord?.accounts?.work?.streamMode).toBeUndefined(); - expect(res.changes).toEqual([ + expect(res.changes).toContain( "Normalized channels.discord.streaming boolean → enum (partial).", + ); + expect(res.changes).toContain( "Normalized channels.discord.accounts.work.streaming boolean → enum (off).", - ]); + ); }); it("migrates Discord legacy streamMode into streaming enum", () => { @@ -223,6 +225,44 @@ describe("normalizeLegacyConfigValues", () => { ]); }); + it("moves missing default account from single-account top-level config when named accounts already exist", () => { + const res = normalizeLegacyConfigValues({ + channels: { + telegram: { + enabled: true, + botToken: "legacy-token", + dmPolicy: "allowlist", + allowFrom: ["123"], + groupPolicy: "allowlist", + streaming: "partial", + accounts: { + alerts: { + enabled: true, + botToken: "alerts-token", + }, + }, + }, + }, + }); + + expect(res.config.channels?.telegram?.accounts?.default).toEqual({ + botToken: "legacy-token", + dmPolicy: "allowlist", + allowFrom: ["123"], + groupPolicy: "allowlist", + streaming: "partial", + }); + expect(res.config.channels?.telegram?.botToken).toBeUndefined(); + expect(res.config.channels?.telegram?.dmPolicy).toBeUndefined(); + expect(res.config.channels?.telegram?.allowFrom).toBeUndefined(); + expect(res.config.channels?.telegram?.groupPolicy).toBeUndefined(); + expect(res.config.channels?.telegram?.streaming).toBeUndefined(); + expect(res.config.channels?.telegram?.accounts?.alerts?.botToken).toBe("alerts-token"); + expect(res.changes).toContain( + "Moved channels.telegram single-account top-level values into channels.telegram.accounts.default.", + ); + }); + it("migrates browser ssrfPolicy allowPrivateNetwork to dangerouslyAllowPrivateNetwork", () => { const res = normalizeLegacyConfigValues({ browser: { diff --git a/src/commands/doctor-legacy-config.ts b/src/commands/doctor-legacy-config.ts index 6f84067ca62..35cd5fba277 100644 --- a/src/commands/doctor-legacy-config.ts +++ b/src/commands/doctor-legacy-config.ts @@ -1,3 +1,4 @@ +import { shouldMoveSingleAccountChannelKey } from "../channels/plugins/setup-helpers.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveDiscordPreviewStreamMode, @@ -5,6 +6,7 @@ import { resolveSlackStreamingMode, resolveTelegramPreviewStreamMode, } from "../config/discord-preview-streaming.js"; +import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; export function normalizeLegacyConfigValues(cfg: OpenClawConfig): { config: OpenClawConfig; @@ -289,9 +291,80 @@ export function normalizeLegacyConfigValues(cfg: OpenClawConfig): { } }; + const seedMissingDefaultAccountsFromSingleAccountBase = () => { + const channels = next.channels as Record | undefined; + if (!channels) { + return; + } + + let channelsChanged = false; + const nextChannels = { ...channels }; + for (const [channelId, rawChannel] of Object.entries(channels)) { + if (!isRecord(rawChannel)) { + continue; + } + const rawAccounts = rawChannel.accounts; + if (!isRecord(rawAccounts)) { + continue; + } + const accountKeys = Object.keys(rawAccounts); + if (accountKeys.length === 0) { + continue; + } + const hasDefault = accountKeys.some((key) => key.trim().toLowerCase() === DEFAULT_ACCOUNT_ID); + if (hasDefault) { + continue; + } + + const keysToMove = Object.entries(rawChannel) + .filter( + ([key, value]) => + key !== "accounts" && + key !== "enabled" && + value !== undefined && + shouldMoveSingleAccountChannelKey({ channelKey: channelId, key }), + ) + .map(([key]) => key); + if (keysToMove.length === 0) { + continue; + } + + const defaultAccount: Record = {}; + for (const key of keysToMove) { + const value = rawChannel[key]; + defaultAccount[key] = value && typeof value === "object" ? structuredClone(value) : value; + } + const nextChannel: Record = { + ...rawChannel, + }; + for (const key of keysToMove) { + delete nextChannel[key]; + } + nextChannel.accounts = { + ...rawAccounts, + [DEFAULT_ACCOUNT_ID]: defaultAccount, + }; + + nextChannels[channelId] = nextChannel; + channelsChanged = true; + changes.push( + `Moved channels.${channelId} single-account top-level values into channels.${channelId}.accounts.default.`, + ); + } + + if (!channelsChanged) { + return; + } + next = { + ...next, + channels: nextChannels as OpenClawConfig["channels"], + }; + }; + normalizeProvider("telegram"); normalizeProvider("slack"); normalizeProvider("discord"); + seedMissingDefaultAccountsFromSingleAccountBase(); const normalizeBrowserSsrFPolicyAlias = () => { const rawBrowser = next.browser; From 58fef1d70319362e5443041230ab71329d7c5805 Mon Sep 17 00:00:00 2001 From: GodsBoy Date: Thu, 26 Feb 2026 10:33:57 +0200 Subject: [PATCH 32/35] fix(telegram): allow inline button callbacks in groups when command was authorized (#27309) --- src/telegram/bot-handlers.ts | 4 +++- src/telegram/bot.test.ts | 44 ++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index ad28c32883d..33626ff475a 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -536,7 +536,9 @@ export const registerTelegramHandlers = ({ }, "callback-allowlist": { enforceDirectAuthorization: true, - enforceGroupAllowlistAuthorization: true, + // Group auth is already enforced by shouldSkipGroupMessage (group policy + allowlist). + // An extra allowlist gate here would block users whose original command was authorized. + enforceGroupAllowlistAuthorization: false, deniedDmReason: "callback unauthorized by inlineButtonsScope allowlist", deniedGroupReason: "callback unauthorized by inlineButtonsScope allowlist", }, diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index e7e326d0e36..2ffcc489baf 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -193,6 +193,50 @@ describe("createTelegramBot", () => { expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-2"); }); + it("allows callback_query in groups when group policy authorizes the sender", async () => { + onSpy.mockClear(); + editMessageTextSpy.mockClear(); + listSkillCommandsForAgents.mockClear(); + + createTelegramBot({ + token: "tok", + config: { + channels: { + telegram: { + dmPolicy: "open", + capabilities: { inlineButtons: "allowlist" }, + allowFrom: [], + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }, + }); + const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( + ctx: Record, + ) => Promise; + expect(callbackHandler).toBeDefined(); + + await callbackHandler({ + callbackQuery: { + id: "cbq-group-1", + data: "commands_page_2", + from: { id: 42, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: -100999, type: "supergroup", title: "Test Group" }, + date: 1736380800, + message_id: 20, + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + // The callback should be processed (not silently blocked) + expect(editMessageTextSpy).toHaveBeenCalledTimes(1); + expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-group-1"); + }); + it("edits commands list for pagination callbacks", async () => { onSpy.mockClear(); listSkillCommandsForAgents.mockClear(); From 0e3ed28950a383c3f4ce4591763881889dd7516c Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 14:42:47 +0530 Subject: [PATCH 33/35] fix: changelog for telegram group inline callbacks (#27343) (thanks @GodsBoy) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 844fe8eb636..7cd03a4b1d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Channels/Multi-account config: when adding a non-default channel account to a single-account top-level channel setup, move existing account-scoped top-level single-account values into `channels..accounts.default` before writing the new account so the original account keeps working without duplicated account values at channel root; `openclaw doctor --fix` now repairs previously mixed channel account shapes the same way. (#27334) thanks @gumadeiras. +- Telegram/Inline buttons: allow callback-query button handling in groups (including `/models` follow-up buttons) when group policy authorizes the sender, by removing the redundant callback allowlist gate that blocked open-policy groups. (#27343) Thanks @GodsBoy. - Daemon/macOS launchd: forward proxy env vars into supervised service environments, keep LaunchAgent `KeepAlive=true` semantics, and harden restart sequencing to `print -> bootout -> wait old pid exit -> bootstrap -> kickstart`. (#27276) thanks @frankekn. - Android/Node invoke: remove native gateway WebSocket `Origin` header to avoid false origin rejections, unify invoke command registry/policy/error parsing paths, and keep command availability checks centralized to reduce dispatcher/advertisement drift. (#27257) Thanks @obviyus. - CI/Windows: shard the Windows `checks-windows` test lane into two matrix jobs and honor explicit shard index overrides in `scripts/test-parallel.mjs` to reduce CI critical-path wall time. (#27234) Thanks @joshavant. From 30fd2bbe195ab944ab649bdb0288bdd3f77f1af0 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 14:48:35 +0530 Subject: [PATCH 34/35] fix(ssrf): honor global family policy for pinned dispatcher --- src/infra/net/ssrf.dispatcher.test.ts | 8 +++++--- src/infra/net/ssrf.ts | 2 -- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/infra/net/ssrf.dispatcher.test.ts b/src/infra/net/ssrf.dispatcher.test.ts index 0dfb816aa00..aaccebc1737 100644 --- a/src/infra/net/ssrf.dispatcher.test.ts +++ b/src/infra/net/ssrf.dispatcher.test.ts @@ -13,7 +13,7 @@ vi.mock("undici", () => ({ import { createPinnedDispatcher, type PinnedHostname } from "./ssrf.js"; describe("createPinnedDispatcher", () => { - it("enables network family auto-selection for pinned lookups", () => { + it("uses pinned lookup without overriding global family policy", () => { const lookup = vi.fn() as unknown as PinnedHostname["lookup"]; const pinned: PinnedHostname = { hostname: "api.telegram.org", @@ -27,9 +27,11 @@ describe("createPinnedDispatcher", () => { expect(agentCtor).toHaveBeenCalledWith({ connect: { lookup, - autoSelectFamily: true, - autoSelectFamilyAttemptTimeout: 300, }, }); + const firstCallArg = agentCtor.mock.calls[0]?.[0] as + | { connect?: Record } + | undefined; + expect(firstCallArg?.connect?.autoSelectFamily).toBeUndefined(); }); }); diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index 8ba29b38e2a..7798e5990a4 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -333,8 +333,6 @@ export function createPinnedDispatcher(pinned: PinnedHostname): Dispatcher { return new Agent({ connect: { lookup: pinned.lookup, - autoSelectFamily: true, - autoSelectFamilyAttemptTimeout: 300, }, }); } From a690b62391cb6c3f5d4b507b828aa97bb96c3171 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 26 Feb 2026 04:35:08 -0500 Subject: [PATCH 35/35] Doctor: ignore slash sessions in transcript integrity check Merged via deterministic merge flow. Prepared head SHA: e5cee7a2eca80e9a61021b323190786ef6a016bd Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> --- CHANGELOG.md | 1 + src/commands/doctor-state-integrity.test.ts | 24 +++++++++++++++++++++ src/commands/doctor-state-integrity.ts | 15 +++++++++++-- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cd03a4b1d1..0a28452ec99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Doctor/State integrity: ignore metadata-only slash routing sessions when checking recent missing transcripts so `openclaw doctor` no longer reports false-positive transcript-missing warnings for `*:slash:*` keys. (#27375) thanks @gumadeiras. - Channels/Multi-account config: when adding a non-default channel account to a single-account top-level channel setup, move existing account-scoped top-level single-account values into `channels..accounts.default` before writing the new account so the original account keeps working without duplicated account values at channel root; `openclaw doctor --fix` now repairs previously mixed channel account shapes the same way. (#27334) thanks @gumadeiras. - Telegram/Inline buttons: allow callback-query button handling in groups (including `/models` follow-up buttons) when group policy authorizes the sender, by removing the redundant callback allowlist gate that blocked open-policy groups. (#27343) Thanks @GodsBoy. - Daemon/macOS launchd: forward proxy env vars into supervised service environments, keep LaunchAgent `KeepAlive=true` semantics, and harden restart sequencing to `print -> bootout -> wait old pid exit -> bootstrap -> kickstart`. (#27276) thanks @frankekn. diff --git a/src/commands/doctor-state-integrity.test.ts b/src/commands/doctor-state-integrity.test.ts index ba889d28bdf..a9d28e3971b 100644 --- a/src/commands/doctor-state-integrity.test.ts +++ b/src/commands/doctor-state-integrity.test.ts @@ -171,4 +171,28 @@ describe("doctor state integrity oauth dir checks", () => { expect(text).not.toContain("--active"); expect(text).not.toContain(" ls "); }); + + it("ignores slash-routing sessions for recent missing transcript warnings", async () => { + const cfg: OpenClawConfig = {}; + setupSessionState(cfg, process.env, process.env.HOME ?? ""); + const storePath = resolveStorePath(cfg.session?.store, { agentId: "main" }); + fs.writeFileSync( + storePath, + JSON.stringify( + { + "agent:main:telegram:slash:6790081233": { + sessionId: "missing-slash-transcript", + updatedAt: Date.now(), + }, + }, + null, + 2, + ), + ); + + await noteStateIntegrity(cfg, { confirmSkipInNonInteractive: vi.fn(async () => false) }); + + const text = stateIntegrityText(); + expect(text).not.toContain("recent sessions are missing transcripts"); + }); }); diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index 2e31da8e76a..7b056a27b1e 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -16,6 +16,7 @@ import { resolveStorePath, } from "../config/sessions.js"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; +import { parseAgentSessionKey } from "../sessions/session-key-utils.js"; import { note } from "../terminal/note.js"; import { shortenHomePath } from "../utils.js"; @@ -165,6 +166,15 @@ function hasPairingPolicy(value: unknown): boolean { return false; } +function isSlashRoutingSessionKey(sessionKey: string): boolean { + const raw = sessionKey.trim().toLowerCase(); + if (!raw) { + return false; + } + const scoped = parseAgentSessionKey(raw)?.rest ?? raw; + return /^[^:]+:slash:[^:]+(?:$|:)/.test(scoped); +} + function shouldRequireOAuthDir(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { if (env.OPENCLAW_OAUTH_DIR?.trim()) { return true; @@ -413,7 +423,8 @@ export async function noteStateIntegrity( return bUpdated - aUpdated; }) .slice(0, 5); - const missing = recent.filter(([, entry]) => { + const recentTranscriptCandidates = recent.filter(([key]) => !isSlashRoutingSessionKey(key)); + const missing = recentTranscriptCandidates.filter(([, entry]) => { const sessionId = entry.sessionId; if (!sessionId) { return false; @@ -424,7 +435,7 @@ export async function noteStateIntegrity( if (missing.length > 0) { warnings.push( [ - `- ${missing.length}/${recent.length} recent sessions are missing transcripts.`, + `- ${missing.length}/${recentTranscriptCandidates.length} recent sessions are missing transcripts.`, ` Verify sessions in store: ${formatCliCommand(`openclaw sessions --store "${absoluteStorePath}"`)}`, ` Preview cleanup impact: ${formatCliCommand(`openclaw sessions cleanup --store "${absoluteStorePath}" --dry-run`)}`, ].join("\n"),