diff --git a/.github/labeler.yml b/.github/labeler.yml
index 99844795bb8d..c5abfdede433 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -245,6 +245,10 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/device-pair/**"
+"extensions: acpx":
+ - changed-files:
+ - any-glob-to-any-file:
+ - "extensions/acpx/**"
"extensions: minimax-portal-auth":
- changed-files:
- any-glob-to-any-file:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8de4f3882c8a..e7bef285a7ae 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/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml
index cf6a7070aa0b..ae2d46719654 100644
--- a/.github/workflows/docker-release.yml
+++ b/.github/workflows/docker-release.yml
@@ -168,6 +168,9 @@ jobs:
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
version="${GITHUB_REF#refs/tags/v}"
tags+=("${IMAGE}:${version}")
+ if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then
+ tags+=("${IMAGE}:latest")
+ fi
fi
if [[ ${#tags[@]} -eq 0 ]]; then
echo "::error::No manifest tags resolved for ref ${GITHUB_REF}"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f6e1a71e0c9b..39f57d947ad7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,27 +2,97 @@
Docs: https://docs.openclaw.ai
-## 2026.2.25 (Unreleased)
+## 2026.2.26 (Unreleased)
+
+### Changes
+
+- ACP/Thread-bound agents: make ACP agents first-class runtimes for thread sessions with `acp` spawn/send dispatch integration, acpx backend bridging, lifecycle controls, startup reconciliation, runtime cleanup, and coalesced thread replies. (#23580) thanks @osolmaz.
+- 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.
+- Onboarding/Plugins: let channel plugins own interactive onboarding flows with optional `configureInteractive` and `configureWhenConfigured` hooks while preserving the generic fallback path. (#27191) thanks @gumadeiras.
+
+### 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.
+- 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.
+- 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.
+- 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. Thanks @gumadeiras.
+
+## 2026.2.25
### Changes
- Android/Chat: improve streaming delivery handling and markdown rendering quality in the native Android chat UI, including better GitHub-flavored markdown behavior. (#26079) Thanks @obviyus.
+- Android/Startup perf: defer foreground-service startup, move WebView debugging init out of critical startup, and add startup macrobenchmark + low-noise perf CLI scripts for deterministic cold-start tracking. (#26659) Thanks @obviyus.
+- 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.
- 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.
+
+### Breaking
+
+- **BREAKING:** Heartbeat direct/DM delivery default is now `allow` again. To keep DM-blocked behavior from `2026.2.24`, set `agents.defaults.heartbeat.directPolicy: "block"` (or per-agent override).
### Fixes
-- Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3.
-- Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3.
-- Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3.
-- Security/Microsoft Teams: isolate group allowlist and command authorization from DM pairing-store entries to prevent cross-context authorization bleed. (#26111) Thanks @bmendonca3.
-- Security/LINE: cap unsigned webhook body reads before auth/signature handling to bound unauthenticated body processing. (#26095) Thanks @bmendonca3.
-- Agents/Model fallback: keep explicit text + image fallback chains reachable even when `agents.defaults.models` allowlists are present, prefer explicit run `agentId` over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify `model_cooldown` / `cooling down` errors as `rate_limit` so failover continues. (#11972, #24137, #17231)
+- 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.
+- 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.
-- Agents/Model fallback: continue fallback traversal on unrecognized errors when candidates remain, while still throwing the original unknown error on the last candidate. (#26106) Thanks @Sid-Qin.
+- Cron/Announce duplicate guard: track attempted announce/direct delivery separately from confirmed `delivered`, and suppress fallback main-session cron summaries when delivery was already attempted to avoid duplicate end-user sends in uncertain-ack paths. (#27018)
+- LINE/Lifecycle: keep LINE `startAccount` pending until abort so webhook startup is no longer misread as immediate channel exit, preventing restart-loop storms on LINE provider boot. (#26528) Thanks @Sid-Qin.
+- Discord/Gateway: capture and drain startup-time gateway `error` events before lifecycle listeners attach so early `Fatal Gateway error: 4014` closes surface as actionable intent guidance instead of uncaught gateway crashes. (#23832) Thanks @theotarr.
+- Discord/Inbound text: preserve embed `title` + `description` fallback text in message and forwarded snapshot parsing so embed titles are not silently dropped from agent input. (#26946) Thanks @stakeswky.
+- Slack/Inbound media fallback: deliver file-only messages even when Slack media downloads fail by adding a filename placeholder fallback, capping fallback names to the shared media-file limit, and normalizing empty filenames to `file` so attachment-only messages are not silently dropped. (#25181) Thanks @justinhuangcode.
+- Telegram/Preview cleanup: keep finalized text previews when a later assistant message is media-only (for example mixed text plus voice turns) by skipping finalized preview archival at assistant-message boundaries, preventing cleanup from deleting already-visible final text messages. (#27042)
- Telegram/Markdown spoilers: keep valid `||spoiler||` pairs while leaving unmatched trailing `||` delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin.
+- Slack/Allowlist channels: match channel IDs case-insensitively during channel allowlist resolution so lowercase config keys (for example `c0abc12345`) correctly match Slack runtime IDs (`C0ABC12345`) under `groupPolicy: "allowlist"`, preventing silent channel-event drops. (#26878) Thanks @lbo728.
+- Discord/Typing indicator: prevent stuck typing indicators by sealing channel typing keepalive callbacks after idle/cleanup and ensuring Discord dispatch always marks typing idle even if preview-stream cleanup fails. (#26295) Thanks @ngutman.
+- Channels/Typing indicator: guard typing keepalive start callbacks after idle/cleanup close so post-close ticks cannot re-trigger stale typing indicators. (#26325) Thanks @win4r.
+- Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including `NO_REPLY`, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881) Thanks @codexGW.
+- Voice-call/TTS tools: hide the `tts` tool when the message provider is `voice`, preventing voice-call runs from selecting self-playback TTS and falling into silent no-output loops. (#27025)
+- Agents/Tools: normalize non-standard plugin tool results that omit `content` so embedded runs no longer crash with `Cannot read properties of undefined (reading 'filter')` after tool completion (including `tesseramemo_query`). (#27007)
+- Cron/Model overrides: when isolated `payload.model` is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972.
+- Agents/Model fallback: keep explicit text + image fallback chains reachable even when `agents.defaults.models` allowlists are present, prefer explicit run `agentId` over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify `model_cooldown` / `cooling down` errors as `rate_limit` so failover continues. (#11972, #24137, #17231)
+- Agents/Model fallback: keep same-provider fallback chains active when session model differs from configured primary, infer cooldown reason from provider profile state (instead of `disabledReason` only), keep no-profile fallback providers eligible (env/models.json paths), and only relax same-provider cooldown fallback attempts for `rate_limit`. (#23816) thanks @ramezgaberiel.
+- Agents/Model fallback: continue fallback traversal on unrecognized errors when candidates remain, while still throwing the original unknown error on the last candidate. (#26106) Thanks @Sid-Qin.
+- Models/Auth probes: map permanent auth failover reasons (`auth_permanent`, for example revoked keys) into probe auth status instead of `unknown`, so `openclaw models status --probe` reports actionable auth failures. (#25754) thanks @rrenamed.
- Hooks/Inbound metadata: include `guildId` and `channelName` in `message_received` metadata for both plugin and internal hook paths. (#26115) Thanks @davidrudduck.
- Discord/Component auth: evaluate guild component interactions with command-gating authorizers so unauthorized users no longer get `CommandAuthorized: true` on modal/button events. (#26119) Thanks @bmendonca3.
-- Slack/Inbound media fallback: deliver file-only messages even when Slack media downloads fail by adding a filename placeholder fallback, capping fallback names to the shared media-file limit, and normalizing empty filenames to `file` so attachment-only messages are not silently dropped. (#25181) Thanks @justinhuangcode.
+- Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting.
+- Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (`2026.2.25`). Thanks @luz-oasis for reporting.
+- Security/Gateway trusted proxy: require `operator` role for the Control UI trusted-proxy pairing bypass so unpaired `node` sessions can no longer connect via `client.id=control-ui` and invoke node event methods. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
+- Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.25`). Thanks @zdi-disclosures for reporting.
+- Security/Microsoft Teams file consent: bind `fileConsent/invoke` upload acceptance/decline to the originating conversation before consuming pending uploads, preventing cross-conversation pending-file upload or cancellation via leaked `uploadId` values; includes regression coverage for match/mismatch invoke handling. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
+- Security/Gateway: harden `agents.files` path handling to block out-of-workspace symlink targets for `agents.files.get`/`agents.files.set`, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting.
+- Security/Workspace FS: reject hardlinked workspace file aliases in `tools.fs.workspaceOnly` and `tools.exec.applyPatch.workspaceOnly` boundary checks (including sandbox mount-root guards) to prevent out-of-workspace read/write via in-workspace hardlink paths. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
+- Security/Browser temp paths: harden trace/download output-path handling against symlink-root and symlink-parent escapes with realpath-based write-path checks plus secure fallback tmp-dir validation that fails closed on unsafe fallback links. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
+- Security/Browser uploads: revalidate upload paths at use-time in Playwright file-chooser and direct-input flows so missing/rebound paths are rejected before `setFiles`, with regression coverage for strict missing-path handling.
+- Security/Exec approvals: bind `system.run` approval matching to exact argv identity and preserve argv whitespace in rendered command text, preventing trailing-space executable path swaps from reusing a mismatched approval. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
+- Security/Exec approvals: harden approval-bound `system.run` execution on node hosts by rejecting symlink `cwd` paths and canonicalizing path-like executable argv before spawn, blocking mutable-cwd symlink retarget chains between approval and execution. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
+- Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
+- Security/Discord reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild `groupPolicy` channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
+- Security/Slack reactions + pins: gate `reaction_*` and `pin_*` system-event enqueue through shared sender authorization so DM `dmPolicy`/`allowFrom` and channel `users` allowlists are enforced consistently for non-message ingress, with regression coverage for denied/allowed sender paths. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
+- Security/Telegram reactions: enforce `dmPolicy`/`allowFrom` and group allowlist authorization on `message_reaction` events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
+- Security/Telegram group allowlist: fail closed for group sender authorization by removing DM pairing-store fallback from group allowlist evaluation; group sender access now requires explicit `groupAllowFrom` or per-group/per-topic `allowFrom`. (#25988) Thanks @bmendonca3.
+- Security/Slack interactions: enforce channel/DM authorization and modal actor binding (`private_metadata.userId`) before enqueueing `block_action`/`view_submission`/`view_closed` system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
+- Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting.
+- Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3.
+- Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3.
+- Security/LINE: cap unsigned webhook body reads before auth/signature handling to bound unauthenticated body processing. (#26095) Thanks @bmendonca3.
+- Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3.
+- Security/Microsoft Teams: isolate group allowlist and command authorization from DM pairing-store entries to prevent cross-context authorization bleed. (#26111) Thanks @bmendonca3.
+- Security/SSRF guard: classify IPv6 multicast literals (`ff00::/8`) as blocked/private-internal targets in shared SSRF IP checks, preventing multicast literals from bypassing URL-host preflight and DNS answer validation. This ships in the next npm release (`2026.2.25`). Thanks @zpbrent for reporting.
+- Tests/Low-memory stability: disable Vitest `vmForks` by default on low-memory local hosts (`<64 GiB`), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with `setSessionRuntimeModel` usage to avoid deterministic suite failures. (#26324) Thanks @ngutman.
## 2026.2.24
diff --git a/Dockerfile b/Dockerfile
index 255340cb02bf..2229a299a560 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -23,7 +23,9 @@ COPY --chown=node:node patches ./patches
COPY --chown=node:node scripts ./scripts
USER node
-RUN pnpm install --frozen-lockfile
+# Reduce OOM risk on low-memory hosts during dependency installation.
+# Docker builds on small VMs may otherwise fail with "Killed" (exit 137).
+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/PR_STATUS.md b/PR_STATUS.md
deleted file mode 100644
index 1887eca27d95..000000000000
--- a/PR_STATUS.md
+++ /dev/null
@@ -1,78 +0,0 @@
-# OpenClaw PR Submission Status
-
-> Auto-maintained by agent team. Last updated: 2026-02-22
-
-## PR Plan Overview
-
-All PRs target upstream `openclaw/openclaw` via fork `kevinWangSheng/openclaw`.
-Each PR follows [CONTRIBUTING.md](./CONTRIBUTING.md) and uses the [PR template](./.github/PULL_REQUEST_TEMPLATE.md).
-
-## Duplicate Check
-
-Before submission, each PR was cross-referenced against:
-
-- 100+ open upstream PRs (as of 2026-02-22)
-- 50 recently merged PRs
-- 50+ open issues
-
-No overlap found with existing PRs.
-
-## PR Status Table
-
-| # | Branch | Title | Type | Status | PR URL |
-| --- | -------------------------------------- | --------------------------------------------------------------------------- | -------- | --------------- | --------------------------------------------------------- |
-| 1 | `security/redos-safe-regex` | fix(security): add ReDoS protection for user-controlled regex patterns | Security | CI Pass | [#23670](https://github.com/openclaw/openclaw/pull/23670) |
-| 2 | `security/session-slug-crypto-random` | fix(security): use crypto.randomInt for session slug generation | Security | CI Pass | [#23671](https://github.com/openclaw/openclaw/pull/23671) |
-| 3 | `fix/json-parse-crash-guard` | fix(resilience): guard JSON.parse of external process output with try-catch | Bug fix | CI Pass | [#23672](https://github.com/openclaw/openclaw/pull/23672) |
-| 4 | `refactor/console-to-subsystem-logger` | refactor(logging): migrate remaining console calls to subsystem logger | Refactor | CI Pass | [#23669](https://github.com/openclaw/openclaw/pull/23669) |
-| 5 | `fix/sanitize-rpc-error-messages` | fix(security): sanitize RPC error messages in signal and imessage clients | Security | CI Pass | [#23724](https://github.com/openclaw/openclaw/pull/23724) |
-| 6 | `fix/download-stream-cleanup` | fix(resilience): destroy write streams on download errors | Bug fix | CI Pass | [#23726](https://github.com/openclaw/openclaw/pull/23726) |
-| 7 | `fix/telegram-status-reaction-cleanup` | fix(telegram): clear done reaction when removeAckAfterReply is true | Bug fix | CI Pass | [#23728](https://github.com/openclaw/openclaw/pull/23728) |
-| 8 | `fix/session-cache-eviction` | fix(memory): add max size eviction to session manager cache | Bug fix | CI Pass (17/17) | [#23744](https://github.com/openclaw/openclaw/pull/23744) |
-| 9 | `fix/fetch-missing-timeout` | fix(resilience): add timeout to unguarded fetch calls in browser subsystem | Bug fix | CI Pass (18/18) | [#23745](https://github.com/openclaw/openclaw/pull/23745) |
-| 10 | `fix/skills-download-partial-cleanup` | fix(resilience): clean up partial file on skill download failure | Bug fix | CI Pass (19/19) | [#24141](https://github.com/openclaw/openclaw/pull/24141) |
-| 11 | `fix/extension-relay-stop-cleanup` | fix(browser): flush pending extension timers on relay stop | Bug fix | CI Pass (20/20) | [#24142](https://github.com/openclaw/openclaw/pull/24142) |
-
-## Isolation Rules
-
-- Each agent works on a separate git worktree branch
-- No two agents modify the same file
-- File ownership:
- - PR 1: `src/infra/exec-approval-forwarder.ts`, `src/discord/monitor/exec-approvals.ts`
- - PR 2: `src/agents/session-slug.ts`
- - PR 3: `src/infra/bonjour-discovery.ts`, `src/infra/outbound/delivery-queue.ts`
- - PR 4: `src/infra/tailscale.ts`, `src/node-host/runner.ts`
- - PR 5: `src/signal/client.ts`, `src/imessage/client.ts`
- - PR 6: `src/media/store.ts`, `src/commands/signal-install.ts`
- - PR 7: `src/telegram/bot-message-dispatch.ts`
- - PR 8: `src/agents/pi-embedded-runner/session-manager-cache.ts`
- - PR 9: `src/cli/nodes-camera.ts`, `src/browser/pw-session.ts`
- - PR 10: `src/agents/skills-install-download.ts`
- - PR 11: `src/browser/extension-relay.ts`
-
-## Verification Results
-
-### Batch 1 (PRs 1-4) — All CI Green
-
-- PR 1: 17 tests pass, check/build/tests all green
-- PR 2: 3 tests pass, check/build/tests all green
-- PR 3: 45 tests pass (3 new), check/build/tests all green
-- PR 4: 12 tests pass, check/build/tests all green
-
-### Batch 2 (PRs 5-7) — CI Running
-
-- PR 5: 3 signal tests pass, check pass, awaiting full test suite
-- PR 6: 38 tests pass (20 media + 18 signal-install), check pass, awaiting full suite
-- PR 7: 47 tests pass (3 new), check pass, awaiting full suite
-
-### Batch 3 (PRs 8-9) — All CI Green
-
-- PR 8 & 9: Initially failed due to pre-existing upstream TS errors + Windows flaky test. Fixed by rebasing onto latest upstream/main and removing `yieldMs: 10` from flaky sandbox test.
-- PR 8: 17/17 pass, check/build/tests/windows all green
-- PR 9: 18/18 pass, check/build/tests/windows all green
-
-### Batch 4 (PRs 10-11) — All CI Green
-
-- PR 10 & 11: Initially failed Windows flaky test (`yieldMs: 10` race). Fixed by removing `yieldMs: 10` from flaky sandbox test (same fix as PRs 8-9).
-- PR 10: 19/19 pass, check/build/tests/windows all green
-- PR 11: 20/20 pass, check/build/tests/windows all green
diff --git a/appcast.xml b/appcast.xml
index 902d60972fd7..f5eb16999343 100644
--- a/appcast.xml
+++ b/appcast.xml
@@ -209,106 +209,84 @@
-
-
2026.2.24
- Wed, 25 Feb 2026 02:59:30 +0000
+ 2026.2.25
+ Thu, 26 Feb 2026 05:14:17 +0100
https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
- 14728
- 2026.2.24
+ 14883
+ 2026.2.25
15.0
- OpenClaw 2026.2.24
+ OpenClaw 2026.2.25
Changes
-Auto-reply/Abort shortcuts: expand standalone stop phrases (stop openclaw, stop action, stop run, stop agent, please stop, and related variants), accept trailing punctuation (for example STOP OPENCLAW!!!), add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms), and treat exact do not do that as a stop trigger while preserving strict standalone matching. (#25103) Thanks @steipete and @vincentkoc.
-Android/App UX: ship a native four-step onboarding flow, move post-onboarding into a five-tab shell (Connect, Chat, Voice, Screen, Settings), add a full Connect setup/manual mode screen, and refresh Android chat/settings surfaces for the new navigation model.
-Talk/Gateway config: add provider-agnostic Talk configuration with legacy compatibility, and expose gateway Talk ElevenLabs config metadata for setup/status surfaces.
-Security/Audit: add security.trust_model.multi_user_heuristic to flag likely shared-user ingress and clarify the personal-assistant trust model, with hardening guidance for intentional multi-user setups (sandbox.mode="all", workspace-scoped FS, reduced tool surface, no personal/private identities on shared runtimes).
-Dependencies: refresh key runtime and tooling packages across the workspace (Bedrock SDK, pi runtime stack, OpenAI, Google auth, and oxlint/oxfmt), while intentionally keeping @buape/carbon pinned.
+Android/Chat: improve streaming delivery handling and markdown rendering quality in the native Android chat UI, including better GitHub-flavored markdown behavior. (#26079) Thanks @obviyus.
+Android/Startup perf: defer foreground-service startup, move WebView debugging init out of critical startup, and add startup macrobenchmark + low-noise perf CLI scripts for deterministic cold-start tracking. (#26659) Thanks @obviyus.
+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.
+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.
Breaking
-BREAKING: Heartbeat delivery now blocks direct/DM targets when destination parsing identifies a direct chat (for example user:, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages.
-BREAKING: Security/Sandbox: block Docker network: "container:" namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true (break-glass). Thanks @tdjackey for reporting.
+BREAKING: Heartbeat direct/DM delivery default is now allow again. To keep DM-blocked behavior from 2026.2.24, set agents.defaults.heartbeat.directPolicy: "block" (or per-agent override).
Fixes
-Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (channel/to/thread) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner.
-Security/Routing: fail closed for shared-session cross-channel replies by binding outbound target resolution to the current turn’s source channel metadata (instead of stale session route fallbacks), and wire those turn-source fields through gateway + command delivery planners with regression coverage. (#24571) Thanks @brandonwise.
-Heartbeat routing: prevent heartbeat leakage/spam into Discord and other direct-message destinations by blocking direct-chat heartbeat delivery targets and keeping blocked-delivery cron/exec prompts internal-only. (#25871)
-Heartbeat defaults/prompts: switch the implicit heartbeat delivery target from last to none (opt-in for external delivery), and use internal-only cron/exec heartbeat prompt wording when delivery is disabled so background checks do not nudge user-facing relay behavior. (#25871, #24638, #25851)
-Auto-reply/Heartbeat queueing: drop heartbeat runs when a session already has an active run instead of enqueueing a stale followup, preventing duplicate heartbeat response branches after queue drain. (#25610, #25606) Thanks @mcaxtr.
-Cron/Heartbeat delivery: stop inheriting cached session lastThreadId for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl.
-Messaging tool dedupe: treat originating channel metadata as authoritative for same-target message.send suppression in proactive runs (heartbeat/cron/exec-event), including synthetic-provider contexts, so delivery-mirror transcript entries no longer cause duplicate Telegram sends. (#25835) Thanks @jadeathena84-arch.
-Channels/Typing keepalive: refresh channel typing callbacks on a keepalive interval during long replies and clear keepalive timers on idle/cleanup across core + extension dispatcher callsites so typing indicators do not expire mid-inference. (#25886, #25882) Thanks @stakeswky.
-Agents/Model fallback: when a run is currently on a configured fallback model, keep traversing the configured fallback chain instead of collapsing straight to primary-only, preventing dead-end failures when primary stays in cooldown. (#25922, #25912) Thanks @Taskle.
-Gateway/Models: honor explicit agents.defaults.models allowlist refs even when bundled model catalog data is stale, synthesize missing allowlist entries in models.list, and allow sessions.patch//model selection for those refs without false model not allowed errors. (#20291) Thanks @kensipe, @nikolasdehor, and @vincentkoc.
-Control UI/Agents: inherit agents.defaults.model.fallbacks in the Overview fallback input when no per-agent model entry exists, while preserving explicit per-agent fallback overrides (including empty lists). (#25729, #25710) Thanks @Suko.
-Automation/Subagent/Cron reliability: honor ANNOUNCE_SKIP in sessions_spawn completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include cron in the coding tool profile so /tools/invoke can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky.
-Discord/Voice reliability: restore runtime DAVE dependency (@snazzah/davey), add configurable DAVE join options (channels.discord.voice.daveEncryption and channels.discord.voice.decryptionFailureTolerance), clean up voice listeners/session teardown, guard against stale connection events, and trigger controlled rejoin recovery after repeated decrypt failures to improve inbound STT stability under DAVE receive errors. (#25861, #25372, #24883, #24825, #23890, #23105, #22961, #23421, #23278, #23032)
-Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all block payloads), fixing missing Discord replies in channels.discord.streaming=block mode. (#25839, #25836, #25792) Thanks @pewallin.
-Discord/Proxy + reactions + model picker: thread channel proxy fetch into inbound media/sticker downloads, use proxy-aware gateway metadata fetch for WSL/corporate proxy setups, wire messages.statusReactions.{emojis,timing} into Discord reaction lifecycle control, and compact model-picker custom_id keys to stay under Discord's 100-char limit while keeping backward-compatible parsing. (#25232, #25507, #25564, #25695) Thanks @openperf, @chilu18, @Yipsh, @lbo728, and @s1korrrr.
-WhatsApp/Web reconnect: treat close status 440 as non-retryable (including string-form status values), stop reconnect loops immediately, and emit operator guidance to relink after resolving session conflicts. (#25858) Thanks @markmusson.
-WhatsApp/Reasoning safety: suppress outbound payloads marked as reasoning and hard-drop text payloads that begin with Reasoning: before WhatsApp delivery, preventing hidden thinking blocks from leaking to end users through final-message paths. (#25804, #25214, #24328)
-Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall.
-Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg.
-Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg.
-Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram autoSelectFamily decisions so outbound fetch calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis.
-Onboarding/Telegram: keep core-channel onboarding available when plugin registry population is missing by falling back to built-in adapters and continuing wizard setup with actionable recovery guidance. (#25803) Thanks @Suko.
-Android/Gateway auth: preserve Android gateway auth state across onboarding, use the native client id for operator sessions, retry with shared-token fallback after device-token auth failures, and avoid clearing tokens on transient connect errors.
-Slack/DM routing: treat D* channel IDs as direct messages even when Slack sends an incorrect channel_type, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr.
-Zalo/Group policy: enforce sender authorization for group messages with groupPolicy + groupAllowFrom (fallback to allowFrom), default runtime group behavior to fail-closed allowlist, and block unauthorized non-command group messages before dispatch. Thanks @tdjackey for reporting.
-macOS/Voice input: guard all audio-input startup paths against missing default microphones (Voice Wake, Talk Mode, Push-to-Talk, mic-level monitor, tester) to avoid launch/runtime crashes on mic-less Macs and fail gracefully until input becomes available. (#25817) Thanks @sfo2001.
-macOS/IME input: when marked text is active, treat Return as IME candidate confirmation first in both the voice overlay composer and shared chat composer to prevent accidental sends while composing CJK text. (#25178) Thanks @bottotl.
-macOS/Voice wake routing: default forwarded voice-wake transcripts to the webchat channel (instead of ambiguous last routing) so local voice prompts stay pinned to the control chat surface unless explicitly overridden. (#25440) Thanks @chilu18.
-macOS/Gateway launch: prefer an available openclaw binary before pnpm/node runtime fallback when resolving local gateway commands, so local startup no longer fails on hosts with broken runtime discovery. (#25512) Thanks @chilu18.
-macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai.
-macOS/WebChat panel: fix rounded-corner clipping by using panel-specific visual-effect blending and matching corner masking on both effect and hosting layers. (#22458) Thanks @apethree and @agisilaos.
-Windows/Exec shell selection: prefer PowerShell 7 (pwsh) discovery (Program Files, ProgramW6432, PATH) before falling back to Windows PowerShell 5.1, fixing && command chaining failures on Windows hosts with PS7 installed. (#25684, #25638) Thanks @zerone0x.
-Windows/Media safety checks: align async local-file identity validation with sync-safe-open behavior by treating win32 dev=0 stats as unknown-device fallbacks (while keeping strict dev checks when both sides are non-zero), fixing false Local media path is not safe to read drops for local attachments/TTS/images. (#25708, #21989, #25699, #25878) Thanks @kevinWangSheng.
-iMessage/Reasoning safety: harden iMessage echo suppression with outbound messageId matching (plus scoped text fallback), and enforce reasoning-payload suppression on routed outbound delivery paths to prevent hidden thinking text from being sent as user-visible channel messages. (#25897, #1649, #25757) Thanks @rmarr and @Iranb.
-Providers/OpenRouter/Auth profiles: bypass auth-profile cooldown/disable windows for OpenRouter, so provider failures no longer put OpenRouter profiles into local cooldown and stale legacy cooldown markers are ignored in fallback and status selection paths. (#25892) Thanks @alexanderatallah for raising this and @vincentkoc for the fix.
-Providers/Google reasoning: sanitize invalid negative thinkingBudget payloads for Gemini 3.1 requests by dropping -1 budgets and mapping configured reasoning effort to thinkingLevel, preventing malformed reasoning payloads on google-generative-ai. (#25900)
-Providers/SiliconFlow: normalize thinking="off" to thinking: null for Pro/* model payloads to avoid provider-side 400 loops and misleading compaction retries. (#25435) Thanks @Zjianru.
-Models/Bedrock auth: normalize additional Bedrock provider aliases (bedrock, aws-bedrock, aws_bedrock, amazon bedrock) to canonical amazon-bedrock, ensuring auth-mode resolution consistently selects AWS SDK fallback. (#25756) Thanks @fwhite13.
-Models/Providers: preserve explicit user reasoning overrides when merging provider model config with built-in catalog metadata, so reasoning: false is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728.
-Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false pairing required failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber.
-CLI/Memory search: accept --query for openclaw memory search (while keeping positional query support), and emit a clear error when neither form is provided. (#25904, #25857) Thanks @niceysam and @stakeswky.
-CLI/Doctor: correct stale recovery hints to use valid commands (openclaw gateway status --deep and openclaw configure --section model). (#24485) Thanks @chilu18.
-Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr.
-Doctor/Plugins: auto-enable now resolves third-party channel plugins by manifest plugin id (not channel id), preventing invalid plugins.entries. writes when ids differ. (#25275) Thanks @zerone0x.
-Config/Plugins: treat stale removed google-antigravity-auth plugin references as compatibility warnings (not hard validation errors) across plugins.entries, plugins.allow, plugins.deny, and plugins.slots.memory, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18.
-Config/Meta: accept numeric meta.lastTouchedAt timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write Date.now() values. (#25491) Thanks @mcaxtr.
-Usage accounting: parse Moonshot/Kimi cached_tokens fields (including prompt_tokens_details.cached_tokens) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001.
-Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber.
-Agents/Billing classification: prevent long assistant/user-facing text from being rewritten as billing failures while preserving explicit status/code/http 402 detection for oversized structured error payloads. (#25680, #25661) Thanks @lairtonlelis.
-Sessions/Tool-result guard: avoid generating synthetic toolResult entries for assistant turns that ended with stopReason: "aborted" or "error", preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell.
-Auto-reply/Reset hooks: guarantee native /new and /reset flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18.
-Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi.
-Sandbox/FS bridge tests: add regression coverage for dash-leading basenames to confirm sandbox file reads resolve to absolute container paths (and avoid shell-option misdiagnosis for dashed filenames). (#25891) Thanks @albertlieyingadrian.
-Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not ; joins) to avoid POSIX sh do; syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility.
-Sandbox/Config: preserve dangerouslyAllowReservedContainerTargets and dangerouslyAllowExternalBindSources during sandbox docker config resolution so explicit bind-mount break-glass overrides reach runtime validation. (#25410) Thanks @skyer-jian.
-Gateway/Security: enforce gateway auth for the exact /api/channels plugin root path (plus /api/channels/ descendants), with regression coverage for query/trailing-slash variants and near-miss paths that must remain plugin-owned. (#25753) Thanks @bmendonca3.
-Exec approvals: treat bare allowlist * as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber.
-iOS/Signing: improve scripts/ios-team-id.sh for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode xcodebuild output directories (apps/ios/build, apps/shared/OpenClawKit/build, Swabble/build). (#22773) Thanks @brianleach.
-Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd.
-Security/Exec: sanitize inherited host execution environment before merge, canonicalize inherited PATH handling, and strip dangerous keys (LD_*, DYLD_*, SSLKEYLOGFILE, and related injection vectors) from non-sandboxed exec runs. (#25755) Thanks @bmendonca3.
-Security/Hooks: normalize hook session-key classification with trim/lowercase plus Unicode NFKC folding (for example full-width HOOK:...) so external-content wrapping cannot be bypassed by mixed-case or lookalike prefixes. (#25750) Thanks @bmendonca3.
-Security/Voice Call: add Telnyx webhook replay detection and canonicalize replay-key signature encoding (Base64/Base64URL equivalent forms dedupe together), so duplicate signed webhook deliveries no longer re-trigger side effects. (#25832) Thanks @bmendonca3.
-Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host os.tmpdir() trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. Thanks @tdjackey for reporting.
-Security/Sandbox media: reject hard-linked OpenClaw tmp media aliases (including symlink-to-hardlink chains) during sandbox media path resolution to prevent out-of-sandbox inode alias reads. (#25820) Thanks @bmendonca3.
-Security/Message actions: enforce local media root checks for sendAttachment and setGroupIcon when sandboxRoot is unset, preventing attachment hydration from reading arbitrary host files via local absolute paths. Thanks @GCXWLP for reporting.
-Security/Telegram: enforce DM authorization before media download/write (including media groups) and move telegram inbound activity tracking after DM authorization, preventing unauthorized sender-triggered inbound media disk writes. Thanks @v8hid for reporting.
-Security/Workspace FS: normalize @-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. Thanks @tdjackey for reporting.
-Security/Synology Chat: enforce fail-closed allowlist behavior for DM ingress so dmPolicy: "allowlist" with empty allowedUserIds rejects all senders instead of allowing unauthorized dispatch. (#25827) Thanks @bmendonca3 for the contribution and @tdjackey for reporting.
-Security/Native images: enforce tools.fs.workspaceOnly for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. Thanks @tdjackey for reporting.
-Security/Exec approvals: bind system.run command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only rawCommand mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. Thanks @tdjackey for reporting.
-Security/Exec companion host: forward canonical system.run display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. Thanks @tdjackey for reporting.
-Security/Exec approvals: fail closed when transparent dispatch-wrapper unwrapping exceeds the depth cap, so nested /usr/bin/env chains cannot bypass shell-wrapper approval gating in allowlist + ask=on-miss mode. Thanks @tdjackey for reporting.
-Security/Exec: limit default safe-bin trusted directories to immutable system paths (/bin, /usr/bin) and require explicit opt-in (tools.exec.safeBinTrustedDirs) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured safeBins resolve outside trusted dirs. Thanks @tdjackey for reporting.
-Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey.
+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.
+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.
+Cron/Announce duplicate guard: track attempted announce/direct delivery separately from confirmed delivered, and suppress fallback main-session cron summaries when delivery was already attempted to avoid duplicate end-user sends in uncertain-ack paths. (#27018)
+LINE/Lifecycle: keep LINE startAccount pending until abort so webhook startup is no longer misread as immediate channel exit, preventing restart-loop storms on LINE provider boot. (#26528) Thanks @Sid-Qin.
+Discord/Gateway: capture and drain startup-time gateway error events before lifecycle listeners attach so early Fatal Gateway error: 4014 closes surface as actionable intent guidance instead of uncaught gateway crashes. (#23832) Thanks @theotarr.
+Discord/Inbound text: preserve embed title + description fallback text in message and forwarded snapshot parsing so embed titles are not silently dropped from agent input. (#26946) Thanks @stakeswky.
+Slack/Inbound media fallback: deliver file-only messages even when Slack media downloads fail by adding a filename placeholder fallback, capping fallback names to the shared media-file limit, and normalizing empty filenames to file so attachment-only messages are not silently dropped. (#25181) Thanks @justinhuangcode.
+Telegram/Preview cleanup: keep finalized text previews when a later assistant message is media-only (for example mixed text plus voice turns) by skipping finalized preview archival at assistant-message boundaries, preventing cleanup from deleting already-visible final text messages. (#27042)
+Telegram/Markdown spoilers: keep valid ||spoiler|| pairs while leaving unmatched trailing || delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin.
+Slack/Allowlist channels: match channel IDs case-insensitively during channel allowlist resolution so lowercase config keys (for example c0abc12345) correctly match Slack runtime IDs (C0ABC12345) under groupPolicy: "allowlist", preventing silent channel-event drops. (#26878) Thanks @lbo728.
+Discord/Typing indicator: prevent stuck typing indicators by sealing channel typing keepalive callbacks after idle/cleanup and ensuring Discord dispatch always marks typing idle even if preview-stream cleanup fails. (#26295) Thanks @ngutman.
+Channels/Typing indicator: guard typing keepalive start callbacks after idle/cleanup close so post-close ticks cannot re-trigger stale typing indicators. (#26325) Thanks @win4r.
+Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including NO_REPLY, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881) Thanks @codexGW.
+Voice-call/TTS tools: hide the tts tool when the message provider is voice, preventing voice-call runs from selecting self-playback TTS and falling into silent no-output loops. (#27025)
+Agents/Tools: normalize non-standard plugin tool results that omit content so embedded runs no longer crash with Cannot read properties of undefined (reading 'filter') after tool completion (including tesseramemo_query). (#27007)
+Cron/Model overrides: when isolated payload.model is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972.
+Agents/Model fallback: keep explicit text + image fallback chains reachable even when agents.defaults.models allowlists are present, prefer explicit run agentId over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify model_cooldown / cooling down errors as rate_limit so failover continues. (#11972, #24137, #17231)
+Agents/Model fallback: keep same-provider fallback chains active when session model differs from configured primary, infer cooldown reason from provider profile state (instead of disabledReason only), keep no-profile fallback providers eligible (env/models.json paths), and only relax same-provider cooldown fallback attempts for rate_limit. (#23816) thanks @ramezgaberiel.
+Agents/Model fallback: continue fallback traversal on unrecognized errors when candidates remain, while still throwing the original unknown error on the last candidate. (#26106) Thanks @Sid-Qin.
+Models/Auth probes: map permanent auth failover reasons (auth_permanent, for example revoked keys) into probe auth status instead of unknown, so openclaw models status --probe reports actionable auth failures. (#25754) thanks @rrenamed.
+Hooks/Inbound metadata: include guildId and channelName in message_received metadata for both plugin and internal hook paths. (#26115) Thanks @davidrudduck.
+Discord/Component auth: evaluate guild component interactions with command-gating authorizers so unauthorized users no longer get CommandAuthorized: true on modal/button events. (#26119) Thanks @bmendonca3.
+Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting.
+Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (2026.2.25). Thanks @luz-oasis for reporting.
+Security/Gateway trusted proxy: require operator role for the Control UI trusted-proxy pairing bypass so unpaired node sessions can no longer connect via client.id=control-ui and invoke node event methods. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
+Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy oauth.json onboarding path that exposed the PKCE verifier via OAuth state; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (2026.2.25). Thanks @zdi-disclosures for reporting.
+Security/Microsoft Teams file consent: bind fileConsent/invoke upload acceptance/decline to the originating conversation before consuming pending uploads, preventing cross-conversation pending-file upload or cancellation via leaked uploadId values; includes regression coverage for match/mismatch invoke handling. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
+Security/Gateway: harden agents.files path handling to block out-of-workspace symlink targets for agents.files.get/agents.files.set, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting.
+Security/Workspace FS: reject hardlinked workspace file aliases in tools.fs.workspaceOnly and tools.exec.applyPatch.workspaceOnly boundary checks (including sandbox mount-root guards) to prevent out-of-workspace read/write via in-workspace hardlink paths. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
+Security/Browser temp paths: harden trace/download output-path handling against symlink-root and symlink-parent escapes with realpath-based write-path checks plus secure fallback tmp-dir validation that fails closed on unsafe fallback links. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
+Security/Browser uploads: revalidate upload paths at use-time in Playwright file-chooser and direct-input flows so missing/rebound paths are rejected before setFiles, with regression coverage for strict missing-path handling.
+Security/Exec approvals: bind system.run approval matching to exact argv identity and preserve argv whitespace in rendered command text, preventing trailing-space executable path swaps from reusing a mismatched approval. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
+Security/Exec approvals: harden approval-bound system.run execution on node hosts by rejecting symlink cwd paths and canonicalizing path-like executable argv before spawn, blocking mutable-cwd symlink retarget chains between approval and execution. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
+Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under dmPolicy/groupPolicy; reaction notifications now require channel access checks first. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
+Security/Discord reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild groupPolicy channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
+Security/Slack reactions + pins: gate reaction_* and pin_* system-event enqueue through shared sender authorization so DM dmPolicy/allowFrom and channel users allowlists are enforced consistently for non-message ingress, with regression coverage for denied/allowed sender paths. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
+Security/Telegram reactions: enforce dmPolicy/allowFrom and group allowlist authorization on message_reaction events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
+Security/Slack interactions: enforce channel/DM authorization and modal actor binding (private_metadata.userId) before enqueueing block_action/view_submission/view_closed system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (2026.2.25). Thanks @tdjackey for reporting.
+Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting.
+Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3.
+Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3.
+Security/LINE: cap unsigned webhook body reads before auth/signature handling to bound unauthenticated body processing. (#26095) Thanks @bmendonca3.
+Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3.
+Security/Microsoft Teams: isolate group allowlist and command authorization from DM pairing-store entries to prevent cross-context authorization bleed. (#26111) Thanks @bmendonca3.
+Security/SSRF guard: classify IPv6 multicast literals (ff00::/8) as blocked/private-internal targets in shared SSRF IP checks, preventing multicast literals from bypassing URL-host preflight and DNS answer validation. This ships in the next npm release (2026.2.25). Thanks @zpbrent for reporting.
+Tests/Low-memory stability: disable Vitest vmForks by default on low-memory local hosts (<64 GiB), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with setSessionRuntimeModel usage to avoid deterministic suite failures. (#26324) Thanks @ngutman.
View full changelog
]]>
-
+
\ No newline at end of file
diff --git a/apps/android/README.md b/apps/android/README.md
index 5e4d32359e0a..4a9951e64412 100644
--- a/apps/android/README.md
+++ b/apps/android/README.md
@@ -34,6 +34,90 @@ cd apps/android
`gradlew` auto-detects the Android SDK at `~/Library/Android/sdk` (macOS default) if `ANDROID_SDK_ROOT` / `ANDROID_HOME` are unset.
+## Macrobenchmark (Startup + Frame Timing)
+
+```bash
+cd apps/android
+./gradlew :benchmark:connectedDebugAndroidTest
+```
+
+Reports are written under:
+
+- `apps/android/benchmark/build/reports/androidTests/connected/`
+
+## Perf CLI (low-noise)
+
+Deterministic startup measurement + hotspot extraction with compact CLI output:
+
+```bash
+cd apps/android
+./scripts/perf-startup-benchmark.sh
+./scripts/perf-startup-hotspots.sh
+```
+
+Benchmark script behavior:
+
+- Runs only `StartupMacrobenchmark#coldStartup` (10 iterations).
+- Prints median/min/max/COV in one line.
+- Writes timestamped snapshot JSON to `apps/android/benchmark/results/`.
+- Auto-compares with previous local snapshot (or pass explicit baseline: `--baseline `).
+
+Hotspot script behavior:
+
+- Ensures debug app installed, captures startup `simpleperf` data for `.MainActivity`.
+- Prints top DSOs, top symbols, and key app-path clues (Compose/MainActivity/WebView).
+- Writes raw `perf.data` path for deeper follow-up if needed.
+
+## Run on a Real Android Phone (USB)
+
+1) On phone, enable **Developer options** + **USB debugging**.
+2) Connect by USB and accept the debugging trust prompt on phone.
+3) Verify ADB can see the device:
+
+```bash
+adb devices -l
+```
+
+4) Install + launch debug build:
+
+```bash
+pnpm android:install
+pnpm android:run
+```
+
+If `adb devices -l` shows `unauthorized`, re-plug and accept the trust prompt again.
+
+### USB-only gateway testing (no LAN dependency)
+
+Use `adb reverse` so Android `localhost:18789` tunnels to your laptop `localhost:18789`.
+
+Terminal A (gateway):
+
+```bash
+pnpm openclaw gateway --port 18789 --verbose
+```
+
+Terminal B (USB tunnel):
+
+```bash
+adb reverse tcp:18789 tcp:18789
+```
+
+Then in app **Connect → Manual**:
+
+- Host: `127.0.0.1`
+- Port: `18789`
+- TLS: off
+
+## Hot Reload / Fast Iteration
+
+This app is native Kotlin + Jetpack Compose.
+
+- For Compose UI edits: use Android Studio **Live Edit** on a debug build (works on physical devices; project `minSdk=31` already meets API requirement).
+- For many non-structural code/resource changes: use Android Studio **Apply Changes**.
+- For structural/native/manifest/Gradle changes: do full reinstall (`pnpm android:run`).
+- Canvas web content already supports live reload when loaded from Gateway `__openclaw__/canvas/` (see `docs/platforms/android.md`).
+
## Connect / Pair
1) Start the gateway (on your main machine):
diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts
index ffe7d1d77c30..da82e9e1ea9f 100644
--- a/apps/android/app/build.gradle.kts
+++ b/apps/android/app/build.gradle.kts
@@ -137,6 +137,7 @@ dependencies {
implementation("androidx.camera:camera-lifecycle:1.5.2")
implementation("androidx.camera:camera-video:1.5.2")
implementation("androidx.camera:camera-view:1.5.2")
+ implementation("com.journeyapps:zxing-android-embedded:4.3.0")
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
implementation("dnsjava:dnsjava:3.6.4")
@@ -145,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/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml
index facdbf301b42..3d0b27f39e66 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" />
+
+
+
+
+
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt b/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt
index cafe0958f86a..b90427672c60 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt
@@ -1,17 +1,13 @@
package ai.openclaw.android
-import android.content.pm.ApplicationInfo
import android.os.Bundle
import android.view.WindowManager
-import android.webkit.WebView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
+import androidx.core.view.WindowCompat
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
-import androidx.core.view.WindowCompat
-import androidx.core.view.WindowInsetsCompat
-import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
@@ -26,10 +22,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- val isDebuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
- WebView.setWebContentsDebuggingEnabled(isDebuggable)
- applyImmersiveMode()
- NodeForegroundService.start(this)
+ WindowCompat.setDecorFitsSystemWindows(window, false)
permissionRequester = PermissionRequester(this)
screenCaptureRequester = ScreenCaptureRequester(this)
viewModel.camera.attachLifecycleOwner(this)
@@ -57,18 +50,9 @@ class MainActivity : ComponentActivity() {
}
}
}
- }
-
- override fun onResume() {
- super.onResume()
- applyImmersiveMode()
- }
- override fun onWindowFocusChanged(hasFocus: Boolean) {
- super.onWindowFocusChanged(hasFocus)
- if (hasFocus) {
- applyImmersiveMode()
- }
+ // Keep startup path lean: start foreground service after first frame.
+ window.decorView.post { NodeForegroundService.start(this) }
}
override fun onStart() {
@@ -80,12 +64,4 @@ class MainActivity : ComponentActivity() {
viewModel.setForeground(false)
super.onStop()
}
-
- private fun applyImmersiveMode() {
- WindowCompat.setDecorFitsSystemWindows(window, false)
- val controller = WindowInsetsControllerCompat(window, window.decorView)
- controller.systemBarsBehavior =
- WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
- controller.hide(WindowInsetsCompat.Type.systemBars())
- }
}
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt
index 7076f09a292f..e0d68c77e69c 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt
@@ -8,6 +8,7 @@ import ai.openclaw.android.node.CameraCaptureManager
import ai.openclaw.android.node.CanvasController
import ai.openclaw.android.node.ScreenRecordManager
import ai.openclaw.android.node.SmsManager
+import ai.openclaw.android.voice.VoiceConversationEntry
import kotlinx.coroutines.flow.StateFlow
class MainViewModel(app: Application) : AndroidViewModel(app) {
@@ -45,14 +46,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val locationMode: StateFlow = runtime.locationMode
val locationPreciseEnabled: StateFlow = runtime.locationPreciseEnabled
val preventSleep: StateFlow = runtime.preventSleep
- val wakeWords: StateFlow> = runtime.wakeWords
- val voiceWakeMode: StateFlow = runtime.voiceWakeMode
- val voiceWakeStatusText: StateFlow = runtime.voiceWakeStatusText
- val voiceWakeIsListening: StateFlow = runtime.voiceWakeIsListening
- val talkEnabled: StateFlow = runtime.talkEnabled
- val talkStatusText: StateFlow = runtime.talkStatusText
- val talkIsListening: StateFlow = runtime.talkIsListening
- val talkIsSpeaking: StateFlow = runtime.talkIsSpeaking
+ val micEnabled: StateFlow = runtime.micEnabled
+ val micStatusText: StateFlow = runtime.micStatusText
+ val micLiveTranscript: StateFlow = runtime.micLiveTranscript
+ val micIsListening: StateFlow = runtime.micIsListening
+ val micQueuedMessages: StateFlow> = runtime.micQueuedMessages
+ val micConversation: StateFlow> = runtime.micConversation
+ val micInputLevel: StateFlow = runtime.micInputLevel
+ val micIsSending: StateFlow = runtime.micIsSending
val manualEnabled: StateFlow = runtime.manualEnabled
val manualHost: StateFlow = runtime.manualHost
val manualPort: StateFlow = runtime.manualPort
@@ -128,24 +129,8 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setCanvasDebugStatusEnabled(value)
}
- fun setWakeWords(words: List) {
- runtime.setWakeWords(words)
- }
-
- fun resetWakeWordsDefaults() {
- runtime.resetWakeWordsDefaults()
- }
-
- fun setVoiceWakeMode(mode: VoiceWakeMode) {
- runtime.setVoiceWakeMode(mode)
- }
-
- fun setTalkEnabled(enabled: Boolean) {
- runtime.setTalkEnabled(enabled)
- }
-
- fun logGatewayDebugSnapshot(source: String = "manual") {
- runtime.logGatewayDebugSnapshot(source)
+ fun setMicEnabled(enabled: Boolean) {
+ runtime.setMicEnabled(enabled)
}
fun refreshGatewayConnection() {
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt b/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt
index 2be9ee71a2c7..ab5e159cf476 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt
@@ -2,23 +2,12 @@ package ai.openclaw.android
import android.app.Application
import android.os.StrictMode
-import android.util.Log
-import java.security.Security
class NodeApp : Application() {
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
override fun onCreate() {
super.onCreate()
- // Register Bouncy Castle as highest-priority provider for Ed25519 support
- try {
- val bcProvider = Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider")
- .getDeclaredConstructor().newInstance() as java.security.Provider
- Security.removeProvider("BC")
- Security.insertProviderAt(bcProvider, 1)
- } catch (it: Throwable) {
- Log.e("NodeApp", "Failed to register Bouncy Castle provider", it)
- }
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeForegroundService.kt b/apps/android/app/src/main/java/ai/openclaw/android/NodeForegroundService.kt
index ee7c8e006747..a6a79dc9c4a9 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/NodeForegroundService.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/NodeForegroundService.kt
@@ -39,22 +39,22 @@ class NodeForegroundService : Service() {
runtime.statusText,
runtime.serverName,
runtime.isConnected,
- runtime.voiceWakeMode,
- runtime.voiceWakeIsListening,
- ) { status, server, connected, voiceMode, voiceListening ->
- Quint(status, server, connected, voiceMode, voiceListening)
- }.collect { (status, server, connected, voiceMode, voiceListening) ->
+ runtime.micEnabled,
+ runtime.micIsListening,
+ ) { status, server, connected, micEnabled, micListening ->
+ Quint(status, server, connected, micEnabled, micListening)
+ }.collect { (status, server, connected, micEnabled, micListening) ->
val title = if (connected) "OpenClaw Node · Connected" else "OpenClaw Node"
- val voiceSuffix =
- if (voiceMode == VoiceWakeMode.Always) {
- if (voiceListening) " · Voice Wake: Listening" else " · Voice Wake: Paused"
+ val micSuffix =
+ if (micEnabled) {
+ if (micListening) " · Mic: Listening" else " · Mic: Pending"
} else {
""
}
- val text = (server?.let { "$status · $it" } ?: status) + voiceSuffix
+ val text = (server?.let { "$status · $it" } ?: status) + micSuffix
val requiresMic =
- voiceMode == VoiceWakeMode.Always && hasRecordAudioPermission()
+ micEnabled && hasRecordAudioPermission()
startForegroundWithTypes(
notification = buildNotification(title = title, text = text),
requiresMic = requiresMic,
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 3e804ec8a076..43b14ce8226a 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
@@ -19,8 +19,8 @@ import ai.openclaw.android.gateway.GatewaySession
import ai.openclaw.android.gateway.probeGatewayTlsFingerprint
import ai.openclaw.android.node.*
import ai.openclaw.android.protocol.OpenClawCanvasA2UIAction
-import ai.openclaw.android.voice.TalkModeManager
-import ai.openclaw.android.voice.VoiceWakeManager
+import ai.openclaw.android.voice.MicCaptureManager
+import ai.openclaw.android.voice.VoiceConversationEntry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -37,6 +37,7 @@ import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
+import java.util.UUID
import java.util.concurrent.atomic.AtomicLong
class NodeRuntime(context: Context) {
@@ -54,40 +55,6 @@ class NodeRuntime(context: Context) {
private val externalAudioCaptureActive = MutableStateFlow(false)
- private val voiceWake: VoiceWakeManager by lazy {
- VoiceWakeManager(
- context = appContext,
- scope = scope,
- onCommand = { command ->
- nodeSession.sendNodeEvent(
- event = "agent.request",
- payloadJson =
- buildJsonObject {
- put("message", JsonPrimitive(command))
- put("sessionKey", JsonPrimitive(resolveMainSessionKey()))
- put("thinking", JsonPrimitive(chatThinkingLevel.value))
- put("deliver", JsonPrimitive(false))
- }.toString(),
- )
- },
- )
- }
-
- val voiceWakeIsListening: StateFlow
- get() = voiceWake.isListening
-
- val voiceWakeStatusText: StateFlow
- get() = voiceWake.statusText
-
- val talkStatusText: StateFlow
- get() = talkMode.statusText
-
- val talkIsListening: StateFlow
- get() = talkMode.isListening
-
- val talkIsSpeaking: StateFlow
- get() = talkMode.isSpeaking
-
private val discovery = GatewayDiscovery(appContext, scope = scope)
val gateways: StateFlow> = discovery.gateways
val discoveryStatusText: StateFlow = discovery.statusText
@@ -125,6 +92,10 @@ class NodeRuntime(context: Context) {
locationPreciseEnabled = { locationPreciseEnabled.value },
)
+ private val notificationsHandler: NotificationsHandler = NotificationsHandler(
+ appContext = appContext,
+ )
+
private val screenHandler: ScreenHandler = ScreenHandler(
screenRecorder = screenRecorder,
setScreenRecordActive = { _screenRecordActive.value = it },
@@ -146,7 +117,7 @@ class NodeRuntime(context: Context) {
prefs = prefs,
cameraEnabled = { cameraEnabled.value },
locationMode = { locationMode.value },
- voiceWakeMode = { voiceWakeMode.value },
+ voiceWakeMode = { VoiceWakeMode.Off },
smsAvailable = { sms.canSendSms() },
hasRecordAudioPermission = { hasRecordAudioPermission() },
manualTls = { manualTls.value },
@@ -156,6 +127,7 @@ class NodeRuntime(context: Context) {
canvas = canvas,
cameraHandler = cameraHandler,
locationHandler = locationHandler,
+ notificationsHandler = notificationsHandler,
screenHandler = screenHandler,
smsHandler = smsHandlerImpl,
a2uiHandler = a2uiHandler,
@@ -164,6 +136,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
@@ -172,8 +146,6 @@ class NodeRuntime(context: Context) {
onCanvasA2uiReset = { _canvasA2uiHydrated.value = false },
)
- private lateinit var gatewayEventHandler: GatewayEventHandler
-
data class GatewayTrustPrompt(
val endpoint: GatewayEndpoint,
val fingerprintSha256: String,
@@ -242,8 +214,8 @@ class NodeRuntime(context: Context) {
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
applyMainSessionKey(mainSessionKey)
updateStatus()
+ micCapture.onGatewayConnectionChanged(true)
scope.launch { refreshBrandingFromGateway() }
- scope.launch { gatewayEventHandler.refreshWakeWordsFromGateway() }
},
onDisconnected = { message ->
operatorConnected = false
@@ -254,11 +226,10 @@ class NodeRuntime(context: Context) {
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
_mainSessionKey.value = "main"
}
- val mainKey = resolveMainSessionKey()
- talkMode.setMainSessionKey(mainKey)
- chat.applyMainSessionKey(mainKey)
+ chat.applyMainSessionKey(resolveMainSessionKey())
chat.onDisconnected(message)
updateStatus()
+ micCapture.onGatewayConnectionChanged(false)
},
onEvent = { event, payloadJson ->
handleGatewayEvent(event, payloadJson)
@@ -279,7 +250,6 @@ class NodeRuntime(context: Context) {
_canvasRehydrateErrorText.value = null
updateStatus()
maybeNavigateToA2uiOnConnect()
- requestCanvasRehydrate(source = "node_connect", force = false)
},
onDisconnected = { message ->
_nodeConnected.value = false
@@ -307,34 +277,74 @@ class NodeRuntime(context: Context) {
json = json,
supportsChatSubscribe = false,
)
- private val talkMode: TalkModeManager by lazy {
- TalkModeManager(
+ private val micCapture: MicCaptureManager by lazy {
+ MicCaptureManager(
context = appContext,
scope = scope,
- session = operatorSession,
- supportsChatSubscribe = false,
- isConnected = { operatorConnected },
+ sendToGateway = { message ->
+ val idempotencyKey = UUID.randomUUID().toString()
+ val params =
+ buildJsonObject {
+ put("sessionKey", JsonPrimitive(resolveMainSessionKey()))
+ put("message", JsonPrimitive(message))
+ put("thinking", JsonPrimitive(chatThinkingLevel.value))
+ put("timeoutMs", JsonPrimitive(30_000))
+ put("idempotencyKey", JsonPrimitive(idempotencyKey))
+ }
+ val response = operatorSession.request("chat.send", params.toString())
+ parseChatSendRunId(response) ?: idempotencyKey
+ },
)
}
+ val micStatusText: StateFlow
+ get() = micCapture.statusText
+
+ val micLiveTranscript: StateFlow
+ get() = micCapture.liveTranscript
+
+ val micIsListening: StateFlow
+ get() = micCapture.isListening
+
+ val micEnabled: StateFlow
+ get() = micCapture.micEnabled
+
+ val micQueuedMessages: StateFlow>
+ get() = micCapture.queuedMessages
+
+ val micConversation: StateFlow>
+ get() = micCapture.conversation
+
+ val micInputLevel: StateFlow
+ get() = micCapture.inputLevel
+
+ val micIsSending: StateFlow
+ get() = micCapture.isSending
+
private fun applyMainSessionKey(candidate: String?) {
val trimmed = normalizeMainKey(candidate) ?: return
if (isCanonicalMainSessionKey(_mainSessionKey.value)) return
if (_mainSessionKey.value == trimmed) return
_mainSessionKey.value = trimmed
- talkMode.setMainSessionKey(trimmed)
chat.applyMainSessionKey(trimmed)
}
private fun updateStatus() {
_isConnected.value = operatorConnected
+ val operator = operatorStatusText.trim()
+ val node = nodeStatusText.trim()
_statusText.value =
when {
operatorConnected && _nodeConnected.value -> "Connected"
operatorConnected && !_nodeConnected.value -> "Connected (node offline)"
- !operatorConnected && _nodeConnected.value -> "Connected (operator offline)"
- operatorStatusText.isNotBlank() && operatorStatusText != "Offline" -> operatorStatusText
- else -> nodeStatusText
+ !operatorConnected && _nodeConnected.value ->
+ if (operator.isNotEmpty() && operator != "Offline") {
+ "Connected (operator: $operator)"
+ } else {
+ "Connected (operator offline)"
+ }
+ operator.isNotBlank() && operator != "Offline" -> operator
+ else -> node
}
}
@@ -417,9 +427,6 @@ class NodeRuntime(context: Context) {
val locationMode: StateFlow = prefs.locationMode
val locationPreciseEnabled: StateFlow = prefs.locationPreciseEnabled
val preventSleep: StateFlow = prefs.preventSleep
- val wakeWords: StateFlow> = prefs.wakeWords
- val voiceWakeMode: StateFlow = prefs.voiceWakeMode
- val talkEnabled: StateFlow = prefs.talkEnabled
val manualEnabled: StateFlow = prefs.manualEnabled
val manualHost: StateFlow = prefs.manualHost
val manualPort: StateFlow = prefs.manualPort
@@ -446,50 +453,17 @@ class NodeRuntime(context: Context) {
val pendingRunCount: StateFlow = chat.pendingRunCount
init {
- gatewayEventHandler = GatewayEventHandler(
- scope = scope,
- prefs = prefs,
- json = json,
- operatorSession = operatorSession,
- isConnected = { _isConnected.value },
- )
+ if (prefs.voiceWakeMode.value != VoiceWakeMode.Off) {
+ prefs.setVoiceWakeMode(VoiceWakeMode.Off)
+ }
scope.launch {
- combine(
- voiceWakeMode,
- isForeground,
- externalAudioCaptureActive,
- wakeWords,
- ) { mode, foreground, externalAudio, words ->
- Quad(mode, foreground, externalAudio, words)
- }.distinctUntilChanged()
- .collect { (mode, foreground, externalAudio, words) ->
- voiceWake.setTriggerWords(words)
-
- val shouldListen =
- when (mode) {
- VoiceWakeMode.Off -> false
- VoiceWakeMode.Foreground -> foreground
- VoiceWakeMode.Always -> true
- } && !externalAudio
-
- if (!shouldListen) {
- voiceWake.stop(statusText = if (mode == VoiceWakeMode.Off) "Off" else "Paused")
- return@collect
- }
-
- if (!hasRecordAudioPermission()) {
- voiceWake.stop(statusText = "Microphone permission required")
- return@collect
- }
-
- voiceWake.start()
- }
+ prefs.loadGatewayToken()
}
scope.launch {
- talkEnabled.collect { enabled ->
- talkMode.setEnabled(enabled)
+ prefs.talkEnabled.collect { enabled ->
+ micCapture.setMicEnabled(enabled)
externalAudioCaptureActive.value = enabled
}
}
@@ -597,34 +571,20 @@ class NodeRuntime(context: Context) {
prefs.setCanvasDebugStatusEnabled(value)
}
- fun setWakeWords(words: List) {
- prefs.setWakeWords(words)
- gatewayEventHandler.scheduleWakeWordsSyncIfNeeded()
- }
-
- fun resetWakeWordsDefaults() {
- setWakeWords(SecurePrefs.defaultWakeWords)
- }
-
- fun setVoiceWakeMode(mode: VoiceWakeMode) {
- prefs.setVoiceWakeMode(mode)
- }
-
- fun setTalkEnabled(value: Boolean) {
+ fun setMicEnabled(value: Boolean) {
prefs.setTalkEnabled(value)
- }
-
- fun logGatewayDebugSnapshot(source: String = "manual") {
- val flowToken = gatewayToken.value.trim()
- val loadedToken = prefs.loadGatewayToken().orEmpty()
- Log.i(
- "OpenClawGatewayDebug",
- "source=$source manualEnabled=${manualEnabled.value} host=${manualHost.value} port=${manualPort.value} tls=${manualTls.value} flowTokenLen=${flowToken.length} loadTokenLen=${loadedToken.length} connected=${isConnected.value} status=${statusText.value}",
- )
+ micCapture.setMicEnabled(value)
+ externalAudioCaptureActive.value = value
}
fun refreshGatewayConnection() {
- val endpoint = connectedEndpoint ?: return
+ val endpoint =
+ connectedEndpoint ?: run {
+ _statusText.value = "Failed: no cached gateway endpoint"
+ return
+ }
+ operatorStatusText = "Connecting…"
+ updateStatus()
val token = prefs.loadGatewayToken()
val password = prefs.loadGatewayPassword()
val tls = connectionManager.resolveTlsParams(endpoint)
@@ -797,15 +757,19 @@ class NodeRuntime(context: Context) {
}
private fun handleGatewayEvent(event: String, payloadJson: String?) {
- if (event == "voicewake.changed") {
- gatewayEventHandler.handleVoiceWakeChangedEvent(payloadJson)
- return
- }
-
- talkMode.handleGatewayEvent(event, payloadJson)
+ micCapture.handleGatewayEvent(event, payloadJson)
chat.handleGatewayEvent(event, payloadJson)
}
+ private fun parseChatSendRunId(response: String): String? {
+ return try {
+ val root = json.parseToJsonElement(response).asObjectOrNull() ?: return null
+ root["runId"].asStringOrNull()
+ } catch (_: Throwable) {
+ null
+ }
+ }
+
private suspend fun refreshBrandingFromGateway() {
if (!_isConnected.value) return
try {
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt b/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt
index f03e2b56e0b0..96e4572955ec 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt
@@ -20,19 +20,21 @@ class SecurePrefs(context: Context) {
val defaultWakeWords: List = listOf("openclaw", "claude")
private const val displayNameKey = "node.displayName"
private const val voiceWakeModeKey = "voiceWake.mode"
+ private const val plainPrefsName = "openclaw.node"
+ private const val securePrefsName = "openclaw.node.secure"
}
private val appContext = context.applicationContext
private val json = Json { ignoreUnknownKeys = true }
+ private val plainPrefs: SharedPreferences =
+ appContext.getSharedPreferences(plainPrefsName, Context.MODE_PRIVATE)
- private val masterKey =
- MasterKey.Builder(context)
+ private val masterKey by lazy {
+ MasterKey.Builder(appContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
-
- private val prefs: SharedPreferences by lazy {
- createPrefs(appContext, "openclaw.node.secure")
}
+ private val securePrefs: SharedPreferences by lazy { createSecurePrefs(appContext, securePrefsName) }
private val _instanceId = MutableStateFlow(loadOrCreateInstanceId())
val instanceId: StateFlow = _instanceId
@@ -41,52 +43,51 @@ class SecurePrefs(context: Context) {
MutableStateFlow(loadOrMigrateDisplayName(context = context))
val displayName: StateFlow = _displayName
- private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true))
+ private val _cameraEnabled = MutableStateFlow(plainPrefs.getBoolean("camera.enabled", true))
val cameraEnabled: StateFlow = _cameraEnabled
private val _locationMode =
- MutableStateFlow(LocationMode.fromRawValue(prefs.getString("location.enabledMode", "off")))
+ MutableStateFlow(LocationMode.fromRawValue(plainPrefs.getString("location.enabledMode", "off")))
val locationMode: StateFlow = _locationMode
private val _locationPreciseEnabled =
- MutableStateFlow(prefs.getBoolean("location.preciseEnabled", true))
+ MutableStateFlow(plainPrefs.getBoolean("location.preciseEnabled", true))
val locationPreciseEnabled: StateFlow = _locationPreciseEnabled
- private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true))
+ private val _preventSleep = MutableStateFlow(plainPrefs.getBoolean("screen.preventSleep", true))
val preventSleep: StateFlow = _preventSleep
private val _manualEnabled =
- MutableStateFlow(prefs.getBoolean("gateway.manual.enabled", false))
+ MutableStateFlow(plainPrefs.getBoolean("gateway.manual.enabled", false))
val manualEnabled: StateFlow = _manualEnabled
private val _manualHost =
- MutableStateFlow(prefs.getString("gateway.manual.host", "") ?: "")
+ MutableStateFlow(plainPrefs.getString("gateway.manual.host", "") ?: "")
val manualHost: StateFlow = _manualHost
private val _manualPort =
- MutableStateFlow(prefs.getInt("gateway.manual.port", 18789))
+ MutableStateFlow(plainPrefs.getInt("gateway.manual.port", 18789))
val manualPort: StateFlow = _manualPort
private val _manualTls =
- MutableStateFlow(prefs.getBoolean("gateway.manual.tls", true))
+ MutableStateFlow(plainPrefs.getBoolean("gateway.manual.tls", true))
val manualTls: StateFlow = _manualTls
- private val _gatewayToken =
- MutableStateFlow(prefs.getString("gateway.manual.token", "") ?: "")
+ private val _gatewayToken = MutableStateFlow("")
val gatewayToken: StateFlow = _gatewayToken
private val _onboardingCompleted =
- MutableStateFlow(prefs.getBoolean("onboarding.completed", false))
+ MutableStateFlow(plainPrefs.getBoolean("onboarding.completed", false))
val onboardingCompleted: StateFlow = _onboardingCompleted
private val _lastDiscoveredStableId =
MutableStateFlow(
- prefs.getString("gateway.lastDiscoveredStableID", "") ?: "",
+ plainPrefs.getString("gateway.lastDiscoveredStableID", "") ?: "",
)
val lastDiscoveredStableId: StateFlow = _lastDiscoveredStableId
private val _canvasDebugStatusEnabled =
- MutableStateFlow(prefs.getBoolean("canvas.debugStatusEnabled", false))
+ MutableStateFlow(plainPrefs.getBoolean("canvas.debugStatusEnabled", false))
val canvasDebugStatusEnabled: StateFlow = _canvasDebugStatusEnabled
private val _wakeWords = MutableStateFlow(loadWakeWords())
@@ -95,65 +96,65 @@ class SecurePrefs(context: Context) {
private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode())
val voiceWakeMode: StateFlow = _voiceWakeMode
- private val _talkEnabled = MutableStateFlow(prefs.getBoolean("talk.enabled", false))
+ private val _talkEnabled = MutableStateFlow(plainPrefs.getBoolean("talk.enabled", false))
val talkEnabled: StateFlow = _talkEnabled
fun setLastDiscoveredStableId(value: String) {
val trimmed = value.trim()
- prefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
+ plainPrefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
_lastDiscoveredStableId.value = trimmed
}
fun setDisplayName(value: String) {
val trimmed = value.trim()
- prefs.edit { putString(displayNameKey, trimmed) }
+ plainPrefs.edit { putString(displayNameKey, trimmed) }
_displayName.value = trimmed
}
fun setCameraEnabled(value: Boolean) {
- prefs.edit { putBoolean("camera.enabled", value) }
+ plainPrefs.edit { putBoolean("camera.enabled", value) }
_cameraEnabled.value = value
}
fun setLocationMode(mode: LocationMode) {
- prefs.edit { putString("location.enabledMode", mode.rawValue) }
+ plainPrefs.edit { putString("location.enabledMode", mode.rawValue) }
_locationMode.value = mode
}
fun setLocationPreciseEnabled(value: Boolean) {
- prefs.edit { putBoolean("location.preciseEnabled", value) }
+ plainPrefs.edit { putBoolean("location.preciseEnabled", value) }
_locationPreciseEnabled.value = value
}
fun setPreventSleep(value: Boolean) {
- prefs.edit { putBoolean("screen.preventSleep", value) }
+ plainPrefs.edit { putBoolean("screen.preventSleep", value) }
_preventSleep.value = value
}
fun setManualEnabled(value: Boolean) {
- prefs.edit { putBoolean("gateway.manual.enabled", value) }
+ plainPrefs.edit { putBoolean("gateway.manual.enabled", value) }
_manualEnabled.value = value
}
fun setManualHost(value: String) {
val trimmed = value.trim()
- prefs.edit { putString("gateway.manual.host", trimmed) }
+ plainPrefs.edit { putString("gateway.manual.host", trimmed) }
_manualHost.value = trimmed
}
fun setManualPort(value: Int) {
- prefs.edit { putInt("gateway.manual.port", value) }
+ plainPrefs.edit { putInt("gateway.manual.port", value) }
_manualPort.value = value
}
fun setManualTls(value: Boolean) {
- prefs.edit { putBoolean("gateway.manual.tls", value) }
+ plainPrefs.edit { putBoolean("gateway.manual.tls", value) }
_manualTls.value = value
}
fun setGatewayToken(value: String) {
val trimmed = value.trim()
- prefs.edit(commit = true) { putString("gateway.manual.token", trimmed) }
+ securePrefs.edit { putString("gateway.manual.token", trimmed) }
_gatewayToken.value = trimmed
}
@@ -162,62 +163,67 @@ class SecurePrefs(context: Context) {
}
fun setOnboardingCompleted(value: Boolean) {
- prefs.edit { putBoolean("onboarding.completed", value) }
+ plainPrefs.edit { putBoolean("onboarding.completed", value) }
_onboardingCompleted.value = value
}
fun setCanvasDebugStatusEnabled(value: Boolean) {
- prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
+ plainPrefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
_canvasDebugStatusEnabled.value = value
}
fun loadGatewayToken(): String? {
- val manual = _gatewayToken.value.trim()
+ val manual =
+ _gatewayToken.value.trim().ifEmpty {
+ val stored = securePrefs.getString("gateway.manual.token", null)?.trim().orEmpty()
+ if (stored.isNotEmpty()) _gatewayToken.value = stored
+ stored
+ }
if (manual.isNotEmpty()) return manual
val key = "gateway.token.${_instanceId.value}"
- val stored = prefs.getString(key, null)?.trim()
+ val stored = securePrefs.getString(key, null)?.trim()
return stored?.takeIf { it.isNotEmpty() }
}
fun saveGatewayToken(token: String) {
val key = "gateway.token.${_instanceId.value}"
- prefs.edit { putString(key, token.trim()) }
+ securePrefs.edit { putString(key, token.trim()) }
}
fun loadGatewayPassword(): String? {
val key = "gateway.password.${_instanceId.value}"
- val stored = prefs.getString(key, null)?.trim()
+ val stored = securePrefs.getString(key, null)?.trim()
return stored?.takeIf { it.isNotEmpty() }
}
fun saveGatewayPassword(password: String) {
val key = "gateway.password.${_instanceId.value}"
- prefs.edit { putString(key, password.trim()) }
+ securePrefs.edit { putString(key, password.trim()) }
}
fun loadGatewayTlsFingerprint(stableId: String): String? {
val key = "gateway.tls.$stableId"
- return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
+ return plainPrefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
}
fun saveGatewayTlsFingerprint(stableId: String, fingerprint: String) {
val key = "gateway.tls.$stableId"
- prefs.edit { putString(key, fingerprint.trim()) }
+ plainPrefs.edit { putString(key, fingerprint.trim()) }
}
fun getString(key: String): String? {
- return prefs.getString(key, null)
+ return securePrefs.getString(key, null)
}
fun putString(key: String, value: String) {
- prefs.edit { putString(key, value) }
+ securePrefs.edit { putString(key, value) }
}
fun remove(key: String) {
- prefs.edit { remove(key) }
+ securePrefs.edit { remove(key) }
}
- private fun createPrefs(context: Context, name: String): SharedPreferences {
+ private fun createSecurePrefs(context: Context, name: String): SharedPreferences {
return EncryptedSharedPreferences.create(
context,
name,
@@ -228,21 +234,21 @@ class SecurePrefs(context: Context) {
}
private fun loadOrCreateInstanceId(): String {
- val existing = prefs.getString("node.instanceId", null)?.trim()
+ val existing = plainPrefs.getString("node.instanceId", null)?.trim()
if (!existing.isNullOrBlank()) return existing
val fresh = UUID.randomUUID().toString()
- prefs.edit { putString("node.instanceId", fresh) }
+ plainPrefs.edit { putString("node.instanceId", fresh) }
return fresh
}
private fun loadOrMigrateDisplayName(context: Context): String {
- val existing = prefs.getString(displayNameKey, null)?.trim().orEmpty()
+ val existing = plainPrefs.getString(displayNameKey, null)?.trim().orEmpty()
if (existing.isNotEmpty() && existing != "Android Node") return existing
val candidate = DeviceNames.bestDefaultNodeName(context).trim()
val resolved = candidate.ifEmpty { "Android Node" }
- prefs.edit { putString(displayNameKey, resolved) }
+ plainPrefs.edit { putString(displayNameKey, resolved) }
return resolved
}
@@ -250,34 +256,34 @@ class SecurePrefs(context: Context) {
val sanitized = WakeWords.sanitize(words, defaultWakeWords)
val encoded =
JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
- prefs.edit { putString("voiceWake.triggerWords", encoded) }
+ plainPrefs.edit { putString("voiceWake.triggerWords", encoded) }
_wakeWords.value = sanitized
}
fun setVoiceWakeMode(mode: VoiceWakeMode) {
- prefs.edit { putString(voiceWakeModeKey, mode.rawValue) }
+ plainPrefs.edit { putString(voiceWakeModeKey, mode.rawValue) }
_voiceWakeMode.value = mode
}
fun setTalkEnabled(value: Boolean) {
- prefs.edit { putBoolean("talk.enabled", value) }
+ plainPrefs.edit { putBoolean("talk.enabled", value) }
_talkEnabled.value = value
}
private fun loadVoiceWakeMode(): VoiceWakeMode {
- val raw = prefs.getString(voiceWakeModeKey, null)
+ val raw = plainPrefs.getString(voiceWakeModeKey, null)
val resolved = VoiceWakeMode.fromRawValue(raw)
// Default ON (foreground) when unset.
if (raw.isNullOrBlank()) {
- prefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
+ plainPrefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
}
return resolved
}
private fun loadWakeWords(): List {
- val raw = prefs.getString("voiceWake.triggerWords", null)?.trim()
+ val raw = plainPrefs.getString("voiceWake.triggerWords", null)?.trim()
if (raw.isNullOrEmpty()) return defaultWakeWords
return try {
val element = json.parseToJsonElement(raw)
@@ -295,5 +301,4 @@ class SecurePrefs(context: Context) {
defaultWakeWords
}
}
-
}
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 810e029fba89..8ace62e087c3 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/DeviceIdentityStore.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt
index ff651c6c17b0..68830772f9a5 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt
@@ -3,11 +3,7 @@ package ai.openclaw.android.gateway
import android.content.Context
import android.util.Base64
import java.io.File
-import java.security.KeyFactory
-import java.security.KeyPairGenerator
import java.security.MessageDigest
-import java.security.Signature
-import java.security.spec.PKCS8EncodedKeySpec
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@@ -22,21 +18,26 @@ data class DeviceIdentity(
class DeviceIdentityStore(context: Context) {
private val json = Json { ignoreUnknownKeys = true }
private val identityFile = File(context.filesDir, "openclaw/identity/device.json")
+ @Volatile private var cachedIdentity: DeviceIdentity? = null
@Synchronized
fun loadOrCreate(): DeviceIdentity {
+ cachedIdentity?.let { return it }
val existing = load()
if (existing != null) {
val derived = deriveDeviceId(existing.publicKeyRawBase64)
if (derived != null && derived != existing.deviceId) {
val updated = existing.copy(deviceId = derived)
save(updated)
+ cachedIdentity = updated
return updated
}
+ cachedIdentity = existing
return existing
}
val fresh = generate()
save(fresh)
+ cachedIdentity = fresh
return fresh
}
@@ -151,22 +152,16 @@ class DeviceIdentityStore(context: Context) {
}
}
- private fun stripSpkiPrefix(spki: ByteArray): ByteArray {
- if (spki.size == ED25519_SPKI_PREFIX.size + 32 &&
- spki.copyOfRange(0, ED25519_SPKI_PREFIX.size).contentEquals(ED25519_SPKI_PREFIX)
- ) {
- return spki.copyOfRange(ED25519_SPKI_PREFIX.size, spki.size)
- }
- return spki
- }
-
private fun sha256Hex(data: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256").digest(data)
- val out = StringBuilder(digest.size * 2)
+ val out = CharArray(digest.size * 2)
+ var i = 0
for (byte in digest) {
- out.append(String.format("%02x", byte))
+ val v = byte.toInt() and 0xff
+ out[i++] = HEX[v ushr 4]
+ out[i++] = HEX[v and 0x0f]
}
- return out.toString()
+ return String(out)
}
private fun base64UrlEncode(data: ByteArray): String {
@@ -174,9 +169,6 @@ class DeviceIdentityStore(context: Context) {
}
companion object {
- private val ED25519_SPKI_PREFIX =
- byteArrayOf(
- 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
- )
+ private val HEX = "0123456789abcdef".toCharArray()
}
}
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 4e210de8fb99..e0aea39768e1 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,13 +55,18 @@ data class GatewayConnectOptions(
class GatewaySession(
private val scope: CoroutineScope,
private val identityStore: DeviceIdentityStore,
- private val deviceAuthStore: DeviceAuthStore,
+ 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,
private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null,
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
) {
+ private companion object {
+ // Keep connect timeout above observed gateway unauthorized close on lower-end devices.
+ private const val CONNECT_RPC_TIMEOUT_MS = 12_000L
+ }
+
data class InvokeRequest(
val id: String,
val nodeId: String,
@@ -195,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()
@@ -302,26 +305,13 @@ class GatewaySession(
val identity = identityStore.loadOrCreate()
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)
val trimmedToken = token?.trim().orEmpty()
- val authToken = if (storedToken.isNullOrBlank()) trimmedToken else storedToken
+ // QR/setup/manual shared token must take precedence; stale role tokens can survive re-onboarding.
+ val authToken = if (trimmedToken.isNotBlank()) trimmedToken else storedToken.orEmpty()
val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim())
- var res = request("connect", payload, timeoutMs = 8_000)
+ val res = request("connect", payload, timeoutMs = CONNECT_RPC_TIMEOUT_MS)
if (!res.ok) {
val msg = res.error?.message ?: "connect failed"
- val hasStoredToken = !storedToken.isNullOrBlank()
- val canRetryWithShared = hasStoredToken && trimmedToken.isNotBlank()
- if (canRetryWithShared) {
- val sharedPayload = buildConnectParams(identity, connectNonce, trimmedToken, password?.trim())
- val sharedRes = request("connect", sharedPayload, timeoutMs = 8_000)
- if (!sharedRes.ok) {
- val retryMsg = sharedRes.error?.message ?: msg
- throw IllegalStateException(retryMsg)
- }
- // Stored device token was bypassed successfully; clear stale token for future connects.
- deviceAuthStore.clearToken(identity.deviceId, options.role)
- res = sharedRes
- } else {
- throw IllegalStateException(msg)
- }
+ throw IllegalStateException(msg)
}
handleConnectSuccess(res, identity.deviceId)
connectDeferred.complete(Unit)
@@ -543,16 +533,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 000000000000..7242f4a55333
--- /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/CameraCaptureManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/CameraCaptureManager.kt
index 65bac915effa..aa038ad9a945 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/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 9b449fc85f37..de30b8af8fe4 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/DeviceNotificationListenerService.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt
new file mode 100644
index 000000000000..709e9af5ec54
--- /dev/null
+++ b/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt
@@ -0,0 +1,164 @@
+package ai.openclaw.android.node
+
+import android.app.Notification
+import android.app.NotificationManager
+import android.content.ComponentName
+import android.content.Context
+import android.os.Build
+import android.service.notification.NotificationListenerService
+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,
+ val title: String?,
+ val text: String?,
+ val subText: String?,
+ val category: String?,
+ val channelId: String?,
+ val postTimeMs: Long,
+ val isOngoing: Boolean,
+ val isClearable: Boolean,
+)
+
+data class DeviceNotificationSnapshot(
+ val enabled: Boolean,
+ val connected: Boolean,
+ val notifications: List,
+)
+
+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 = sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_TITLE))
+ val body =
+ 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,
+ title = title,
+ text = body,
+ subText = subText,
+ category = notification.category?.trim()?.ifEmpty { null },
+ channelId = notification.channelId?.trim()?.ifEmpty { null },
+ postTimeMs = postTime,
+ isOngoing = isOngoing,
+ isClearable = isClearable,
+ )
+ }
+
+ 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, enabled: Boolean = isAccessEnabled(context)): DeviceNotificationSnapshot {
+ return DeviceNotificationStore.snapshot(enabled = enabled)
+ }
+
+ 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
new file mode 100644
index 000000000000..ce87525904fe
--- /dev/null
+++ b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeCommandRegistry.kt
@@ -0,0 +1,118 @@
+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.OpenClawNotificationsCommand
+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 = OpenClawNotificationsCommand.List.rawValue,
+ ),
+ 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/main/java/ai/openclaw/android/node/InvokeDispatcher.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt
index 91e9da8add14..936ad7b3d111 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,
@@ -20,40 +22,25 @@ 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,
) {
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",
)
- }
- }
-
- // Check camera enabled
- if (command.startsWith(OpenClawCameraCommand.NamespacePrefix) && !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.requiresForeground && !isForeground()) {
return GatewaySession.InvokeResult.error(
- code = "LOCATION_DISABLED",
- message = "LOCATION_DISABLED: enable Location in Settings",
+ code = "NODE_BACKGROUND_UNAVAILABLE",
+ message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
)
}
+ availabilityError(spec.availability)?.let { return it }
return when (command) {
// Canvas commands
@@ -75,53 +62,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 +99,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
@@ -157,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)
@@ -170,11 +132,80 @@ 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",
+ )
+ }
+ }
+
+ 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",
+ )
+ }
}
}
}
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 8ba5ad276d5e..c3f463174a4e 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/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 000000000000..0216e19208cf
--- /dev/null
+++ b/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt
@@ -0,0 +1,81 @@
+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
+
+internal interface NotificationsStateProvider {
+ fun readSnapshot(context: Context): DeviceNotificationSnapshot
+
+ 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/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt b/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt
index ccca40c4c353..d73c61d233b4 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/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt
index 9f7cf2211a16..875b82796d3e 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt
@@ -168,6 +168,11 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
validationText = null
return@Button
}
+ if (statusText.contains("operator offline", ignoreCase = true)) {
+ validationText = null
+ viewModel.refreshGatewayConnection()
+ return@Button
+ }
val config =
resolveGatewayConnectConfig(
@@ -397,15 +402,6 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
HorizontalDivider(color = mobileBorder)
- Text(
- "Debug snapshot: mode=${if (inputMode == ConnectInputMode.SetupCode) "setup" else "manual"}, manualEnabled=$manualEnabled, tokenLen=${gatewayToken.trim().length}",
- style = mobileCaption1,
- color = mobileTextSecondary,
- )
- TextButton(onClick = { viewModel.logGatewayDebugSnapshot(source = "connect_tab") }) {
- Text("Log gateway debug snapshot", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent)
- }
-
TextButton(onClick = { viewModel.setOnboardingCompleted(false) }) {
Text("Run onboarding again", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent)
}
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/GatewayConfigResolver.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/GatewayConfigResolver.kt
index 5036c6290d3f..4421a82be4b6 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/ui/GatewayConfigResolver.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/GatewayConfigResolver.kt
@@ -1,9 +1,13 @@
package ai.openclaw.android.ui
-import android.util.Base64
import androidx.core.net.toUri
+import java.util.Base64
import java.util.Locale
-import org.json.JSONObject
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.jsonObject
internal data class GatewayEndpointConfig(
val host: String,
@@ -26,6 +30,8 @@ internal data class GatewayConnectConfig(
val password: String,
)
+private val gatewaySetupJson = Json { ignoreUnknownKeys = true }
+
internal fun resolveGatewayConnectConfig(
useSetupCode: Boolean,
setupCode: String,
@@ -94,18 +100,23 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
}
return try {
- val decoded = String(Base64.decode(padded, Base64.DEFAULT), Charsets.UTF_8)
- val obj = JSONObject(decoded)
- val url = obj.optString("url").trim()
+ val decoded = String(Base64.getDecoder().decode(padded), Charsets.UTF_8)
+ val obj = parseJsonObject(decoded) ?: return null
+ val url = jsonField(obj, "url").orEmpty()
if (url.isEmpty()) return null
- val token = obj.optString("token").trim().ifEmpty { null }
- val password = obj.optString("password").trim().ifEmpty { null }
+ val token = jsonField(obj, "token")
+ val password = jsonField(obj, "password")
GatewaySetupCode(url = url, token = token, password = password)
- } catch (_: Throwable) {
+ } catch (_: IllegalArgumentException) {
null
}
}
+internal fun resolveScannedSetupCode(rawInput: String): String? {
+ val setupCode = resolveSetupCodeCandidate(rawInput) ?: return null
+ return setupCode.takeIf { decodeGatewaySetupCode(it) != null }
+}
+
internal fun composeGatewayManualUrl(hostInput: String, portInput: String, tls: Boolean): String? {
val host = hostInput.trim()
val port = portInput.trim().toIntOrNull() ?: return null
@@ -113,3 +124,19 @@ internal fun composeGatewayManualUrl(hostInput: String, portInput: String, tls:
val scheme = if (tls) "https" else "http"
return "$scheme://$host:$port"
}
+
+private fun parseJsonObject(input: String): JsonObject? {
+ return runCatching { gatewaySetupJson.parseToJsonElement(input).jsonObject }.getOrNull()
+}
+
+private fun resolveSetupCodeCandidate(rawInput: String): String? {
+ val trimmed = rawInput.trim()
+ if (trimmed.isEmpty()) return null
+ val qrSetupCode = parseJsonObject(trimmed)?.let { jsonField(it, "setupCode") }
+ return qrSetupCode ?: trimmed
+}
+
+private fun jsonField(obj: JsonObject, key: String): String? {
+ val value = (obj[key] as? JsonPrimitive)?.contentOrNull?.trim().orEmpty()
+ return value.ifEmpty { null }
+}
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt
index 8c732d9c3603..4c9e064e6afb 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt
@@ -6,6 +6,7 @@ import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
@@ -51,6 +52,8 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.ExpandLess
+import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -74,6 +77,8 @@ import androidx.core.content.ContextCompat
import ai.openclaw.android.LocationMode
import ai.openclaw.android.MainViewModel
import ai.openclaw.android.R
+import com.journeyapps.barcodescanner.ScanContract
+import com.journeyapps.barcodescanner.ScanOptions
private enum class OnboardingStep(val index: Int, val label: String) {
Welcome(1, "Welcome"),
@@ -192,6 +197,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
var gatewayUrl by rememberSaveable { mutableStateOf("") }
var gatewayPassword by rememberSaveable { mutableStateOf("") }
var gatewayInputMode by rememberSaveable { mutableStateOf(GatewayInputMode.SetupCode) }
+ var gatewayAdvancedOpen by rememberSaveable { mutableStateOf(false) }
var manualHost by rememberSaveable { mutableStateOf("10.0.2.2") }
var manualPort by rememberSaveable { mutableStateOf("18789") }
var manualTls by rememberSaveable { mutableStateOf(false) }
@@ -246,6 +252,23 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
step = OnboardingStep.FinalCheck
}
+ val qrScanLauncher =
+ rememberLauncherForActivityResult(ScanContract()) { result ->
+ val contents = result.contents?.trim().orEmpty()
+ if (contents.isEmpty()) {
+ return@rememberLauncherForActivityResult
+ }
+ val scannedSetupCode = resolveScannedSetupCode(contents)
+ if (scannedSetupCode == null) {
+ gatewayError = "QR code did not contain a valid setup code."
+ return@rememberLauncherForActivityResult
+ }
+ setupCode = scannedSetupCode
+ gatewayInputMode = GatewayInputMode.SetupCode
+ gatewayError = null
+ attemptedConnect = false
+ }
+
if (pendingTrust != null) {
val prompt = pendingTrust!!
AlertDialog(
@@ -316,6 +339,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
OnboardingStep.Gateway ->
GatewayStep(
inputMode = gatewayInputMode,
+ advancedOpen = gatewayAdvancedOpen,
setupCode = setupCode,
manualHost = manualHost,
manualPort = manualPort,
@@ -323,6 +347,18 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
gatewayToken = persistedGatewayToken,
gatewayPassword = gatewayPassword,
gatewayError = gatewayError,
+ onScanQrClick = {
+ gatewayError = null
+ qrScanLauncher.launch(
+ ScanOptions().apply {
+ setDesiredBarcodeFormats(ScanOptions.QR_CODE)
+ setPrompt("Scan OpenClaw onboarding QR")
+ setBeepEnabled(false)
+ setOrientationLocked(false)
+ },
+ )
+ },
+ onAdvancedOpenChange = { gatewayAdvancedOpen = it },
onInputModeChange = {
gatewayInputMode = it
gatewayError = null
@@ -367,7 +403,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
remoteAddress = remoteAddress,
attemptedConnect = attemptedConnect,
enabledPermissions = enabledPermissionSummary,
- methodLabel = if (gatewayInputMode == GatewayInputMode.SetupCode) "Setup Code" else "Manual",
+ methodLabel = if (gatewayInputMode == GatewayInputMode.SetupCode) "QR / Setup Code" else "Manual",
)
}
}
@@ -429,7 +465,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
if (gatewayInputMode == GatewayInputMode.SetupCode) {
val parsedSetup = decodeGatewaySetupCode(setupCode)
if (parsedSetup == null) {
- gatewayError = "Invalid setup code."
+ gatewayError = "Scan QR code first, or use Advanced setup."
return@Button
}
val parsedGateway = parseGatewayEndpoint(parsedSetup.url)
@@ -607,6 +643,7 @@ private fun WelcomeStep() {
@Composable
private fun GatewayStep(
inputMode: GatewayInputMode,
+ advancedOpen: Boolean,
setupCode: String,
manualHost: String,
manualPort: String,
@@ -614,6 +651,8 @@ private fun GatewayStep(
gatewayToken: String,
gatewayPassword: String,
gatewayError: String?,
+ onScanQrClick: () -> Unit,
+ onAdvancedOpenChange: (Boolean) -> Unit,
onInputModeChange: (GatewayInputMode) -> Unit,
onSetupCodeChange: (String) -> Unit,
onManualHostChange: (String) -> Unit,
@@ -626,175 +665,225 @@ private fun GatewayStep(
val manualResolvedEndpoint = remember(manualHost, manualPort, manualTls) { composeGatewayManualUrl(manualHost, manualPort, manualTls)?.let { parseGatewayEndpoint(it)?.displayUrl } }
StepShell(title = "Gateway Connection") {
- GuideBlock(title = "Get setup code + gateway URL") {
+ GuideBlock(title = "Scan onboarding QR") {
Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary)
- CommandBlock("openclaw qr --setup-code-only")
- CommandBlock("openclaw qr --json")
- Text(
- "`--json` prints `setupCode` and `gatewayUrl`.",
- style = onboardingCalloutStyle,
- color = onboardingTextSecondary,
- )
- Text(
- "Auto URL discovery is not wired yet. Android emulator uses `10.0.2.2`; real devices need LAN/Tailscale host.",
- style = onboardingCalloutStyle,
- color = onboardingTextSecondary,
- )
+ CommandBlock("openclaw qr")
+ Text("Then scan with this device.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
+ }
+ Button(
+ onClick = onScanQrClick,
+ modifier = Modifier.fillMaxWidth().height(48.dp),
+ shape = RoundedCornerShape(12.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = onboardingAccent,
+ contentColor = Color.White,
+ ),
+ ) {
+ Text("Scan QR code", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
+ }
+ if (!resolvedEndpoint.isNullOrBlank()) {
+ Text("QR captured. Review endpoint below.", style = onboardingCalloutStyle, color = onboardingSuccess)
+ ResolvedEndpoint(endpoint = resolvedEndpoint)
}
- GatewayModeToggle(inputMode = inputMode, onInputModeChange = onInputModeChange)
-
- if (inputMode == GatewayInputMode.SetupCode) {
- Text("SETUP CODE", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
- OutlinedTextField(
- value = setupCode,
- onValueChange = onSetupCodeChange,
- placeholder = { Text("Paste code from `openclaw qr --setup-code-only`", color = onboardingTextTertiary, style = onboardingBodyStyle) },
- modifier = Modifier.fillMaxWidth(),
- minLines = 3,
- maxLines = 5,
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
- textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText),
- shape = RoundedCornerShape(14.dp),
- colors =
- OutlinedTextFieldDefaults.colors(
- focusedContainerColor = onboardingSurface,
- unfocusedContainerColor = onboardingSurface,
- focusedBorderColor = onboardingAccent,
- unfocusedBorderColor = onboardingBorder,
- focusedTextColor = onboardingText,
- unfocusedTextColor = onboardingText,
- cursorColor = onboardingAccent,
- ),
- )
- if (!resolvedEndpoint.isNullOrBlank()) {
- ResolvedEndpoint(endpoint = resolvedEndpoint)
- }
- } else {
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- QuickFillChip(label = "Android Emulator", onClick = {
- onManualHostChange("10.0.2.2")
- onManualPortChange("18789")
- onManualTlsChange(false)
- })
- QuickFillChip(label = "Localhost", onClick = {
- onManualHostChange("127.0.0.1")
- onManualPortChange("18789")
- onManualTlsChange(false)
- })
- }
-
- Text("HOST", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
- OutlinedTextField(
- value = manualHost,
- onValueChange = onManualHostChange,
- placeholder = { Text("10.0.2.2", color = onboardingTextTertiary, style = onboardingBodyStyle) },
- modifier = Modifier.fillMaxWidth(),
- singleLine = true,
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
- textStyle = onboardingBodyStyle.copy(color = onboardingText),
- shape = RoundedCornerShape(14.dp),
- colors =
- OutlinedTextFieldDefaults.colors(
- focusedContainerColor = onboardingSurface,
- unfocusedContainerColor = onboardingSurface,
- focusedBorderColor = onboardingAccent,
- unfocusedBorderColor = onboardingBorder,
- focusedTextColor = onboardingText,
- unfocusedTextColor = onboardingText,
- cursorColor = onboardingAccent,
- ),
- )
-
- Text("PORT", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
- OutlinedTextField(
- value = manualPort,
- onValueChange = onManualPortChange,
- placeholder = { Text("18789", color = onboardingTextTertiary, style = onboardingBodyStyle) },
- modifier = Modifier.fillMaxWidth(),
- singleLine = true,
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
- textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText),
- shape = RoundedCornerShape(14.dp),
- colors =
- OutlinedTextFieldDefaults.colors(
- focusedContainerColor = onboardingSurface,
- unfocusedContainerColor = onboardingSurface,
- focusedBorderColor = onboardingAccent,
- unfocusedBorderColor = onboardingBorder,
- focusedTextColor = onboardingText,
- unfocusedTextColor = onboardingText,
- cursorColor = onboardingAccent,
- ),
- )
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(12.dp),
+ color = onboardingSurface,
+ border = androidx.compose.foundation.BorderStroke(1.dp, onboardingBorderStrong),
+ onClick = { onAdvancedOpenChange(!advancedOpen) },
+ ) {
Row(
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
- Text("Use TLS", style = onboardingHeadlineStyle, color = onboardingText)
- Text("Switch to secure websocket (`wss`).", style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary)
+ Text("Advanced setup", style = onboardingHeadlineStyle, color = onboardingText)
+ Text("Paste setup code or enter host/port manually.", style = onboardingCaption1Style, color = onboardingTextSecondary)
}
- Switch(
- checked = manualTls,
- onCheckedChange = onManualTlsChange,
- colors =
- SwitchDefaults.colors(
- checkedTrackColor = onboardingAccent,
- uncheckedTrackColor = onboardingBorderStrong,
- checkedThumbColor = Color.White,
- uncheckedThumbColor = Color.White,
- ),
+ Icon(
+ imageVector = if (advancedOpen) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
+ contentDescription = if (advancedOpen) "Collapse advanced setup" else "Expand advanced setup",
+ tint = onboardingTextSecondary,
)
}
+ }
- Text("TOKEN (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
- OutlinedTextField(
- value = gatewayToken,
- onValueChange = onTokenChange,
- placeholder = { Text("token", color = onboardingTextTertiary, style = onboardingBodyStyle) },
- modifier = Modifier.fillMaxWidth(),
- singleLine = true,
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
- textStyle = onboardingBodyStyle.copy(color = onboardingText),
- shape = RoundedCornerShape(14.dp),
- colors =
- OutlinedTextFieldDefaults.colors(
- focusedContainerColor = onboardingSurface,
- unfocusedContainerColor = onboardingSurface,
- focusedBorderColor = onboardingAccent,
- unfocusedBorderColor = onboardingBorder,
- focusedTextColor = onboardingText,
- unfocusedTextColor = onboardingText,
- cursorColor = onboardingAccent,
- ),
- )
+ AnimatedVisibility(visible = advancedOpen) {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ GuideBlock(title = "Manual setup commands") {
+ Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary)
+ CommandBlock("openclaw qr --setup-code-only")
+ CommandBlock("openclaw qr --json")
+ Text(
+ "`--json` prints `setupCode` and `gatewayUrl`.",
+ style = onboardingCalloutStyle,
+ color = onboardingTextSecondary,
+ )
+ Text(
+ "Auto URL discovery is not wired yet. Android emulator uses `10.0.2.2`; real devices need LAN/Tailscale host.",
+ style = onboardingCalloutStyle,
+ color = onboardingTextSecondary,
+ )
+ }
+ GatewayModeToggle(inputMode = inputMode, onInputModeChange = onInputModeChange)
- Text("PASSWORD (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
- OutlinedTextField(
- value = gatewayPassword,
- onValueChange = onPasswordChange,
- placeholder = { Text("password", color = onboardingTextTertiary, style = onboardingBodyStyle) },
- modifier = Modifier.fillMaxWidth(),
- singleLine = true,
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
- textStyle = onboardingBodyStyle.copy(color = onboardingText),
- shape = RoundedCornerShape(14.dp),
- colors =
- OutlinedTextFieldDefaults.colors(
- focusedContainerColor = onboardingSurface,
- unfocusedContainerColor = onboardingSurface,
- focusedBorderColor = onboardingAccent,
- unfocusedBorderColor = onboardingBorder,
- focusedTextColor = onboardingText,
- unfocusedTextColor = onboardingText,
- cursorColor = onboardingAccent,
- ),
- )
+ if (inputMode == GatewayInputMode.SetupCode) {
+ Text("SETUP CODE", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
+ OutlinedTextField(
+ value = setupCode,
+ onValueChange = onSetupCodeChange,
+ placeholder = { Text("Paste code from `openclaw qr --setup-code-only`", color = onboardingTextTertiary, style = onboardingBodyStyle) },
+ modifier = Modifier.fillMaxWidth(),
+ minLines = 3,
+ maxLines = 5,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
+ textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText),
+ shape = RoundedCornerShape(14.dp),
+ colors =
+ OutlinedTextFieldDefaults.colors(
+ focusedContainerColor = onboardingSurface,
+ unfocusedContainerColor = onboardingSurface,
+ focusedBorderColor = onboardingAccent,
+ unfocusedBorderColor = onboardingBorder,
+ focusedTextColor = onboardingText,
+ unfocusedTextColor = onboardingText,
+ cursorColor = onboardingAccent,
+ ),
+ )
+ if (!resolvedEndpoint.isNullOrBlank()) {
+ ResolvedEndpoint(endpoint = resolvedEndpoint)
+ }
+ } else {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ QuickFillChip(label = "Android Emulator", onClick = {
+ onManualHostChange("10.0.2.2")
+ onManualPortChange("18789")
+ onManualTlsChange(false)
+ })
+ QuickFillChip(label = "Localhost", onClick = {
+ onManualHostChange("127.0.0.1")
+ onManualPortChange("18789")
+ onManualTlsChange(false)
+ })
+ }
+
+ Text("HOST", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
+ OutlinedTextField(
+ value = manualHost,
+ onValueChange = onManualHostChange,
+ placeholder = { Text("10.0.2.2", color = onboardingTextTertiary, style = onboardingBodyStyle) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
+ textStyle = onboardingBodyStyle.copy(color = onboardingText),
+ shape = RoundedCornerShape(14.dp),
+ colors =
+ OutlinedTextFieldDefaults.colors(
+ focusedContainerColor = onboardingSurface,
+ unfocusedContainerColor = onboardingSurface,
+ focusedBorderColor = onboardingAccent,
+ unfocusedBorderColor = onboardingBorder,
+ focusedTextColor = onboardingText,
+ unfocusedTextColor = onboardingText,
+ cursorColor = onboardingAccent,
+ ),
+ )
+
+ Text("PORT", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
+ OutlinedTextField(
+ value = manualPort,
+ onValueChange = onManualPortChange,
+ placeholder = { Text("18789", color = onboardingTextTertiary, style = onboardingBodyStyle) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText),
+ shape = RoundedCornerShape(14.dp),
+ colors =
+ OutlinedTextFieldDefaults.colors(
+ focusedContainerColor = onboardingSurface,
+ unfocusedContainerColor = onboardingSurface,
+ focusedBorderColor = onboardingAccent,
+ unfocusedBorderColor = onboardingBorder,
+ focusedTextColor = onboardingText,
+ unfocusedTextColor = onboardingText,
+ cursorColor = onboardingAccent,
+ ),
+ )
- if (!manualResolvedEndpoint.isNullOrBlank()) {
- ResolvedEndpoint(endpoint = manualResolvedEndpoint)
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text("Use TLS", style = onboardingHeadlineStyle, color = onboardingText)
+ Text("Switch to secure websocket (`wss`).", style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary)
+ }
+ Switch(
+ checked = manualTls,
+ onCheckedChange = onManualTlsChange,
+ colors =
+ SwitchDefaults.colors(
+ checkedTrackColor = onboardingAccent,
+ uncheckedTrackColor = onboardingBorderStrong,
+ checkedThumbColor = Color.White,
+ uncheckedThumbColor = Color.White,
+ ),
+ )
+ }
+
+ Text("TOKEN (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
+ OutlinedTextField(
+ value = gatewayToken,
+ onValueChange = onTokenChange,
+ placeholder = { Text("token", color = onboardingTextTertiary, style = onboardingBodyStyle) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
+ textStyle = onboardingBodyStyle.copy(color = onboardingText),
+ shape = RoundedCornerShape(14.dp),
+ colors =
+ OutlinedTextFieldDefaults.colors(
+ focusedContainerColor = onboardingSurface,
+ unfocusedContainerColor = onboardingSurface,
+ focusedBorderColor = onboardingAccent,
+ unfocusedBorderColor = onboardingBorder,
+ focusedTextColor = onboardingText,
+ unfocusedTextColor = onboardingText,
+ cursorColor = onboardingAccent,
+ ),
+ )
+
+ Text("PASSWORD (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
+ OutlinedTextField(
+ value = gatewayPassword,
+ onValueChange = onPasswordChange,
+ placeholder = { Text("password", color = onboardingTextTertiary, style = onboardingBodyStyle) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
+ textStyle = onboardingBodyStyle.copy(color = onboardingText),
+ shape = RoundedCornerShape(14.dp),
+ colors =
+ OutlinedTextFieldDefaults.colors(
+ focusedContainerColor = onboardingSurface,
+ unfocusedContainerColor = onboardingSurface,
+ focusedBorderColor = onboardingAccent,
+ unfocusedBorderColor = onboardingBorder,
+ focusedTextColor = onboardingText,
+ unfocusedTextColor = onboardingText,
+ cursorColor = onboardingAccent,
+ ),
+ )
+
+ if (!manualResolvedEndpoint.isNullOrBlank()) {
+ ResolvedEndpoint(endpoint = manualResolvedEndpoint)
+ }
+ }
}
}
@@ -964,7 +1053,7 @@ private fun PermissionsStep(
}
PermissionToggleRow(
title = "Microphone",
- subtitle = "Talk mode + voice features",
+ subtitle = "Voice tab transcription",
checked = enableMicrophone,
granted = isPermissionGranted(context, Manifest.permission.RECORD_AUDIO),
onCheckedChange = onMicrophoneChange,
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt
index b68c06ff2ff9..1345d8e3cb9f 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt
@@ -5,20 +5,19 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
-import androidx.compose.foundation.layout.isImeVisible
+import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
-import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ScreenShare
@@ -41,6 +40,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import ai.openclaw.android.MainViewModel
@@ -65,10 +65,8 @@ private enum class StatusVisual {
}
@Composable
-@OptIn(ExperimentalLayoutApi::class)
fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) {
var activeTab by rememberSaveable { mutableStateOf(HomeTab.Connect) }
- val imeVisible = WindowInsets.isImeVisible
val statusText by viewModel.statusText.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
@@ -85,6 +83,10 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier)
}
}
+ val density = LocalDensity.current
+ val imeVisible = WindowInsets.ime.getBottom(density) > 0
+ val hideBottomTabBar = activeTab == HomeTab.Chat && imeVisible
+
Scaffold(
modifier = modifier,
containerColor = Color.Transparent,
@@ -96,7 +98,7 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier)
)
},
bottomBar = {
- if (!imeVisible) {
+ if (!hideBottomTabBar) {
BottomTabBar(
activeTab = activeTab,
onSelect = { activeTab = it },
@@ -109,12 +111,13 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier)
Modifier
.fillMaxSize()
.padding(innerPadding)
+ .consumeWindowInsets(innerPadding)
.background(mobileBackgroundGradient),
) {
when (activeTab) {
HomeTab.Connect -> ConnectTabScreen(viewModel = viewModel)
HomeTab.Chat -> ChatSheet(viewModel = viewModel)
- HomeTab.Voice -> ComingSoonTabScreen(label = "VOICE", title = "Coming soon", description = "Voice mode is coming soon.")
+ HomeTab.Voice -> VoiceTabScreen(viewModel = viewModel)
HomeTab.Screen -> ScreenTabScreen(viewModel = viewModel)
HomeTab.Settings -> SettingsSheet(viewModel = viewModel)
}
@@ -265,18 +268,21 @@ private fun BottomTabBar(
Box(
modifier =
Modifier
- .fillMaxWidth()
- .windowInsetsPadding(safeInsets),
+ .fillMaxWidth(),
) {
Surface(
- modifier = Modifier.fillMaxWidth().offset(y = (-4).dp),
+ modifier = Modifier.fillMaxWidth(),
color = Color.White.copy(alpha = 0.97f),
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
border = BorderStroke(1.dp, mobileBorder),
shadowElevation = 6.dp,
) {
Row(
- modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .windowInsetsPadding(safeInsets)
+ .padding(horizontal = 10.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -312,22 +318,3 @@ private fun BottomTabBar(
}
}
}
-
-@Composable
-private fun ComingSoonTabScreen(
- label: String,
- title: String,
- description: String,
-) {
- Box(modifier = Modifier.fillMaxSize().padding(horizontal = 22.dp, vertical = 18.dp)) {
- Column(
- modifier = Modifier.align(Alignment.Center),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(10.dp),
- ) {
- Text(label, style = mobileCaption1.copy(fontWeight = FontWeight.Bold), color = mobileAccent)
- Text(title, style = mobileTitle1, color = mobileText)
- Text(description, style = mobileBody, color = mobileTextSecondary)
- }
- }
-}
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt
index 2a6219578c70..6de3151a7f19 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt
@@ -9,7 +9,6 @@ import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
-import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
@@ -32,8 +31,6 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.foundation.text.KeyboardActions
-import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material3.Button
@@ -48,7 +45,7 @@ import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -56,41 +53,33 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
import ai.openclaw.android.BuildConfig
import ai.openclaw.android.LocationMode
import ai.openclaw.android.MainViewModel
-import ai.openclaw.android.VoiceWakeMode
-import ai.openclaw.android.WakeWords
@Composable
fun SettingsSheet(viewModel: MainViewModel) {
val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
val instanceId by viewModel.instanceId.collectAsState()
val displayName by viewModel.displayName.collectAsState()
val cameraEnabled by viewModel.cameraEnabled.collectAsState()
val locationMode by viewModel.locationMode.collectAsState()
val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState()
val preventSleep by viewModel.preventSleep.collectAsState()
- val wakeWords by viewModel.wakeWords.collectAsState()
- val voiceWakeMode by viewModel.voiceWakeMode.collectAsState()
- val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState()
- val isConnected by viewModel.isConnected.collectAsState()
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
val listState = rememberLazyListState()
- val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
- val focusManager = LocalFocusManager.current
- var wakeWordsHadFocus by remember { mutableStateOf(false) }
val deviceModel =
remember {
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
@@ -116,14 +105,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
leadingIconColor = mobileTextSecondary,
)
- LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
- val commitWakeWords = {
- val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords)
- if (parsed != null) {
- viewModel.setWakeWords(parsed)
- }
- }
-
val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
val cameraOk = perms[Manifest.permission.CAMERA] == true
@@ -165,9 +146,16 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
}
+ var micPermissionGranted by
+ remember {
+ mutableStateOf(
+ ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
+ PackageManager.PERMISSION_GRANTED,
+ )
+ }
val audioPermissionLauncher =
- rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { _ ->
- // Status text is handled by NodeRuntime.
+ rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ micPermissionGranted = granted
}
val smsPermissionAvailable =
@@ -187,6 +175,22 @@ fun SettingsSheet(viewModel: MainViewModel) {
viewModel.refreshGatewayConnection()
}
+ DisposableEffect(lifecycleOwner, context) {
+ val observer =
+ LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ micPermissionGranted =
+ ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
+ PackageManager.PERMISSION_GRANTED
+ smsPermissionGranted =
+ ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
+ PackageManager.PERMISSION_GRANTED
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ }
+
fun setCameraEnabledChecked(checked: Boolean) {
if (!checked) {
viewModel.setCameraEnabled(false)
@@ -302,7 +306,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
item { HorizontalDivider(color = mobileBorder) }
- // Voice
+ // Voice
item {
Text(
"VOICE",
@@ -310,120 +314,48 @@ fun SettingsSheet(viewModel: MainViewModel) {
color = mobileAccent,
)
}
- item {
- val enabled = voiceWakeMode != VoiceWakeMode.Off
- ListItem(
- modifier = settingsRowModifier(),
- colors = listItemColors,
- headlineContent = { Text("Voice Wake", style = mobileHeadline) },
- supportingContent = { Text(voiceWakeStatusText, style = mobileCallout) },
- trailingContent = {
- Switch(
- checked = enabled,
- onCheckedChange = { on ->
- if (on) {
- val micOk =
- ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
- PackageManager.PERMISSION_GRANTED
- if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
- viewModel.setVoiceWakeMode(VoiceWakeMode.Foreground)
+ item {
+ ListItem(
+ modifier = settingsRowModifier(),
+ colors = listItemColors,
+ headlineContent = { Text("Microphone permission", style = mobileHeadline) },
+ supportingContent = {
+ Text(
+ if (micPermissionGranted) {
+ "Granted. Use the Voice tab mic button to capture transcript."
} else {
- viewModel.setVoiceWakeMode(VoiceWakeMode.Off)
- }
- },
- )
- },
- )
- }
- item {
- AnimatedVisibility(visible = voiceWakeMode != VoiceWakeMode.Off) {
- Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
- ListItem(
- modifier = settingsRowModifier(),
- colors = listItemColors,
- headlineContent = { Text("Foreground Only", style = mobileHeadline) },
- supportingContent = { Text("Listens only while OpenClaw is open.", style = mobileCallout) },
- trailingContent = {
- RadioButton(
- selected = voiceWakeMode == VoiceWakeMode.Foreground,
- onClick = {
- val micOk =
- ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
- PackageManager.PERMISSION_GRANTED
- if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
- viewModel.setVoiceWakeMode(VoiceWakeMode.Foreground)
- },
- )
- },
- )
- ListItem(
- modifier = settingsRowModifier(),
- colors = listItemColors,
- headlineContent = { Text("Always", style = mobileHeadline) },
- supportingContent = { Text("Keeps listening in the background (shows a persistent notification).", style = mobileCallout) },
- trailingContent = {
- RadioButton(
- selected = voiceWakeMode == VoiceWakeMode.Always,
- onClick = {
- val micOk =
- ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
- PackageManager.PERMISSION_GRANTED
- if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
- viewModel.setVoiceWakeMode(VoiceWakeMode.Always)
- },
+ "Required for Voice tab transcription."
+ },
+ style = mobileCallout,
+ )
+ },
+ trailingContent = {
+ Button(
+ onClick = {
+ if (micPermissionGranted) {
+ openAppSettings(context)
+ } else {
+ audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
+ }
+ },
+ colors = settingsPrimaryButtonColors(),
+ shape = RoundedCornerShape(14.dp),
+ ) {
+ Text(
+ if (micPermissionGranted) "Manage" else "Grant",
+ style = mobileCallout.copy(fontWeight = FontWeight.Bold),
)
- },
- )
- }
- }
- }
- item {
- OutlinedTextField(
- value = wakeWordsText,
- onValueChange = setWakeWordsText,
- label = { Text("Wake Words (comma-separated)", style = mobileCaption1, color = mobileTextSecondary) },
- modifier =
- Modifier.fillMaxWidth().onFocusChanged { focusState ->
- if (focusState.isFocused) {
- wakeWordsHadFocus = true
- } else if (wakeWordsHadFocus) {
- wakeWordsHadFocus = false
- commitWakeWords()
}
},
- singleLine = true,
- keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
- keyboardActions =
- KeyboardActions(
- onDone = {
- commitWakeWords()
- focusManager.clearFocus()
- },
- ),
- textStyle = mobileBody.copy(color = mobileText),
- colors = settingsTextFieldColors(),
- )
- }
+ )
+ }
item {
- Button(
- onClick = viewModel::resetWakeWordsDefaults,
- colors = settingsPrimaryButtonColors(),
- shape = RoundedCornerShape(14.dp),
- ) {
- Text("Reset defaults", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
- }
+ Text(
+ "Voice wake and talk modes were removed. Voice now uses one mic on/off flow in the Voice tab.",
+ style = mobileCallout,
+ color = mobileTextSecondary,
+ )
}
- item {
- Text(
- if (isConnected) {
- "Any node can edit wake words. Changes sync via the gateway."
- } else {
- "Connect to a gateway to sync wake words globally."
- },
- style = mobileCallout,
- color = mobileTextSecondary,
- )
- }
item { HorizontalDivider(color = mobileBorder) }
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/VoiceTabScreen.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/VoiceTabScreen.kt
new file mode 100644
index 000000000000..9149a0f08864
--- /dev/null
+++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/VoiceTabScreen.kt
@@ -0,0 +1,451 @@
+package ai.openclaw.android.ui
+
+import android.Manifest
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.provider.Settings
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Mic
+import androidx.compose.material.icons.filled.MicOff
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import ai.openclaw.android.MainViewModel
+import ai.openclaw.android.voice.VoiceConversationEntry
+import ai.openclaw.android.voice.VoiceConversationRole
+import kotlin.math.PI
+import kotlin.math.max
+import kotlin.math.sin
+
+@Composable
+fun VoiceTabScreen(viewModel: MainViewModel) {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val activity = remember(context) { context.findActivity() }
+ val listState = rememberLazyListState()
+
+ val isConnected by viewModel.isConnected.collectAsState()
+ val gatewayStatus by viewModel.statusText.collectAsState()
+ val micEnabled by viewModel.micEnabled.collectAsState()
+ val micStatusText by viewModel.micStatusText.collectAsState()
+ val micLiveTranscript by viewModel.micLiveTranscript.collectAsState()
+ val micQueuedMessages by viewModel.micQueuedMessages.collectAsState()
+ val micConversation by viewModel.micConversation.collectAsState()
+ val micInputLevel by viewModel.micInputLevel.collectAsState()
+ val micIsSending by viewModel.micIsSending.collectAsState()
+
+ val hasStreamingAssistant = micConversation.any { it.role == VoiceConversationRole.Assistant && it.isStreaming }
+ val showThinkingBubble = micIsSending && !hasStreamingAssistant
+
+ var hasMicPermission by remember { mutableStateOf(context.hasRecordAudioPermission()) }
+ var pendingMicEnable by remember { mutableStateOf(false) }
+
+ DisposableEffect(lifecycleOwner, context) {
+ val observer =
+ LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ hasMicPermission = context.hasRecordAudioPermission()
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ }
+
+ val requestMicPermission =
+ rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ hasMicPermission = granted
+ if (granted && pendingMicEnable) {
+ viewModel.setMicEnabled(true)
+ }
+ pendingMicEnable = false
+ }
+
+ LaunchedEffect(micConversation.size, showThinkingBubble) {
+ val total = micConversation.size + if (showThinkingBubble) 1 else 0
+ if (total > 0) {
+ listState.animateScrollToItem(total - 1)
+ }
+ }
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(mobileBackgroundGradient)
+ .imePadding()
+ .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom))
+ .padding(horizontal = 20.dp, vertical = 14.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(
+ "VOICE",
+ style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
+ color = mobileAccent,
+ )
+ Text("Voice mode", style = mobileTitle2, color = mobileText)
+ }
+ Surface(
+ shape = RoundedCornerShape(999.dp),
+ color = if (isConnected) mobileAccentSoft else mobileSurfaceStrong,
+ border = BorderStroke(1.dp, if (isConnected) mobileAccent.copy(alpha = 0.25f) else mobileBorderStrong),
+ ) {
+ Text(
+ if (isConnected) "Connected" else "Offline",
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
+ style = mobileCaption1,
+ color = if (isConnected) mobileAccent else mobileTextSecondary,
+ )
+ }
+ }
+
+ LazyColumn(
+ state = listState,
+ modifier = Modifier.fillMaxWidth().weight(1f),
+ contentPadding = PaddingValues(vertical = 4.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ if (micConversation.isEmpty() && !showThinkingBubble) {
+ item {
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ "Tap the mic and speak. Each pause sends a turn automatically.",
+ style = mobileCallout,
+ color = mobileTextSecondary,
+ )
+ }
+ }
+ }
+
+ items(items = micConversation, key = { it.id }) { entry ->
+ VoiceTurnBubble(entry = entry)
+ }
+
+ if (showThinkingBubble) {
+ item {
+ VoiceThinkingBubble()
+ }
+ }
+ }
+
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(20.dp),
+ color = Color.White,
+ border = BorderStroke(1.dp, mobileBorder),
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Surface(
+ shape = RoundedCornerShape(999.dp),
+ color = mobileSurface,
+ border = BorderStroke(1.dp, mobileBorder),
+ ) {
+ val queueCount = micQueuedMessages.size
+ val stateText =
+ when {
+ queueCount > 0 -> "$queueCount queued"
+ micIsSending -> "Sending"
+ micEnabled -> "Listening"
+ else -> "Mic off"
+ }
+ Text(
+ "$gatewayStatus · $stateText",
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp),
+ style = mobileCaption1,
+ color = mobileTextSecondary,
+ )
+ }
+
+ if (!micLiveTranscript.isNullOrBlank()) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(14.dp),
+ color = mobileAccentSoft,
+ border = BorderStroke(1.dp, mobileAccent.copy(alpha = 0.2f)),
+ ) {
+ Text(
+ micLiveTranscript!!.trim(),
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
+ style = mobileCallout,
+ color = mobileText,
+ )
+ }
+ }
+
+ MicWaveform(level = micInputLevel, active = micEnabled)
+
+ Button(
+ onClick = {
+ if (micEnabled) {
+ viewModel.setMicEnabled(false)
+ return@Button
+ }
+ if (hasMicPermission) {
+ viewModel.setMicEnabled(true)
+ } else {
+ pendingMicEnable = true
+ requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
+ }
+ },
+ shape = CircleShape,
+ contentPadding = PaddingValues(0.dp),
+ modifier = Modifier.size(86.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = if (micEnabled) mobileDanger else mobileAccent,
+ contentColor = Color.White,
+ ),
+ ) {
+ Icon(
+ imageVector = if (micEnabled) Icons.Default.MicOff else Icons.Default.Mic,
+ contentDescription = if (micEnabled) "Turn microphone off" else "Turn microphone on",
+ modifier = Modifier.size(30.dp),
+ )
+ }
+
+ Text(
+ if (micEnabled) "Tap to stop" else "Tap to speak",
+ style = mobileCallout,
+ color = mobileTextSecondary,
+ )
+
+ if (!hasMicPermission) {
+ val showRationale =
+ if (activity == null) {
+ false
+ } else {
+ ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.RECORD_AUDIO)
+ }
+ Text(
+ if (showRationale) {
+ "Microphone permission is required for voice mode."
+ } else {
+ "Microphone blocked. Open app settings to enable it."
+ },
+ style = mobileCaption1,
+ color = mobileWarning,
+ textAlign = TextAlign.Center,
+ )
+ Button(
+ onClick = { openAppSettings(context) },
+ shape = RoundedCornerShape(12.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = mobileSurfaceStrong, contentColor = mobileText),
+ ) {
+ Text("Open settings", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold))
+ }
+ }
+
+ Text(
+ micStatusText,
+ style = mobileCaption1,
+ color = mobileTextTertiary,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun VoiceTurnBubble(entry: VoiceConversationEntry) {
+ val isUser = entry.role == VoiceConversationRole.User
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
+ ) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(0.90f),
+ shape = RoundedCornerShape(14.dp),
+ color = if (isUser) mobileAccentSoft else mobileSurface,
+ border = BorderStroke(1.dp, if (isUser) mobileAccent.copy(alpha = 0.2f) else mobileBorder),
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Text(
+ if (isUser) "You" else "OpenClaw",
+ style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold),
+ color = mobileTextSecondary,
+ )
+ Text(
+ if (entry.isStreaming && entry.text.isBlank()) "Listening response…" else entry.text,
+ style = mobileCallout,
+ color = mobileText,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun VoiceThinkingBubble() {
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(0.68f),
+ shape = RoundedCornerShape(14.dp),
+ color = mobileSurface,
+ border = BorderStroke(1.dp, mobileBorder),
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ThinkingDots(color = mobileTextSecondary)
+ Text("OpenClaw is thinking…", style = mobileCallout, color = mobileTextSecondary)
+ }
+ }
+ }
+}
+
+@Composable
+private fun ThinkingDots(color: Color) {
+ Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) {
+ ThinkingDot(alpha = 0.38f, color = color)
+ ThinkingDot(alpha = 0.62f, color = color)
+ ThinkingDot(alpha = 0.90f, color = color)
+ }
+}
+
+@Composable
+private fun ThinkingDot(alpha: Float, color: Color) {
+ Surface(
+ modifier = Modifier.size(6.dp).alpha(alpha),
+ shape = CircleShape,
+ color = color,
+ ) {}
+}
+
+@Composable
+private fun MicWaveform(level: Float, active: Boolean) {
+ val transition = rememberInfiniteTransition(label = "voiceWave")
+ val phase by
+ transition.animateFloat(
+ initialValue = 0f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(animation = tween(1_000, easing = LinearEasing), repeatMode = RepeatMode.Restart),
+ label = "voiceWavePhase",
+ )
+
+ val effective = if (active) level.coerceIn(0f, 1f) else 0f
+ val base = max(effective, if (active) 0.05f else 0f)
+
+ Row(
+ modifier = Modifier.fillMaxWidth().heightIn(min = 40.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ repeat(16) { index ->
+ val pulse =
+ if (!active) {
+ 0f
+ } else {
+ ((sin(((phase * 2f * PI) + (index * 0.55f)).toDouble()) + 1.0) * 0.5).toFloat()
+ }
+ val barHeight = 6.dp + (24.dp * (base * pulse))
+ Box(
+ modifier =
+ Modifier
+ .width(5.dp)
+ .height(barHeight)
+ .background(if (active) mobileAccent else mobileBorderStrong, RoundedCornerShape(999.dp)),
+ )
+ }
+ }
+}
+
+private fun Context.hasRecordAudioPermission(): Boolean {
+ return (
+ ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
+ PackageManager.PERMISSION_GRANTED
+ )
+}
+
+private fun Context.findActivity(): Activity? =
+ when (this) {
+ is Activity -> this
+ is ContextWrapper -> baseContext.findActivity()
+ else -> null
+ }
+
+private fun openAppSettings(context: Context) {
+ val intent =
+ Intent(
+ Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
+ Uri.fromParts("package", context.packageName, null),
+ )
+ context.startActivity(intent)
+}
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt
index 7f71995906bd..22099500ebf1 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt
@@ -5,6 +5,7 @@ import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -161,6 +162,7 @@ fun ChatComposer(
label = "Refresh",
icon = Icons.Default.Refresh,
enabled = true,
+ compact = true,
onClick = onRefresh,
)
@@ -168,6 +170,7 @@ fun ChatComposer(
label = "Abort",
icon = Icons.Default.Stop,
enabled = pendingRunCount > 0,
+ compact = true,
onClick = onAbort,
)
}
@@ -196,7 +199,12 @@ fun ChatComposer(
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(16.dp))
}
Spacer(modifier = Modifier.width(8.dp))
- Text("Send", style = mobileHeadline.copy(fontWeight = FontWeight.Bold))
+ Text(
+ text = "Send",
+ style = mobileHeadline.copy(fontWeight = FontWeight.Bold),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
}
}
}
@@ -207,12 +215,13 @@ private fun SecondaryActionButton(
label: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
enabled: Boolean,
+ compact: Boolean = false,
onClick: () -> Unit,
) {
Button(
onClick = onClick,
enabled = enabled,
- modifier = Modifier.height(44.dp),
+ modifier = if (compact) Modifier.size(44.dp) else Modifier.height(44.dp),
shape = RoundedCornerShape(14.dp),
colors =
ButtonDefaults.buttonColors(
@@ -222,15 +231,17 @@ private fun SecondaryActionButton(
disabledContentColor = mobileTextTertiary,
),
border = BorderStroke(1.dp, mobileBorderStrong),
- contentPadding = ButtonDefaults.ContentPadding,
+ contentPadding = if (compact) PaddingValues(0.dp) else ButtonDefaults.ContentPadding,
) {
Icon(icon, contentDescription = label, modifier = Modifier.size(14.dp))
- Spacer(modifier = Modifier.width(5.dp))
- Text(
- text = label,
- style = mobileCallout.copy(fontWeight = FontWeight.SemiBold),
- color = if (enabled) mobileTextSecondary else mobileTextTertiary,
- )
+ if (!compact) {
+ Spacer(modifier = Modifier.width(5.dp))
+ Text(
+ text = label,
+ style = mobileCallout.copy(fontWeight = FontWeight.SemiBold),
+ color = if (enabled) mobileTextSecondary else mobileTextTertiary,
+ )
+ }
}
}
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt
index d1c2743ef041..12e13ab365ab 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -122,33 +123,35 @@ fun ChatSheetContent(viewModel: MainViewModel) {
modifier = Modifier.weight(1f, fill = true),
)
- ChatComposer(
- healthOk = healthOk,
- thinkingLevel = thinkingLevel,
- pendingRunCount = pendingRunCount,
- attachments = attachments,
- onPickImages = { pickImages.launch("image/*") },
- onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
- onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) },
- onRefresh = {
- viewModel.refreshChat()
- viewModel.refreshChatSessions(limit = 200)
- },
- onAbort = { viewModel.abortChat() },
- onSend = { text ->
- val outgoing =
- attachments.map { att ->
- OutgoingAttachment(
- type = "image",
- mimeType = att.mimeType,
- fileName = att.fileName,
- base64 = att.base64,
- )
- }
- viewModel.sendChat(message = text, thinking = thinkingLevel, attachments = outgoing)
- attachments.clear()
- },
- )
+ Row(modifier = Modifier.fillMaxWidth().imePadding()) {
+ ChatComposer(
+ healthOk = healthOk,
+ thinkingLevel = thinkingLevel,
+ pendingRunCount = pendingRunCount,
+ attachments = attachments,
+ onPickImages = { pickImages.launch("image/*") },
+ onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
+ onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) },
+ onRefresh = {
+ viewModel.refreshChat()
+ viewModel.refreshChatSessions(limit = 200)
+ },
+ onAbort = { viewModel.abortChat() },
+ onSend = { text ->
+ val outgoing =
+ attachments.map { att ->
+ OutgoingAttachment(
+ type = "image",
+ mimeType = att.mimeType,
+ fileName = att.fileName,
+ base64 = att.base64,
+ )
+ }
+ viewModel.sendChat(message = text, thinking = thinkingLevel, attachments = outgoing)
+ attachments.clear()
+ },
+ )
+ }
}
}
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/voice/MicCaptureManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/voice/MicCaptureManager.kt
new file mode 100644
index 000000000000..c28e523a1821
--- /dev/null
+++ b/apps/android/app/src/main/java/ai/openclaw/android/voice/MicCaptureManager.kt
@@ -0,0 +1,523 @@
+package ai.openclaw.android.voice
+
+import android.Manifest
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.speech.RecognitionListener
+import android.speech.RecognizerIntent
+import android.speech.SpeechRecognizer
+import androidx.core.content.ContextCompat
+import java.util.UUID
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+
+enum class VoiceConversationRole {
+ User,
+ Assistant,
+}
+
+data class VoiceConversationEntry(
+ val id: String,
+ val role: VoiceConversationRole,
+ val text: String,
+ val isStreaming: Boolean = false,
+)
+
+class MicCaptureManager(
+ private val context: Context,
+ private val scope: CoroutineScope,
+ private val sendToGateway: suspend (String) -> String?,
+) {
+ companion object {
+ private const val speechMinSessionMs = 30_000L
+ private const val speechCompleteSilenceMs = 1_500L
+ private const val speechPossibleSilenceMs = 900L
+ private const val maxConversationEntries = 40
+ private const val pendingRunTimeoutMs = 45_000L
+ }
+
+ private data class QueuedUtterance(
+ val text: String,
+ )
+
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private val json = Json { ignoreUnknownKeys = true }
+
+ private val _micEnabled = MutableStateFlow(false)
+ val micEnabled: StateFlow = _micEnabled
+
+ private val _isListening = MutableStateFlow(false)
+ val isListening: StateFlow = _isListening
+
+ private val _statusText = MutableStateFlow("Mic off")
+ val statusText: StateFlow = _statusText
+
+ private val _liveTranscript = MutableStateFlow(null)
+ val liveTranscript: StateFlow = _liveTranscript
+
+ private val _queuedMessages = MutableStateFlow>(emptyList())
+ val queuedMessages: StateFlow> = _queuedMessages
+
+ private val _conversation = MutableStateFlow>(emptyList())
+ val conversation: StateFlow> = _conversation
+
+ private val _inputLevel = MutableStateFlow(0f)
+ val inputLevel: StateFlow = _inputLevel
+
+ private val _isSending = MutableStateFlow(false)
+ val isSending: StateFlow = _isSending
+
+ private val messageQueue = ArrayDeque()
+ private val sessionSegments = mutableListOf()
+ private var lastFinalSegment: String? = null
+ private var pendingRunId: String? = null
+ private var pendingAssistantEntryId: String? = null
+ private var gatewayConnected = false
+
+ private var recognizer: SpeechRecognizer? = null
+ private var restartJob: Job? = null
+ private var pendingRunTimeoutJob: Job? = null
+ private var stopRequested = false
+
+ fun setMicEnabled(enabled: Boolean) {
+ if (_micEnabled.value == enabled) return
+ _micEnabled.value = enabled
+ if (enabled) {
+ start()
+ sendQueuedIfIdle()
+ } else {
+ stop()
+ flushSessionToQueue()
+ sendQueuedIfIdle()
+ }
+ }
+
+ fun onGatewayConnectionChanged(connected: Boolean) {
+ gatewayConnected = connected
+ if (connected) {
+ sendQueuedIfIdle()
+ return
+ }
+ if (messageQueue.isNotEmpty()) {
+ _statusText.value = queuedWaitingStatus()
+ }
+ }
+
+ fun handleGatewayEvent(event: String, payloadJson: String?) {
+ if (event != "chat") return
+ if (payloadJson.isNullOrBlank()) return
+ val payload =
+ try {
+ json.parseToJsonElement(payloadJson).asObjectOrNull()
+ } catch (_: Throwable) {
+ null
+ } ?: return
+
+ val runId = pendingRunId ?: return
+ val eventRunId = payload["runId"].asStringOrNull() ?: return
+ if (eventRunId != runId) return
+
+ when (payload["state"].asStringOrNull()) {
+ "delta" -> {
+ val deltaText = parseAssistantText(payload)
+ if (!deltaText.isNullOrBlank()) {
+ upsertPendingAssistant(text = deltaText.trim(), isStreaming = true)
+ }
+ }
+ "final" -> {
+ val finalText = parseAssistantText(payload)?.trim().orEmpty()
+ if (finalText.isNotEmpty()) {
+ upsertPendingAssistant(text = finalText, isStreaming = false)
+ } else if (pendingAssistantEntryId != null) {
+ updateConversationEntry(pendingAssistantEntryId!!, text = null, isStreaming = false)
+ }
+ completePendingTurn()
+ }
+ "error" -> {
+ val errorMessage = payload["errorMessage"].asStringOrNull()?.trim().orEmpty().ifEmpty { "Voice request failed" }
+ upsertPendingAssistant(text = errorMessage, isStreaming = false)
+ completePendingTurn()
+ }
+ "aborted" -> {
+ upsertPendingAssistant(text = "Response aborted", isStreaming = false)
+ completePendingTurn()
+ }
+ }
+ }
+
+ private fun start() {
+ stopRequested = false
+ if (!SpeechRecognizer.isRecognitionAvailable(context)) {
+ _statusText.value = "Speech recognizer unavailable"
+ _micEnabled.value = false
+ return
+ }
+ if (!hasMicPermission()) {
+ _statusText.value = "Microphone permission required"
+ _micEnabled.value = false
+ return
+ }
+
+ mainHandler.post {
+ try {
+ if (recognizer == null) {
+ recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) }
+ }
+ startListeningSession()
+ } catch (err: Throwable) {
+ _statusText.value = "Start failed: ${err.message ?: err::class.simpleName}"
+ _micEnabled.value = false
+ }
+ }
+ }
+
+ private fun stop() {
+ stopRequested = true
+ restartJob?.cancel()
+ restartJob = null
+ _isListening.value = false
+ _statusText.value = if (_isSending.value) "Mic off · sending…" else "Mic off"
+ _inputLevel.value = 0f
+ mainHandler.post {
+ recognizer?.cancel()
+ recognizer?.destroy()
+ recognizer = null
+ }
+ }
+
+ private fun startListeningSession() {
+ val recognizerInstance = recognizer ?: return
+ val intent =
+ Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
+ putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
+ putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
+ putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3)
+ putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName)
+ putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_MINIMUM_LENGTH_MILLIS, speechMinSessionMs)
+ putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, speechCompleteSilenceMs)
+ putExtra(
+ RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS,
+ speechPossibleSilenceMs,
+ )
+ }
+ _statusText.value =
+ when {
+ _isSending.value -> "Listening · sending queued voice"
+ messageQueue.isNotEmpty() -> "Listening · ${messageQueue.size} queued"
+ else -> "Listening"
+ }
+ _isListening.value = true
+ recognizerInstance.startListening(intent)
+ }
+
+ private fun scheduleRestart(delayMs: Long = 300L) {
+ if (stopRequested) return
+ if (!_micEnabled.value) return
+ restartJob?.cancel()
+ restartJob =
+ scope.launch {
+ delay(delayMs)
+ mainHandler.post {
+ if (stopRequested || !_micEnabled.value) return@post
+ try {
+ startListeningSession()
+ } catch (_: Throwable) {
+ // retry through onError
+ }
+ }
+ }
+ }
+
+ private fun flushSessionToQueue() {
+ val message = sessionSegments.joinToString(" ").trim()
+ sessionSegments.clear()
+ _liveTranscript.value = null
+ lastFinalSegment = null
+ if (message.isEmpty()) return
+
+ appendConversation(
+ role = VoiceConversationRole.User,
+ text = message,
+ )
+ messageQueue.addLast(QueuedUtterance(text = message))
+ publishQueue()
+ }
+
+ private fun publishQueue() {
+ _queuedMessages.value = messageQueue.map { it.text }
+ }
+
+ private fun sendQueuedIfIdle() {
+ if (_isSending.value) return
+ if (messageQueue.isEmpty()) {
+ if (_micEnabled.value) {
+ _statusText.value = "Listening"
+ } else {
+ _statusText.value = "Mic off"
+ }
+ return
+ }
+ if (!gatewayConnected) {
+ _statusText.value = queuedWaitingStatus()
+ return
+ }
+
+ val next = messageQueue.first()
+ _isSending.value = true
+ pendingRunTimeoutJob?.cancel()
+ pendingRunTimeoutJob = null
+ _statusText.value = if (_micEnabled.value) "Listening · sending queued voice" else "Sending queued voice"
+
+ scope.launch {
+ try {
+ val runId = sendToGateway(next.text)
+ pendingRunId = runId
+ if (runId == null) {
+ pendingRunTimeoutJob?.cancel()
+ pendingRunTimeoutJob = null
+ messageQueue.removeFirst()
+ publishQueue()
+ _isSending.value = false
+ pendingAssistantEntryId = null
+ sendQueuedIfIdle()
+ } else {
+ armPendingRunTimeout(runId)
+ }
+ } catch (err: Throwable) {
+ pendingRunTimeoutJob?.cancel()
+ pendingRunTimeoutJob = null
+ _isSending.value = false
+ pendingRunId = null
+ pendingAssistantEntryId = null
+ _statusText.value =
+ if (!gatewayConnected) {
+ queuedWaitingStatus()
+ } else {
+ "Send failed: ${err.message ?: err::class.simpleName}"
+ }
+ }
+ }
+ }
+
+ private fun armPendingRunTimeout(runId: String) {
+ pendingRunTimeoutJob?.cancel()
+ pendingRunTimeoutJob =
+ scope.launch {
+ delay(pendingRunTimeoutMs)
+ if (pendingRunId != runId) return@launch
+ pendingRunId = null
+ pendingAssistantEntryId = null
+ _isSending.value = false
+ _statusText.value =
+ if (gatewayConnected) {
+ "Voice reply timed out; retrying queued turn"
+ } else {
+ queuedWaitingStatus()
+ }
+ sendQueuedIfIdle()
+ }
+ }
+
+ private fun completePendingTurn() {
+ pendingRunTimeoutJob?.cancel()
+ pendingRunTimeoutJob = null
+ if (messageQueue.isNotEmpty()) {
+ messageQueue.removeFirst()
+ publishQueue()
+ }
+ pendingRunId = null
+ pendingAssistantEntryId = null
+ _isSending.value = false
+ sendQueuedIfIdle()
+ }
+
+ private fun queuedWaitingStatus(): String {
+ return "${messageQueue.size} queued · waiting for gateway"
+ }
+
+ private fun appendConversation(
+ role: VoiceConversationRole,
+ text: String,
+ isStreaming: Boolean = false,
+ ): String {
+ val id = UUID.randomUUID().toString()
+ _conversation.value =
+ (_conversation.value + VoiceConversationEntry(id = id, role = role, text = text, isStreaming = isStreaming))
+ .takeLast(maxConversationEntries)
+ return id
+ }
+
+ private fun updateConversationEntry(id: String, text: String?, isStreaming: Boolean) {
+ val current = _conversation.value
+ _conversation.value =
+ current.map { entry ->
+ if (entry.id == id) {
+ val updatedText = text ?: entry.text
+ entry.copy(text = updatedText, isStreaming = isStreaming)
+ } else {
+ entry
+ }
+ }
+ }
+
+ private fun upsertPendingAssistant(text: String, isStreaming: Boolean) {
+ val currentId = pendingAssistantEntryId
+ if (currentId == null) {
+ pendingAssistantEntryId =
+ appendConversation(
+ role = VoiceConversationRole.Assistant,
+ text = text,
+ isStreaming = isStreaming,
+ )
+ return
+ }
+ updateConversationEntry(id = currentId, text = text, isStreaming = isStreaming)
+ }
+
+ private fun onFinalTranscript(text: String) {
+ val trimmed = text.trim()
+ if (trimmed.isEmpty()) return
+ _liveTranscript.value = trimmed
+ if (lastFinalSegment == trimmed) return
+ lastFinalSegment = trimmed
+ sessionSegments.add(trimmed)
+ }
+
+ private fun disableMic(status: String) {
+ stopRequested = true
+ restartJob?.cancel()
+ restartJob = null
+ _micEnabled.value = false
+ _isListening.value = false
+ _inputLevel.value = 0f
+ _statusText.value = status
+ mainHandler.post {
+ recognizer?.cancel()
+ recognizer?.destroy()
+ recognizer = null
+ }
+ }
+
+ private fun hasMicPermission(): Boolean {
+ return (
+ ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
+ PackageManager.PERMISSION_GRANTED
+ )
+ }
+
+ private fun parseAssistantText(payload: JsonObject): String? {
+ val message = payload["message"].asObjectOrNull() ?: return null
+ if (message["role"].asStringOrNull() != "assistant") return null
+ val content = message["content"] as? JsonArray ?: return null
+
+ val parts =
+ content.mapNotNull { item ->
+ val obj = item.asObjectOrNull() ?: return@mapNotNull null
+ if (obj["type"].asStringOrNull() != "text") return@mapNotNull null
+ obj["text"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
+ }
+ if (parts.isEmpty()) return null
+ return parts.joinToString("\n")
+ }
+
+ private val listener =
+ object : RecognitionListener {
+ override fun onReadyForSpeech(params: Bundle?) {
+ _isListening.value = true
+ }
+
+ override fun onBeginningOfSpeech() {}
+
+ override fun onRmsChanged(rmsdB: Float) {
+ val level = ((rmsdB + 2f) / 12f).coerceIn(0f, 1f)
+ _inputLevel.value = level
+ }
+
+ override fun onBufferReceived(buffer: ByteArray?) {}
+
+ override fun onEndOfSpeech() {
+ _inputLevel.value = 0f
+ scheduleRestart()
+ }
+
+ override fun onError(error: Int) {
+ if (stopRequested) return
+ _isListening.value = false
+ _inputLevel.value = 0f
+ val status =
+ when (error) {
+ SpeechRecognizer.ERROR_AUDIO -> "Audio error"
+ SpeechRecognizer.ERROR_CLIENT -> "Client error"
+ SpeechRecognizer.ERROR_NETWORK -> "Network error"
+ SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout"
+ SpeechRecognizer.ERROR_NO_MATCH -> "Listening"
+ SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy"
+ SpeechRecognizer.ERROR_SERVER -> "Server error"
+ SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Listening"
+ SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Microphone permission required"
+ SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED -> "Language not supported on this device"
+ SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE -> "Language unavailable on this device"
+ SpeechRecognizer.ERROR_SERVER_DISCONNECTED -> "Speech service disconnected"
+ SpeechRecognizer.ERROR_TOO_MANY_REQUESTS -> "Speech requests limited; retrying"
+ else -> "Speech error ($error)"
+ }
+ _statusText.value = status
+
+ if (
+ error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS ||
+ error == SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED ||
+ error == SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE
+ ) {
+ disableMic(status)
+ return
+ }
+
+ val restartDelayMs =
+ when (error) {
+ SpeechRecognizer.ERROR_NO_MATCH,
+ SpeechRecognizer.ERROR_SPEECH_TIMEOUT,
+ -> 1_200L
+ SpeechRecognizer.ERROR_TOO_MANY_REQUESTS -> 2_500L
+ else -> 600L
+ }
+ scheduleRestart(delayMs = restartDelayMs)
+ }
+
+ override fun onResults(results: Bundle?) {
+ val text = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty().firstOrNull()
+ if (!text.isNullOrBlank()) {
+ onFinalTranscript(text)
+ flushSessionToQueue()
+ sendQueuedIfIdle()
+ }
+ scheduleRestart()
+ }
+
+ override fun onPartialResults(partialResults: Bundle?) {
+ val text = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty().firstOrNull()
+ if (!text.isNullOrBlank()) {
+ _liveTranscript.value = text.trim()
+ }
+ }
+
+ override fun onEvent(eventType: Int, params: Bundle?) {}
+ }
+}
+
+private fun kotlinx.serialization.json.JsonElement?.asObjectOrNull(): JsonObject? =
+ this as? JsonObject
+
+private fun kotlinx.serialization.json.JsonElement?.asStringOrNull(): String? =
+ (this as? JsonPrimitive)?.takeIf { it.isString }?.content
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 000000000000..e8a37aef21b5
--- /dev/null
+++ b/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt
@@ -0,0 +1,442 @@
+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.Assert.assertNull
+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
+
+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 {
+ @Test
+ fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking {
+ val json = Json { ignoreUnknownKeys = true }
+ 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) {
+ 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 deviceAuthStore = InMemoryDeviceAuthStore()
+ val session =
+ GatewaySession(
+ scope = CoroutineScope(sessionJob + Dispatchers.Default),
+ identityStore = DeviceIdentityStore(app),
+ deviceAuthStore = deviceAuthStore,
+ 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)
+ assertNull(handshakeOrigin.get())
+ 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()
+ 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 deviceAuthStore = InMemoryDeviceAuthStore()
+ val session =
+ GatewaySession(
+ scope = CoroutineScope(sessionJob + Dispatchers.Default),
+ identityStore = DeviceIdentityStore(app),
+ deviceAuthStore = deviceAuthStore,
+ 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 deviceAuthStore = InMemoryDeviceAuthStore()
+ val session =
+ GatewaySession(
+ scope = CoroutineScope(sessionJob + Dispatchers.Default),
+ identityStore = DeviceIdentityStore(app),
+ deviceAuthStore = deviceAuthStore,
+ 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()
+ }
+ }
+}
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 000000000000..ca8e8f21424a
--- /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)
+ }
+}
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 000000000000..88795b0d9cec
--- /dev/null
+++ b/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt
@@ -0,0 +1,51 @@
+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
+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))
+ assertTrue(commands.contains(OpenClawNotificationsCommand.List.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(OpenClawNotificationsCommand.List.rawValue))
+ assertTrue(commands.contains(OpenClawSmsCommand.Send.rawValue))
+ assertTrue(commands.contains("debug.logs"))
+ assertTrue(commands.contains("debug.ed25519"))
+ assertTrue(commands.contains("app.update"))
+ }
+}
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 000000000000..7768e6e25dab
--- /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
+ }
+}
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 10ab733ae53f..71eec189509e 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)
+ }
}
diff --git a/apps/android/app/src/test/java/ai/openclaw/android/ui/GatewayConfigResolverTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/ui/GatewayConfigResolverTest.kt
new file mode 100644
index 000000000000..7dc2dd1a239b
--- /dev/null
+++ b/apps/android/app/src/test/java/ai/openclaw/android/ui/GatewayConfigResolverTest.kt
@@ -0,0 +1,59 @@
+package ai.openclaw.android.ui
+
+import java.util.Base64
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class GatewayConfigResolverTest {
+ @Test
+ fun resolveScannedSetupCodeAcceptsRawSetupCode() {
+ val setupCode = encodeSetupCode("""{"url":"wss://gateway.example:18789","token":"token-1"}""")
+
+ val resolved = resolveScannedSetupCode(setupCode)
+
+ assertEquals(setupCode, resolved)
+ }
+
+ @Test
+ fun resolveScannedSetupCodeAcceptsQrJsonPayload() {
+ val setupCode = encodeSetupCode("""{"url":"wss://gateway.example:18789","password":"pw-1"}""")
+ val qrJson =
+ """
+ {
+ "setupCode": "$setupCode",
+ "gatewayUrl": "wss://gateway.example:18789",
+ "auth": "password",
+ "urlSource": "gateway.remote.url"
+ }
+ """.trimIndent()
+
+ val resolved = resolveScannedSetupCode(qrJson)
+
+ assertEquals(setupCode, resolved)
+ }
+
+ @Test
+ fun resolveScannedSetupCodeRejectsInvalidInput() {
+ val resolved = resolveScannedSetupCode("not-a-valid-setup-code")
+ assertNull(resolved)
+ }
+
+ @Test
+ fun resolveScannedSetupCodeRejectsJsonWithInvalidSetupCode() {
+ val qrJson = """{"setupCode":"invalid"}"""
+ val resolved = resolveScannedSetupCode(qrJson)
+ assertNull(resolved)
+ }
+
+ @Test
+ fun resolveScannedSetupCodeRejectsJsonWithNonStringSetupCode() {
+ val qrJson = """{"setupCode":{"nested":"value"}}"""
+ val resolved = resolveScannedSetupCode(qrJson)
+ assertNull(resolved)
+ }
+
+ private fun encodeSetupCode(payloadJson: String): String {
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.toByteArray(Charsets.UTF_8))
+ }
+}
diff --git a/apps/android/benchmark/build.gradle.kts b/apps/android/benchmark/build.gradle.kts
new file mode 100644
index 000000000000..99d1d8e4c60c
--- /dev/null
+++ b/apps/android/benchmark/build.gradle.kts
@@ -0,0 +1,36 @@
+plugins {
+ id("com.android.test")
+}
+
+android {
+ namespace = "ai.openclaw.android.benchmark"
+ compileSdk = 36
+
+ defaultConfig {
+ minSdk = 31
+ targetSdk = 36
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "DEBUGGABLE,EMULATOR"
+ }
+
+ targetProjectPath = ":app"
+ experimentalProperties["android.experimental.self-instrumenting"] = true
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
+ allWarningsAsErrors.set(true)
+ }
+}
+
+dependencies {
+ implementation("androidx.benchmark:benchmark-macro-junit4:1.4.1")
+ implementation("androidx.test.ext:junit:1.2.1")
+ implementation("androidx.test.uiautomator:uiautomator:2.4.0-alpha06")
+}
diff --git a/apps/android/benchmark/src/main/java/ai/openclaw/android/benchmark/StartupMacrobenchmark.kt b/apps/android/benchmark/src/main/java/ai/openclaw/android/benchmark/StartupMacrobenchmark.kt
new file mode 100644
index 000000000000..46181f6a9a18
--- /dev/null
+++ b/apps/android/benchmark/src/main/java/ai/openclaw/android/benchmark/StartupMacrobenchmark.kt
@@ -0,0 +1,76 @@
+package ai.openclaw.android.benchmark
+
+import androidx.benchmark.macro.CompilationMode
+import androidx.benchmark.macro.FrameTimingMetric
+import androidx.benchmark.macro.StartupMode
+import androidx.benchmark.macro.StartupTimingMetric
+import androidx.benchmark.macro.junit4.MacrobenchmarkRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.uiautomator.UiDevice
+import org.junit.Assume.assumeTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class StartupMacrobenchmark {
+ @get:Rule
+ val benchmarkRule = MacrobenchmarkRule()
+
+ private val packageName = "ai.openclaw.android"
+
+ @Test
+ fun coldStartup() {
+ runBenchmarkOrSkip {
+ benchmarkRule.measureRepeated(
+ packageName = packageName,
+ metrics = listOf(StartupTimingMetric()),
+ startupMode = StartupMode.COLD,
+ compilationMode = CompilationMode.None(),
+ iterations = 10,
+ ) {
+ pressHome()
+ startActivityAndWait()
+ }
+ }
+ }
+
+ @Test
+ fun startupAndScrollFrameTiming() {
+ runBenchmarkOrSkip {
+ benchmarkRule.measureRepeated(
+ packageName = packageName,
+ metrics = listOf(FrameTimingMetric()),
+ startupMode = StartupMode.WARM,
+ compilationMode = CompilationMode.None(),
+ iterations = 10,
+ ) {
+ startActivityAndWait()
+ val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+ val x = device.displayWidth / 2
+ val yStart = (device.displayHeight * 0.8f).toInt()
+ val yEnd = (device.displayHeight * 0.25f).toInt()
+ repeat(4) {
+ device.swipe(x, yStart, x, yEnd, 24)
+ device.waitForIdle()
+ }
+ }
+ }
+ }
+
+ private fun runBenchmarkOrSkip(run: () -> Unit) {
+ try {
+ run()
+ } catch (err: IllegalStateException) {
+ val message = err.message.orEmpty()
+ val knownDeviceIssue =
+ message.contains("Unable to confirm activity launch completion") ||
+ message.contains("no renderthread slices", ignoreCase = true)
+ if (knownDeviceIssue) {
+ assumeTrue("Skipping benchmark on this device: $message", false)
+ }
+ throw err
+ }
+ }
+}
diff --git a/apps/android/build.gradle.kts b/apps/android/build.gradle.kts
index bea7b46b2c21..1d191c9e375c 100644
--- a/apps/android/build.gradle.kts
+++ b/apps/android/build.gradle.kts
@@ -1,5 +1,6 @@
plugins {
id("com.android.application") version "9.0.1" apply false
+ id("com.android.test") version "9.0.1" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" apply false
}
diff --git a/apps/android/scripts/perf-startup-benchmark.sh b/apps/android/scripts/perf-startup-benchmark.sh
new file mode 100755
index 000000000000..70342d3cba48
--- /dev/null
+++ b/apps/android/scripts/perf-startup-benchmark.sh
@@ -0,0 +1,124 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
+RESULTS_DIR="$ANDROID_DIR/benchmark/results"
+CLASS_FILTER="ai.openclaw.android.benchmark.StartupMacrobenchmark#coldStartup"
+BASELINE_JSON=""
+
+usage() {
+ cat <<'EOF'
+Usage:
+ ./scripts/perf-startup-benchmark.sh [--baseline ]
+
+Runs cold-start macrobenchmark only, then prints a compact summary.
+Also saves a timestamped snapshot JSON under benchmark/results/.
+If --baseline is omitted, compares against latest previous snapshot when available.
+EOF
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --baseline)
+ BASELINE_JSON="${2:-}"
+ shift 2
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown arg: $1" >&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+done
+
+if ! command -v jq >/dev/null 2>&1; then
+ echo "jq required but missing." >&2
+ exit 1
+fi
+
+if ! command -v adb >/dev/null 2>&1; then
+ echo "adb required but missing." >&2
+ exit 1
+fi
+
+device_count="$(adb devices | awk 'NR>1 && $2=="device" {c+=1} END {print c+0}')"
+if [[ "$device_count" -lt 1 ]]; then
+ echo "No connected Android device (adb state=device)." >&2
+ exit 1
+fi
+
+mkdir -p "$RESULTS_DIR"
+
+run_log="$(mktemp -t openclaw-android-bench.XXXXXX.log)"
+trap 'rm -f "$run_log"' EXIT
+
+cd "$ANDROID_DIR"
+
+./gradlew :benchmark:connectedDebugAndroidTest \
+ -Pandroid.testInstrumentationRunnerArguments.class="$CLASS_FILTER" \
+ --console=plain \
+ >"$run_log" 2>&1
+
+latest_json="$(
+ find "$ANDROID_DIR/benchmark/build/outputs/connected_android_test_additional_output/debug/connected" \
+ -name '*benchmarkData.json' -type f \
+ | while IFS= read -r file; do
+ printf '%s\t%s\n' "$(stat -f '%m' "$file")" "$file"
+ done \
+ | sort -nr \
+ | head -n1 \
+ | cut -f2-
+)"
+
+if [[ -z "$latest_json" || ! -f "$latest_json" ]]; then
+ echo "benchmarkData.json not found after run." >&2
+ tail -n 120 "$run_log" >&2
+ exit 1
+fi
+
+timestamp="$(date +%Y%m%d-%H%M%S)"
+snapshot_json="$RESULTS_DIR/startup-$timestamp.json"
+cp "$latest_json" "$snapshot_json"
+
+median_ms="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.median' "$snapshot_json")"
+min_ms="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.minimum' "$snapshot_json")"
+max_ms="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.maximum' "$snapshot_json")"
+cov="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.coefficientOfVariation' "$snapshot_json")"
+device="$(jq -r '.context.build.model' "$snapshot_json")"
+sdk="$(jq -r '.context.build.version.sdk' "$snapshot_json")"
+runs_count="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.runs | length' "$snapshot_json")"
+
+printf 'startup.cold.median_ms=%.3f min_ms=%.3f max_ms=%.3f cov=%.4f runs=%s device=%s sdk=%s\n' \
+ "$median_ms" "$min_ms" "$max_ms" "$cov" "$runs_count" "$device" "$sdk"
+echo "snapshot_json=$snapshot_json"
+
+if [[ -z "$BASELINE_JSON" ]]; then
+ BASELINE_JSON="$(
+ find "$RESULTS_DIR" -name 'startup-*.json' -type f \
+ | while IFS= read -r file; do
+ if [[ "$file" == "$snapshot_json" ]]; then
+ continue
+ fi
+ printf '%s\t%s\n' "$(stat -f '%m' "$file")" "$file"
+ done \
+ | sort -nr \
+ | head -n1 \
+ | cut -f2-
+ )"
+fi
+
+if [[ -n "$BASELINE_JSON" ]]; then
+ if [[ ! -f "$BASELINE_JSON" ]]; then
+ echo "Baseline file missing: $BASELINE_JSON" >&2
+ exit 1
+ fi
+ base_median="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.median' "$BASELINE_JSON")"
+ delta_ms="$(awk -v a="$median_ms" -v b="$base_median" 'BEGIN { printf "%.3f", (a-b) }')"
+ delta_pct="$(awk -v a="$median_ms" -v b="$base_median" 'BEGIN { if (b==0) { print "nan" } else { printf "%.2f", ((a-b)/b)*100 } }')"
+ echo "baseline_median_ms=$base_median delta_ms=$delta_ms delta_pct=$delta_pct%"
+fi
diff --git a/apps/android/scripts/perf-startup-hotspots.sh b/apps/android/scripts/perf-startup-hotspots.sh
new file mode 100755
index 000000000000..787d5fac3005
--- /dev/null
+++ b/apps/android/scripts/perf-startup-hotspots.sh
@@ -0,0 +1,154 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
+
+PACKAGE="ai.openclaw.android"
+ACTIVITY=".MainActivity"
+DURATION_SECONDS="10"
+OUTPUT_PERF_DATA=""
+
+usage() {
+ cat <<'EOF'
+Usage:
+ ./scripts/perf-startup-hotspots.sh [--package ] [--activity ] [--duration ] [--out ]
+
+Captures startup CPU profile via simpleperf (app_profiler.py), then prints concise hotspot summaries.
+Default package/activity target OpenClaw Android startup.
+EOF
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --package)
+ PACKAGE="${2:-}"
+ shift 2
+ ;;
+ --activity)
+ ACTIVITY="${2:-}"
+ shift 2
+ ;;
+ --duration)
+ DURATION_SECONDS="${2:-}"
+ shift 2
+ ;;
+ --out)
+ OUTPUT_PERF_DATA="${2:-}"
+ shift 2
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown arg: $1" >&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+done
+
+if ! command -v uv >/dev/null 2>&1; then
+ echo "uv required but missing." >&2
+ exit 1
+fi
+
+if ! command -v adb >/dev/null 2>&1; then
+ echo "adb required but missing." >&2
+ exit 1
+fi
+
+if [[ -z "$OUTPUT_PERF_DATA" ]]; then
+ OUTPUT_PERF_DATA="/tmp/openclaw-startup-$(date +%Y%m%d-%H%M%S).perf.data"
+fi
+
+device_count="$(adb devices | awk 'NR>1 && $2=="device" {c+=1} END {print c+0}')"
+if [[ "$device_count" -lt 1 ]]; then
+ echo "No connected Android device (adb state=device)." >&2
+ exit 1
+fi
+
+simpleperf_dir=""
+if [[ -n "${ANDROID_NDK_HOME:-}" && -f "${ANDROID_NDK_HOME}/simpleperf/app_profiler.py" ]]; then
+ simpleperf_dir="${ANDROID_NDK_HOME}/simpleperf"
+elif [[ -n "${ANDROID_NDK_ROOT:-}" && -f "${ANDROID_NDK_ROOT}/simpleperf/app_profiler.py" ]]; then
+ simpleperf_dir="${ANDROID_NDK_ROOT}/simpleperf"
+else
+ latest_simpleperf="$(ls -d "${HOME}/Library/Android/sdk/ndk/"*/simpleperf 2>/dev/null | sort -V | tail -n1 || true)"
+ if [[ -n "$latest_simpleperf" && -f "$latest_simpleperf/app_profiler.py" ]]; then
+ simpleperf_dir="$latest_simpleperf"
+ fi
+fi
+
+if [[ -z "$simpleperf_dir" ]]; then
+ echo "simpleperf not found. Set ANDROID_NDK_HOME or install NDK under ~/Library/Android/sdk/ndk/." >&2
+ exit 1
+fi
+
+app_profiler="$simpleperf_dir/app_profiler.py"
+report_py="$simpleperf_dir/report.py"
+ndk_path="$(cd -- "$simpleperf_dir/.." && pwd)"
+
+tmp_dir="$(mktemp -d -t openclaw-android-hotspots.XXXXXX)"
+trap 'rm -rf "$tmp_dir"' EXIT
+
+capture_log="$tmp_dir/capture.log"
+dso_csv="$tmp_dir/dso.csv"
+symbols_csv="$tmp_dir/symbols.csv"
+children_txt="$tmp_dir/children.txt"
+
+cd "$ANDROID_DIR"
+./gradlew :app:installDebug --console=plain >"$tmp_dir/install.log" 2>&1
+
+if ! uv run --no-project python3 "$app_profiler" \
+ -p "$PACKAGE" \
+ -a "$ACTIVITY" \
+ -o "$OUTPUT_PERF_DATA" \
+ --ndk_path "$ndk_path" \
+ -r "-e task-clock:u -f 1000 -g --duration $DURATION_SECONDS" \
+ >"$capture_log" 2>&1; then
+ echo "simpleperf capture failed. tail(capture_log):" >&2
+ tail -n 120 "$capture_log" >&2
+ exit 1
+fi
+
+uv run --no-project python3 "$report_py" \
+ -i "$OUTPUT_PERF_DATA" \
+ --sort dso \
+ --csv \
+ --csv-separator "|" \
+ --include-process-name "$PACKAGE" \
+ >"$dso_csv" 2>"$tmp_dir/report-dso.err"
+
+uv run --no-project python3 "$report_py" \
+ -i "$OUTPUT_PERF_DATA" \
+ --sort dso,symbol \
+ --csv \
+ --csv-separator "|" \
+ --include-process-name "$PACKAGE" \
+ >"$symbols_csv" 2>"$tmp_dir/report-symbols.err"
+
+uv run --no-project python3 "$report_py" \
+ -i "$OUTPUT_PERF_DATA" \
+ --children \
+ --sort dso,symbol \
+ -n \
+ --percent-limit 0.2 \
+ --include-process-name "$PACKAGE" \
+ >"$children_txt" 2>"$tmp_dir/report-children.err"
+
+clean_csv() {
+ awk 'BEGIN{print_on=0} /^Overhead\|/{print_on=1} print_on==1{print}' "$1"
+}
+
+echo "perf_data=$OUTPUT_PERF_DATA"
+echo
+echo "top_dso_self:"
+clean_csv "$dso_csv" | tail -n +2 | awk -F'|' 'NR<=10 {printf " %s %s\n", $1, $2}'
+echo
+echo "top_symbols_self:"
+clean_csv "$symbols_csv" | tail -n +2 | awk -F'|' 'NR<=20 {printf " %s %s :: %s\n", $1, $2, $3}'
+echo
+echo "app_path_clues_children:"
+rg 'androidx\.compose|MainActivity|NodeRuntime|NodeForegroundService|SecurePrefs|WebView|libwebviewchromium' "$children_txt" | awk 'NR<=20 {print}' || true
diff --git a/apps/android/settings.gradle.kts b/apps/android/settings.gradle.kts
index b3b43a445501..25e5d09bbe1d 100644
--- a/apps/android/settings.gradle.kts
+++ b/apps/android/settings.gradle.kts
@@ -16,3 +16,4 @@ dependencyResolutionManagement {
rootProject.name = "OpenClawNodeAndroid"
include(":app")
+include(":benchmark")
diff --git a/apps/macos/Sources/OpenClaw/AnthropicAuthControls.swift b/apps/macos/Sources/OpenClaw/AnthropicAuthControls.swift
deleted file mode 100644
index 06f107d6c6e3..000000000000
--- a/apps/macos/Sources/OpenClaw/AnthropicAuthControls.swift
+++ /dev/null
@@ -1,234 +0,0 @@
-import AppKit
-import Combine
-import SwiftUI
-
-@MainActor
-struct AnthropicAuthControls: View {
- let connectionMode: AppState.ConnectionMode
-
- @State private var oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus = OpenClawOAuthStore.anthropicOAuthStatus()
- @State private var pkce: AnthropicOAuth.PKCE?
- @State private var code: String = ""
- @State private var busy = false
- @State private var statusText: String?
- @State private var autoDetectClipboard = true
- @State private var autoConnectClipboard = true
- @State private var lastPasteboardChangeCount = NSPasteboard.general.changeCount
-
- private static let clipboardPoll: AnyPublisher = {
- if ProcessInfo.processInfo.isRunningTests {
- return Empty(completeImmediately: false).eraseToAnyPublisher()
- }
- return Timer.publish(every: 0.4, on: .main, in: .common)
- .autoconnect()
- .eraseToAnyPublisher()
- }()
-
- var body: some View {
- VStack(alignment: .leading, spacing: 10) {
- if self.connectionMode != .local {
- Text("Gateway isn’t running locally; OAuth must be created on the gateway host.")
- .font(.footnote)
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- }
-
- HStack(spacing: 10) {
- Circle()
- .fill(self.oauthStatus.isConnected ? Color.green : Color.orange)
- .frame(width: 8, height: 8)
- Text(self.oauthStatus.shortDescription)
- .font(.footnote.weight(.semibold))
- .foregroundStyle(.secondary)
- Spacer()
- Button("Reveal") {
- NSWorkspace.shared.activateFileViewerSelecting([OpenClawOAuthStore.oauthURL()])
- }
- .buttonStyle(.bordered)
- .disabled(!FileManager().fileExists(atPath: OpenClawOAuthStore.oauthURL().path))
-
- Button("Refresh") {
- self.refresh()
- }
- .buttonStyle(.bordered)
- }
-
- Text(OpenClawOAuthStore.oauthURL().path)
- .font(.caption.monospaced())
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .truncationMode(.middle)
- .textSelection(.enabled)
-
- HStack(spacing: 12) {
- Button {
- self.startOAuth()
- } label: {
- if self.busy {
- ProgressView().controlSize(.small)
- } else {
- Text(self.oauthStatus.isConnected ? "Re-auth (OAuth)" : "Open sign-in (OAuth)")
- }
- }
- .buttonStyle(.borderedProminent)
- .disabled(self.connectionMode != .local || self.busy)
-
- if self.pkce != nil {
- Button("Cancel") {
- self.pkce = nil
- self.code = ""
- self.statusText = nil
- }
- .buttonStyle(.bordered)
- .disabled(self.busy)
- }
- }
-
- if self.pkce != nil {
- VStack(alignment: .leading, spacing: 8) {
- Text("Paste `code#state`")
- .font(.footnote.weight(.semibold))
- .foregroundStyle(.secondary)
-
- TextField("code#state", text: self.$code)
- .textFieldStyle(.roundedBorder)
- .disabled(self.busy)
-
- Toggle("Auto-detect from clipboard", isOn: self.$autoDetectClipboard)
- .font(.footnote)
- .foregroundStyle(.secondary)
- .disabled(self.busy)
-
- Toggle("Auto-connect when detected", isOn: self.$autoConnectClipboard)
- .font(.footnote)
- .foregroundStyle(.secondary)
- .disabled(self.busy)
-
- Button("Connect") {
- Task { await self.finishOAuth() }
- }
- .buttonStyle(.bordered)
- .disabled(self.busy || self.connectionMode != .local || self.code
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .isEmpty)
- }
- }
-
- if let statusText, !statusText.isEmpty {
- Text(statusText)
- .font(.footnote)
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- }
- }
- .onAppear {
- self.refresh()
- }
- .onReceive(Self.clipboardPoll) { _ in
- self.pollClipboardIfNeeded()
- }
- }
-
- private func refresh() {
- let imported = OpenClawOAuthStore.importLegacyAnthropicOAuthIfNeeded()
- self.oauthStatus = OpenClawOAuthStore.anthropicOAuthStatus()
- if imported != nil {
- self.statusText = "Imported existing OAuth credentials."
- }
- }
-
- private func startOAuth() {
- guard self.connectionMode == .local else { return }
- guard !self.busy else { return }
- self.busy = true
- defer { self.busy = false }
-
- do {
- let pkce = try AnthropicOAuth.generatePKCE()
- self.pkce = pkce
- let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce)
- NSWorkspace.shared.open(url)
- self.statusText = "Browser opened. After approving, paste the `code#state` value here."
- } catch {
- self.statusText = "Failed to start OAuth: \(error.localizedDescription)"
- }
- }
-
- @MainActor
- private func finishOAuth() async {
- guard self.connectionMode == .local else { return }
- guard !self.busy else { return }
- guard let pkce = self.pkce else { return }
- self.busy = true
- defer { self.busy = false }
-
- guard let parsed = AnthropicOAuthCodeState.parse(from: self.code) else {
- self.statusText = "OAuth failed: missing or invalid code/state."
- return
- }
-
- do {
- let creds = try await AnthropicOAuth.exchangeCode(
- code: parsed.code,
- state: parsed.state,
- verifier: pkce.verifier)
- try OpenClawOAuthStore.saveAnthropicOAuth(creds)
- self.refresh()
- self.pkce = nil
- self.code = ""
- self.statusText = "Connected. OpenClaw can now use Claude via OAuth."
- } catch {
- self.statusText = "OAuth failed: \(error.localizedDescription)"
- }
- }
-
- private func pollClipboardIfNeeded() {
- guard self.connectionMode == .local else { return }
- guard self.pkce != nil else { return }
- guard !self.busy else { return }
- guard self.autoDetectClipboard else { return }
-
- let pb = NSPasteboard.general
- let changeCount = pb.changeCount
- guard changeCount != self.lastPasteboardChangeCount else { return }
- self.lastPasteboardChangeCount = changeCount
-
- guard let raw = pb.string(forType: .string), !raw.isEmpty else { return }
- guard let parsed = AnthropicOAuthCodeState.parse(from: raw) else { return }
- guard let pkce = self.pkce, parsed.state == pkce.verifier else { return }
-
- let next = "\(parsed.code)#\(parsed.state)"
- if self.code != next {
- self.code = next
- self.statusText = "Detected `code#state` from clipboard."
- }
-
- guard self.autoConnectClipboard else { return }
- Task { await self.finishOAuth() }
- }
-}
-
-#if DEBUG
-extension AnthropicAuthControls {
- init(
- connectionMode: AppState.ConnectionMode,
- oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus,
- pkce: AnthropicOAuth.PKCE? = nil,
- code: String = "",
- busy: Bool = false,
- statusText: String? = nil,
- autoDetectClipboard: Bool = true,
- autoConnectClipboard: Bool = true)
- {
- self.connectionMode = connectionMode
- self._oauthStatus = State(initialValue: oauthStatus)
- self._pkce = State(initialValue: pkce)
- self._code = State(initialValue: code)
- self._busy = State(initialValue: busy)
- self._statusText = State(initialValue: statusText)
- self._autoDetectClipboard = State(initialValue: autoDetectClipboard)
- self._autoConnectClipboard = State(initialValue: autoConnectClipboard)
- self._lastPasteboardChangeCount = State(initialValue: NSPasteboard.general.changeCount)
- }
-}
-#endif
diff --git a/apps/macos/Sources/OpenClaw/AnthropicOAuth.swift b/apps/macos/Sources/OpenClaw/AnthropicOAuth.swift
deleted file mode 100644
index f594cc04c311..000000000000
--- a/apps/macos/Sources/OpenClaw/AnthropicOAuth.swift
+++ /dev/null
@@ -1,383 +0,0 @@
-import CryptoKit
-import Foundation
-import OSLog
-import Security
-
-struct AnthropicOAuthCredentials: Codable {
- let type: String
- let refresh: String
- let access: String
- let expires: Int64
-}
-
-enum AnthropicAuthMode: Equatable {
- case oauthFile
- case oauthEnv
- case apiKeyEnv
- case missing
-
- var shortLabel: String {
- switch self {
- case .oauthFile: "OAuth (OpenClaw token file)"
- case .oauthEnv: "OAuth (env var)"
- case .apiKeyEnv: "API key (env var)"
- case .missing: "Missing credentials"
- }
- }
-
- var isConfigured: Bool {
- switch self {
- case .missing: false
- case .oauthFile, .oauthEnv, .apiKeyEnv: true
- }
- }
-}
-
-enum AnthropicAuthResolver {
- static func resolve(
- environment: [String: String] = ProcessInfo.processInfo.environment,
- oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus = OpenClawOAuthStore
- .anthropicOAuthStatus()) -> AnthropicAuthMode
- {
- if oauthStatus.isConnected { return .oauthFile }
-
- if let token = environment["ANTHROPIC_OAUTH_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines),
- !token.isEmpty
- {
- return .oauthEnv
- }
-
- if let key = environment["ANTHROPIC_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines),
- !key.isEmpty
- {
- return .apiKeyEnv
- }
-
- return .missing
- }
-}
-
-enum AnthropicOAuth {
- private static let logger = Logger(subsystem: "ai.openclaw", category: "anthropic-oauth")
-
- private static let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
- private static let authorizeURL = URL(string: "https://claude.ai/oauth/authorize")!
- private static let tokenURL = URL(string: "https://console.anthropic.com/v1/oauth/token")!
- private static let redirectURI = "https://console.anthropic.com/oauth/code/callback"
- private static let scopes = "org:create_api_key user:profile user:inference"
-
- struct PKCE {
- let verifier: String
- let challenge: String
- }
-
- static func generatePKCE() throws -> PKCE {
- var bytes = [UInt8](repeating: 0, count: 32)
- let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
- guard status == errSecSuccess else {
- throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
- }
- let verifier = Data(bytes).base64URLEncodedString()
- let hash = SHA256.hash(data: Data(verifier.utf8))
- let challenge = Data(hash).base64URLEncodedString()
- return PKCE(verifier: verifier, challenge: challenge)
- }
-
- static func buildAuthorizeURL(pkce: PKCE) -> URL {
- var components = URLComponents(url: self.authorizeURL, resolvingAgainstBaseURL: false)!
- components.queryItems = [
- URLQueryItem(name: "code", value: "true"),
- URLQueryItem(name: "client_id", value: self.clientId),
- URLQueryItem(name: "response_type", value: "code"),
- URLQueryItem(name: "redirect_uri", value: self.redirectURI),
- URLQueryItem(name: "scope", value: self.scopes),
- URLQueryItem(name: "code_challenge", value: pkce.challenge),
- URLQueryItem(name: "code_challenge_method", value: "S256"),
- // Match legacy flow: state is the verifier.
- URLQueryItem(name: "state", value: pkce.verifier),
- ]
- return components.url!
- }
-
- static func exchangeCode(
- code: String,
- state: String,
- verifier: String) async throws -> AnthropicOAuthCredentials
- {
- let payload: [String: Any] = [
- "grant_type": "authorization_code",
- "client_id": self.clientId,
- "code": code,
- "state": state,
- "redirect_uri": self.redirectURI,
- "code_verifier": verifier,
- ]
- let body = try JSONSerialization.data(withJSONObject: payload, options: [])
-
- var request = URLRequest(url: self.tokenURL)
- request.httpMethod = "POST"
- request.httpBody = body
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
-
- let (data, response) = try await URLSession.shared.data(for: request)
- guard let http = response as? HTTPURLResponse else {
- throw URLError(.badServerResponse)
- }
- guard (200..<300).contains(http.statusCode) else {
- let text = String(data: data, encoding: .utf8) ?? ""
- throw NSError(
- domain: "AnthropicOAuth",
- code: http.statusCode,
- userInfo: [NSLocalizedDescriptionKey: "Token exchange failed: \(text)"])
- }
-
- let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
- let access = decoded?["access_token"] as? String
- let refresh = decoded?["refresh_token"] as? String
- let expiresIn = decoded?["expires_in"] as? Double
- guard let access, let refresh, let expiresIn else {
- throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [
- NSLocalizedDescriptionKey: "Unexpected token response.",
- ])
- }
-
- // Match legacy flow: expiresAt = now + expires_in - 5 minutes.
- let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
- + Int64(expiresIn * 1000)
- - Int64(5 * 60 * 1000)
-
- self.logger.info("Anthropic OAuth exchange ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
- return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs)
- }
-
- static func refresh(refreshToken: String) async throws -> AnthropicOAuthCredentials {
- let payload: [String: Any] = [
- "grant_type": "refresh_token",
- "client_id": self.clientId,
- "refresh_token": refreshToken,
- ]
- let body = try JSONSerialization.data(withJSONObject: payload, options: [])
-
- var request = URLRequest(url: self.tokenURL)
- request.httpMethod = "POST"
- request.httpBody = body
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
-
- let (data, response) = try await URLSession.shared.data(for: request)
- guard let http = response as? HTTPURLResponse else {
- throw URLError(.badServerResponse)
- }
- guard (200..<300).contains(http.statusCode) else {
- let text = String(data: data, encoding: .utf8) ?? ""
- throw NSError(
- domain: "AnthropicOAuth",
- code: http.statusCode,
- userInfo: [NSLocalizedDescriptionKey: "Token refresh failed: \(text)"])
- }
-
- let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
- let access = decoded?["access_token"] as? String
- let refresh = (decoded?["refresh_token"] as? String) ?? refreshToken
- let expiresIn = decoded?["expires_in"] as? Double
- guard let access, let expiresIn else {
- throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [
- NSLocalizedDescriptionKey: "Unexpected token response.",
- ])
- }
-
- let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
- + Int64(expiresIn * 1000)
- - Int64(5 * 60 * 1000)
-
- self.logger.info("Anthropic OAuth refresh ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
- return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs)
- }
-}
-
-enum OpenClawOAuthStore {
- static let oauthFilename = "oauth.json"
- private static let providerKey = "anthropic"
- private static let openclawOAuthDirEnv = "OPENCLAW_OAUTH_DIR"
- private static let legacyPiDirEnv = "PI_CODING_AGENT_DIR"
-
- enum AnthropicOAuthStatus: Equatable {
- case missingFile
- case unreadableFile
- case invalidJSON
- case missingProviderEntry
- case missingTokens
- case connected(expiresAtMs: Int64?)
-
- var isConnected: Bool {
- if case .connected = self { return true }
- return false
- }
-
- var shortDescription: String {
- switch self {
- case .missingFile: "OpenClaw OAuth token file not found"
- case .unreadableFile: "OpenClaw OAuth token file not readable"
- case .invalidJSON: "OpenClaw OAuth token file invalid"
- case .missingProviderEntry: "No Anthropic entry in OpenClaw OAuth token file"
- case .missingTokens: "Anthropic entry missing tokens"
- case .connected: "OpenClaw OAuth credentials found"
- }
- }
- }
-
- static func oauthDir() -> URL {
- if let override = ProcessInfo.processInfo.environment[self.openclawOAuthDirEnv]?
- .trimmingCharacters(in: .whitespacesAndNewlines),
- !override.isEmpty
- {
- let expanded = NSString(string: override).expandingTildeInPath
- return URL(fileURLWithPath: expanded, isDirectory: true)
- }
- let home = FileManager().homeDirectoryForCurrentUser
- return home.appendingPathComponent(".openclaw", isDirectory: true)
- .appendingPathComponent("credentials", isDirectory: true)
- }
-
- static func oauthURL() -> URL {
- self.oauthDir().appendingPathComponent(self.oauthFilename)
- }
-
- static func legacyOAuthURLs() -> [URL] {
- var urls: [URL] = []
- let env = ProcessInfo.processInfo.environment
- if let override = env[self.legacyPiDirEnv]?.trimmingCharacters(in: .whitespacesAndNewlines),
- !override.isEmpty
- {
- let expanded = NSString(string: override).expandingTildeInPath
- urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename))
- }
-
- let home = FileManager().homeDirectoryForCurrentUser
- urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)"))
- urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)"))
- urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)"))
- urls.append(home.appendingPathComponent(".config/anthropic/\(self.oauthFilename)"))
-
- var seen = Set()
- return urls.filter { url in
- let path = url.standardizedFileURL.path
- if seen.contains(path) { return false }
- seen.insert(path)
- return true
- }
- }
-
- static func importLegacyAnthropicOAuthIfNeeded() -> URL? {
- let dest = self.oauthURL()
- guard !FileManager().fileExists(atPath: dest.path) else { return nil }
-
- for url in self.legacyOAuthURLs() {
- guard FileManager().fileExists(atPath: url.path) else { continue }
- guard self.anthropicOAuthStatus(at: url).isConnected else { continue }
- guard let storage = self.loadStorage(at: url) else { continue }
- do {
- try self.saveStorage(storage)
- return url
- } catch {
- continue
- }
- }
-
- return nil
- }
-
- static func anthropicOAuthStatus() -> AnthropicOAuthStatus {
- self.anthropicOAuthStatus(at: self.oauthURL())
- }
-
- static func hasAnthropicOAuth() -> Bool {
- self.anthropicOAuthStatus().isConnected
- }
-
- static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus {
- guard FileManager().fileExists(atPath: url.path) else { return .missingFile }
-
- guard let data = try? Data(contentsOf: url) else { return .unreadableFile }
- guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON }
- guard let storage = json as? [String: Any] else { return .invalidJSON }
- guard let rawEntry = storage[self.providerKey] else { return .missingProviderEntry }
- guard let entry = rawEntry as? [String: Any] else { return .invalidJSON }
-
- let refresh = self.firstString(in: entry, keys: ["refresh", "refresh_token", "refreshToken"])
- let access = self.firstString(in: entry, keys: ["access", "access_token", "accessToken"])
- guard refresh?.isEmpty == false, access?.isEmpty == false else { return .missingTokens }
-
- let expiresAny = entry["expires"] ?? entry["expires_at"] ?? entry["expiresAt"]
- let expiresAtMs: Int64? = if let ms = expiresAny as? Int64 {
- ms
- } else if let number = expiresAny as? NSNumber {
- number.int64Value
- } else if let ms = expiresAny as? Double {
- Int64(ms)
- } else {
- nil
- }
-
- return .connected(expiresAtMs: expiresAtMs)
- }
-
- static func loadAnthropicOAuthRefreshToken() -> String? {
- let url = self.oauthURL()
- guard let storage = self.loadStorage(at: url) else { return nil }
- guard let rawEntry = storage[self.providerKey] as? [String: Any] else { return nil }
- let refresh = self.firstString(in: rawEntry, keys: ["refresh", "refresh_token", "refreshToken"])
- return refresh?.trimmingCharacters(in: .whitespacesAndNewlines)
- }
-
- private static func firstString(in dict: [String: Any], keys: [String]) -> String? {
- for key in keys {
- if let value = dict[key] as? String { return value }
- }
- return nil
- }
-
- private static func loadStorage(at url: URL) -> [String: Any]? {
- guard let data = try? Data(contentsOf: url) else { return nil }
- guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil }
- return json as? [String: Any]
- }
-
- static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws {
- let url = self.oauthURL()
- let existing: [String: Any] = self.loadStorage(at: url) ?? [:]
-
- var updated = existing
- updated[self.providerKey] = [
- "type": creds.type,
- "refresh": creds.refresh,
- "access": creds.access,
- "expires": creds.expires,
- ]
-
- try self.saveStorage(updated)
- }
-
- private static func saveStorage(_ storage: [String: Any]) throws {
- let dir = self.oauthDir()
- try FileManager().createDirectory(
- at: dir,
- withIntermediateDirectories: true,
- attributes: [.posixPermissions: 0o700])
-
- let url = self.oauthURL()
- let data = try JSONSerialization.data(
- withJSONObject: storage,
- options: [.prettyPrinted, .sortedKeys])
- try data.write(to: url, options: [.atomic])
- try FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
- }
-}
-
-extension Data {
- fileprivate func base64URLEncodedString() -> String {
- self.base64EncodedString()
- .replacingOccurrences(of: "+", with: "-")
- .replacingOccurrences(of: "/", with: "_")
- .replacingOccurrences(of: "=", with: "")
- }
-}
diff --git a/apps/macos/Sources/OpenClaw/AnthropicOAuthCodeState.swift b/apps/macos/Sources/OpenClaw/AnthropicOAuthCodeState.swift
deleted file mode 100644
index 2a88898c34df..000000000000
--- a/apps/macos/Sources/OpenClaw/AnthropicOAuthCodeState.swift
+++ /dev/null
@@ -1,59 +0,0 @@
-import Foundation
-
-enum AnthropicOAuthCodeState {
- struct Parsed: Equatable {
- let code: String
- let state: String
- }
-
- /// Extracts a `code#state` payload from arbitrary text.
- ///
- /// Supports:
- /// - raw `code#state`
- /// - OAuth callback URLs containing `code=` and `state=` query params
- /// - surrounding text/backticks from instructions pages
- static func extract(from raw: String) -> String? {
- let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- .trimmingCharacters(in: CharacterSet(charactersIn: "`"))
- if text.isEmpty { return nil }
-
- if let fromURL = self.extractFromURL(text) { return fromURL }
- if let fromToken = self.extractFromToken(text) { return fromToken }
- return nil
- }
-
- static func parse(from raw: String) -> Parsed? {
- guard let extracted = self.extract(from: raw) else { return nil }
- let parts = extracted.split(separator: "#", maxSplits: 1).map(String.init)
- let code = parts.first ?? ""
- let state = parts.count > 1 ? parts[1] : ""
- guard !code.isEmpty, !state.isEmpty else { return nil }
- return Parsed(code: code, state: state)
- }
-
- private static func extractFromURL(_ text: String) -> String? {
- // Users might copy the callback URL from the browser address bar.
- guard let components = URLComponents(string: text),
- let items = components.queryItems,
- let code = items.first(where: { $0.name == "code" })?.value,
- let state = items.first(where: { $0.name == "state" })?.value,
- !code.isEmpty, !state.isEmpty
- else { return nil }
-
- return "\(code)#\(state)"
- }
-
- private static func extractFromToken(_ text: String) -> String? {
- // Base64url-ish tokens; keep this fairly strict to avoid false positives.
- let pattern = #"([A-Za-z0-9._~-]{8,})#([A-Za-z0-9._~-]{8,})"#
- guard let re = try? NSRegularExpression(pattern: pattern) else { return nil }
-
- let range = NSRange(text.startIndex..?
@State var needsBootstrap = false
@State var didAutoKickoff = false
@State var showAdvancedConnection = false
@@ -104,19 +87,9 @@ struct OnboardingView: View {
let pageWidth: CGFloat = Self.windowWidth
let contentHeight: CGFloat = 460
let connectionPageIndex = 1
- let anthropicAuthPageIndex = 2
let wizardPageIndex = 3
let onboardingChatPageIndex = 8
- static let clipboardPoll: AnyPublisher = {
- if ProcessInfo.processInfo.isRunningTests {
- return Empty(completeImmediately: false).eraseToAnyPublisher()
- }
- return Timer.publish(every: 0.4, on: .main, in: .common)
- .autoconnect()
- .eraseToAnyPublisher()
- }()
-
let permissionsPageIndex = 5
static func pageOrder(
for mode: AppState.ConnectionMode,
diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift
index bcd5bd6d44d2..a521926ddb99 100644
--- a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift
+++ b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift
@@ -78,70 +78,4 @@ extension OnboardingView {
self.copied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { self.copied = false }
}
-
- func startAnthropicOAuth() {
- guard !self.anthropicAuthBusy else { return }
- self.anthropicAuthBusy = true
- defer { self.anthropicAuthBusy = false }
-
- do {
- let pkce = try AnthropicOAuth.generatePKCE()
- self.anthropicAuthPKCE = pkce
- let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce)
- NSWorkspace.shared.open(url)
- self.anthropicAuthStatus = "Browser opened. After approving, paste the `code#state` value here."
- } catch {
- self.anthropicAuthStatus = "Failed to start OAuth: \(error.localizedDescription)"
- }
- }
-
- @MainActor
- func finishAnthropicOAuth() async {
- guard !self.anthropicAuthBusy else { return }
- guard let pkce = self.anthropicAuthPKCE else { return }
- self.anthropicAuthBusy = true
- defer { self.anthropicAuthBusy = false }
-
- guard let parsed = AnthropicOAuthCodeState.parse(from: self.anthropicAuthCode) else {
- self.anthropicAuthStatus = "OAuth failed: missing or invalid code/state."
- return
- }
-
- do {
- let creds = try await AnthropicOAuth.exchangeCode(
- code: parsed.code,
- state: parsed.state,
- verifier: pkce.verifier)
- try OpenClawOAuthStore.saveAnthropicOAuth(creds)
- self.refreshAnthropicOAuthStatus()
- self.anthropicAuthStatus = "Connected. OpenClaw can now use Claude."
- } catch {
- self.anthropicAuthStatus = "OAuth failed: \(error.localizedDescription)"
- }
- }
-
- func pollAnthropicClipboardIfNeeded() {
- guard self.currentPage == self.anthropicAuthPageIndex else { return }
- guard self.anthropicAuthPKCE != nil else { return }
- guard !self.anthropicAuthBusy else { return }
- guard self.anthropicAuthAutoDetectClipboard else { return }
-
- let pb = NSPasteboard.general
- let changeCount = pb.changeCount
- guard changeCount != self.anthropicAuthLastPasteboardChangeCount else { return }
- self.anthropicAuthLastPasteboardChangeCount = changeCount
-
- guard let raw = pb.string(forType: .string), !raw.isEmpty else { return }
- guard let parsed = AnthropicOAuthCodeState.parse(from: raw) else { return }
- guard let pkce = self.anthropicAuthPKCE, parsed.state == pkce.verifier else { return }
-
- let next = "\(parsed.code)#\(parsed.state)"
- if self.anthropicAuthCode != next {
- self.anthropicAuthCode = next
- self.anthropicAuthStatus = "Detected `code#state` from clipboard."
- }
-
- guard self.anthropicAuthAutoConnectClipboard else { return }
- Task { await self.finishAnthropicOAuth() }
- }
}
diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift
index ce87e211ce44..9b0e45e205c6 100644
--- a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift
+++ b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift
@@ -53,7 +53,6 @@ extension OnboardingView {
.onDisappear {
self.stopPermissionMonitoring()
self.stopDiscovery()
- self.stopAuthMonitoring()
Task { await self.onboardingWizard.cancelIfRunning() }
}
.task {
@@ -61,7 +60,6 @@ extension OnboardingView {
self.refreshCLIStatus()
await self.loadWorkspaceDefaults()
await self.ensureDefaultWorkspace()
- self.refreshAnthropicOAuthStatus()
self.refreshBootstrapStatus()
self.preferredGatewayID = GatewayDiscoveryPreferences.preferredStableID()
}
diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift
index dfbdf91d44d8..efe37f31673c 100644
--- a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift
+++ b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift
@@ -47,7 +47,6 @@ extension OnboardingView {
func updateMonitoring(for pageIndex: Int) {
self.updatePermissionMonitoring(for: pageIndex)
self.updateDiscoveryMonitoring(for: pageIndex)
- self.updateAuthMonitoring(for: pageIndex)
self.maybeKickoffOnboardingChat(for: pageIndex)
}
@@ -63,33 +62,6 @@ extension OnboardingView {
self.gatewayDiscovery.stop()
}
- func updateAuthMonitoring(for pageIndex: Int) {
- let shouldMonitor = pageIndex == self.anthropicAuthPageIndex && self.state.connectionMode == .local
- if shouldMonitor, !self.monitoringAuth {
- self.monitoringAuth = true
- self.startAuthMonitoring()
- } else if !shouldMonitor, self.monitoringAuth {
- self.stopAuthMonitoring()
- }
- }
-
- func startAuthMonitoring() {
- self.refreshAnthropicOAuthStatus()
- self.authMonitorTask?.cancel()
- self.authMonitorTask = Task {
- while !Task.isCancelled {
- await MainActor.run { self.refreshAnthropicOAuthStatus() }
- try? await Task.sleep(nanoseconds: 1_000_000_000)
- }
- }
- }
-
- func stopAuthMonitoring() {
- self.monitoringAuth = false
- self.authMonitorTask?.cancel()
- self.authMonitorTask = nil
- }
-
func installCLI() async {
guard !self.installingCLI else { return }
self.installingCLI = true
@@ -125,54 +97,4 @@ extension OnboardingView {
expected: expected)
}
}
-
- func refreshAnthropicOAuthStatus() {
- _ = OpenClawOAuthStore.importLegacyAnthropicOAuthIfNeeded()
- let previous = self.anthropicAuthDetectedStatus
- let status = OpenClawOAuthStore.anthropicOAuthStatus()
- self.anthropicAuthDetectedStatus = status
- self.anthropicAuthConnected = status.isConnected
-
- if previous != status {
- self.anthropicAuthVerified = false
- self.anthropicAuthVerificationAttempted = false
- self.anthropicAuthVerificationFailed = false
- self.anthropicAuthVerifiedAt = nil
- }
- }
-
- @MainActor
- func verifyAnthropicOAuthIfNeeded(force: Bool = false) async {
- guard self.state.connectionMode == .local else { return }
- guard self.anthropicAuthDetectedStatus.isConnected else { return }
- if self.anthropicAuthVerified, !force { return }
- if self.anthropicAuthVerifying { return }
- if self.anthropicAuthVerificationAttempted, !force { return }
-
- self.anthropicAuthVerificationAttempted = true
- self.anthropicAuthVerifying = true
- self.anthropicAuthVerificationFailed = false
- defer { self.anthropicAuthVerifying = false }
-
- guard let refresh = OpenClawOAuthStore.loadAnthropicOAuthRefreshToken(), !refresh.isEmpty else {
- self.anthropicAuthStatus = "OAuth verification failed: missing refresh token."
- self.anthropicAuthVerificationFailed = true
- return
- }
-
- do {
- let updated = try await AnthropicOAuth.refresh(refreshToken: refresh)
- try OpenClawOAuthStore.saveAnthropicOAuth(updated)
- self.refreshAnthropicOAuthStatus()
- self.anthropicAuthVerified = true
- self.anthropicAuthVerifiedAt = Date()
- self.anthropicAuthVerificationFailed = false
- self.anthropicAuthStatus = "OAuth detected and verified."
- } catch {
- self.anthropicAuthVerified = false
- self.anthropicAuthVerifiedAt = nil
- self.anthropicAuthVerificationFailed = true
- self.anthropicAuthStatus = "OAuth verification failed: \(error.localizedDescription)"
- }
- }
}
diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift
index ed40bd2ed58b..4f942dfe8a4f 100644
--- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift
+++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift
@@ -12,8 +12,6 @@ extension OnboardingView {
self.welcomePage()
case 1:
self.connectionPage()
- case 2:
- self.anthropicAuthPage()
case 3:
self.wizardPage()
case 5:
@@ -340,170 +338,6 @@ extension OnboardingView {
.buttonStyle(.plain)
}
- func anthropicAuthPage() -> some View {
- self.onboardingPage {
- Text("Connect Claude")
- .font(.largeTitle.weight(.semibold))
- Text("Give your model the token it needs!")
- .font(.body)
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
- .frame(maxWidth: 540)
- .fixedSize(horizontal: false, vertical: true)
- Text("OpenClaw supports any model — we strongly recommend Opus 4.6 for the best experience.")
- .font(.callout)
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
- .frame(maxWidth: 540)
- .fixedSize(horizontal: false, vertical: true)
-
- self.onboardingCard(spacing: 12, padding: 16) {
- HStack(alignment: .center, spacing: 10) {
- Circle()
- .fill(self.anthropicAuthVerified ? Color.green : Color.orange)
- .frame(width: 10, height: 10)
- Text(
- self.anthropicAuthConnected
- ? (self.anthropicAuthVerified
- ? "Claude connected (OAuth) — verified"
- : "Claude connected (OAuth)")
- : "Not connected yet")
- .font(.headline)
- Spacer()
- }
-
- if self.anthropicAuthConnected, self.anthropicAuthVerifying {
- Text("Verifying OAuth…")
- .font(.caption)
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- } else if !self.anthropicAuthConnected {
- Text(self.anthropicAuthDetectedStatus.shortDescription)
- .font(.caption)
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- } else if self.anthropicAuthVerified, let date = self.anthropicAuthVerifiedAt {
- Text("Detected working OAuth (\(date.formatted(date: .abbreviated, time: .shortened))).")
- .font(.caption)
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- }
-
- Text(
- "This lets OpenClaw use Claude immediately. Credentials are stored at " +
- "`~/.openclaw/credentials/oauth.json` (owner-only).")
- .font(.subheadline)
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
-
- HStack(spacing: 12) {
- Text(OpenClawOAuthStore.oauthURL().path)
- .font(.caption)
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .truncationMode(.middle)
-
- Spacer()
-
- Button("Reveal") {
- NSWorkspace.shared.activateFileViewerSelecting([OpenClawOAuthStore.oauthURL()])
- }
- .buttonStyle(.bordered)
-
- Button("Refresh") {
- self.refreshAnthropicOAuthStatus()
- }
- .buttonStyle(.bordered)
- }
-
- Divider().padding(.vertical, 2)
-
- HStack(spacing: 12) {
- if !self.anthropicAuthVerified {
- if self.anthropicAuthConnected {
- Button("Verify") {
- Task { await self.verifyAnthropicOAuthIfNeeded(force: true) }
- }
- .buttonStyle(.borderedProminent)
- .disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying)
-
- if self.anthropicAuthVerificationFailed {
- Button("Re-auth (OAuth)") {
- self.startAnthropicOAuth()
- }
- .buttonStyle(.bordered)
- .disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying)
- }
- } else {
- Button {
- self.startAnthropicOAuth()
- } label: {
- if self.anthropicAuthBusy {
- ProgressView()
- } else {
- Text("Open Claude sign-in (OAuth)")
- }
- }
- .buttonStyle(.borderedProminent)
- .disabled(self.anthropicAuthBusy)
- }
- }
- }
-
- if !self.anthropicAuthVerified, self.anthropicAuthPKCE != nil {
- VStack(alignment: .leading, spacing: 8) {
- Text("Paste the `code#state` value")
- .font(.headline)
- TextField("code#state", text: self.$anthropicAuthCode)
- .textFieldStyle(.roundedBorder)
-
- Toggle("Auto-detect from clipboard", isOn: self.$anthropicAuthAutoDetectClipboard)
- .font(.caption)
- .foregroundStyle(.secondary)
- .disabled(self.anthropicAuthBusy)
-
- Toggle("Auto-connect when detected", isOn: self.$anthropicAuthAutoConnectClipboard)
- .font(.caption)
- .foregroundStyle(.secondary)
- .disabled(self.anthropicAuthBusy)
-
- Button("Connect") {
- Task { await self.finishAnthropicOAuth() }
- }
- .buttonStyle(.bordered)
- .disabled(
- self.anthropicAuthBusy ||
- self.anthropicAuthCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
- }
- .onReceive(Self.clipboardPoll) { _ in
- self.pollAnthropicClipboardIfNeeded()
- }
- }
-
- self.onboardingCard(spacing: 8, padding: 12) {
- Text("API key (advanced)")
- .font(.headline)
- Text(
- "You can also use an Anthropic API key, but this UI is instructions-only for now " +
- "(GUI apps don’t automatically inherit your shell env vars like `ANTHROPIC_API_KEY`).")
- .font(.subheadline)
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- }
- .shadow(color: .clear, radius: 0)
- .background(Color.clear)
-
- if let status = self.anthropicAuthStatus {
- Text(status)
- .font(.caption)
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- }
- }
- }
- .task { await self.verifyAnthropicOAuthIfNeeded() }
- }
-
func permissionsPage() -> some View {
self.onboardingPage {
Text("Grant permissions")
diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift
index cf8c3d0c78f6..2bd9c525ad4a 100644
--- a/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift
+++ b/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift
@@ -37,18 +37,9 @@ extension OnboardingView {
view.cliStatus = "Installed"
view.workspacePath = "/tmp/openclaw"
view.workspaceStatus = "Saved workspace"
- view.anthropicAuthPKCE = AnthropicOAuth.PKCE(verifier: "verifier", challenge: "challenge")
- view.anthropicAuthCode = "code#state"
- view.anthropicAuthStatus = "Connected"
- view.anthropicAuthDetectedStatus = .connected(expiresAtMs: 1_700_000_000_000)
- view.anthropicAuthConnected = true
- view.anthropicAuthAutoDetectClipboard = false
- view.anthropicAuthAutoConnectClipboard = false
-
view.state.connectionMode = .local
_ = view.welcomePage()
_ = view.connectionPage()
- _ = view.anthropicAuthPage()
_ = view.wizardPage()
_ = view.permissionsPage()
_ = view.cliPage()
diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
index 4e766514defc..60b44d4545c8 100644
--- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
+++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
@@ -408,6 +408,7 @@ public struct SendParams: Codable, Sendable {
public let gifplayback: Bool?
public let channel: String?
public let accountid: String?
+ public let agentid: String?
public let threadid: String?
public let sessionkey: String?
public let idempotencykey: String
@@ -420,6 +421,7 @@ public struct SendParams: Codable, Sendable {
gifplayback: Bool?,
channel: String?,
accountid: String?,
+ agentid: String?,
threadid: String?,
sessionkey: String?,
idempotencykey: String)
@@ -431,6 +433,7 @@ public struct SendParams: Codable, Sendable {
self.gifplayback = gifplayback
self.channel = channel
self.accountid = accountid
+ self.agentid = agentid
self.threadid = threadid
self.sessionkey = sessionkey
self.idempotencykey = idempotencykey
@@ -444,6 +447,7 @@ public struct SendParams: Codable, Sendable {
case gifplayback = "gifPlayback"
case channel
case accountid = "accountId"
+ case agentid = "agentId"
case threadid = "threadId"
case sessionkey = "sessionKey"
case idempotencykey = "idempotencyKey"
@@ -2805,6 +2809,7 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
public struct ExecApprovalRequestParams: Codable, Sendable {
public let id: String?
public let command: String
+ public let commandargv: [String]?
public let cwd: AnyCodable?
public let nodeid: AnyCodable?
public let host: AnyCodable?
@@ -2819,6 +2824,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public init(
id: String?,
command: String,
+ commandargv: [String]?,
cwd: AnyCodable?,
nodeid: AnyCodable?,
host: AnyCodable?,
@@ -2832,6 +2838,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
{
self.id = id
self.command = command
+ self.commandargv = commandargv
self.cwd = cwd
self.nodeid = nodeid
self.host = host
@@ -2847,6 +2854,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
private enum CodingKeys: String, CodingKey {
case id
case command
+ case commandargv = "commandArgv"
case cwd
case nodeid = "nodeId"
case host
diff --git a/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthControlsSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthControlsSmokeTests.swift
deleted file mode 100644
index 84c618339328..000000000000
--- a/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthControlsSmokeTests.swift
+++ /dev/null
@@ -1,29 +0,0 @@
-import Testing
-@testable import OpenClaw
-
-@Suite(.serialized)
-@MainActor
-struct AnthropicAuthControlsSmokeTests {
- @Test func anthropicAuthControlsBuildsBodyLocal() {
- let pkce = AnthropicOAuth.PKCE(verifier: "verifier", challenge: "challenge")
- let view = AnthropicAuthControls(
- connectionMode: .local,
- oauthStatus: .connected(expiresAtMs: 1_700_000_000_000),
- pkce: pkce,
- code: "code#state",
- statusText: "Detected code",
- autoDetectClipboard: false,
- autoConnectClipboard: false)
- _ = view.body
- }
-
- @Test func anthropicAuthControlsBuildsBodyRemote() {
- let view = AnthropicAuthControls(
- connectionMode: .remote,
- oauthStatus: .missingFile,
- pkce: nil,
- code: "",
- statusText: nil)
- _ = view.body
- }
-}
diff --git a/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift b/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift
deleted file mode 100644
index c41b7f64be4c..000000000000
--- a/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift
+++ /dev/null
@@ -1,52 +0,0 @@
-import Foundation
-import Testing
-@testable import OpenClaw
-
-@Suite
-struct AnthropicAuthResolverTests {
- @Test
- func prefersOAuthFileOverEnv() throws {
- let dir = FileManager().temporaryDirectory
- .appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true)
- try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
- let oauthFile = dir.appendingPathComponent("oauth.json")
- let payload = [
- "anthropic": [
- "type": "oauth",
- "refresh": "r1",
- "access": "a1",
- "expires": 1_234_567_890,
- ],
- ]
- let data = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys])
- try data.write(to: oauthFile, options: [.atomic])
-
- let status = OpenClawOAuthStore.anthropicOAuthStatus(at: oauthFile)
- let mode = AnthropicAuthResolver.resolve(environment: [
- "ANTHROPIC_API_KEY": "sk-ant-ignored",
- ], oauthStatus: status)
- #expect(mode == .oauthFile)
- }
-
- @Test
- func reportsOAuthEnvWhenPresent() {
- let mode = AnthropicAuthResolver.resolve(environment: [
- "ANTHROPIC_OAUTH_TOKEN": "token",
- ], oauthStatus: .missingFile)
- #expect(mode == .oauthEnv)
- }
-
- @Test
- func reportsAPIKeyEnvWhenPresent() {
- let mode = AnthropicAuthResolver.resolve(environment: [
- "ANTHROPIC_API_KEY": "sk-ant-key",
- ], oauthStatus: .missingFile)
- #expect(mode == .apiKeyEnv)
- }
-
- @Test
- func reportsMissingWhenNothingConfigured() {
- let mode = AnthropicAuthResolver.resolve(environment: [:], oauthStatus: .missingFile)
- #expect(mode == .missing)
- }
-}
diff --git a/apps/macos/Tests/OpenClawIPCTests/AnthropicOAuthCodeStateTests.swift b/apps/macos/Tests/OpenClawIPCTests/AnthropicOAuthCodeStateTests.swift
deleted file mode 100644
index 3d337c2b279c..000000000000
--- a/apps/macos/Tests/OpenClawIPCTests/AnthropicOAuthCodeStateTests.swift
+++ /dev/null
@@ -1,31 +0,0 @@
-import Testing
-@testable import OpenClaw
-
-@Suite
-struct AnthropicOAuthCodeStateTests {
- @Test
- func parsesRawToken() {
- let parsed = AnthropicOAuthCodeState.parse(from: "abcDEF1234#stateXYZ9876")
- #expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
- }
-
- @Test
- func parsesBacktickedToken() {
- let parsed = AnthropicOAuthCodeState.parse(from: "`abcDEF1234#stateXYZ9876`")
- #expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
- }
-
- @Test
- func parsesCallbackURL() {
- let raw = "https://console.anthropic.com/oauth/code/callback?code=abcDEF1234&state=stateXYZ9876"
- let parsed = AnthropicOAuthCodeState.parse(from: raw)
- #expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
- }
-
- @Test
- func extractsFromSurroundingText() {
- let raw = "Paste the code#state value: abcDEF1234#stateXYZ9876 then return."
- let parsed = AnthropicOAuthCodeState.parse(from: raw)
- #expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
- }
-}
diff --git a/apps/macos/Tests/OpenClawIPCTests/OpenClawOAuthStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/OpenClawOAuthStoreTests.swift
deleted file mode 100644
index b34e9c3008ab..000000000000
--- a/apps/macos/Tests/OpenClawIPCTests/OpenClawOAuthStoreTests.swift
+++ /dev/null
@@ -1,97 +0,0 @@
-import Foundation
-import Testing
-@testable import OpenClaw
-
-@Suite
-struct OpenClawOAuthStoreTests {
- @Test
- func returnsMissingWhenFileAbsent() {
- let url = FileManager().temporaryDirectory
- .appendingPathComponent("openclaw-oauth-\(UUID().uuidString)")
- .appendingPathComponent("oauth.json")
- #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
- }
-
- @Test
- func usesEnvOverrideForOpenClawOAuthDir() throws {
- let key = "OPENCLAW_OAUTH_DIR"
- let previous = ProcessInfo.processInfo.environment[key]
- defer {
- if let previous {
- setenv(key, previous, 1)
- } else {
- unsetenv(key)
- }
- }
-
- let dir = FileManager().temporaryDirectory
- .appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true)
- setenv(key, dir.path, 1)
-
- #expect(OpenClawOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL)
- }
-
- @Test
- func acceptsPiFormatTokens() throws {
- let url = try self.writeOAuthFile([
- "anthropic": [
- "type": "oauth",
- "refresh": "r1",
- "access": "a1",
- "expires": 1_234_567_890,
- ],
- ])
-
- #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url).isConnected)
- }
-
- @Test
- func acceptsTokenKeyVariants() throws {
- let url = try self.writeOAuthFile([
- "anthropic": [
- "type": "oauth",
- "refresh_token": "r1",
- "access_token": "a1",
- ],
- ])
-
- #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url).isConnected)
- }
-
- @Test
- func reportsMissingProviderEntry() throws {
- let url = try self.writeOAuthFile([
- "other": [
- "type": "oauth",
- "refresh": "r1",
- "access": "a1",
- ],
- ])
-
- #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingProviderEntry)
- }
-
- @Test
- func reportsMissingTokens() throws {
- let url = try self.writeOAuthFile([
- "anthropic": [
- "type": "oauth",
- "refresh": "",
- "access": "a1",
- ],
- ])
-
- #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingTokens)
- }
-
- private func writeOAuthFile(_ json: [String: Any]) throws -> URL {
- let dir = FileManager().temporaryDirectory
- .appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true)
- try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
-
- let url = dir.appendingPathComponent("oauth.json")
- let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys])
- try data.write(to: url, options: [.atomic])
- return url
- }
-}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
index 4e766514defc..60b44d4545c8 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
@@ -408,6 +408,7 @@ public struct SendParams: Codable, Sendable {
public let gifplayback: Bool?
public let channel: String?
public let accountid: String?
+ public let agentid: String?
public let threadid: String?
public let sessionkey: String?
public let idempotencykey: String
@@ -420,6 +421,7 @@ public struct SendParams: Codable, Sendable {
gifplayback: Bool?,
channel: String?,
accountid: String?,
+ agentid: String?,
threadid: String?,
sessionkey: String?,
idempotencykey: String)
@@ -431,6 +433,7 @@ public struct SendParams: Codable, Sendable {
self.gifplayback = gifplayback
self.channel = channel
self.accountid = accountid
+ self.agentid = agentid
self.threadid = threadid
self.sessionkey = sessionkey
self.idempotencykey = idempotencykey
@@ -444,6 +447,7 @@ public struct SendParams: Codable, Sendable {
case gifplayback = "gifPlayback"
case channel
case accountid = "accountId"
+ case agentid = "agentId"
case threadid = "threadId"
case sessionkey = "sessionKey"
case idempotencykey = "idempotencyKey"
@@ -2805,6 +2809,7 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
public struct ExecApprovalRequestParams: Codable, Sendable {
public let id: String?
public let command: String
+ public let commandargv: [String]?
public let cwd: AnyCodable?
public let nodeid: AnyCodable?
public let host: AnyCodable?
@@ -2819,6 +2824,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public init(
id: String?,
command: String,
+ commandargv: [String]?,
cwd: AnyCodable?,
nodeid: AnyCodable?,
host: AnyCodable?,
@@ -2832,6 +2838,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
{
self.id = id
self.command = command
+ self.commandargv = commandargv
self.cwd = cwd
self.nodeid = nodeid
self.host = host
@@ -2847,6 +2854,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
private enum CodingKeys: String, CodingKey {
case id
case command
+ case commandargv = "commandArgv"
case cwd
case nodeid = "nodeId"
case host
diff --git a/changelog/fragments/README.md b/changelog/fragments/README.md
new file mode 100644
index 000000000000..93bb5b65d706
--- /dev/null
+++ b/changelog/fragments/README.md
@@ -0,0 +1,13 @@
+# Changelog Fragments
+
+Use this directory when a PR should not edit `CHANGELOG.md` directly.
+
+- One fragment file per PR.
+- File name recommendation: `pr-.md`.
+- Include at least one line with both `#` and `thanks @`.
+
+Example:
+
+```md
+- Fix LINE monitor lifecycle wait ownership (#27001) (thanks @alice)
+```
diff --git a/docker-setup.sh b/docker-setup.sh
index 8c67dc0962d7..1f6e51cd75d8 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'
@@ -247,12 +323,20 @@ upsert_env "$ENV_FILE" \
OPENCLAW_HOME_VOLUME \
OPENCLAW_DOCKER_APT_PACKAGES
-echo "==> Building Docker image: $IMAGE_NAME"
-docker build \
- --build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \
- -t "$IMAGE_NAME" \
- -f "$ROOT_DIR/Dockerfile" \
- "$ROOT_DIR"
+if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then
+ echo "==> Building Docker image: $IMAGE_NAME"
+ docker build \
+ --build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \
+ -t "$IMAGE_NAME" \
+ -f "$ROOT_DIR/Dockerfile" \
+ "$ROOT_DIR"
+else
+ echo "==> Pulling Docker image: $IMAGE_NAME"
+ if ! docker pull "$IMAGE_NAME"; then
+ echo "ERROR: Failed to pull image $IMAGE_NAME. Please check the image name and your access permissions." >&2
+ exit 1
+ fi
+fi
echo ""
echo "==> Onboarding (interactive)"
@@ -265,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/channels/pairing.md b/docs/channels/pairing.md
index 4b575eb87c76..d402de166623 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/channels/telegram.md b/docs/channels/telegram.md
index 6a454bd8dcfa..46db95202b4e 100644
--- a/docs/channels/telegram.md
+++ b/docs/channels/telegram.md
@@ -553,6 +553,7 @@ curl "https://api.telegram.org/bot/getUpdates"
Notes:
- `own` means user reactions to bot-sent messages only (best-effort via sent-message cache).
+ - Reaction events still respect Telegram access controls (`dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`); unauthorized senders are dropped.
- Telegram does not provide thread IDs in reaction updates.
- non-forum groups route to group chat session
- forum groups route to the group general-topic session (`:topic:1`), not the exact originating topic
diff --git a/docs/cli/agents.md b/docs/cli/agents.md
index 39679265f14a..5bdc8a68bf21 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 4213efb3eb7d..23e0b2cfd4be 100644
--- a/docs/cli/channels.md
+++ b/docs/cli/channels.md
@@ -35,6 +35,26 @@ 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)).
+
+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 32eb31b5eb3d..a780dfd2a5ed 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).
@@ -574,7 +576,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/cli/security.md b/docs/cli/security.md
index fe8af41ec259..cc705b31a30d 100644
--- a/docs/cli/security.md
+++ b/docs/cli/security.md
@@ -29,7 +29,7 @@ It also emits `security.trust_model.multi_user_heuristic` when config suggests l
For intentional shared-user setups, the audit guidance is to sandbox all sessions, keep filesystem access workspace-scoped, and keep personal/private identities or credentials off that runtime.
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`.
-It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries, when `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when open groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed extension plugin tools may be reachable under permissive tool policy.
+It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries (exact node command-name matching only, not shell-text filtering), when `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when open groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed extension plugin tools may be reachable under permissive tool policy.
It also flags `gateway.allowRealIpFallback=true` (header-spoofing risk if proxies are misconfigured) and `discovery.mdns.mode="full"` (metadata leakage via mDNS TXT records).
It also warns when sandbox browser uses Docker `bridge` network without `sandbox.browser.cdpSourceRange`.
It also flags dangerous sandbox Docker network modes (including `host` and `container:*` namespace joins).
diff --git a/docs/concepts/models.md b/docs/concepts/models.md
index ee8f06ecb3d4..b4317273d5c8 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/concepts/multi-agent.md b/docs/concepts/multi-agent.md
index 069fcfb63679..842531cc2a68 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/docs/docs.json b/docs/docs.json
index 811bed0f7f80..cbbbd3a7f5bc 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -986,7 +986,7 @@
},
{
"group": "Agent coordination",
- "pages": ["tools/agent-send", "tools/subagents", "multi-agent-sandbox-tools"]
+ "pages": ["tools/agent-send", "tools/subagents", "tools/multi-agent-sandbox-tools"]
},
{
"group": "Skills and extensions",
diff --git a/docs/experiments/plans/acp-thread-bound-agents.md b/docs/experiments/plans/acp-thread-bound-agents.md
new file mode 100644
index 000000000000..3ca509c94927
--- /dev/null
+++ b/docs/experiments/plans/acp-thread-bound-agents.md
@@ -0,0 +1,800 @@
+---
+summary: "Integrate ACP coding agents via a first-class ACP control plane in core and plugin-backed runtimes (acpx first)"
+owner: "onutc"
+status: "draft"
+last_updated: "2026-02-25"
+title: "ACP Thread Bound Agents"
+---
+
+# ACP Thread Bound Agents
+
+## Overview
+
+This plan defines how OpenClaw should support ACP coding agents in thread-capable channels (Discord first) with production-level lifecycle and recovery.
+
+Related document:
+
+- [Unified Runtime Streaming Refactor Plan](/experiments/plans/acp-unified-streaming-refactor)
+
+Target user experience:
+
+- a user spawns or focuses an ACP session into a thread
+- user messages in that thread route to the bound ACP session
+- agent output streams back to the same thread persona
+- session can be persistent or one shot with explicit cleanup controls
+
+## Decision summary
+
+Long term recommendation is a hybrid architecture:
+
+- OpenClaw core owns ACP control plane concerns
+ - session identity and metadata
+ - thread binding and routing decisions
+ - delivery invariants and duplicate suppression
+ - lifecycle cleanup and recovery semantics
+- ACP runtime backend is pluggable
+ - first backend is an acpx-backed plugin service
+ - runtime does ACP transport, queueing, cancel, reconnect
+
+OpenClaw should not reimplement ACP transport internals in core.
+OpenClaw should not rely on a pure plugin-only interception path for routing.
+
+## North-star architecture (holy grail)
+
+Treat ACP as a first-class control plane in OpenClaw, with pluggable runtime adapters.
+
+Non-negotiable invariants:
+
+- every ACP thread binding references a valid ACP session record
+- every ACP session has explicit lifecycle state (`creating`, `idle`, `running`, `cancelling`, `closed`, `error`)
+- every ACP run has explicit run state (`queued`, `running`, `completed`, `failed`, `cancelled`)
+- spawn, bind, and initial enqueue are atomic
+- command retries are idempotent (no duplicate runs or duplicate Discord outputs)
+- bound-thread channel output is a projection of ACP run events, never ad-hoc side effects
+
+Long-term ownership model:
+
+- `AcpSessionManager` is the single ACP writer and orchestrator
+- manager lives in gateway process first; can be moved to a dedicated sidecar later behind the same interface
+- per ACP session key, manager owns one in-memory actor (serialized command execution)
+- adapters (`acpx`, future backends) are transport/runtime implementations only
+
+Long-term persistence model:
+
+- move ACP control-plane state to a dedicated SQLite store (WAL mode) under OpenClaw state dir
+- keep `SessionEntry.acp` as compatibility projection during migration, not source-of-truth
+- store ACP events append-only to support replay, crash recovery, and deterministic delivery
+
+### Delivery strategy (bridge to holy-grail)
+
+- short-term bridge
+ - keep current thread binding mechanics and existing ACP config surface
+ - fix metadata-gap bugs and route ACP turns through a single core ACP branch
+ - add idempotency keys and fail-closed routing checks immediately
+- long-term cutover
+ - move ACP source-of-truth to control-plane DB + actors
+ - make bound-thread delivery purely event-projection based
+ - remove legacy fallback behavior that depends on opportunistic session-entry metadata
+
+## Why not pure plugin only
+
+Current plugin hooks are not sufficient for end to end ACP session routing without core changes.
+
+- inbound routing from thread binding resolves to a session key in core dispatch first
+- message hooks are fire-and-forget and cannot short-circuit the main reply path
+- plugin commands are good for control operations but not for replacing core per-turn dispatch flow
+
+Result:
+
+- ACP runtime can be pluginized
+- ACP routing branch must exist in core
+
+## Existing foundation to reuse
+
+Already implemented and should remain canonical:
+
+- thread binding target supports `subagent` and `acp`
+- inbound thread routing override resolves by binding before normal dispatch
+- outbound thread identity via webhook in reply delivery
+- `/focus` and `/unfocus` flow with ACP target compatibility
+- persistent binding store with restore on startup
+- unbind lifecycle on archive, delete, unfocus, reset, and delete
+
+This plan extends that foundation rather than replacing it.
+
+## Architecture
+
+### Boundary model
+
+Core (must be in OpenClaw core):
+
+- ACP session-mode dispatch branch in the reply pipeline
+- delivery arbitration to avoid parent plus thread duplication
+- ACP control-plane persistence (with `SessionEntry.acp` compatibility projection during migration)
+- lifecycle unbind and runtime detach semantics tied to session reset/delete
+
+Plugin backend (acpx implementation):
+
+- ACP runtime worker supervision
+- acpx process invocation and event parsing
+- ACP command handlers (`/acp ...`) and operator UX
+- backend-specific config defaults and diagnostics
+
+### Runtime ownership model
+
+- one gateway process owns ACP orchestration state
+- ACP execution runs in supervised child processes via acpx backend
+- process strategy is long lived per active ACP session key, not per message
+
+This avoids startup cost on every prompt and keeps cancel and reconnect semantics reliable.
+
+### Core runtime contract
+
+Add a core ACP runtime contract so routing code does not depend on CLI details and can switch backends without changing dispatch logic:
+
+```ts
+export type AcpRuntimePromptMode = "prompt" | "steer";
+
+export type AcpRuntimeHandle = {
+ sessionKey: string;
+ backend: string;
+ runtimeSessionName: string;
+};
+
+export type AcpRuntimeEvent =
+ | { type: "text_delta"; stream: "output" | "thought"; text: string }
+ | { type: "tool_call"; name: string; argumentsText: string }
+ | { type: "done"; usage?: Record }
+ | { type: "error"; code: string; message: string; retryable?: boolean };
+
+export interface AcpRuntime {
+ ensureSession(input: {
+ sessionKey: string;
+ agent: string;
+ mode: "persistent" | "oneshot";
+ cwd?: string;
+ env?: Record;
+ idempotencyKey: string;
+ }): Promise;
+
+ submit(input: {
+ handle: AcpRuntimeHandle;
+ text: string;
+ mode: AcpRuntimePromptMode;
+ idempotencyKey: string;
+ }): Promise<{ runtimeRunId: string }>;
+
+ stream(input: {
+ handle: AcpRuntimeHandle;
+ runtimeRunId: string;
+ onEvent: (event: AcpRuntimeEvent) => Promise | void;
+ signal?: AbortSignal;
+ }): Promise;
+
+ cancel(input: {
+ handle: AcpRuntimeHandle;
+ runtimeRunId?: string;
+ reason?: string;
+ idempotencyKey: string;
+ }): Promise;
+
+ close(input: { handle: AcpRuntimeHandle; reason: string; idempotencyKey: string }): Promise;
+
+ health?(): Promise<{ ok: boolean; details?: string }>;
+}
+```
+
+Implementation detail:
+
+- first backend: `AcpxRuntime` shipped as a plugin service
+- core resolves runtime via registry and fails with explicit operator error when no ACP runtime backend is available
+
+### Control-plane data model and persistence
+
+Long-term source-of-truth is a dedicated ACP SQLite database (WAL mode), for transactional updates and crash-safe recovery:
+
+- `acp_sessions`
+ - `session_key` (pk), `backend`, `agent`, `mode`, `cwd`, `state`, `created_at`, `updated_at`, `last_error`
+- `acp_runs`
+ - `run_id` (pk), `session_key` (fk), `state`, `requester_message_id`, `idempotency_key`, `started_at`, `ended_at`, `error_code`, `error_message`
+- `acp_bindings`
+ - `binding_key` (pk), `thread_id`, `channel_id`, `account_id`, `session_key` (fk), `expires_at`, `bound_at`
+- `acp_events`
+ - `event_id` (pk), `run_id` (fk), `seq`, `kind`, `payload_json`, `created_at`
+- `acp_delivery_checkpoint`
+ - `run_id` (pk/fk), `last_event_seq`, `last_discord_message_id`, `updated_at`
+- `acp_idempotency`
+ - `scope`, `idempotency_key`, `result_json`, `created_at`, unique `(scope, idempotency_key)`
+
+```ts
+export type AcpSessionMeta = {
+ backend: string;
+ agent: string;
+ runtimeSessionName: string;
+ mode: "persistent" | "oneshot";
+ cwd?: string;
+ state: "idle" | "running" | "error";
+ lastActivityAt: number;
+ lastError?: string;
+};
+```
+
+Storage rules:
+
+- keep `SessionEntry.acp` as a compatibility projection during migration
+- process ids and sockets stay in memory only
+- durable lifecycle and run status live in ACP DB, not generic session JSON
+- if runtime owner dies, gateway rehydrates from ACP DB and resumes from checkpoints
+
+### Routing and delivery
+
+Inbound:
+
+- keep current thread binding lookup as first routing step
+- if bound target is ACP session, route to ACP runtime branch instead of `getReplyFromConfig`
+- explicit `/acp steer` command uses `mode: "steer"`
+
+Outbound:
+
+- ACP event stream is normalized to OpenClaw reply chunks
+- delivery target is resolved through existing bound destination path
+- when a bound thread is active for that session turn, parent channel completion is suppressed
+
+Streaming policy:
+
+- stream partial output with coalescing window
+- configurable min interval and max chunk bytes to stay under Discord rate limits
+- final message always emitted on completion or failure
+
+### State machines and transaction boundaries
+
+Session state machine:
+
+- `creating -> idle -> running -> idle`
+- `running -> cancelling -> idle | error`
+- `idle -> closed`
+- `error -> idle | closed`
+
+Run state machine:
+
+- `queued -> running -> completed`
+- `running -> failed | cancelled`
+- `queued -> cancelled`
+
+Required transaction boundaries:
+
+- spawn transaction
+ - create ACP session row
+ - create/update ACP thread binding row
+ - enqueue initial run row
+- close transaction
+ - mark session closed
+ - delete/expire binding rows
+ - write final close event
+- cancel transaction
+ - mark target run cancelling/cancelled with idempotency key
+
+No partial success is allowed across these boundaries.
+
+### Per-session actor model
+
+`AcpSessionManager` runs one actor per ACP session key:
+
+- actor mailbox serializes `submit`, `cancel`, `close`, and `stream` side effects
+- actor owns runtime handle hydration and runtime adapter process lifecycle for that session
+- actor writes run events in-order (`seq`) before any Discord delivery
+- actor updates delivery checkpoints after successful outbound send
+
+This removes cross-turn races and prevents duplicate or out-of-order thread output.
+
+### Idempotency and delivery projection
+
+All external ACP actions must carry idempotency keys:
+
+- spawn idempotency key
+- prompt/steer idempotency key
+- cancel idempotency key
+- close idempotency key
+
+Delivery rules:
+
+- Discord messages are derived from `acp_events` plus `acp_delivery_checkpoint`
+- retries resume from checkpoint without re-sending already delivered chunks
+- final reply emission is exactly-once per run from projection logic
+
+### Recovery and self-healing
+
+On gateway start:
+
+- load non-terminal ACP sessions (`creating`, `idle`, `running`, `cancelling`, `error`)
+- recreate actors lazily on first inbound event or eagerly under configured cap
+- reconcile any `running` runs missing heartbeats and mark `failed` or recover via adapter
+
+On inbound Discord thread message:
+
+- if binding exists but ACP session is missing, fail closed with explicit stale-binding message
+- optionally auto-unbind stale binding after operator-safe validation
+- never silently route stale ACP bindings to normal LLM path
+
+### Lifecycle and safety
+
+Supported operations:
+
+- cancel current run: `/acp cancel`
+- unbind thread: `/unfocus`
+- close ACP session: `/acp close`
+- auto close idle sessions by effective TTL
+
+TTL policy:
+
+- effective TTL is minimum of
+ - global/session TTL
+ - Discord thread binding TTL
+ - ACP runtime owner TTL
+
+Safety controls:
+
+- allowlist ACP agents by name
+- restrict workspace roots for ACP sessions
+- env allowlist passthrough
+- max concurrent ACP sessions per account and globally
+- bounded restart backoff for runtime crashes
+
+## Config surface
+
+Core keys:
+
+- `acp.enabled`
+- `acp.dispatch.enabled` (independent ACP routing kill switch)
+- `acp.backend` (default `acpx`)
+- `acp.defaultAgent`
+- `acp.allowedAgents[]`
+- `acp.maxConcurrentSessions`
+- `acp.stream.coalesceIdleMs`
+- `acp.stream.maxChunkChars`
+- `acp.runtime.ttlMinutes`
+- `acp.controlPlane.store` (`sqlite` default)
+- `acp.controlPlane.storePath`
+- `acp.controlPlane.recovery.eagerActors`
+- `acp.controlPlane.recovery.reconcileRunningAfterMs`
+- `acp.controlPlane.checkpoint.flushEveryEvents`
+- `acp.controlPlane.checkpoint.flushEveryMs`
+- `acp.idempotency.ttlHours`
+- `channels.discord.threadBindings.spawnAcpSessions`
+
+Plugin/backend keys (acpx plugin section):
+
+- backend command/path overrides
+- backend env allowlist
+- backend per-agent presets
+- backend startup/stop timeouts
+- backend max inflight runs per session
+
+## Implementation specification
+
+### Control-plane modules (new)
+
+Add dedicated ACP control-plane modules in core:
+
+- `src/acp/control-plane/manager.ts`
+ - owns ACP actors, lifecycle transitions, command serialization
+- `src/acp/control-plane/store.ts`
+ - SQLite schema management, transactions, query helpers
+- `src/acp/control-plane/events.ts`
+ - typed ACP event definitions and serialization
+- `src/acp/control-plane/checkpoint.ts`
+ - durable delivery checkpoints and replay cursors
+- `src/acp/control-plane/idempotency.ts`
+ - idempotency key reservation and response replay
+- `src/acp/control-plane/recovery.ts`
+ - boot-time reconciliation and actor rehydrate plan
+
+Compatibility bridge modules:
+
+- `src/acp/runtime/session-meta.ts`
+ - remains temporarily for projection into `SessionEntry.acp`
+ - must stop being source-of-truth after migration cutover
+
+### Required invariants (must enforce in code)
+
+- ACP session creation and thread bind are atomic (single transaction)
+- there is at most one active run per ACP session actor at a time
+- event `seq` is strictly increasing per run
+- delivery checkpoint never advances past last committed event
+- idempotency replay returns previous success payload for duplicate command keys
+- stale/missing ACP metadata cannot route into normal non-ACP reply path
+
+### Core touchpoints
+
+Core files to change:
+
+- `src/auto-reply/reply/dispatch-from-config.ts`
+ - ACP branch calls `AcpSessionManager.submit` and event-projection delivery
+ - remove direct ACP fallback that bypasses control-plane invariants
+- `src/auto-reply/reply/inbound-context.ts` (or nearest normalized context boundary)
+ - expose normalized routing keys and idempotency seeds for ACP control plane
+- `src/config/sessions/types.ts`
+ - keep `SessionEntry.acp` as projection-only compatibility field
+- `src/gateway/server-methods/sessions.ts`
+ - reset/delete/archive must call ACP manager close/unbind transaction path
+- `src/infra/outbound/bound-delivery-router.ts`
+ - enforce fail-closed destination behavior for ACP bound session turns
+- `src/discord/monitor/thread-bindings.ts`
+ - add ACP stale-binding validation helpers wired to control-plane lookups
+- `src/auto-reply/reply/commands-acp.ts`
+ - route spawn/cancel/close/steer through ACP manager APIs
+- `src/agents/acp-spawn.ts`
+ - stop ad-hoc metadata writes; call ACP manager spawn transaction
+- `src/plugin-sdk/**` and plugin runtime bridge
+ - expose ACP backend registration and health semantics cleanly
+
+Core files explicitly not replaced:
+
+- `src/discord/monitor/message-handler.preflight.ts`
+ - keep thread binding override behavior as the canonical session-key resolver
+
+### ACP runtime registry API
+
+Add a core registry module:
+
+- `src/acp/runtime/registry.ts`
+
+Required API:
+
+```ts
+export type AcpRuntimeBackend = {
+ id: string;
+ runtime: AcpRuntime;
+ healthy?: () => boolean;
+};
+
+export function registerAcpRuntimeBackend(backend: AcpRuntimeBackend): void;
+export function unregisterAcpRuntimeBackend(id: string): void;
+export function getAcpRuntimeBackend(id?: string): AcpRuntimeBackend | null;
+export function requireAcpRuntimeBackend(id?: string): AcpRuntimeBackend;
+```
+
+Behavior:
+
+- `requireAcpRuntimeBackend` throws a typed ACP backend missing error when unavailable
+- plugin service registers backend on `start` and unregisters on `stop`
+- runtime lookups are read-only and process-local
+
+### acpx runtime plugin contract (implementation detail)
+
+For the first production backend (`extensions/acpx`), OpenClaw and acpx are
+connected with a strict command contract:
+
+- backend id: `acpx`
+- plugin service id: `acpx-runtime`
+- runtime handle encoding: `runtimeSessionName = acpx:v1:`
+- encoded payload fields:
+ - `name` (acpx named session; uses OpenClaw `sessionKey`)
+ - `agent` (acpx agent command)
+ - `cwd` (session workspace root)
+ - `mode` (`persistent | oneshot`)
+
+Command mapping:
+
+- ensure session:
+ - `acpx --format json --json-strict --cwd sessions ensure --name `
+- prompt turn:
+ - `acpx --format json --json-strict --cwd prompt --session --file -`
+- cancel:
+ - `acpx --format json --json-strict --cwd cancel --session `
+- close:
+ - `acpx --format json --json-strict --cwd sessions close `
+
+Streaming:
+
+- OpenClaw consumes ndjson events from `acpx --format json --json-strict`
+- `text` => `text_delta/output`
+- `thought` => `text_delta/thought`
+- `tool_call` => `tool_call`
+- `done` => `done`
+- `error` => `error`
+
+### Session schema patch
+
+Patch `SessionEntry` in `src/config/sessions/types.ts`:
+
+```ts
+type SessionAcpMeta = {
+ backend: string;
+ agent: string;
+ runtimeSessionName: string;
+ mode: "persistent" | "oneshot";
+ cwd?: string;
+ state: "idle" | "running" | "error";
+ lastActivityAt: number;
+ lastError?: string;
+};
+```
+
+Persisted field:
+
+- `SessionEntry.acp?: SessionAcpMeta`
+
+Migration rules:
+
+- phase A: dual-write (`acp` projection + ACP SQLite source-of-truth)
+- phase B: read-primary from ACP SQLite, fallback-read from legacy `SessionEntry.acp`
+- phase C: migration command backfills missing ACP rows from valid legacy entries
+- phase D: remove fallback-read and keep projection optional for UX only
+- legacy fields (`cliSessionIds`, `claudeCliSessionId`) remain untouched
+
+### Error contract
+
+Add stable ACP error codes and user-facing messages:
+
+- `ACP_BACKEND_MISSING`
+ - message: `ACP runtime backend is not configured. Install and enable the acpx runtime plugin.`
+- `ACP_BACKEND_UNAVAILABLE`
+ - message: `ACP runtime backend is currently unavailable. Try again in a moment.`
+- `ACP_SESSION_INIT_FAILED`
+ - message: `Could not initialize ACP session runtime.`
+- `ACP_TURN_FAILED`
+ - message: `ACP turn failed before completion.`
+
+Rules:
+
+- return actionable user-safe message in-thread
+- log detailed backend/system error only in runtime logs
+- never silently fall back to normal LLM path when ACP routing was explicitly selected
+
+### Duplicate delivery arbitration
+
+Single routing rule for ACP bound turns:
+
+- if an active thread binding exists for the target ACP session and requester context, deliver only to that bound thread
+- do not also send to parent channel for the same turn
+- if bound destination selection is ambiguous, fail closed with explicit error (no implicit parent fallback)
+- if no active binding exists, use normal session destination behavior
+
+### Observability and operational readiness
+
+Required metrics:
+
+- ACP spawn success/failure count by backend and error code
+- ACP run latency percentiles (queue wait, runtime turn time, delivery projection time)
+- ACP actor restart count and restart reason
+- stale-binding detection count
+- idempotency replay hit rate
+- Discord delivery retry and rate-limit counters
+
+Required logs:
+
+- structured logs keyed by `sessionKey`, `runId`, `backend`, `threadId`, `idempotencyKey`
+- explicit state transition logs for session and run state machines
+- adapter command logs with redaction-safe arguments and exit summary
+
+Required diagnostics:
+
+- `/acp sessions` includes state, active run, last error, and binding status
+- `/acp doctor` (or equivalent) validates backend registration, store health, and stale bindings
+
+### Config precedence and effective values
+
+ACP enablement precedence:
+
+- account override: `channels.discord.accounts..threadBindings.spawnAcpSessions`
+- channel override: `channels.discord.threadBindings.spawnAcpSessions`
+- global ACP gate: `acp.enabled`
+- dispatch gate: `acp.dispatch.enabled`
+- backend availability: registered backend for `acp.backend`
+
+Auto-enable behavior:
+
+- when ACP is configured (`acp.enabled=true`, `acp.dispatch.enabled=true`, or
+ `acp.backend=acpx`), plugin auto-enable marks `plugins.entries.acpx.enabled=true`
+ unless denylisted or explicitly disabled
+
+TTL effective value:
+
+- `min(session ttl, discord thread binding ttl, acp runtime ttl)`
+
+### Test map
+
+Unit tests:
+
+- `src/acp/runtime/registry.test.ts` (new)
+- `src/auto-reply/reply/dispatch-from-config.acp.test.ts` (new)
+- `src/infra/outbound/bound-delivery-router.test.ts` (extend ACP fail-closed cases)
+- `src/config/sessions/types.test.ts` or nearest session-store tests (ACP metadata persistence)
+
+Integration tests:
+
+- `src/discord/monitor/reply-delivery.test.ts` (bound ACP delivery target behavior)
+- `src/discord/monitor/message-handler.preflight*.test.ts` (bound ACP session-key routing continuity)
+- acpx plugin runtime tests in backend package (service register/start/stop + event normalization)
+
+Gateway e2e tests:
+
+- `src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts` (extend ACP reset/delete lifecycle coverage)
+- ACP thread turn roundtrip e2e for spawn, message, stream, cancel, unfocus, restart recovery
+
+### Rollout guard
+
+Add independent ACP dispatch kill switch:
+
+- `acp.dispatch.enabled` default `false` for first release
+- when disabled:
+ - ACP spawn/focus control commands may still bind sessions
+ - ACP dispatch path does not activate
+ - user receives explicit message that ACP dispatch is disabled by policy
+- after canary validation, default can be flipped to `true` in a later release
+
+## Command and UX plan
+
+### New commands
+
+- `/acp spawn [--mode persistent|oneshot] [--thread auto|here|off]`
+- `/acp cancel [session]`
+- `/acp steer `
+- `/acp close [session]`
+- `/acp sessions`
+
+### Existing command compatibility
+
+- `/focus ` continues to support ACP targets
+- `/unfocus` keeps current semantics
+- `/session ttl` remains the top level TTL override
+
+## Phased rollout
+
+### Phase 0 ADR and schema freeze
+
+- ship ADR for ACP control-plane ownership and adapter boundaries
+- freeze DB schema (`acp_sessions`, `acp_runs`, `acp_bindings`, `acp_events`, `acp_delivery_checkpoint`, `acp_idempotency`)
+- define stable ACP error codes, event contract, and state-transition guards
+
+### Phase 1 Control-plane foundation in core
+
+- implement `AcpSessionManager` and per-session actor runtime
+- implement ACP SQLite store and transaction helpers
+- implement idempotency store and replay helpers
+- implement event append + delivery checkpoint modules
+- wire spawn/cancel/close APIs to manager with transactional guarantees
+
+### Phase 2 Core routing and lifecycle integration
+
+- route thread-bound ACP turns from dispatch pipeline into ACP manager
+- enforce fail-closed routing when ACP binding/session invariants fail
+- integrate reset/delete/archive/unfocus lifecycle with ACP close/unbind transactions
+- add stale-binding detection and optional auto-unbind policy
+
+### Phase 3 acpx backend adapter/plugin
+
+- implement `acpx` adapter against runtime contract (`ensureSession`, `submit`, `stream`, `cancel`, `close`)
+- add backend health checks and startup/teardown registration
+- normalize acpx ndjson events into ACP runtime events
+- enforce backend timeouts, process supervision, and restart/backoff policy
+
+### Phase 4 Delivery projection and channel UX (Discord first)
+
+- implement event-driven channel projection with checkpoint resume (Discord first)
+- coalesce streaming chunks with rate-limit aware flush policy
+- guarantee exactly-once final completion message per run
+- ship `/acp spawn`, `/acp cancel`, `/acp steer`, `/acp close`, `/acp sessions`
+
+### Phase 5 Migration and cutover
+
+- introduce dual-write to `SessionEntry.acp` projection plus ACP SQLite source-of-truth
+- add migration utility for legacy ACP metadata rows
+- flip read path to ACP SQLite primary
+- remove legacy fallback routing that depends on missing `SessionEntry.acp`
+
+### Phase 6 Hardening, SLOs, and scale limits
+
+- enforce concurrency limits (global/account/session), queue policies, and timeout budgets
+- add full telemetry, dashboards, and alert thresholds
+- chaos-test crash recovery and duplicate-delivery suppression
+- publish runbook for backend outage, DB corruption, and stale-binding remediation
+
+### Full implementation checklist
+
+- core control-plane modules and tests
+- DB migrations and rollback plan
+- ACP manager API integration across dispatch and commands
+- adapter registration interface in plugin runtime bridge
+- acpx adapter implementation and tests
+- thread-capable channel delivery projection logic with checkpoint replay (Discord first)
+- lifecycle hooks for reset/delete/archive/unfocus
+- stale-binding detector and operator-facing diagnostics
+- config validation and precedence tests for all new ACP keys
+- operational docs and troubleshooting runbook
+
+## Test plan
+
+Unit tests:
+
+- ACP DB transaction boundaries (spawn/bind/enqueue atomicity, cancel, close)
+- ACP state-machine transition guards for sessions and runs
+- idempotency reservation/replay semantics across all ACP commands
+- per-session actor serialization and queue ordering
+- acpx event parser and chunk coalescer
+- runtime supervisor restart and backoff policy
+- config precedence and effective TTL calculation
+- core ACP routing branch selection and fail-closed behavior when backend/session is invalid
+
+Integration tests:
+
+- fake ACP adapter process for deterministic streaming and cancel behavior
+- ACP manager + dispatch integration with transactional persistence
+- thread-bound inbound routing to ACP session key
+- thread-bound outbound delivery suppresses parent channel duplication
+- checkpoint replay recovers after delivery failure and resumes from last event
+- plugin service registration and teardown of ACP runtime backend
+
+Gateway e2e tests:
+
+- spawn ACP with thread, exchange multi-turn prompts, unfocus
+- gateway restart with persisted ACP DB and bindings, then continue same session
+- concurrent ACP sessions in multiple threads have no cross-talk
+- duplicate command retries (same idempotency key) do not create duplicate runs or replies
+- stale-binding scenario yields explicit error and optional auto-clean behavior
+
+## Risks and mitigations
+
+- Duplicate deliveries during transition
+ - Mitigation: single destination resolver and idempotent event checkpoint
+- Runtime process churn under load
+ - Mitigation: long lived per session owners + concurrency caps + backoff
+- Plugin absent or misconfigured
+ - Mitigation: explicit operator-facing error and fail-closed ACP routing (no implicit fallback to normal session path)
+- Config confusion between subagent and ACP gates
+ - Mitigation: explicit ACP keys and command feedback that includes effective policy source
+- Control-plane store corruption or migration bugs
+ - Mitigation: WAL mode, backup/restore hooks, migration smoke tests, and read-only fallback diagnostics
+- Actor deadlocks or mailbox starvation
+ - Mitigation: watchdog timers, actor health probes, and bounded mailbox depth with rejection telemetry
+
+## Acceptance checklist
+
+- ACP session spawn can create or bind a thread in a supported channel adapter (currently Discord)
+- all thread messages route to bound ACP session only
+- ACP outputs appear in the same thread identity with streaming or batches
+- no duplicate output in parent channel for bound turns
+- spawn+bind+initial enqueue are atomic in persistent store
+- ACP command retries are idempotent and do not duplicate runs or outputs
+- cancel, close, unfocus, archive, reset, and delete perform deterministic cleanup
+- crash restart preserves mapping and resumes multi turn continuity
+- concurrent thread bound ACP sessions work independently
+- ACP backend missing state produces clear actionable error
+- stale bindings are detected and surfaced explicitly (with optional safe auto-clean)
+- control-plane metrics and diagnostics are available for operators
+- new unit, integration, and e2e coverage passes
+
+## Addendum: targeted refactors for current implementation (status)
+
+These are non-blocking follow-ups to keep the ACP path maintainable after the current feature set lands.
+
+### 1) Centralize ACP dispatch policy evaluation (completed)
+
+- implemented via shared ACP policy helpers in `src/acp/policy.ts`
+- dispatch, ACP command lifecycle handlers, and ACP spawn path now consume shared policy logic
+
+### 2) Split ACP command handler by subcommand domain (completed)
+
+- `src/auto-reply/reply/commands-acp.ts` is now a thin router
+- subcommand behavior is split into:
+ - `src/auto-reply/reply/commands-acp/lifecycle.ts`
+ - `src/auto-reply/reply/commands-acp/runtime-options.ts`
+ - `src/auto-reply/reply/commands-acp/diagnostics.ts`
+ - shared helpers in `src/auto-reply/reply/commands-acp/shared.ts`
+
+### 3) Split ACP session manager by responsibility (completed)
+
+- manager is split into:
+ - `src/acp/control-plane/manager.ts` (public facade + singleton)
+ - `src/acp/control-plane/manager.core.ts` (manager implementation)
+ - `src/acp/control-plane/manager.types.ts` (manager types/deps)
+ - `src/acp/control-plane/manager.utils.ts` (normalization + helper functions)
+
+### 4) Optional acpx runtime adapter cleanup
+
+- `extensions/acpx/src/runtime.ts` can be split into:
+- process execution/supervision
+- ndjson event parsing/normalization
+- runtime API surface (`submit`, `cancel`, `close`, etc.)
+- improves testability and makes backend behavior easier to audit
diff --git a/docs/experiments/plans/acp-unified-streaming-refactor.md b/docs/experiments/plans/acp-unified-streaming-refactor.md
new file mode 100644
index 000000000000..3834fb9f8d89
--- /dev/null
+++ b/docs/experiments/plans/acp-unified-streaming-refactor.md
@@ -0,0 +1,96 @@
+---
+summary: "Holy grail refactor plan for one unified runtime streaming pipeline across main, subagent, and ACP"
+owner: "onutc"
+status: "draft"
+last_updated: "2026-02-25"
+title: "Unified Runtime Streaming Refactor Plan"
+---
+
+# Unified Runtime Streaming Refactor Plan
+
+## Objective
+
+Deliver one shared streaming pipeline for `main`, `subagent`, and `acp` so all runtimes get identical coalescing, chunking, delivery ordering, and crash recovery behavior.
+
+## Why this exists
+
+- Current behavior is split across multiple runtime-specific shaping paths.
+- Formatting/coalescing bugs can be fixed in one path but remain in others.
+- Delivery consistency, duplicate suppression, and recovery semantics are harder to reason about.
+
+## Target architecture
+
+Single pipeline, runtime-specific adapters:
+
+1. Runtime adapters emit canonical events only.
+2. Shared stream assembler coalesces and finalizes text/tool/status events.
+3. Shared channel projector applies channel-specific chunking/formatting once.
+4. Shared delivery ledger enforces idempotent send/replay semantics.
+5. Outbound channel adapter executes sends and records delivery checkpoints.
+
+Canonical event contract:
+
+- `turn_started`
+- `text_delta`
+- `block_final`
+- `tool_started`
+- `tool_finished`
+- `status`
+- `turn_completed`
+- `turn_failed`
+- `turn_cancelled`
+
+## Workstreams
+
+### 1) Canonical streaming contract
+
+- Define strict event schema + validation in core.
+- Add adapter contract tests to guarantee each runtime emits compatible events.
+- Reject malformed runtime events early and surface structured diagnostics.
+
+### 2) Shared stream processor
+
+- Replace runtime-specific coalescer/projector logic with one processor.
+- Processor owns text delta buffering, idle flush, max-chunk splitting, and completion flush.
+- Move ACP/main/subagent config resolution into one helper to prevent drift.
+
+### 3) Shared channel projection
+
+- Keep channel adapters dumb: accept finalized blocks and send.
+- Move Discord-specific chunking quirks to channel projector only.
+- Keep pipeline channel-agnostic before projection.
+
+### 4) Delivery ledger + replay
+
+- Add per-turn/per-chunk delivery IDs.
+- Record checkpoints before and after physical send.
+- On restart, replay pending chunks idempotently and avoid duplicates.
+
+### 5) Migration and cutover
+
+- Phase 1: shadow mode (new pipeline computes output but old path sends; compare).
+- Phase 2: runtime-by-runtime cutover (`acp`, then `subagent`, then `main` or reverse by risk).
+- Phase 3: delete legacy runtime-specific streaming code.
+
+## Non-goals
+
+- No changes to ACP policy/permissions model in this refactor.
+- No channel-specific feature expansion outside projection compatibility fixes.
+- No transport/backend redesign (acpx plugin contract remains as-is unless needed for event parity).
+
+## Risks and mitigations
+
+- Risk: behavioral regressions in existing main/subagent paths.
+ Mitigation: shadow mode diffing + adapter contract tests + channel e2e tests.
+- Risk: duplicate sends during crash recovery.
+ Mitigation: durable delivery IDs + idempotent replay in delivery adapter.
+- Risk: runtime adapters diverge again.
+ Mitigation: required shared contract test suite for all adapters.
+
+## Acceptance criteria
+
+- All runtimes pass shared streaming contract tests.
+- Discord ACP/main/subagent produce equivalent spacing/chunking behavior for tiny deltas.
+- Crash/restart replay sends no duplicate chunk for the same delivery ID.
+- Legacy ACP projector/coalescer path is removed.
+- Streaming config resolution is shared and runtime-independent.
diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md
index d3838bbdae60..0639dc36e926 100644
--- a/docs/gateway/configuration-examples.md
+++ b/docs/gateway/configuration-examples.md
@@ -273,6 +273,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
every: "30m",
model: "anthropic/claude-sonnet-4-5",
target: "last",
+ directPolicy: "allow", // allow (default) | block
to: "+15555550123",
prompt: "HEARTBEAT",
ackMaxChars: 300,
@@ -627,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.
diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md
index 01ad82b6098c..a715ec89ba62 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
@@ -800,6 +803,7 @@ Periodic heartbeat runs.
includeReasoning: false,
session: "main",
to: "+15555550123",
+ directPolicy: "allow", // allow (default) | block
target: "none", // default: none | options: last | whatsapp | telegram | discord | ...
prompt: "Read HEARTBEAT.md if it exists...",
ackMaxChars: 300,
@@ -812,7 +816,7 @@ Periodic heartbeat runs.
- `every`: duration string (ms/s/m/h). Default: `30m`.
- `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs.
-- Heartbeats never deliver to direct/DM chat targets when the destination can be classified as direct (for example `user:`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs); those runs still execute, but outbound delivery is skipped.
+- `directPolicy`: direct/DM delivery policy. `allow` (default) permits direct-target delivery. `block` suppresses direct-target delivery and emits `reason=dm-blocked`.
- Per-agent: set `agents.list[].heartbeat`. When any agent defines `heartbeat`, **only those agents** run heartbeats.
- Heartbeats run full agent turns — shorter intervals burn more tokens.
@@ -1250,6 +1254,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
},
resetTriggers: ["/new", "/reset"],
store: "~/.openclaw/agents/{agentId}/sessions/sessions.json",
+ parentForkMaxTokens: 100000, // skip parent-thread fork above this token count (0 disables)
maintenance: {
mode: "warn", // warn | enforce
pruneAfter: "30d",
@@ -1283,6 +1288,9 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
- **`identityLinks`**: map canonical ids to provider-prefixed peers for cross-channel session sharing.
- **`reset`**: primary reset policy. `daily` resets at `atHour` local time; `idle` resets after `idleMinutes`. When both configured, whichever expires first wins.
- **`resetByType`**: per-type overrides (`direct`, `group`, `thread`). Legacy `dm` accepted as alias for `direct`.
+- **`parentForkMaxTokens`**: max parent-session `totalTokens` allowed when creating a forked thread session (default `100000`).
+ - If parent `totalTokens` is above this value, OpenClaw starts a fresh thread session instead of inheriting parent transcript history.
+ - Set `0` to disable this guard and always allow parent forking.
- **`mainKey`**: legacy field. Runtime now always uses `"main"` for the main direct-chat bucket.
- **`sendPolicy`**: match by `channel`, `chatType` (`direct|group|channel`, with legacy `dm` alias), `keyPrefix`, or `rawKeyPrefix`. First deny wins.
- **`maintenance`**: session-store cleanup + retention controls.
@@ -1736,6 +1744,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
@@ -2141,8 +2153,9 @@ See [Plugins](/tools/plugin).
- `auth.allowTailscale`: when `true`, Tailscale Serve identity headers can satisfy Control UI/WebSocket auth (verified via `tailscale whois`); HTTP API endpoints still require token/password auth. This tokenless flow assumes the gateway host is trusted. Defaults to `true` when `tailscale.mode = "serve"`.
- `auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`.
- `auth.rateLimit.exemptLoopback` defaults to `true`; set `false` when you intentionally want localhost traffic rate-limited too (for test setups or strict proxy deployments).
+- Browser-origin WS auth attempts are always throttled with loopback exemption disabled (defense-in-depth against browser-based localhost brute force).
- `tailscale.mode`: `serve` (tailnet only, loopback bind) or `funnel` (public, requires auth).
-- `controlUi.allowedOrigins`: explicit browser-origin allowlist for Control UI/WebChat WebSocket connects. Required when Control UI is reachable on non-loopback binds.
+- `controlUi.allowedOrigins`: explicit browser-origin allowlist for Gateway WebSocket connects. Required when browser clients are expected from non-loopback origins.
- `controlUi.dangerouslyAllowHostHeaderOriginFallback`: dangerous mode that enables Host-header origin fallback for deployments that intentionally rely on Host-header origin policy.
- `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`.
- `gateway.remote.token` is for remote CLI calls only; does not enable local gateway auth.
diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md
index 3f7403d4647e..ff3179d28e2a 100644
--- a/docs/gateway/configuration.md
+++ b/docs/gateway/configuration.md
@@ -239,7 +239,8 @@ When validation fails:
```
- `every`: duration string (`30m`, `2h`). Set `0m` to disable.
- - `target`: `last` | `whatsapp` | `telegram` | `discord` | `none` (DM-style `user:` heartbeat delivery is blocked)
+ - `target`: `last` | `whatsapp` | `telegram` | `discord` | `none`
+ - `directPolicy`: `allow` (default) or `block` for DM-style heartbeat targets
- See [Heartbeat](/gateway/heartbeat) for the full guide.
diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md
index 4647cb8b411b..4ecc10b4c66f 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/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md
index cf7ea489c40b..a4f4aa64ea94 100644
--- a/docs/gateway/heartbeat.md
+++ b/docs/gateway/heartbeat.md
@@ -32,6 +32,7 @@ Example config:
heartbeat: {
every: "30m",
target: "last", // explicit delivery to last contact (default is "none")
+ directPolicy: "allow", // default: allow direct/DM targets; set "block" to suppress
// activeHours: { start: "08:00", end: "24:00" },
// includeReasoning: true, // optional: send separate `Reasoning:` message too
},
@@ -215,7 +216,9 @@ Use `accountId` to target a specific account on multi-account channels like Tele
- `last`: deliver to the last used external channel.
- explicit channel: `whatsapp` / `telegram` / `discord` / `googlechat` / `slack` / `msteams` / `signal` / `imessage`.
- `none` (default): run the heartbeat but **do not deliver** externally.
-- Direct/DM heartbeat destinations are blocked when target parsing identifies a direct chat (for example `user:`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs).
+- `directPolicy`: controls direct/DM delivery behavior:
+ - `allow` (default): allow direct/DM heartbeat delivery.
+ - `block`: suppress direct/DM delivery (`reason=dm-blocked`).
- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp or a Telegram chat id). For Telegram topics/threads, use `:topic:`.
- `accountId`: optional account id for multi-account channels. When `target: "last"`, the account id applies to the resolved last channel if it supports accounts; otherwise it is ignored. If the account id does not match a configured account for the resolved channel, delivery is skipped.
- `prompt`: overrides the default prompt body (not merged).
@@ -236,7 +239,7 @@ Use `accountId` to target a specific account on multi-account channels like Tele
- `session` only affects the run context; delivery is controlled by `target` and `to`.
- To deliver to a specific channel/recipient, set `target` + `to`. With
`target: "last"`, delivery uses the last external channel for that session.
-- Heartbeat deliveries never send to direct/DM targets when the destination is identified as direct; those runs still execute, but outbound delivery is skipped.
+- Heartbeat deliveries allow direct/DM targets by default. Set `directPolicy: "block"` to suppress direct-target sends while still running the heartbeat turn.
- If the main queue is busy, the heartbeat is skipped and retried later.
- If `target` resolves to no external destination, the run still happens but no
outbound message is sent.
diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md
index 3824d1d283e1..32a2a55329d8 100644
--- a/docs/gateway/security/index.md
+++ b/docs/gateway/security/index.md
@@ -188,7 +188,7 @@ If more than one person can DM your bot:
- **Browser control exposure** (remote nodes, relay ports, remote CDP endpoints).
- **Local disk hygiene** (permissions, symlinks, config includes, “synced folder” paths).
- **Plugins** (extensions exist without an explicit allowlist).
-- **Policy drift/misconfig** (sandbox docker settings configured but sandbox mode off; ineffective `gateway.nodes.denyCommands` patterns; dangerous `gateway.nodes.allowCommands` entries; global `tools.profile="minimal"` overridden by per-agent profiles; extension plugin tools reachable under permissive tool policy).
+- **Policy drift/misconfig** (sandbox docker settings configured but sandbox mode off; ineffective `gateway.nodes.denyCommands` patterns because matching is exact command-name only (for example `system.run`) and does not inspect shell text; dangerous `gateway.nodes.allowCommands` entries; global `tools.profile="minimal"` overridden by per-agent profiles; extension plugin tools reachable under permissive tool policy).
- **Runtime expectation drift** (for example `tools.exec.host="sandbox"` while sandbox mode is off, which runs directly on the gateway host).
- **Model hygiene** (warn when configured models look legacy; not a hard block).
@@ -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/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md
index 23483076102b..45963f155798 100644
--- a/docs/gateway/troubleshooting.md
+++ b/docs/gateway/troubleshooting.md
@@ -174,7 +174,7 @@ Common signatures:
- `cron: timer tick failed` → scheduler tick failed; check file/log/runtime errors.
- `heartbeat skipped` with `reason=quiet-hours` → outside active hours window.
- `heartbeat: unknown accountId` → invalid account id for heartbeat delivery target.
-- `heartbeat skipped` with `reason=dm-blocked` → heartbeat target resolved to a DM-style `user:` destination (blocked by design).
+- `heartbeat skipped` with `reason=dm-blocked` → heartbeat target resolved to a DM-style destination while `agents.defaults.heartbeat.directPolicy` (or per-agent override) is set to `block`.
Related:
diff --git a/docs/help/testing.md b/docs/help/testing.md
index 7932a1f244fa..01bb80abb473 100644
--- a/docs/help/testing.md
+++ b/docs/help/testing.md
@@ -336,6 +336,11 @@ These run `pnpm test:live` inside the repo Docker image, mounting your local con
- Gateway networking (two containers, WS auth + health): `pnpm test:docker:gateway-network` (script: `scripts/e2e/gateway-network-docker.sh`)
- Plugins (custom extension load + registry smoke): `pnpm test:docker:plugins` (script: `scripts/e2e/plugins-docker.sh`)
+Manual ACP plain-language thread smoke (not CI):
+
+- `bun scripts/dev/discord-acp-plain-language-smoke.ts --channel ...`
+- Keep this script for regression/debug workflows. It may be needed again for ACP thread routing validation, so do not delete it.
+
Useful env vars:
- `OPENCLAW_CONFIG_DIR=...` (default: `~/.openclaw`) mounted to `/home/node/.openclaw`
diff --git a/docs/install/docker.md b/docs/install/docker.md
index decd1d779ee7..42cefd4be01b 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 b0ec51a75dd0..2c6bdd8ac1f6 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,16 @@ 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.
+
+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
@@ -394,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
+```
---
@@ -449,7 +473,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
diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md
index aff09a303e8a..d258eeb6722c 100644
--- a/docs/reference/session-management-compaction.md
+++ b/docs/reference/session-management-compaction.md
@@ -128,6 +128,7 @@ Rules of thumb:
- **Reset** (`/new`, `/reset`) creates a new `sessionId` for that `sessionKey`.
- **Daily reset** (default 4:00 AM local time on the gateway host) creates a new `sessionId` on the next message after the reset boundary.
- **Idle expiry** (`session.reset.idleMinutes` or legacy `session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window. When daily + idle are both configured, whichever expires first wins.
+- **Thread parent fork guard** (`session.parentForkMaxTokens`, default `100000`) skips parent transcript forking when the parent session is already too large; the new thread starts fresh. Set `0` to disable.
Implementation detail: the decision happens in `initSessionState()` in `src/auto-reply/reply/session.ts`.
diff --git a/docs/start/onboarding.md b/docs/start/onboarding.md
index ab9289b8a110..dfa058af545b 100644
--- a/docs/start/onboarding.md
+++ b/docs/start/onboarding.md
@@ -29,6 +29,12 @@ For a general overview of onboarding paths, see [Onboarding Overview](/start/onb
+
+Security trust model:
+
+- By default, OpenClaw is a personal agent: one trusted operator boundary.
+- Shared/multi-user setups require lock-down (split trust boundaries, keep tool access minimal, and follow [Security](/gateway/security)).
+
@@ -37,17 +43,19 @@ For a general overview of onboarding paths, see [Onboarding Overview](/start/onb
Where does the **Gateway** run?
-- **This Mac (Local only):** onboarding can run OAuth flows and write credentials
+- **This Mac (Local only):** onboarding can configure auth and write credentials
locally.
-- **Remote (over SSH/Tailnet):** onboarding does **not** run OAuth locally;
+- **Remote (over SSH/Tailnet):** onboarding does **not** configure local auth;
credentials must exist on the gateway host.
- **Configure later:** skip setup and leave the app unconfigured.
**Gateway auth tip:**
+
- The wizard now generates a **token** even for loopback, so local WS clients must authenticate.
- If you disable auth, any local process can connect; use that only on fully trusted machines.
- Use a **token** for multi‑machine access or non‑loopback binds.
+
diff --git a/docs/start/openclaw.md b/docs/start/openclaw.md
index 058f2fa67fef..671efe420c72 100644
--- a/docs/start/openclaw.md
+++ b/docs/start/openclaw.md
@@ -164,7 +164,7 @@ Set `agents.defaults.heartbeat.every: "0m"` to disable.
- If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown headers like `# Heading`), OpenClaw skips the heartbeat run to save API calls.
- If the file is missing, the heartbeat still runs and the model decides what to do.
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), OpenClaw suppresses outbound delivery for that heartbeat.
-- Heartbeat delivery to DM-style `user:` targets is blocked; those runs still execute but skip outbound delivery.
+- By default, heartbeat delivery to DM-style `user:` targets is allowed. Set `agents.defaults.heartbeat.directPolicy: "block"` to suppress direct-target delivery while keeping heartbeat runs active.
- Heartbeats run full agent turns — shorter intervals burn more tokens.
```json5
diff --git a/docs/start/setup.md b/docs/start/setup.md
index ee50e02afd47..7eef5bce7143 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/docs/tools/acp-agents.md b/docs/tools/acp-agents.md
new file mode 100644
index 000000000000..4cfc3ca92c4a
--- /dev/null
+++ b/docs/tools/acp-agents.md
@@ -0,0 +1,265 @@
+---
+summary: "Use ACP runtime sessions for Pi, Claude Code, Codex, OpenCode, Gemini CLI, and other harness agents"
+read_when:
+ - Running coding harnesses through ACP
+ - Setting up thread-bound ACP sessions on thread-capable channels
+ - Troubleshooting ACP backend and plugin wiring
+title: "ACP Agents"
+---
+
+# ACP agents
+
+ACP sessions let OpenClaw run external coding harnesses (for example Pi, Claude Code, Codex, OpenCode, and Gemini CLI) through an ACP backend plugin.
+
+If you ask OpenClaw in plain language to "run this in Codex" or "start Claude Code in a thread", OpenClaw should route that request to the ACP runtime (not the native sub-agent runtime).
+
+## Quick start for humans
+
+Examples of natural requests:
+
+- "Start a persistent Codex session in a thread here and keep it focused."
+- "Run this as a one-shot Claude Code ACP session and summarize the result."
+- "Use Gemini CLI for this task in a thread, then keep follow-ups in that same thread."
+
+What OpenClaw should do:
+
+1. Pick `runtime: "acp"`.
+2. Resolve the requested harness target (`agentId`, for example `codex`).
+3. If thread binding is requested and the current channel supports it, bind the ACP session to the thread.
+4. Route follow-up thread messages to that same ACP session until unfocused/closed/expired.
+
+## ACP versus sub-agents
+
+Use ACP when you want an external harness runtime. Use sub-agents when you want OpenClaw-native delegated runs.
+
+| Area | ACP session | Sub-agent run |
+| ------------- | ------------------------------------- | ---------------------------------- |
+| Runtime | ACP backend plugin (for example acpx) | OpenClaw native sub-agent runtime |
+| Session key | `agent::acp:` | `agent::subagent:` |
+| Main commands | `/acp ...` | `/subagents ...` |
+| Spawn tool | `sessions_spawn` with `runtime:"acp"` | `sessions_spawn` (default runtime) |
+
+See also [Sub-agents](/tools/subagents).
+
+## Thread-bound sessions (channel-agnostic)
+
+When thread bindings are enabled for a channel adapter, ACP sessions can be bound to threads:
+
+- OpenClaw binds a thread to a target ACP session.
+- Follow-up messages in that thread route to the bound ACP session.
+- ACP output is delivered back to the same thread.
+- Unfocus/close/archive/TTL expiry removes the binding.
+
+Thread binding support is adapter-specific. If the active channel adapter does not support thread bindings, OpenClaw returns a clear unsupported/unavailable message.
+
+Required feature flags for thread-bound ACP:
+
+- `acp.enabled=true`
+- `acp.dispatch.enabled=true`
+- Channel-adapter ACP thread-spawn flag enabled (adapter-specific)
+ - Discord: `channels.discord.threadBindings.spawnAcpSessions=true`
+
+### Thread supporting channels
+
+- Any channel adapter that exposes session/thread binding capability.
+- Current built-in support: Discord.
+- Plugin channels can add support through the same binding interface.
+
+## Start ACP sessions (interfaces)
+
+### From `sessions_spawn`
+
+Use `runtime: "acp"` to start an ACP session from an agent turn or tool call.
+
+```json
+{
+ "task": "Open the repo and summarize failing tests",
+ "runtime": "acp",
+ "agentId": "codex",
+ "thread": true,
+ "mode": "session"
+}
+```
+
+Notes:
+
+- `runtime` defaults to `subagent`, so set `runtime: "acp"` explicitly for ACP sessions.
+- If `agentId` is omitted, OpenClaw uses `acp.defaultAgent` when configured.
+- `mode: "session"` requires `thread: true` to keep a persistent bound conversation.
+
+Interface details:
+
+- `task` (required): initial prompt sent to the ACP session.
+- `runtime` (required for ACP): must be `"acp"`.
+- `agentId` (optional): ACP target harness id. Falls back to `acp.defaultAgent` if set.
+- `thread` (optional, default `false`): request thread binding flow where supported.
+- `mode` (optional): `run` (one-shot) or `session` (persistent).
+ - default is `run`
+ - if `thread: true` and mode omitted, OpenClaw may default to persistent behavior per runtime path
+ - `mode: "session"` requires `thread: true`
+- `cwd` (optional): requested runtime working directory (validated by backend/runtime policy).
+- `label` (optional): operator-facing label used in session/banner text.
+
+### From `/acp` command
+
+Use `/acp spawn` for explicit operator control from chat when needed.
+
+```text
+/acp spawn codex --mode persistent --thread auto
+/acp spawn codex --mode oneshot --thread off
+/acp spawn codex --thread here
+```
+
+Key flags:
+
+- `--mode persistent|oneshot`
+- `--thread auto|here|off`
+- `--cwd `
+- `--label `
+
+See [Slash Commands](/tools/slash-commands).
+
+## ACP controls
+
+Available command family:
+
+- `/acp spawn`
+- `/acp cancel`
+- `/acp steer`
+- `/acp close`
+- `/acp status`
+- `/acp set-mode`
+- `/acp set`
+- `/acp cwd`
+- `/acp permissions`
+- `/acp timeout`
+- `/acp model`
+- `/acp reset-options`
+- `/acp sessions`
+- `/acp doctor`
+- `/acp install`
+
+`/acp status` shows the effective runtime options and, when available, both runtime-level and backend-level session identifiers.
+
+Some controls depend on backend capabilities. If a backend does not support a control, OpenClaw returns a clear unsupported-control error.
+
+## acpx harness support (current)
+
+Current acpx built-in harness aliases:
+
+- `pi`
+- `claude`
+- `codex`
+- `opencode`
+- `gemini`
+
+When OpenClaw uses the acpx backend, prefer these values for `agentId` unless your acpx config defines custom agent aliases.
+
+Direct acpx CLI usage can also target arbitrary adapters via `--agent `, but that raw escape hatch is an acpx CLI feature (not the normal OpenClaw `agentId` path).
+
+## Required config
+
+Core ACP baseline:
+
+```json5
+{
+ acp: {
+ enabled: true,
+ dispatch: { enabled: true },
+ backend: "acpx",
+ defaultAgent: "codex",
+ allowedAgents: ["pi", "claude", "codex", "opencode", "gemini"],
+ maxConcurrentSessions: 8,
+ stream: {
+ coalesceIdleMs: 300,
+ maxChunkChars: 1200,
+ },
+ runtime: {
+ ttlMinutes: 120,
+ },
+ },
+}
+```
+
+Thread binding config is channel-adapter specific. Example for Discord:
+
+```json5
+{
+ session: {
+ threadBindings: {
+ enabled: true,
+ ttlHours: 24,
+ },
+ },
+ channels: {
+ discord: {
+ threadBindings: {
+ enabled: true,
+ spawnAcpSessions: true,
+ },
+ },
+ },
+}
+```
+
+If thread-bound ACP spawn does not work, verify the adapter feature flag first:
+
+- Discord: `channels.discord.threadBindings.spawnAcpSessions=true`
+
+See [Configuration Reference](/gateway/configuration-reference).
+
+## Plugin setup for acpx backend
+
+Install and enable plugin:
+
+```bash
+openclaw plugins install @openclaw/acpx
+openclaw config set plugins.entries.acpx.enabled true
+```
+
+Local workspace install during development:
+
+```bash
+openclaw plugins install ./extensions/acpx
+```
+
+Then verify backend health:
+
+```text
+/acp doctor
+```
+
+### Pinned acpx install strategy (current behavior)
+
+`@openclaw/acpx` now enforces a strict plugin-local pinning model:
+
+1. The extension pins an exact acpx dependency in `extensions/acpx/package.json`.
+2. Runtime command is fixed to the plugin-local binary (`extensions/acpx/node_modules/.bin/acpx`), not global `PATH`.
+3. Plugin config does not expose `command` or `commandArgs`, so runtime command drift is blocked.
+4. Startup registers the ACP backend immediately as not-ready.
+5. A background ensure job verifies `acpx --version` against the pinned version.
+6. If missing/mismatched, it runs plugin-local install (`npm install --omit=dev --no-save acpx@`) and re-verifies before healthy.
+
+Notes:
+
+- OpenClaw startup stays non-blocking while acpx ensure runs.
+- If network/install fails, backend remains unavailable and `/acp doctor` reports an actionable fix.
+
+See [Plugins](/tools/plugin).
+
+## Troubleshooting
+
+- Error: `ACP runtime backend is not configured`
+ Install and enable the configured backend plugin, then run `/acp doctor`.
+
+- Error: ACP dispatch disabled
+ Enable `acp.dispatch.enabled=true`.
+
+- Error: target agent not allowed
+ Pass an allowed `agentId` or update `acp.allowedAgents`.
+
+- Error: thread binding unavailable on this channel
+ Use a channel adapter that supports thread bindings, or run ACP in non-thread mode.
+
+- Error: missing ACP metadata for a bound session
+ Recreate the session with `/acp spawn` (or `sessions_spawn` with `runtime:"acp"`) and rebind the thread.
diff --git a/docs/tools/index.md b/docs/tools/index.md
index 269b6856d038..fa35a63cb7bd 100644
--- a/docs/tools/index.md
+++ b/docs/tools/index.md
@@ -464,7 +464,7 @@ Core parameters:
- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none)
- `sessions_history`: `sessionKey` (or `sessionId`), `limit?`, `includeTools?`
- `sessions_send`: `sessionKey` (or `sessionId`), `message`, `timeoutSeconds?` (0 = fire-and-forget)
-- `sessions_spawn`: `task`, `label?`, `agentId?`, `model?`, `thinking?`, `runTimeoutSeconds?`, `thread?`, `mode?`, `cleanup?`
+- `sessions_spawn`: `task`, `label?`, `runtime?`, `agentId?`, `model?`, `thinking?`, `cwd?`, `runTimeoutSeconds?`, `thread?`, `mode?`, `cleanup?`
- `session_status`: `sessionKey?` (default current; accepts `sessionId`), `model?` (`default` clears override)
Notes:
@@ -474,6 +474,7 @@ Notes:
- Session targeting is controlled by `tools.sessions.visibility` (default `tree`: current session + spawned subagent sessions). If you run a shared agent for multiple users, consider setting `tools.sessions.visibility: "self"` to prevent cross-session browsing.
- `sessions_send` waits for final completion when `timeoutSeconds > 0`.
- Delivery/announce happens after completion and is best-effort; `status: "ok"` confirms the agent run finished, not that the announce was delivered.
+- `sessions_spawn` supports `runtime: "subagent" | "acp"` (`subagent` default). For ACP runtime behavior, see [ACP Agents](/tools/acp-agents).
- `sessions_spawn` starts a sub-agent run and posts an announce reply back to the requester chat.
- Supports one-shot mode (`mode: "run"`) and persistent thread-bound mode (`mode: "session"` with `thread: true`).
- If `thread: true` and `mode` is omitted, mode defaults to `session`.
diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md
index 9250501f2d9e..3dc575088eb9 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/docs/tools/slash-commands.md b/docs/tools/slash-commands.md
index 86dd32a83c8d..4d045d4ee716 100644
--- a/docs/tools/slash-commands.md
+++ b/docs/tools/slash-commands.md
@@ -80,6 +80,7 @@ Text + native (when enabled):
- `/whoami` (show your sender id; alias: `/id`)
- `/session ttl ` (manage session-level settings, such as TTL)
- `/subagents list|kill|log|info|send|steer|spawn` (inspect, control, or spawn sub-agent runs for the current session)
+- `/acp spawn|cancel|steer|close|status|set-mode|set|cwd|permissions|timeout|model|reset-options|doctor|install|sessions` (inspect and control ACP runtime sessions)
- `/agents` (list thread-bound agents for this session)
- `/focus ` (Discord: bind this thread, or a new thread, to a session/subagent target)
- `/unfocus` (Discord: remove the current thread binding)
@@ -125,6 +126,7 @@ Notes:
- `/restart` is enabled by default; set `commands.restart: false` to disable it.
- Discord-only native command: `/vc join|leave|status` controls voice channels (requires `channels.discord.voice` and native commands; not available as text).
- Discord thread-binding commands (`/focus`, `/unfocus`, `/agents`, `/session ttl`) require effective thread bindings to be enabled (`session.threadBindings.enabled` and/or `channels.discord.threadBindings.enabled`).
+- ACP command reference and runtime behavior: [ACP Agents](/tools/acp-agents).
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
- Tool failure summaries are still shown when relevant, but detailed failure text is only included when `/verbose` is `on` or `full`.
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.
diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md
index 9542858c8402..8d066a94e7f3 100644
--- a/docs/tools/subagents.md
+++ b/docs/tools/subagents.md
@@ -51,6 +51,7 @@ These commands work on channels that support persistent thread bindings. See **T
- `--model` and `--thinking` override defaults for that specific run.
- Use `info`/`log` to inspect details and output after completion.
- `/subagents spawn` is one-shot mode (`mode: "run"`). For persistent thread-bound sessions, use `sessions_spawn` with `thread: true` and `mode: "session"`.
+- For ACP harness sessions (Codex, Claude Code, Gemini CLI), use `sessions_spawn` with `runtime: "acp"` and see [ACP Agents](/tools/acp-agents).
Primary goals:
diff --git a/extensions/acpx/index.ts b/extensions/acpx/index.ts
new file mode 100644
index 000000000000..5f57e396f801
--- /dev/null
+++ b/extensions/acpx/index.ts
@@ -0,0 +1,19 @@
+import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
+import { createAcpxPluginConfigSchema } from "./src/config.js";
+import { createAcpxRuntimeService } from "./src/service.js";
+
+const plugin = {
+ id: "acpx",
+ name: "ACPX Runtime",
+ description: "ACP runtime backend powered by the acpx CLI.",
+ configSchema: createAcpxPluginConfigSchema(),
+ register(api: OpenClawPluginApi) {
+ api.registerService(
+ createAcpxRuntimeService({
+ pluginConfig: api.pluginConfig,
+ }),
+ );
+ },
+};
+
+export default plugin;
diff --git a/extensions/acpx/openclaw.plugin.json b/extensions/acpx/openclaw.plugin.json
new file mode 100644
index 000000000000..61790e6ca05d
--- /dev/null
+++ b/extensions/acpx/openclaw.plugin.json
@@ -0,0 +1,55 @@
+{
+ "id": "acpx",
+ "name": "ACPX Runtime",
+ "description": "ACP runtime backend powered by a pinned plugin-local acpx CLI.",
+ "skills": ["./skills"],
+ "configSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "cwd": {
+ "type": "string"
+ },
+ "permissionMode": {
+ "type": "string",
+ "enum": ["approve-all", "approve-reads", "deny-all"]
+ },
+ "nonInteractivePermissions": {
+ "type": "string",
+ "enum": ["deny", "fail"]
+ },
+ "timeoutSeconds": {
+ "type": "number",
+ "minimum": 0.001
+ },
+ "queueOwnerTtlSeconds": {
+ "type": "number",
+ "minimum": 0
+ }
+ }
+ },
+ "uiHints": {
+ "cwd": {
+ "label": "Default Working Directory",
+ "help": "Default cwd for ACP session operations when not set per session."
+ },
+ "permissionMode": {
+ "label": "Permission Mode",
+ "help": "Default acpx permission policy for runtime prompts."
+ },
+ "nonInteractivePermissions": {
+ "label": "Non-Interactive Permission Policy",
+ "help": "acpx policy when interactive permission prompts are unavailable."
+ },
+ "timeoutSeconds": {
+ "label": "Prompt Timeout Seconds",
+ "help": "Optional acpx timeout for each runtime turn.",
+ "advanced": true
+ },
+ "queueOwnerTtlSeconds": {
+ "label": "Queue Owner TTL Seconds",
+ "help": "Idle queue-owner TTL for acpx prompt turns. Keep this short in OpenClaw to avoid delayed completion after each turn.",
+ "advanced": true
+ }
+ }
+}
diff --git a/extensions/acpx/package.json b/extensions/acpx/package.json
new file mode 100644
index 000000000000..7f77d8a04ac5
--- /dev/null
+++ b/extensions/acpx/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@openclaw/acpx",
+ "version": "2026.2.22",
+ "description": "OpenClaw ACP runtime backend via acpx",
+ "type": "module",
+ "dependencies": {
+ "acpx": "^0.1.13"
+ },
+ "openclaw": {
+ "extensions": [
+ "./index.ts"
+ ]
+ }
+}
diff --git a/extensions/acpx/skills/acp-router/SKILL.md b/extensions/acpx/skills/acp-router/SKILL.md
new file mode 100644
index 000000000000..c80978fa8ae6
--- /dev/null
+++ b/extensions/acpx/skills/acp-router/SKILL.md
@@ -0,0 +1,209 @@
+---
+name: acp-router
+description: Route plain-language requests for Pi, Claude Code, Codex, OpenCode, Gemini CLI, or ACP harness work into either OpenClaw ACP runtime sessions or direct acpx-driven sessions ("telephone game" flow).
+user-invocable: false
+---
+
+# ACP Harness Router
+
+When user intent is "run this in Pi/Claude Code/Codex/OpenCode/Gemini (ACP harness)", do not use subagent runtime or PTY scraping. Route through ACP-aware flows.
+
+## Intent detection
+
+Trigger this skill when the user asks OpenClaw to:
+
+- run something in Pi / Claude Code / Codex / OpenCode / Gemini
+- continue existing harness work
+- relay instructions to an external coding harness
+- keep an external harness conversation in a thread-like conversation
+
+## Mode selection
+
+Choose one of these paths:
+
+1. OpenClaw ACP runtime path (default): use `sessions_spawn` / ACP runtime tools.
+2. Direct `acpx` path (telephone game): use `acpx` CLI through `exec` to drive the harness session directly.
+
+Use direct `acpx` when one of these is true:
+
+- user explicitly asks for direct `acpx` driving
+- ACP runtime/plugin path is unavailable or unhealthy
+- the task is "just relay prompts to harness" and no OpenClaw ACP lifecycle features are needed
+
+Do not use:
+
+- `subagents` runtime for harness control
+- `/acp` command delegation as a requirement for the user
+- PTY scraping of pi/claude/codex/opencode/gemini CLIs when `acpx` is available
+
+## AgentId mapping
+
+Use these defaults when user names a harness directly:
+
+- "pi" -> `agentId: "pi"`
+- "claude" or "claude code" -> `agentId: "claude"`
+- "codex" -> `agentId: "codex"`
+- "opencode" -> `agentId: "opencode"`
+- "gemini" or "gemini cli" -> `agentId: "gemini"`
+
+These defaults match current acpx built-in aliases.
+
+If policy rejects the chosen id, report the policy error clearly and ask for the allowed ACP agent id.
+
+## OpenClaw ACP runtime path
+
+Required behavior:
+
+1. Use `sessions_spawn` with:
+ - `runtime: "acp"`
+ - `thread: true`
+ - `mode: "session"` (unless user explicitly wants one-shot)
+2. Put requested work in `task` so the ACP session gets it immediately.
+3. Set `agentId` explicitly unless ACP default agent is known.
+4. Do not ask user to run slash commands or CLI when this path works directly.
+
+Example:
+
+User: "spawn a test codex session in thread and tell it to say hi"
+
+Call:
+
+```json
+{
+ "task": "Say hi.",
+ "runtime": "acp",
+ "agentId": "codex",
+ "thread": true,
+ "mode": "session"
+}
+```
+
+## Thread spawn recovery policy
+
+When the user asks to start a coding harness in a thread (for example "start a codex/claude/pi thread"), treat that as an ACP runtime request and try to satisfy it end-to-end.
+
+Required behavior when ACP backend is unavailable:
+
+1. Do not immediately ask the user to pick an alternate path.
+2. First attempt automatic local repair:
+ - ensure plugin-local pinned acpx is installed in `extensions/acpx`
+ - verify `${ACPX_CMD} --version`
+3. After reinstall/repair, restart the gateway and explicitly offer to run that restart for the user.
+4. Retry ACP thread spawn once after repair.
+5. Only if repair+retry fails, report the concrete error and then offer fallback options.
+
+When offering fallback, keep ACP first:
+
+- Option 1: retry ACP spawn after showing exact failing step
+- Option 2: direct acpx telephone-game flow
+
+Do not default to subagent runtime for these requests.
+
+## ACPX install and version policy (direct acpx path)
+
+For this repo, direct `acpx` calls must follow the same pinned policy as the `@openclaw/acpx` extension.
+
+1. Prefer plugin-local binary, not global PATH:
+ - `./extensions/acpx/node_modules/.bin/acpx`
+2. Resolve pinned version from extension dependency:
+ - `node -e "console.log(require('./extensions/acpx/package.json').dependencies.acpx)"`
+3. If binary is missing or version mismatched, install plugin-local pinned version:
+ - `cd extensions/acpx && npm install --omit=dev --no-save acpx@`
+4. Verify before use:
+ - `./extensions/acpx/node_modules/.bin/acpx --version`
+5. If install/repair changed ACPX artifacts, restart the gateway and offer to run the restart.
+6. Do not run `npm install -g acpx` unless the user explicitly asks for global install.
+
+Set and reuse:
+
+```bash
+ACPX_CMD="./extensions/acpx/node_modules/.bin/acpx"
+```
+
+## Direct acpx path ("telephone game")
+
+Use this path to drive harness sessions without `/acp` or subagent runtime.
+
+### Rules
+
+1. Use `exec` commands that call `${ACPX_CMD}`.
+2. Reuse a stable session name per conversation so follow-up prompts stay in the same harness context.
+3. Prefer `--format quiet` for clean assistant text to relay back to user.
+4. Use `exec` (one-shot) only when the user wants one-shot behavior.
+5. Keep working directory explicit (`--cwd`) when task scope depends on repo context.
+
+### Session naming
+
+Use a deterministic name, for example:
+
+- `oc--`
+
+Where `conversationId` is thread id when available, otherwise channel/conversation id.
+
+### Command templates
+
+Persistent session (create if missing, then prompt):
+
+```bash
+${ACPX_CMD} codex sessions show oc-codex- \
+ || ${ACPX_CMD} codex sessions new --name oc-codex-
+
+${ACPX_CMD} codex -s oc-codex- --cwd --format quiet ""
+```
+
+One-shot:
+
+```bash
+${ACPX_CMD} codex exec --cwd --format quiet ""
+```
+
+Cancel in-flight turn:
+
+```bash
+${ACPX_CMD} codex cancel -s oc-codex-
+```
+
+Close session:
+
+```bash
+${ACPX_CMD} codex sessions close oc-codex-
+```
+
+### Harness aliases in acpx
+
+- `pi`
+- `claude`
+- `codex`
+- `opencode`
+- `gemini`
+
+### Built-in adapter commands in acpx
+
+Defaults are:
+
+- `pi -> npx pi-acp`
+- `claude -> npx -y @zed-industries/claude-agent-acp`
+- `codex -> npx @zed-industries/codex-acp`
+- `opencode -> npx -y opencode-ai acp`
+- `gemini -> gemini`
+
+If `~/.acpx/config.json` overrides `agents`, those overrides replace defaults.
+
+### Failure handling
+
+- `acpx: command not found`:
+ - for thread-spawn ACP requests, install plugin-local pinned acpx in `extensions/acpx` immediately
+ - restart gateway after install and offer to run the restart automatically
+ - then retry once
+ - do not ask for install permission first unless policy explicitly requires it
+ - do not install global `acpx` unless explicitly requested
+- adapter command missing (for example `claude-agent-acp` not found):
+ - for thread-spawn ACP requests, first restore built-in defaults by removing broken `~/.acpx/config.json` agent overrides
+ - then retry once before offering fallback
+ - if user wants binary-based overrides, install exactly the configured adapter binary
+- `NO_SESSION`: run `${ACPX_CMD} sessions new --name ` then retry prompt.
+- queue busy: either wait for completion (default) or use `--no-wait` when async behavior is explicitly desired.
+
+### Output relay
+
+When relaying to user, return the final assistant text output from `acpx` command result. Avoid relaying raw local tool noise unless user asked for verbose logs.
diff --git a/extensions/acpx/src/config.test.ts b/extensions/acpx/src/config.test.ts
new file mode 100644
index 000000000000..efd6d5c7e730
--- /dev/null
+++ b/extensions/acpx/src/config.test.ts
@@ -0,0 +1,53 @@
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+import {
+ ACPX_BUNDLED_BIN,
+ createAcpxPluginConfigSchema,
+ resolveAcpxPluginConfig,
+} from "./config.js";
+
+describe("acpx plugin config parsing", () => {
+ it("resolves a strict plugin-local acpx command", () => {
+ const resolved = resolveAcpxPluginConfig({
+ rawConfig: {
+ cwd: "/tmp/workspace",
+ },
+ workspaceDir: "/tmp/workspace",
+ });
+
+ expect(resolved.command).toBe(ACPX_BUNDLED_BIN);
+ expect(resolved.cwd).toBe(path.resolve("/tmp/workspace"));
+ });
+
+ it("rejects command overrides", () => {
+ expect(() =>
+ resolveAcpxPluginConfig({
+ rawConfig: {
+ command: "acpx-custom",
+ },
+ workspaceDir: "/tmp/workspace",
+ }),
+ ).toThrow("unknown config key: command");
+ });
+
+ it("rejects commandArgs overrides", () => {
+ expect(() =>
+ resolveAcpxPluginConfig({
+ rawConfig: {
+ commandArgs: ["--foo"],
+ },
+ workspaceDir: "/tmp/workspace",
+ }),
+ ).toThrow("unknown config key: commandArgs");
+ });
+
+ it("schema rejects empty cwd", () => {
+ const schema = createAcpxPluginConfigSchema();
+ if (!schema.safeParse) {
+ throw new Error("acpx config schema missing safeParse");
+ }
+ const parsed = schema.safeParse({ cwd: " " });
+
+ expect(parsed.success).toBe(false);
+ });
+});
diff --git a/extensions/acpx/src/config.ts b/extensions/acpx/src/config.ts
new file mode 100644
index 000000000000..bf5d0e0993ee
--- /dev/null
+++ b/extensions/acpx/src/config.ts
@@ -0,0 +1,196 @@
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk";
+
+export const ACPX_PERMISSION_MODES = ["approve-all", "approve-reads", "deny-all"] as const;
+export type AcpxPermissionMode = (typeof ACPX_PERMISSION_MODES)[number];
+
+export const ACPX_NON_INTERACTIVE_POLICIES = ["deny", "fail"] as const;
+export type AcpxNonInteractivePermissionPolicy = (typeof ACPX_NON_INTERACTIVE_POLICIES)[number];
+
+export const ACPX_PINNED_VERSION = "0.1.13";
+const ACPX_BIN_NAME = process.platform === "win32" ? "acpx.cmd" : "acpx";
+export const ACPX_PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+export const ACPX_BUNDLED_BIN = path.join(ACPX_PLUGIN_ROOT, "node_modules", ".bin", ACPX_BIN_NAME);
+export const ACPX_LOCAL_INSTALL_COMMAND = `npm install --omit=dev --no-save acpx@${ACPX_PINNED_VERSION}`;
+
+export type AcpxPluginConfig = {
+ cwd?: string;
+ permissionMode?: AcpxPermissionMode;
+ nonInteractivePermissions?: AcpxNonInteractivePermissionPolicy;
+ timeoutSeconds?: number;
+ queueOwnerTtlSeconds?: number;
+};
+
+export type ResolvedAcpxPluginConfig = {
+ command: string;
+ cwd: string;
+ permissionMode: AcpxPermissionMode;
+ nonInteractivePermissions: AcpxNonInteractivePermissionPolicy;
+ timeoutSeconds?: number;
+ queueOwnerTtlSeconds: number;
+};
+
+const DEFAULT_PERMISSION_MODE: AcpxPermissionMode = "approve-reads";
+const DEFAULT_NON_INTERACTIVE_POLICY: AcpxNonInteractivePermissionPolicy = "fail";
+const DEFAULT_QUEUE_OWNER_TTL_SECONDS = 0.1;
+
+type ParseResult =
+ | { ok: true; value: AcpxPluginConfig | undefined }
+ | { ok: false; message: string };
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function isPermissionMode(value: string): value is AcpxPermissionMode {
+ return ACPX_PERMISSION_MODES.includes(value as AcpxPermissionMode);
+}
+
+function isNonInteractivePermissionPolicy(
+ value: string,
+): value is AcpxNonInteractivePermissionPolicy {
+ return ACPX_NON_INTERACTIVE_POLICIES.includes(value as AcpxNonInteractivePermissionPolicy);
+}
+
+function parseAcpxPluginConfig(value: unknown): ParseResult {
+ if (value === undefined) {
+ return { ok: true, value: undefined };
+ }
+ if (!isRecord(value)) {
+ return { ok: false, message: "expected config object" };
+ }
+ const allowedKeys = new Set([
+ "cwd",
+ "permissionMode",
+ "nonInteractivePermissions",
+ "timeoutSeconds",
+ "queueOwnerTtlSeconds",
+ ]);
+ for (const key of Object.keys(value)) {
+ if (!allowedKeys.has(key)) {
+ return { ok: false, message: `unknown config key: ${key}` };
+ }
+ }
+
+ const cwd = value.cwd;
+ if (cwd !== undefined && (typeof cwd !== "string" || cwd.trim() === "")) {
+ return { ok: false, message: "cwd must be a non-empty string" };
+ }
+
+ const permissionMode = value.permissionMode;
+ if (
+ permissionMode !== undefined &&
+ (typeof permissionMode !== "string" || !isPermissionMode(permissionMode))
+ ) {
+ return {
+ ok: false,
+ message: `permissionMode must be one of: ${ACPX_PERMISSION_MODES.join(", ")}`,
+ };
+ }
+
+ const nonInteractivePermissions = value.nonInteractivePermissions;
+ if (
+ nonInteractivePermissions !== undefined &&
+ (typeof nonInteractivePermissions !== "string" ||
+ !isNonInteractivePermissionPolicy(nonInteractivePermissions))
+ ) {
+ return {
+ ok: false,
+ message: `nonInteractivePermissions must be one of: ${ACPX_NON_INTERACTIVE_POLICIES.join(", ")}`,
+ };
+ }
+
+ const timeoutSeconds = value.timeoutSeconds;
+ if (
+ timeoutSeconds !== undefined &&
+ (typeof timeoutSeconds !== "number" || !Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0)
+ ) {
+ return { ok: false, message: "timeoutSeconds must be a positive number" };
+ }
+
+ const queueOwnerTtlSeconds = value.queueOwnerTtlSeconds;
+ if (
+ queueOwnerTtlSeconds !== undefined &&
+ (typeof queueOwnerTtlSeconds !== "number" ||
+ !Number.isFinite(queueOwnerTtlSeconds) ||
+ queueOwnerTtlSeconds < 0)
+ ) {
+ return { ok: false, message: "queueOwnerTtlSeconds must be a non-negative number" };
+ }
+
+ return {
+ ok: true,
+ value: {
+ cwd: typeof cwd === "string" ? cwd.trim() : undefined,
+ permissionMode: typeof permissionMode === "string" ? permissionMode : undefined,
+ nonInteractivePermissions:
+ typeof nonInteractivePermissions === "string" ? nonInteractivePermissions : undefined,
+ timeoutSeconds: typeof timeoutSeconds === "number" ? timeoutSeconds : undefined,
+ queueOwnerTtlSeconds:
+ typeof queueOwnerTtlSeconds === "number" ? queueOwnerTtlSeconds : undefined,
+ },
+ };
+}
+
+export function createAcpxPluginConfigSchema(): OpenClawPluginConfigSchema {
+ return {
+ safeParse(value: unknown):
+ | { success: true; data?: unknown }
+ | {
+ success: false;
+ error: { issues: Array<{ path: Array; message: string }> };
+ } {
+ const parsed = parseAcpxPluginConfig(value);
+ if (parsed.ok) {
+ return { success: true, data: parsed.value };
+ }
+ return {
+ success: false,
+ error: {
+ issues: [{ path: [], message: parsed.message }],
+ },
+ };
+ },
+ jsonSchema: {
+ type: "object",
+ additionalProperties: false,
+ properties: {
+ cwd: { type: "string" },
+ permissionMode: {
+ type: "string",
+ enum: [...ACPX_PERMISSION_MODES],
+ },
+ nonInteractivePermissions: {
+ type: "string",
+ enum: [...ACPX_NON_INTERACTIVE_POLICIES],
+ },
+ timeoutSeconds: { type: "number", minimum: 0.001 },
+ queueOwnerTtlSeconds: { type: "number", minimum: 0 },
+ },
+ },
+ };
+}
+
+export function resolveAcpxPluginConfig(params: {
+ rawConfig: unknown;
+ workspaceDir?: string;
+}): ResolvedAcpxPluginConfig {
+ const parsed = parseAcpxPluginConfig(params.rawConfig);
+ if (!parsed.ok) {
+ throw new Error(parsed.message);
+ }
+ const normalized = parsed.value ?? {};
+ const fallbackCwd = params.workspaceDir?.trim() || process.cwd();
+ const cwd = path.resolve(normalized.cwd?.trim() || fallbackCwd);
+
+ return {
+ command: ACPX_BUNDLED_BIN,
+ cwd,
+ permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE,
+ nonInteractivePermissions:
+ normalized.nonInteractivePermissions ?? DEFAULT_NON_INTERACTIVE_POLICY,
+ timeoutSeconds: normalized.timeoutSeconds,
+ queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS,
+ };
+}
diff --git a/extensions/acpx/src/ensure.test.ts b/extensions/acpx/src/ensure.test.ts
new file mode 100644
index 000000000000..0b36c3def365
--- /dev/null
+++ b/extensions/acpx/src/ensure.test.ts
@@ -0,0 +1,125 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { ACPX_LOCAL_INSTALL_COMMAND, ACPX_PINNED_VERSION } from "./config.js";
+
+const { resolveSpawnFailureMock, spawnAndCollectMock } = vi.hoisted(() => ({
+ resolveSpawnFailureMock: vi.fn(() => null),
+ spawnAndCollectMock: vi.fn(),
+}));
+
+vi.mock("./runtime-internals/process.js", () => ({
+ resolveSpawnFailure: resolveSpawnFailureMock,
+ spawnAndCollect: spawnAndCollectMock,
+}));
+
+import { checkPinnedAcpxVersion, ensurePinnedAcpx } from "./ensure.js";
+
+describe("acpx ensure", () => {
+ beforeEach(() => {
+ resolveSpawnFailureMock.mockReset();
+ resolveSpawnFailureMock.mockReturnValue(null);
+ spawnAndCollectMock.mockReset();
+ });
+
+ it("accepts the pinned acpx version", async () => {
+ spawnAndCollectMock.mockResolvedValueOnce({
+ stdout: `acpx ${ACPX_PINNED_VERSION}\n`,
+ stderr: "",
+ code: 0,
+ error: null,
+ });
+
+ const result = await checkPinnedAcpxVersion({
+ command: "/plugin/node_modules/.bin/acpx",
+ cwd: "/plugin",
+ expectedVersion: ACPX_PINNED_VERSION,
+ });
+
+ expect(result).toEqual({
+ ok: true,
+ version: ACPX_PINNED_VERSION,
+ expectedVersion: ACPX_PINNED_VERSION,
+ });
+ });
+
+ it("reports version mismatch", async () => {
+ spawnAndCollectMock.mockResolvedValueOnce({
+ stdout: "acpx 0.0.9\n",
+ stderr: "",
+ code: 0,
+ error: null,
+ });
+
+ const result = await checkPinnedAcpxVersion({
+ command: "/plugin/node_modules/.bin/acpx",
+ cwd: "/plugin",
+ expectedVersion: ACPX_PINNED_VERSION,
+ });
+
+ expect(result).toMatchObject({
+ ok: false,
+ reason: "version-mismatch",
+ expectedVersion: ACPX_PINNED_VERSION,
+ installedVersion: "0.0.9",
+ installCommand: ACPX_LOCAL_INSTALL_COMMAND,
+ });
+ });
+
+ it("installs and verifies pinned acpx when precheck fails", async () => {
+ spawnAndCollectMock
+ .mockResolvedValueOnce({
+ stdout: "acpx 0.0.9\n",
+ stderr: "",
+ code: 0,
+ error: null,
+ })
+ .mockResolvedValueOnce({
+ stdout: "added 1 package\n",
+ stderr: "",
+ code: 0,
+ error: null,
+ })
+ .mockResolvedValueOnce({
+ stdout: `acpx ${ACPX_PINNED_VERSION}\n`,
+ stderr: "",
+ code: 0,
+ error: null,
+ });
+
+ await ensurePinnedAcpx({
+ command: "/plugin/node_modules/.bin/acpx",
+ pluginRoot: "/plugin",
+ expectedVersion: ACPX_PINNED_VERSION,
+ });
+
+ expect(spawnAndCollectMock).toHaveBeenCalledTimes(3);
+ expect(spawnAndCollectMock.mock.calls[1]?.[0]).toMatchObject({
+ command: "npm",
+ args: ["install", "--omit=dev", "--no-save", `acpx@${ACPX_PINNED_VERSION}`],
+ cwd: "/plugin",
+ });
+ });
+
+ it("fails with actionable error when npm install fails", async () => {
+ spawnAndCollectMock
+ .mockResolvedValueOnce({
+ stdout: "acpx 0.0.9\n",
+ stderr: "",
+ code: 0,
+ error: null,
+ })
+ .mockResolvedValueOnce({
+ stdout: "",
+ stderr: "network down",
+ code: 1,
+ error: null,
+ });
+
+ await expect(
+ ensurePinnedAcpx({
+ command: "/plugin/node_modules/.bin/acpx",
+ pluginRoot: "/plugin",
+ expectedVersion: ACPX_PINNED_VERSION,
+ }),
+ ).rejects.toThrow("failed to install plugin-local acpx");
+ });
+});
diff --git a/extensions/acpx/src/ensure.ts b/extensions/acpx/src/ensure.ts
new file mode 100644
index 000000000000..6bb015587ae7
--- /dev/null
+++ b/extensions/acpx/src/ensure.ts
@@ -0,0 +1,169 @@
+import type { PluginLogger } from "openclaw/plugin-sdk";
+import { ACPX_LOCAL_INSTALL_COMMAND, ACPX_PINNED_VERSION, ACPX_PLUGIN_ROOT } from "./config.js";
+import { resolveSpawnFailure, spawnAndCollect } from "./runtime-internals/process.js";
+
+const SEMVER_PATTERN = /\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b/;
+
+export type AcpxVersionCheckResult =
+ | {
+ ok: true;
+ version: string;
+ expectedVersion: string;
+ }
+ | {
+ ok: false;
+ reason: "missing-command" | "missing-version" | "version-mismatch" | "execution-failed";
+ message: string;
+ expectedVersion: string;
+ installCommand: string;
+ installedVersion?: string;
+ };
+
+function extractVersion(stdout: string, stderr: string): string | null {
+ const combined = `${stdout}\n${stderr}`;
+ const match = combined.match(SEMVER_PATTERN);
+ return match?.[0] ?? null;
+}
+
+export async function checkPinnedAcpxVersion(params: {
+ command: string;
+ cwd?: string;
+ expectedVersion?: string;
+}): Promise {
+ const expectedVersion = params.expectedVersion ?? ACPX_PINNED_VERSION;
+ const cwd = params.cwd ?? ACPX_PLUGIN_ROOT;
+ const result = await spawnAndCollect({
+ command: params.command,
+ args: ["--version"],
+ cwd,
+ });
+
+ if (result.error) {
+ const spawnFailure = resolveSpawnFailure(result.error, cwd);
+ if (spawnFailure === "missing-command") {
+ return {
+ ok: false,
+ reason: "missing-command",
+ message: `acpx command not found at ${params.command}`,
+ expectedVersion,
+ installCommand: ACPX_LOCAL_INSTALL_COMMAND,
+ };
+ }
+ return {
+ ok: false,
+ reason: "execution-failed",
+ message: result.error.message,
+ expectedVersion,
+ installCommand: ACPX_LOCAL_INSTALL_COMMAND,
+ };
+ }
+
+ if ((result.code ?? 0) !== 0) {
+ const stderr = result.stderr.trim();
+ return {
+ ok: false,
+ reason: "execution-failed",
+ message: stderr || `acpx --version failed with code ${result.code ?? "unknown"}`,
+ expectedVersion,
+ installCommand: ACPX_LOCAL_INSTALL_COMMAND,
+ };
+ }
+
+ const installedVersion = extractVersion(result.stdout, result.stderr);
+ if (!installedVersion) {
+ return {
+ ok: false,
+ reason: "missing-version",
+ message: "acpx --version output did not include a parseable version",
+ expectedVersion,
+ installCommand: ACPX_LOCAL_INSTALL_COMMAND,
+ };
+ }
+
+ if (installedVersion !== expectedVersion) {
+ return {
+ ok: false,
+ reason: "version-mismatch",
+ message: `acpx version mismatch: found ${installedVersion}, expected ${expectedVersion}`,
+ expectedVersion,
+ installCommand: ACPX_LOCAL_INSTALL_COMMAND,
+ installedVersion,
+ };
+ }
+
+ return {
+ ok: true,
+ version: installedVersion,
+ expectedVersion,
+ };
+}
+
+let pendingEnsure: Promise | null = null;
+
+export async function ensurePinnedAcpx(params: {
+ command: string;
+ logger?: PluginLogger;
+ pluginRoot?: string;
+ expectedVersion?: string;
+}): Promise {
+ if (pendingEnsure) {
+ return await pendingEnsure;
+ }
+
+ pendingEnsure = (async () => {
+ const pluginRoot = params.pluginRoot ?? ACPX_PLUGIN_ROOT;
+ const expectedVersion = params.expectedVersion ?? ACPX_PINNED_VERSION;
+
+ const precheck = await checkPinnedAcpxVersion({
+ command: params.command,
+ cwd: pluginRoot,
+ expectedVersion,
+ });
+ if (precheck.ok) {
+ return;
+ }
+
+ params.logger?.warn(
+ `acpx local binary unavailable or mismatched (${precheck.message}); running plugin-local install`,
+ );
+
+ const install = await spawnAndCollect({
+ command: "npm",
+ args: ["install", "--omit=dev", "--no-save", `acpx@${expectedVersion}`],
+ cwd: pluginRoot,
+ });
+
+ if (install.error) {
+ const spawnFailure = resolveSpawnFailure(install.error, pluginRoot);
+ if (spawnFailure === "missing-command") {
+ throw new Error("npm is required to install plugin-local acpx but was not found on PATH");
+ }
+ throw new Error(`failed to install plugin-local acpx: ${install.error.message}`);
+ }
+
+ if ((install.code ?? 0) !== 0) {
+ const stderr = install.stderr.trim();
+ const stdout = install.stdout.trim();
+ const detail = stderr || stdout || `npm exited with code ${install.code ?? "unknown"}`;
+ throw new Error(`failed to install plugin-local acpx: ${detail}`);
+ }
+
+ const postcheck = await checkPinnedAcpxVersion({
+ command: params.command,
+ cwd: pluginRoot,
+ expectedVersion,
+ });
+
+ if (!postcheck.ok) {
+ throw new Error(`plugin-local acpx verification failed after install: ${postcheck.message}`);
+ }
+
+ params.logger?.info(`acpx plugin-local binary ready (version ${postcheck.version})`);
+ })();
+
+ try {
+ await pendingEnsure;
+ } finally {
+ pendingEnsure = null;
+ }
+}
diff --git a/extensions/acpx/src/runtime-internals/events.ts b/extensions/acpx/src/runtime-internals/events.ts
new file mode 100644
index 000000000000..074787b3fdff
--- /dev/null
+++ b/extensions/acpx/src/runtime-internals/events.ts
@@ -0,0 +1,140 @@
+import type { AcpRuntimeEvent } from "openclaw/plugin-sdk";
+import {
+ asOptionalBoolean,
+ asOptionalString,
+ asString,
+ asTrimmedString,
+ type AcpxErrorEvent,
+ type AcpxJsonObject,
+ isRecord,
+} from "./shared.js";
+
+export function toAcpxErrorEvent(value: unknown): AcpxErrorEvent | null {
+ if (!isRecord(value)) {
+ return null;
+ }
+ if (asTrimmedString(value.type) !== "error") {
+ return null;
+ }
+ return {
+ message: asTrimmedString(value.message) || "acpx reported an error",
+ code: asOptionalString(value.code),
+ retryable: asOptionalBoolean(value.retryable),
+ };
+}
+
+export function parseJsonLines(value: string): AcpxJsonObject[] {
+ const events: AcpxJsonObject[] = [];
+ for (const line of value.split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (!trimmed) {
+ continue;
+ }
+ try {
+ const parsed = JSON.parse(trimmed) as unknown;
+ if (isRecord(parsed)) {
+ events.push(parsed);
+ }
+ } catch {
+ // Ignore malformed lines; callers handle missing typed events via exit code.
+ }
+ }
+ return events;
+}
+
+export function parsePromptEventLine(line: string): AcpRuntimeEvent | null {
+ const trimmed = line.trim();
+ if (!trimmed) {
+ return null;
+ }
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(trimmed);
+ } catch {
+ return {
+ type: "status",
+ text: trimmed,
+ };
+ }
+
+ if (!isRecord(parsed)) {
+ return null;
+ }
+
+ const type = asTrimmedString(parsed.type);
+ switch (type) {
+ case "text": {
+ const content = asString(parsed.content);
+ if (content == null || content.length === 0) {
+ return null;
+ }
+ return {
+ type: "text_delta",
+ text: content,
+ stream: "output",
+ };
+ }
+ case "thought": {
+ const content = asString(parsed.content);
+ if (content == null || content.length === 0) {
+ return null;
+ }
+ return {
+ type: "text_delta",
+ text: content,
+ stream: "thought",
+ };
+ }
+ case "tool_call": {
+ const title = asTrimmedString(parsed.title) || asTrimmedString(parsed.toolCallId) || "tool";
+ const status = asTrimmedString(parsed.status);
+ return {
+ type: "tool_call",
+ text: status ? `${title} (${status})` : title,
+ };
+ }
+ case "client_operation": {
+ const method = asTrimmedString(parsed.method) || "operation";
+ const status = asTrimmedString(parsed.status);
+ const summary = asTrimmedString(parsed.summary);
+ const text = [method, status, summary].filter(Boolean).join(" ");
+ if (!text) {
+ return null;
+ }
+ return { type: "status", text };
+ }
+ case "plan": {
+ const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
+ const first = entries.find((entry) => isRecord(entry)) as Record | undefined;
+ const content = asTrimmedString(first?.content);
+ if (!content) {
+ return null;
+ }
+ return { type: "status", text: `plan: ${content}` };
+ }
+ case "update": {
+ const update = asTrimmedString(parsed.update);
+ if (!update) {
+ return null;
+ }
+ return { type: "status", text: update };
+ }
+ case "done": {
+ return {
+ type: "done",
+ stopReason: asOptionalString(parsed.stopReason),
+ };
+ }
+ case "error": {
+ const message = asTrimmedString(parsed.message) || "acpx runtime error";
+ return {
+ type: "error",
+ message,
+ code: asOptionalString(parsed.code),
+ retryable: asOptionalBoolean(parsed.retryable),
+ };
+ }
+ default:
+ return null;
+ }
+}
diff --git a/extensions/acpx/src/runtime-internals/process.ts b/extensions/acpx/src/runtime-internals/process.ts
new file mode 100644
index 000000000000..752b48835ec7
--- /dev/null
+++ b/extensions/acpx/src/runtime-internals/process.ts
@@ -0,0 +1,137 @@
+import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
+import { existsSync } from "node:fs";
+import path from "node:path";
+
+export type SpawnExit = {
+ code: number | null;
+ signal: NodeJS.Signals | null;
+ error: Error | null;
+};
+
+type ResolvedSpawnCommand = {
+ command: string;
+ args: string[];
+ shell?: boolean;
+};
+
+function resolveSpawnCommand(params: { command: string; args: string[] }): ResolvedSpawnCommand {
+ if (process.platform !== "win32") {
+ return { command: params.command, args: params.args };
+ }
+
+ const extension = path.extname(params.command).toLowerCase();
+ if (extension === ".js" || extension === ".cjs" || extension === ".mjs") {
+ return {
+ command: process.execPath,
+ args: [params.command, ...params.args],
+ };
+ }
+
+ if (extension === ".cmd" || extension === ".bat") {
+ return {
+ command: params.command,
+ args: params.args,
+ shell: true,
+ };
+ }
+
+ return {
+ command: params.command,
+ args: params.args,
+ };
+}
+
+export function spawnWithResolvedCommand(params: {
+ command: string;
+ args: string[];
+ cwd: string;
+}): ChildProcessWithoutNullStreams {
+ const resolved = resolveSpawnCommand({
+ command: params.command,
+ args: params.args,
+ });
+
+ return spawn(resolved.command, resolved.args, {
+ cwd: params.cwd,
+ env: process.env,
+ stdio: ["pipe", "pipe", "pipe"],
+ shell: resolved.shell,
+ });
+}
+
+export async function waitForExit(child: ChildProcessWithoutNullStreams): Promise {
+ return await new Promise((resolve) => {
+ let settled = false;
+ const finish = (result: SpawnExit) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ resolve(result);
+ };
+
+ child.once("error", (err) => {
+ finish({ code: null, signal: null, error: err });
+ });
+
+ child.once("close", (code, signal) => {
+ finish({ code, signal, error: null });
+ });
+ });
+}
+
+export async function spawnAndCollect(params: {
+ command: string;
+ args: string[];
+ cwd: string;
+}): Promise<{
+ stdout: string;
+ stderr: string;
+ code: number | null;
+ error: Error | null;
+}> {
+ const child = spawnWithResolvedCommand(params);
+ child.stdin.end();
+
+ let stdout = "";
+ let stderr = "";
+ child.stdout.on("data", (chunk) => {
+ stdout += String(chunk);
+ });
+ child.stderr.on("data", (chunk) => {
+ stderr += String(chunk);
+ });
+
+ const exit = await waitForExit(child);
+ return {
+ stdout,
+ stderr,
+ code: exit.code,
+ error: exit.error,
+ };
+}
+
+export function resolveSpawnFailure(
+ err: unknown,
+ cwd: string,
+): "missing-command" | "missing-cwd" | null {
+ if (!err || typeof err !== "object") {
+ return null;
+ }
+ const code = (err as NodeJS.ErrnoException).code;
+ if (code !== "ENOENT") {
+ return null;
+ }
+ return directoryExists(cwd) ? "missing-command" : "missing-cwd";
+}
+
+function directoryExists(cwd: string): boolean {
+ if (!cwd) {
+ return false;
+ }
+ try {
+ return existsSync(cwd);
+ } catch {
+ return false;
+ }
+}
diff --git a/extensions/acpx/src/runtime-internals/shared.ts b/extensions/acpx/src/runtime-internals/shared.ts
new file mode 100644
index 000000000000..2f9b48025e66
--- /dev/null
+++ b/extensions/acpx/src/runtime-internals/shared.ts
@@ -0,0 +1,56 @@
+import type { ResolvedAcpxPluginConfig } from "../config.js";
+
+export type AcpxHandleState = {
+ name: string;
+ agent: string;
+ cwd: string;
+ mode: "persistent" | "oneshot";
+ acpxRecordId?: string;
+ backendSessionId?: string;
+ agentSessionId?: string;
+};
+
+export type AcpxJsonObject = Record;
+
+export type AcpxErrorEvent = {
+ message: string;
+ code?: string;
+ retryable?: boolean;
+};
+
+export function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+export function asTrimmedString(value: unknown): string {
+ return typeof value === "string" ? value.trim() : "";
+}
+
+export function asString(value: unknown): string | undefined {
+ return typeof value === "string" ? value : undefined;
+}
+
+export function asOptionalString(value: unknown): string | undefined {
+ const text = asTrimmedString(value);
+ return text || undefined;
+}
+
+export function asOptionalBoolean(value: unknown): boolean | undefined {
+ return typeof value === "boolean" ? value : undefined;
+}
+
+export function deriveAgentFromSessionKey(sessionKey: string, fallbackAgent: string): string {
+ const match = sessionKey.match(/^agent:([^:]+):/i);
+ const candidate = match?.[1] ? asTrimmedString(match[1]) : "";
+ return candidate || fallbackAgent;
+}
+
+export function buildPermissionArgs(mode: ResolvedAcpxPluginConfig["permissionMode"]): string[] {
+ if (mode === "approve-all") {
+ return ["--approve-all"];
+ }
+ if (mode === "deny-all") {
+ return ["--deny-all"];
+ }
+ return ["--approve-reads"];
+}
diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts
new file mode 100644
index 000000000000..d5e4fd275c79
--- /dev/null
+++ b/extensions/acpx/src/runtime.test.ts
@@ -0,0 +1,619 @@
+import fs from "node:fs";
+import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js";
+import { ACPX_PINNED_VERSION, type ResolvedAcpxPluginConfig } from "./config.js";
+import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
+
+const NOOP_LOGGER = {
+ info: (_message: string) => {},
+ warn: (_message: string) => {},
+ error: (_message: string) => {},
+ debug: (_message: string) => {},
+};
+
+const MOCK_CLI_SCRIPT = String.raw`#!/usr/bin/env node
+const fs = require("node:fs");
+
+const args = process.argv.slice(2);
+const logPath = process.env.MOCK_ACPX_LOG;
+const writeLog = (entry) => {
+ if (!logPath) return;
+ fs.appendFileSync(logPath, JSON.stringify(entry) + "\n");
+};
+
+if (args.includes("--version")) {
+ process.stdout.write("mock-acpx ${ACPX_PINNED_VERSION}\\n");
+ process.exit(0);
+}
+
+if (args.includes("--help")) {
+ process.stdout.write("mock-acpx help\\n");
+ process.exit(0);
+}
+
+const commandIndex = args.findIndex(
+ (arg) =>
+ arg === "prompt" ||
+ arg === "cancel" ||
+ arg === "sessions" ||
+ arg === "set-mode" ||
+ arg === "set" ||
+ arg === "status",
+);
+const command = commandIndex >= 0 ? args[commandIndex] : "";
+const agent = commandIndex > 0 ? args[commandIndex - 1] : "unknown";
+
+const readFlag = (flag) => {
+ const idx = args.indexOf(flag);
+ if (idx < 0) return "";
+ return String(args[idx + 1] || "");
+};
+
+const sessionFromOption = readFlag("--session");
+const ensureName = readFlag("--name");
+const closeName = command === "sessions" && args[commandIndex + 1] === "close" ? String(args[commandIndex + 2] || "") : "";
+const setModeValue = command === "set-mode" ? String(args[commandIndex + 1] || "") : "";
+const setKey = command === "set" ? String(args[commandIndex + 1] || "") : "";
+const setValue = command === "set" ? String(args[commandIndex + 2] || "") : "";
+
+if (command === "sessions" && args[commandIndex + 1] === "ensure") {
+ writeLog({ kind: "ensure", agent, args, sessionName: ensureName });
+ process.stdout.write(JSON.stringify({
+ type: "session_ensured",
+ acpxRecordId: "rec-" + ensureName,
+ acpxSessionId: "sid-" + ensureName,
+ agentSessionId: "inner-" + ensureName,
+ name: ensureName,
+ created: true,
+ }) + "\n");
+ process.exit(0);
+}
+
+if (command === "cancel") {
+ writeLog({ kind: "cancel", agent, args, sessionName: sessionFromOption });
+ process.stdout.write(JSON.stringify({
+ acpxSessionId: "sid-" + sessionFromOption,
+ cancelled: true,
+ }) + "\n");
+ process.exit(0);
+}
+
+if (command === "set-mode") {
+ writeLog({ kind: "set-mode", agent, args, sessionName: sessionFromOption, mode: setModeValue });
+ process.stdout.write(JSON.stringify({
+ type: "mode_set",
+ acpxSessionId: "sid-" + sessionFromOption,
+ mode: setModeValue,
+ }) + "\n");
+ process.exit(0);
+}
+
+if (command === "set") {
+ writeLog({
+ kind: "set",
+ agent,
+ args,
+ sessionName: sessionFromOption,
+ key: setKey,
+ value: setValue,
+ });
+ process.stdout.write(JSON.stringify({
+ type: "config_set",
+ acpxSessionId: "sid-" + sessionFromOption,
+ key: setKey,
+ value: setValue,
+ }) + "\n");
+ process.exit(0);
+}
+
+if (command === "status") {
+ writeLog({ kind: "status", agent, args, sessionName: sessionFromOption });
+ process.stdout.write(JSON.stringify({
+ acpxRecordId: sessionFromOption ? "rec-" + sessionFromOption : null,
+ acpxSessionId: sessionFromOption ? "sid-" + sessionFromOption : null,
+ agentSessionId: sessionFromOption ? "inner-" + sessionFromOption : null,
+ status: sessionFromOption ? "alive" : "no-session",
+ pid: 4242,
+ uptime: 120,
+ }) + "\n");
+ process.exit(0);
+}
+
+if (command === "sessions" && args[commandIndex + 1] === "close") {
+ writeLog({ kind: "close", agent, args, sessionName: closeName });
+ process.stdout.write(JSON.stringify({
+ type: "session_closed",
+ acpxRecordId: "rec-" + closeName,
+ acpxSessionId: "sid-" + closeName,
+ name: closeName,
+ }) + "\n");
+ process.exit(0);
+}
+
+if (command === "prompt") {
+ const stdinText = fs.readFileSync(0, "utf8");
+ writeLog({ kind: "prompt", agent, args, sessionName: sessionFromOption, stdinText });
+ const acpxSessionId = "sid-" + sessionFromOption;
+
+ if (stdinText.includes("trigger-error")) {
+ process.stdout.write(JSON.stringify({
+ eventVersion: 1,
+ acpxSessionId,
+ requestId: "req-1",
+ seq: 0,
+ stream: "prompt",
+ type: "error",
+ code: "RUNTIME",
+ message: "mock failure",
+ }) + "\n");
+ process.exit(1);
+ }
+
+ if (stdinText.includes("split-spacing")) {
+ process.stdout.write(JSON.stringify({
+ eventVersion: 1,
+ acpxSessionId,
+ requestId: "req-1",
+ seq: 0,
+ stream: "prompt",
+ type: "text",
+ content: "alpha",
+ }) + "\n");
+ process.stdout.write(JSON.stringify({
+ eventVersion: 1,
+ acpxSessionId,
+ requestId: "req-1",
+ seq: 1,
+ stream: "prompt",
+ type: "text",
+ content: " beta",
+ }) + "\n");
+ process.stdout.write(JSON.stringify({
+ eventVersion: 1,
+ acpxSessionId,
+ requestId: "req-1",
+ seq: 2,
+ stream: "prompt",
+ type: "text",
+ content: " gamma",
+ }) + "\n");
+ process.stdout.write(JSON.stringify({
+ eventVersion: 1,
+ acpxSessionId,
+ requestId: "req-1",
+ seq: 3,
+ stream: "prompt",
+ type: "done",
+ stopReason: "end_turn",
+ }) + "\n");
+ process.exit(0);
+ }
+
+ process.stdout.write(JSON.stringify({
+ eventVersion: 1,
+ acpxSessionId,
+ requestId: "req-1",
+ seq: 0,
+ stream: "prompt",
+ type: "thought",
+ content: "thinking",
+ }) + "\n");
+ process.stdout.write(JSON.stringify({
+ eventVersion: 1,
+ acpxSessionId,
+ requestId: "req-1",
+ seq: 1,
+ stream: "prompt",
+ type: "tool_call",
+ title: "run-tests",
+ status: "in_progress",
+ }) + "\n");
+ process.stdout.write(JSON.stringify({
+ eventVersion: 1,
+ acpxSessionId,
+ requestId: "req-1",
+ seq: 2,
+ stream: "prompt",
+ type: "text",
+ content: "echo:" + stdinText.trim(),
+ }) + "\n");
+ process.stdout.write(JSON.stringify({
+ eventVersion: 1,
+ acpxSessionId,
+ requestId: "req-1",
+ seq: 3,
+ stream: "prompt",
+ type: "done",
+ stopReason: "end_turn",
+ }) + "\n");
+ process.exit(0);
+}
+
+writeLog({ kind: "unknown", args });
+process.stdout.write(JSON.stringify({
+ eventVersion: 1,
+ acpxSessionId: "unknown",
+ seq: 0,
+ stream: "control",
+ type: "error",
+ code: "USAGE",
+ message: "unknown command",
+}) + "\n");
+process.exit(2);
+`;
+
+const tempDirs: string[] = [];
+
+async function createMockRuntime(params?: {
+ permissionMode?: ResolvedAcpxPluginConfig["permissionMode"];
+ queueOwnerTtlSeconds?: number;
+}): Promise<{
+ runtime: AcpxRuntime;
+ logPath: string;
+ config: ResolvedAcpxPluginConfig;
+}> {
+ const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-runtime-test-"));
+ tempDirs.push(dir);
+ const scriptPath = path.join(dir, "mock-acpx.cjs");
+ const logPath = path.join(dir, "calls.log");
+ await writeFile(scriptPath, MOCK_CLI_SCRIPT, "utf8");
+ await chmod(scriptPath, 0o755);
+ process.env.MOCK_ACPX_LOG = logPath;
+
+ const config: ResolvedAcpxPluginConfig = {
+ command: scriptPath,
+ cwd: dir,
+ permissionMode: params?.permissionMode ?? "approve-all",
+ nonInteractivePermissions: "fail",
+ queueOwnerTtlSeconds: params?.queueOwnerTtlSeconds ?? 0.1,
+ };
+
+ return {
+ runtime: new AcpxRuntime(config, {
+ queueOwnerTtlSeconds: params?.queueOwnerTtlSeconds,
+ logger: NOOP_LOGGER,
+ }),
+ logPath,
+ config,
+ };
+}
+
+async function readLogEntries(logPath: string): Promise>> {
+ if (!fs.existsSync(logPath)) {
+ return [];
+ }
+ const raw = await readFile(logPath, "utf8");
+ return raw
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .map((line) => JSON.parse(line) as Record);
+}
+
+afterEach(async () => {
+ delete process.env.MOCK_ACPX_LOG;
+ while (tempDirs.length > 0) {
+ const dir = tempDirs.pop();
+ if (!dir) {
+ continue;
+ }
+ await rm(dir, {
+ recursive: true,
+ force: true,
+ maxRetries: 10,
+ retryDelay: 10,
+ });
+ }
+});
+
+describe("AcpxRuntime", () => {
+ it("passes the shared ACP adapter contract suite", async () => {
+ const fixture = await createMockRuntime();
+ await runAcpRuntimeAdapterContract({
+ createRuntime: async () => fixture.runtime,
+ agentId: "codex",
+ successPrompt: "contract-pass",
+ errorPrompt: "trigger-error",
+ assertSuccessEvents: (events) => {
+ expect(events.some((event) => event.type === "done")).toBe(true);
+ },
+ assertErrorOutcome: ({ events, thrown }) => {
+ expect(events.some((event) => event.type === "error") || Boolean(thrown)).toBe(true);
+ },
+ });
+
+ const logs = await readLogEntries(fixture.logPath);
+ expect(logs.some((entry) => entry.kind === "ensure")).toBe(true);
+ expect(logs.some((entry) => entry.kind === "status")).toBe(true);
+ expect(logs.some((entry) => entry.kind === "set-mode")).toBe(true);
+ expect(logs.some((entry) => entry.kind === "set")).toBe(true);
+ expect(logs.some((entry) => entry.kind === "cancel")).toBe(true);
+ expect(logs.some((entry) => entry.kind === "close")).toBe(true);
+ });
+
+ it("ensures sessions and streams prompt events", async () => {
+ const { runtime, logPath } = await createMockRuntime({ queueOwnerTtlSeconds: 180 });
+
+ const handle = await runtime.ensureSession({
+ sessionKey: "agent:codex:acp:123",
+ agent: "codex",
+ mode: "persistent",
+ });
+ expect(handle.backend).toBe("acpx");
+ expect(handle.acpxRecordId).toBe("rec-agent:codex:acp:123");
+ expect(handle.agentSessionId).toBe("inner-agent:codex:acp:123");
+ expect(handle.backendSessionId).toBe("sid-agent:codex:acp:123");
+ const decoded = decodeAcpxRuntimeHandleState(handle.runtimeSessionName);
+ expect(decoded?.acpxRecordId).toBe("rec-agent:codex:acp:123");
+ expect(decoded?.agentSessionId).toBe("inner-agent:codex:acp:123");
+ expect(decoded?.backendSessionId).toBe("sid-agent:codex:acp:123");
+
+ const events = [];
+ for await (const event of runtime.runTurn({
+ handle,
+ text: "hello world",
+ mode: "prompt",
+ requestId: "req-test",
+ })) {
+ events.push(event);
+ }
+
+ expect(events).toContainEqual({
+ type: "text_delta",
+ text: "thinking",
+ stream: "thought",
+ });
+ expect(events).toContainEqual({
+ type: "tool_call",
+ text: "run-tests (in_progress)",
+ });
+ expect(events).toContainEqual({
+ type: "text_delta",
+ text: "echo:hello world",
+ stream: "output",
+ });
+ expect(events).toContainEqual({
+ type: "done",
+ stopReason: "end_turn",
+ });
+
+ const logs = await readLogEntries(logPath);
+ const ensure = logs.find((entry) => entry.kind === "ensure");
+ const prompt = logs.find((entry) => entry.kind === "prompt");
+ expect(ensure).toBeDefined();
+ expect(prompt).toBeDefined();
+ expect(Array.isArray(prompt?.args)).toBe(true);
+ const promptArgs = (prompt?.args as string[]) ?? [];
+ expect(promptArgs).toContain("--ttl");
+ expect(promptArgs).toContain("180");
+ expect(promptArgs).toContain("--approve-all");
+ });
+
+ it("passes a queue-owner TTL by default to avoid long idle stalls", async () => {
+ const { runtime, logPath } = await createMockRuntime();
+ const handle = await runtime.ensureSession({
+ sessionKey: "agent:codex:acp:ttl-default",
+ agent: "codex",
+ mode: "persistent",
+ });
+
+ for await (const _event of runtime.runTurn({
+ handle,
+ text: "ttl-default",
+ mode: "prompt",
+ requestId: "req-ttl-default",
+ })) {
+ // drain
+ }
+
+ const logs = await readLogEntries(logPath);
+ const prompt = logs.find((entry) => entry.kind === "prompt");
+ expect(prompt).toBeDefined();
+ const promptArgs = (prompt?.args as string[]) ?? [];
+ const ttlFlagIndex = promptArgs.indexOf("--ttl");
+ expect(ttlFlagIndex).toBeGreaterThanOrEqual(0);
+ expect(promptArgs[ttlFlagIndex + 1]).toBe("0.1");
+ });
+
+ it("preserves leading spaces across streamed text deltas", async () => {
+ const { runtime } = await createMockRuntime();
+ const handle = await runtime.ensureSession({
+ sessionKey: "agent:codex:acp:space",
+ agent: "codex",
+ mode: "persistent",
+ });
+
+ const textDeltas: string[] = [];
+ for await (const event of runtime.runTurn({
+ handle,
+ text: "split-spacing",
+ mode: "prompt",
+ requestId: "req-space",
+ })) {
+ if (event.type === "text_delta" && event.stream === "output") {
+ textDeltas.push(event.text);
+ }
+ }
+
+ expect(textDeltas).toEqual(["alpha", " beta", " gamma"]);
+ expect(textDeltas.join("")).toBe("alpha beta gamma");
+ });
+
+ it("maps acpx error events into ACP runtime error events", async () => {
+ const { runtime } = await createMockRuntime();
+ const handle = await runtime.ensureSession({
+ sessionKey: "agent:codex:acp:456",
+ agent: "codex",
+ mode: "persistent",
+ });
+
+ const events = [];
+ for await (const event of runtime.runTurn({
+ handle,
+ text: "trigger-error",
+ mode: "prompt",
+ requestId: "req-err",
+ })) {
+ events.push(event);
+ }
+
+ expect(events).toContainEqual({
+ type: "error",
+ message: "mock failure",
+ code: "RUNTIME",
+ retryable: undefined,
+ });
+ });
+
+ it("supports cancel and close using encoded runtime handle state", async () => {
+ const { runtime, logPath, config } = await createMockRuntime();
+ const handle = await runtime.ensureSession({
+ sessionKey: "agent:claude:acp:789",
+ agent: "claude",
+ mode: "persistent",
+ });
+
+ const decoded = decodeAcpxRuntimeHandleState(handle.runtimeSessionName);
+ expect(decoded?.name).toBe("agent:claude:acp:789");
+
+ const secondRuntime = new AcpxRuntime(config, { logger: NOOP_LOGGER });
+
+ await secondRuntime.cancel({ handle, reason: "test" });
+ await secondRuntime.close({ handle, reason: "test" });
+
+ const logs = await readLogEntries(logPath);
+ const cancel = logs.find((entry) => entry.kind === "cancel");
+ const close = logs.find((entry) => entry.kind === "close");
+ expect(cancel?.sessionName).toBe("agent:claude:acp:789");
+ expect(close?.sessionName).toBe("agent:claude:acp:789");
+ });
+
+ it("exposes control capabilities and runs set-mode/set/status commands", async () => {
+ const { runtime, logPath } = await createMockRuntime();
+ const handle = await runtime.ensureSession({
+ sessionKey: "agent:codex:acp:controls",
+ agent: "codex",
+ mode: "persistent",
+ });
+
+ const capabilities = runtime.getCapabilities();
+ expect(capabilities.controls).toContain("session/set_mode");
+ expect(capabilities.controls).toContain("session/set_config_option");
+ expect(capabilities.controls).toContain("session/status");
+
+ await runtime.setMode({
+ handle,
+ mode: "plan",
+ });
+ await runtime.setConfigOption({
+ handle,
+ key: "model",
+ value: "openai-codex/gpt-5.3-codex",
+ });
+ const status = await runtime.getStatus({ handle });
+ const ensuredSessionName = "agent:codex:acp:controls";
+
+ expect(status.summary).toContain("status=alive");
+ expect(status.acpxRecordId).toBe("rec-" + ensuredSessionName);
+ expect(status.backendSessionId).toBe("sid-" + ensuredSessionName);
+ expect(status.agentSessionId).toBe("inner-" + ensuredSessionName);
+ expect(status.details?.acpxRecordId).toBe("rec-" + ensuredSessionName);
+ expect(status.details?.status).toBe("alive");
+ expect(status.details?.pid).toBe(4242);
+
+ const logs = await readLogEntries(logPath);
+ expect(logs.find((entry) => entry.kind === "set-mode")?.mode).toBe("plan");
+ expect(logs.find((entry) => entry.kind === "set")?.key).toBe("model");
+ expect(logs.find((entry) => entry.kind === "status")).toBeDefined();
+ });
+
+ it("skips prompt execution when runTurn starts with an already-aborted signal", async () => {
+ const { runtime, logPath } = await createMockRuntime();
+ const handle = await runtime.ensureSession({
+ sessionKey: "agent:codex:acp:aborted",
+ agent: "codex",
+ mode: "persistent",
+ });
+ const controller = new AbortController();
+ controller.abort();
+
+ const events = [];
+ for await (const event of runtime.runTurn({
+ handle,
+ text: "should-not-run",
+ mode: "prompt",
+ requestId: "req-aborted",
+ signal: controller.signal,
+ })) {
+ events.push(event);
+ }
+
+ const logs = await readLogEntries(logPath);
+ expect(events).toEqual([]);
+ expect(logs.some((entry) => entry.kind === "prompt")).toBe(false);
+ });
+
+ it("does not mark backend unhealthy when a per-session cwd is missing", async () => {
+ const { runtime } = await createMockRuntime();
+ const missingCwd = path.join(os.tmpdir(), "openclaw-acpx-runtime-test-missing-cwd");
+
+ await runtime.probeAvailability();
+ expect(runtime.isHealthy()).toBe(true);
+
+ await expect(
+ runtime.ensureSession({
+ sessionKey: "agent:codex:acp:missing-cwd",
+ agent: "codex",
+ mode: "persistent",
+ cwd: missingCwd,
+ }),
+ ).rejects.toMatchObject({
+ code: "ACP_SESSION_INIT_FAILED",
+ message: expect.stringContaining("working directory does not exist"),
+ });
+ expect(runtime.isHealthy()).toBe(true);
+ });
+
+ it("marks runtime unhealthy when command is missing", async () => {
+ const runtime = new AcpxRuntime(
+ {
+ command: "/definitely/missing/acpx",
+ cwd: process.cwd(),
+ permissionMode: "approve-reads",
+ nonInteractivePermissions: "fail",
+ queueOwnerTtlSeconds: 0.1,
+ },
+ { logger: NOOP_LOGGER },
+ );
+
+ await runtime.probeAvailability();
+ expect(runtime.isHealthy()).toBe(false);
+ });
+
+ it("marks runtime healthy when command is available", async () => {
+ const { runtime } = await createMockRuntime();
+ await runtime.probeAvailability();
+ expect(runtime.isHealthy()).toBe(true);
+ });
+
+ it("returns doctor report for missing command", async () => {
+ const runtime = new AcpxRuntime(
+ {
+ command: "/definitely/missing/acpx",
+ cwd: process.cwd(),
+ permissionMode: "approve-reads",
+ nonInteractivePermissions: "fail",
+ queueOwnerTtlSeconds: 0.1,
+ },
+ { logger: NOOP_LOGGER },
+ );
+
+ const report = await runtime.doctor();
+ expect(report.ok).toBe(false);
+ expect(report.code).toBe("ACP_BACKEND_UNAVAILABLE");
+ expect(report.installCommand).toContain("acpx");
+ });
+});
diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts
new file mode 100644
index 000000000000..a5273c7e0f28
--- /dev/null
+++ b/extensions/acpx/src/runtime.ts
@@ -0,0 +1,578 @@
+import { createInterface } from "node:readline";
+import type {
+ AcpRuntimeCapabilities,
+ AcpRuntimeDoctorReport,
+ AcpRuntime,
+ AcpRuntimeEnsureInput,
+ AcpRuntimeErrorCode,
+ AcpRuntimeEvent,
+ AcpRuntimeHandle,
+ AcpRuntimeStatus,
+ AcpRuntimeTurnInput,
+ PluginLogger,
+} from "openclaw/plugin-sdk";
+import { AcpRuntimeError } from "openclaw/plugin-sdk";
+import {
+ ACPX_LOCAL_INSTALL_COMMAND,
+ ACPX_PINNED_VERSION,
+ type ResolvedAcpxPluginConfig,
+} from "./config.js";
+import { checkPinnedAcpxVersion } from "./ensure.js";
+import {
+ parseJsonLines,
+ parsePromptEventLine,
+ toAcpxErrorEvent,
+} from "./runtime-internals/events.js";
+import {
+ resolveSpawnFailure,
+ spawnAndCollect,
+ spawnWithResolvedCommand,
+ waitForExit,
+} from "./runtime-internals/process.js";
+import {
+ asOptionalString,
+ asTrimmedString,
+ buildPermissionArgs,
+ deriveAgentFromSessionKey,
+ isRecord,
+ type AcpxHandleState,
+ type AcpxJsonObject,
+} from "./runtime-internals/shared.js";
+
+export const ACPX_BACKEND_ID = "acpx";
+
+const ACPX_RUNTIME_HANDLE_PREFIX = "acpx:v1:";
+const DEFAULT_AGENT_FALLBACK = "codex";
+const ACPX_CAPABILITIES: AcpRuntimeCapabilities = {
+ controls: ["session/set_mode", "session/set_config_option", "session/status"],
+};
+
+export function encodeAcpxRuntimeHandleState(state: AcpxHandleState): string {
+ const payload = Buffer.from(JSON.stringify(state), "utf8").toString("base64url");
+ return `${ACPX_RUNTIME_HANDLE_PREFIX}${payload}`;
+}
+
+export function decodeAcpxRuntimeHandleState(runtimeSessionName: string): AcpxHandleState | null {
+ const trimmed = runtimeSessionName.trim();
+ if (!trimmed.startsWith(ACPX_RUNTIME_HANDLE_PREFIX)) {
+ return null;
+ }
+ const encoded = trimmed.slice(ACPX_RUNTIME_HANDLE_PREFIX.length);
+ if (!encoded) {
+ return null;
+ }
+ try {
+ const raw = Buffer.from(encoded, "base64url").toString("utf8");
+ const parsed = JSON.parse(raw) as unknown;
+ if (!isRecord(parsed)) {
+ return null;
+ }
+ const name = asTrimmedString(parsed.name);
+ const agent = asTrimmedString(parsed.agent);
+ const cwd = asTrimmedString(parsed.cwd);
+ const mode = asTrimmedString(parsed.mode);
+ const acpxRecordId = asOptionalString(parsed.acpxRecordId);
+ const backendSessionId = asOptionalString(parsed.backendSessionId);
+ const agentSessionId = asOptionalString(parsed.agentSessionId);
+ if (!name || !agent || !cwd) {
+ return null;
+ }
+ if (mode !== "persistent" && mode !== "oneshot") {
+ return null;
+ }
+ return {
+ name,
+ agent,
+ cwd,
+ mode,
+ ...(acpxRecordId ? { acpxRecordId } : {}),
+ ...(backendSessionId ? { backendSessionId } : {}),
+ ...(agentSessionId ? { agentSessionId } : {}),
+ };
+ } catch {
+ return null;
+ }
+}
+
+export class AcpxRuntime implements AcpRuntime {
+ private healthy = false;
+ private readonly logger?: PluginLogger;
+ private readonly queueOwnerTtlSeconds: number;
+
+ constructor(
+ private readonly config: ResolvedAcpxPluginConfig,
+ opts?: {
+ logger?: PluginLogger;
+ queueOwnerTtlSeconds?: number;
+ },
+ ) {
+ this.logger = opts?.logger;
+ const requestedQueueOwnerTtlSeconds = opts?.queueOwnerTtlSeconds;
+ this.queueOwnerTtlSeconds =
+ typeof requestedQueueOwnerTtlSeconds === "number" &&
+ Number.isFinite(requestedQueueOwnerTtlSeconds) &&
+ requestedQueueOwnerTtlSeconds >= 0
+ ? requestedQueueOwnerTtlSeconds
+ : this.config.queueOwnerTtlSeconds;
+ }
+
+ isHealthy(): boolean {
+ return this.healthy;
+ }
+
+ async probeAvailability(): Promise {
+ const versionCheck = await checkPinnedAcpxVersion({
+ command: this.config.command,
+ cwd: this.config.cwd,
+ expectedVersion: ACPX_PINNED_VERSION,
+ });
+ if (!versionCheck.ok) {
+ this.healthy = false;
+ return;
+ }
+
+ try {
+ const result = await spawnAndCollect({
+ command: this.config.command,
+ args: ["--help"],
+ cwd: this.config.cwd,
+ });
+ this.healthy = result.error == null && (result.code ?? 0) === 0;
+ } catch {
+ this.healthy = false;
+ }
+ }
+
+ async ensureSession(input: AcpRuntimeEnsureInput): Promise {
+ const sessionName = asTrimmedString(input.sessionKey);
+ if (!sessionName) {
+ throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
+ }
+ const agent = asTrimmedString(input.agent);
+ if (!agent) {
+ throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP agent id is required.");
+ }
+ const cwd = asTrimmedString(input.cwd) || this.config.cwd;
+ const mode = input.mode;
+
+ const events = await this.runControlCommand({
+ args: this.buildControlArgs({
+ cwd,
+ command: [agent, "sessions", "ensure", "--name", sessionName],
+ }),
+ cwd,
+ fallbackCode: "ACP_SESSION_INIT_FAILED",
+ });
+ const ensuredEvent = events.find(
+ (event) =>
+ asOptionalString(event.agentSessionId) ||
+ asOptionalString(event.acpxSessionId) ||
+ asOptionalString(event.acpxRecordId),
+ );
+ const acpxRecordId = ensuredEvent ? asOptionalString(ensuredEvent.acpxRecordId) : undefined;
+ const agentSessionId = ensuredEvent ? asOptionalString(ensuredEvent.agentSessionId) : undefined;
+ const backendSessionId = ensuredEvent
+ ? asOptionalString(ensuredEvent.acpxSessionId)
+ : undefined;
+
+ return {
+ sessionKey: input.sessionKey,
+ backend: ACPX_BACKEND_ID,
+ runtimeSessionName: encodeAcpxRuntimeHandleState({
+ name: sessionName,
+ agent,
+ cwd,
+ mode,
+ ...(acpxRecordId ? { acpxRecordId } : {}),
+ ...(backendSessionId ? { backendSessionId } : {}),
+ ...(agentSessionId ? { agentSessionId } : {}),
+ }),
+ cwd,
+ ...(acpxRecordId ? { acpxRecordId } : {}),
+ ...(backendSessionId ? { backendSessionId } : {}),
+ ...(agentSessionId ? { agentSessionId } : {}),
+ };
+ }
+
+ async *runTurn(input: AcpRuntimeTurnInput): AsyncIterable {
+ const state = this.resolveHandleState(input.handle);
+ const args = this.buildPromptArgs({
+ agent: state.agent,
+ sessionName: state.name,
+ cwd: state.cwd,
+ });
+
+ const cancelOnAbort = async () => {
+ await this.cancel({
+ handle: input.handle,
+ reason: "abort-signal",
+ }).catch((err) => {
+ this.logger?.warn?.(`acpx runtime abort-cancel failed: ${String(err)}`);
+ });
+ };
+ const onAbort = () => {
+ void cancelOnAbort();
+ };
+
+ if (input.signal?.aborted) {
+ await cancelOnAbort();
+ return;
+ }
+ if (input.signal) {
+ input.signal.addEventListener("abort", onAbort, { once: true });
+ }
+ const child = spawnWithResolvedCommand({
+ command: this.config.command,
+ args,
+ cwd: state.cwd,
+ });
+ child.stdin.on("error", () => {
+ // Ignore EPIPE when the child exits before stdin flush completes.
+ });
+
+ child.stdin.end(input.text);
+
+ let stderr = "";
+ child.stderr.on("data", (chunk) => {
+ stderr += String(chunk);
+ });
+
+ let sawDone = false;
+ let sawError = false;
+ const lines = createInterface({ input: child.stdout });
+ try {
+ for await (const line of lines) {
+ const parsed = parsePromptEventLine(line);
+ if (!parsed) {
+ continue;
+ }
+ if (parsed.type === "done") {
+ sawDone = true;
+ }
+ if (parsed.type === "error") {
+ sawError = true;
+ }
+ yield parsed;
+ }
+
+ const exit = await waitForExit(child);
+ if (exit.error) {
+ const spawnFailure = resolveSpawnFailure(exit.error, state.cwd);
+ if (spawnFailure === "missing-command") {
+ this.healthy = false;
+ throw new AcpRuntimeError(
+ "ACP_BACKEND_UNAVAILABLE",
+ `acpx command not found: ${this.config.command}`,
+ { cause: exit.error },
+ );
+ }
+ if (spawnFailure === "missing-cwd") {
+ throw new AcpRuntimeError(
+ "ACP_TURN_FAILED",
+ `ACP runtime working directory does not exist: ${state.cwd}`,
+ { cause: exit.error },
+ );
+ }
+ throw new AcpRuntimeError("ACP_TURN_FAILED", exit.error.message, { cause: exit.error });
+ }
+
+ if ((exit.code ?? 0) !== 0 && !sawError) {
+ yield {
+ type: "error",
+ message: stderr.trim() || `acpx exited with code ${exit.code ?? "unknown"}`,
+ };
+ return;
+ }
+
+ if (!sawDone && !sawError) {
+ yield { type: "done" };
+ }
+ } finally {
+ lines.close();
+ if (input.signal) {
+ input.signal.removeEventListener("abort", onAbort);
+ }
+ }
+ }
+
+ getCapabilities(): AcpRuntimeCapabilities {
+ return ACPX_CAPABILITIES;
+ }
+
+ async getStatus(input: { handle: AcpRuntimeHandle }): Promise {
+ const state = this.resolveHandleState(input.handle);
+ const events = await this.runControlCommand({
+ args: this.buildControlArgs({
+ cwd: state.cwd,
+ command: [state.agent, "status", "--session", state.name],
+ }),
+ cwd: state.cwd,
+ fallbackCode: "ACP_TURN_FAILED",
+ ignoreNoSession: true,
+ });
+ const detail = events.find((event) => !toAcpxErrorEvent(event)) ?? events[0];
+ if (!detail) {
+ return {
+ summary: "acpx status unavailable",
+ };
+ }
+ const status = asTrimmedString(detail.status) || "unknown";
+ const acpxRecordId = asOptionalString(detail.acpxRecordId);
+ const acpxSessionId = asOptionalString(detail.acpxSessionId);
+ const agentSessionId = asOptionalString(detail.agentSessionId);
+ const pid = typeof detail.pid === "number" && Number.isFinite(detail.pid) ? detail.pid : null;
+ const summary = [
+ `status=${status}`,
+ acpxRecordId ? `acpxRecordId=${acpxRecordId}` : null,
+ acpxSessionId ? `acpxSessionId=${acpxSessionId}` : null,
+ pid != null ? `pid=${pid}` : null,
+ ]
+ .filter(Boolean)
+ .join(" ");
+ return {
+ summary,
+ ...(acpxRecordId ? { acpxRecordId } : {}),
+ ...(acpxSessionId ? { backendSessionId: acpxSessionId } : {}),
+ ...(agentSessionId ? { agentSessionId } : {}),
+ details: detail,
+ };
+ }
+
+ async setMode(input: { handle: AcpRuntimeHandle; mode: string }): Promise {
+ const state = this.resolveHandleState(input.handle);
+ const mode = asTrimmedString(input.mode);
+ if (!mode) {
+ throw new AcpRuntimeError("ACP_TURN_FAILED", "ACP runtime mode is required.");
+ }
+ await this.runControlCommand({
+ args: this.buildControlArgs({
+ cwd: state.cwd,
+ command: [state.agent, "set-mode", mode, "--session", state.name],
+ }),
+ cwd: state.cwd,
+ fallbackCode: "ACP_TURN_FAILED",
+ });
+ }
+
+ async setConfigOption(input: {
+ handle: AcpRuntimeHandle;
+ key: string;
+ value: string;
+ }): Promise {
+ const state = this.resolveHandleState(input.handle);
+ const key = asTrimmedString(input.key);
+ const value = asTrimmedString(input.value);
+ if (!key || !value) {
+ throw new AcpRuntimeError("ACP_TURN_FAILED", "ACP config option key/value are required.");
+ }
+ await this.runControlCommand({
+ args: this.buildControlArgs({
+ cwd: state.cwd,
+ command: [state.agent, "set", key, value, "--session", state.name],
+ }),
+ cwd: state.cwd,
+ fallbackCode: "ACP_TURN_FAILED",
+ });
+ }
+
+ async doctor(): Promise {
+ const versionCheck = await checkPinnedAcpxVersion({
+ command: this.config.command,
+ cwd: this.config.cwd,
+ expectedVersion: ACPX_PINNED_VERSION,
+ });
+ if (!versionCheck.ok) {
+ this.healthy = false;
+ const details = [
+ `expected=${versionCheck.expectedVersion}`,
+ versionCheck.installedVersion ? `installed=${versionCheck.installedVersion}` : null,
+ ].filter((detail): detail is string => Boolean(detail));
+ return {
+ ok: false,
+ code: "ACP_BACKEND_UNAVAILABLE",
+ message: versionCheck.message,
+ installCommand: versionCheck.installCommand,
+ details,
+ };
+ }
+
+ try {
+ const result = await spawnAndCollect({
+ command: this.config.command,
+ args: ["--help"],
+ cwd: this.config.cwd,
+ });
+ if (result.error) {
+ const spawnFailure = resolveSpawnFailure(result.error, this.config.cwd);
+ if (spawnFailure === "missing-command") {
+ this.healthy = false;
+ return {
+ ok: false,
+ code: "ACP_BACKEND_UNAVAILABLE",
+ message: `acpx command not found: ${this.config.command}`,
+ installCommand: ACPX_LOCAL_INSTALL_COMMAND,
+ };
+ }
+ if (spawnFailure === "missing-cwd") {
+ this.healthy = false;
+ return {
+ ok: false,
+ code: "ACP_BACKEND_UNAVAILABLE",
+ message: `ACP runtime working directory does not exist: ${this.config.cwd}`,
+ };
+ }
+ this.healthy = false;
+ return {
+ ok: false,
+ code: "ACP_BACKEND_UNAVAILABLE",
+ message: result.error.message,
+ details: [String(result.error)],
+ };
+ }
+ if ((result.code ?? 0) !== 0) {
+ this.healthy = false;
+ return {
+ ok: false,
+ code: "ACP_BACKEND_UNAVAILABLE",
+ message: result.stderr.trim() || `acpx exited with code ${result.code ?? "unknown"}`,
+ };
+ }
+ this.healthy = true;
+ return {
+ ok: true,
+ message: `acpx command available (${this.config.command}, version ${versionCheck.version})`,
+ };
+ } catch (error) {
+ this.healthy = false;
+ return {
+ ok: false,
+ code: "ACP_BACKEND_UNAVAILABLE",
+ message: error instanceof Error ? error.message : String(error),
+ };
+ }
+ }
+
+ async cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise {
+ const state = this.resolveHandleState(input.handle);
+ await this.runControlCommand({
+ args: this.buildControlArgs({
+ cwd: state.cwd,
+ command: [state.agent, "cancel", "--session", state.name],
+ }),
+ cwd: state.cwd,
+ fallbackCode: "ACP_TURN_FAILED",
+ ignoreNoSession: true,
+ });
+ }
+
+ async close(input: { handle: AcpRuntimeHandle; reason: string }): Promise {
+ const state = this.resolveHandleState(input.handle);
+ await this.runControlCommand({
+ args: this.buildControlArgs({
+ cwd: state.cwd,
+ command: [state.agent, "sessions", "close", state.name],
+ }),
+ cwd: state.cwd,
+ fallbackCode: "ACP_TURN_FAILED",
+ ignoreNoSession: true,
+ });
+ }
+
+ private resolveHandleState(handle: AcpRuntimeHandle): AcpxHandleState {
+ const decoded = decodeAcpxRuntimeHandleState(handle.runtimeSessionName);
+ if (decoded) {
+ return decoded;
+ }
+
+ const legacyName = asTrimmedString(handle.runtimeSessionName);
+ if (!legacyName) {
+ throw new AcpRuntimeError(
+ "ACP_SESSION_INIT_FAILED",
+ "Invalid acpx runtime handle: runtimeSessionName is missing.",
+ );
+ }
+
+ return {
+ name: legacyName,
+ agent: deriveAgentFromSessionKey(handle.sessionKey, DEFAULT_AGENT_FALLBACK),
+ cwd: this.config.cwd,
+ mode: "persistent",
+ };
+ }
+
+ private buildControlArgs(params: { cwd: string; command: string[] }): string[] {
+ return ["--format", "json", "--json-strict", "--cwd", params.cwd, ...params.command];
+ }
+
+ private buildPromptArgs(params: { agent: string; sessionName: string; cwd: string }): string[] {
+ const args = [
+ "--format",
+ "json",
+ "--json-strict",
+ "--cwd",
+ params.cwd,
+ ...buildPermissionArgs(this.config.permissionMode),
+ "--non-interactive-permissions",
+ this.config.nonInteractivePermissions,
+ ];
+ if (this.config.timeoutSeconds) {
+ args.push("--timeout", String(this.config.timeoutSeconds));
+ }
+ args.push("--ttl", String(this.queueOwnerTtlSeconds));
+ args.push(params.agent, "prompt", "--session", params.sessionName, "--file", "-");
+ return args;
+ }
+
+ private async runControlCommand(params: {
+ args: string[];
+ cwd: string;
+ fallbackCode: AcpRuntimeErrorCode;
+ ignoreNoSession?: boolean;
+ }): Promise {
+ const result = await spawnAndCollect({
+ command: this.config.command,
+ args: params.args,
+ cwd: params.cwd,
+ });
+
+ if (result.error) {
+ const spawnFailure = resolveSpawnFailure(result.error, params.cwd);
+ if (spawnFailure === "missing-command") {
+ this.healthy = false;
+ throw new AcpRuntimeError(
+ "ACP_BACKEND_UNAVAILABLE",
+ `acpx command not found: ${this.config.command}`,
+ { cause: result.error },
+ );
+ }
+ if (spawnFailure === "missing-cwd") {
+ throw new AcpRuntimeError(
+ params.fallbackCode,
+ `ACP runtime working directory does not exist: ${params.cwd}`,
+ { cause: result.error },
+ );
+ }
+ throw new AcpRuntimeError(params.fallbackCode, result.error.message, { cause: result.error });
+ }
+
+ const events = parseJsonLines(result.stdout);
+ const errorEvent = events.map((event) => toAcpxErrorEvent(event)).find(Boolean) ?? null;
+ if (errorEvent) {
+ if (params.ignoreNoSession && errorEvent.code === "NO_SESSION") {
+ return events;
+ }
+ throw new AcpRuntimeError(
+ params.fallbackCode,
+ errorEvent.code ? `${errorEvent.code}: ${errorEvent.message}` : errorEvent.message,
+ );
+ }
+
+ if ((result.code ?? 0) !== 0) {
+ throw new AcpRuntimeError(
+ params.fallbackCode,
+ result.stderr.trim() || `acpx exited with code ${result.code ?? "unknown"}`,
+ );
+ }
+ return events;
+ }
+}
diff --git a/extensions/acpx/src/service.test.ts b/extensions/acpx/src/service.test.ts
new file mode 100644
index 000000000000..30fc9fa7205f
--- /dev/null
+++ b/extensions/acpx/src/service.test.ts
@@ -0,0 +1,173 @@
+import type { AcpRuntime, OpenClawPluginServiceContext } from "openclaw/plugin-sdk";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { AcpRuntimeError } from "../../../src/acp/runtime/errors.js";
+import {
+ __testing,
+ getAcpRuntimeBackend,
+ requireAcpRuntimeBackend,
+} from "../../../src/acp/runtime/registry.js";
+import { ACPX_BUNDLED_BIN } from "./config.js";
+import { createAcpxRuntimeService } from "./service.js";
+
+const { ensurePinnedAcpxSpy } = vi.hoisted(() => ({
+ ensurePinnedAcpxSpy: vi.fn(async () => {}),
+}));
+
+vi.mock("./ensure.js", () => ({
+ ensurePinnedAcpx: ensurePinnedAcpxSpy,
+}));
+
+type RuntimeStub = AcpRuntime & {
+ probeAvailability(): Promise;
+ isHealthy(): boolean;
+};
+
+function createRuntimeStub(healthy: boolean): {
+ runtime: RuntimeStub;
+ probeAvailabilitySpy: ReturnType;
+ isHealthySpy: ReturnType;
+} {
+ const probeAvailabilitySpy = vi.fn(async () => {});
+ const isHealthySpy = vi.fn(() => healthy);
+ return {
+ runtime: {
+ ensureSession: vi.fn(async (input) => ({
+ sessionKey: input.sessionKey,
+ backend: "acpx",
+ runtimeSessionName: input.sessionKey,
+ })),
+ runTurn: vi.fn(async function* () {
+ yield { type: "done" as const };
+ }),
+ cancel: vi.fn(async () => {}),
+ close: vi.fn(async () => {}),
+ async probeAvailability() {
+ await probeAvailabilitySpy();
+ },
+ isHealthy() {
+ return isHealthySpy();
+ },
+ },
+ probeAvailabilitySpy,
+ isHealthySpy,
+ };
+}
+
+function createServiceContext(
+ overrides: Partial = {},
+): OpenClawPluginServiceContext {
+ return {
+ config: {},
+ workspaceDir: "/tmp/workspace",
+ stateDir: "/tmp/state",
+ logger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ },
+ ...overrides,
+ };
+}
+
+describe("createAcpxRuntimeService", () => {
+ beforeEach(() => {
+ __testing.resetAcpRuntimeBackendsForTests();
+ ensurePinnedAcpxSpy.mockReset();
+ ensurePinnedAcpxSpy.mockImplementation(async () => {});
+ });
+
+ it("registers and unregisters the acpx backend", async () => {
+ const { runtime, probeAvailabilitySpy } = createRuntimeStub(true);
+ const service = createAcpxRuntimeService({
+ runtimeFactory: () => runtime,
+ });
+ const context = createServiceContext();
+
+ await service.start(context);
+ expect(getAcpRuntimeBackend("acpx")?.runtime).toBe(runtime);
+
+ await vi.waitFor(() => {
+ expect(ensurePinnedAcpxSpy).toHaveBeenCalledOnce();
+ expect(probeAvailabilitySpy).toHaveBeenCalledOnce();
+ });
+
+ await service.stop?.(context);
+ expect(getAcpRuntimeBackend("acpx")).toBeNull();
+ });
+
+ it("marks backend unavailable when runtime health check fails", async () => {
+ const { runtime } = createRuntimeStub(false);
+ const service = createAcpxRuntimeService({
+ runtimeFactory: () => runtime,
+ });
+ const context = createServiceContext();
+
+ await service.start(context);
+
+ expect(() => requireAcpRuntimeBackend("acpx")).toThrowError(AcpRuntimeError);
+ try {
+ requireAcpRuntimeBackend("acpx");
+ throw new Error("expected ACP backend lookup to fail");
+ } catch (error) {
+ expect((error as AcpRuntimeError).code).toBe("ACP_BACKEND_UNAVAILABLE");
+ }
+ });
+
+ it("passes queue-owner TTL from plugin config", async () => {
+ const { runtime } = createRuntimeStub(true);
+ const runtimeFactory = vi.fn(() => runtime);
+ const service = createAcpxRuntimeService({
+ runtimeFactory,
+ pluginConfig: {
+ queueOwnerTtlSeconds: 0.25,
+ },
+ });
+ const context = createServiceContext();
+
+ await service.start(context);
+
+ expect(runtimeFactory).toHaveBeenCalledWith(
+ expect.objectContaining({
+ queueOwnerTtlSeconds: 0.25,
+ pluginConfig: expect.objectContaining({
+ command: ACPX_BUNDLED_BIN,
+ }),
+ }),
+ );
+ });
+
+ it("uses a short default queue-owner TTL", async () => {
+ const { runtime } = createRuntimeStub(true);
+ const runtimeFactory = vi.fn(() => runtime);
+ const service = createAcpxRuntimeService({
+ runtimeFactory,
+ });
+ const context = createServiceContext();
+
+ await service.start(context);
+
+ expect(runtimeFactory).toHaveBeenCalledWith(
+ expect.objectContaining({
+ queueOwnerTtlSeconds: 0.1,
+ }),
+ );
+ });
+
+ it("does not block startup while acpx ensure runs", async () => {
+ const { runtime } = createRuntimeStub(true);
+ ensurePinnedAcpxSpy.mockImplementation(() => new Promise(() => {}));
+ const service = createAcpxRuntimeService({
+ runtimeFactory: () => runtime,
+ });
+ const context = createServiceContext();
+
+ const startResult = await Promise.race([
+ Promise.resolve(service.start(context)).then(() => "started"),
+ new Promise((resolve) => setTimeout(() => resolve("timed_out"), 100)),
+ ]);
+
+ expect(startResult).toBe("started");
+ expect(getAcpRuntimeBackend("acpx")?.runtime).toBe(runtime);
+ });
+});
diff --git a/extensions/acpx/src/service.ts b/extensions/acpx/src/service.ts
new file mode 100644
index 000000000000..65768d00ce8a
--- /dev/null
+++ b/extensions/acpx/src/service.ts
@@ -0,0 +1,102 @@
+import type {
+ AcpRuntime,
+ OpenClawPluginService,
+ OpenClawPluginServiceContext,
+ PluginLogger,
+} from "openclaw/plugin-sdk";
+import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "openclaw/plugin-sdk";
+import {
+ ACPX_PINNED_VERSION,
+ resolveAcpxPluginConfig,
+ type ResolvedAcpxPluginConfig,
+} from "./config.js";
+import { ensurePinnedAcpx } from "./ensure.js";
+import { ACPX_BACKEND_ID, AcpxRuntime } from "./runtime.js";
+
+type AcpxRuntimeLike = AcpRuntime & {
+ probeAvailability(): Promise;
+ isHealthy(): boolean;
+};
+
+type AcpxRuntimeFactoryParams = {
+ pluginConfig: ResolvedAcpxPluginConfig;
+ queueOwnerTtlSeconds: number;
+ logger?: PluginLogger;
+};
+
+type CreateAcpxRuntimeServiceParams = {
+ pluginConfig?: unknown;
+ runtimeFactory?: (params: AcpxRuntimeFactoryParams) => AcpxRuntimeLike;
+};
+
+function createDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntimeLike {
+ return new AcpxRuntime(params.pluginConfig, {
+ logger: params.logger,
+ queueOwnerTtlSeconds: params.queueOwnerTtlSeconds,
+ });
+}
+
+export function createAcpxRuntimeService(
+ params: CreateAcpxRuntimeServiceParams = {},
+): OpenClawPluginService {
+ let runtime: AcpxRuntimeLike | null = null;
+ let lifecycleRevision = 0;
+
+ return {
+ id: "acpx-runtime",
+ async start(ctx: OpenClawPluginServiceContext): Promise {
+ const pluginConfig = resolveAcpxPluginConfig({
+ rawConfig: params.pluginConfig,
+ workspaceDir: ctx.workspaceDir,
+ });
+ const runtimeFactory = params.runtimeFactory ?? createDefaultRuntime;
+ runtime = runtimeFactory({
+ pluginConfig,
+ queueOwnerTtlSeconds: pluginConfig.queueOwnerTtlSeconds,
+ logger: ctx.logger,
+ });
+
+ registerAcpRuntimeBackend({
+ id: ACPX_BACKEND_ID,
+ runtime,
+ healthy: () => runtime?.isHealthy() ?? false,
+ });
+ ctx.logger.info(
+ `acpx runtime backend registered (command: ${pluginConfig.command}, pinned: ${ACPX_PINNED_VERSION})`,
+ );
+
+ lifecycleRevision += 1;
+ const currentRevision = lifecycleRevision;
+ void (async () => {
+ try {
+ await ensurePinnedAcpx({
+ command: pluginConfig.command,
+ logger: ctx.logger,
+ expectedVersion: ACPX_PINNED_VERSION,
+ });
+ if (currentRevision !== lifecycleRevision) {
+ return;
+ }
+ await runtime?.probeAvailability();
+ if (runtime?.isHealthy()) {
+ ctx.logger.info("acpx runtime backend ready");
+ } else {
+ ctx.logger.warn("acpx runtime backend probe failed after local install");
+ }
+ } catch (err) {
+ if (currentRevision !== lifecycleRevision) {
+ return;
+ }
+ ctx.logger.warn(
+ `acpx runtime setup failed: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+ })();
+ },
+ async stop(_ctx: OpenClawPluginServiceContext): Promise {
+ lifecycleRevision += 1;
+ unregisterAcpRuntimeBackend(ACPX_BACKEND_ID);
+ runtime = null;
+ },
+ };
+}
diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts
index 67fb50a78c63..f25e47d50e62 100644
--- a/extensions/bluebubbles/src/monitor-processing.ts
+++ b/extensions/bluebubbles/src/monitor-processing.ts
@@ -7,8 +7,7 @@ import {
logTypingFailure,
recordPendingHistoryEntryIfEnabled,
resolveAckReaction,
- resolveDmGroupAccessDecision,
- resolveEffectiveAllowFromLists,
+ resolveDmGroupAccessWithLists,
resolveControlCommandGate,
stripMarkdown,
type HistoryEntry,
@@ -504,24 +503,13 @@ export async function processMessage(
const storeAllowFrom = await core.channel.pairing
.readAllowFromStore("bluebubbles")
.catch(() => []);
- const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({
- allowFrom: account.config.allowFrom,
- groupAllowFrom: account.config.groupAllowFrom,
- storeAllowFrom,
- dmPolicy,
- });
- const groupAllowEntry = formatGroupAllowlistEntry({
- chatGuid: message.chatGuid,
- chatId: message.chatId ?? undefined,
- chatIdentifier: message.chatIdentifier ?? undefined,
- });
- const groupName = message.chatName?.trim() || undefined;
- const accessDecision = resolveDmGroupAccessDecision({
+ const accessDecision = resolveDmGroupAccessWithLists({
isGroup,
dmPolicy,
groupPolicy,
- effectiveAllowFrom,
- effectiveGroupAllowFrom,
+ allowFrom: account.config.allowFrom,
+ groupAllowFrom: account.config.groupAllowFrom,
+ storeAllowFrom,
isSenderAllowed: (allowFrom) =>
isAllowedBlueBubblesSender({
allowFrom,
@@ -531,6 +519,14 @@ export async function processMessage(
chatIdentifier: message.chatIdentifier ?? undefined,
}),
});
+ const effectiveAllowFrom = accessDecision.effectiveAllowFrom;
+ const effectiveGroupAllowFrom = accessDecision.effectiveGroupAllowFrom;
+ const groupAllowEntry = formatGroupAllowlistEntry({
+ chatGuid: message.chatGuid,
+ chatId: message.chatId ?? undefined,
+ chatIdentifier: message.chatIdentifier ?? undefined,
+ });
+ const groupName = message.chatName?.trim() || undefined;
if (accessDecision.decision !== "allow") {
if (isGroup) {
@@ -1389,18 +1385,13 @@ export async function processReaction(
const storeAllowFrom = await core.channel.pairing
.readAllowFromStore("bluebubbles")
.catch(() => []);
- const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({
- allowFrom: account.config.allowFrom,
- groupAllowFrom: account.config.groupAllowFrom,
- storeAllowFrom,
- dmPolicy,
- });
- const accessDecision = resolveDmGroupAccessDecision({
+ const accessDecision = resolveDmGroupAccessWithLists({
isGroup: reaction.isGroup,
dmPolicy,
groupPolicy,
- effectiveAllowFrom,
- effectiveGroupAllowFrom,
+ allowFrom: account.config.allowFrom,
+ groupAllowFrom: account.config.groupAllowFrom,
+ storeAllowFrom,
isSenderAllowed: (allowFrom) =>
isAllowedBlueBubblesSender({
allowFrom,
diff --git a/extensions/line/src/channel.startup.test.ts b/extensions/line/src/channel.startup.test.ts
index e5b0ce333f5c..812636113cb9 100644
--- a/extensions/line/src/channel.startup.test.ts
+++ b/extensions/line/src/channel.startup.test.ts
@@ -37,6 +37,7 @@ function createStartAccountCtx(params: {
token: string;
secret: string;
runtime: ReturnType;
+ abortSignal?: AbortSignal;
}): ChannelGatewayContext {
const snapshot: ChannelAccountSnapshot = {
accountId: "default",
@@ -56,7 +57,7 @@ function createStartAccountCtx(params: {
},
cfg: {} as OpenClawConfig,
runtime: params.runtime,
- abortSignal: new AbortController().signal,
+ abortSignal: params.abortSignal ?? new AbortController().signal,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
getStatus: () => snapshot,
setStatus: vi.fn(),
@@ -104,14 +105,19 @@ describe("linePlugin gateway.startAccount", () => {
const { runtime, monitorLineProvider } = createRuntime();
setLineRuntime(runtime);
- await linePlugin.gateway!.startAccount!(
+ const abort = new AbortController();
+ const task = linePlugin.gateway!.startAccount!(
createStartAccountCtx({
token: "token",
secret: "secret",
runtime: createRuntimeEnv(),
+ abortSignal: abort.signal,
}),
);
+ // Allow async internals (probeLineBot await) to flush
+ await new Promise((r) => setTimeout(r, 20));
+
expect(monitorLineProvider).toHaveBeenCalledWith(
expect.objectContaining({
channelAccessToken: "token",
@@ -119,5 +125,8 @@ describe("linePlugin gateway.startAccount", () => {
accountId: "default",
}),
);
+
+ abort.abort();
+ await task;
});
});
diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts
index a260d96c9615..1c87ad8e2f3f 100644
--- a/extensions/line/src/channel.ts
+++ b/extensions/line/src/channel.ts
@@ -651,7 +651,7 @@ export const linePlugin: ChannelPlugin = {
ctx.log?.info(`[${account.accountId}] starting LINE provider${lineBotLabel}`);
- return getLineRuntime().channel.line.monitorLineProvider({
+ const monitor = await getLineRuntime().channel.line.monitorLineProvider({
channelAccessToken: token,
channelSecret: secret,
accountId: account.accountId,
@@ -660,6 +660,8 @@ export const linePlugin: ChannelPlugin = {
abortSignal: ctx.abortSignal,
webhookPath: account.config.webhookPath,
});
+
+ return monitor;
},
logoutAccount: async ({ accountId, cfg }) => {
const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() ?? "";
diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts
index 6056c3fef156..af8d8e07e60a 100644
--- a/extensions/mattermost/src/mattermost/monitor.ts
+++ b/extensions/mattermost/src/mattermost/monitor.ts
@@ -17,6 +17,7 @@ import {
recordPendingHistoryEntryIfEnabled,
isDangerousNameMatchingEnabled,
resolveControlCommandGate,
+ resolveDmGroupAccessWithLists,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveChannelMediaMaxBytes,
@@ -883,68 +884,38 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const kind = channelKind(channelInfo.type);
// Enforce DM/group policy and allowlist checks (same as normal messages)
- if (kind === "direct") {
- const dmPolicy = account.config.dmPolicy ?? "pairing";
- if (dmPolicy === "disabled") {
- logVerboseMessage(`mattermost: drop reaction (dmPolicy=disabled sender=${userId})`);
- return;
- }
- // For pairing/allowlist modes, only allow reactions from approved senders
- if (dmPolicy !== "open") {
- const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []);
- const storeAllowFrom = normalizeAllowList(
- dmPolicy === "allowlist"
- ? []
- : await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []),
- );
- const effectiveAllowFrom = Array.from(new Set([...configAllowFrom, ...storeAllowFrom]));
- const allowed = isSenderAllowed({
+ const dmPolicy = account.config.dmPolicy ?? "pairing";
+ const storeAllowFrom = normalizeAllowList(
+ dmPolicy === "allowlist"
+ ? []
+ : await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []),
+ );
+ const reactionAccess = resolveDmGroupAccessWithLists({
+ isGroup: kind !== "direct",
+ dmPolicy,
+ groupPolicy,
+ allowFrom: account.config.allowFrom,
+ groupAllowFrom: account.config.groupAllowFrom,
+ storeAllowFrom,
+ isSenderAllowed: (allowFrom) =>
+ isSenderAllowed({
senderId: userId,
senderName,
- allowFrom: effectiveAllowFrom,
+ allowFrom: normalizeAllowList(allowFrom),
allowNameMatching,
- });
- if (!allowed) {
- logVerboseMessage(
- `mattermost: drop reaction (dmPolicy=${dmPolicy} sender=${userId} not allowed)`,
- );
- return;
- }
- }
- } else if (kind) {
- if (groupPolicy === "disabled") {
- logVerboseMessage(`mattermost: drop reaction (groupPolicy=disabled channel=${channelId})`);
- return;
- }
- if (groupPolicy === "allowlist") {
- const dmPolicyForStore = account.config.dmPolicy ?? "pairing";
- const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []);
- const configGroupAllowFrom = normalizeAllowList(account.config.groupAllowFrom ?? []);
- const storeAllowFrom = normalizeAllowList(
- dmPolicyForStore === "allowlist"
- ? []
- : await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []),
+ }),
+ });
+ if (reactionAccess.decision !== "allow") {
+ if (kind === "direct") {
+ logVerboseMessage(
+ `mattermost: drop reaction (dmPolicy=${dmPolicy} sender=${userId} reason=${reactionAccess.reason})`,
);
- const effectiveGroupAllowFrom = Array.from(
- new Set([
- ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
- ...storeAllowFrom,
- ]),
+ } else {
+ logVerboseMessage(
+ `mattermost: drop reaction (groupPolicy=${groupPolicy} sender=${userId} reason=${reactionAccess.reason} channel=${channelId})`,
);
- // Drop when allowlist is empty (same as normal message handler)
- const allowed =
- effectiveGroupAllowFrom.length > 0 &&
- isSenderAllowed({
- senderId: userId,
- senderName,
- allowFrom: effectiveGroupAllowFrom,
- allowNameMatching,
- });
- if (!allowed) {
- logVerboseMessage(`mattermost: drop reaction (groupPolicy=allowlist sender=${userId})`);
- return;
- }
}
+ return;
}
const teamId = channelInfo?.team_id ?? undefined;
diff --git a/extensions/msteams/src/monitor-handler.file-consent.test.ts b/extensions/msteams/src/monitor-handler.file-consent.test.ts
new file mode 100644
index 000000000000..804ce58107c8
--- /dev/null
+++ b/extensions/msteams/src/monitor-handler.file-consent.test.ts
@@ -0,0 +1,220 @@
+import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { MSTeamsConversationStore } from "./conversation-store.js";
+import type { MSTeamsAdapter } from "./messenger.js";
+import {
+ type MSTeamsActivityHandler,
+ type MSTeamsMessageHandlerDeps,
+ registerMSTeamsHandlers,
+} from "./monitor-handler.js";
+import { clearPendingUploads, getPendingUpload, storePendingUpload } from "./pending-uploads.js";
+import type { MSTeamsPollStore } from "./polls.js";
+import { setMSTeamsRuntime } from "./runtime.js";
+import type { MSTeamsTurnContext } from "./sdk-types.js";
+
+const fileConsentMockState = vi.hoisted(() => ({
+ uploadToConsentUrl: vi.fn(),
+}));
+
+vi.mock("./file-consent.js", async () => {
+ const actual = await vi.importActual("./file-consent.js");
+ return {
+ ...actual,
+ uploadToConsentUrl: fileConsentMockState.uploadToConsentUrl,
+ };
+});
+
+const runtimeStub: PluginRuntime = {
+ logging: {
+ shouldLogVerbose: () => false,
+ },
+ channel: {
+ debounce: {
+ resolveInboundDebounceMs: () => 0,
+ createInboundDebouncer: () => ({
+ enqueue: async () => {},
+ }),
+ },
+ },
+} as unknown as PluginRuntime;
+
+function createDeps(): MSTeamsMessageHandlerDeps {
+ const adapter: MSTeamsAdapter = {
+ continueConversation: async () => {},
+ process: async () => {},
+ };
+ const conversationStore: MSTeamsConversationStore = {
+ upsert: async () => {},
+ get: async () => null,
+ list: async () => [],
+ remove: async () => false,
+ findByUserId: async () => null,
+ };
+ const pollStore: MSTeamsPollStore = {
+ createPoll: async () => {},
+ getPoll: async () => null,
+ recordVote: async () => null,
+ };
+ return {
+ cfg: {} as OpenClawConfig,
+ runtime: {
+ error: vi.fn(),
+ } as unknown as RuntimeEnv,
+ appId: "test-app-id",
+ adapter,
+ tokenProvider: {
+ getAccessToken: async () => "token",
+ },
+ textLimit: 4000,
+ mediaMaxBytes: 8 * 1024 * 1024,
+ conversationStore,
+ pollStore,
+ log: {
+ info: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ },
+ };
+}
+
+function createActivityHandler(): MSTeamsActivityHandler {
+ let handler: MSTeamsActivityHandler;
+ handler = {
+ onMessage: () => handler,
+ onMembersAdded: () => handler,
+ run: async () => {},
+ };
+ return handler;
+}
+
+function createInvokeContext(params: {
+ conversationId: string;
+ uploadId: string;
+ action: "accept" | "decline";
+}): { context: MSTeamsTurnContext; sendActivity: ReturnType } {
+ const sendActivity = vi.fn(async () => ({ id: "activity-id" }));
+ const uploadInfo =
+ params.action === "accept"
+ ? {
+ name: "secret.txt",
+ uploadUrl: "https://upload.example.com/put",
+ contentUrl: "https://content.example.com/file",
+ uniqueId: "unique-id",
+ fileType: "txt",
+ }
+ : undefined;
+ return {
+ context: {
+ activity: {
+ type: "invoke",
+ name: "fileConsent/invoke",
+ conversation: { id: params.conversationId },
+ value: {
+ type: "fileUpload",
+ action: params.action,
+ uploadInfo,
+ context: { uploadId: params.uploadId },
+ },
+ },
+ sendActivity,
+ sendActivities: async () => [],
+ } as unknown as MSTeamsTurnContext,
+ sendActivity,
+ };
+}
+
+describe("msteams file consent invoke authz", () => {
+ beforeEach(() => {
+ setMSTeamsRuntime(runtimeStub);
+ clearPendingUploads();
+ fileConsentMockState.uploadToConsentUrl.mockReset();
+ fileConsentMockState.uploadToConsentUrl.mockResolvedValue(undefined);
+ });
+
+ it("uploads when invoke conversation matches pending upload conversation", async () => {
+ const uploadId = storePendingUpload({
+ buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"),
+ filename: "secret.txt",
+ contentType: "text/plain",
+ conversationId: "19:victim@thread.v2",
+ });
+ const deps = createDeps();
+ const handler = registerMSTeamsHandlers(createActivityHandler(), deps);
+ const { context, sendActivity } = createInvokeContext({
+ conversationId: "19:victim@thread.v2;messageid=abc123",
+ uploadId,
+ action: "accept",
+ });
+
+ await handler.run?.(context);
+
+ expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1);
+ expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledWith(
+ expect.objectContaining({
+ url: "https://upload.example.com/put",
+ }),
+ );
+ expect(getPendingUpload(uploadId)).toBeUndefined();
+ expect(sendActivity).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "invokeResponse",
+ }),
+ );
+ });
+
+ it("rejects cross-conversation accept invoke and keeps pending upload", async () => {
+ const uploadId = storePendingUpload({
+ buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"),
+ filename: "secret.txt",
+ contentType: "text/plain",
+ conversationId: "19:victim@thread.v2",
+ });
+ const deps = createDeps();
+ const handler = registerMSTeamsHandlers(createActivityHandler(), deps);
+ const { context, sendActivity } = createInvokeContext({
+ conversationId: "19:attacker@thread.v2",
+ uploadId,
+ action: "accept",
+ });
+
+ await handler.run?.(context);
+
+ expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
+ expect(getPendingUpload(uploadId)).toBeDefined();
+ expect(sendActivity).toHaveBeenCalledWith(
+ "The file upload request has expired. Please try sending the file again.",
+ );
+ expect(sendActivity).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "invokeResponse",
+ }),
+ );
+ });
+
+ it("ignores cross-conversation decline invoke and keeps pending upload", async () => {
+ const uploadId = storePendingUpload({
+ buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"),
+ filename: "secret.txt",
+ contentType: "text/plain",
+ conversationId: "19:victim@thread.v2",
+ });
+ const deps = createDeps();
+ const handler = registerMSTeamsHandlers(createActivityHandler(), deps);
+ const { context, sendActivity } = createInvokeContext({
+ conversationId: "19:attacker@thread.v2",
+ uploadId,
+ action: "decline",
+ });
+
+ await handler.run?.(context);
+
+ expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
+ expect(getPendingUpload(uploadId)).toBeDefined();
+ expect(sendActivity).toHaveBeenCalledTimes(1);
+ expect(sendActivity).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "invokeResponse",
+ }),
+ );
+ });
+});
diff --git a/extensions/msteams/src/monitor-handler.ts b/extensions/msteams/src/monitor-handler.ts
index d4b848fde5ae..086b82d496a7 100644
--- a/extensions/msteams/src/monitor-handler.ts
+++ b/extensions/msteams/src/monitor-handler.ts
@@ -1,6 +1,7 @@
import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk";
import type { MSTeamsConversationStore } from "./conversation-store.js";
import { buildFileInfoCard, parseFileConsentInvoke, uploadToConsentUrl } from "./file-consent.js";
+import { normalizeMSTeamsConversationId } from "./inbound.js";
import type { MSTeamsAdapter } from "./messenger.js";
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
@@ -42,6 +43,8 @@ async function handleFileConsentInvoke(
context: MSTeamsTurnContext,
log: MSTeamsMonitorLogger,
): Promise {
+ const expiredUploadMessage =
+ "The file upload request has expired. Please try sending the file again.";
const activity = context.activity;
if (activity.type !== "invoke" || activity.name !== "fileConsent/invoke") {
return false;
@@ -57,9 +60,24 @@ async function handleFileConsentInvoke(
typeof consentResponse.context?.uploadId === "string"
? consentResponse.context.uploadId
: undefined;
+ const pendingFile = getPendingUpload(uploadId);
+ if (pendingFile) {
+ const pendingConversationId = normalizeMSTeamsConversationId(pendingFile.conversationId);
+ const invokeConversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "");
+ if (!invokeConversationId || pendingConversationId !== invokeConversationId) {
+ log.info("file consent conversation mismatch", {
+ uploadId,
+ expectedConversationId: pendingConversationId,
+ receivedConversationId: invokeConversationId || undefined,
+ });
+ if (consentResponse.action === "accept") {
+ await context.sendActivity(expiredUploadMessage);
+ }
+ return true;
+ }
+ }
if (consentResponse.action === "accept" && consentResponse.uploadInfo) {
- const pendingFile = getPendingUpload(uploadId);
if (pendingFile) {
log.debug?.("user accepted file consent, uploading", {
uploadId,
@@ -101,9 +119,7 @@ async function handleFileConsentInvoke(
}
} else {
log.debug?.("pending file not found for consent", { uploadId });
- await context.sendActivity(
- "The file upload request has expired. Please try sending the file again.",
- );
+ await context.sendActivity(expiredUploadMessage);
}
} else {
// User declined
diff --git a/extensions/nextcloud-talk/src/monitor.auth-order.test.ts b/extensions/nextcloud-talk/src/monitor.auth-order.test.ts
index f2b4b65054d9..6cc149dde47e 100644
--- a/extensions/nextcloud-talk/src/monitor.auth-order.test.ts
+++ b/extensions/nextcloud-talk/src/monitor.auth-order.test.ts
@@ -1,50 +1,5 @@
-import { type AddressInfo } from "node:net";
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { createNextcloudTalkWebhookServer } from "./monitor.js";
-
-type WebhookHarness = {
- webhookUrl: string;
- stop: () => Promise;
-};
-
-const cleanupFns: Array<() => Promise> = [];
-
-afterEach(async () => {
- while (cleanupFns.length > 0) {
- const cleanup = cleanupFns.pop();
- if (cleanup) {
- await cleanup();
- }
- }
-});
-
-async function startWebhookServer(params: {
- path: string;
- maxBodyBytes: number;
- readBody?: (req: import("node:http").IncomingMessage, maxBodyBytes: number) => Promise;
-}): Promise {
- const { server, start } = createNextcloudTalkWebhookServer({
- port: 0,
- host: "127.0.0.1",
- path: params.path,
- secret: "nextcloud-secret",
- maxBodyBytes: params.maxBodyBytes,
- readBody: params.readBody,
- onMessage: vi.fn(),
- });
- await start();
- const address = server.address() as AddressInfo | null;
- if (!address) {
- throw new Error("missing server address");
- }
- return {
- webhookUrl: `http://127.0.0.1:${address.port}${params.path}`,
- stop: () =>
- new Promise((resolve) => {
- server.close(() => resolve());
- }),
- };
-}
+import { describe, expect, it, vi } from "vitest";
+import { startWebhookServer } from "./monitor.test-harness.js";
describe("createNextcloudTalkWebhookServer auth order", () => {
it("rejects missing signature headers before reading request body", async () => {
@@ -55,8 +10,8 @@ describe("createNextcloudTalkWebhookServer auth order", () => {
path: "/nextcloud-auth-order",
maxBodyBytes: 128,
readBody,
+ onMessage: vi.fn(),
});
- cleanupFns.push(harness.stop);
const response = await fetch(harness.webhookUrl, {
method: "POST",
diff --git a/extensions/nextcloud-talk/src/monitor.backend.test.ts b/extensions/nextcloud-talk/src/monitor.backend.test.ts
new file mode 100644
index 000000000000..aaf9a30a9c84
--- /dev/null
+++ b/extensions/nextcloud-talk/src/monitor.backend.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it, vi } from "vitest";
+import { startWebhookServer } from "./monitor.test-harness.js";
+import { generateNextcloudTalkSignature } from "./signature.js";
+
+describe("createNextcloudTalkWebhookServer backend allowlist", () => {
+ it("rejects requests from unexpected backend origins", async () => {
+ const onMessage = vi.fn(async () => {});
+ const harness = await startWebhookServer({
+ path: "/nextcloud-backend-check",
+ isBackendAllowed: (backend) => backend === "https://nextcloud.expected",
+ onMessage,
+ });
+
+ const payload = {
+ type: "Create",
+ actor: { type: "Person", id: "alice", name: "Alice" },
+ object: {
+ type: "Note",
+ id: "msg-1",
+ name: "hello",
+ content: "hello",
+ mediaType: "text/plain",
+ },
+ target: { type: "Collection", id: "room-1", name: "Room 1" },
+ };
+ const body = JSON.stringify(payload);
+ const { random, signature } = generateNextcloudTalkSignature({
+ body,
+ secret: "nextcloud-secret",
+ });
+ const response = await fetch(harness.webhookUrl, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-nextcloud-talk-random": random,
+ "x-nextcloud-talk-signature": signature,
+ "x-nextcloud-talk-backend": "https://nextcloud.unexpected",
+ },
+ body,
+ });
+
+ expect(response.status).toBe(401);
+ expect(await response.json()).toEqual({ error: "Invalid backend" });
+ expect(onMessage).not.toHaveBeenCalled();
+ });
+});
diff --git a/extensions/nextcloud-talk/src/monitor.replay.test.ts b/extensions/nextcloud-talk/src/monitor.replay.test.ts
new file mode 100644
index 000000000000..387e7a8304fc
--- /dev/null
+++ b/extensions/nextcloud-talk/src/monitor.replay.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it, vi } from "vitest";
+import { startWebhookServer } from "./monitor.test-harness.js";
+import { generateNextcloudTalkSignature } from "./signature.js";
+import type { NextcloudTalkInboundMessage } from "./types.js";
+
+function createSignedRequest(body: string): { random: string; signature: string } {
+ return generateNextcloudTalkSignature({
+ body,
+ secret: "nextcloud-secret",
+ });
+}
+
+describe("createNextcloudTalkWebhookServer replay handling", () => {
+ it("acknowledges replayed requests and skips onMessage side effects", async () => {
+ const seen = new Set();
+ const onMessage = vi.fn(async () => {});
+ const shouldProcessMessage = vi.fn(async (message: NextcloudTalkInboundMessage) => {
+ if (seen.has(message.messageId)) {
+ return false;
+ }
+ seen.add(message.messageId);
+ return true;
+ });
+ const harness = await startWebhookServer({
+ path: "/nextcloud-replay",
+ shouldProcessMessage,
+ onMessage,
+ });
+
+ const payload = {
+ type: "Create",
+ actor: { type: "Person", id: "alice", name: "Alice" },
+ object: {
+ type: "Note",
+ id: "msg-1",
+ name: "hello",
+ content: "hello",
+ mediaType: "text/plain",
+ },
+ target: { type: "Collection", id: "room-1", name: "Room 1" },
+ };
+ const body = JSON.stringify(payload);
+ const { random, signature } = createSignedRequest(body);
+ const headers = {
+ "content-type": "application/json",
+ "x-nextcloud-talk-random": random,
+ "x-nextcloud-talk-signature": signature,
+ "x-nextcloud-talk-backend": "https://nextcloud.example",
+ };
+
+ const first = await fetch(harness.webhookUrl, {
+ method: "POST",
+ headers,
+ body,
+ });
+ const second = await fetch(harness.webhookUrl, {
+ method: "POST",
+ headers,
+ body,
+ });
+
+ expect(first.status).toBe(200);
+ expect(second.status).toBe(200);
+ expect(shouldProcessMessage).toHaveBeenCalledTimes(2);
+ expect(onMessage).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/extensions/nextcloud-talk/src/monitor.test-harness.ts b/extensions/nextcloud-talk/src/monitor.test-harness.ts
new file mode 100644
index 000000000000..f0daf42e8d5a
--- /dev/null
+++ b/extensions/nextcloud-talk/src/monitor.test-harness.ts
@@ -0,0 +1,59 @@
+import { type AddressInfo } from "node:net";
+import { afterEach } from "vitest";
+import { createNextcloudTalkWebhookServer } from "./monitor.js";
+import type { NextcloudTalkWebhookServerOptions } from "./types.js";
+
+export type WebhookHarness = {
+ webhookUrl: string;
+ stop: () => Promise;
+};
+
+const cleanupFns: Array<() => Promise> = [];
+
+afterEach(async () => {
+ while (cleanupFns.length > 0) {
+ const cleanup = cleanupFns.pop();
+ if (cleanup) {
+ await cleanup();
+ }
+ }
+});
+
+export type StartWebhookServerParams = Omit<
+ NextcloudTalkWebhookServerOptions,
+ "port" | "host" | "path" | "secret"
+> & {
+ path: string;
+ secret?: string;
+ host?: string;
+ port?: number;
+};
+
+export async function startWebhookServer(
+ params: StartWebhookServerParams,
+): Promise