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 { + const host = params.host ?? "127.0.0.1"; + const port = params.port ?? 0; + const secret = params.secret ?? "nextcloud-secret"; + const { server, start } = createNextcloudTalkWebhookServer({ + ...params, + port, + host, + secret, + }); + await start(); + const address = server.address() as AddressInfo | null; + if (!address) { + throw new Error("missing server address"); + } + + const harness: WebhookHarness = { + webhookUrl: `http://${host}:${address.port}${params.path}`, + stop: () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + }; + cleanupFns.push(harness.stop); + return harness; +} diff --git a/extensions/nextcloud-talk/src/monitor.ts b/extensions/nextcloud-talk/src/monitor.ts index 4b68a3c4d0b8..3fb3da3e75bb 100644 --- a/extensions/nextcloud-talk/src/monitor.ts +++ b/extensions/nextcloud-talk/src/monitor.ts @@ -1,4 +1,5 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import os from "node:os"; import { createLoggerBackedRuntime, type RuntimeEnv, @@ -8,11 +9,13 @@ import { } from "openclaw/plugin-sdk"; import { resolveNextcloudTalkAccount } from "./accounts.js"; import { handleNextcloudTalkInbound } from "./inbound.js"; +import { createNextcloudTalkReplayGuard } from "./replay-guard.js"; import { getNextcloudTalkRuntime } from "./runtime.js"; import { extractNextcloudTalkHeaders, verifyNextcloudTalkSignature } from "./signature.js"; import type { CoreConfig, NextcloudTalkInboundMessage, + NextcloudTalkWebhookHeaders, NextcloudTalkWebhookPayload, NextcloudTalkWebhookServerOptions, } from "./types.js"; @@ -23,6 +26,14 @@ const DEFAULT_WEBHOOK_PATH = "/nextcloud-talk-webhook"; const DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; const DEFAULT_WEBHOOK_BODY_TIMEOUT_MS = 30_000; const HEALTH_PATH = "/healthz"; +const WEBHOOK_ERRORS = { + missingSignatureHeaders: "Missing signature headers", + invalidBackend: "Invalid backend", + invalidSignature: "Invalid signature", + invalidPayloadFormat: "Invalid payload format", + payloadTooLarge: "Payload too large", + internalServerError: "Internal server error", +} as const; function formatError(err: unknown): string { if (err instanceof Error) { @@ -31,6 +42,14 @@ function formatError(err: unknown): string { return typeof err === "string" ? err : JSON.stringify(err); } +function normalizeOrigin(value: string): string | null { + try { + return new URL(value).origin.toLowerCase(); + } catch { + return null; + } +} + function parseWebhookPayload(body: string): NextcloudTalkWebhookPayload | null { try { const data = JSON.parse(body); @@ -51,6 +70,83 @@ function parseWebhookPayload(body: string): NextcloudTalkWebhookPayload | null { } } +function writeJsonResponse( + res: ServerResponse, + status: number, + body?: Record, +): void { + if (body) { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + return; + } + res.writeHead(status); + res.end(); +} + +function writeWebhookError(res: ServerResponse, status: number, error: string): void { + if (res.headersSent) { + return; + } + writeJsonResponse(res, status, { error }); +} + +function validateWebhookHeaders(params: { + req: IncomingMessage; + res: ServerResponse; + isBackendAllowed?: (backend: string) => boolean; +}): NextcloudTalkWebhookHeaders | null { + const headers = extractNextcloudTalkHeaders( + params.req.headers as Record, + ); + if (!headers) { + writeWebhookError(params.res, 400, WEBHOOK_ERRORS.missingSignatureHeaders); + return null; + } + if (params.isBackendAllowed && !params.isBackendAllowed(headers.backend)) { + writeWebhookError(params.res, 401, WEBHOOK_ERRORS.invalidBackend); + return null; + } + return headers; +} + +function verifyWebhookSignature(params: { + headers: NextcloudTalkWebhookHeaders; + body: string; + secret: string; + res: ServerResponse; +}): boolean { + const isValid = verifyNextcloudTalkSignature({ + signature: params.headers.signature, + random: params.headers.random, + body: params.body, + secret: params.secret, + }); + if (!isValid) { + writeWebhookError(params.res, 401, WEBHOOK_ERRORS.invalidSignature); + return false; + } + return true; +} + +function decodeWebhookCreateMessage(params: { + body: string; + res: ServerResponse; +}): + | { kind: "message"; message: NextcloudTalkInboundMessage } + | { kind: "ignore" } + | { kind: "invalid" } { + const payload = parseWebhookPayload(params.body); + if (!payload) { + writeWebhookError(params.res, 400, WEBHOOK_ERRORS.invalidPayloadFormat); + return { kind: "invalid" }; + } + if (payload.type !== "Create") { + return { kind: "ignore" }; + } + return { kind: "message", message: payloadToInboundMessage(payload) }; +} + function payloadToInboundMessage( payload: NextcloudTalkWebhookPayload, ): NextcloudTalkInboundMessage { @@ -93,6 +189,8 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe ? Math.floor(opts.maxBodyBytes) : DEFAULT_WEBHOOK_MAX_BODY_BYTES; const readBody = opts.readBody ?? readNextcloudTalkWebhookBody; + const isBackendAllowed = opts.isBackendAllowed; + const shouldProcessMessage = opts.shouldProcessMessage; const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { if (req.url === HEALTH_PATH) { @@ -108,47 +206,49 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe } try { - const headers = extractNextcloudTalkHeaders( - req.headers as Record, - ); + const headers = validateWebhookHeaders({ + req, + res, + isBackendAllowed, + }); if (!headers) { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Missing signature headers" })); return; } const body = await readBody(req, maxBodyBytes); - const isValid = verifyNextcloudTalkSignature({ - signature: headers.signature, - random: headers.random, + const hasValidSignature = verifyWebhookSignature({ + headers, body, secret, + res, }); - - if (!isValid) { - res.writeHead(401, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Invalid signature" })); + if (!hasValidSignature) { return; } - const payload = parseWebhookPayload(body); - if (!payload) { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Invalid payload format" })); + const decoded = decodeWebhookCreateMessage({ + body, + res, + }); + if (decoded.kind === "invalid") { return; } - - if (payload.type !== "Create") { - res.writeHead(200); - res.end(); + if (decoded.kind === "ignore") { + writeJsonResponse(res, 200); return; } - const message = payloadToInboundMessage(payload); + const message = decoded.message; + if (shouldProcessMessage) { + const shouldProcess = await shouldProcessMessage(message); + if (!shouldProcess) { + writeJsonResponse(res, 200); + return; + } + } - res.writeHead(200); - res.end(); + writeJsonResponse(res, 200); try { await onMessage(message); @@ -157,25 +257,16 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe } } catch (err) { if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) { - if (!res.headersSent) { - res.writeHead(413, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Payload too large" })); - } + writeWebhookError(res, 413, WEBHOOK_ERRORS.payloadTooLarge); return; } if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) { - if (!res.headersSent) { - res.writeHead(408, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") })); - } + writeWebhookError(res, 408, requestBodyErrorToText("REQUEST_BODY_TIMEOUT")); return; } const error = err instanceof Error ? err : new Error(formatError(err)); onError?.(error); - if (!res.headersSent) { - res.writeHead(500, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Internal server error" })); - } + writeWebhookError(res, 500, WEBHOOK_ERRORS.internalServerError); } }); @@ -233,12 +324,41 @@ export async function monitorNextcloudTalkProvider( channel: "nextcloud-talk", accountId: account.accountId, }); + const expectedBackendOrigin = normalizeOrigin(account.baseUrl); + const replayGuard = createNextcloudTalkReplayGuard({ + stateDir: core.state.resolveStateDir(process.env, os.homedir), + onDiskError: (error) => { + logger.warn( + `[nextcloud-talk:${account.accountId}] replay guard disk error: ${String(error)}`, + ); + }, + }); const { start, stop } = createNextcloudTalkWebhookServer({ port, host, path, secret: account.secret, + isBackendAllowed: (backend) => { + if (!expectedBackendOrigin) { + return true; + } + const backendOrigin = normalizeOrigin(backend); + return backendOrigin === expectedBackendOrigin; + }, + shouldProcessMessage: async (message) => { + const shouldProcess = await replayGuard.shouldProcessMessage({ + accountId: account.accountId, + roomToken: message.roomToken, + messageId: message.messageId, + }); + if (!shouldProcess) { + logger.warn( + `[nextcloud-talk:${account.accountId}] replayed webhook ignored room=${message.roomToken} messageId=${message.messageId}`, + ); + } + return shouldProcess; + }, onMessage: async (message) => { core.channel.activity.record({ channel: "nextcloud-talk", diff --git a/extensions/nextcloud-talk/src/replay-guard.test.ts b/extensions/nextcloud-talk/src/replay-guard.test.ts new file mode 100644 index 000000000000..0bf18acb600e --- /dev/null +++ b/extensions/nextcloud-talk/src/replay-guard.test.ts @@ -0,0 +1,70 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createNextcloudTalkReplayGuard } from "./replay-guard.js"; + +const tempDirs: string[] = []; + +afterEach(async () => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + await rm(dir, { recursive: true, force: true }); + } + } +}); + +async function makeTempDir(): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), "nextcloud-talk-replay-")); + tempDirs.push(dir); + return dir; +} + +describe("createNextcloudTalkReplayGuard", () => { + it("persists replay decisions across guard instances", async () => { + const stateDir = await makeTempDir(); + + const firstGuard = createNextcloudTalkReplayGuard({ stateDir }); + const firstAttempt = await firstGuard.shouldProcessMessage({ + accountId: "account-a", + roomToken: "room-1", + messageId: "msg-1", + }); + const replayAttempt = await firstGuard.shouldProcessMessage({ + accountId: "account-a", + roomToken: "room-1", + messageId: "msg-1", + }); + + const secondGuard = createNextcloudTalkReplayGuard({ stateDir }); + const restartReplayAttempt = await secondGuard.shouldProcessMessage({ + accountId: "account-a", + roomToken: "room-1", + messageId: "msg-1", + }); + + expect(firstAttempt).toBe(true); + expect(replayAttempt).toBe(false); + expect(restartReplayAttempt).toBe(false); + }); + + it("scopes replay state by account namespace", async () => { + const stateDir = await makeTempDir(); + const guard = createNextcloudTalkReplayGuard({ stateDir }); + + const accountAFirst = await guard.shouldProcessMessage({ + accountId: "account-a", + roomToken: "room-1", + messageId: "msg-9", + }); + const accountBFirst = await guard.shouldProcessMessage({ + accountId: "account-b", + roomToken: "room-1", + messageId: "msg-9", + }); + + expect(accountAFirst).toBe(true); + expect(accountBFirst).toBe(true); + }); +}); diff --git a/extensions/nextcloud-talk/src/replay-guard.ts b/extensions/nextcloud-talk/src/replay-guard.ts new file mode 100644 index 000000000000..14b074ed2ab6 --- /dev/null +++ b/extensions/nextcloud-talk/src/replay-guard.ts @@ -0,0 +1,65 @@ +import path from "node:path"; +import { createPersistentDedupe } from "openclaw/plugin-sdk"; + +const DEFAULT_REPLAY_TTL_MS = 24 * 60 * 60 * 1000; +const DEFAULT_MEMORY_MAX_SIZE = 1_000; +const DEFAULT_FILE_MAX_ENTRIES = 10_000; + +function sanitizeSegment(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return "default"; + } + return trimmed.replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +function buildReplayKey(params: { roomToken: string; messageId: string }): string | null { + const roomToken = params.roomToken.trim(); + const messageId = params.messageId.trim(); + if (!roomToken || !messageId) { + return null; + } + return `${roomToken}:${messageId}`; +} + +export type NextcloudTalkReplayGuardOptions = { + stateDir: string; + ttlMs?: number; + memoryMaxSize?: number; + fileMaxEntries?: number; + onDiskError?: (error: unknown) => void; +}; + +export type NextcloudTalkReplayGuard = { + shouldProcessMessage: (params: { + accountId: string; + roomToken: string; + messageId: string; + }) => Promise; +}; + +export function createNextcloudTalkReplayGuard( + options: NextcloudTalkReplayGuardOptions, +): NextcloudTalkReplayGuard { + const stateDir = options.stateDir.trim(); + const persistentDedupe = createPersistentDedupe({ + ttlMs: options.ttlMs ?? DEFAULT_REPLAY_TTL_MS, + memoryMaxSize: options.memoryMaxSize ?? DEFAULT_MEMORY_MAX_SIZE, + fileMaxEntries: options.fileMaxEntries ?? DEFAULT_FILE_MAX_ENTRIES, + resolveFilePath: (namespace) => + path.join(stateDir, "nextcloud-talk", "replay-dedupe", `${sanitizeSegment(namespace)}.json`), + }); + + return { + shouldProcessMessage: async ({ accountId, roomToken, messageId }) => { + const replayKey = buildReplayKey({ roomToken, messageId }); + if (!replayKey) { + return true; + } + return await persistentDedupe.checkAndRecord(replayKey, { + namespace: accountId, + onDiskError: options.onDiskError, + }); + }, + }; +} diff --git a/extensions/nextcloud-talk/src/types.ts b/extensions/nextcloud-talk/src/types.ts index a9fe49be36da..e7af64a965cc 100644 --- a/extensions/nextcloud-talk/src/types.ts +++ b/extensions/nextcloud-talk/src/types.ts @@ -170,6 +170,8 @@ export type NextcloudTalkWebhookServerOptions = { secret: string; maxBodyBytes?: number; readBody?: (req: import("node:http").IncomingMessage, maxBodyBytes: number) => Promise; + isBackendAllowed?: (backend: string) => boolean; + shouldProcessMessage?: (message: NextcloudTalkInboundMessage) => boolean | Promise; onMessage: (message: NextcloudTalkInboundMessage) => void | Promise; onError?: (error: Error) => void; abortSignal?: AbortSignal; diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json index e4474651f07e..9046055d2f9f 100644 --- a/extensions/synology-chat/package.json +++ b/extensions/synology-chat/package.json @@ -22,7 +22,7 @@ "install": { "npmSpec": "@openclaw/synology-chat", "localPath": "extensions/synology-chat", - "defaultChoice": "npm" + "defaultChoice": "local" } } } diff --git a/package.json b/package.json index 06cafb013384..3602e1d4f827 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "build": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-compat.ts", "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", - "check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging", + "check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", "deadcode:ci": "pnpm deadcode:report:ci:knip && pnpm deadcode:report:ci:ts-prune && pnpm deadcode:report:ci:ts-unused", @@ -98,6 +98,7 @@ "lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix", "lint:fix": "oxlint --type-aware --fix && pnpm format", "lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)", + "lint:tmp:channel-agnostic-boundaries": "node scripts/check-channel-agnostic-boundaries.mjs", "lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs", "lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs", "mac:open": "open dist/OpenClaw.app", @@ -151,7 +152,7 @@ }, "dependencies": { "@agentclientprotocol/sdk": "0.14.1", - "@aws-sdk/client-bedrock": "^3.997.0", + "@aws-sdk/client-bedrock": "^3.998.0", "@buape/carbon": "0.0.0-beta-20260216184201", "@clack/prompts": "^1.0.1", "@discordjs/voice": "^0.19.0", @@ -161,10 +162,10 @@ "@larksuiteoapi/node-sdk": "^1.59.0", "@line/bot-sdk": "^10.6.0", "@lydell/node-pty": "1.2.0-beta.3", - "@mariozechner/pi-agent-core": "0.55.0", - "@mariozechner/pi-ai": "0.55.0", - "@mariozechner/pi-coding-agent": "0.55.0", - "@mariozechner/pi-tui": "0.55.0", + "@mariozechner/pi-agent-core": "0.55.1", + "@mariozechner/pi-ai": "0.55.1", + "@mariozechner/pi-coding-agent": "0.55.1", + "@mariozechner/pi-tui": "0.55.1", "@mozilla/readability": "^0.6.0", "@sinclair/typebox": "0.34.48", "@slack/bolt": "^4.6.0", @@ -216,7 +217,7 @@ "@types/node": "^25.3.0", "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.18.1", - "@typescript/native-preview": "7.0.0-dev.20260224.1", + "@typescript/native-preview": "7.0.0-dev.20260225.1", "@vitest/coverage-v8": "^4.0.18", "lit": "^3.3.2", "oxfmt": "0.35.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6933cba7d0c4..9e828a64f249 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: 0.14.1 version: 0.14.1(zod@4.3.6) '@aws-sdk/client-bedrock': - specifier: ^3.997.0 - version: 3.997.0 + specifier: ^3.998.0 + version: 3.998.0 '@buape/carbon': specifier: 0.0.0-beta-20260216184201 version: 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.1.1) @@ -54,23 +54,23 @@ importers: specifier: 1.2.0-beta.3 version: 1.2.0-beta.3 '@mariozechner/pi-agent-core': - specifier: 0.55.0 - version: 0.55.0(ws@8.19.0)(zod@4.3.6) + specifier: 0.55.1 + version: 0.55.1(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-ai': - specifier: 0.55.0 - version: 0.55.0(ws@8.19.0)(zod@4.3.6) + specifier: 0.55.1 + version: 0.55.1(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-coding-agent': - specifier: 0.55.0 - version: 0.55.0(ws@8.19.0)(zod@4.3.6) + specifier: 0.55.1 + version: 0.55.1(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-tui': - specifier: 0.55.0 - version: 0.55.0 + specifier: 0.55.1 + version: 0.55.1 '@mozilla/readability': specifier: ^0.6.0 version: 0.6.0 '@napi-rs/canvas': specifier: ^0.1.89 - version: 0.1.92 + version: 0.1.95 '@sinclair/typebox': specifier: 0.34.48 version: 0.34.48 @@ -220,8 +220,8 @@ importers: specifier: ^8.18.1 version: 8.18.1 '@typescript/native-preview': - specifier: 7.0.0-dev.20260224.1 - version: 7.0.0-dev.20260224.1 + specifier: 7.0.0-dev.20260225.1 + version: 7.0.0-dev.20260225.1 '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18) @@ -242,7 +242,7 @@ importers: version: 0.21.1(signal-polyfill@0.2.2) tsdown: specifier: ^0.20.3 - version: 0.20.3(@typescript/native-preview@7.0.0-dev.20260224.1)(typescript@5.9.3) + version: 0.20.3(@typescript/native-preview@7.0.0-dev.20260225.1)(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -257,6 +257,12 @@ importers: specifier: ^0.10.0 version: 0.10.0 + extensions/acpx: + dependencies: + acpx: + specifier: ^0.1.13 + version: 0.1.13(zod@4.3.6) + extensions/bluebubbles: {} extensions/copilot-proxy: {} @@ -320,7 +326,7 @@ importers: version: 10.6.1 openclaw: specifier: '>=2026.1.26' - version: 2026.2.23(@napi-rs/canvas@0.1.94)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.15.1(typescript@5.9.3)) + version: 2026.2.24(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.15.1(typescript@5.9.3)) extensions/imessage: {} @@ -356,7 +362,7 @@ importers: dependencies: openclaw: specifier: '>=2026.1.26' - version: 2026.2.23(@napi-rs/canvas@0.1.94)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.15.1(typescript@5.9.3)) + version: 2026.2.24(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.15.1(typescript@5.9.3)) extensions/memory-lancedb: dependencies: @@ -544,235 +550,111 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/client-bedrock-runtime@3.995.0': - resolution: {integrity: sha512-nI7tT11L9s34AKr95GHmxs6k2+3ie+rEOew2cXOwsMC9k/5aifrZwh0JjAkBop4FqbmS8n0ZjCKDjBZFY/0YxQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/client-bedrock-runtime@3.997.0': - resolution: {integrity: sha512-yEgCc/HvI7dLeXQLCuc4cnbzwE/NbNpKX8NmSSWTy3jnjiMZwrNKdHMBgPoNvaEb0klHhnTyO+JCHVVCPI/eYw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/client-bedrock@3.995.0': - resolution: {integrity: sha512-ONw5c7pOeHe78kC+jK2j73hP727Kqp7cc9lZqkfshlBD8MWxXmZM9GihIQLrNBCSUKRhc19NH7DUM6B7uN0mMQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/client-bedrock@3.997.0': - resolution: {integrity: sha512-PMRqxSzfkQHbU7ADVlT4jYLB7beFQWLXN9CGI9D9P8eqCIaDVv3YxTfwcT3FcBVucqktdTBTEowhvKn0whr/rA==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/client-sso@3.993.0': - resolution: {integrity: sha512-VLUN+wIeNX24fg12SCbzTUBnBENlL014yMKZvRhPkcn4wHR6LKgNrjsG3fZ03Xs0XoKaGtNFi1VVrq666sGBoQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/core@3.973.11': - resolution: {integrity: sha512-wdQ8vrvHkKIV7yNUKXyjPWKCdYEUrZTHJ8Ojd5uJxXp9vqPCkUR1dpi1NtOLcrDgueJH7MUH5lQZxshjFPSbDA==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/core@3.973.13': - resolution: {integrity: sha512-eCFiLyBhJR7c/i8hZOETdzj2wsLFzi2L/w9/jajOgwmGqO8xrUExqkTZqdjROkwU62owqeqSuw4sIzlCv1E/ww==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-env@3.972.11': - resolution: {integrity: sha512-hbyoFuVm3qOAGfIPS9t7jCs8GFLFoaOs8ZmYp/chqciuHDyEGv+J365ip7YSvXSrxxUbeW9NyB1hTLt40NBMRg==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-env@3.972.9': - resolution: {integrity: sha512-ZptrOwQynfupubvcngLkbdIq/aXvl/czdpEG8XJ8mN8Nb19BR0jaK0bR+tfuMU36Ez9q4xv7GGkHFqEEP2hUUQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-http@3.972.11': - resolution: {integrity: sha512-hECWoOoH386bGr89NQc9vA/abkGf5TJrMREt+lhNcnSNmoBS04fK7vc3LrJBSQAUGGVj0Tz3f4dHB3w5veovig==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-http@3.972.13': - resolution: {integrity: sha512-a864QxQWFkdCZ5wQF0QZNKTbqAc/DFQNeARp4gOyZZdql5RHjj4CppUSfwAzS9cpw2IPY3eeJjWqLZ1QiDB/6w==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-ini@3.972.11': - resolution: {integrity: sha512-kvPFn626ABLzxmjFMoqMRtmFKMeiUdWPhwxhmuPu233tqHnNuXzHv0MtrZlkzHd+rwlh9j0zCbQo89B54wIazQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-ini@3.972.9': - resolution: {integrity: sha512-zr1csEu9n4eDiHMTYJabX1mDGuGLgjgUnNckIivvk43DocJC9/f6DefFrnUPZXE+GHtbW50YuXb+JIxKykU74A==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-login@3.972.11': - resolution: {integrity: sha512-stdy09EpBTmsxGiXe1vB5qtXNww9wact36/uWLlSV0/vWbCOUAY2JjhPXoDVLk8n+E6r0M5HeZseLk+iTtifxg==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-login@3.972.9': - resolution: {integrity: sha512-m4RIpVgZChv0vWS/HKChg1xLgZPpx8Z+ly9Fv7FwA8SOfuC6I3htcSaBz2Ch4bneRIiBUhwP4ziUo0UZgtJStQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-node@3.972.10': - resolution: {integrity: sha512-70nCESlvnzjo4LjJ8By8MYIiBogkYPSXl3WmMZfH9RZcB/Nt9qVWbFpYj6Fk1vLa4Vk8qagFVeXgxdieMxG1QA==} + '@aws-sdk/client-bedrock-runtime@3.998.0': + resolution: {integrity: sha512-orRgpdNmdRLik+en3xDxlGuT5AxQU+GFUTMn97ZdRuPLnAiY7Y6/8VTsod6y97/3NB8xuTZbH9wNXzW97IWNMA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.12': - resolution: {integrity: sha512-gMWGnHbNSKWRj+PAiuSg0EDpEwpyIgk0v9U6EuZ1C/5/BUv25Way+E+UFB7r+YYkscuBJMJ+ai8E2K0Q8dx50g==} + '@aws-sdk/client-bedrock@3.998.0': + resolution: {integrity: sha512-NeSBIdsJwVtACGHXVoguJOsKhq6oR5Q2B6BUU7LWGqIl1skwPors77aLpOa2240ZFtX3Br/0lJYfxAhB8692KA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.11': - resolution: {integrity: sha512-B049fvbv41vf0Fs5bCtbzHpruBDp61sPiFDxUmkAJ/zvgSAturpj2rqzV1rj2clg4mb44Uxp9rgpcODexNFlFA==} + '@aws-sdk/core@3.973.14': + resolution: {integrity: sha512-iAQ1jIGESTVjoqNNY9VlsE9FnCz+Hc8s+dgurF6WrgFyVIw+uggH+V102RFhwjRv4dLSSLfzjDwvQnLszov7TQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.9': - resolution: {integrity: sha512-gOWl0Fe2gETj5Bk151+LYKpeGi2lBDLNu+NMNpHRlIrKHdBmVun8/AalwMK8ci4uRfG5a3/+zvZBMpuen1SZ0A==} + '@aws-sdk/credential-provider-env@3.972.12': + resolution: {integrity: sha512-WPtj/iAYHHd+NDM6AZoilZwUz0nMaPxbTPGLA7nhyIYRZN2L8trqfbNvm7g/Jr3gzfKp1LpO6AtBTnrhz9WW2g==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.11': - resolution: {integrity: sha512-vX9z8skN8vPtamVWmSCm4KQohub+1uMuRzIo4urZ2ZUMBAl1bqHatVD/roCb3qRfAyIGvZXCA/AWS03BQRMyCQ==} + '@aws-sdk/credential-provider-http@3.972.14': + resolution: {integrity: sha512-umtjCicH2o/Fcc8Fu1562UkDyt6gql4czTYVlUfHfAM8S4QEKggzmtHYYYpPfQcjFj1ajyy68ahYSuF67x4ptQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.9': - resolution: {integrity: sha512-ey7S686foGTArvFhi3ifQXmgptKYvLSGE2250BAQceMSXZddz7sUSNERGJT2S7u5KIe/kgugxrt01hntXVln6w==} + '@aws-sdk/credential-provider-ini@3.972.12': + resolution: {integrity: sha512-qjzgnMl6GIBbVeK74jBqSF07+s6kyeZl5R88qjMs302JlqkxE57jkvflDmZ9I017ffEWqIUa9/M4Hfp28qyu1g==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.11': - resolution: {integrity: sha512-VR2Ju/QBdOjnWNIYuxRml63eFDLGc6Zl8aDwLi1rzgWo3rLBgtaWhWVBAijhVXzyPdQIOqdL8hvll5ybqumjeQ==} + '@aws-sdk/credential-provider-login@3.972.12': + resolution: {integrity: sha512-AO57y46PzG24bJzxWLk+FYJG6MzxvXoFXnOKnmKUGV43ub4/FS/4Rz7zCC6ThqUotgqEFd30l5LTAd65RP65pg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.9': - resolution: {integrity: sha512-8LnfS76nHXoEc9aRRiMMpxZxJeDG0yusdyo3NvPhCgESmBUgpMa4luhGbClW5NoX/qRcGxxM6Z/esqANSNMTow==} + '@aws-sdk/credential-provider-node@3.972.13': + resolution: {integrity: sha512-ME2sgus+gFRtiudy5Xqj9iT/tj8lHOIGrFgktuO5skJU4EngOvTZ1Hpj8mknrW4FgWXmpWhc88NtEscUuuDpKw==} engines: {node: '>=20.0.0'} - '@aws-sdk/eventstream-handler-node@3.972.5': - resolution: {integrity: sha512-xEmd3dnyn83K6t4AJxBJA63wpEoCD45ERFG0XMTViD2E/Ohls9TLxjOWPb1PAxR9/46cKy/TImez1GoqP6xVNQ==} + '@aws-sdk/credential-provider-process@3.972.12': + resolution: {integrity: sha512-msxrHBpVP5AOIDohNPCINUtL47f7XI1TEru3N13uM3nWUMvIRA1vFa8Tlxbxm1EntPPvLAxRmvE5EbjDjOZkbw==} engines: {node: '>=20.0.0'} - '@aws-sdk/eventstream-handler-node@3.972.7': - resolution: {integrity: sha512-p8k2ZWKJVrR3KIcBbI+/+FcWXdwe3LLgGnixsA7w8lDwWjzSVDHFp6uPeSqBt5PQpRxzak9EheJ1xTmOnHGf4g==} + '@aws-sdk/credential-provider-sso@3.972.12': + resolution: {integrity: sha512-D5iC5546hJyhobJN0szOT4KVeJQ8z/meZq2B3lEDZFcvHONKw+tzq36DAJUy3qLTueeB2geSxiHXngQlA11eoA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-eventstream@3.972.3': - resolution: {integrity: sha512-pbvZ6Ye/Ks6BAZPa3RhsNjHrvxU9li25PMhSdDpbX0jzdpKpAkIR65gXSNKmA/REnSdEMWSD4vKUW+5eMFzB6w==} + '@aws-sdk/credential-provider-web-identity@3.972.12': + resolution: {integrity: sha512-yluBahBVsduoA/zgV0NAXtwwXvQ6tNn95dNA3Hg+vISdiPWA46QY0d9PLO2KpNbjtm+1oGcWxemS4fYTwJ0W1w==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-eventstream@3.972.4': - resolution: {integrity: sha512-0t+2Dn46cRE9iu5ynUXINBtR0wNHi/Jz3FbrqS5k3dGot2O7Ln1xCqXbJUAtGM5ZAqN77SbnpETAgVWC84DeoA==} + '@aws-sdk/eventstream-handler-node@3.972.8': + resolution: {integrity: sha512-tVrf8X7hKnqv3HyVraUbsQW5mfHlD++S5NSIbfQEx0sCRvIwUbTPDl/lJCxhNmZ2zjgUyBIXIKrWilFWBxzv+w==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-host-header@3.972.3': - resolution: {integrity: sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==} + '@aws-sdk/middleware-eventstream@3.972.5': + resolution: {integrity: sha512-j8sFerTrzS9tEJhiW2k+T9hsELE+13D5H+mqMjTRyPSgAOebkiK9d4t8vjbLOXuk7yi5lop40x15MubgcjpLmQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-host-header@3.972.4': - resolution: {integrity: sha512-4q2Vg7/zOB10huDBLjzzTwVjBpG22X3J3ief2XrJEgTaANZrNfA3/cGbCVNAibSbu/nIYA7tDk8WCdsIzDDc4Q==} + '@aws-sdk/middleware-host-header@3.972.5': + resolution: {integrity: sha512-dVA0m1cEQ2iA6yB19aHvWNeUVTuvTt3AXzT0aiIu2uxk0S7AcmwDCDaRgYa/v+eFHcJVxEnpYTozqA7X62xinw==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-logger@3.972.3': - resolution: {integrity: sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==} + '@aws-sdk/middleware-logger@3.972.5': + resolution: {integrity: sha512-03RqplLZjUTkYi0dDPR/bbOLnDLFNdaVvNENgA3XK7Ph1MhEBhUYlgoGfOyRAKApDZ+WG4ykOoA8jI8J04jmFA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-logger@3.972.4': - resolution: {integrity: sha512-xFqPvTysuZAHSkdygT+ken/5rzkR7fhOoDPejAJQslZpp0XBepmCJnDOqA57ERtCTBpu8wpjTFI1ETd4S0AXEw==} + '@aws-sdk/middleware-recursion-detection@3.972.5': + resolution: {integrity: sha512-2QSuuVkpHTe84+mDdnFjHX8rAP3g0yYwLVAhS3lQN1rW5Z/zNsf8/pYQrLjLO4n4sPCsUAkTa0Vrod0lk+o1Tg==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-recursion-detection@3.972.3': - resolution: {integrity: sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==} + '@aws-sdk/middleware-user-agent@3.972.14': + resolution: {integrity: sha512-PzDz+yRAQuIzd+4ZY3s6/TYRzlNKAn4Gae3E5uLV7NnYHqrZHFoAfKE4beXcu3C51pA2/FQ3X2qOGSYqUoN1WQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-recursion-detection@3.972.4': - resolution: {integrity: sha512-tVbRaayUZ7y2bOb02hC3oEPTqQf2A0HpPDwdMl1qTmye/q8Mq1F1WiIoFkQwG/YQFvbyErYIDMbYzIlxzzLtjQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-user-agent@3.972.11': - resolution: {integrity: sha512-R8CvPsPHXwzIHCAza+bllY6PrctEk4lYq/SkHJz9NLoBHCcKQrbOcsfXxO6xmipSbUNIbNIUhH0lBsJGgsRdiw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-user-agent@3.972.13': - resolution: {integrity: sha512-p1kVYbzBxRmhuOHoL/ANJPCedqUxnVgkEjxPoxt5pQv/yzppHM7aBWciYEE9TZY59M421D3GjLfZIZBoEFboVQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-websocket@3.972.6': - resolution: {integrity: sha512-1DedO6N3m8zQ/vG6twNiHtsdwBgk773VdavLEbB3NXeKZDlzSK1BTviqWwvJdKx5UnIy4kGGP6WWpCEFEt/bhQ==} - engines: {node: '>= 14.0.0'} - - '@aws-sdk/middleware-websocket@3.972.8': - resolution: {integrity: sha512-KPUXz8lRw73Rh12/QkELxiryC9Wi9Ah1xNzFe2Vtbz2/81c2ZA0yM8er+u0iCF/SRMMhDQshLcmRNgn/ueA+gA==} + '@aws-sdk/middleware-websocket@3.972.9': + resolution: {integrity: sha512-O+FSwU9UvKd+QNuGLHqvmP33kkH4jh8pAgdMo3wbFLf+u30fS9/2gbSSWWtNCcWkSNFyG6RUlKU7jPSLApFfGw==} engines: {node: '>= 14.0.0'} - '@aws-sdk/nested-clients@3.993.0': - resolution: {integrity: sha512-iOq86f2H67924kQUIPOAvlmMaOAvOLoDOIb66I2YqSUpMYB6ufiuJW3RlREgskxv86S5qKzMnfy/X6CqMjK6XQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/nested-clients@3.995.0': - resolution: {integrity: sha512-7gq9gismVhESiRsSt0eYe1y1b6jS20LqLk+e/YSyPmGi9yHdndHQLIq73RbEJnK/QPpkQGFqq70M1mI46M1HGw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/nested-clients@3.996.1': - resolution: {integrity: sha512-XHVLFRGkuV2gh2uwBahCt65ALMb5wMpqplXEZIvFnWOCPlk60B7h7M5J9Em243K8iICDiWY6KhBEqVGfjTqlLA==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/region-config-resolver@3.972.3': - resolution: {integrity: sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/region-config-resolver@3.972.4': - resolution: {integrity: sha512-3GrJYv5eI65oCKveBZP7Q246dVP+tqeys9aKMB0dfX1glUWfppWlxIu52derqdNb9BX9lxYmeiaBcBIqOAYSgQ==} + '@aws-sdk/nested-clients@3.996.2': + resolution: {integrity: sha512-W+u6EM8WRxOIhAhR2mXMHSaUygqItpTehkgxLwJngXqr9RlAR4t6CtECH7o7QK0ct3oyi5Z8ViDHtPbel+D2Rg==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.993.0': - resolution: {integrity: sha512-+35g4c+8r7sB9Sjp1KPdM8qxGn6B/shBjJtEUN4e+Edw9UEQlZKIzioOGu3UAbyE0a/s450LdLZr4wbJChtmww==} + '@aws-sdk/region-config-resolver@3.972.5': + resolution: {integrity: sha512-AOitrygDwfTNCLCW7L+GScDy1p49FZ6WutTUFWROouoPetfVNmpL4q8TWD3MhfY/ynhoGhleUQENrBH374EU8w==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.995.0': - resolution: {integrity: sha512-lYSadNdZZ513qCKoj/KlJ+PgCycL3n8ZNS37qLVFC0t7TbHzoxvGquu9aD2n9OCERAn43OMhQ7dXjYDYdjAXzA==} + '@aws-sdk/token-providers@3.998.0': + resolution: {integrity: sha512-JFzi44tQnENZQ+1DYcHfoa/wTRKkccz0VsNMow0rvsxZtqUEkeV2pYFbir35mHTyUKju9995ay1MAGxLt1dpRA==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.997.0': - resolution: {integrity: sha512-UdG36F7lU9aTqGFRieEyuRUJlgEJBqKeKKekC0esH21DbUSKhPR1kZBah214kYasIaWe1hLJLaqUigoTa5hZAQ==} + '@aws-sdk/types@3.973.3': + resolution: {integrity: sha512-tma6D8/xHZHJEUqmr6ksZjZ0onyIUqKDQLyp50ttZJmS0IwFYzxBgp5CxFvpYAnah52V3UtgrqGA6E83gtT7NQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.973.1': - resolution: {integrity: sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==} + '@aws-sdk/util-endpoints@3.996.2': + resolution: {integrity: sha512-83E6T1CKi0/IozPzqRBKqduW0mS4UQdI3soBH6CG7UgupTADWunqEMOTuPWCs9XGjpJJ4ujj+yu7pn8svhp5yg==} engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.973.2': - resolution: {integrity: sha512-maTZwGsALtnAw4TJr/S6yERAosTwPduu0XhUV+SdbvRZtCOgSgk1ttL2R0XYzvkYSpvbtJocn77tBXq2AKglBw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-endpoints@3.993.0': - resolution: {integrity: sha512-j6vioBeRZ4eHX4SWGvGPpwGg/xSOcK7f1GL0VM+rdf3ZFTIsUEhCFmD78B+5r2PgztcECSzEfvHQX01k8dPQPw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-endpoints@3.995.0': - resolution: {integrity: sha512-aym/pjB8SLbo9w2nmkrDdAAVKVlf7CM71B9mKhjDbJTzwpSFBPHqJIMdDyj0mLumKC0aIVDr1H6U+59m9GvMFw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-endpoints@3.996.1': - resolution: {integrity: sha512-7cJyd+M5i0IoqWkJa1KFx8KNCGIx+Ywu+lT53KpqX7ReVwz03DCKUqvZ/y65vdKwo9w9/HptSAeLDluO5MpGIg==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-format-url@3.972.3': - resolution: {integrity: sha512-n7F2ycckcKFXa01vAsT/SJdjFHfKH9s96QHcs5gn8AaaigASICeME8WdUL9uBp8XV/OVwEt8+6gzn6KFUgQa8g==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-format-url@3.972.4': - resolution: {integrity: sha512-rPm9g4WvgTz4ko5kqseIG5Vp5LUAbWBBDalm4ogHLMc0i20ChwQWqwuTUPJSu8zXn43jIM0xO2KZaYQsFJb+ew==} + '@aws-sdk/util-format-url@3.972.5': + resolution: {integrity: sha512-PccfrPQVOEQSL8xaSvu988ESMlqdH1Qfk3AWPZksCOYPHyzYeUV988E+DBachXNV7tBVTUvK85cZYEZu7JtPxQ==} engines: {node: '>=20.0.0'} '@aws-sdk/util-locate-window@3.965.4': resolution: {integrity: sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-user-agent-browser@3.972.3': - resolution: {integrity: sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==} - - '@aws-sdk/util-user-agent-browser@3.972.4': - resolution: {integrity: sha512-GHb+8XHv6hfLWKQKAKaSOm+vRvogg07s+FWtbR3+eCXXPSFn9XVmiYF4oypAxH7dGIvoxkVG/buHEnzYukyJiA==} - - '@aws-sdk/util-user-agent-node@3.972.10': - resolution: {integrity: sha512-LVXzICPlsheET+sE6tkcS47Q5HkSTrANIlqL1iFxGAY/wRQ236DX/PCAK56qMh9QJoXAfXfoRW0B0Og4R+X7Nw==} - engines: {node: '>=20.0.0'} - peerDependencies: - aws-crt: '>=1.0.0' - peerDependenciesMeta: - aws-crt: - optional: true + '@aws-sdk/util-user-agent-browser@3.972.5': + resolution: {integrity: sha512-2ja1WqtuBaEAMgVoHYuWx393DF6ULqdt3OozeO7BosqouYaoU47Adtp9vEF+GImSG/Q8A+dqfwDULTTdMkHGUQ==} - '@aws-sdk/util-user-agent-node@3.972.12': - resolution: {integrity: sha512-c1n3wBK6te+Vd9qU86nF8AsYuiBsxLn0AADGWyFX7vEADr3btaAg5iPQT6GYj6rvzSOEVVisvaAatOWInlJUbQ==} + '@aws-sdk/util-user-agent-node@3.972.13': + resolution: {integrity: sha512-PHErmuu+v6iAST48zcsB2cYwDKW45gk6qCp49t1p0NGZ4EaFPr/tA5jl0X/ekDwvWbuT0LTj++fjjdVQAbuh0Q==} engines: {node: '>=20.0.0'} peerDependencies: aws-crt: '>=1.0.0' @@ -780,12 +662,8 @@ packages: aws-crt: optional: true - '@aws-sdk/xml-builder@3.972.5': - resolution: {integrity: sha512-mCae5Ys6Qm1LDu0qdGwx2UQ63ONUe+FHw908fJzLDqFKTDBK4LDZUqKWm4OkTCNFq19bftjsBSESIGLD/s3/rA==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/xml-builder@3.972.6': - resolution: {integrity: sha512-YrXu+UnfC8IdARa4ZkrpcyuRmA/TVgYW6Lcdtvi34NQgRjM1hTirNirN+rGb+s/kNomby8oJiIAu0KNbiZC7PA==} + '@aws-sdk/xml-builder@3.972.7': + resolution: {integrity: sha512-9GF86s6mHuc1TYCbuKatMDWl2PyK3KIkpRaI7ul2/gYZPfaLzKZ+ISHhxzVb9KVeakf75tUQe6CXW2gugSCXNw==} engines: {node: '>=20.0.0'} '@aws/lambda-invoke-store@0.2.3': @@ -804,12 +682,12 @@ packages: resolution: {integrity: sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==} engines: {node: '>=20.0.0'} - '@azure/msal-common@16.0.4': - resolution: {integrity: sha512-0KZ9/wbUyZN65JLAx5bGNfWjkD0kRMUgM99oSpZFg7wEOb3XcKIiHrFnIpgyc8zZ70fHodyh8JKEOel1oN24Gw==} + '@azure/msal-common@16.1.0': + resolution: {integrity: sha512-uiX0ChrRFbreXlPlDR8LwHKmZpJudDAr124iNWJKJ+b7MJUWXmvVU3idSi/c5lk1FwLVZeMxhQir3BGdV09I+g==} engines: {node: '>=0.8.0'} - '@azure/msal-node@5.0.4': - resolution: {integrity: sha512-WbA77m68noCw4qV+1tMm5nodll34JCDF0KmrSrp9LskS0bGbgHt98ZRxq69BQK5mjMqDD5ThHJOrrGSfzPybxw==} + '@azure/msal-node@5.0.5': + resolution: {integrity: sha512-CxUYSZgFiviUC3d8Hc+tT7uxre6QkPEWYEHWXmyEBzaO6tfFY4hs5KbXWU6s4q9Zv1NP/04qiR3mcujYLRuYuw==} engines: {node: '>=20'} '@babel/generator@8.0.0-rc.1': @@ -1504,26 +1382,21 @@ packages: resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==} hasBin: true - '@mariozechner/pi-agent-core@0.54.1': - resolution: {integrity: sha512-AC0SqEbR62PckWOyP0CmhYtfcC+Q6e1DGghwEcKpomTtmNfHTy7iTVy64mmtB2CFiN8j4rJFCqh2xJHgucUvkA==} - engines: {node: '>=20.0.0'} - '@mariozechner/pi-agent-core@0.55.0': resolution: {integrity: sha512-8RLaOpmESBSqTSpA/6E9ihxYybhrkNa5LOYNdJst57LuDSDytfvkiTXlKA4DjsHua4PKopG9p0Wgqaem+kKvCA==} engines: {node: '>=20.0.0'} - '@mariozechner/pi-ai@0.54.1': - resolution: {integrity: sha512-tiVvoNQV+3dpWgRQ1U/3bwJoDVSYwL17BE/kc00nXmaSLAPwNZoxLagtQ+HBr/rGzkq5viOgQf2dk+ud+/4UCg==} + '@mariozechner/pi-agent-core@0.55.1': + resolution: {integrity: sha512-t9FAb4ouy8HJSIa8gSRC7j8oeUOb2XDdhvBiHj7FhfpYafj1vRPrvGIEXUV8fPJDCI07vhK9iztP27EPk+yEWw==} engines: {node: '>=20.0.0'} - hasBin: true '@mariozechner/pi-ai@0.55.0': resolution: {integrity: sha512-G5rutF5h1hFZgU1W2yYktZJegKUZVDhdGCxvl7zPOonrGBczuNBKmM87VXvl1m+t9718rYMsgTSBseGN0RhYug==} engines: {node: '>=20.0.0'} hasBin: true - '@mariozechner/pi-coding-agent@0.54.1': - resolution: {integrity: sha512-pPFrdaKZ16oIcdhZVcfWPhCDFx8PWHaACjQS9aFFcMOhLBduyKAGyf8bQtfysekl+gIbBSGDT2rgCxsOwK2bQw==} + '@mariozechner/pi-ai@0.55.1': + resolution: {integrity: sha512-JJX1LrVWPUPMExu0f89XR4nMNP37+FNLjEE4cIHq9Hi6xQtOiiEi7OjDFMx58hWsq81xH1CwmQXqGTWBjbXKTw==} engines: {node: '>=20.0.0'} hasBin: true @@ -1532,14 +1405,19 @@ packages: engines: {node: '>=20.0.0'} hasBin: true - '@mariozechner/pi-tui@0.54.1': - resolution: {integrity: sha512-FY8QcLlr9T276oZAwMSSPo1drg+J9Y7B+A0S9g8Jh6IFJxymKZZq29/Vit6XDziJfZIgJDraC6lpobtxgTEoFQ==} + '@mariozechner/pi-coding-agent@0.55.1': + resolution: {integrity: sha512-H2M8mbBNyDqhON6+3m4H8CjqJ9taGq/CM3B8dG73+VJJIXFm5SExhU9bdgcw2xh0wWj8yEumsj0of6Tu+F7Ffg==} engines: {node: '>=20.0.0'} + hasBin: true '@mariozechner/pi-tui@0.55.0': resolution: {integrity: sha512-qFdBsA0CTIQbUlN5hp1yJOSgJJiuTegx+oNPzpHxaMMBPjwMuh3Y8szBqE/2HxroA6mGSQfp/fzuPinTK1+Iyg==} engines: {node: '>=20.0.0'} + '@mariozechner/pi-tui@0.55.1': + resolution: {integrity: sha512-rnqDUp2fm/ySevC0Ltj/ZFRbEc1kZ1A4qHESejj9hA8NVrb/pX9g82XwTE762JOieEGrRWAtmHLNOm7/e4dJMw==} + engines: {node: '>=20.0.0'} + '@matrix-org/matrix-sdk-crypto-nodejs@0.4.0': resolution: {integrity: sha512-+qqgpn39XFSbsD0dFjssGO9vHEP7sTyfs8yTpt8vuqWpUpF20QMwpCZi0jpYw7GxjErNTsMshopuo8677DfGEA==} engines: {node: '>= 22'} @@ -1559,144 +1437,74 @@ packages: resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==} engines: {node: '>=14.0.0'} - '@napi-rs/canvas-android-arm64@0.1.92': - resolution: {integrity: sha512-rDOtq53ujfOuevD5taxAuIFALuf1QsQWZe1yS/N4MtT+tNiDBEdjufvQRPWZ11FubL2uwgP8ApYU3YOaNu1ZsQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [android] - - '@napi-rs/canvas-android-arm64@0.1.94': - resolution: {integrity: sha512-YQ6K83RWNMQOtgpk1aIML97QTE3zxPmVCHTi5eA8Nss4+B9JZi5J7LHQr7B5oD7VwSfWd++xsPdUiJ1+frqsMg==} + '@napi-rs/canvas-android-arm64@0.1.95': + resolution: {integrity: sha512-SqTh0wsYbetckMXEvHqmR7HKRJujVf1sYv1xdlhkifg6TlCSysz1opa49LlS3+xWuazcQcfRfmhA07HxxxGsAA==} engines: {node: '>= 10'} cpu: [arm64] os: [android] - '@napi-rs/canvas-darwin-arm64@0.1.92': - resolution: {integrity: sha512-4PT6GRGCr7yMRehp42x0LJb1V0IEy1cDZDDayv7eKbFUIGbPFkV7CRC9Bee5MPkjg1EB4ZPXXUyy3gjQm7mR8Q==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@napi-rs/canvas-darwin-arm64@0.1.94': - resolution: {integrity: sha512-h1yl9XjqSrYZAbBUHCVLAhwd2knM8D8xt081Pv40KqNJXfeMmBrhG1SfroRymG2ak+pl42iQlWjFZ2Z8AWFdSw==} + '@napi-rs/canvas-darwin-arm64@0.1.95': + resolution: {integrity: sha512-F7jT0Syu+B9DGBUBcMk3qCRIxAWiDXmvEjamwbYfbZl7asI1pmXZUnCOoIu49Wt0RNooToYfRDxU9omD6t5Xuw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@napi-rs/canvas-darwin-x64@0.1.92': - resolution: {integrity: sha512-5e/3ZapP7CqPtDcZPtmowCsjoyQwuNMMD7c0GKPtZQ8pgQhLkeq/3fmk0HqNSD1i227FyJN/9pDrhw/UMTkaWA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@napi-rs/canvas-darwin-x64@0.1.94': - resolution: {integrity: sha512-rkr/lrafbU0IIHebst+sQJf1HjdHvTMN0GGqWvw5OfaVS0K/sVxhNHtxi8oCfaRSvRE62aJZjWTcdc2ue/o6yw==} + '@napi-rs/canvas-darwin-x64@0.1.95': + resolution: {integrity: sha512-54eb2Ho15RDjYGXO/harjRznBrAvu+j5nQ85Z4Qd6Qg3slR8/Ja+Yvvy9G4yo7rdX6NR9GPkZeSTf2UcKXwaXw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.92': - resolution: {integrity: sha512-j6KaLL9iir68lwpzzY+aBGag1PZp3+gJE2mQ3ar4VJVmyLRVOh+1qsdNK1gfWoAVy5w6U7OEYFrLzN2vOFUSng==} - engines: {node: '>= 10'} - cpu: [arm] - os: [linux] - - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.94': - resolution: {integrity: sha512-q95TDo32YkTKdi+Sp2yQ2Npm7pmfKEruNoJ3RUIw1KvQQ9EHKL3fii/iuU60tnzP0W+c8BKN7BFstNFcm2KXCQ==} + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.95': + resolution: {integrity: sha512-hYaLCSLx5bmbnclzQc3ado3PgZ66blJWzjXp0wJmdwpr/kH+Mwhj6vuytJIomgksyJoCdIqIa4N6aiqBGJtJ5Q==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@napi-rs/canvas-linux-arm64-gnu@0.1.92': - resolution: {integrity: sha512-s3NlnJMHOSotUYVoTCoC1OcomaChFdKmZg0VsHFeIkeHbwX0uPHP4eCX1irjSfMykyvsGHTQDfBAtGYuqxCxhQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@napi-rs/canvas-linux-arm64-gnu@0.1.94': - resolution: {integrity: sha512-Je5/gKVybWAoIGyDOcJF1zYgBTKWkPIkfOgvCzrQcl8h7DiDvRvEY70EapA+NicGe4X3DW9VsCT34KZJnerShA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@napi-rs/canvas-linux-arm64-musl@0.1.92': - resolution: {integrity: sha512-xV0GQnukYq5qY+ebkAwHjnP2OrSGBxS3vSi1zQNQj0bkXU6Ou+Tw7JjCM7pZcQ28MUyEBS1yKfo7rc7ip2IPFQ==} + '@napi-rs/canvas-linux-arm64-gnu@0.1.95': + resolution: {integrity: sha512-J7VipONahKsmScPZsipHVQBqpbZx4favaD8/enWzzlGcjiwycOoymL7f4tNeqdjK0su19bDOUt6mjp9gsPWYlw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@napi-rs/canvas-linux-arm64-musl@0.1.94': - resolution: {integrity: sha512-9YleDDauDEZNsFnfz3HyZvp1LK1ECu8N2gDUg1wtL7uWLQv8dUbfVeFtp5HOdxht1o7LsWRmQeqeIbnD4EqE2A==} + '@napi-rs/canvas-linux-arm64-musl@0.1.95': + resolution: {integrity: sha512-PXy0UT1J/8MPG8UAkWp6Fd51ZtIZINFzIjGH909JjQrtCuJf3X6nanHYdz1A+Wq9o4aoPAw1YEUpFS1lelsVlg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@napi-rs/canvas-linux-riscv64-gnu@0.1.92': - resolution: {integrity: sha512-+GKvIFbQ74eB/TopEdH6XIXcvOGcuKvCITLGXy7WLJAyNp3Kdn1ncjxg91ihatBaPR+t63QOE99yHuIWn3UQ9w==} - engines: {node: '>= 10'} - cpu: [riscv64] - os: [linux] - - '@napi-rs/canvas-linux-riscv64-gnu@0.1.94': - resolution: {integrity: sha512-lQUy9Xvz7ch8+0AXq8RkioLD41iQ6EqdKFu5uV40BxkBDijB2SCm1jna/BRhqitQRSjwAk2KlLUxTjHChyfNGg==} + '@napi-rs/canvas-linux-riscv64-gnu@0.1.95': + resolution: {integrity: sha512-2IzCkW2RHRdcgF9W5/plHvYFpc6uikyjMb5SxjqmNxfyDFz9/HB89yhi8YQo0SNqrGRI7yBVDec7Pt+uMyRWsg==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - '@napi-rs/canvas-linux-x64-gnu@0.1.92': - resolution: {integrity: sha512-tFd6MwbEhZ1g64iVY2asV+dOJC+GT3Yd6UH4G3Hp0/VHQ6qikB+nvXEULskFYZ0+wFqlGPtXjG1Jmv7sJy+3Ww==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@napi-rs/canvas-linux-x64-gnu@0.1.94': - resolution: {integrity: sha512-0IYgyuUaugHdWxXRhDQUCMxTou8kAHHmpIBFtbmdRlciPlfK7AYQW5agvUU1PghPc5Ja3Zzp5qZfiiLu36vIWQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@napi-rs/canvas-linux-x64-musl@0.1.92': - resolution: {integrity: sha512-uSuqeSveB/ZGd72VfNbHCSXO9sArpZTvznMVsb42nqPP7gBGEH6NJQ0+hmF+w24unEmxBhPYakP/Wiosm16KkA==} + '@napi-rs/canvas-linux-x64-gnu@0.1.95': + resolution: {integrity: sha512-OV/ol/OtcUr4qDhQg8G7SdViZX8XyQeKpPsVv/j3+7U178FGoU4M+yIocdVo1ih/A8GQ63+LjF4jDoEjaVU8Pw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@napi-rs/canvas-linux-x64-musl@0.1.94': - resolution: {integrity: sha512-xuetfzzcflCIiBw2HJlOU4/+zTqhdxoe1BEcwdBsHAd/5wAQ4Pp+FGPi5g74gDvtcXQmTdEU3fLQvHc/j3wbxQ==} + '@napi-rs/canvas-linux-x64-musl@0.1.95': + resolution: {integrity: sha512-Z5KzqBK/XzPz5+SFHKz7yKqClEQ8pOiEDdgk5SlphBLVNb8JFIJkxhtJKSvnJyHh2rjVgiFmvtJzMF0gNwwKyQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@napi-rs/canvas-win32-arm64-msvc@0.1.92': - resolution: {integrity: sha512-20SK5AU/OUNz9ZuoAPj5ekWai45EIBDh/XsdrVZ8le/pJVlhjFU3olbumSQUXRFn7lBRS+qwM8kA//uLaDx6iQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@napi-rs/canvas-win32-arm64-msvc@0.1.94': - resolution: {integrity: sha512-2F3p8wci4Q4vjbENlQtSibqFWxBdpzYk1c8Jh1mqqLE92rBKElG018dBJ6C8Dp49vE350Hmy5LrfdLgFKMG8sg==} + '@napi-rs/canvas-win32-arm64-msvc@0.1.95': + resolution: {integrity: sha512-aj0YbRpe8qVJ4OzMsK7NfNQePgcf9zkGFzNZ9mSuaxXzhpLHmlF2GivNdCdNOg8WzA/NxV6IU4c5XkXadUMLeA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@napi-rs/canvas-win32-x64-msvc@0.1.92': - resolution: {integrity: sha512-KEhyZLzq1MXCNlXybz4k25MJmHFp+uK1SIb8yJB0xfrQjz5aogAMhyseSzewo+XxAq3OAOdyKvfHGNzT3w1RPg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@napi-rs/canvas-win32-x64-msvc@0.1.94': - resolution: {integrity: sha512-hjwaIKMrQLoNiu3724octSGhDVKkBwJtMeQ3qUXOi+y60h2q6Sxq3+MM2za3V88+XQzzwn0DgG0Xo6v6gzV8kQ==} + '@napi-rs/canvas-win32-x64-msvc@0.1.95': + resolution: {integrity: sha512-GA8leTTCfdjuHi8reICTIxU0081PhXvl3lzIniLUjeLACx9GubUiyzkwFb+oyeKLS5IAGZFLKnzAf4wm2epRlA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@napi-rs/canvas@0.1.92': - resolution: {integrity: sha512-q7ZaUCJkEU5BeOdE7fBx1XWRd2T5Ady65nxq4brMf5L4cE1VV/ACq5w9Z5b/IVJs8CwSSIwc30nlthH0gFo4Ig==} - engines: {node: '>= 10'} - - '@napi-rs/canvas@0.1.94': - resolution: {integrity: sha512-8jBkvqynXNdQPNZjLJxB/Rp9PdnnMSHFBLzPmMc615nlt/O6w0ergBbkEDEOr8EbjL8nRQDpEklPx4pzD7zrbg==} + '@napi-rs/canvas@0.1.95': + resolution: {integrity: sha512-lkg23ge+rgyhgUwXmlbkPEhuhHq/hUi/gXKH+4I7vO+lJrbNfEYcQdJLIGjKyXLQzgFiiyDAwh5vAe/tITAE+w==} engines: {node: '>= 10'} '@napi-rs/wasm-runtime@1.1.1': @@ -1827,8 +1635,8 @@ packages: resolution: {integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==} engines: {node: '>= 20'} - '@octokit/endpoint@11.0.2': - resolution: {integrity: sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==} + '@octokit/endpoint@11.0.3': + resolution: {integrity: sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==} engines: {node: '>= 20'} '@octokit/graphql@9.0.3': @@ -1871,8 +1679,8 @@ packages: peerDependencies: '@octokit/core': '>=6' - '@octokit/plugin-retry@8.0.3': - resolution: {integrity: sha512-vKGx1i3MC0za53IzYBSBXcrhmd+daQDzuZfYDd52X5S0M2otf3kVZTVP8bLA3EkU0lTvd1WEC2OlNNa4G+dohA==} + '@octokit/plugin-retry@8.1.0': + resolution: {integrity: sha512-O1FZgXeiGb2sowEr/hYTr6YunGdSAFWnr2fyW39Ah85H8O33ELASQxcvOFF5LE6Tjekcyu2ms4qAzJVhSaJxTw==} engines: {node: '>= 20'} peerDependencies: '@octokit/core': '>=7' @@ -1887,8 +1695,8 @@ packages: resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==} engines: {node: '>= 20'} - '@octokit/request@10.0.7': - resolution: {integrity: sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==} + '@octokit/request@10.0.8': + resolution: {integrity: sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==} engines: {node: '>= 20'} '@octokit/types@16.0.0': @@ -2679,22 +2487,10 @@ packages: resolution: {integrity: sha512-qocxM/X4XGATqQtUkbE9SPUB6wekBi+FyJOMbPj0AhvyvFGYEmOlz6VB22iMePCQsFmMIvFSeViDvA7mZJG47g==} engines: {node: '>=18.0.0'} - '@smithy/abort-controller@4.2.8': - resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==} - engines: {node: '>=18.0.0'} - - '@smithy/config-resolver@4.4.6': - resolution: {integrity: sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==} - engines: {node: '>=18.0.0'} - '@smithy/config-resolver@4.4.9': resolution: {integrity: sha512-ejQvXqlcU30h7liR9fXtj7PIAau1t/sFbJpgWPfiYDs7zd16jpH0IsSXKcba2jF6ChTXvIjACs27kNMc5xxE2Q==} engines: {node: '>=18.0.0'} - '@smithy/core@3.23.2': - resolution: {integrity: sha512-HaaH4VbGie4t0+9nY3tNBRSxVTr96wzIqexUa6C2qx3MPePAuz7lIxPxYtt1Wc//SPfJLNoZJzfdt0B6ksj2jA==} - engines: {node: '>=18.0.0'} - '@smithy/core@3.23.6': resolution: {integrity: sha512-4xE+0L2NrsFKpEVFlFELkIHQddBvMbQ41LRIP74dGCXnY1zQ9DgksrBcRBDJT+iOzGy4VEJIeU3hkUK5mn06kg==} engines: {node: '>=18.0.0'} @@ -2703,82 +2499,42 @@ packages: resolution: {integrity: sha512-3bsMLJJLTZGZqVGGeBVFfLzuRulVsGTj12BzRKODTHqUABpIr0jMN1vN3+u6r2OfyhAQ2pXaMZWX/swBK5I6PQ==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.2.8': - resolution: {integrity: sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==} - engines: {node: '>=18.0.0'} - '@smithy/eventstream-codec@4.2.10': resolution: {integrity: sha512-A4ynrsFFfSXUHicfTcRehytppFBcY3HQxEGYiyGktPIOye3Ot7fxpiy4VR42WmtGI4Wfo6OXt/c1Ky1nUFxYYQ==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-codec@4.2.8': - resolution: {integrity: sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==} - engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-browser@4.2.10': resolution: {integrity: sha512-0xupsu9yj9oDVuQ50YCTS9nuSYhGlrwqdaKQel9y2Fz7LU9fNErVlw9N0o4pm4qqvWEGbSTI4HKc6XJfB30MVw==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-browser@4.2.8': - resolution: {integrity: sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==} - engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-config-resolver@4.3.10': resolution: {integrity: sha512-8kn6sinrduk0yaYHMJDsNuiFpXwQwibR7n/4CDUqn4UgaG+SeBHu5jHGFdU9BLFAM7Q4/gvr9RYxBHz9/jKrhA==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-config-resolver@4.3.8': - resolution: {integrity: sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==} - engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-node@4.2.10': resolution: {integrity: sha512-uUrxPGgIffnYfvIOUmBM5i+USdEBRTdh7mLPttjphgtooxQ8CtdO1p6K5+Q4BBAZvKlvtJ9jWyrWpBJYzBKsyQ==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-node@4.2.8': - resolution: {integrity: sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==} - engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-universal@4.2.10': resolution: {integrity: sha512-aArqzOEvcs2dK+xQVCgLbpJQGfZihw8SD4ymhkwNTtwKbnrzdhJsFDKuMQnam2kF69WzgJYOU5eJlCx+CA32bw==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-universal@4.2.8': - resolution: {integrity: sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==} - engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@5.3.11': resolution: {integrity: sha512-wbTRjOxdFuyEg0CpumjZO0hkUl+fetJFqxNROepuLIoijQh51aMBmzFLfoQdwRjxsuuS2jizzIUTjPWgd8pd7g==} engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@5.3.9': - resolution: {integrity: sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==} - engines: {node: '>=18.0.0'} - '@smithy/hash-node@4.2.10': resolution: {integrity: sha512-1VzIOI5CcsvMDvP3iv1vG/RfLJVVVc67dCRyLSB2Hn9SWCZrDO3zvcIzj3BfEtqRW5kcMg5KAeVf1K3dR6nD3w==} engines: {node: '>=18.0.0'} - '@smithy/hash-node@4.2.8': - resolution: {integrity: sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==} - engines: {node: '>=18.0.0'} - '@smithy/invalid-dependency@4.2.10': resolution: {integrity: sha512-vy9KPNSFUU0ajFYk0sDZIYiUlAWGEAhRfehIr5ZkdFrRFTAuXEPUd41USuqHU6vvLX4r6Q9X7MKBco5+Il0Org==} engines: {node: '>=18.0.0'} - '@smithy/invalid-dependency@4.2.8': - resolution: {integrity: sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==} - engines: {node: '>=18.0.0'} - '@smithy/is-array-buffer@2.2.0': resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} - '@smithy/is-array-buffer@4.2.0': - resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} - engines: {node: '>=18.0.0'} - '@smithy/is-array-buffer@4.2.1': resolution: {integrity: sha512-Yfu664Qbf1B4IYIsYgKoABt010daZjkaCRvdU/sPnZG6TtHOB0md0RjNdLGzxe5UIdn9js4ftPICzmkRa9RJ4Q==} engines: {node: '>=18.0.0'} @@ -2787,22 +2543,10 @@ packages: resolution: {integrity: sha512-TQZ9kX5c6XbjhaEBpvhSvMEZ0klBs1CFtOdPFwATZSbC9UeQfKHPLPN9Y+I6wZGMOavlYTOlHEPDrt42PMSH9w==} engines: {node: '>=18.0.0'} - '@smithy/middleware-content-length@4.2.8': - resolution: {integrity: sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-endpoint@4.4.16': - resolution: {integrity: sha512-L5GICFCSsNhbJ5JSKeWFGFy16Q2OhoBizb3X2DrxaJwXSEujVvjG9Jt386dpQn2t7jINglQl0b4K/Su69BdbMA==} - engines: {node: '>=18.0.0'} - '@smithy/middleware-endpoint@4.4.20': resolution: {integrity: sha512-9W6Np4ceBP3XCYAGLoMCmn8t2RRVzuD1ndWPLBbv7H9CrwM9Bprf6Up6BM9ZA/3alodg0b7Kf6ftBK9R1N04vw==} engines: {node: '>=18.0.0'} - '@smithy/middleware-retry@4.4.33': - resolution: {integrity: sha512-jLqZOdJhtIL4lnA9hXnAG6GgnJlo1sD3FqsTxm9wSfjviqgWesY/TMBVnT84yr4O0Vfe0jWoXlfFbzsBVph3WA==} - engines: {node: '>=18.0.0'} - '@smithy/middleware-retry@4.4.37': resolution: {integrity: sha512-/1psZZllBBSQ7+qo5+hhLz7AEPGLx3Z0+e3ramMBEuPK2PfvLK4SrncDB9VegX5mBn+oP/UTDrM6IHrFjvX1ZA==} engines: {node: '>=18.0.0'} @@ -2811,30 +2555,14 @@ packages: resolution: {integrity: sha512-STQdONGPwbbC7cusL60s7vOa6He6A9w2jWhoapL0mgVjmR19pr26slV+yoSP76SIssMTX/95e5nOZ6UQv6jolg==} engines: {node: '>=18.0.0'} - '@smithy/middleware-serde@4.2.9': - resolution: {integrity: sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==} - engines: {node: '>=18.0.0'} - '@smithy/middleware-stack@4.2.10': resolution: {integrity: sha512-pmts/WovNcE/tlyHa8z/groPeOtqtEpp61q3W0nW1nDJuMq/x+hWa/OVQBtgU0tBqupeXq0VBOLA4UZwE8I0YA==} engines: {node: '>=18.0.0'} - '@smithy/middleware-stack@4.2.8': - resolution: {integrity: sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==} - engines: {node: '>=18.0.0'} - '@smithy/node-config-provider@4.3.10': resolution: {integrity: sha512-UALRbJtVX34AdP2VECKVlnNgidLHA2A7YgcJzwSBg1hzmnO/bZBHl/LDQQyYifzUwp1UOODnl9JJ3KNawpUJ9w==} engines: {node: '>=18.0.0'} - '@smithy/node-config-provider@4.3.8': - resolution: {integrity: sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==} - engines: {node: '>=18.0.0'} - - '@smithy/node-http-handler@4.4.10': - resolution: {integrity: sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA==} - engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.4.12': resolution: {integrity: sha512-zo1+WKJkR9x7ZtMeMDAAsq2PufwiLDmkhcjpWPRRkmeIuOm6nq1qjFICSZbnjBvD09ei8KMo26BWxsu2BUU+5w==} engines: {node: '>=18.0.0'} @@ -2843,46 +2571,22 @@ packages: resolution: {integrity: sha512-5jm60P0CU7tom0eNrZ7YrkgBaoLFXzmqB0wVS+4uK8PPGmosSrLNf6rRd50UBvukztawZ7zyA8TxlrKpF5z9jw==} engines: {node: '>=18.0.0'} - '@smithy/property-provider@4.2.8': - resolution: {integrity: sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==} - engines: {node: '>=18.0.0'} - '@smithy/protocol-http@5.3.10': resolution: {integrity: sha512-2NzVWpYY0tRdfeCJLsgrR89KE3NTWT2wGulhNUxYlRmtRmPwLQwKzhrfVaiNlA9ZpJvbW7cjTVChYKgnkqXj1A==} engines: {node: '>=18.0.0'} - '@smithy/protocol-http@5.3.8': - resolution: {integrity: sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==} - engines: {node: '>=18.0.0'} - '@smithy/querystring-builder@4.2.10': resolution: {integrity: sha512-HeN7kEvuzO2DmAzLukE9UryiUvejD3tMp9a1D1NJETerIfKobBUCLfviP6QEk500166eD2IATaXM59qgUI+YDA==} engines: {node: '>=18.0.0'} - '@smithy/querystring-builder@4.2.8': - resolution: {integrity: sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==} - engines: {node: '>=18.0.0'} - '@smithy/querystring-parser@4.2.10': resolution: {integrity: sha512-4Mh18J26+ao1oX5wXJfWlTT+Q1OpDR8ssiC9PDOuEgVBGloqg18Fw7h5Ct8DyT9NBYwJgtJ2nLjKKFU6RP1G1Q==} engines: {node: '>=18.0.0'} - '@smithy/querystring-parser@4.2.8': - resolution: {integrity: sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==} - engines: {node: '>=18.0.0'} - '@smithy/service-error-classification@4.2.10': resolution: {integrity: sha512-0R/+/Il5y8nB/By90o8hy/bWVYptbIfvoTYad0igYQO5RefhNCDmNzqxaMx7K1t/QWo0d6UynqpqN5cCQt1MCg==} engines: {node: '>=18.0.0'} - '@smithy/service-error-classification@4.2.8': - resolution: {integrity: sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==} - engines: {node: '>=18.0.0'} - - '@smithy/shared-ini-file-loader@4.4.3': - resolution: {integrity: sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==} - engines: {node: '>=18.0.0'} - '@smithy/shared-ini-file-loader@4.4.5': resolution: {integrity: sha512-pHgASxl50rrtOztgQCPmOXFjRW+mCd7ALr/3uXNzRrRoGV5G2+78GOsQ3HlQuBVHCh9o6xqMNvlIKZjWn4Euug==} engines: {node: '>=18.0.0'} @@ -2891,22 +2595,10 @@ packages: resolution: {integrity: sha512-Wab3wW8468WqTKIxI+aZe3JYO52/RYT/8sDOdzkUhjnLakLe9qoQqIcfih/qxcF4qWEFoWBszY0mj5uxffaVXA==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.3.8': - resolution: {integrity: sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==} - engines: {node: '>=18.0.0'} - - '@smithy/smithy-client@4.11.5': - resolution: {integrity: sha512-xixwBRqoeP2IUgcAl3U9dvJXc+qJum4lzo3maaJxifsZxKUYLfVfCXvhT4/jD01sRrHg5zjd1cw2Zmjr4/SuKQ==} - engines: {node: '>=18.0.0'} - '@smithy/smithy-client@4.12.0': resolution: {integrity: sha512-R8bQ9K3lCcXyZmBnQqUZJF4ChZmtWT5NLi6x5kgWx5D+/j0KorXcA0YcFg/X5TOgnTCy1tbKc6z2g2y4amFupQ==} engines: {node: '>=18.0.0'} - '@smithy/types@4.12.0': - resolution: {integrity: sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==} - engines: {node: '>=18.0.0'} - '@smithy/types@4.13.0': resolution: {integrity: sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==} engines: {node: '>=18.0.0'} @@ -2915,30 +2607,14 @@ packages: resolution: {integrity: sha512-uypjF7fCDsRk26u3qHmFI/ePL7bxxB9vKkE+2WKEciHhz+4QtbzWiHRVNRJwU3cKhrYDYQE3b0MRFtqfLYdA4A==} engines: {node: '>=18.0.0'} - '@smithy/url-parser@4.2.8': - resolution: {integrity: sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==} - engines: {node: '>=18.0.0'} - - '@smithy/util-base64@4.3.0': - resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==} - engines: {node: '>=18.0.0'} - '@smithy/util-base64@4.3.1': resolution: {integrity: sha512-BKGuawX4Doq/bI/uEmg+Zyc36rJKWuin3py89PquXBIBqmbnJwBBsmKhdHfNEp0+A4TDgLmT/3MSKZ1SxHcR6w==} engines: {node: '>=18.0.0'} - '@smithy/util-body-length-browser@4.2.0': - resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==} - engines: {node: '>=18.0.0'} - '@smithy/util-body-length-browser@4.2.1': resolution: {integrity: sha512-SiJeLiozrAoCrgDBUgsVbmqHmMgg/2bA15AzcbcW+zan7SuyAVHN4xTSbq0GlebAIwlcaX32xacnrG488/J/6g==} engines: {node: '>=18.0.0'} - '@smithy/util-body-length-node@4.2.1': - resolution: {integrity: sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==} - engines: {node: '>=18.0.0'} - '@smithy/util-body-length-node@4.2.2': resolution: {integrity: sha512-4rHqBvxtJEBvsZcFQSPQqXP2b/yy/YlB66KlcEgcH2WNoOKCKB03DSLzXmOsXjbl8dJ4OEYTn31knhdznwk7zw==} engines: {node: '>=18.0.0'} @@ -2947,50 +2623,26 @@ packages: resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} - '@smithy/util-buffer-from@4.2.0': - resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} - engines: {node: '>=18.0.0'} - '@smithy/util-buffer-from@4.2.1': resolution: {integrity: sha512-/swhmt1qTiVkaejlmMPPDgZhEaWb/HWMGRBheaxwuVkusp/z+ErJyQxO6kaXumOciZSWlmq6Z5mNylCd33X7Ig==} engines: {node: '>=18.0.0'} - '@smithy/util-config-provider@4.2.0': - resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} - engines: {node: '>=18.0.0'} - '@smithy/util-config-provider@4.2.1': resolution: {integrity: sha512-462id/00U8JWFw6qBuTSWfN5TxOHvDu4WliI97qOIOnuC/g+NDAknTU8eoGXEPlLkRVgWEr03jJBLV4o2FL8+A==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-browser@4.3.32': - resolution: {integrity: sha512-092sjYfFMQ/iaPH798LY/OJFBcYu0sSK34Oy9vdixhsU36zlZu8OcYjF3TD4e2ARupyK7xaxPXl+T0VIJTEkkg==} - engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-browser@4.3.36': resolution: {integrity: sha512-R0smq7EHQXRVMxkAxtH5akJ/FvgAmNF6bUy/GwY/N20T4GrwjT633NFm0VuRpC+8Bbv8R9A0DoJ9OiZL/M3xew==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-node@4.2.35': - resolution: {integrity: sha512-miz/ggz87M8VuM29y7jJZMYkn7+IErM5p5UgKIf8OtqVs/h2bXr1Bt3uTsREsI/4nK8a0PQERbAPsVPVNIsG7Q==} - engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-node@4.2.39': resolution: {integrity: sha512-otWuoDm35btJV1L8MyHrPl462B07QCdMTktKc7/yM+Psv6KbED/ziXiHnmr7yPHUjfIwE9S8Max0LO24Mo3ZVg==} engines: {node: '>=18.0.0'} - '@smithy/util-endpoints@3.2.8': - resolution: {integrity: sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==} - engines: {node: '>=18.0.0'} - '@smithy/util-endpoints@3.3.1': resolution: {integrity: sha512-xyctc4klmjmieQiF9I1wssBWleRV0RhJ2DpO8+8yzi2LO1Z+4IWOZNGZGNj4+hq9kdo+nyfrRLmQTzc16Op2Vg==} engines: {node: '>=18.0.0'} - '@smithy/util-hex-encoding@4.2.0': - resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} - engines: {node: '>=18.0.0'} - '@smithy/util-hex-encoding@4.2.1': resolution: {integrity: sha512-c1hHtkgAWmE35/50gmdKajgGAKV3ePJ7t6UtEmpfCWJmQE9BQAQPz0URUVI89eSkcDqCtzqllxzG28IQoZPvwA==} engines: {node: '>=18.0.0'} @@ -2999,30 +2651,14 @@ packages: resolution: {integrity: sha512-LxaQIWLp4y0r72eA8mwPNQ9va4h5KeLM0I3M/HV9klmFaY2kN766wf5vsTzmaOpNNb7GgXAd9a25P3h8T49PSA==} engines: {node: '>=18.0.0'} - '@smithy/util-middleware@4.2.8': - resolution: {integrity: sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==} - engines: {node: '>=18.0.0'} - '@smithy/util-retry@4.2.10': resolution: {integrity: sha512-HrBzistfpyE5uqTwiyLsFHscgnwB0kgv8vySp7q5kZ0Eltn/tjosaSGGDj/jJ9ys7pWzIP/icE2d+7vMKXLv7A==} engines: {node: '>=18.0.0'} - '@smithy/util-retry@4.2.8': - resolution: {integrity: sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==} - engines: {node: '>=18.0.0'} - - '@smithy/util-stream@4.5.12': - resolution: {integrity: sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg==} - engines: {node: '>=18.0.0'} - '@smithy/util-stream@4.5.15': resolution: {integrity: sha512-OlOKnaqnkU9X+6wEkd7mN+WB7orPbCVDauXOj22Q7VtiTkvy7ZdSsOg4QiNAZMgI4OkvNf+/VLUC3VXkxuWJZw==} engines: {node: '>=18.0.0'} - '@smithy/util-uri-escape@4.2.0': - resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==} - engines: {node: '>=18.0.0'} - '@smithy/util-uri-escape@4.2.1': resolution: {integrity: sha512-YmiUDn2eo2IOiWYYvGQkgX5ZkBSiTQu4FlDo5jNPpAxng2t6Sjb6WutnZV9l6VR4eJul1ABmCrnWBC9hKHQa6Q==} engines: {node: '>=18.0.0'} @@ -3031,18 +2667,10 @@ packages: resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} engines: {node: '>=14.0.0'} - '@smithy/util-utf8@4.2.0': - resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==} - engines: {node: '>=18.0.0'} - '@smithy/util-utf8@4.2.1': resolution: {integrity: sha512-DSIwNaWtmzrNQHv8g7DBGR9mulSit65KSj5ymGEIAknmIN8IpbZefEep10LaMG/P/xquwbmJ1h9ectz8z6mV6g==} engines: {node: '>=18.0.0'} - '@smithy/uuid@1.1.0': - resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} - engines: {node: '>=18.0.0'} - '@smithy/uuid@1.1.1': resolution: {integrity: sha512-dSfDCeihDmZlV2oyr0yWPTUfh07suS+R5OB+FZGiv/hHyK3hrFBW5rR1UYjfa57vBsrP9lciFkRPzebaV1Qujw==} engines: {node: '>=18.0.0'} @@ -3304,43 +2932,46 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260224.1': - resolution: {integrity: sha512-9VHXRhB7sM5DFqdlKaeDww8vuklgfzhYCjBazLCEnuFvb4J+rJ1DodLykc2bL+6kE8k6sdhYi3x8ipfbjtO44g==} + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260225.1': + resolution: {integrity: sha512-3qSsqv7FmM4z09wEpEXdhmgMfiJF/OMOZa41AdgMsXTTRpX2/38hDg2KGhi3fc24M2T3MnLPLTqw6HyTOBaV1Q==} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260224.1': - resolution: {integrity: sha512-uCHipPRcIhHnvb7lAM29MQ1QT9pZ+uirqtH630aOMFm8VG3j8mkxVM9iGRLx829n38DMSDLjc3joCrQO3+sDcQ==} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260225.1': + resolution: {integrity: sha512-F8ZCCX2UESHcbxvnkd1Dn5PTnOOgpGddFHYgn4usyWRMzNZLPP+YjyGALZe9zdR/D8L0uraND0Haok+TPq8xYg==} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260224.1': - resolution: {integrity: sha512-yFEEq6hD2R70+lTogb211sPdCwz3H5hpYh0+YuKVMPsKo0oM8/jMvgjj2pyutmj/uCKLdbcJ9HP2vJ/13Szbcg==} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260225.1': + resolution: {integrity: sha512-Up8Z/QNcwce5C4rWnbLNW5w7lRARdyKZcNbB1NMnaswaGOBdeDmdP0wbVsOgJMoDp6vnun+EkvrSft8hWLLhIg==} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260224.1': - resolution: {integrity: sha512-cEWSRQ8b+CXdMJvoG18IjNTvBo+qT22B5imqm6nAssMpyHHQb62PvZGnrA8mPRQNPzLpa5F956j8GwAjyP8hBQ==} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260225.1': + resolution: {integrity: sha512-Iu5rnCmqwGIMUu//BXkl9VQaxAAsqVvFhU4mJoNexNkMxPqVcu9quqYAouY7tN/95WcKzUsPpyRfkThdbNFO/g==} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260224.1': - resolution: {integrity: sha512-zGz5kVcCeBRheQwA4jVTAxtbLsBsTkp9AEvWK5AlyCs1rQCUQobBhtx37X4VEmxn4ekIDMxYgaZdlZb7/PGp8w==} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260225.1': + resolution: {integrity: sha512-WWjIfHCWlcriempYYc/sPJ3HFt6znNZKp60nvDNih0+wmxNqEfT5Yzu5zAY0awIe7XLelFSY+bolkpzMYVWEIQ==} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260224.1': - resolution: {integrity: sha512-A0f9ZDQqKvGk/an59HuAJuzoI/wMyrgTd69oX9gFCx7+5E/ajSdgv0Eg1Fco+nyLfT/UVM0CV3ERyWrKzx277w==} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260225.1': + resolution: {integrity: sha512-lmfQO+HdmPMk0dtPoNo8dZereTUYNQuapsAI7nFHCP8F25I8eGKKXY2nD1R8W1hp/LmVtske1pqKFNN6IOCt5g==} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260224.1': - resolution: {integrity: sha512-Se9JrcMdVLeDYMLn+CKEV3qy1yiildb5N23USGvnC9siNFalz8tVgd589dhRP+ywDhXnbIsZiFKDrZF/7B4wSQ==} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260225.1': + resolution: {integrity: sha512-e4eJyzR9ne0XreqYgQNqfX7SNuaePxggnUtVrLERgBv25QKwdQl72GnSXDhdxZHzrb97YwumiXWMQQJj9h8NCg==} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260224.1': - resolution: {integrity: sha512-PU0zBXLvz6RKxbIubT66RCnJXgScdDIhfmNMkvRhOnX/C4SZom5TFSn7BEHC3w8JPj7OSz5OYoubtV1Haty2GA==} + '@typescript/native-preview@7.0.0-dev.20260225.1': + resolution: {integrity: sha512-mUf1aON+eZLupLorX4214n4W6uWIz/lvNv81ErzjJylD/GyJPEJkvDLmgIK3bbvLpMwTRWdVJLhpLCah5Qe8iA==} hasBin: true '@typespec/ts-http-runtime@0.3.3': @@ -3432,8 +3063,8 @@ packages: link-preview-js: optional: true - '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': - resolution: {tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67} + '@whiskeysockets/libsignal-node@git+https://github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': + resolution: {commit: 1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67, repo: https://github.com/whiskeysockets/libsignal-node.git, type: git} version: 2.0.1 abbrev@1.1.1: @@ -3461,6 +3092,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acpx@0.1.13: + resolution: {integrity: sha512-C032VkV3cNa13ubq9YhskTWvDTsciNAQfNHZLW3PIN3atdkrzkV0v2yi6Znp7UZDw+pzgpKUsOrZWl64Lwr+3w==} + engines: {node: '>=18'} + hasBin: true + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -3598,10 +3234,26 @@ packages: axios@1.13.5: resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} + b4a@1.8.0: + resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -3649,6 +3301,9 @@ packages: resolution: {integrity: sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==} engines: {node: 18 || 20 || >=22} + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -3769,6 +3424,10 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -3961,6 +3620,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -4043,6 +3705,9 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -4058,6 +3723,11 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + extsprintf@1.3.0: resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} engines: {'0': node >=0.6.0} @@ -4068,6 +3738,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -4075,6 +3748,9 @@ packages: resolution: {integrity: sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==} hasBin: true + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -4194,10 +3870,6 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.4.0: - resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} - engines: {node: '>=18'} - get-east-asian-width@1.5.0: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} @@ -4210,6 +3882,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + get-tsconfig@4.13.6: resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} @@ -4388,8 +4064,8 @@ packages: resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} - ipull@3.9.3: - resolution: {integrity: sha512-ZMkxaopfwKHwmEuGDYx7giNBdLxbHbRCWcQVA1D2eqE4crUguupfxej6s7UqbidYEwT69dkyumYkY8DPHIxF9g==} + ipull@3.9.5: + resolution: {integrity: sha512-5w/yZB5lXmTfsvNawmvkCjYo4SJNuKQz/av8TC1UiOyfOHyaM+DReqbpU2XpWYfmY+NIUbRRH8PUAWsxaS+IfA==} engines: {node: '>=18.0.0'} hasBin: true @@ -4502,6 +4178,9 @@ packages: json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json-with-bigint@3.5.3: + resolution: {integrity: sha512-QObKu6nxy7NsxqR0VK4rkXnsNr5L9ElJaGEg+ucJ6J7/suoKZ0n+p76cu9aCqowytxEbwYNzvrMerfMkXneF5A==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -4550,8 +4229,8 @@ packages: lifecycle-utils@2.1.0: resolution: {integrity: sha512-AnrXnE2/OF9PHCyFg0RSqsnQTzV991XaZA/buhFDoc58xU7rhSCDgCz/09Lqpsn4MpoPHt7TRAXV1kWZypFVsA==} - lifecycle-utils@3.1.0: - resolution: {integrity: sha512-kVvegv+r/icjIo1dkHv1hznVQi4FzEVglJD2IU4w07HzevIyH3BAYsFZzEIbBk/nNZjXHGgclJ5g9rz9QdBCLw==} + lifecycle-utils@3.1.1: + resolution: {integrity: sha512-gNd3OvhFNjHykJE3uGntz7UuPzWlK9phrIdXxU9Adis0+ExkwnZibfxCJWiWWZ+a6VbKiZrb+9D9hCQWd4vjTg==} lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} @@ -5031,8 +4710,8 @@ packages: zod: optional: true - openclaw@2026.2.23: - resolution: {integrity: sha512-7I7G898212v3OzUidgM8kZdZYAziT78Dc5zgeqsV2tfCbINtHK0Pdc2rg2eDLoDYAcheLh0fvH5qn/15Yu9q7A==} + openclaw@2026.2.24: + resolution: {integrity: sha512-a6zrcS6v5tUWqzsFh5cNtyu5+Tra1UW5yvPtYhRYCKSS/q6lXrLu+dj0ylJPOHRPAho2alZZL1gw1Qd2hAd2sQ==} engines: {node: '>=22.12.0'} hasBin: true peerDependencies: @@ -5042,9 +4721,6 @@ packages: opus-decoder@0.7.11: resolution: {integrity: sha512-+e+Jz3vGQLxRTBHs8YJQPRPc1Tr+/aC6coV/DlZylriA29BdHQAYXhvNRKtjftof17OFng0+P4wsFIqQu3a48A==} - opusscript@0.0.8: - resolution: {integrity: sha512-VSTi1aWFuCkRCVq+tx/BQ5q9fMnQ9pVZ3JU4UHKqTkf0ED3fKEPdr+gKAAl3IA2hj9rrP6iyq3hlcJq3HELtNQ==} - opusscript@0.1.1: resolution: {integrity: sha512-mL0fZZOUnXdZ78woRXp18lApwpp0lF5tozJOD1Wut0dgrA9WuQTgSels/CSmFleaAZrJi/nci5KOVtbuxeWoQA==} @@ -5175,6 +4851,9 @@ packages: peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + performance-now@2.1.0: resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} @@ -5289,6 +4968,9 @@ packages: psl@1.15.0: resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -5532,8 +5214,8 @@ packages: peerDependencies: signal-polyfill: ^0.2.0 - simple-git@3.31.1: - resolution: {integrity: sha512-oiWP4Q9+kO8q9hHqkX35uuHmxiEbZNTrZ5IPxgMGrJwN76pzjm/jabkZO0ItEcqxAincqGAzL3QHSaHt4+knBg==} + simple-git@3.32.2: + resolution: {integrity: sha512-n/jhNmvYh8dwyfR6idSfpXrFazuyd57jwNMzgjGnKZV/1lTh0HKvPq20v4AQ62rP+l19bWjjXPTCdGHMt0AdrQ==} simple-yenc@1.0.4: resolution: {integrity: sha512-5gvxpSd79e9a3V4QDYUqnqxeD4HGlhCakVpb6gMnDD7lexJggSBJRBO5h52y/iJrdXRilX9UCuDaIJhSWm5OWw==} @@ -5545,6 +5227,11 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + skillflag@0.1.4: + resolution: {integrity: sha512-egFg+XCF5sloOWdtzxZivTX7n4UDj5pxQoY33wbT8h+YSDjMQJ76MZUg2rXQIBXmIDtlZhLgirS1g/3R5/qaHA==} + engines: {node: '>=18'} + hasBin: true + sleep-promise@9.1.0: resolution: {integrity: sha512-UHYzVpz9Xn8b+jikYSD6bqvf754xL2uBUzDFwiU6NcdZeifPr6UfgU43xpkPu67VMS88+TI2PSI7Eohgqf2fKA==} @@ -5644,6 +5331,9 @@ packages: resolution: {integrity: sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==} engines: {node: '>=18'} + streamx@2.23.0: + resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -5689,11 +5379,17 @@ packages: resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==} engines: {node: '>=12.17'} + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + tar@7.5.9: resolution: {integrity: sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==} engines: {node: '>=18'} deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -6075,6 +5771,9 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yoctocolors@2.1.2: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} @@ -6108,7 +5807,7 @@ snapshots: '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 tslib: 2.8.1 '@aws-crypto/sha256-browser@5.2.0': @@ -6116,7 +5815,7 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 '@aws-sdk/util-locate-window': 3.965.4 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -6124,7 +5823,7 @@ snapshots: '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -6133,81 +5832,29 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/client-bedrock-runtime@3.995.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.11 - '@aws-sdk/credential-provider-node': 3.972.10 - '@aws-sdk/eventstream-handler-node': 3.972.5 - '@aws-sdk/middleware-eventstream': 3.972.3 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.11 - '@aws-sdk/middleware-websocket': 3.972.6 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/token-providers': 3.995.0 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.995.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.10 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.2 - '@smithy/eventstream-serde-browser': 4.2.8 - '@smithy/eventstream-serde-config-resolver': 4.3.8 - '@smithy/eventstream-serde-node': 4.2.8 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.16 - '@smithy/middleware-retry': 4.4.33 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.32 - '@smithy/util-defaults-mode-node': 4.2.35 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-stream': 4.5.12 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/client-bedrock-runtime@3.997.0': + '@aws-sdk/client-bedrock-runtime@3.998.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.13 - '@aws-sdk/credential-provider-node': 3.972.12 - '@aws-sdk/eventstream-handler-node': 3.972.7 - '@aws-sdk/middleware-eventstream': 3.972.4 - '@aws-sdk/middleware-host-header': 3.972.4 - '@aws-sdk/middleware-logger': 3.972.4 - '@aws-sdk/middleware-recursion-detection': 3.972.4 - '@aws-sdk/middleware-user-agent': 3.972.13 - '@aws-sdk/middleware-websocket': 3.972.8 - '@aws-sdk/region-config-resolver': 3.972.4 - '@aws-sdk/token-providers': 3.997.0 - '@aws-sdk/types': 3.973.2 - '@aws-sdk/util-endpoints': 3.996.1 - '@aws-sdk/util-user-agent-browser': 3.972.4 - '@aws-sdk/util-user-agent-node': 3.972.12 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/credential-provider-node': 3.972.13 + '@aws-sdk/eventstream-handler-node': 3.972.8 + '@aws-sdk/middleware-eventstream': 3.972.5 + '@aws-sdk/middleware-host-header': 3.972.5 + '@aws-sdk/middleware-logger': 3.972.5 + '@aws-sdk/middleware-recursion-detection': 3.972.5 + '@aws-sdk/middleware-user-agent': 3.972.14 + '@aws-sdk/middleware-websocket': 3.972.9 + '@aws-sdk/region-config-resolver': 3.972.5 + '@aws-sdk/token-providers': 3.998.0 + '@aws-sdk/types': 3.973.3 + '@aws-sdk/util-endpoints': 3.996.2 + '@aws-sdk/util-user-agent-browser': 3.972.5 + '@aws-sdk/util-user-agent-node': 3.972.13 '@smithy/config-resolver': 4.4.9 '@smithy/core': 3.23.6 '@smithy/eventstream-serde-browser': 4.2.10 @@ -6241,67 +5888,22 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-bedrock@3.995.0': + '@aws-sdk/client-bedrock@3.998.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.11 - '@aws-sdk/credential-provider-node': 3.972.10 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.11 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/token-providers': 3.995.0 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.995.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.10 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.2 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.16 - '@smithy/middleware-retry': 4.4.33 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.32 - '@smithy/util-defaults-mode-node': 4.2.35 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/client-bedrock@3.997.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.13 - '@aws-sdk/credential-provider-node': 3.972.12 - '@aws-sdk/middleware-host-header': 3.972.4 - '@aws-sdk/middleware-logger': 3.972.4 - '@aws-sdk/middleware-recursion-detection': 3.972.4 - '@aws-sdk/middleware-user-agent': 3.972.13 - '@aws-sdk/region-config-resolver': 3.972.4 - '@aws-sdk/token-providers': 3.997.0 - '@aws-sdk/types': 3.973.2 - '@aws-sdk/util-endpoints': 3.996.1 - '@aws-sdk/util-user-agent-browser': 3.972.4 - '@aws-sdk/util-user-agent-node': 3.972.12 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/credential-provider-node': 3.972.13 + '@aws-sdk/middleware-host-header': 3.972.5 + '@aws-sdk/middleware-logger': 3.972.5 + '@aws-sdk/middleware-recursion-detection': 3.972.5 + '@aws-sdk/middleware-user-agent': 3.972.14 + '@aws-sdk/region-config-resolver': 3.972.5 + '@aws-sdk/token-providers': 3.998.0 + '@aws-sdk/types': 3.973.3 + '@aws-sdk/util-endpoints': 3.996.2 + '@aws-sdk/util-user-agent-browser': 3.972.5 + '@aws-sdk/util-user-agent-node': 3.972.13 '@smithy/config-resolver': 4.4.9 '@smithy/core': 3.23.6 '@smithy/fetch-http-handler': 5.3.11 @@ -6331,69 +5933,10 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso@3.993.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.11 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.11 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.993.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.10 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.2 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.16 - '@smithy/middleware-retry': 4.4.33 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.32 - '@smithy/util-defaults-mode-node': 4.2.35 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/core@3.973.11': - dependencies: - '@aws-sdk/types': 3.973.1 - '@aws-sdk/xml-builder': 3.972.5 - '@smithy/core': 3.23.2 - '@smithy/node-config-provider': 4.3.8 - '@smithy/property-provider': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/signature-v4': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - - '@aws-sdk/core@3.973.13': + '@aws-sdk/core@3.973.14': dependencies: - '@aws-sdk/types': 3.973.2 - '@aws-sdk/xml-builder': 3.972.6 + '@aws-sdk/types': 3.973.3 + '@aws-sdk/xml-builder': 3.972.7 '@smithy/core': 3.23.6 '@smithy/node-config-provider': 4.3.10 '@smithy/property-provider': 4.2.10 @@ -6406,39 +5949,18 @@ snapshots: '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.11': + '@aws-sdk/credential-provider-env@3.972.12': dependencies: - '@aws-sdk/core': 3.973.13 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/types': 3.973.3 '@smithy/property-provider': 4.2.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.9': - dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-http@3.972.11': - dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/types': 3.973.1 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/node-http-handler': 4.4.10 - '@smithy/property-provider': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/util-stream': 4.5.12 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-http@3.972.13': + '@aws-sdk/credential-provider-http@3.972.14': dependencies: - '@aws-sdk/core': 3.973.13 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/types': 3.973.3 '@smithy/fetch-http-handler': 5.3.11 '@smithy/node-http-handler': 4.4.12 '@smithy/property-provider': 4.2.10 @@ -6448,17 +5970,17 @@ snapshots: '@smithy/util-stream': 4.5.15 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.972.11': - dependencies: - '@aws-sdk/core': 3.973.13 - '@aws-sdk/credential-provider-env': 3.972.11 - '@aws-sdk/credential-provider-http': 3.972.13 - '@aws-sdk/credential-provider-login': 3.972.11 - '@aws-sdk/credential-provider-process': 3.972.11 - '@aws-sdk/credential-provider-sso': 3.972.11 - '@aws-sdk/credential-provider-web-identity': 3.972.11 - '@aws-sdk/nested-clients': 3.996.1 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/credential-provider-ini@3.972.12': + dependencies: + '@aws-sdk/core': 3.973.14 + '@aws-sdk/credential-provider-env': 3.972.12 + '@aws-sdk/credential-provider-http': 3.972.14 + '@aws-sdk/credential-provider-login': 3.972.12 + '@aws-sdk/credential-provider-process': 3.972.12 + '@aws-sdk/credential-provider-sso': 3.972.12 + '@aws-sdk/credential-provider-web-identity': 3.972.12 + '@aws-sdk/nested-clients': 3.996.2 + '@aws-sdk/types': 3.973.3 '@smithy/credential-provider-imds': 4.2.10 '@smithy/property-provider': 4.2.10 '@smithy/shared-ini-file-loader': 4.4.5 @@ -6467,30 +5989,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-ini@3.972.9': - dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/credential-provider-env': 3.972.9 - '@aws-sdk/credential-provider-http': 3.972.11 - '@aws-sdk/credential-provider-login': 3.972.9 - '@aws-sdk/credential-provider-process': 3.972.9 - '@aws-sdk/credential-provider-sso': 3.972.9 - '@aws-sdk/credential-provider-web-identity': 3.972.9 - '@aws-sdk/nested-clients': 3.993.0 - '@aws-sdk/types': 3.973.1 - '@smithy/credential-provider-imds': 4.2.8 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-login@3.972.11': + '@aws-sdk/credential-provider-login@3.972.12': dependencies: - '@aws-sdk/core': 3.973.13 - '@aws-sdk/nested-clients': 3.996.1 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/nested-clients': 3.996.2 + '@aws-sdk/types': 3.973.3 '@smithy/property-provider': 4.2.10 '@smithy/protocol-http': 5.3.10 '@smithy/shared-ini-file-loader': 4.4.5 @@ -6499,45 +6002,15 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-login@3.972.9': - dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/nested-clients': 3.993.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-node@3.972.10': - dependencies: - '@aws-sdk/credential-provider-env': 3.972.9 - '@aws-sdk/credential-provider-http': 3.972.11 - '@aws-sdk/credential-provider-ini': 3.972.9 - '@aws-sdk/credential-provider-process': 3.972.9 - '@aws-sdk/credential-provider-sso': 3.972.9 - '@aws-sdk/credential-provider-web-identity': 3.972.9 - '@aws-sdk/types': 3.973.1 - '@smithy/credential-provider-imds': 4.2.8 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-node@3.972.12': + '@aws-sdk/credential-provider-node@3.972.13': dependencies: - '@aws-sdk/credential-provider-env': 3.972.11 - '@aws-sdk/credential-provider-http': 3.972.13 - '@aws-sdk/credential-provider-ini': 3.972.11 - '@aws-sdk/credential-provider-process': 3.972.11 - '@aws-sdk/credential-provider-sso': 3.972.11 - '@aws-sdk/credential-provider-web-identity': 3.972.11 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/credential-provider-env': 3.972.12 + '@aws-sdk/credential-provider-http': 3.972.14 + '@aws-sdk/credential-provider-ini': 3.972.12 + '@aws-sdk/credential-provider-process': 3.972.12 + '@aws-sdk/credential-provider-sso': 3.972.12 + '@aws-sdk/credential-provider-web-identity': 3.972.12 + '@aws-sdk/types': 3.973.3 '@smithy/credential-provider-imds': 4.2.10 '@smithy/property-provider': 4.2.10 '@smithy/shared-ini-file-loader': 4.4.5 @@ -6546,30 +6019,21 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-process@3.972.11': + '@aws-sdk/credential-provider-process@3.972.12': dependencies: - '@aws-sdk/core': 3.973.13 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/types': 3.973.3 '@smithy/property-provider': 4.2.10 '@smithy/shared-ini-file-loader': 4.4.5 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-process@3.972.9': - dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-sso@3.972.11': + '@aws-sdk/credential-provider-sso@3.972.12': dependencies: - '@aws-sdk/core': 3.973.13 - '@aws-sdk/nested-clients': 3.996.1 - '@aws-sdk/token-providers': 3.997.0 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/nested-clients': 3.996.2 + '@aws-sdk/token-providers': 3.998.0 + '@aws-sdk/types': 3.973.3 '@smithy/property-provider': 4.2.10 '@smithy/shared-ini-file-loader': 4.4.5 '@smithy/types': 4.13.0 @@ -6577,24 +6041,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-sso@3.972.9': - dependencies: - '@aws-sdk/client-sso': 3.993.0 - '@aws-sdk/core': 3.973.11 - '@aws-sdk/token-providers': 3.993.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-web-identity@3.972.11': + '@aws-sdk/credential-provider-web-identity@3.972.12': dependencies: - '@aws-sdk/core': 3.973.13 - '@aws-sdk/nested-clients': 3.996.1 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/nested-clients': 3.996.2 + '@aws-sdk/types': 3.973.3 '@smithy/property-provider': 4.2.10 '@smithy/shared-ini-file-loader': 4.4.5 '@smithy/types': 4.13.0 @@ -6602,127 +6053,55 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-web-identity@3.972.9': - dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/nested-clients': 3.993.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/eventstream-handler-node@3.972.5': - dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/eventstream-codec': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/eventstream-handler-node@3.972.7': + '@aws-sdk/eventstream-handler-node@3.972.8': dependencies: - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 '@smithy/eventstream-codec': 4.2.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/middleware-eventstream@3.972.3': + '@aws-sdk/middleware-eventstream@3.972.5': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-eventstream@3.972.4': - dependencies: - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 '@smithy/protocol-http': 5.3.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/middleware-host-header@3.972.3': - dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-host-header@3.972.4': + '@aws-sdk/middleware-host-header@3.972.5': dependencies: - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 '@smithy/protocol-http': 5.3.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/middleware-logger@3.972.3': + '@aws-sdk/middleware-logger@3.972.5': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-logger@3.972.4': - dependencies: - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/middleware-recursion-detection@3.972.3': - dependencies: - '@aws-sdk/types': 3.973.1 - '@aws/lambda-invoke-store': 0.2.3 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-recursion-detection@3.972.4': + '@aws-sdk/middleware-recursion-detection@3.972.5': dependencies: - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 '@aws/lambda-invoke-store': 0.2.3 '@smithy/protocol-http': 5.3.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/middleware-user-agent@3.972.11': - dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.993.0 - '@smithy/core': 3.23.2 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-user-agent@3.972.13': + '@aws-sdk/middleware-user-agent@3.972.14': dependencies: - '@aws-sdk/core': 3.973.13 - '@aws-sdk/types': 3.973.2 - '@aws-sdk/util-endpoints': 3.996.1 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/types': 3.973.3 + '@aws-sdk/util-endpoints': 3.996.2 '@smithy/core': 3.23.6 '@smithy/protocol-http': 5.3.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/middleware-websocket@3.972.6': - dependencies: - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-format-url': 3.972.3 - '@smithy/eventstream-codec': 4.2.8 - '@smithy/eventstream-serde-browser': 4.2.8 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/protocol-http': 5.3.8 - '@smithy/signature-v4': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-hex-encoding': 4.2.0 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-websocket@3.972.8': + '@aws-sdk/middleware-websocket@3.972.9': dependencies: - '@aws-sdk/types': 3.973.2 - '@aws-sdk/util-format-url': 3.972.4 + '@aws-sdk/types': 3.973.3 + '@aws-sdk/util-format-url': 3.972.5 '@smithy/eventstream-codec': 4.2.10 '@smithy/eventstream-serde-browser': 4.2.10 '@smithy/fetch-http-handler': 5.3.11 @@ -6734,106 +6113,20 @@ snapshots: '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.993.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.11 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.11 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.993.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.10 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.2 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.16 - '@smithy/middleware-retry': 4.4.33 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.32 - '@smithy/util-defaults-mode-node': 4.2.35 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/nested-clients@3.995.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.11 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.11 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.995.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.10 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.2 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.16 - '@smithy/middleware-retry': 4.4.33 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.32 - '@smithy/util-defaults-mode-node': 4.2.35 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/nested-clients@3.996.1': + '@aws-sdk/nested-clients@3.996.2': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.13 - '@aws-sdk/middleware-host-header': 3.972.4 - '@aws-sdk/middleware-logger': 3.972.4 - '@aws-sdk/middleware-recursion-detection': 3.972.4 - '@aws-sdk/middleware-user-agent': 3.972.13 - '@aws-sdk/region-config-resolver': 3.972.4 - '@aws-sdk/types': 3.973.2 - '@aws-sdk/util-endpoints': 3.996.1 - '@aws-sdk/util-user-agent-browser': 3.972.4 - '@aws-sdk/util-user-agent-node': 3.972.12 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/middleware-host-header': 3.972.5 + '@aws-sdk/middleware-logger': 3.972.5 + '@aws-sdk/middleware-recursion-detection': 3.972.5 + '@aws-sdk/middleware-user-agent': 3.972.14 + '@aws-sdk/region-config-resolver': 3.972.5 + '@aws-sdk/types': 3.973.3 + '@aws-sdk/util-endpoints': 3.996.2 + '@aws-sdk/util-user-agent-browser': 3.972.5 + '@aws-sdk/util-user-agent-node': 3.972.13 '@smithy/config-resolver': 4.4.9 '@smithy/core': 3.23.6 '@smithy/fetch-http-handler': 5.3.11 @@ -6863,51 +6156,19 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/region-config-resolver@3.972.3': - dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/config-resolver': 4.4.6 - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/region-config-resolver@3.972.4': + '@aws-sdk/region-config-resolver@3.972.5': dependencies: - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 '@smithy/config-resolver': 4.4.9 '@smithy/node-config-provider': 4.3.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/token-providers@3.993.0': - dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/nested-clients': 3.993.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/token-providers@3.995.0': - dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/nested-clients': 3.995.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/token-providers@3.997.0': + '@aws-sdk/token-providers@3.998.0': dependencies: - '@aws-sdk/core': 3.973.13 - '@aws-sdk/nested-clients': 3.996.1 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/core': 3.973.14 + '@aws-sdk/nested-clients': 3.996.2 + '@aws-sdk/types': 3.973.3 '@smithy/property-provider': 4.2.10 '@smithy/shared-ini-file-loader': 4.4.5 '@smithy/types': 4.13.0 @@ -6915,50 +6176,22 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/types@3.973.1': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/types@3.973.2': + '@aws-sdk/types@3.973.3': dependencies: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/util-endpoints@3.993.0': + '@aws-sdk/util-endpoints@3.996.2': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-endpoints': 3.2.8 - tslib: 2.8.1 - - '@aws-sdk/util-endpoints@3.995.0': - dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-endpoints': 3.2.8 - tslib: 2.8.1 - - '@aws-sdk/util-endpoints@3.996.1': - dependencies: - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 '@smithy/types': 4.13.0 '@smithy/url-parser': 4.2.10 '@smithy/util-endpoints': 3.3.1 tslib: 2.8.1 - '@aws-sdk/util-format-url@3.972.3': + '@aws-sdk/util-format-url@3.972.5': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/querystring-builder': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/util-format-url@3.972.4': - dependencies: - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 '@smithy/querystring-builder': 4.2.10 '@smithy/types': 4.13.0 tslib: 2.8.1 @@ -6967,43 +6200,22 @@ snapshots: dependencies: tslib: 2.8.1 - '@aws-sdk/util-user-agent-browser@3.972.3': - dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 - bowser: 2.14.1 - tslib: 2.8.1 - - '@aws-sdk/util-user-agent-browser@3.972.4': + '@aws-sdk/util-user-agent-browser@3.972.5': dependencies: - '@aws-sdk/types': 3.973.2 + '@aws-sdk/types': 3.973.3 '@smithy/types': 4.13.0 bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/util-user-agent-node@3.972.10': - dependencies: - '@aws-sdk/middleware-user-agent': 3.972.11 - '@aws-sdk/types': 3.973.1 - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/util-user-agent-node@3.972.12': + '@aws-sdk/util-user-agent-node@3.972.13': dependencies: - '@aws-sdk/middleware-user-agent': 3.972.13 - '@aws-sdk/types': 3.973.2 + '@aws-sdk/middleware-user-agent': 3.972.14 + '@aws-sdk/types': 3.973.3 '@smithy/node-config-provider': 4.3.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.5': - dependencies: - '@smithy/types': 4.12.0 - fast-xml-parser: 5.3.6 - tslib: 2.8.1 - - '@aws-sdk/xml-builder@3.972.6': + '@aws-sdk/xml-builder@3.972.7': dependencies: '@smithy/types': 4.13.0 fast-xml-parser: 5.3.6 @@ -7031,11 +6243,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@azure/msal-common@16.0.4': {} + '@azure/msal-common@16.1.0': {} - '@azure/msal-node@5.0.4': + '@azure/msal-node@5.0.5': dependencies: - '@azure/msal-common': 16.0.4 + '@azure/msal-common': 16.1.0 jsonwebtoken: 9.0.3 uuid: 8.3.2 @@ -7080,26 +6292,6 @@ snapshots: '@borewit/text-codec@0.2.1': {} - '@buape/carbon@0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.0.8)': - dependencies: - '@types/node': 25.3.0 - discord-api-types: 0.38.37 - optionalDependencies: - '@cloudflare/workers-types': 4.20260120.0 - '@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.0.8) - '@hono/node-server': 1.19.9(hono@4.11.10) - '@types/bun': 1.3.9 - '@types/ws': 8.18.1 - ws: 8.19.0 - transitivePeerDependencies: - - '@discordjs/opus' - - bufferutil - - ffmpeg-static - - hono - - node-opus - - opusscript - - utf-8-validate - '@buape/carbon@0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.1.1)': dependencies: '@types/node': 25.3.0 @@ -7256,21 +6448,6 @@ snapshots: - supports-color optional: true - '@discordjs/voice@0.19.0(@discordjs/opus@0.10.0)(opusscript@0.0.8)': - dependencies: - '@types/ws': 8.18.1 - discord-api-types: 0.38.40 - prism-media: 1.3.5(@discordjs/opus@0.10.0)(opusscript@0.0.8) - tslib: 2.8.1 - ws: 8.19.0 - transitivePeerDependencies: - - '@discordjs/opus' - - bufferutil - - ffmpeg-static - - node-opus - - opusscript - - utf-8-validate - '@discordjs/voice@0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1)': dependencies: '@types/ws': 8.18.1 @@ -7728,9 +6905,9 @@ snapshots: std-env: 3.10.0 yoctocolors: 2.1.2 - '@mariozechner/pi-agent-core@0.54.1(ws@8.19.0)(zod@4.3.6)': + '@mariozechner/pi-agent-core@0.55.0(ws@8.19.0)(zod@4.3.6)': dependencies: - '@mariozechner/pi-ai': 0.54.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.55.0(ws@8.19.0)(zod@4.3.6) transitivePeerDependencies: - '@modelcontextprotocol/sdk' - aws-crt @@ -7740,9 +6917,9 @@ snapshots: - ws - zod - '@mariozechner/pi-agent-core@0.55.0(ws@8.19.0)(zod@4.3.6)': + '@mariozechner/pi-agent-core@0.55.1(ws@8.19.0)(zod@4.3.6)': dependencies: - '@mariozechner/pi-ai': 0.55.0(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.55.1(ws@8.19.0)(zod@4.3.6) transitivePeerDependencies: - '@modelcontextprotocol/sdk' - aws-crt @@ -7752,10 +6929,10 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.54.1(ws@8.19.0)(zod@4.3.6)': + '@mariozechner/pi-ai@0.55.0(ws@8.19.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) - '@aws-sdk/client-bedrock-runtime': 3.995.0 + '@aws-sdk/client-bedrock-runtime': 3.998.0 '@google/genai': 1.42.0 '@mistralai/mistralai': 1.10.0 '@sinclair/typebox': 0.34.48 @@ -7776,10 +6953,10 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.55.0(ws@8.19.0)(zod@4.3.6)': + '@mariozechner/pi-ai@0.55.1(ws@8.19.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) - '@aws-sdk/client-bedrock-runtime': 3.997.0 + '@aws-sdk/client-bedrock-runtime': 3.998.0 '@google/genai': 1.42.0 '@mistralai/mistralai': 1.10.0 '@sinclair/typebox': 0.34.48 @@ -7800,12 +6977,12 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.54.1(ws@8.19.0)(zod@4.3.6)': + '@mariozechner/pi-coding-agent@0.55.0(ws@8.19.0)(zod@4.3.6)': dependencies: '@mariozechner/jiti': 2.6.5 - '@mariozechner/pi-agent-core': 0.54.1(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-ai': 0.54.1(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-tui': 0.54.1 + '@mariozechner/pi-agent-core': 0.55.0(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.55.0(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-tui': 0.55.0 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 cli-highlight: 2.1.11 @@ -7829,16 +7006,17 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.55.0(ws@8.19.0)(zod@4.3.6)': + '@mariozechner/pi-coding-agent@0.55.1(ws@8.19.0)(zod@4.3.6)': dependencies: '@mariozechner/jiti': 2.6.5 - '@mariozechner/pi-agent-core': 0.55.0(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-ai': 0.55.0(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-tui': 0.55.0 + '@mariozechner/pi-agent-core': 0.55.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.55.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-tui': 0.55.1 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 cli-highlight: 2.1.11 diff: 8.0.3 + extract-zip: 2.0.1 file-type: 21.3.0 glob: 13.0.6 hosted-git-info: 9.0.2 @@ -7858,7 +7036,7 @@ snapshots: - ws - zod - '@mariozechner/pi-tui@0.54.1': + '@mariozechner/pi-tui@0.55.0': dependencies: '@types/mime-types': 2.1.4 chalk: 5.6.2 @@ -7867,7 +7045,7 @@ snapshots: marked: 15.0.12 mime-types: 3.0.2 - '@mariozechner/pi-tui@0.55.0': + '@mariozechner/pi-tui@0.55.1': dependencies: '@types/mime-types': 2.1.4 chalk: 5.6.2 @@ -7894,7 +7072,7 @@ snapshots: '@microsoft/agents-hosting@1.3.1': dependencies: '@azure/core-auth': 1.10.1 - '@azure/msal-node': 5.0.4 + '@azure/msal-node': 5.0.5 '@microsoft/agents-activity': 1.3.1 axios: 1.13.5(debug@4.4.3) jsonwebtoken: 9.0.3 @@ -7912,99 +7090,52 @@ snapshots: '@mozilla/readability@0.6.0': {} - '@napi-rs/canvas-android-arm64@0.1.92': - optional: true - - '@napi-rs/canvas-android-arm64@0.1.94': + '@napi-rs/canvas-android-arm64@0.1.95': optional: true - '@napi-rs/canvas-darwin-arm64@0.1.92': + '@napi-rs/canvas-darwin-arm64@0.1.95': optional: true - '@napi-rs/canvas-darwin-arm64@0.1.94': + '@napi-rs/canvas-darwin-x64@0.1.95': optional: true - '@napi-rs/canvas-darwin-x64@0.1.92': + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.95': optional: true - '@napi-rs/canvas-darwin-x64@0.1.94': + '@napi-rs/canvas-linux-arm64-gnu@0.1.95': optional: true - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.92': + '@napi-rs/canvas-linux-arm64-musl@0.1.95': optional: true - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.94': + '@napi-rs/canvas-linux-riscv64-gnu@0.1.95': optional: true - '@napi-rs/canvas-linux-arm64-gnu@0.1.92': + '@napi-rs/canvas-linux-x64-gnu@0.1.95': optional: true - '@napi-rs/canvas-linux-arm64-gnu@0.1.94': + '@napi-rs/canvas-linux-x64-musl@0.1.95': optional: true - '@napi-rs/canvas-linux-arm64-musl@0.1.92': + '@napi-rs/canvas-win32-arm64-msvc@0.1.95': optional: true - '@napi-rs/canvas-linux-arm64-musl@0.1.94': + '@napi-rs/canvas-win32-x64-msvc@0.1.95': optional: true - '@napi-rs/canvas-linux-riscv64-gnu@0.1.92': - optional: true - - '@napi-rs/canvas-linux-riscv64-gnu@0.1.94': - optional: true - - '@napi-rs/canvas-linux-x64-gnu@0.1.92': - optional: true - - '@napi-rs/canvas-linux-x64-gnu@0.1.94': - optional: true - - '@napi-rs/canvas-linux-x64-musl@0.1.92': - optional: true - - '@napi-rs/canvas-linux-x64-musl@0.1.94': - optional: true - - '@napi-rs/canvas-win32-arm64-msvc@0.1.92': - optional: true - - '@napi-rs/canvas-win32-arm64-msvc@0.1.94': - optional: true - - '@napi-rs/canvas-win32-x64-msvc@0.1.92': - optional: true - - '@napi-rs/canvas-win32-x64-msvc@0.1.94': - optional: true - - '@napi-rs/canvas@0.1.92': - optionalDependencies: - '@napi-rs/canvas-android-arm64': 0.1.92 - '@napi-rs/canvas-darwin-arm64': 0.1.92 - '@napi-rs/canvas-darwin-x64': 0.1.92 - '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.92 - '@napi-rs/canvas-linux-arm64-gnu': 0.1.92 - '@napi-rs/canvas-linux-arm64-musl': 0.1.92 - '@napi-rs/canvas-linux-riscv64-gnu': 0.1.92 - '@napi-rs/canvas-linux-x64-gnu': 0.1.92 - '@napi-rs/canvas-linux-x64-musl': 0.1.92 - '@napi-rs/canvas-win32-arm64-msvc': 0.1.92 - '@napi-rs/canvas-win32-x64-msvc': 0.1.92 - - '@napi-rs/canvas@0.1.94': + '@napi-rs/canvas@0.1.95': optionalDependencies: - '@napi-rs/canvas-android-arm64': 0.1.94 - '@napi-rs/canvas-darwin-arm64': 0.1.94 - '@napi-rs/canvas-darwin-x64': 0.1.94 - '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.94 - '@napi-rs/canvas-linux-arm64-gnu': 0.1.94 - '@napi-rs/canvas-linux-arm64-musl': 0.1.94 - '@napi-rs/canvas-linux-riscv64-gnu': 0.1.94 - '@napi-rs/canvas-linux-x64-gnu': 0.1.94 - '@napi-rs/canvas-linux-x64-musl': 0.1.94 - '@napi-rs/canvas-win32-arm64-msvc': 0.1.94 - '@napi-rs/canvas-win32-x64-msvc': 0.1.94 + '@napi-rs/canvas-android-arm64': 0.1.95 + '@napi-rs/canvas-darwin-arm64': 0.1.95 + '@napi-rs/canvas-darwin-x64': 0.1.95 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.95 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.95 + '@napi-rs/canvas-linux-arm64-musl': 0.1.95 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.95 + '@napi-rs/canvas-linux-x64-gnu': 0.1.95 + '@napi-rs/canvas-linux-x64-musl': 0.1.95 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.95 + '@napi-rs/canvas-win32-x64-msvc': 0.1.95 '@napi-rs/wasm-runtime@1.1.1': dependencies: @@ -8076,7 +7207,7 @@ snapshots: dependencies: '@octokit/auth-oauth-app': 9.0.3 '@octokit/auth-oauth-user': 6.0.2 - '@octokit/request': 10.0.7 + '@octokit/request': 10.0.8 '@octokit/request-error': 7.1.0 '@octokit/types': 16.0.0 toad-cache: 3.7.0 @@ -8087,14 +7218,14 @@ snapshots: dependencies: '@octokit/auth-oauth-device': 8.0.3 '@octokit/auth-oauth-user': 6.0.2 - '@octokit/request': 10.0.7 + '@octokit/request': 10.0.8 '@octokit/types': 16.0.0 universal-user-agent: 7.0.3 '@octokit/auth-oauth-device@8.0.3': dependencies: '@octokit/oauth-methods': 6.0.2 - '@octokit/request': 10.0.7 + '@octokit/request': 10.0.8 '@octokit/types': 16.0.0 universal-user-agent: 7.0.3 @@ -8102,7 +7233,7 @@ snapshots: dependencies: '@octokit/auth-oauth-device': 8.0.3 '@octokit/oauth-methods': 6.0.2 - '@octokit/request': 10.0.7 + '@octokit/request': 10.0.8 '@octokit/types': 16.0.0 universal-user-agent: 7.0.3 @@ -8117,20 +7248,20 @@ snapshots: dependencies: '@octokit/auth-token': 6.0.0 '@octokit/graphql': 9.0.3 - '@octokit/request': 10.0.7 + '@octokit/request': 10.0.8 '@octokit/request-error': 7.1.0 '@octokit/types': 16.0.0 before-after-hook: 4.0.0 universal-user-agent: 7.0.3 - '@octokit/endpoint@11.0.2': + '@octokit/endpoint@11.0.3': dependencies: '@octokit/types': 16.0.0 universal-user-agent: 7.0.3 '@octokit/graphql@9.0.3': dependencies: - '@octokit/request': 10.0.7 + '@octokit/request': 10.0.8 '@octokit/types': 16.0.0 universal-user-agent: 7.0.3 @@ -8150,7 +7281,7 @@ snapshots: '@octokit/oauth-methods@6.0.2': dependencies: '@octokit/oauth-authorization-url': 8.0.0 - '@octokit/request': 10.0.7 + '@octokit/request': 10.0.8 '@octokit/request-error': 7.1.0 '@octokit/types': 16.0.0 @@ -8172,7 +7303,7 @@ snapshots: '@octokit/core': 7.0.6 '@octokit/types': 16.0.0 - '@octokit/plugin-retry@8.0.3(@octokit/core@7.0.6)': + '@octokit/plugin-retry@8.1.0(@octokit/core@7.0.6)': dependencies: '@octokit/core': 7.0.6 '@octokit/request-error': 7.1.0 @@ -8189,12 +7320,13 @@ snapshots: dependencies: '@octokit/types': 16.0.0 - '@octokit/request@10.0.7': + '@octokit/request@10.0.8': dependencies: - '@octokit/endpoint': 11.0.2 + '@octokit/endpoint': 11.0.3 '@octokit/request-error': 7.1.0 '@octokit/types': 16.0.0 fast-content-type-parse: 3.0.0 + json-with-bigint: 3.5.3 universal-user-agent: 7.0.3 '@octokit/types@16.0.0': @@ -8859,20 +7991,6 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/abort-controller@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/config-resolver@4.4.6': - dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-config-provider': 4.2.0 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - tslib: 2.8.1 - '@smithy/config-resolver@4.4.9': dependencies: '@smithy/node-config-provider': 4.3.10 @@ -8882,19 +8000,6 @@ snapshots: '@smithy/util-middleware': 4.2.10 tslib: 2.8.1 - '@smithy/core@3.23.2': - dependencies: - '@smithy/middleware-serde': 4.2.9 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-stream': 4.5.12 - '@smithy/util-utf8': 4.2.0 - '@smithy/uuid': 1.1.0 - tslib: 2.8.1 - '@smithy/core@3.23.6': dependencies: '@smithy/middleware-serde': 4.2.11 @@ -8916,14 +8021,6 @@ snapshots: '@smithy/url-parser': 4.2.10 tslib: 2.8.1 - '@smithy/credential-provider-imds@4.2.8': - dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/property-provider': 4.2.8 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - tslib: 2.8.1 - '@smithy/eventstream-codec@4.2.10': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -8931,59 +8028,29 @@ snapshots: '@smithy/util-hex-encoding': 4.2.1 tslib: 2.8.1 - '@smithy/eventstream-codec@4.2.8': - dependencies: - '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.12.0 - '@smithy/util-hex-encoding': 4.2.0 - tslib: 2.8.1 - '@smithy/eventstream-serde-browser@4.2.10': dependencies: '@smithy/eventstream-serde-universal': 4.2.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/eventstream-serde-browser@4.2.8': - dependencies: - '@smithy/eventstream-serde-universal': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/eventstream-serde-config-resolver@4.3.10': dependencies: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/eventstream-serde-config-resolver@4.3.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/eventstream-serde-node@4.2.10': dependencies: '@smithy/eventstream-serde-universal': 4.2.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/eventstream-serde-node@4.2.8': - dependencies: - '@smithy/eventstream-serde-universal': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/eventstream-serde-universal@4.2.10': dependencies: '@smithy/eventstream-codec': 4.2.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/eventstream-serde-universal@4.2.8': - dependencies: - '@smithy/eventstream-codec': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/fetch-http-handler@5.3.11': dependencies: '@smithy/protocol-http': 5.3.10 @@ -8992,14 +8059,6 @@ snapshots: '@smithy/util-base64': 4.3.1 tslib: 2.8.1 - '@smithy/fetch-http-handler@5.3.9': - dependencies: - '@smithy/protocol-http': 5.3.8 - '@smithy/querystring-builder': 4.2.8 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - tslib: 2.8.1 - '@smithy/hash-node@4.2.10': dependencies: '@smithy/types': 4.13.0 @@ -9007,31 +8066,15 @@ snapshots: '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 - '@smithy/hash-node@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - '@smithy/util-buffer-from': 4.2.0 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - '@smithy/invalid-dependency@4.2.10': dependencies: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/invalid-dependency@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/is-array-buffer@2.2.0': dependencies: tslib: 2.8.1 - '@smithy/is-array-buffer@4.2.0': - dependencies: - tslib: 2.8.1 - '@smithy/is-array-buffer@4.2.1': dependencies: tslib: 2.8.1 @@ -9042,23 +8085,6 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/middleware-content-length@4.2.8': - dependencies: - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/middleware-endpoint@4.4.16': - dependencies: - '@smithy/core': 3.23.2 - '@smithy/middleware-serde': 4.2.9 - '@smithy/node-config-provider': 4.3.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-middleware': 4.2.8 - tslib: 2.8.1 - '@smithy/middleware-endpoint@4.4.20': dependencies: '@smithy/core': 3.23.6 @@ -9070,18 +8096,6 @@ snapshots: '@smithy/util-middleware': 4.2.10 tslib: 2.8.1 - '@smithy/middleware-retry@4.4.33': - dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/service-error-classification': 4.2.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/uuid': 1.1.0 - tslib: 2.8.1 - '@smithy/middleware-retry@4.4.37': dependencies: '@smithy/node-config-provider': 4.3.10 @@ -9100,22 +8114,11 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/middleware-serde@4.2.9': - dependencies: - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/middleware-stack@4.2.10': dependencies: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/middleware-stack@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/node-config-provider@4.3.10': dependencies: '@smithy/property-provider': 4.2.10 @@ -9123,21 +8126,6 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/node-config-provider@4.3.8': - dependencies: - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/node-http-handler@4.4.10': - dependencies: - '@smithy/abort-controller': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/querystring-builder': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/node-http-handler@4.4.12': dependencies: '@smithy/abort-controller': 4.2.10 @@ -9151,56 +8139,26 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/property-provider@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/protocol-http@5.3.10': dependencies: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/protocol-http@5.3.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/querystring-builder@4.2.10': dependencies: '@smithy/types': 4.13.0 '@smithy/util-uri-escape': 4.2.1 tslib: 2.8.1 - '@smithy/querystring-builder@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - '@smithy/util-uri-escape': 4.2.0 - tslib: 2.8.1 - '@smithy/querystring-parser@4.2.10': dependencies: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/querystring-parser@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/service-error-classification@4.2.10': dependencies: '@smithy/types': 4.13.0 - '@smithy/service-error-classification@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - - '@smithy/shared-ini-file-loader@4.4.3': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/shared-ini-file-loader@4.4.5': dependencies: '@smithy/types': 4.13.0 @@ -9217,27 +8175,6 @@ snapshots: '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 - '@smithy/signature-v4@5.3.8': - dependencies: - '@smithy/is-array-buffer': 4.2.0 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-hex-encoding': 4.2.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-uri-escape': 4.2.0 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - - '@smithy/smithy-client@4.11.5': - dependencies: - '@smithy/core': 3.23.2 - '@smithy/middleware-endpoint': 4.4.16 - '@smithy/middleware-stack': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-stream': 4.5.12 - tslib: 2.8.1 - '@smithy/smithy-client@4.12.0': dependencies: '@smithy/core': 3.23.6 @@ -9248,10 +8185,6 @@ snapshots: '@smithy/util-stream': 4.5.15 tslib: 2.8.1 - '@smithy/types@4.12.0': - dependencies: - tslib: 2.8.1 - '@smithy/types@4.13.0': dependencies: tslib: 2.8.1 @@ -9262,36 +8195,16 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/url-parser@4.2.8': - dependencies: - '@smithy/querystring-parser': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/util-base64@4.3.0': - dependencies: - '@smithy/util-buffer-from': 4.2.0 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - '@smithy/util-base64@4.3.1': dependencies: '@smithy/util-buffer-from': 4.2.1 '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 - '@smithy/util-body-length-browser@4.2.0': - dependencies: - tslib: 2.8.1 - '@smithy/util-body-length-browser@4.2.1': dependencies: tslib: 2.8.1 - '@smithy/util-body-length-node@4.2.1': - dependencies: - tslib: 2.8.1 - '@smithy/util-body-length-node@4.2.2': dependencies: tslib: 2.8.1 @@ -9301,31 +8214,15 @@ snapshots: '@smithy/is-array-buffer': 2.2.0 tslib: 2.8.1 - '@smithy/util-buffer-from@4.2.0': - dependencies: - '@smithy/is-array-buffer': 4.2.0 - tslib: 2.8.1 - '@smithy/util-buffer-from@4.2.1': dependencies: '@smithy/is-array-buffer': 4.2.1 tslib: 2.8.1 - '@smithy/util-config-provider@4.2.0': - dependencies: - tslib: 2.8.1 - '@smithy/util-config-provider@4.2.1': dependencies: tslib: 2.8.1 - '@smithy/util-defaults-mode-browser@4.3.32': - dependencies: - '@smithy/property-provider': 4.2.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/util-defaults-mode-browser@4.3.36': dependencies: '@smithy/property-provider': 4.2.10 @@ -9333,16 +8230,6 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/util-defaults-mode-node@4.2.35': - dependencies: - '@smithy/config-resolver': 4.4.6 - '@smithy/credential-provider-imds': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/property-provider': 4.2.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/util-defaults-mode-node@4.2.39': dependencies: '@smithy/config-resolver': 4.4.9 @@ -9353,22 +8240,12 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/util-endpoints@3.2.8': - dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/util-endpoints@3.3.1': dependencies: '@smithy/node-config-provider': 4.3.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/util-hex-encoding@4.2.0': - dependencies: - tslib: 2.8.1 - '@smithy/util-hex-encoding@4.2.1': dependencies: tslib: 2.8.1 @@ -9378,34 +8255,12 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/util-middleware@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - '@smithy/util-retry@4.2.10': dependencies: '@smithy/service-error-classification': 4.2.10 '@smithy/types': 4.13.0 tslib: 2.8.1 - '@smithy/util-retry@4.2.8': - dependencies: - '@smithy/service-error-classification': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/util-stream@4.5.12': - dependencies: - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/node-http-handler': 4.4.10 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-buffer-from': 4.2.0 - '@smithy/util-hex-encoding': 4.2.0 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - '@smithy/util-stream@4.5.15': dependencies: '@smithy/fetch-http-handler': 5.3.11 @@ -9417,10 +8272,6 @@ snapshots: '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 - '@smithy/util-uri-escape@4.2.0': - dependencies: - tslib: 2.8.1 - '@smithy/util-uri-escape@4.2.1': dependencies: tslib: 2.8.1 @@ -9430,20 +8281,11 @@ snapshots: '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 - '@smithy/util-utf8@4.2.0': - dependencies: - '@smithy/util-buffer-from': 4.2.0 - tslib: 2.8.1 - '@smithy/util-utf8@4.2.1': dependencies: '@smithy/util-buffer-from': 4.2.1 tslib: 2.8.1 - '@smithy/uuid@1.1.0': - dependencies: - tslib: 2.8.1 - '@smithy/uuid@1.1.1': dependencies: tslib: 2.8.1 @@ -9733,36 +8575,41 @@ snapshots: dependencies: '@types/node': 25.3.0 - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260224.1': + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 25.3.0 + optional: true + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260225.1': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260224.1': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260225.1': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260224.1': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260225.1': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260224.1': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260225.1': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260224.1': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260225.1': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260224.1': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260225.1': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260224.1': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260225.1': optional: true - '@typescript/native-preview@7.0.0-dev.20260224.1': + '@typescript/native-preview@7.0.0-dev.20260225.1': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260224.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260224.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260224.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260224.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260224.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260224.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260224.1 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260225.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260225.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260225.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260225.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260225.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260225.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260225.1 '@typespec/ts-http-runtime@0.3.3': dependencies: @@ -9912,7 +8759,7 @@ snapshots: '@cacheable/node-cache': 1.7.6 '@hapi/boom': 9.1.4 async-mutex: 0.5.0 - libsignal: '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67' + libsignal: '@whiskeysockets/libsignal-node@git+https://github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67' lru-cache: 11.2.6 music-metadata: 11.12.1 p-queue: 9.1.0 @@ -9927,7 +8774,7 @@ snapshots: - supports-color - utf-8-validate - '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': + '@whiskeysockets/libsignal-node@git+https://github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': dependencies: curve25519-js: 0.0.4 protobufjs: 6.8.8 @@ -9955,6 +8802,16 @@ snapshots: acorn@8.16.0: {} + acpx@0.1.13(zod@4.3.6): + dependencies: + '@agentclientprotocol/sdk': 0.14.1(zod@4.3.6) + commander: 13.1.0 + skillflag: 0.1.4 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - zod + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -10094,8 +8951,12 @@ snapshots: transitivePeerDependencies: - debug + b4a@1.8.0: {} + balanced-match@4.0.4: {} + bare-events@2.8.2: {} + base64-js@1.5.1: {} basic-auth@2.0.1: @@ -10157,6 +9018,8 @@ snapshots: dependencies: balanced-match: 4.0.4 + buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} buffer-from@1.1.2: {} @@ -10290,6 +9153,8 @@ snapshots: commander@10.0.1: {} + commander@13.1.0: {} + commander@14.0.3: {} console-control-strings@1.1.0: {} @@ -10433,6 +9298,10 @@ snapshots: encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + entities@4.5.0: {} entities@7.0.1: {} @@ -10517,6 +9386,12 @@ snapshots: eventemitter3@5.0.4: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + expect-type@1.3.0: {} express@4.22.1: @@ -10590,18 +9465,34 @@ snapshots: extend@3.0.2: {} + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + extsprintf@1.3.0: {} fast-content-type-parse@3.0.0: {} fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-uri@3.1.0: {} fast-xml-parser@5.3.6: dependencies: strnum: 2.1.2 + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -10745,8 +9636,6 @@ snapshots: get-caller-file@2.0.5: {} - get-east-asian-width@1.4.0: {} - get-east-asian-width@1.5.0: {} get-intrinsic@1.3.0: @@ -10767,6 +9656,10 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-stream@5.2.0: + dependencies: + pump: 3.0.3 + get-tsconfig@4.13.6: dependencies: resolve-pkg-maps: 1.0.0 @@ -10977,7 +9870,7 @@ snapshots: ipaddr.js@2.3.0: {} - ipull@3.9.3: + ipull@3.9.5: dependencies: '@tinyhttp/content-disposition': 2.2.4 async-retry: 1.3.3 @@ -11020,7 +9913,7 @@ snapshots: is-fullwidth-code-point@5.1.0: dependencies: - get-east-asian-width: 1.4.0 + get-east-asian-width: 1.5.0 is-interactive@2.0.0: {} @@ -11092,6 +9985,8 @@ snapshots: json-stringify-safe@5.0.1: {} + json-with-bigint@3.5.3: {} + json5@2.2.3: {} jsonfile@6.2.0: @@ -11164,7 +10059,7 @@ snapshots: lifecycle-utils@2.1.0: {} - lifecycle-utils@3.1.0: {} + lifecycle-utils@3.1.1: {} lightningcss-android-arm64@1.30.2: optional: true @@ -11492,9 +10387,9 @@ snapshots: filenamify: 6.0.0 fs-extra: 11.3.3 ignore: 7.0.5 - ipull: 3.9.3 + ipull: 3.9.5 is-unicode-supported: 2.1.0 - lifecycle-utils: 3.1.0 + lifecycle-utils: 3.1.1 log-symbols: 7.0.1 nanoid: 5.1.6 node-addon-api: 8.5.0 @@ -11503,7 +10398,7 @@ snapshots: pretty-ms: 9.3.0 proper-lockfile: 4.1.2 semver: 7.7.4 - simple-git: 3.31.1 + simple-git: 3.32.2 slice-ansi: 7.1.2 stdout-update: 4.0.1 strip-ansi: 7.1.2 @@ -11588,7 +10483,7 @@ snapshots: '@octokit/plugin-paginate-graphql': 6.0.0(@octokit/core@7.0.6) '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6) '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.6) - '@octokit/plugin-retry': 8.0.3(@octokit/core@7.0.6) + '@octokit/plugin-retry': 8.1.0(@octokit/core@7.0.6) '@octokit/plugin-throttling': 11.0.3(@octokit/core@7.0.6) '@octokit/request-error': 7.1.0 '@octokit/types': 16.0.0 @@ -11632,28 +10527,29 @@ snapshots: ws: 8.19.0 zod: 4.3.6 - openclaw@2026.2.23(@napi-rs/canvas@0.1.94)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.15.1(typescript@5.9.3)): + openclaw@2026.2.24(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.15.1(typescript@5.9.3)): dependencies: '@agentclientprotocol/sdk': 0.14.1(zod@4.3.6) - '@aws-sdk/client-bedrock': 3.995.0 - '@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.0.8) + '@aws-sdk/client-bedrock': 3.998.0 + '@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.11.10)(opusscript@0.1.1) '@clack/prompts': 1.0.1 - '@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.0.8) + '@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1) '@grammyjs/runner': 2.0.3(grammy@1.40.0) '@grammyjs/transformer-throttler': 1.2.1(grammy@1.40.0) '@homebridge/ciao': 1.3.5 '@larksuiteoapi/node-sdk': 1.59.0 '@line/bot-sdk': 10.6.0 '@lydell/node-pty': 1.2.0-beta.3 - '@mariozechner/pi-agent-core': 0.54.1(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-ai': 0.54.1(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-coding-agent': 0.54.1(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-tui': 0.54.1 + '@mariozechner/pi-agent-core': 0.55.0(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.55.0(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-coding-agent': 0.55.0(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-tui': 0.55.0 '@mozilla/readability': 0.6.0 - '@napi-rs/canvas': 0.1.94 + '@napi-rs/canvas': 0.1.95 '@sinclair/typebox': 0.34.48 '@slack/bolt': 4.6.0(@types/express@5.0.6) '@slack/web-api': 7.14.1 + '@snazzah/davey': 0.1.9 '@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) ajv: 8.18.0 chalk: 5.6.2 @@ -11676,7 +10572,7 @@ snapshots: markdown-it: 14.1.1 node-edge-tts: 1.2.10 node-llama-cpp: 3.15.1(typescript@5.9.3) - opusscript: 0.0.8 + opusscript: 0.1.1 osc-progress: 0.3.0 pdfjs-dist: 5.4.624 playwright-core: 1.58.2 @@ -11713,8 +10609,6 @@ snapshots: '@wasm-audio-decoders/common': 9.0.7 optional: true - opusscript@0.0.8: {} - opusscript@0.1.1: {} ora@8.2.0: @@ -11878,11 +10772,13 @@ snapshots: pdfjs-dist@5.4.624: optionalDependencies: - '@napi-rs/canvas': 0.1.94 + '@napi-rs/canvas': 0.1.95 node-readable-to-web-readable-stream: 0.4.2 peberminta@0.9.0: {} + pend@1.2.0: {} + performance-now@2.1.0: {} picocolors@1.1.1: {} @@ -11943,11 +10839,6 @@ snapshots: dependencies: parse-ms: 4.0.0 - prism-media@1.3.5(@discordjs/opus@0.10.0)(opusscript@0.0.8): - optionalDependencies: - '@discordjs/opus': 0.10.0 - opusscript: 0.0.8 - prism-media@1.3.5(@discordjs/opus@0.10.0)(opusscript@0.1.1): optionalDependencies: '@discordjs/opus': 0.10.0 @@ -12033,6 +10924,11 @@ snapshots: dependencies: punycode: 2.3.1 + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode.js@2.3.1: {} punycode@2.3.1: {} @@ -12141,7 +11037,7 @@ snapshots: dependencies: glob: 10.5.0 - rolldown-plugin-dts@0.22.1(@typescript/native-preview@7.0.0-dev.20260224.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3): + rolldown-plugin-dts@0.22.1(@typescript/native-preview@7.0.0-dev.20260225.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3): dependencies: '@babel/generator': 8.0.0-rc.1 '@babel/helper-validator-identifier': 8.0.0-rc.1 @@ -12154,7 +11050,7 @@ snapshots: obug: 2.1.1 rolldown: 1.0.0-rc.3 optionalDependencies: - '@typescript/native-preview': 7.0.0-dev.20260224.1 + '@typescript/native-preview': 7.0.0-dev.20260225.1 typescript: 5.9.3 transitivePeerDependencies: - oxc-resolver @@ -12380,7 +11276,7 @@ snapshots: dependencies: signal-polyfill: 0.2.2 - simple-git@3.31.1: + simple-git@3.32.2: dependencies: '@kwsites/file-exists': 1.1.1 '@kwsites/promise-deferred': 1.1.1 @@ -12399,6 +11295,14 @@ snapshots: sisteransi@1.0.5: {} + skillflag@0.1.4: + dependencies: + '@clack/prompts': 1.0.1 + tar-stream: 3.1.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + sleep-promise@9.1.0: {} slice-ansi@7.1.2: @@ -12494,6 +11398,15 @@ snapshots: steno@4.0.2: {} + streamx@2.23.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -12509,7 +11422,7 @@ snapshots: string-width@7.2.0: dependencies: emoji-regex: 10.6.0 - get-east-asian-width: 1.4.0 + get-east-asian-width: 1.5.0 strip-ansi: 7.1.2 string_decoder@1.1.1: @@ -12545,6 +11458,15 @@ snapshots: array-back: 6.2.2 wordwrapjs: 5.1.1 + tar-stream@3.1.7: + dependencies: + b4a: 1.8.0 + fast-fifo: 1.3.2 + streamx: 2.23.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + tar@7.5.9: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -12553,6 +11475,12 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + text-decoder@1.2.7: + dependencies: + b4a: 1.8.0 + transitivePeerDependencies: + - react-native-b4a + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -12603,7 +11531,7 @@ snapshots: ts-algebra@2.0.0: {} - tsdown@0.20.3(@typescript/native-preview@7.0.0-dev.20260224.1)(typescript@5.9.3): + tsdown@0.20.3(@typescript/native-preview@7.0.0-dev.20260225.1)(typescript@5.9.3): dependencies: ansis: 4.2.0 cac: 6.7.14 @@ -12614,7 +11542,7 @@ snapshots: obug: 2.1.1 picomatch: 4.0.3 rolldown: 1.0.0-rc.3 - rolldown-plugin-dts: 0.22.1(@typescript/native-preview@7.0.0-dev.20260224.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3) + rolldown-plugin-dts: 0.22.1(@typescript/native-preview@7.0.0-dev.20260225.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3) semver: 7.7.4 tinyexec: 1.0.2 tinyglobby: 0.2.15 @@ -12859,6 +11787,11 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + yoctocolors@2.1.2: {} zod-to-json-schema@3.25.1(zod@3.25.76): diff --git a/scripts/check-channel-agnostic-boundaries.mjs b/scripts/check-channel-agnostic-boundaries.mjs new file mode 100644 index 000000000000..3b63911e86d0 --- /dev/null +++ b/scripts/check-channel-agnostic-boundaries.mjs @@ -0,0 +1,405 @@ +#!/usr/bin/env node + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const acpCoreProtectedSources = [ + path.join(repoRoot, "src", "acp"), + path.join(repoRoot, "src", "agents", "acp-spawn.ts"), + path.join(repoRoot, "src", "auto-reply", "reply", "commands-acp"), + path.join(repoRoot, "src", "infra", "outbound", "conversation-id.ts"), +]; + +const channelCoreProtectedSources = [ + path.join(repoRoot, "src", "channels", "thread-bindings-policy.ts"), + path.join(repoRoot, "src", "channels", "thread-bindings-messages.ts"), +]; +const acpUserFacingTextSources = [ + path.join(repoRoot, "src", "auto-reply", "reply", "commands-acp"), +]; +const systemMarkLiteralGuardSources = [ + path.join(repoRoot, "src", "auto-reply", "reply", "commands-acp"), + path.join(repoRoot, "src", "auto-reply", "reply", "dispatch-acp.ts"), + path.join(repoRoot, "src", "auto-reply", "reply", "directive-handling.shared.ts"), + path.join(repoRoot, "src", "channels", "thread-bindings-messages.ts"), +]; + +const channelIds = [ + "bluebubbles", + "discord", + "googlechat", + "imessage", + "irc", + "line", + "matrix", + "msteams", + "signal", + "slack", + "telegram", + "web", + "whatsapp", + "zalo", + "zalouser", +]; + +const channelIdSet = new Set(channelIds); +const channelSegmentRe = new RegExp(`(^|[._/-])(?:${channelIds.join("|")})([._/-]|$)`); +const comparisonOperators = new Set([ + ts.SyntaxKind.EqualsEqualsEqualsToken, + ts.SyntaxKind.ExclamationEqualsEqualsToken, + ts.SyntaxKind.EqualsEqualsToken, + ts.SyntaxKind.ExclamationEqualsToken, +]); + +const allowedViolations = new Set([]); + +function isTestLikeFile(filePath) { + return ( + filePath.endsWith(".test.ts") || + filePath.endsWith(".test-utils.ts") || + filePath.endsWith(".test-harness.ts") || + filePath.endsWith(".e2e-harness.ts") + ); +} + +async function collectTypeScriptFiles(targetPath) { + const stat = await fs.stat(targetPath); + if (stat.isFile()) { + if (!targetPath.endsWith(".ts") || isTestLikeFile(targetPath)) { + return []; + } + return [targetPath]; + } + + const entries = await fs.readdir(targetPath, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const entryPath = path.join(targetPath, entry.name); + if (entry.isDirectory()) { + files.push(...(await collectTypeScriptFiles(entryPath))); + continue; + } + if (!entry.isFile()) { + continue; + } + if (!entryPath.endsWith(".ts")) { + continue; + } + if (isTestLikeFile(entryPath)) { + continue; + } + files.push(entryPath); + } + return files; +} + +function toLine(sourceFile, node) { + return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1; +} + +function isChannelsPropertyAccess(node) { + if (ts.isPropertyAccessExpression(node)) { + return node.name.text === "channels"; + } + if (ts.isElementAccessExpression(node) && ts.isStringLiteral(node.argumentExpression)) { + return node.argumentExpression.text === "channels"; + } + return false; +} + +function readStringLiteral(node) { + if (ts.isStringLiteral(node)) { + return node.text; + } + if (ts.isNoSubstitutionTemplateLiteral(node)) { + return node.text; + } + return null; +} + +function isChannelLiteralNode(node) { + const text = readStringLiteral(node); + return text ? channelIdSet.has(text) : false; +} + +function matchesChannelModuleSpecifier(specifier) { + return channelSegmentRe.test(specifier.replaceAll("\\", "/")); +} + +function getPropertyNameText(name) { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text; + } + return null; +} + +const userFacingChannelNameRe = + /\b(?:discord|telegram|slack|signal|imessage|whatsapp|google\s*chat|irc|line|zalo|matrix|msteams|bluebubbles)\b/i; +const systemMarkLiteral = "⚙️"; + +function isModuleSpecifierStringNode(node) { + const parent = node.parent; + if (ts.isImportDeclaration(parent) || ts.isExportDeclaration(parent)) { + return true; + } + return ( + ts.isCallExpression(parent) && + parent.expression.kind === ts.SyntaxKind.ImportKeyword && + parent.arguments[0] === node + ); +} + +export function findChannelAgnosticBoundaryViolations( + content, + fileName = "source.ts", + options = {}, +) { + const checkModuleSpecifiers = options.checkModuleSpecifiers ?? true; + const checkConfigPaths = options.checkConfigPaths ?? true; + const checkChannelComparisons = options.checkChannelComparisons ?? true; + const checkChannelAssignments = options.checkChannelAssignments ?? true; + const moduleSpecifierMatcher = options.moduleSpecifierMatcher ?? matchesChannelModuleSpecifier; + + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); + const violations = []; + + const visit = (node) => { + if ( + checkModuleSpecifiers && + ts.isImportDeclaration(node) && + ts.isStringLiteral(node.moduleSpecifier) + ) { + const specifier = node.moduleSpecifier.text; + if (moduleSpecifierMatcher(specifier)) { + violations.push({ + line: toLine(sourceFile, node.moduleSpecifier), + reason: `imports channel module "${specifier}"`, + }); + } + } + + if ( + checkModuleSpecifiers && + ts.isExportDeclaration(node) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) { + const specifier = node.moduleSpecifier.text; + if (moduleSpecifierMatcher(specifier)) { + violations.push({ + line: toLine(sourceFile, node.moduleSpecifier), + reason: `re-exports channel module "${specifier}"`, + }); + } + } + + if ( + checkModuleSpecifiers && + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments.length > 0 && + ts.isStringLiteral(node.arguments[0]) + ) { + const specifier = node.arguments[0].text; + if (moduleSpecifierMatcher(specifier)) { + violations.push({ + line: toLine(sourceFile, node.arguments[0]), + reason: `dynamically imports channel module "${specifier}"`, + }); + } + } + + if ( + checkConfigPaths && + ts.isPropertyAccessExpression(node) && + channelIdSet.has(node.name.text) + ) { + if (isChannelsPropertyAccess(node.expression)) { + violations.push({ + line: toLine(sourceFile, node.name), + reason: `references config path "channels.${node.name.text}"`, + }); + } + } + + if ( + checkConfigPaths && + ts.isElementAccessExpression(node) && + ts.isStringLiteral(node.argumentExpression) && + channelIdSet.has(node.argumentExpression.text) + ) { + if (isChannelsPropertyAccess(node.expression)) { + violations.push({ + line: toLine(sourceFile, node.argumentExpression), + reason: `references config path "channels[${JSON.stringify(node.argumentExpression.text)}]"`, + }); + } + } + + if ( + checkChannelComparisons && + ts.isBinaryExpression(node) && + comparisonOperators.has(node.operatorToken.kind) + ) { + if (isChannelLiteralNode(node.left) || isChannelLiteralNode(node.right)) { + const leftText = node.left.getText(sourceFile); + const rightText = node.right.getText(sourceFile); + violations.push({ + line: toLine(sourceFile, node.operatorToken), + reason: `compares with channel id literal (${leftText} ${node.operatorToken.getText(sourceFile)} ${rightText})`, + }); + } + } + + if (checkChannelAssignments && ts.isPropertyAssignment(node)) { + const propName = getPropertyNameText(node.name); + if (propName === "channel" && isChannelLiteralNode(node.initializer)) { + violations.push({ + line: toLine(sourceFile, node.initializer), + reason: `assigns channel id literal to "channel" (${node.initializer.getText(sourceFile)})`, + }); + } + } + + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return violations; +} + +export function findChannelCoreReverseDependencyViolations(content, fileName = "source.ts") { + return findChannelAgnosticBoundaryViolations(content, fileName, { + checkModuleSpecifiers: true, + checkConfigPaths: false, + checkChannelComparisons: false, + checkChannelAssignments: false, + moduleSpecifierMatcher: matchesChannelModuleSpecifier, + }); +} + +export function findAcpUserFacingChannelNameViolations(content, fileName = "source.ts") { + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); + const violations = []; + + const visit = (node) => { + const text = readStringLiteral(node); + if (text && userFacingChannelNameRe.test(text) && !isModuleSpecifierStringNode(node)) { + violations.push({ + line: toLine(sourceFile, node), + reason: `user-facing text references channel name (${JSON.stringify(text)})`, + }); + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return violations; +} + +export function findSystemMarkLiteralViolations(content, fileName = "source.ts") { + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); + const violations = []; + + const visit = (node) => { + const text = readStringLiteral(node); + if (text && text.includes(systemMarkLiteral) && !isModuleSpecifierStringNode(node)) { + violations.push({ + line: toLine(sourceFile, node), + reason: `hardcoded system mark literal (${JSON.stringify(text)})`, + }); + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return violations; +} + +const boundaryRuleSets = [ + { + id: "acp-core", + sources: acpCoreProtectedSources, + scan: (content, fileName) => findChannelAgnosticBoundaryViolations(content, fileName), + }, + { + id: "channel-core-reverse-deps", + sources: channelCoreProtectedSources, + scan: (content, fileName) => findChannelCoreReverseDependencyViolations(content, fileName), + }, + { + id: "acp-user-facing-text", + sources: acpUserFacingTextSources, + scan: (content, fileName) => findAcpUserFacingChannelNameViolations(content, fileName), + }, + { + id: "system-mark-literal-usage", + sources: systemMarkLiteralGuardSources, + scan: (content, fileName) => findSystemMarkLiteralViolations(content, fileName), + }, +]; + +export async function main() { + const violations = []; + for (const ruleSet of boundaryRuleSets) { + const files = ( + await Promise.all( + ruleSet.sources.map(async (sourcePath) => { + try { + return await collectTypeScriptFiles(sourcePath); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return []; + } + throw error; + } + }), + ) + ).flat(); + for (const filePath of files) { + const relativeFile = path.relative(repoRoot, filePath); + if ( + allowedViolations.has(`${ruleSet.id}:${relativeFile}`) || + allowedViolations.has(relativeFile) + ) { + continue; + } + const content = await fs.readFile(filePath, "utf8"); + for (const violation of ruleSet.scan(content, relativeFile)) { + violations.push(`${ruleSet.id} ${relativeFile}:${violation.line}: ${violation.reason}`); + } + } + } + + if (violations.length === 0) { + return; + } + + console.error("Found channel-specific references in channel-agnostic sources:"); + for (const violation of violations) { + console.error(`- ${violation}`); + } + console.error( + "Move channel-specific logic to channel adapters or add a justified allowlist entry.", + ); + process.exit(1); +} + +const isDirectExecution = (() => { + const entry = process.argv[1]; + if (!entry) { + return false; + } + return path.resolve(entry) === fileURLToPath(import.meta.url); +})(); + +if (isDirectExecution) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/scripts/dev/discord-acp-plain-language-smoke.ts b/scripts/dev/discord-acp-plain-language-smoke.ts new file mode 100644 index 000000000000..33b8eb0d54f9 --- /dev/null +++ b/scripts/dev/discord-acp-plain-language-smoke.ts @@ -0,0 +1,779 @@ +#!/usr/bin/env bun +// Manual ACP thread smoke for plain-language routing. +// Keep this script available for regression/debug validation. Do not delete. +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +type ThreadBindingRecord = { + accountId?: string; + channelId?: string; + threadId?: string; + targetKind?: string; + targetSessionKey?: string; + agentId?: string; + boundBy?: string; + boundAt?: number; +}; + +type ThreadBindingsPayload = { + version?: number; + bindings?: Record; +}; + +type DiscordMessage = { + id: string; + content?: string; + timestamp?: string; + author?: { + id?: string; + username?: string; + bot?: boolean; + }; +}; + +type DiscordUser = { + id: string; + username: string; + bot?: boolean; +}; + +type DriverMode = "token" | "webhook"; + +type Args = { + channelId: string; + driverMode: DriverMode; + driverToken: string; + driverTokenPrefix: string; + botToken: string; + botTokenPrefix: string; + targetAgent: string; + timeoutMs: number; + pollMs: number; + mentionUserId?: string; + instruction?: string; + threadBindingsPath: string; + json: boolean; +}; + +type SuccessResult = { + ok: true; + smokeId: string; + ackToken: string; + sentMessageId: string; + binding: { + threadId: string; + targetSessionKey: string; + targetKind: string; + agentId: string; + boundAt: number; + accountId?: string; + channelId?: string; + }; + ackMessage: { + id: string; + authorId?: string; + authorUsername?: string; + timestamp?: string; + content?: string; + }; +}; + +type FailureResult = { + ok: false; + smokeId: string; + stage: "validation" | "send-message" | "wait-binding" | "wait-ack" | "discord-api" | "unexpected"; + error: string; + diagnostics?: { + parentChannelRecent?: Array<{ + id: string; + author?: string; + bot?: boolean; + content?: string; + }>; + bindingCandidates?: Array<{ + threadId: string; + targetSessionKey: string; + targetKind?: string; + agentId?: string; + boundAt?: number; + }>; + }; +}; + +const DISCORD_API_BASE = "https://discord.com/api/v10"; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function parseNumber(value: string | undefined, fallback: number): number { + if (!value) { + return fallback; + } + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function resolveStateDir(): string { + const override = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim(); + if (override) { + return override.startsWith("~") + ? path.resolve(process.env.HOME || "", override.slice(1)) + : path.resolve(override); + } + const home = process.env.OPENCLAW_HOME?.trim() || process.env.HOME || ""; + return path.join(home, ".openclaw"); +} + +function resolveArg(flag: string): string | undefined { + const argv = process.argv.slice(2); + const eq = argv.find((entry) => entry.startsWith(`${flag}=`)); + if (eq) { + return eq.slice(flag.length + 1); + } + const idx = argv.indexOf(flag); + if (idx >= 0 && idx + 1 < argv.length) { + return argv[idx + 1]; + } + return undefined; +} + +function hasFlag(flag: string): boolean { + return process.argv.slice(2).includes(flag); +} + +function usage(): string { + return ( + "Usage: bun scripts/dev/discord-acp-plain-language-smoke.ts " + + "--channel [--token | --driver webhook --bot-token ] [options]\n\n" + + "Manual live smoke only (not CI). Sends a plain-language instruction in Discord and verifies:\n" + + "1) OpenClaw spawned an ACP thread binding\n" + + "2) agent replied in that bound thread with the expected ACK token\n\n" + + "Options:\n" + + " --channel Parent Discord channel id (required)\n" + + " --driver Driver transport mode (default: token)\n" + + " --token Driver Discord token (required for driver=token)\n" + + " --token-prefix Auth prefix for --token (default: Bot)\n" + + " --bot-token Bot token for webhook driver mode\n" + + " --bot-token-prefix Auth prefix for --bot-token (default: Bot)\n" + + " --agent Expected ACP agent id (default: codex)\n" + + " --mention Mention this user in the instruction (optional)\n" + + " --instruction Custom instruction template (optional)\n" + + " --timeout-ms Total timeout in ms (default: 240000)\n" + + " --poll-ms Poll interval in ms (default: 1500)\n" + + " --thread-bindings-path

Override thread-bindings json path\n" + + " --json Emit JSON output\n" + + "\n" + + "Environment fallbacks:\n" + + " OPENCLAW_DISCORD_SMOKE_CHANNEL_ID\n" + + " OPENCLAW_DISCORD_SMOKE_DRIVER\n" + + " OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN\n" + + " OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN_PREFIX\n" + + " OPENCLAW_DISCORD_SMOKE_BOT_TOKEN\n" + + " OPENCLAW_DISCORD_SMOKE_BOT_TOKEN_PREFIX\n" + + " OPENCLAW_DISCORD_SMOKE_AGENT\n" + + " OPENCLAW_DISCORD_SMOKE_MENTION_USER_ID\n" + + " OPENCLAW_DISCORD_SMOKE_TIMEOUT_MS\n" + + " OPENCLAW_DISCORD_SMOKE_POLL_MS\n" + + " OPENCLAW_DISCORD_SMOKE_THREAD_BINDINGS_PATH" + ); +} + +function parseArgs(): Args { + const channelId = + resolveArg("--channel") || + process.env.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID || + process.env.CLAWDBOT_DISCORD_SMOKE_CHANNEL_ID || + ""; + const driverModeRaw = + resolveArg("--driver") || + process.env.OPENCLAW_DISCORD_SMOKE_DRIVER || + process.env.CLAWDBOT_DISCORD_SMOKE_DRIVER || + "token"; + const normalizedDriverMode = driverModeRaw.trim().toLowerCase(); + const driverMode: DriverMode = + normalizedDriverMode === "webhook" + ? "webhook" + : normalizedDriverMode === "token" + ? "token" + : "token"; + const driverToken = + resolveArg("--token") || + process.env.OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN || + process.env.CLAWDBOT_DISCORD_SMOKE_DRIVER_TOKEN || + ""; + const driverTokenPrefix = + resolveArg("--token-prefix") || process.env.OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN_PREFIX || "Bot"; + const botToken = + resolveArg("--bot-token") || + process.env.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN || + process.env.CLAWDBOT_DISCORD_SMOKE_BOT_TOKEN || + process.env.DISCORD_BOT_TOKEN || + ""; + const botTokenPrefix = + resolveArg("--bot-token-prefix") || + process.env.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN_PREFIX || + "Bot"; + const targetAgent = + resolveArg("--agent") || + process.env.OPENCLAW_DISCORD_SMOKE_AGENT || + process.env.CLAWDBOT_DISCORD_SMOKE_AGENT || + "codex"; + const mentionUserId = + resolveArg("--mention") || + process.env.OPENCLAW_DISCORD_SMOKE_MENTION_USER_ID || + process.env.CLAWDBOT_DISCORD_SMOKE_MENTION_USER_ID || + undefined; + const instruction = + resolveArg("--instruction") || + process.env.OPENCLAW_DISCORD_SMOKE_INSTRUCTION || + process.env.CLAWDBOT_DISCORD_SMOKE_INSTRUCTION || + undefined; + const timeoutMs = parseNumber( + resolveArg("--timeout-ms") || process.env.OPENCLAW_DISCORD_SMOKE_TIMEOUT_MS, + 240_000, + ); + const pollMs = parseNumber( + resolveArg("--poll-ms") || process.env.OPENCLAW_DISCORD_SMOKE_POLL_MS, + 1_500, + ); + const defaultBindingsPath = path.join(resolveStateDir(), "discord", "thread-bindings.json"); + const threadBindingsPath = + resolveArg("--thread-bindings-path") || + process.env.OPENCLAW_DISCORD_SMOKE_THREAD_BINDINGS_PATH || + defaultBindingsPath; + const json = hasFlag("--json"); + + if (!channelId) { + throw new Error(usage()); + } + if (driverMode === "token" && !driverToken) { + throw new Error(usage()); + } + if (driverMode === "webhook" && !botToken) { + throw new Error(usage()); + } + + return { + channelId, + driverMode, + driverToken, + driverTokenPrefix, + botToken, + botTokenPrefix, + targetAgent, + timeoutMs, + pollMs, + mentionUserId, + instruction, + threadBindingsPath, + json, + }; +} + +function resolveAuthorizationHeader(params: { token: string; tokenPrefix: string }): string { + const token = params.token.trim(); + if (!token) { + throw new Error("Missing Discord driver token."); + } + if (token.includes(" ")) { + return token; + } + return `${params.tokenPrefix.trim() || "Bot"} ${token}`; +} + +async function discordApi(params: { + method: "GET" | "POST"; + path: string; + authHeader: string; + body?: unknown; + retries?: number; +}): Promise { + const retries = params.retries ?? 6; + for (let attempt = 0; attempt <= retries; attempt += 1) { + const response = await fetch(`${DISCORD_API_BASE}${params.path}`, { + method: params.method, + headers: { + Authorization: params.authHeader, + "Content-Type": "application/json", + }, + body: params.body === undefined ? undefined : JSON.stringify(params.body), + }); + + if (response.status === 429) { + const body = (await response.json().catch(() => ({}))) as { retry_after?: number }; + const waitSeconds = typeof body.retry_after === "number" ? body.retry_after : 1; + await sleep(Math.ceil(waitSeconds * 1000)); + continue; + } + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error( + `Discord API ${params.method} ${params.path} failed: ${response.status} ${response.statusText}${text ? ` :: ${text}` : ""}`, + ); + } + + if (response.status === 204) { + return undefined as T; + } + + return (await response.json()) as T; + } + + throw new Error(`Discord API ${params.method} ${params.path} exceeded retry budget.`); +} + +async function discordWebhookApi(params: { + method: "POST" | "DELETE"; + webhookId: string; + webhookToken: string; + body?: unknown; + query?: string; + retries?: number; +}): Promise { + const retries = params.retries ?? 6; + const suffix = params.query ? `?${params.query}` : ""; + const path = `/webhooks/${encodeURIComponent(params.webhookId)}/${encodeURIComponent(params.webhookToken)}${suffix}`; + for (let attempt = 0; attempt <= retries; attempt += 1) { + const response = await fetch(`${DISCORD_API_BASE}${path}`, { + method: params.method, + headers: { + "Content-Type": "application/json", + }, + body: params.body === undefined ? undefined : JSON.stringify(params.body), + }); + + if (response.status === 429) { + const body = (await response.json().catch(() => ({}))) as { retry_after?: number }; + const waitSeconds = typeof body.retry_after === "number" ? body.retry_after : 1; + await sleep(Math.ceil(waitSeconds * 1000)); + continue; + } + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error( + `Discord webhook API ${params.method} ${path} failed: ${response.status} ${response.statusText}${text ? ` :: ${text}` : ""}`, + ); + } + + if (response.status === 204) { + return undefined as T; + } + + return (await response.json()) as T; + } + + throw new Error(`Discord webhook API ${params.method} ${path} exceeded retry budget.`); +} + +async function readThreadBindings(filePath: string): Promise { + const raw = await fs.readFile(filePath, "utf8"); + const payload = JSON.parse(raw) as ThreadBindingsPayload; + const entries = Object.values(payload.bindings ?? {}); + return entries.filter((entry) => Boolean(entry?.threadId && entry?.targetSessionKey)); +} + +function normalizeBoundAt(record: ThreadBindingRecord): number { + if (typeof record.boundAt === "number" && Number.isFinite(record.boundAt)) { + return record.boundAt; + } + return 0; +} + +function resolveCandidateBindings(params: { + entries: ThreadBindingRecord[]; + minBoundAt: number; + targetAgent: string; +}): ThreadBindingRecord[] { + const normalizedTargetAgent = params.targetAgent.trim().toLowerCase(); + return params.entries + .filter((entry) => { + const targetKind = String(entry.targetKind || "") + .trim() + .toLowerCase(); + if (targetKind !== "acp") { + return false; + } + if (normalizeBoundAt(entry) < params.minBoundAt) { + return false; + } + const agentId = String(entry.agentId || "") + .trim() + .toLowerCase(); + if (normalizedTargetAgent && agentId && agentId !== normalizedTargetAgent) { + return false; + } + return true; + }) + .toSorted((a, b) => normalizeBoundAt(b) - normalizeBoundAt(a)); +} + +function buildInstruction(params: { + smokeId: string; + ackToken: string; + targetAgent: string; + mentionUserId?: string; + template?: string; +}): string { + const mentionPrefix = params.mentionUserId?.trim() ? `<@${params.mentionUserId.trim()}> ` : ""; + if (params.template?.trim()) { + return mentionPrefix + params.template.trim(); + } + return ( + mentionPrefix + + `Manual smoke ${params.smokeId}: Please spawn a ${params.targetAgent} ACP coding agent in a thread for this request, keep it persistent, and in that thread reply with exactly "${params.ackToken}" and nothing else.` + ); +} + +function toRecentMessageRow(message: DiscordMessage) { + return { + id: message.id, + author: message.author?.username || message.author?.id || "unknown", + bot: Boolean(message.author?.bot), + content: (message.content || "").slice(0, 500), + }; +} + +function printOutput(params: { json: boolean; payload: SuccessResult | FailureResult }) { + if (params.json) { + // eslint-disable-next-line no-console + console.log(JSON.stringify(params.payload, null, 2)); + return; + } + if (params.payload.ok) { + const success = params.payload; + // eslint-disable-next-line no-console + console.log("PASS"); + // eslint-disable-next-line no-console + console.log(`smokeId: ${success.smokeId}`); + // eslint-disable-next-line no-console + console.log(`sentMessageId: ${success.sentMessageId}`); + // eslint-disable-next-line no-console + console.log(`threadId: ${success.binding.threadId}`); + // eslint-disable-next-line no-console + console.log(`sessionKey: ${success.binding.targetSessionKey}`); + // eslint-disable-next-line no-console + console.log(`ackMessageId: ${success.ackMessage.id}`); + // eslint-disable-next-line no-console + console.log( + `ackAuthor: ${success.ackMessage.authorUsername || success.ackMessage.authorId || "unknown"}`, + ); + return; + } + const failure = params.payload; + // eslint-disable-next-line no-console + console.error("FAIL"); + // eslint-disable-next-line no-console + console.error(`stage: ${failure.stage}`); + // eslint-disable-next-line no-console + console.error(`smokeId: ${failure.smokeId}`); + // eslint-disable-next-line no-console + console.error(`error: ${failure.error}`); + if (failure.diagnostics?.bindingCandidates?.length) { + // eslint-disable-next-line no-console + console.error("binding candidates:"); + for (const candidate of failure.diagnostics.bindingCandidates) { + // eslint-disable-next-line no-console + console.error( + ` thread=${candidate.threadId} kind=${candidate.targetKind || "?"} agent=${candidate.agentId || "?"} boundAt=${candidate.boundAt || 0} session=${candidate.targetSessionKey}`, + ); + } + } + if (failure.diagnostics?.parentChannelRecent?.length) { + // eslint-disable-next-line no-console + console.error("recent parent channel messages:"); + for (const row of failure.diagnostics.parentChannelRecent) { + // eslint-disable-next-line no-console + console.error(` ${row.id} ${row.author}${row.bot ? " [bot]" : ""}: ${row.content || ""}`); + } + } +} + +async function run(): Promise { + let args: Args; + try { + args = parseArgs(); + } catch (err) { + return { + ok: false, + stage: "validation", + smokeId: "n/a", + error: err instanceof Error ? err.message : String(err), + }; + } + + const smokeId = `acp-smoke-${Date.now()}-${randomUUID().slice(0, 8)}`; + const ackToken = `ACP_SMOKE_ACK_${smokeId}`; + const instruction = buildInstruction({ + smokeId, + ackToken, + targetAgent: args.targetAgent, + mentionUserId: args.mentionUserId, + template: args.instruction, + }); + + let readAuthHeader = ""; + let sentMessageId = ""; + let setupStage: "discord-api" | "send-message" = "discord-api"; + let senderAuthorId: string | undefined; + let webhookForCleanup: + | { + id: string; + token: string; + } + | undefined; + + try { + if (args.driverMode === "token") { + const authHeader = resolveAuthorizationHeader({ + token: args.driverToken, + tokenPrefix: args.driverTokenPrefix, + }); + readAuthHeader = authHeader; + + const driverUser = await discordApi({ + method: "GET", + path: "/users/@me", + authHeader, + }); + senderAuthorId = driverUser.id; + + setupStage = "send-message"; + const sent = await discordApi({ + method: "POST", + path: `/channels/${encodeURIComponent(args.channelId)}/messages`, + authHeader, + body: { + content: instruction, + allowed_mentions: args.mentionUserId + ? { parse: [], users: [args.mentionUserId] } + : { parse: [] }, + }, + }); + sentMessageId = sent.id; + } else { + const botAuthHeader = resolveAuthorizationHeader({ + token: args.botToken, + tokenPrefix: args.botTokenPrefix, + }); + readAuthHeader = botAuthHeader; + + await discordApi({ + method: "GET", + path: "/users/@me", + authHeader: botAuthHeader, + }); + + setupStage = "send-message"; + const webhook = await discordApi<{ id: string; token?: string | null }>({ + method: "POST", + path: `/channels/${encodeURIComponent(args.channelId)}/webhooks`, + authHeader: botAuthHeader, + body: { + name: `openclaw-acp-smoke-${smokeId.slice(-8)}`, + }, + }); + if (!webhook.id || !webhook.token) { + return { + ok: false, + stage: "send-message", + smokeId, + error: + "Discord webhook creation succeeded but no webhook token was returned; cannot post smoke message.", + }; + } + webhookForCleanup = { id: webhook.id, token: webhook.token }; + + const sent = await discordWebhookApi({ + method: "POST", + webhookId: webhook.id, + webhookToken: webhook.token, + query: "wait=true", + body: { + content: instruction, + allowed_mentions: args.mentionUserId + ? { parse: [], users: [args.mentionUserId] } + : { parse: [] }, + }, + }); + sentMessageId = sent.id; + senderAuthorId = sent.author?.id; + } + } catch (err) { + return { + ok: false, + stage: setupStage, + smokeId, + error: err instanceof Error ? err.message : String(err), + }; + } + + const startedAt = Date.now(); + + const deadline = startedAt + args.timeoutMs; + let winningBinding: ThreadBindingRecord | undefined; + let latestCandidates: ThreadBindingRecord[] = []; + + try { + while (Date.now() < deadline && !winningBinding) { + try { + const entries = await readThreadBindings(args.threadBindingsPath); + latestCandidates = resolveCandidateBindings({ + entries, + minBoundAt: startedAt - 3_000, + targetAgent: args.targetAgent, + }); + winningBinding = latestCandidates[0]; + } catch { + // Keep polling; file may not exist yet or may be mid-write. + } + if (!winningBinding) { + await sleep(args.pollMs); + } + } + + if (!winningBinding?.threadId || !winningBinding?.targetSessionKey) { + let parentRecent: DiscordMessage[] = []; + try { + parentRecent = await discordApi({ + method: "GET", + path: `/channels/${encodeURIComponent(args.channelId)}/messages?limit=20`, + authHeader: readAuthHeader, + }); + } catch { + // Best effort diagnostics only. + } + return { + ok: false, + stage: "wait-binding", + smokeId, + error: `Timed out waiting for new ACP thread binding (path: ${args.threadBindingsPath}).`, + diagnostics: { + bindingCandidates: latestCandidates.slice(0, 6).map((entry) => ({ + threadId: entry.threadId || "", + targetSessionKey: entry.targetSessionKey || "", + targetKind: entry.targetKind, + agentId: entry.agentId, + boundAt: entry.boundAt, + })), + parentChannelRecent: parentRecent.map(toRecentMessageRow), + }, + }; + } + + const threadId = winningBinding.threadId; + let ackMessage: DiscordMessage | undefined; + while (Date.now() < deadline && !ackMessage) { + try { + const threadMessages = await discordApi({ + method: "GET", + path: `/channels/${encodeURIComponent(threadId)}/messages?limit=50`, + authHeader: readAuthHeader, + }); + ackMessage = threadMessages.find((message) => { + const content = message.content || ""; + if (!content.includes(ackToken)) { + return false; + } + const authorId = message.author?.id || ""; + return !senderAuthorId || authorId !== senderAuthorId; + }); + } catch { + // Keep polling; thread can appear before read permissions settle. + } + if (!ackMessage) { + await sleep(args.pollMs); + } + } + + if (!ackMessage) { + let parentRecent: DiscordMessage[] = []; + try { + parentRecent = await discordApi({ + method: "GET", + path: `/channels/${encodeURIComponent(args.channelId)}/messages?limit=20`, + authHeader: readAuthHeader, + }); + } catch { + // Best effort diagnostics only. + } + + return { + ok: false, + stage: "wait-ack", + smokeId, + error: `Thread bound (${threadId}) but timed out waiting for ACK token "${ackToken}" from OpenClaw.`, + diagnostics: { + bindingCandidates: [ + { + threadId: winningBinding.threadId || "", + targetSessionKey: winningBinding.targetSessionKey || "", + targetKind: winningBinding.targetKind, + agentId: winningBinding.agentId, + boundAt: winningBinding.boundAt, + }, + ], + parentChannelRecent: parentRecent.map(toRecentMessageRow), + }, + }; + } + + return { + ok: true, + smokeId, + ackToken, + sentMessageId, + binding: { + threadId, + targetSessionKey: winningBinding.targetSessionKey, + targetKind: String(winningBinding.targetKind || "acp"), + agentId: String(winningBinding.agentId || args.targetAgent), + boundAt: normalizeBoundAt(winningBinding), + accountId: winningBinding.accountId, + channelId: winningBinding.channelId, + }, + ackMessage: { + id: ackMessage.id, + authorId: ackMessage.author?.id, + authorUsername: ackMessage.author?.username, + timestamp: ackMessage.timestamp, + content: ackMessage.content, + }, + }; + } finally { + if (webhookForCleanup) { + await discordWebhookApi({ + method: "DELETE", + webhookId: webhookForCleanup.id, + webhookToken: webhookForCleanup.token, + }).catch(() => { + // Best-effort cleanup only. + }); + } + } +} + +if (hasFlag("--help") || hasFlag("-h")) { + // eslint-disable-next-line no-console + console.log(usage()); + process.exit(0); +} + +const result = await run().catch( + (err): FailureResult => ({ + ok: false, + stage: "unexpected", + smokeId: "n/a", + error: err instanceof Error ? err.message : String(err), + }), +); + +printOutput({ + json: hasFlag("--json"), + payload: result, +}); + +process.exit(result.ok ? 0 : 1); diff --git a/scripts/pr b/scripts/pr index 90cfe029db08..36ab74972c4f 100755 --- a/scripts/pr +++ b/scripts/pr @@ -664,6 +664,61 @@ validate_changelog_entry_for_pr() { echo "changelog validated: found PR #$pr (contributor handle unavailable, skipping thanks check)" } +changed_changelog_fragment_files() { + git diff --name-only origin/main...HEAD -- changelog/fragments | rg '^changelog/fragments/.*\.md$' || true +} + +validate_changelog_fragments_for_pr() { + local pr="$1" + local contrib="$2" + shift 2 + + if [ "$#" -lt 1 ]; then + echo "No changelog fragments provided for validation." + exit 1 + fi + + local pr_pattern + pr_pattern="(#$pr|openclaw#$pr)" + + local added_lines + local file + local all_added_lines="" + for file in "$@"; do + added_lines=$(git diff --unified=0 origin/main...HEAD -- "$file" | awk ' + /^\+\+\+/ { next } + /^\+/ { print substr($0, 2) } + ') + + if [ -z "$added_lines" ]; then + echo "$file is in diff but no added lines were detected." + exit 1 + fi + + all_added_lines=$(printf '%s\n%s\n' "$all_added_lines" "$added_lines") + done + + local with_pr + with_pr=$(printf '%s\n' "$all_added_lines" | rg -in "$pr_pattern" || true) + if [ -z "$with_pr" ]; then + echo "Changelog fragment update must reference PR #$pr (for example, (#$pr))." + exit 1 + fi + + if [ -n "$contrib" ] && [ "$contrib" != "null" ]; then + local with_pr_and_thanks + with_pr_and_thanks=$(printf '%s\n' "$all_added_lines" | rg -in "$pr_pattern" | rg -i "thanks @$contrib" || true) + if [ -z "$with_pr_and_thanks" ]; then + echo "Changelog fragment update must include both PR #$pr and thanks @$contrib on the entry line." + exit 1 + fi + echo "changelog fragments validated: found PR #$pr + thanks @$contrib" + return 0 + fi + + echo "changelog fragments validated: found PR #$pr (contributor handle unavailable, skipping thanks check)" +} + prepare_gates() { local pr="$1" enter_worktree "$pr" false @@ -684,13 +739,30 @@ prepare_gates() { docs_only=true fi - # Enforce workflow policy: every prepared PR must include a changelog update. - if ! printf '%s\n' "$changed_files" | rg -q '^CHANGELOG\.md$'; then - echo "Missing CHANGELOG.md update in PR diff. This workflow requires a changelog entry." + local has_changelog_update=false + if printf '%s\n' "$changed_files" | rg -q '^CHANGELOG\.md$'; then + has_changelog_update=true + fi + local fragment_files + fragment_files=$(changed_changelog_fragment_files) + local has_fragment_update=false + if [ -n "$fragment_files" ]; then + has_fragment_update=true + fi + # Enforce workflow policy: every prepared PR must include either CHANGELOG.md + # or one or more changelog fragments. + if [ "$has_changelog_update" = "false" ] && [ "$has_fragment_update" = "false" ]; then + echo "Missing changelog update. Add CHANGELOG.md changes or changelog/fragments/*.md entry." exit 1 fi local contrib="${PR_AUTHOR:-}" - validate_changelog_entry_for_pr "$pr" "$contrib" + if [ "$has_changelog_update" = "true" ]; then + validate_changelog_entry_for_pr "$pr" "$contrib" + fi + if [ "$has_fragment_update" = "true" ]; then + mapfile -t fragment_file_list <<<"$fragment_files" + validate_changelog_fragments_for_pr "$pr" "$contrib" "${fragment_file_list[@]}" + fi run_quiet_logged "pnpm build" ".local/gates-build.log" pnpm build run_quiet_logged "pnpm check" ".local/gates-check.log" pnpm check diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 0ec8d2fdc5f3..e866ef712ab1 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -88,14 +88,20 @@ const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; const isMacOS = process.platform === "darwin" || process.env.RUNNER_OS === "macOS"; const isWindows = process.platform === "win32" || process.env.RUNNER_OS === "Windows"; const isWindowsCi = isCI && isWindows; +const hostCpuCount = os.cpus().length; +const hostMemoryGiB = Math.floor(os.totalmem() / 1024 ** 3); +// Keep aggressive local defaults for high-memory workstations (Mac Studio class). +const highMemLocalHost = !isCI && hostMemoryGiB >= 96; +const lowMemLocalHost = !isCI && hostMemoryGiB < 64; const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "", 10); // vmForks is a big win for transform/import heavy suites, but Node 24 had -// regressions with Vitest's vm runtime in this repo. Keep it opt-out via +// regressions with Vitest's vm runtime in this repo, and low-memory local hosts +// are more likely to hit per-worker V8 heap ceilings. Keep it opt-out via // OPENCLAW_TEST_VM_FORKS=0, and let users force-enable with =1. const supportsVmForks = Number.isFinite(nodeMajor) ? nodeMajor !== 24 : true; const useVmForks = process.env.OPENCLAW_TEST_VM_FORKS === "1" || - (process.env.OPENCLAW_TEST_VM_FORKS !== "0" && !isWindows && supportsVmForks); + (process.env.OPENCLAW_TEST_VM_FORKS !== "0" && !isWindows && supportsVmForks && !lowMemLocalHost); const disableIsolation = process.env.OPENCLAW_TEST_NO_ISOLATE === "1"; const runs = [ ...(useVmForks @@ -154,11 +160,31 @@ const runs = [ }, ]; const shardOverride = Number.parseInt(process.env.OPENCLAW_TEST_SHARDS ?? "", 10); -const shardCount = isWindowsCi - ? Number.isFinite(shardOverride) && shardOverride > 1 - ? shardOverride - : 2 - : 1; +const configuredShardCount = + Number.isFinite(shardOverride) && shardOverride > 1 ? shardOverride : null; +const shardCount = configuredShardCount ?? (isWindowsCi ? 2 : 1); +const shardIndexOverride = (() => { + const parsed = Number.parseInt(process.env.OPENCLAW_TEST_SHARD_INDEX ?? "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +})(); + +if (shardIndexOverride !== null && shardCount <= 1) { + console.error( + `[test-parallel] OPENCLAW_TEST_SHARD_INDEX=${String( + shardIndexOverride, + )} requires OPENCLAW_TEST_SHARDS>1.`, + ); + process.exit(2); +} + +if (shardIndexOverride !== null && shardIndexOverride > shardCount) { + console.error( + `[test-parallel] OPENCLAW_TEST_SHARD_INDEX=${String( + shardIndexOverride, + )} exceeds OPENCLAW_TEST_SHARDS=${String(shardCount)}.`, + ); + process.exit(2); +} const windowsCiArgs = isWindowsCi ? ["--dangerouslyIgnoreUnhandledErrors"] : []; const silentArgs = process.env.OPENCLAW_TEST_SHOW_PASSED_LOGS === "1" ? [] : ["--silent=passed-only"]; @@ -176,11 +202,6 @@ const testProfile = const overrideWorkers = Number.parseInt(process.env.OPENCLAW_TEST_WORKERS ?? "", 10); const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null; -const hostCpuCount = os.cpus().length; -const hostMemoryGiB = Math.floor(os.totalmem() / 1024 ** 3); -// Keep aggressive local defaults for high-memory workstations (Mac Studio class). -const highMemLocalHost = !isCI && hostMemoryGiB >= 96; -const lowMemLocalHost = !isCI && hostMemoryGiB < 64; const parallelGatewayEnabled = process.env.OPENCLAW_TEST_PARALLEL_GATEWAY === "1" || (!isCI && highMemLocalHost); // Keep gateway serial by default except when explicitly requested or on high-memory local hosts. @@ -206,7 +227,7 @@ const defaultWorkerBudget = ? { unit: 2, unitIsolated: 1, - extensions: 1, + extensions: 4, gateway: 1, } : testProfile === "serial" @@ -236,7 +257,7 @@ const defaultWorkerBudget = // Sub-64 GiB local hosts are prone to OOM with large vmFork runs. unit: 2, unitIsolated: 1, - extensions: 1, + extensions: 4, gateway: 1, } : { @@ -335,9 +356,15 @@ const runOnce = (entry, extraArgs = []) => new Promise((resolve) => { const maxWorkers = maxWorkersForRun(entry.name); const reporterArgs = buildReporterArgs(entry, extraArgs); + // vmForks with a single worker has shown cross-file leakage in extension suites. + // Fall back to process forks when we intentionally clamp that lane to one worker. + const entryArgs = + entry.name === "extensions" && maxWorkers === 1 && entry.args.includes("--pool=vmForks") + ? entry.args.map((arg) => (arg === "--pool=vmForks" ? "--pool=forks" : arg)) + : entry.args; const args = maxWorkers ? [ - ...entry.args, + ...entryArgs, "--maxWorkers", String(maxWorkers), ...silentArgs, @@ -345,7 +372,7 @@ const runOnce = (entry, extraArgs = []) => ...windowsCiArgs, ...extraArgs, ] - : [...entry.args, ...silentArgs, ...reporterArgs, ...windowsCiArgs, ...extraArgs]; + : [...entryArgs, ...silentArgs, ...reporterArgs, ...windowsCiArgs, ...extraArgs]; const nodeOptions = process.env.NODE_OPTIONS ?? ""; const nextNodeOptions = WARNING_SUPPRESSION_FLAGS.reduce( (acc, flag) => (acc.includes(flag) ? acc : `${acc} ${flag}`.trim()), @@ -384,6 +411,9 @@ const run = async (entry) => { if (shardCount <= 1) { return runOnce(entry); } + if (shardIndexOverride !== null) { + return runOnce(entry, ["--shard", `${shardIndexOverride}/${shardCount}`]); + } for (let shardIndex = 1; shardIndex <= shardCount; shardIndex += 1) { // eslint-disable-next-line no-await-in-loop const code = await runOnce(entry, ["--shard", `${shardIndex}/${shardCount}`]); diff --git a/scripts/upstream-auto-sync.sh b/scripts/upstream-auto-sync.sh index 7a26afd7c836..42715e02fcd6 100755 --- a/scripts/upstream-auto-sync.sh +++ b/scripts/upstream-auto-sync.sh @@ -4,7 +4,8 @@ set -euo pipefail # Idempotent upstream sync script for QVerisBot fork. # # Usage: -# bash scripts/upstream-auto-sync.sh +# bash scripts/upstream-auto-sync.sh # 完整同步 +# bash scripts/upstream-auto-sync.sh --post-merge-only # 冲突解决后只执行 fork 补丁 # # Optional env overrides: # UPSTREAM_REMOTE=upstream @@ -19,6 +20,8 @@ TODAY="$(date +"%Y-%m-%d")" SYNC_BRANCH="${SYNC_BRANCH:-sync/upstream-$TODAY}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +POST_MERGE_ONLY=false +[[ "${1:-}" == "--post-merge-only" ]] && POST_MERGE_ONLY=true die() { echo "ERROR: $*" >&2 @@ -148,6 +151,15 @@ SHIM_PKG # Main # --------------------------------------------------------------------------- +if [[ "$POST_MERGE_ONLY" == "true" ]]; then + ensure_git_repo + echo "==> Post-merge only: applying QVerisBot fork patches" + apply_fork_patches + echo + echo "==> Done." + exit 0 +fi + echo "==> Preflight checks" ensure_git_repo ensure_no_in_progress_ops diff --git a/skills/coding-agent/SKILL.md b/skills/coding-agent/SKILL.md index ef4e059499d2..cca6ef83ad54 100644 --- a/skills/coding-agent/SKILL.md +++ b/skills/coding-agent/SKILL.md @@ -1,6 +1,6 @@ --- name: coding-agent -description: "Delegate coding tasks to Codex, Claude Code, or Pi agents via background process. Use when: (1) building/creating new features or apps, (2) reviewing PRs (spawn in temp dir), (3) refactoring large codebases, (4) iterative coding that needs file exploration. NOT for: simple one-liner fixes (just edit), reading code (use read tool), or any work in ~/clawd workspace (never spawn agents here). Requires a bash tool that supports pty:true." +description: 'Delegate coding tasks to Codex, Claude Code, or Pi agents via background process. Use when: (1) building/creating new features or apps, (2) reviewing PRs (spawn in temp dir), (3) refactoring large codebases, (4) iterative coding that needs file exploration. NOT for: simple one-liner fixes (just edit), reading code (use read tool), thread-bound ACP harness requests in chat (for example spawn/run Codex or Claude Code in a Discord thread; use sessions_spawn with runtime:"acp"), or any work in ~/clawd workspace (never spawn agents here). Requires a bash tool that supports pty:true.' metadata: { "openclaw": { "emoji": "🧩", "requires": { "anyBins": ["claude", "codex", "opencode", "pi"] } }, diff --git a/src/acp/control-plane/manager.core.ts b/src/acp/control-plane/manager.core.ts new file mode 100644 index 000000000000..99ec096bb7fb --- /dev/null +++ b/src/acp/control-plane/manager.core.ts @@ -0,0 +1,1314 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { logVerbose } from "../../globals.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; +import { isAcpSessionKey } from "../../sessions/session-key-utils.js"; +import { + AcpRuntimeError, + toAcpRuntimeError, + withAcpRuntimeErrorBoundary, +} from "../runtime/errors.js"; +import { + createIdentityFromEnsure, + identityEquals, + isSessionIdentityPending, + mergeSessionIdentity, + resolveRuntimeHandleIdentifiersFromIdentity, + resolveSessionIdentityFromMeta, +} from "../runtime/session-identity.js"; +import type { + AcpRuntime, + AcpRuntimeCapabilities, + AcpRuntimeHandle, + AcpRuntimeStatus, +} from "../runtime/types.js"; +import { reconcileManagerRuntimeSessionIdentifiers } from "./manager.identity-reconcile.js"; +import { + applyManagerRuntimeControls, + resolveManagerRuntimeCapabilities, +} from "./manager.runtime-controls.js"; +import { + type AcpCloseSessionInput, + type AcpCloseSessionResult, + type AcpInitializeSessionInput, + type AcpManagerObservabilitySnapshot, + type AcpRunTurnInput, + type AcpSessionManagerDeps, + type AcpSessionResolution, + type AcpSessionRuntimeOptions, + type AcpSessionStatus, + type AcpStartupIdentityReconcileResult, + type ActiveTurnState, + DEFAULT_DEPS, + type SessionAcpMeta, + type SessionEntry, + type TurnLatencyStats, +} from "./manager.types.js"; +import { + createUnsupportedControlError, + hasLegacyAcpIdentityProjection, + normalizeAcpErrorCode, + normalizeActorKey, + normalizeSessionKey, + resolveAcpAgentFromSessionKey, + resolveMissingMetaError, + resolveRuntimeIdleTtlMs, +} from "./manager.utils.js"; +import { CachedRuntimeState, RuntimeCache } from "./runtime-cache.js"; +import { + inferRuntimeOptionPatchFromConfigOption, + mergeRuntimeOptions, + normalizeRuntimeOptions, + normalizeText, + resolveRuntimeOptionsFromMeta, + runtimeOptionsEqual, + validateRuntimeConfigOptionInput, + validateRuntimeModeInput, + validateRuntimeOptionPatch, +} from "./runtime-options.js"; +import { SessionActorQueue } from "./session-actor-queue.js"; + +export class AcpSessionManager { + private readonly actorQueue = new SessionActorQueue(); + private readonly actorTailBySession = this.actorQueue.getTailMapForTesting(); + private readonly runtimeCache = new RuntimeCache(); + private readonly activeTurnBySession = new Map(); + private readonly turnLatencyStats: TurnLatencyStats = { + completed: 0, + failed: 0, + totalMs: 0, + maxMs: 0, + }; + private readonly errorCountsByCode = new Map(); + private evictedRuntimeCount = 0; + private lastEvictedAt: number | undefined; + + constructor(private readonly deps: AcpSessionManagerDeps = DEFAULT_DEPS) {} + + resolveSession(params: { cfg: OpenClawConfig; sessionKey: string }): AcpSessionResolution { + const sessionKey = normalizeSessionKey(params.sessionKey); + if (!sessionKey) { + return { + kind: "none", + sessionKey, + }; + } + const acp = this.deps.readSessionEntry({ + cfg: params.cfg, + sessionKey, + })?.acp; + if (acp) { + return { + kind: "ready", + sessionKey, + meta: acp, + }; + } + if (isAcpSessionKey(sessionKey)) { + return { + kind: "stale", + sessionKey, + error: resolveMissingMetaError(sessionKey), + }; + } + return { + kind: "none", + sessionKey, + }; + } + + getObservabilitySnapshot(cfg: OpenClawConfig): AcpManagerObservabilitySnapshot { + const completedTurns = this.turnLatencyStats.completed + this.turnLatencyStats.failed; + const averageLatencyMs = + completedTurns > 0 ? Math.round(this.turnLatencyStats.totalMs / completedTurns) : 0; + return { + runtimeCache: { + activeSessions: this.runtimeCache.size(), + idleTtlMs: resolveRuntimeIdleTtlMs(cfg), + evictedTotal: this.evictedRuntimeCount, + ...(this.lastEvictedAt ? { lastEvictedAt: this.lastEvictedAt } : {}), + }, + turns: { + active: this.activeTurnBySession.size, + queueDepth: this.actorQueue.getTotalPendingCount(), + completed: this.turnLatencyStats.completed, + failed: this.turnLatencyStats.failed, + averageLatencyMs, + maxLatencyMs: this.turnLatencyStats.maxMs, + }, + errorsByCode: Object.fromEntries( + [...this.errorCountsByCode.entries()].toSorted(([a], [b]) => a.localeCompare(b)), + ), + }; + } + + async reconcilePendingSessionIdentities(params: { + cfg: OpenClawConfig; + }): Promise { + let checked = 0; + let resolved = 0; + let failed = 0; + + let acpSessions: Awaited>; + try { + acpSessions = await this.deps.listAcpSessions({ + cfg: params.cfg, + }); + } catch (error) { + logVerbose(`acp-manager: startup identity scan failed: ${String(error)}`); + return { checked, resolved, failed: failed + 1 }; + } + + for (const session of acpSessions) { + if (!session.acp || !session.sessionKey) { + continue; + } + const currentIdentity = resolveSessionIdentityFromMeta(session.acp); + if (!isSessionIdentityPending(currentIdentity)) { + continue; + } + + checked += 1; + try { + const becameResolved = await this.withSessionActor(session.sessionKey, async () => { + const resolution = this.resolveSession({ + cfg: params.cfg, + sessionKey: session.sessionKey, + }); + if (resolution.kind !== "ready") { + return false; + } + const { runtime, handle, meta } = await this.ensureRuntimeHandle({ + cfg: params.cfg, + sessionKey: session.sessionKey, + meta: resolution.meta, + }); + const reconciled = await this.reconcileRuntimeSessionIdentifiers({ + cfg: params.cfg, + sessionKey: session.sessionKey, + runtime, + handle, + meta, + failOnStatusError: false, + }); + return !isSessionIdentityPending(resolveSessionIdentityFromMeta(reconciled.meta)); + }); + if (becameResolved) { + resolved += 1; + } + } catch (error) { + failed += 1; + logVerbose( + `acp-manager: startup identity reconcile failed for ${session.sessionKey}: ${String(error)}`, + ); + } + } + + return { checked, resolved, failed }; + } + + async initializeSession(input: AcpInitializeSessionInput): Promise<{ + runtime: AcpRuntime; + handle: AcpRuntimeHandle; + meta: SessionAcpMeta; + }> { + const sessionKey = normalizeSessionKey(input.sessionKey); + if (!sessionKey) { + throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required."); + } + const agent = normalizeAgentId(input.agent); + await this.evictIdleRuntimeHandles({ cfg: input.cfg }); + return await this.withSessionActor(sessionKey, async () => { + const backend = this.deps.requireRuntimeBackend(input.backendId || input.cfg.acp?.backend); + const runtime = backend.runtime; + const initialRuntimeOptions = validateRuntimeOptionPatch({ cwd: input.cwd }); + const requestedCwd = initialRuntimeOptions.cwd; + this.enforceConcurrentSessionLimit({ + cfg: input.cfg, + sessionKey, + }); + const handle = await withAcpRuntimeErrorBoundary({ + run: async () => + await runtime.ensureSession({ + sessionKey, + agent, + mode: input.mode, + cwd: requestedCwd, + }), + fallbackCode: "ACP_SESSION_INIT_FAILED", + fallbackMessage: "Could not initialize ACP session runtime.", + }); + const effectiveCwd = normalizeText(handle.cwd) ?? requestedCwd; + const effectiveRuntimeOptions = normalizeRuntimeOptions({ + ...initialRuntimeOptions, + ...(effectiveCwd ? { cwd: effectiveCwd } : {}), + }); + + const identityNow = Date.now(); + const initializedIdentity = + mergeSessionIdentity({ + current: undefined, + incoming: createIdentityFromEnsure({ + handle, + now: identityNow, + }), + now: identityNow, + }) ?? + ({ + state: "pending", + source: "ensure", + lastUpdatedAt: identityNow, + } as const); + const meta: SessionAcpMeta = { + backend: handle.backend || backend.id, + agent, + runtimeSessionName: handle.runtimeSessionName, + identity: initializedIdentity, + mode: input.mode, + ...(Object.keys(effectiveRuntimeOptions).length > 0 + ? { runtimeOptions: effectiveRuntimeOptions } + : {}), + cwd: effectiveCwd, + state: "idle", + lastActivityAt: Date.now(), + }; + try { + const persisted = await this.writeSessionMeta({ + cfg: input.cfg, + sessionKey, + mutate: () => meta, + failOnError: true, + }); + if (!persisted?.acp) { + throw new AcpRuntimeError( + "ACP_SESSION_INIT_FAILED", + `Could not persist ACP metadata for ${sessionKey}.`, + ); + } + } catch (error) { + await runtime + .close({ + handle, + reason: "init-meta-failed", + }) + .catch((closeError) => { + logVerbose( + `acp-manager: cleanup close failed after metadata write error for ${sessionKey}: ${String(closeError)}`, + ); + }); + throw error; + } + this.setCachedRuntimeState(sessionKey, { + runtime, + handle, + backend: handle.backend || backend.id, + agent, + mode: input.mode, + cwd: effectiveCwd, + }); + return { + runtime, + handle, + meta, + }; + }); + } + + async getSessionStatus(params: { + cfg: OpenClawConfig; + sessionKey: string; + }): Promise { + const sessionKey = normalizeSessionKey(params.sessionKey); + if (!sessionKey) { + throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required."); + } + await this.evictIdleRuntimeHandles({ cfg: params.cfg }); + return await this.withSessionActor(sessionKey, async () => { + const resolution = this.resolveSession({ + cfg: params.cfg, + sessionKey, + }); + if (resolution.kind === "none") { + throw new AcpRuntimeError( + "ACP_SESSION_INIT_FAILED", + `Session is not ACP-enabled: ${sessionKey}`, + ); + } + if (resolution.kind === "stale") { + throw resolution.error; + } + const { + runtime, + handle: ensuredHandle, + meta: ensuredMeta, + } = await this.ensureRuntimeHandle({ + cfg: params.cfg, + sessionKey, + meta: resolution.meta, + }); + let handle = ensuredHandle; + let meta = ensuredMeta; + const capabilities = await this.resolveRuntimeCapabilities({ runtime, handle }); + let runtimeStatus: AcpRuntimeStatus | undefined; + if (runtime.getStatus) { + runtimeStatus = await withAcpRuntimeErrorBoundary({ + run: async () => await runtime.getStatus!({ handle }), + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "Could not read ACP runtime status.", + }); + } + ({ handle, meta, runtimeStatus } = await this.reconcileRuntimeSessionIdentifiers({ + cfg: params.cfg, + sessionKey, + runtime, + handle, + meta, + runtimeStatus, + failOnStatusError: true, + })); + const identity = resolveSessionIdentityFromMeta(meta); + return { + sessionKey, + backend: handle.backend || meta.backend, + agent: meta.agent, + ...(identity ? { identity } : {}), + state: meta.state, + mode: meta.mode, + runtimeOptions: resolveRuntimeOptionsFromMeta(meta), + capabilities, + runtimeStatus, + lastActivityAt: meta.lastActivityAt, + lastError: meta.lastError, + }; + }); + } + + async setSessionRuntimeMode(params: { + cfg: OpenClawConfig; + sessionKey: string; + runtimeMode: string; + }): Promise { + const sessionKey = normalizeSessionKey(params.sessionKey); + if (!sessionKey) { + throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required."); + } + const runtimeMode = validateRuntimeModeInput(params.runtimeMode); + + await this.evictIdleRuntimeHandles({ cfg: params.cfg }); + return await this.withSessionActor(sessionKey, async () => { + const resolution = this.resolveSession({ + cfg: params.cfg, + sessionKey, + }); + if (resolution.kind === "none") { + throw new AcpRuntimeError( + "ACP_SESSION_INIT_FAILED", + `Session is not ACP-enabled: ${sessionKey}`, + ); + } + if (resolution.kind === "stale") { + throw resolution.error; + } + const { runtime, handle, meta } = await this.ensureRuntimeHandle({ + cfg: params.cfg, + sessionKey, + meta: resolution.meta, + }); + const capabilities = await this.resolveRuntimeCapabilities({ runtime, handle }); + if (!capabilities.controls.includes("session/set_mode") || !runtime.setMode) { + throw createUnsupportedControlError({ + backend: handle.backend || meta.backend, + control: "session/set_mode", + }); + } + + await withAcpRuntimeErrorBoundary({ + run: async () => + await runtime.setMode!({ + handle, + mode: runtimeMode, + }), + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "Could not update ACP runtime mode.", + }); + + const nextOptions = mergeRuntimeOptions({ + current: resolveRuntimeOptionsFromMeta(meta), + patch: { runtimeMode }, + }); + await this.persistRuntimeOptions({ + cfg: params.cfg, + sessionKey, + options: nextOptions, + }); + return nextOptions; + }); + } + + async setSessionConfigOption(params: { + cfg: OpenClawConfig; + sessionKey: string; + key: string; + value: string; + }): Promise { + const sessionKey = normalizeSessionKey(params.sessionKey); + if (!sessionKey) { + throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required."); + } + const normalizedOption = validateRuntimeConfigOptionInput(params.key, params.value); + const key = normalizedOption.key; + const value = normalizedOption.value; + + await this.evictIdleRuntimeHandles({ cfg: params.cfg }); + return await this.withSessionActor(sessionKey, async () => { + const resolution = this.resolveSession({ + cfg: params.cfg, + sessionKey, + }); + if (resolution.kind === "none") { + throw new AcpRuntimeError( + "ACP_SESSION_INIT_FAILED", + `Session is not ACP-enabled: ${sessionKey}`, + ); + } + if (resolution.kind === "stale") { + throw resolution.error; + } + const { runtime, handle, meta } = await this.ensureRuntimeHandle({ + cfg: params.cfg, + sessionKey, + meta: resolution.meta, + }); + const inferredPatch = inferRuntimeOptionPatchFromConfigOption(key, value); + const capabilities = await this.resolveRuntimeCapabilities({ runtime, handle }); + if ( + !capabilities.controls.includes("session/set_config_option") || + !runtime.setConfigOption + ) { + throw createUnsupportedControlError({ + backend: handle.backend || meta.backend, + control: "session/set_config_option", + }); + } + + const advertisedKeys = new Set( + (capabilities.configOptionKeys ?? []) + .map((entry) => normalizeText(entry)) + .filter(Boolean) as string[], + ); + if (advertisedKeys.size > 0 && !advertisedKeys.has(key)) { + throw new AcpRuntimeError( + "ACP_BACKEND_UNSUPPORTED_CONTROL", + `ACP backend "${handle.backend || meta.backend}" does not accept config key "${key}".`, + ); + } + + await withAcpRuntimeErrorBoundary({ + run: async () => + await runtime.setConfigOption!({ + handle, + key, + value, + }), + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "Could not update ACP runtime config option.", + }); + + const nextOptions = mergeRuntimeOptions({ + current: resolveRuntimeOptionsFromMeta(meta), + patch: inferredPatch, + }); + await this.persistRuntimeOptions({ + cfg: params.cfg, + sessionKey, + options: nextOptions, + }); + return nextOptions; + }); + } + + async updateSessionRuntimeOptions(params: { + cfg: OpenClawConfig; + sessionKey: string; + patch: Partial; + }): Promise { + const sessionKey = normalizeSessionKey(params.sessionKey); + const validatedPatch = validateRuntimeOptionPatch(params.patch); + if (!sessionKey) { + throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required."); + } + + await this.evictIdleRuntimeHandles({ cfg: params.cfg }); + return await this.withSessionActor(sessionKey, async () => { + const resolution = this.resolveSession({ + cfg: params.cfg, + sessionKey, + }); + if (resolution.kind === "none") { + throw new AcpRuntimeError( + "ACP_SESSION_INIT_FAILED", + `Session is not ACP-enabled: ${sessionKey}`, + ); + } + if (resolution.kind === "stale") { + throw resolution.error; + } + const nextOptions = mergeRuntimeOptions({ + current: resolveRuntimeOptionsFromMeta(resolution.meta), + patch: validatedPatch, + }); + await this.persistRuntimeOptions({ + cfg: params.cfg, + sessionKey, + options: nextOptions, + }); + return nextOptions; + }); + } + + async resetSessionRuntimeOptions(params: { + cfg: OpenClawConfig; + sessionKey: string; + }): Promise { + const sessionKey = normalizeSessionKey(params.sessionKey); + if (!sessionKey) { + throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required."); + } + await this.evictIdleRuntimeHandles({ cfg: params.cfg }); + return await this.withSessionActor(sessionKey, async () => { + const resolution = this.resolveSession({ + cfg: params.cfg, + sessionKey, + }); + if (resolution.kind === "none") { + throw new AcpRuntimeError( + "ACP_SESSION_INIT_FAILED", + `Session is not ACP-enabled: ${sessionKey}`, + ); + } + if (resolution.kind === "stale") { + throw resolution.error; + } + const { runtime, handle } = await this.ensureRuntimeHandle({ + cfg: params.cfg, + sessionKey, + meta: resolution.meta, + }); + await withAcpRuntimeErrorBoundary({ + run: async () => + await runtime.close({ + handle, + reason: "reset-runtime-options", + }), + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "Could not reset ACP runtime options.", + }); + this.clearCachedRuntimeState(sessionKey); + await this.persistRuntimeOptions({ + cfg: params.cfg, + sessionKey, + options: {}, + }); + return {}; + }); + } + + async runTurn(input: AcpRunTurnInput): Promise { + const sessionKey = normalizeSessionKey(input.sessionKey); + if (!sessionKey) { + throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required."); + } + await this.evictIdleRuntimeHandles({ cfg: input.cfg }); + await this.withSessionActor(sessionKey, async () => { + const resolution = this.resolveSession({ + cfg: input.cfg, + sessionKey, + }); + if (resolution.kind === "none") { + throw new AcpRuntimeError( + "ACP_SESSION_INIT_FAILED", + `Session is not ACP-enabled: ${sessionKey}`, + ); + } + if (resolution.kind === "stale") { + throw resolution.error; + } + + const { + runtime, + handle: ensuredHandle, + meta: ensuredMeta, + } = await this.ensureRuntimeHandle({ + cfg: input.cfg, + sessionKey, + meta: resolution.meta, + }); + let handle = ensuredHandle; + const meta = ensuredMeta; + await this.applyRuntimeControls({ + sessionKey, + runtime, + handle, + meta, + }); + const turnStartedAt = Date.now(); + const actorKey = normalizeActorKey(sessionKey); + + await this.setSessionState({ + cfg: input.cfg, + sessionKey, + state: "running", + clearLastError: true, + }); + + const internalAbortController = new AbortController(); + const onCallerAbort = () => { + internalAbortController.abort(); + }; + if (input.signal?.aborted) { + internalAbortController.abort(); + } else if (input.signal) { + input.signal.addEventListener("abort", onCallerAbort, { once: true }); + } + + const activeTurn: ActiveTurnState = { + runtime, + handle, + abortController: internalAbortController, + }; + this.activeTurnBySession.set(actorKey, activeTurn); + + let streamError: AcpRuntimeError | null = null; + try { + const combinedSignal = + input.signal && typeof AbortSignal.any === "function" + ? AbortSignal.any([input.signal, internalAbortController.signal]) + : internalAbortController.signal; + for await (const event of runtime.runTurn({ + handle, + text: input.text, + mode: input.mode, + requestId: input.requestId, + signal: combinedSignal, + })) { + if (event.type === "error") { + streamError = new AcpRuntimeError( + normalizeAcpErrorCode(event.code), + event.message?.trim() || "ACP turn failed before completion.", + ); + } + if (input.onEvent) { + await input.onEvent(event); + } + } + if (streamError) { + throw streamError; + } + this.recordTurnCompletion({ + startedAt: turnStartedAt, + }); + await this.setSessionState({ + cfg: input.cfg, + sessionKey, + state: "idle", + clearLastError: true, + }); + } catch (error) { + const acpError = toAcpRuntimeError({ + error, + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "ACP turn failed before completion.", + }); + this.recordTurnCompletion({ + startedAt: turnStartedAt, + errorCode: acpError.code, + }); + await this.setSessionState({ + cfg: input.cfg, + sessionKey, + state: "error", + lastError: acpError.message, + }); + throw acpError; + } finally { + if (input.signal) { + input.signal.removeEventListener("abort", onCallerAbort); + } + if (this.activeTurnBySession.get(actorKey) === activeTurn) { + this.activeTurnBySession.delete(actorKey); + } + if (meta.mode !== "oneshot") { + ({ handle } = await this.reconcileRuntimeSessionIdentifiers({ + cfg: input.cfg, + sessionKey, + runtime, + handle, + meta, + failOnStatusError: false, + })); + } + if (meta.mode === "oneshot") { + try { + await runtime.close({ + handle, + reason: "oneshot-complete", + }); + } catch (error) { + logVerbose(`acp-manager: ACP oneshot close failed for ${sessionKey}: ${String(error)}`); + } finally { + this.clearCachedRuntimeState(sessionKey); + } + } + } + }); + } + + async cancelSession(params: { + cfg: OpenClawConfig; + sessionKey: string; + reason?: string; + }): Promise { + const sessionKey = normalizeSessionKey(params.sessionKey); + if (!sessionKey) { + throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required."); + } + await this.evictIdleRuntimeHandles({ cfg: params.cfg }); + const actorKey = normalizeActorKey(sessionKey); + const activeTurn = this.activeTurnBySession.get(actorKey); + if (activeTurn) { + activeTurn.abortController.abort(); + if (!activeTurn.cancelPromise) { + activeTurn.cancelPromise = activeTurn.runtime.cancel({ + handle: activeTurn.handle, + reason: params.reason, + }); + } + await withAcpRuntimeErrorBoundary({ + run: async () => await activeTurn.cancelPromise!, + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "ACP cancel failed before completion.", + }); + return; + } + + await this.withSessionActor(sessionKey, async () => { + const resolution = this.resolveSession({ + cfg: params.cfg, + sessionKey, + }); + if (resolution.kind === "none") { + throw new AcpRuntimeError( + "ACP_SESSION_INIT_FAILED", + `Session is not ACP-enabled: ${sessionKey}`, + ); + } + if (resolution.kind === "stale") { + throw resolution.error; + } + const { runtime, handle } = await this.ensureRuntimeHandle({ + cfg: params.cfg, + sessionKey, + meta: resolution.meta, + }); + try { + await withAcpRuntimeErrorBoundary({ + run: async () => + await runtime.cancel({ + handle, + reason: params.reason, + }), + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "ACP cancel failed before completion.", + }); + await this.setSessionState({ + cfg: params.cfg, + sessionKey, + state: "idle", + clearLastError: true, + }); + } catch (error) { + const acpError = toAcpRuntimeError({ + error, + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "ACP cancel failed before completion.", + }); + await this.setSessionState({ + cfg: params.cfg, + sessionKey, + state: "error", + lastError: acpError.message, + }); + throw acpError; + } + }); + } + + async closeSession(input: AcpCloseSessionInput): Promise { + const sessionKey = normalizeSessionKey(input.sessionKey); + if (!sessionKey) { + throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required."); + } + await this.evictIdleRuntimeHandles({ cfg: input.cfg }); + return await this.withSessionActor(sessionKey, async () => { + const resolution = this.resolveSession({ + cfg: input.cfg, + sessionKey, + }); + if (resolution.kind === "none") { + if (input.requireAcpSession ?? true) { + throw new AcpRuntimeError( + "ACP_SESSION_INIT_FAILED", + `Session is not ACP-enabled: ${sessionKey}`, + ); + } + return { + runtimeClosed: false, + metaCleared: false, + }; + } + if (resolution.kind === "stale") { + if (input.requireAcpSession ?? true) { + throw resolution.error; + } + return { + runtimeClosed: false, + metaCleared: false, + }; + } + + let runtimeClosed = false; + let runtimeNotice: string | undefined; + try { + const { runtime, handle } = await this.ensureRuntimeHandle({ + cfg: input.cfg, + sessionKey, + meta: resolution.meta, + }); + await withAcpRuntimeErrorBoundary({ + run: async () => + await runtime.close({ + handle, + reason: input.reason, + }), + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "ACP close failed before completion.", + }); + runtimeClosed = true; + this.clearCachedRuntimeState(sessionKey); + } catch (error) { + const acpError = toAcpRuntimeError({ + error, + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "ACP close failed before completion.", + }); + if ( + input.allowBackendUnavailable && + (acpError.code === "ACP_BACKEND_MISSING" || acpError.code === "ACP_BACKEND_UNAVAILABLE") + ) { + // Treat unavailable backends as terminal for this cached handle so it + // cannot continue counting against maxConcurrentSessions. + this.clearCachedRuntimeState(sessionKey); + runtimeNotice = acpError.message; + } else { + throw acpError; + } + } + + let metaCleared = false; + if (input.clearMeta) { + await this.writeSessionMeta({ + cfg: input.cfg, + sessionKey, + mutate: (_current, entry) => { + if (!entry) { + return null; + } + return null; + }, + failOnError: true, + }); + metaCleared = true; + } + + return { + runtimeClosed, + runtimeNotice, + metaCleared, + }; + }); + } + + private async ensureRuntimeHandle(params: { + cfg: OpenClawConfig; + sessionKey: string; + meta: SessionAcpMeta; + }): Promise<{ runtime: AcpRuntime; handle: AcpRuntimeHandle; meta: SessionAcpMeta }> { + const agent = + params.meta.agent?.trim() || resolveAcpAgentFromSessionKey(params.sessionKey, "main"); + const mode = params.meta.mode; + const runtimeOptions = resolveRuntimeOptionsFromMeta(params.meta); + const cwd = runtimeOptions.cwd ?? normalizeText(params.meta.cwd); + const configuredBackend = (params.meta.backend || params.cfg.acp?.backend || "").trim(); + const cached = this.getCachedRuntimeState(params.sessionKey); + if (cached) { + const backendMatches = !configuredBackend || cached.backend === configuredBackend; + const agentMatches = cached.agent === agent; + const modeMatches = cached.mode === mode; + const cwdMatches = (cached.cwd ?? "") === (cwd ?? ""); + if (backendMatches && agentMatches && modeMatches && cwdMatches) { + return { + runtime: cached.runtime, + handle: cached.handle, + meta: params.meta, + }; + } + this.clearCachedRuntimeState(params.sessionKey); + } + + this.enforceConcurrentSessionLimit({ + cfg: params.cfg, + sessionKey: params.sessionKey, + }); + + const backend = this.deps.requireRuntimeBackend(configuredBackend || undefined); + const runtime = backend.runtime; + const ensured = await withAcpRuntimeErrorBoundary({ + run: async () => + await runtime.ensureSession({ + sessionKey: params.sessionKey, + agent, + mode, + cwd, + }), + fallbackCode: "ACP_SESSION_INIT_FAILED", + fallbackMessage: "Could not initialize ACP session runtime.", + }); + + const previousMeta = params.meta; + const previousIdentity = resolveSessionIdentityFromMeta(previousMeta); + const now = Date.now(); + const effectiveCwd = normalizeText(ensured.cwd) ?? cwd; + const nextRuntimeOptions = normalizeRuntimeOptions({ + ...runtimeOptions, + ...(effectiveCwd ? { cwd: effectiveCwd } : {}), + }); + const nextIdentity = + mergeSessionIdentity({ + current: previousIdentity, + incoming: createIdentityFromEnsure({ + handle: ensured, + now, + }), + now, + }) ?? previousIdentity; + const nextHandleIdentifiers = resolveRuntimeHandleIdentifiersFromIdentity(nextIdentity); + const nextHandle: AcpRuntimeHandle = { + ...ensured, + ...(nextHandleIdentifiers.backendSessionId + ? { backendSessionId: nextHandleIdentifiers.backendSessionId } + : {}), + ...(nextHandleIdentifiers.agentSessionId + ? { agentSessionId: nextHandleIdentifiers.agentSessionId } + : {}), + }; + const nextMeta: SessionAcpMeta = { + backend: ensured.backend || backend.id, + agent, + runtimeSessionName: ensured.runtimeSessionName, + ...(nextIdentity ? { identity: nextIdentity } : {}), + mode: params.meta.mode, + ...(Object.keys(nextRuntimeOptions).length > 0 ? { runtimeOptions: nextRuntimeOptions } : {}), + ...(effectiveCwd ? { cwd: effectiveCwd } : {}), + state: previousMeta.state, + lastActivityAt: now, + ...(previousMeta.lastError ? { lastError: previousMeta.lastError } : {}), + }; + const shouldPersistMeta = + previousMeta.backend !== nextMeta.backend || + previousMeta.runtimeSessionName !== nextMeta.runtimeSessionName || + !identityEquals(previousIdentity, nextIdentity) || + previousMeta.agent !== nextMeta.agent || + previousMeta.cwd !== nextMeta.cwd || + !runtimeOptionsEqual(previousMeta.runtimeOptions, nextMeta.runtimeOptions) || + hasLegacyAcpIdentityProjection(previousMeta); + if (shouldPersistMeta) { + await this.writeSessionMeta({ + cfg: params.cfg, + sessionKey: params.sessionKey, + mutate: (_current, entry) => { + if (!entry) { + return null; + } + return nextMeta; + }, + }); + } + this.setCachedRuntimeState(params.sessionKey, { + runtime, + handle: nextHandle, + backend: ensured.backend || backend.id, + agent, + mode, + cwd: effectiveCwd, + appliedControlSignature: undefined, + }); + return { + runtime, + handle: nextHandle, + meta: nextMeta, + }; + } + + private async persistRuntimeOptions(params: { + cfg: OpenClawConfig; + sessionKey: string; + options: AcpSessionRuntimeOptions; + }): Promise { + const normalized = normalizeRuntimeOptions(params.options); + const hasOptions = Object.keys(normalized).length > 0; + await this.writeSessionMeta({ + cfg: params.cfg, + sessionKey: params.sessionKey, + mutate: (current, entry) => { + if (!entry) { + return null; + } + const base = current ?? entry.acp; + if (!base) { + return null; + } + return { + backend: base.backend, + agent: base.agent, + runtimeSessionName: base.runtimeSessionName, + ...(base.identity ? { identity: base.identity } : {}), + mode: base.mode, + runtimeOptions: hasOptions ? normalized : undefined, + cwd: normalized.cwd, + state: base.state, + lastActivityAt: Date.now(), + ...(base.lastError ? { lastError: base.lastError } : {}), + }; + }, + failOnError: true, + }); + + const cached = this.getCachedRuntimeState(params.sessionKey); + if (!cached) { + return; + } + if ((cached.cwd ?? "") !== (normalized.cwd ?? "")) { + this.clearCachedRuntimeState(params.sessionKey); + return; + } + // Persisting options does not guarantee this process pushed all controls to the runtime. + // Force the next turn to reconcile runtime controls from persisted metadata. + cached.appliedControlSignature = undefined; + } + + private enforceConcurrentSessionLimit(params: { cfg: OpenClawConfig; sessionKey: string }): void { + const configuredLimit = params.cfg.acp?.maxConcurrentSessions; + if (typeof configuredLimit !== "number" || !Number.isFinite(configuredLimit)) { + return; + } + const limit = Math.max(1, Math.floor(configuredLimit)); + const actorKey = normalizeActorKey(params.sessionKey); + if (this.runtimeCache.has(actorKey)) { + return; + } + const activeCount = this.runtimeCache.size(); + if (activeCount >= limit) { + throw new AcpRuntimeError( + "ACP_SESSION_INIT_FAILED", + `ACP max concurrent sessions reached (${activeCount}/${limit}).`, + ); + } + } + + private recordTurnCompletion(params: { startedAt: number; errorCode?: AcpRuntimeError["code"] }) { + const durationMs = Math.max(0, Date.now() - params.startedAt); + this.turnLatencyStats.totalMs += durationMs; + this.turnLatencyStats.maxMs = Math.max(this.turnLatencyStats.maxMs, durationMs); + if (params.errorCode) { + this.turnLatencyStats.failed += 1; + this.recordErrorCode(params.errorCode); + return; + } + this.turnLatencyStats.completed += 1; + } + + private recordErrorCode(code: string): void { + const normalized = normalizeAcpErrorCode(code); + this.errorCountsByCode.set(normalized, (this.errorCountsByCode.get(normalized) ?? 0) + 1); + } + + private async evictIdleRuntimeHandles(params: { cfg: OpenClawConfig }): Promise { + const idleTtlMs = resolveRuntimeIdleTtlMs(params.cfg); + if (idleTtlMs <= 0 || this.runtimeCache.size() === 0) { + return; + } + const now = Date.now(); + const candidates = this.runtimeCache.collectIdleCandidates({ + maxIdleMs: idleTtlMs, + now, + }); + if (candidates.length === 0) { + return; + } + + for (const candidate of candidates) { + await this.actorQueue.run(candidate.actorKey, async () => { + if (this.activeTurnBySession.has(candidate.actorKey)) { + return; + } + const lastTouchedAt = this.runtimeCache.getLastTouchedAt(candidate.actorKey); + if (lastTouchedAt == null || now - lastTouchedAt < idleTtlMs) { + return; + } + const cached = this.runtimeCache.peek(candidate.actorKey); + if (!cached) { + return; + } + this.runtimeCache.clear(candidate.actorKey); + this.evictedRuntimeCount += 1; + this.lastEvictedAt = Date.now(); + try { + await cached.runtime.close({ + handle: cached.handle, + reason: "idle-evicted", + }); + } catch (error) { + logVerbose( + `acp-manager: idle eviction close failed for ${candidate.state.handle.sessionKey}: ${String(error)}`, + ); + } + }); + } + } + + private async resolveRuntimeCapabilities(params: { + runtime: AcpRuntime; + handle: AcpRuntimeHandle; + }): Promise { + return await resolveManagerRuntimeCapabilities(params); + } + + private async applyRuntimeControls(params: { + sessionKey: string; + runtime: AcpRuntime; + handle: AcpRuntimeHandle; + meta: SessionAcpMeta; + }): Promise { + await applyManagerRuntimeControls({ + ...params, + getCachedRuntimeState: (sessionKey) => this.getCachedRuntimeState(sessionKey), + }); + } + + private async setSessionState(params: { + cfg: OpenClawConfig; + sessionKey: string; + state: SessionAcpMeta["state"]; + lastError?: string; + clearLastError?: boolean; + }): Promise { + await this.writeSessionMeta({ + cfg: params.cfg, + sessionKey: params.sessionKey, + mutate: (current, entry) => { + if (!entry) { + return null; + } + const base = current ?? entry.acp; + if (!base) { + return null; + } + const next: SessionAcpMeta = { + backend: base.backend, + agent: base.agent, + runtimeSessionName: base.runtimeSessionName, + ...(base.identity ? { identity: base.identity } : {}), + mode: base.mode, + ...(base.runtimeOptions ? { runtimeOptions: base.runtimeOptions } : {}), + ...(base.cwd ? { cwd: base.cwd } : {}), + state: params.state, + lastActivityAt: Date.now(), + ...(base.lastError ? { lastError: base.lastError } : {}), + }; + if (params.lastError?.trim()) { + next.lastError = params.lastError.trim(); + } else if (params.clearLastError) { + delete next.lastError; + } + return next; + }, + }); + } + + private async reconcileRuntimeSessionIdentifiers(params: { + cfg: OpenClawConfig; + sessionKey: string; + runtime: AcpRuntime; + handle: AcpRuntimeHandle; + meta: SessionAcpMeta; + runtimeStatus?: AcpRuntimeStatus; + failOnStatusError: boolean; + }): Promise<{ + handle: AcpRuntimeHandle; + meta: SessionAcpMeta; + runtimeStatus?: AcpRuntimeStatus; + }> { + return await reconcileManagerRuntimeSessionIdentifiers({ + ...params, + setCachedHandle: (sessionKey, handle) => { + const cached = this.getCachedRuntimeState(sessionKey); + if (cached) { + cached.handle = handle; + } + }, + writeSessionMeta: async (writeParams) => await this.writeSessionMeta(writeParams), + }); + } + + private async writeSessionMeta(params: { + cfg: OpenClawConfig; + sessionKey: string; + mutate: ( + current: SessionAcpMeta | undefined, + entry: SessionEntry | undefined, + ) => SessionAcpMeta | null | undefined; + failOnError?: boolean; + }): Promise { + try { + return await this.deps.upsertSessionMeta({ + cfg: params.cfg, + sessionKey: params.sessionKey, + mutate: params.mutate, + }); + } catch (error) { + if (params.failOnError) { + throw error; + } + logVerbose( + `acp-manager: failed persisting ACP metadata for ${params.sessionKey}: ${String(error)}`, + ); + return null; + } + } + + private async withSessionActor(sessionKey: string, op: () => Promise): Promise { + const actorKey = normalizeActorKey(sessionKey); + return await this.actorQueue.run(actorKey, op); + } + + private getCachedRuntimeState(sessionKey: string): CachedRuntimeState | null { + return this.runtimeCache.get(normalizeActorKey(sessionKey)); + } + + private setCachedRuntimeState(sessionKey: string, state: CachedRuntimeState): void { + this.runtimeCache.set(normalizeActorKey(sessionKey), state); + } + + private clearCachedRuntimeState(sessionKey: string): void { + this.runtimeCache.clear(normalizeActorKey(sessionKey)); + } +} diff --git a/src/acp/control-plane/manager.identity-reconcile.ts b/src/acp/control-plane/manager.identity-reconcile.ts new file mode 100644 index 000000000000..d78a22ea04fe --- /dev/null +++ b/src/acp/control-plane/manager.identity-reconcile.ts @@ -0,0 +1,159 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { logVerbose } from "../../globals.js"; +import { withAcpRuntimeErrorBoundary } from "../runtime/errors.js"; +import { + createIdentityFromStatus, + identityEquals, + mergeSessionIdentity, + resolveRuntimeHandleIdentifiersFromIdentity, + resolveSessionIdentityFromMeta, +} from "../runtime/session-identity.js"; +import type { AcpRuntime, AcpRuntimeHandle, AcpRuntimeStatus } from "../runtime/types.js"; +import type { SessionAcpMeta, SessionEntry } from "./manager.types.js"; +import { hasLegacyAcpIdentityProjection } from "./manager.utils.js"; + +export async function reconcileManagerRuntimeSessionIdentifiers(params: { + cfg: OpenClawConfig; + sessionKey: string; + runtime: AcpRuntime; + handle: AcpRuntimeHandle; + meta: SessionAcpMeta; + runtimeStatus?: AcpRuntimeStatus; + failOnStatusError: boolean; + setCachedHandle: (sessionKey: string, handle: AcpRuntimeHandle) => void; + writeSessionMeta: (params: { + cfg: OpenClawConfig; + sessionKey: string; + mutate: ( + current: SessionAcpMeta | undefined, + entry: SessionEntry | undefined, + ) => SessionAcpMeta | null | undefined; + failOnError?: boolean; + }) => Promise; +}): Promise<{ + handle: AcpRuntimeHandle; + meta: SessionAcpMeta; + runtimeStatus?: AcpRuntimeStatus; +}> { + let runtimeStatus = params.runtimeStatus; + if (!runtimeStatus && params.runtime.getStatus) { + try { + runtimeStatus = await withAcpRuntimeErrorBoundary({ + run: async () => + await params.runtime.getStatus!({ + handle: params.handle, + }), + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "Could not read ACP runtime status.", + }); + } catch (error) { + if (params.failOnStatusError) { + throw error; + } + logVerbose( + `acp-manager: failed to refresh ACP runtime status for ${params.sessionKey}: ${String(error)}`, + ); + return { + handle: params.handle, + meta: params.meta, + runtimeStatus, + }; + } + } + + const now = Date.now(); + const currentIdentity = resolveSessionIdentityFromMeta(params.meta); + const nextIdentity = + mergeSessionIdentity({ + current: currentIdentity, + incoming: createIdentityFromStatus({ + status: runtimeStatus, + now, + }), + now, + }) ?? currentIdentity; + const handleIdentifiers = resolveRuntimeHandleIdentifiersFromIdentity(nextIdentity); + const handleChanged = + handleIdentifiers.backendSessionId !== params.handle.backendSessionId || + handleIdentifiers.agentSessionId !== params.handle.agentSessionId; + const nextHandle: AcpRuntimeHandle = handleChanged + ? { + ...params.handle, + ...(handleIdentifiers.backendSessionId + ? { backendSessionId: handleIdentifiers.backendSessionId } + : {}), + ...(handleIdentifiers.agentSessionId + ? { agentSessionId: handleIdentifiers.agentSessionId } + : {}), + } + : params.handle; + if (handleChanged) { + params.setCachedHandle(params.sessionKey, nextHandle); + } + + const metaChanged = + !identityEquals(currentIdentity, nextIdentity) || hasLegacyAcpIdentityProjection(params.meta); + if (!metaChanged) { + return { + handle: nextHandle, + meta: params.meta, + runtimeStatus, + }; + } + const nextMeta: SessionAcpMeta = { + backend: params.meta.backend, + agent: params.meta.agent, + runtimeSessionName: params.meta.runtimeSessionName, + ...(nextIdentity ? { identity: nextIdentity } : {}), + mode: params.meta.mode, + ...(params.meta.runtimeOptions ? { runtimeOptions: params.meta.runtimeOptions } : {}), + ...(params.meta.cwd ? { cwd: params.meta.cwd } : {}), + lastActivityAt: now, + state: params.meta.state, + ...(params.meta.lastError ? { lastError: params.meta.lastError } : {}), + }; + if (!identityEquals(currentIdentity, nextIdentity)) { + const currentAgentSessionId = currentIdentity?.agentSessionId ?? ""; + const nextAgentSessionId = nextIdentity?.agentSessionId ?? ""; + const currentAcpxSessionId = currentIdentity?.acpxSessionId ?? ""; + const nextAcpxSessionId = nextIdentity?.acpxSessionId ?? ""; + const currentAcpxRecordId = currentIdentity?.acpxRecordId ?? ""; + const nextAcpxRecordId = nextIdentity?.acpxRecordId ?? ""; + logVerbose( + `acp-manager: session identity updated for ${params.sessionKey} ` + + `(agentSessionId ${currentAgentSessionId} -> ${nextAgentSessionId}, ` + + `acpxSessionId ${currentAcpxSessionId} -> ${nextAcpxSessionId}, ` + + `acpxRecordId ${currentAcpxRecordId} -> ${nextAcpxRecordId})`, + ); + } + await params.writeSessionMeta({ + cfg: params.cfg, + sessionKey: params.sessionKey, + mutate: (current, entry) => { + if (!entry) { + return null; + } + const base = current ?? entry.acp; + if (!base) { + return null; + } + return { + backend: base.backend, + agent: base.agent, + runtimeSessionName: base.runtimeSessionName, + ...(nextIdentity ? { identity: nextIdentity } : {}), + mode: base.mode, + ...(base.runtimeOptions ? { runtimeOptions: base.runtimeOptions } : {}), + ...(base.cwd ? { cwd: base.cwd } : {}), + state: base.state, + lastActivityAt: now, + ...(base.lastError ? { lastError: base.lastError } : {}), + }; + }, + }); + return { + handle: nextHandle, + meta: nextMeta, + runtimeStatus, + }; +} diff --git a/src/acp/control-plane/manager.runtime-controls.ts b/src/acp/control-plane/manager.runtime-controls.ts new file mode 100644 index 000000000000..6c2b9e0a267a --- /dev/null +++ b/src/acp/control-plane/manager.runtime-controls.ts @@ -0,0 +1,118 @@ +import { AcpRuntimeError, withAcpRuntimeErrorBoundary } from "../runtime/errors.js"; +import type { AcpRuntime, AcpRuntimeCapabilities, AcpRuntimeHandle } from "../runtime/types.js"; +import type { SessionAcpMeta } from "./manager.types.js"; +import { createUnsupportedControlError } from "./manager.utils.js"; +import type { CachedRuntimeState } from "./runtime-cache.js"; +import { + buildRuntimeConfigOptionPairs, + buildRuntimeControlSignature, + normalizeText, + resolveRuntimeOptionsFromMeta, +} from "./runtime-options.js"; + +export async function resolveManagerRuntimeCapabilities(params: { + runtime: AcpRuntime; + handle: AcpRuntimeHandle; +}): Promise { + let reported: AcpRuntimeCapabilities | undefined; + if (params.runtime.getCapabilities) { + reported = await withAcpRuntimeErrorBoundary({ + run: async () => await params.runtime.getCapabilities!({ handle: params.handle }), + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "Could not read ACP runtime capabilities.", + }); + } + const controls = new Set(reported?.controls ?? []); + if (params.runtime.setMode) { + controls.add("session/set_mode"); + } + if (params.runtime.setConfigOption) { + controls.add("session/set_config_option"); + } + if (params.runtime.getStatus) { + controls.add("session/status"); + } + const normalizedKeys = (reported?.configOptionKeys ?? []) + .map((entry) => normalizeText(entry)) + .filter(Boolean) as string[]; + return { + controls: [...controls].toSorted(), + ...(normalizedKeys.length > 0 ? { configOptionKeys: normalizedKeys } : {}), + }; +} + +export async function applyManagerRuntimeControls(params: { + sessionKey: string; + runtime: AcpRuntime; + handle: AcpRuntimeHandle; + meta: SessionAcpMeta; + getCachedRuntimeState: (sessionKey: string) => CachedRuntimeState | null; +}): Promise { + const options = resolveRuntimeOptionsFromMeta(params.meta); + const signature = buildRuntimeControlSignature(options); + const cached = params.getCachedRuntimeState(params.sessionKey); + if (cached?.appliedControlSignature === signature) { + return; + } + + const capabilities = await resolveManagerRuntimeCapabilities({ + runtime: params.runtime, + handle: params.handle, + }); + const backend = params.handle.backend || params.meta.backend; + const runtimeMode = normalizeText(options.runtimeMode); + const configOptions = buildRuntimeConfigOptionPairs(options); + const advertisedKeys = new Set( + (capabilities.configOptionKeys ?? []) + .map((entry) => normalizeText(entry)) + .filter(Boolean) as string[], + ); + + await withAcpRuntimeErrorBoundary({ + run: async () => { + if (runtimeMode) { + if (!capabilities.controls.includes("session/set_mode") || !params.runtime.setMode) { + throw createUnsupportedControlError({ + backend, + control: "session/set_mode", + }); + } + await params.runtime.setMode({ + handle: params.handle, + mode: runtimeMode, + }); + } + + if (configOptions.length > 0) { + if ( + !capabilities.controls.includes("session/set_config_option") || + !params.runtime.setConfigOption + ) { + throw createUnsupportedControlError({ + backend, + control: "session/set_config_option", + }); + } + for (const [key, value] of configOptions) { + if (advertisedKeys.size > 0 && !advertisedKeys.has(key)) { + throw new AcpRuntimeError( + "ACP_BACKEND_UNSUPPORTED_CONTROL", + `ACP backend "${backend}" does not accept config key "${key}".`, + ); + } + await params.runtime.setConfigOption({ + handle: params.handle, + key, + value, + }); + } + } + }, + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "Could not apply ACP runtime options before turn execution.", + }); + + if (cached) { + cached.appliedControlSignature = signature; + } +} diff --git a/src/acp/control-plane/manager.test.ts b/src/acp/control-plane/manager.test.ts new file mode 100644 index 000000000000..ebdf356ca9fb --- /dev/null +++ b/src/acp/control-plane/manager.test.ts @@ -0,0 +1,1250 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { AcpSessionRuntimeOptions, SessionAcpMeta } from "../../config/sessions/types.js"; +import { AcpRuntimeError } from "../runtime/errors.js"; +import type { AcpRuntime, AcpRuntimeCapabilities } from "../runtime/types.js"; + +const hoisted = vi.hoisted(() => { + const listAcpSessionEntriesMock = vi.fn(); + const readAcpSessionEntryMock = vi.fn(); + const upsertAcpSessionMetaMock = vi.fn(); + const requireAcpRuntimeBackendMock = vi.fn(); + return { + listAcpSessionEntriesMock, + readAcpSessionEntryMock, + upsertAcpSessionMetaMock, + requireAcpRuntimeBackendMock, + }; +}); + +vi.mock("../runtime/session-meta.js", () => ({ + listAcpSessionEntries: (params: unknown) => hoisted.listAcpSessionEntriesMock(params), + readAcpSessionEntry: (params: unknown) => hoisted.readAcpSessionEntryMock(params), + upsertAcpSessionMeta: (params: unknown) => hoisted.upsertAcpSessionMetaMock(params), +})); + +vi.mock("../runtime/registry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + requireAcpRuntimeBackend: (backendId?: string) => + hoisted.requireAcpRuntimeBackendMock(backendId), + }; +}); + +const { AcpSessionManager } = await import("./manager.js"); + +const baseCfg = { + acp: { + enabled: true, + backend: "acpx", + dispatch: { enabled: true }, + }, +} as const; + +function createRuntime(): { + runtime: AcpRuntime; + ensureSession: ReturnType; + runTurn: ReturnType; + cancel: ReturnType; + close: ReturnType; + getCapabilities: ReturnType; + getStatus: ReturnType; + setMode: ReturnType; + setConfigOption: ReturnType; +} { + const ensureSession = vi.fn( + async (input: { sessionKey: string; agent: string; mode: "persistent" | "oneshot" }) => ({ + sessionKey: input.sessionKey, + backend: "acpx", + runtimeSessionName: `${input.sessionKey}:${input.mode}:runtime`, + }), + ); + const runTurn = vi.fn(async function* () { + yield { type: "done" as const }; + }); + const cancel = vi.fn(async () => {}); + const close = vi.fn(async () => {}); + const getCapabilities = vi.fn( + async (): Promise => ({ + controls: ["session/set_mode", "session/set_config_option", "session/status"], + }), + ); + const getStatus = vi.fn(async () => ({ + summary: "status=alive", + details: { status: "alive" }, + })); + const setMode = vi.fn(async () => {}); + const setConfigOption = vi.fn(async () => {}); + return { + runtime: { + ensureSession, + runTurn, + getCapabilities, + getStatus, + setMode, + setConfigOption, + cancel, + close, + }, + ensureSession, + runTurn, + cancel, + close, + getCapabilities, + getStatus, + setMode, + setConfigOption, + }; +} + +function readySessionMeta() { + return { + backend: "acpx", + agent: "codex", + runtimeSessionName: "runtime-1", + mode: "persistent" as const, + state: "idle" as const, + lastActivityAt: Date.now(), + }; +} + +function extractStatesFromUpserts(): SessionAcpMeta["state"][] { + const states: SessionAcpMeta["state"][] = []; + for (const [firstArg] of hoisted.upsertAcpSessionMetaMock.mock.calls) { + const payload = firstArg as { + mutate: ( + current: SessionAcpMeta | undefined, + entry: { acp?: SessionAcpMeta } | undefined, + ) => SessionAcpMeta | null | undefined; + }; + const current = readySessionMeta(); + const next = payload.mutate(current, { acp: current }); + if (next?.state) { + states.push(next.state); + } + } + return states; +} + +function extractRuntimeOptionsFromUpserts(): Array { + const options: Array = []; + for (const [firstArg] of hoisted.upsertAcpSessionMetaMock.mock.calls) { + const payload = firstArg as { + mutate: ( + current: SessionAcpMeta | undefined, + entry: { acp?: SessionAcpMeta } | undefined, + ) => SessionAcpMeta | null | undefined; + }; + const current = readySessionMeta(); + const next = payload.mutate(current, { acp: current }); + if (next) { + options.push(next.runtimeOptions); + } + } + return options; +} + +describe("AcpSessionManager", () => { + beforeEach(() => { + hoisted.listAcpSessionEntriesMock.mockReset().mockResolvedValue([]); + hoisted.readAcpSessionEntryMock.mockReset(); + hoisted.upsertAcpSessionMetaMock.mockReset().mockResolvedValue(null); + hoisted.requireAcpRuntimeBackendMock.mockReset(); + }); + + it("marks ACP-shaped sessions without metadata as stale", () => { + hoisted.readAcpSessionEntryMock.mockReturnValue(null); + const manager = new AcpSessionManager(); + + const resolved = manager.resolveSession({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + }); + + expect(resolved.kind).toBe("stale"); + if (resolved.kind !== "stale") { + return; + } + expect(resolved.error.code).toBe("ACP_SESSION_INIT_FAILED"); + expect(resolved.error.message).toContain("ACP metadata is missing"); + }); + + it("serializes concurrent turns for the same ACP session", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:session-1", + storeSessionKey: "agent:codex:acp:session-1", + acp: readySessionMeta(), + }); + + let inFlight = 0; + let maxInFlight = 0; + runtimeState.runTurn.mockImplementation(async function* (_input: { requestId: string }) { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + try { + await new Promise((resolve) => setTimeout(resolve, 10)); + yield { type: "done" }; + } finally { + inFlight -= 1; + } + }); + + const manager = new AcpSessionManager(); + const first = manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + text: "first", + mode: "prompt", + requestId: "r1", + }); + const second = manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + text: "second", + mode: "prompt", + requestId: "r2", + }); + await Promise.all([first, second]); + + expect(maxInFlight).toBe(1); + expect(runtimeState.runTurn).toHaveBeenCalledTimes(2); + }); + + it("runs turns for different ACP sessions in parallel", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => { + const sessionKey = (paramsUnknown as { sessionKey?: string }).sessionKey ?? ""; + return { + sessionKey, + storeSessionKey: sessionKey, + acp: { + ...readySessionMeta(), + runtimeSessionName: `runtime:${sessionKey}`, + }, + }; + }); + + let inFlight = 0; + let maxInFlight = 0; + runtimeState.runTurn.mockImplementation(async function* () { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + try { + await new Promise((resolve) => setTimeout(resolve, 15)); + yield { type: "done" as const }; + } finally { + inFlight -= 1; + } + }); + + const manager = new AcpSessionManager(); + await Promise.all([ + manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-a", + text: "first", + mode: "prompt", + requestId: "r1", + }), + manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-b", + text: "second", + mode: "prompt", + requestId: "r2", + }), + ]); + + expect(maxInFlight).toBe(2); + }); + + it("reuses runtime session handles for repeat turns in the same manager process", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:session-1", + storeSessionKey: "agent:codex:acp:session-1", + acp: readySessionMeta(), + }); + + const manager = new AcpSessionManager(); + await manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + text: "first", + mode: "prompt", + requestId: "r1", + }); + await manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + text: "second", + mode: "prompt", + requestId: "r2", + }); + + expect(runtimeState.ensureSession).toHaveBeenCalledTimes(1); + expect(runtimeState.runTurn).toHaveBeenCalledTimes(2); + }); + + it("rehydrates runtime handles after a manager restart", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:session-1", + storeSessionKey: "agent:codex:acp:session-1", + acp: readySessionMeta(), + }); + + const managerA = new AcpSessionManager(); + await managerA.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + text: "before restart", + mode: "prompt", + requestId: "r1", + }); + const managerB = new AcpSessionManager(); + await managerB.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + text: "after restart", + mode: "prompt", + requestId: "r2", + }); + + expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2); + }); + + it("enforces acp.maxConcurrentSessions when opening new runtime handles", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => { + const sessionKey = (paramsUnknown as { sessionKey?: string }).sessionKey ?? ""; + return { + sessionKey, + storeSessionKey: sessionKey, + acp: { + ...readySessionMeta(), + runtimeSessionName: `runtime:${sessionKey}`, + }, + }; + }); + const limitedCfg = { + acp: { + ...baseCfg.acp, + maxConcurrentSessions: 1, + }, + } as OpenClawConfig; + + const manager = new AcpSessionManager(); + await manager.runTurn({ + cfg: limitedCfg, + sessionKey: "agent:codex:acp:session-a", + text: "first", + mode: "prompt", + requestId: "r1", + }); + + await expect( + manager.runTurn({ + cfg: limitedCfg, + sessionKey: "agent:codex:acp:session-b", + text: "second", + mode: "prompt", + requestId: "r2", + }), + ).rejects.toMatchObject({ + code: "ACP_SESSION_INIT_FAILED", + message: expect.stringContaining("max concurrent sessions"), + }); + expect(runtimeState.ensureSession).toHaveBeenCalledTimes(1); + }); + + it("enforces acp.maxConcurrentSessions during initializeSession", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.upsertAcpSessionMetaMock.mockResolvedValue({ + sessionKey: "agent:codex:acp:session-a", + storeSessionKey: "agent:codex:acp:session-a", + acp: readySessionMeta(), + }); + const limitedCfg = { + acp: { + ...baseCfg.acp, + maxConcurrentSessions: 1, + }, + } as OpenClawConfig; + + const manager = new AcpSessionManager(); + await manager.initializeSession({ + cfg: limitedCfg, + sessionKey: "agent:codex:acp:session-a", + agent: "codex", + mode: "persistent", + }); + + await expect( + manager.initializeSession({ + cfg: limitedCfg, + sessionKey: "agent:codex:acp:session-b", + agent: "codex", + mode: "persistent", + }), + ).rejects.toMatchObject({ + code: "ACP_SESSION_INIT_FAILED", + message: expect.stringContaining("max concurrent sessions"), + }); + expect(runtimeState.ensureSession).toHaveBeenCalledTimes(1); + }); + + it("drops cached runtime handles when close tolerates backend-unavailable errors", async () => { + const runtimeState = createRuntime(); + runtimeState.close.mockRejectedValueOnce( + new AcpRuntimeError("ACP_BACKEND_UNAVAILABLE", "runtime temporarily unavailable"), + ); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => { + const sessionKey = (paramsUnknown as { sessionKey?: string }).sessionKey ?? ""; + return { + sessionKey, + storeSessionKey: sessionKey, + acp: { + ...readySessionMeta(), + runtimeSessionName: `runtime:${sessionKey}`, + }, + }; + }); + const limitedCfg = { + acp: { + ...baseCfg.acp, + maxConcurrentSessions: 1, + }, + } as OpenClawConfig; + + const manager = new AcpSessionManager(); + await manager.runTurn({ + cfg: limitedCfg, + sessionKey: "agent:codex:acp:session-a", + text: "first", + mode: "prompt", + requestId: "r1", + }); + + const closeResult = await manager.closeSession({ + cfg: limitedCfg, + sessionKey: "agent:codex:acp:session-a", + reason: "manual-close", + allowBackendUnavailable: true, + }); + expect(closeResult.runtimeClosed).toBe(false); + expect(closeResult.runtimeNotice).toContain("temporarily unavailable"); + + await expect( + manager.runTurn({ + cfg: limitedCfg, + sessionKey: "agent:codex:acp:session-b", + text: "second", + mode: "prompt", + requestId: "r2", + }), + ).resolves.toBeUndefined(); + expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2); + }); + + it("evicts idle cached runtimes before enforcing max concurrent limits", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-02-23T00:00:00.000Z")); + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => { + const sessionKey = (paramsUnknown as { sessionKey?: string }).sessionKey ?? ""; + return { + sessionKey, + storeSessionKey: sessionKey, + acp: { + ...readySessionMeta(), + runtimeSessionName: `runtime:${sessionKey}`, + }, + }; + }); + const cfg = { + acp: { + ...baseCfg.acp, + maxConcurrentSessions: 1, + runtime: { + ttlMinutes: 0.01, + }, + }, + } as OpenClawConfig; + + const manager = new AcpSessionManager(); + await manager.runTurn({ + cfg, + sessionKey: "agent:codex:acp:session-a", + text: "first", + mode: "prompt", + requestId: "r1", + }); + + vi.advanceTimersByTime(2_000); + await manager.runTurn({ + cfg, + sessionKey: "agent:codex:acp:session-b", + text: "second", + mode: "prompt", + requestId: "r2", + }); + + expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2); + expect(runtimeState.close).toHaveBeenCalledWith( + expect.objectContaining({ + reason: "idle-evicted", + handle: expect.objectContaining({ + sessionKey: "agent:codex:acp:session-a", + }), + }), + ); + } finally { + vi.useRealTimers(); + } + }); + + it("tracks ACP turn latency and error-code observability", async () => { + const runtimeState = createRuntime(); + runtimeState.runTurn.mockImplementation(async function* (input: { requestId: string }) { + if (input.requestId === "fail") { + throw new Error("runtime exploded"); + } + yield { type: "done" as const }; + }); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => { + const sessionKey = (paramsUnknown as { sessionKey?: string }).sessionKey ?? ""; + return { + sessionKey, + storeSessionKey: sessionKey, + acp: { + ...readySessionMeta(), + runtimeSessionName: `runtime:${sessionKey}`, + }, + }; + }); + + const manager = new AcpSessionManager(); + await manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + text: "ok", + mode: "prompt", + requestId: "ok", + }); + await expect( + manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + text: "boom", + mode: "prompt", + requestId: "fail", + }), + ).rejects.toMatchObject({ + code: "ACP_TURN_FAILED", + }); + + const snapshot = manager.getObservabilitySnapshot(baseCfg); + expect(snapshot.turns.completed).toBe(1); + expect(snapshot.turns.failed).toBe(1); + expect(snapshot.turns.active).toBe(0); + expect(snapshot.turns.queueDepth).toBe(0); + expect(snapshot.errorsByCode.ACP_TURN_FAILED).toBe(1); + }); + + it("rolls back ensured runtime sessions when metadata persistence fails", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.upsertAcpSessionMetaMock.mockRejectedValueOnce(new Error("disk full")); + + const manager = new AcpSessionManager(); + await expect( + manager.initializeSession({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + agent: "codex", + mode: "persistent", + }), + ).rejects.toThrow("disk full"); + expect(runtimeState.close).toHaveBeenCalledWith( + expect.objectContaining({ + reason: "init-meta-failed", + handle: expect.objectContaining({ + sessionKey: "agent:codex:acp:session-1", + }), + }), + ); + }); + + it("preempts an active turn on cancel and returns to idle state", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:session-1", + storeSessionKey: "agent:codex:acp:session-1", + acp: readySessionMeta(), + }); + + let enteredRun = false; + runtimeState.runTurn.mockImplementation(async function* (input: { signal?: AbortSignal }) { + enteredRun = true; + await new Promise((resolve) => { + if (input.signal?.aborted) { + resolve(); + return; + } + input.signal?.addEventListener("abort", () => resolve(), { once: true }); + }); + yield { type: "done" as const, stopReason: "cancel" }; + }); + + const manager = new AcpSessionManager(); + const runPromise = manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + text: "long task", + mode: "prompt", + requestId: "run-1", + }); + await vi.waitFor(() => { + expect(enteredRun).toBe(true); + }); + + await manager.cancelSession({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + reason: "manual-cancel", + }); + await runPromise; + + expect(runtimeState.cancel).toHaveBeenCalledTimes(1); + expect(runtimeState.cancel).toHaveBeenCalledWith( + expect.objectContaining({ + reason: "manual-cancel", + }), + ); + const states = extractStatesFromUpserts(); + expect(states).toContain("running"); + expect(states).toContain("idle"); + expect(states).not.toContain("error"); + }); + + it("cleans actor-tail bookkeeping after session turns complete", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => { + const sessionKey = (paramsUnknown as { sessionKey?: string }).sessionKey ?? ""; + return { + sessionKey, + storeSessionKey: sessionKey, + acp: { + ...readySessionMeta(), + runtimeSessionName: `runtime:${sessionKey}`, + }, + }; + }); + runtimeState.runTurn.mockImplementation(async function* () { + yield { type: "done" as const }; + }); + + const manager = new AcpSessionManager(); + await manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-a", + text: "first", + mode: "prompt", + requestId: "r1", + }); + await manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-b", + text: "second", + mode: "prompt", + requestId: "r2", + }); + + const internals = manager as unknown as { + actorTailBySession: Map>; + }; + expect(internals.actorTailBySession.size).toBe(0); + }); + + it("surfaces backend failures raised after a done event", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:session-1", + storeSessionKey: "agent:codex:acp:session-1", + acp: readySessionMeta(), + }); + runtimeState.runTurn.mockImplementation(async function* () { + yield { type: "done" as const }; + throw new Error("acpx exited with code 1"); + }); + + const manager = new AcpSessionManager(); + await expect( + manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + text: "do work", + mode: "prompt", + requestId: "run-1", + }), + ).rejects.toMatchObject({ + code: "ACP_TURN_FAILED", + message: "acpx exited with code 1", + }); + + const states = extractStatesFromUpserts(); + expect(states).toContain("running"); + expect(states).toContain("error"); + expect(states.at(-1)).toBe("error"); + }); + + it("persists runtime mode changes through setSessionRuntimeMode", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:session-1", + storeSessionKey: "agent:codex:acp:session-1", + acp: readySessionMeta(), + }); + + const manager = new AcpSessionManager(); + const options = await manager.setSessionRuntimeMode({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + runtimeMode: "plan", + }); + + expect(runtimeState.setMode).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "plan", + }), + ); + expect(options.runtimeMode).toBe("plan"); + expect(extractRuntimeOptionsFromUpserts().some((entry) => entry?.runtimeMode === "plan")).toBe( + true, + ); + }); + + it("reapplies persisted controls on next turn after runtime option updates", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + + let currentMeta: SessionAcpMeta = { + ...readySessionMeta(), + runtimeOptions: { + runtimeMode: "plan", + }, + }; + hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => { + const sessionKey = + (paramsUnknown as { sessionKey?: string }).sessionKey ?? "agent:codex:acp:session-1"; + return { + sessionKey, + storeSessionKey: sessionKey, + acp: currentMeta, + }; + }); + hoisted.upsertAcpSessionMetaMock.mockImplementation(async (paramsUnknown: unknown) => { + const params = paramsUnknown as { + mutate: ( + current: SessionAcpMeta | undefined, + entry: { acp?: SessionAcpMeta } | undefined, + ) => SessionAcpMeta | null | undefined; + }; + const next = params.mutate(currentMeta, { acp: currentMeta }); + if (next) { + currentMeta = next; + } + return { + sessionId: "session-1", + updatedAt: Date.now(), + acp: currentMeta, + }; + }); + + const manager = new AcpSessionManager(); + await manager.setSessionConfigOption({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + key: "model", + value: "openai-codex/gpt-5.3-codex", + }); + expect(runtimeState.setMode).not.toHaveBeenCalled(); + + await manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + text: "do work", + mode: "prompt", + requestId: "run-1", + }); + + expect(runtimeState.setMode).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "plan", + }), + ); + }); + + it("reconciles persisted ACP session identifiers from runtime status after a turn", async () => { + const runtimeState = createRuntime(); + runtimeState.ensureSession.mockResolvedValue({ + sessionKey: "agent:codex:acp:session-1", + backend: "acpx", + runtimeSessionName: "runtime-1", + backendSessionId: "acpx-stale", + agentSessionId: "agent-stale", + }); + runtimeState.getStatus.mockResolvedValue({ + summary: "status=alive", + backendSessionId: "acpx-fresh", + agentSessionId: "agent-fresh", + details: { status: "alive" }, + }); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + + let currentMeta: SessionAcpMeta = { + ...readySessionMeta(), + identity: { + state: "resolved", + source: "status", + acpxSessionId: "acpx-stale", + agentSessionId: "agent-stale", + lastUpdatedAt: Date.now(), + }, + }; + hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => { + const sessionKey = + (paramsUnknown as { sessionKey?: string }).sessionKey ?? "agent:codex:acp:session-1"; + return { + sessionKey, + storeSessionKey: sessionKey, + acp: currentMeta, + }; + }); + hoisted.upsertAcpSessionMetaMock.mockImplementation(async (paramsUnknown: unknown) => { + const params = paramsUnknown as { + mutate: ( + current: SessionAcpMeta | undefined, + entry: { acp?: SessionAcpMeta } | undefined, + ) => SessionAcpMeta | null | undefined; + }; + const next = params.mutate(currentMeta, { acp: currentMeta }); + if (next) { + currentMeta = next; + } + return { + sessionId: "session-1", + updatedAt: Date.now(), + acp: currentMeta, + }; + }); + + const manager = new AcpSessionManager(); + await manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + text: "do work", + mode: "prompt", + requestId: "run-1", + }); + + expect(runtimeState.getStatus).toHaveBeenCalledTimes(1); + expect(currentMeta.identity?.acpxSessionId).toBe("acpx-fresh"); + expect(currentMeta.identity?.agentSessionId).toBe("agent-fresh"); + }); + + it("reconciles pending ACP identities during startup scan", async () => { + const runtimeState = createRuntime(); + runtimeState.getStatus.mockResolvedValue({ + summary: "status=alive", + acpxRecordId: "acpx-record-1", + backendSessionId: "acpx-session-1", + agentSessionId: "agent-session-1", + details: { status: "alive" }, + }); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + + let currentMeta: SessionAcpMeta = { + ...readySessionMeta(), + identity: { + state: "pending", + source: "ensure", + acpxSessionId: "acpx-stale", + lastUpdatedAt: Date.now(), + }, + }; + const sessionKey = "agent:codex:acp:session-1"; + hoisted.listAcpSessionEntriesMock.mockResolvedValue([ + { + cfg: baseCfg, + storePath: "/tmp/sessions-acp.json", + sessionKey, + storeSessionKey: sessionKey, + entry: { + sessionId: "session-1", + updatedAt: Date.now(), + acp: currentMeta, + }, + acp: currentMeta, + }, + ]); + hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => { + const key = (paramsUnknown as { sessionKey?: string }).sessionKey ?? sessionKey; + return { + sessionKey: key, + storeSessionKey: key, + acp: currentMeta, + }; + }); + hoisted.upsertAcpSessionMetaMock.mockImplementation(async (paramsUnknown: unknown) => { + const params = paramsUnknown as { + mutate: ( + current: SessionAcpMeta | undefined, + entry: { acp?: SessionAcpMeta } | undefined, + ) => SessionAcpMeta | null | undefined; + }; + const next = params.mutate(currentMeta, { acp: currentMeta }); + if (next) { + currentMeta = next; + } + return { + sessionId: "session-1", + updatedAt: Date.now(), + acp: currentMeta, + }; + }); + + const manager = new AcpSessionManager(); + const result = await manager.reconcilePendingSessionIdentities({ cfg: baseCfg }); + + expect(result).toEqual({ checked: 1, resolved: 1, failed: 0 }); + expect(currentMeta.identity?.state).toBe("resolved"); + expect(currentMeta.identity?.acpxRecordId).toBe("acpx-record-1"); + expect(currentMeta.identity?.acpxSessionId).toBe("acpx-session-1"); + expect(currentMeta.identity?.agentSessionId).toBe("agent-session-1"); + }); + + it("skips startup identity reconciliation for already resolved sessions", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + const sessionKey = "agent:codex:acp:session-1"; + const resolvedMeta: SessionAcpMeta = { + ...readySessionMeta(), + identity: { + state: "resolved", + source: "status", + acpxSessionId: "acpx-sid-1", + agentSessionId: "agent-sid-1", + lastUpdatedAt: Date.now(), + }, + }; + hoisted.listAcpSessionEntriesMock.mockResolvedValue([ + { + cfg: baseCfg, + storePath: "/tmp/sessions-acp.json", + sessionKey, + storeSessionKey: sessionKey, + entry: { + sessionId: "session-1", + updatedAt: Date.now(), + acp: resolvedMeta, + }, + acp: resolvedMeta, + }, + ]); + + const manager = new AcpSessionManager(); + const result = await manager.reconcilePendingSessionIdentities({ cfg: baseCfg }); + + expect(result).toEqual({ checked: 0, resolved: 0, failed: 0 }); + expect(runtimeState.getStatus).not.toHaveBeenCalled(); + expect(runtimeState.ensureSession).not.toHaveBeenCalled(); + }); + + it("preserves existing ACP session identifiers when ensure returns none", async () => { + const runtimeState = createRuntime(); + runtimeState.ensureSession.mockResolvedValue({ + sessionKey: "agent:codex:acp:session-1", + backend: "acpx", + runtimeSessionName: "runtime-2", + }); + runtimeState.getStatus.mockResolvedValue({ + summary: "status=alive", + details: { status: "alive" }, + }); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:session-1", + storeSessionKey: "agent:codex:acp:session-1", + acp: { + ...readySessionMeta(), + identity: { + state: "resolved", + source: "status", + acpxSessionId: "acpx-stable", + agentSessionId: "agent-stable", + lastUpdatedAt: Date.now(), + }, + }, + }); + + const manager = new AcpSessionManager(); + const status = await manager.getSessionStatus({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + }); + + expect(status.identity?.acpxSessionId).toBe("acpx-stable"); + expect(status.identity?.agentSessionId).toBe("agent-stable"); + }); + + it("applies persisted runtime options before running turns", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:session-1", + storeSessionKey: "agent:codex:acp:session-1", + acp: { + ...readySessionMeta(), + runtimeOptions: { + runtimeMode: "plan", + model: "openai-codex/gpt-5.3-codex", + permissionProfile: "strict", + timeoutSeconds: 120, + }, + }, + }); + + const manager = new AcpSessionManager(); + await manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + text: "do work", + mode: "prompt", + requestId: "run-1", + }); + + expect(runtimeState.setMode).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "plan", + }), + ); + expect(runtimeState.setConfigOption).toHaveBeenCalledWith( + expect.objectContaining({ + key: "model", + value: "openai-codex/gpt-5.3-codex", + }), + ); + expect(runtimeState.setConfigOption).toHaveBeenCalledWith( + expect.objectContaining({ + key: "approval_policy", + value: "strict", + }), + ); + expect(runtimeState.setConfigOption).toHaveBeenCalledWith( + expect.objectContaining({ + key: "timeout", + value: "120", + }), + ); + }); + + it("returns unsupported-control error when backend does not support set_config_option", async () => { + const runtimeState = createRuntime(); + const unsupportedRuntime: AcpRuntime = { + ensureSession: runtimeState.ensureSession as AcpRuntime["ensureSession"], + runTurn: runtimeState.runTurn as AcpRuntime["runTurn"], + getCapabilities: vi.fn(async () => ({ controls: [] })), + cancel: runtimeState.cancel as AcpRuntime["cancel"], + close: runtimeState.close as AcpRuntime["close"], + }; + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: unsupportedRuntime, + }); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:session-1", + storeSessionKey: "agent:codex:acp:session-1", + acp: readySessionMeta(), + }); + + const manager = new AcpSessionManager(); + await expect( + manager.setSessionConfigOption({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + key: "model", + value: "gpt-5.3-codex", + }), + ).rejects.toMatchObject({ + code: "ACP_BACKEND_UNSUPPORTED_CONTROL", + }); + }); + + it("rejects invalid runtime option values before backend controls run", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:session-1", + storeSessionKey: "agent:codex:acp:session-1", + acp: readySessionMeta(), + }); + + const manager = new AcpSessionManager(); + await expect( + manager.setSessionConfigOption({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + key: "timeout", + value: "not-a-number", + }), + ).rejects.toMatchObject({ + code: "ACP_INVALID_RUNTIME_OPTION", + }); + expect(runtimeState.setConfigOption).not.toHaveBeenCalled(); + + await expect( + manager.updateSessionRuntimeOptions({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + patch: { cwd: "relative/path" }, + }), + ).rejects.toMatchObject({ + code: "ACP_INVALID_RUNTIME_OPTION", + }); + }); + + it("can close and clear metadata when backend is unavailable", async () => { + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:session-1", + storeSessionKey: "agent:codex:acp:session-1", + acp: readySessionMeta(), + }); + hoisted.requireAcpRuntimeBackendMock.mockImplementation(() => { + throw new AcpRuntimeError( + "ACP_BACKEND_MISSING", + "ACP runtime backend is not configured. Install and enable the acpx runtime plugin.", + ); + }); + + const manager = new AcpSessionManager(); + const result = await manager.closeSession({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + reason: "manual-close", + allowBackendUnavailable: true, + clearMeta: true, + }); + + expect(result.runtimeClosed).toBe(false); + expect(result.runtimeNotice).toContain("not configured"); + expect(result.metaCleared).toBe(true); + expect(hoisted.upsertAcpSessionMetaMock).toHaveBeenCalled(); + }); + + it("surfaces metadata clear errors during closeSession", async () => { + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:session-1", + storeSessionKey: "agent:codex:acp:session-1", + acp: readySessionMeta(), + }); + hoisted.requireAcpRuntimeBackendMock.mockImplementation(() => { + throw new AcpRuntimeError( + "ACP_BACKEND_MISSING", + "ACP runtime backend is not configured. Install and enable the acpx runtime plugin.", + ); + }); + hoisted.upsertAcpSessionMetaMock.mockRejectedValueOnce(new Error("disk locked")); + + const manager = new AcpSessionManager(); + await expect( + manager.closeSession({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + reason: "manual-close", + allowBackendUnavailable: true, + clearMeta: true, + }), + ).rejects.toThrow("disk locked"); + }); +}); diff --git a/src/acp/control-plane/manager.ts b/src/acp/control-plane/manager.ts new file mode 100644 index 000000000000..e15bf1ec9b77 --- /dev/null +++ b/src/acp/control-plane/manager.ts @@ -0,0 +1,29 @@ +import { AcpSessionManager } from "./manager.core.js"; + +export { AcpSessionManager } from "./manager.core.js"; +export type { + AcpCloseSessionInput, + AcpCloseSessionResult, + AcpInitializeSessionInput, + AcpManagerObservabilitySnapshot, + AcpRunTurnInput, + AcpSessionResolution, + AcpSessionRuntimeOptions, + AcpSessionStatus, + AcpStartupIdentityReconcileResult, +} from "./manager.types.js"; + +let ACP_SESSION_MANAGER_SINGLETON: AcpSessionManager | null = null; + +export function getAcpSessionManager(): AcpSessionManager { + if (!ACP_SESSION_MANAGER_SINGLETON) { + ACP_SESSION_MANAGER_SINGLETON = new AcpSessionManager(); + } + return ACP_SESSION_MANAGER_SINGLETON; +} + +export const __testing = { + resetAcpSessionManagerForTests() { + ACP_SESSION_MANAGER_SINGLETON = null; + }, +}; diff --git a/src/acp/control-plane/manager.types.ts b/src/acp/control-plane/manager.types.ts new file mode 100644 index 000000000000..7337e8063f9c --- /dev/null +++ b/src/acp/control-plane/manager.types.ts @@ -0,0 +1,141 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { + SessionAcpIdentity, + AcpSessionRuntimeOptions, + SessionAcpMeta, + SessionEntry, +} from "../../config/sessions/types.js"; +import type { AcpRuntimeError } from "../runtime/errors.js"; +import { requireAcpRuntimeBackend } from "../runtime/registry.js"; +import { + listAcpSessionEntries, + readAcpSessionEntry, + upsertAcpSessionMeta, +} from "../runtime/session-meta.js"; +import type { + AcpRuntime, + AcpRuntimeCapabilities, + AcpRuntimeEvent, + AcpRuntimeHandle, + AcpRuntimePromptMode, + AcpRuntimeSessionMode, + AcpRuntimeStatus, +} from "../runtime/types.js"; + +export type AcpSessionResolution = + | { + kind: "none"; + sessionKey: string; + } + | { + kind: "stale"; + sessionKey: string; + error: AcpRuntimeError; + } + | { + kind: "ready"; + sessionKey: string; + meta: SessionAcpMeta; + }; + +export type AcpInitializeSessionInput = { + cfg: OpenClawConfig; + sessionKey: string; + agent: string; + mode: AcpRuntimeSessionMode; + cwd?: string; + backendId?: string; +}; + +export type AcpRunTurnInput = { + cfg: OpenClawConfig; + sessionKey: string; + text: string; + mode: AcpRuntimePromptMode; + requestId: string; + signal?: AbortSignal; + onEvent?: (event: AcpRuntimeEvent) => Promise | void; +}; + +export type AcpCloseSessionInput = { + cfg: OpenClawConfig; + sessionKey: string; + reason: string; + clearMeta?: boolean; + allowBackendUnavailable?: boolean; + requireAcpSession?: boolean; +}; + +export type AcpCloseSessionResult = { + runtimeClosed: boolean; + runtimeNotice?: string; + metaCleared: boolean; +}; + +export type AcpSessionStatus = { + sessionKey: string; + backend: string; + agent: string; + identity?: SessionAcpIdentity; + state: SessionAcpMeta["state"]; + mode: AcpRuntimeSessionMode; + runtimeOptions: AcpSessionRuntimeOptions; + capabilities: AcpRuntimeCapabilities; + runtimeStatus?: AcpRuntimeStatus; + lastActivityAt: number; + lastError?: string; +}; + +export type AcpManagerObservabilitySnapshot = { + runtimeCache: { + activeSessions: number; + idleTtlMs: number; + evictedTotal: number; + lastEvictedAt?: number; + }; + turns: { + active: number; + queueDepth: number; + completed: number; + failed: number; + averageLatencyMs: number; + maxLatencyMs: number; + }; + errorsByCode: Record; +}; + +export type AcpStartupIdentityReconcileResult = { + checked: number; + resolved: number; + failed: number; +}; + +export type ActiveTurnState = { + runtime: AcpRuntime; + handle: AcpRuntimeHandle; + abortController: AbortController; + cancelPromise?: Promise; +}; + +export type TurnLatencyStats = { + completed: number; + failed: number; + totalMs: number; + maxMs: number; +}; + +export type AcpSessionManagerDeps = { + listAcpSessions: typeof listAcpSessionEntries; + readSessionEntry: typeof readAcpSessionEntry; + upsertSessionMeta: typeof upsertAcpSessionMeta; + requireRuntimeBackend: typeof requireAcpRuntimeBackend; +}; + +export const DEFAULT_DEPS: AcpSessionManagerDeps = { + listAcpSessions: listAcpSessionEntries, + readSessionEntry: readAcpSessionEntry, + upsertSessionMeta: upsertAcpSessionMeta, + requireRuntimeBackend: requireAcpRuntimeBackend, +}; + +export type { AcpSessionRuntimeOptions, SessionAcpMeta, SessionEntry }; diff --git a/src/acp/control-plane/manager.utils.ts b/src/acp/control-plane/manager.utils.ts new file mode 100644 index 000000000000..3b6b2dacc45d --- /dev/null +++ b/src/acp/control-plane/manager.utils.ts @@ -0,0 +1,64 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { SessionAcpMeta } from "../../config/sessions/types.js"; +import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; +import { ACP_ERROR_CODES, AcpRuntimeError } from "../runtime/errors.js"; + +export function resolveAcpAgentFromSessionKey(sessionKey: string, fallback = "main"): string { + const parsed = parseAgentSessionKey(sessionKey); + return normalizeAgentId(parsed?.agentId ?? fallback); +} + +export function resolveMissingMetaError(sessionKey: string): AcpRuntimeError { + return new AcpRuntimeError( + "ACP_SESSION_INIT_FAILED", + `ACP metadata is missing for ${sessionKey}. Recreate this ACP session with /acp spawn and rebind the thread.`, + ); +} + +export function normalizeSessionKey(sessionKey: string): string { + return sessionKey.trim(); +} + +export function normalizeActorKey(sessionKey: string): string { + return sessionKey.trim().toLowerCase(); +} + +export function normalizeAcpErrorCode(code: string | undefined): AcpRuntimeError["code"] { + if (!code) { + return "ACP_TURN_FAILED"; + } + const normalized = code.trim().toUpperCase(); + for (const allowed of ACP_ERROR_CODES) { + if (allowed === normalized) { + return allowed; + } + } + return "ACP_TURN_FAILED"; +} + +export function createUnsupportedControlError(params: { + backend: string; + control: string; +}): AcpRuntimeError { + return new AcpRuntimeError( + "ACP_BACKEND_UNSUPPORTED_CONTROL", + `ACP backend "${params.backend}" does not support ${params.control}.`, + ); +} + +export function resolveRuntimeIdleTtlMs(cfg: OpenClawConfig): number { + const ttlMinutes = cfg.acp?.runtime?.ttlMinutes; + if (typeof ttlMinutes !== "number" || !Number.isFinite(ttlMinutes) || ttlMinutes <= 0) { + return 0; + } + return Math.round(ttlMinutes * 60 * 1000); +} + +export function hasLegacyAcpIdentityProjection(meta: SessionAcpMeta): boolean { + const raw = meta as Record; + return ( + Object.hasOwn(raw, "backendSessionId") || + Object.hasOwn(raw, "agentSessionId") || + Object.hasOwn(raw, "sessionIdsProvisional") + ); +} diff --git a/src/acp/control-plane/runtime-cache.test.ts b/src/acp/control-plane/runtime-cache.test.ts new file mode 100644 index 000000000000..ea0aa2f7124b --- /dev/null +++ b/src/acp/control-plane/runtime-cache.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AcpRuntime } from "../runtime/types.js"; +import type { AcpRuntimeHandle } from "../runtime/types.js"; +import type { CachedRuntimeState } from "./runtime-cache.js"; +import { RuntimeCache } from "./runtime-cache.js"; + +function mockState(sessionKey: string): CachedRuntimeState { + const runtime = { + ensureSession: vi.fn(async () => ({ + sessionKey, + backend: "acpx", + runtimeSessionName: `runtime:${sessionKey}`, + })), + runTurn: vi.fn(async function* () { + yield { type: "done" as const }; + }), + cancel: vi.fn(async () => {}), + close: vi.fn(async () => {}), + } as unknown as AcpRuntime; + return { + runtime, + handle: { + sessionKey, + backend: "acpx", + runtimeSessionName: `runtime:${sessionKey}`, + } as AcpRuntimeHandle, + backend: "acpx", + agent: "codex", + mode: "persistent", + }; +} + +describe("RuntimeCache", () => { + it("tracks idle candidates with touch-aware lookups", () => { + vi.useFakeTimers(); + try { + const cache = new RuntimeCache(); + const actor = "agent:codex:acp:s1"; + cache.set(actor, mockState(actor), { now: 1_000 }); + + expect(cache.collectIdleCandidates({ maxIdleMs: 1_000, now: 1_999 })).toHaveLength(0); + expect(cache.collectIdleCandidates({ maxIdleMs: 1_000, now: 2_000 })).toHaveLength(1); + + cache.get(actor, { now: 2_500 }); + expect(cache.collectIdleCandidates({ maxIdleMs: 1_000, now: 3_200 })).toHaveLength(0); + expect(cache.collectIdleCandidates({ maxIdleMs: 1_000, now: 3_500 })).toHaveLength(1); + } finally { + vi.useRealTimers(); + } + }); + + it("returns snapshot entries with idle durations", () => { + const cache = new RuntimeCache(); + cache.set("a", mockState("a"), { now: 10 }); + cache.set("b", mockState("b"), { now: 100 }); + + const snapshot = cache.snapshot({ now: 1_100 }); + const byActor = new Map(snapshot.map((entry) => [entry.actorKey, entry])); + expect(byActor.get("a")?.idleMs).toBe(1_090); + expect(byActor.get("b")?.idleMs).toBe(1_000); + }); +}); diff --git a/src/acp/control-plane/runtime-cache.ts b/src/acp/control-plane/runtime-cache.ts new file mode 100644 index 000000000000..ca00cc1331bb --- /dev/null +++ b/src/acp/control-plane/runtime-cache.ts @@ -0,0 +1,99 @@ +import type { AcpRuntime, AcpRuntimeHandle, AcpRuntimeSessionMode } from "../runtime/types.js"; + +export type CachedRuntimeState = { + runtime: AcpRuntime; + handle: AcpRuntimeHandle; + backend: string; + agent: string; + mode: AcpRuntimeSessionMode; + cwd?: string; + appliedControlSignature?: string; +}; + +type RuntimeCacheEntry = { + state: CachedRuntimeState; + lastTouchedAt: number; +}; + +export type CachedRuntimeSnapshot = { + actorKey: string; + state: CachedRuntimeState; + lastTouchedAt: number; + idleMs: number; +}; + +export class RuntimeCache { + private readonly cache = new Map(); + + size(): number { + return this.cache.size; + } + + has(actorKey: string): boolean { + return this.cache.has(actorKey); + } + + get( + actorKey: string, + params: { + touch?: boolean; + now?: number; + } = {}, + ): CachedRuntimeState | null { + const entry = this.cache.get(actorKey); + if (!entry) { + return null; + } + if (params.touch !== false) { + entry.lastTouchedAt = params.now ?? Date.now(); + } + return entry.state; + } + + peek(actorKey: string): CachedRuntimeState | null { + return this.get(actorKey, { touch: false }); + } + + getLastTouchedAt(actorKey: string): number | null { + return this.cache.get(actorKey)?.lastTouchedAt ?? null; + } + + set( + actorKey: string, + state: CachedRuntimeState, + params: { + now?: number; + } = {}, + ): void { + this.cache.set(actorKey, { + state, + lastTouchedAt: params.now ?? Date.now(), + }); + } + + clear(actorKey: string): void { + this.cache.delete(actorKey); + } + + snapshot(params: { now?: number } = {}): CachedRuntimeSnapshot[] { + const now = params.now ?? Date.now(); + const entries: CachedRuntimeSnapshot[] = []; + for (const [actorKey, entry] of this.cache.entries()) { + entries.push({ + actorKey, + state: entry.state, + lastTouchedAt: entry.lastTouchedAt, + idleMs: Math.max(0, now - entry.lastTouchedAt), + }); + } + return entries; + } + + collectIdleCandidates(params: { maxIdleMs: number; now?: number }): CachedRuntimeSnapshot[] { + if (!Number.isFinite(params.maxIdleMs) || params.maxIdleMs <= 0) { + return []; + } + const now = params.now ?? Date.now(); + return this.snapshot({ now }).filter((entry) => entry.idleMs >= params.maxIdleMs); + } +} diff --git a/src/acp/control-plane/runtime-options.ts b/src/acp/control-plane/runtime-options.ts new file mode 100644 index 000000000000..5f3b77bf1c8e --- /dev/null +++ b/src/acp/control-plane/runtime-options.ts @@ -0,0 +1,349 @@ +import { isAbsolute } from "node:path"; +import type { AcpSessionRuntimeOptions, SessionAcpMeta } from "../../config/sessions/types.js"; +import { AcpRuntimeError } from "../runtime/errors.js"; + +const MAX_RUNTIME_MODE_LENGTH = 64; +const MAX_MODEL_LENGTH = 200; +const MAX_PERMISSION_PROFILE_LENGTH = 80; +const MAX_CWD_LENGTH = 4096; +const MIN_TIMEOUT_SECONDS = 1; +const MAX_TIMEOUT_SECONDS = 24 * 60 * 60; +const MAX_BACKEND_OPTION_KEY_LENGTH = 64; +const MAX_BACKEND_OPTION_VALUE_LENGTH = 512; +const MAX_BACKEND_EXTRAS = 32; + +const SAFE_OPTION_KEY_RE = /^[a-z0-9][a-z0-9._:-]*$/i; + +function failInvalidOption(message: string): never { + throw new AcpRuntimeError("ACP_INVALID_RUNTIME_OPTION", message); +} + +function validateNoControlChars(value: string, field: string): string { + for (let i = 0; i < value.length; i += 1) { + const code = value.charCodeAt(i); + if (code < 32 || code === 127) { + failInvalidOption(`${field} must not include control characters.`); + } + } + return value; +} + +function validateBoundedText(params: { value: unknown; field: string; maxLength: number }): string { + const normalized = normalizeText(params.value); + if (!normalized) { + failInvalidOption(`${params.field} must not be empty.`); + } + if (normalized.length > params.maxLength) { + failInvalidOption(`${params.field} must be at most ${params.maxLength} characters.`); + } + return validateNoControlChars(normalized, params.field); +} + +function validateBackendOptionKey(rawKey: unknown): string { + const key = validateBoundedText({ + value: rawKey, + field: "ACP config key", + maxLength: MAX_BACKEND_OPTION_KEY_LENGTH, + }); + if (!SAFE_OPTION_KEY_RE.test(key)) { + failInvalidOption( + "ACP config key must use letters, numbers, dots, colons, underscores, or dashes.", + ); + } + return key; +} + +function validateBackendOptionValue(rawValue: unknown): string { + return validateBoundedText({ + value: rawValue, + field: "ACP config value", + maxLength: MAX_BACKEND_OPTION_VALUE_LENGTH, + }); +} + +export function validateRuntimeModeInput(rawMode: unknown): string { + return validateBoundedText({ + value: rawMode, + field: "Runtime mode", + maxLength: MAX_RUNTIME_MODE_LENGTH, + }); +} + +export function validateRuntimeModelInput(rawModel: unknown): string { + return validateBoundedText({ + value: rawModel, + field: "Model id", + maxLength: MAX_MODEL_LENGTH, + }); +} + +export function validateRuntimePermissionProfileInput(rawProfile: unknown): string { + return validateBoundedText({ + value: rawProfile, + field: "Permission profile", + maxLength: MAX_PERMISSION_PROFILE_LENGTH, + }); +} + +export function validateRuntimeCwdInput(rawCwd: unknown): string { + const cwd = validateBoundedText({ + value: rawCwd, + field: "Working directory", + maxLength: MAX_CWD_LENGTH, + }); + if (!isAbsolute(cwd)) { + failInvalidOption(`Working directory must be an absolute path. Received "${cwd}".`); + } + return cwd; +} + +export function validateRuntimeTimeoutSecondsInput(rawTimeout: unknown): number { + if (typeof rawTimeout !== "number" || !Number.isFinite(rawTimeout)) { + failInvalidOption("Timeout must be a positive integer in seconds."); + } + const timeout = Math.round(rawTimeout); + if (timeout < MIN_TIMEOUT_SECONDS || timeout > MAX_TIMEOUT_SECONDS) { + failInvalidOption( + `Timeout must be between ${MIN_TIMEOUT_SECONDS} and ${MAX_TIMEOUT_SECONDS} seconds.`, + ); + } + return timeout; +} + +export function parseRuntimeTimeoutSecondsInput(rawTimeout: unknown): number { + const normalized = normalizeText(rawTimeout); + if (!normalized || !/^\d+$/.test(normalized)) { + failInvalidOption("Timeout must be a positive integer in seconds."); + } + return validateRuntimeTimeoutSecondsInput(Number.parseInt(normalized, 10)); +} + +export function validateRuntimeConfigOptionInput( + rawKey: unknown, + rawValue: unknown, +): { + key: string; + value: string; +} { + return { + key: validateBackendOptionKey(rawKey), + value: validateBackendOptionValue(rawValue), + }; +} + +export function validateRuntimeOptionPatch( + patch: Partial | undefined, +): Partial { + if (!patch) { + return {}; + } + const rawPatch = patch as Record; + const allowedKeys = new Set([ + "runtimeMode", + "model", + "cwd", + "permissionProfile", + "timeoutSeconds", + "backendExtras", + ]); + for (const key of Object.keys(rawPatch)) { + if (!allowedKeys.has(key)) { + failInvalidOption(`Unknown runtime option "${key}".`); + } + } + + const next: Partial = {}; + if (Object.hasOwn(rawPatch, "runtimeMode")) { + if (rawPatch.runtimeMode === undefined) { + next.runtimeMode = undefined; + } else { + next.runtimeMode = validateRuntimeModeInput(rawPatch.runtimeMode); + } + } + if (Object.hasOwn(rawPatch, "model")) { + if (rawPatch.model === undefined) { + next.model = undefined; + } else { + next.model = validateRuntimeModelInput(rawPatch.model); + } + } + if (Object.hasOwn(rawPatch, "cwd")) { + if (rawPatch.cwd === undefined) { + next.cwd = undefined; + } else { + next.cwd = validateRuntimeCwdInput(rawPatch.cwd); + } + } + if (Object.hasOwn(rawPatch, "permissionProfile")) { + if (rawPatch.permissionProfile === undefined) { + next.permissionProfile = undefined; + } else { + next.permissionProfile = validateRuntimePermissionProfileInput(rawPatch.permissionProfile); + } + } + if (Object.hasOwn(rawPatch, "timeoutSeconds")) { + if (rawPatch.timeoutSeconds === undefined) { + next.timeoutSeconds = undefined; + } else { + next.timeoutSeconds = validateRuntimeTimeoutSecondsInput(rawPatch.timeoutSeconds); + } + } + if (Object.hasOwn(rawPatch, "backendExtras")) { + const rawExtras = rawPatch.backendExtras; + if (rawExtras === undefined) { + next.backendExtras = undefined; + } else if (!rawExtras || typeof rawExtras !== "object" || Array.isArray(rawExtras)) { + failInvalidOption("Backend extras must be a key/value object."); + } else { + const entries = Object.entries(rawExtras); + if (entries.length > MAX_BACKEND_EXTRAS) { + failInvalidOption(`Backend extras must include at most ${MAX_BACKEND_EXTRAS} entries.`); + } + const extras: Record = {}; + for (const [entryKey, entryValue] of entries) { + const { key, value } = validateRuntimeConfigOptionInput(entryKey, entryValue); + extras[key] = value; + } + next.backendExtras = Object.keys(extras).length > 0 ? extras : undefined; + } + } + + return next; +} + +export function normalizeText(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed || undefined; +} + +export function normalizeRuntimeOptions( + options: AcpSessionRuntimeOptions | undefined, +): AcpSessionRuntimeOptions { + const runtimeMode = normalizeText(options?.runtimeMode); + const model = normalizeText(options?.model); + const cwd = normalizeText(options?.cwd); + const permissionProfile = normalizeText(options?.permissionProfile); + let timeoutSeconds: number | undefined; + if (typeof options?.timeoutSeconds === "number" && Number.isFinite(options.timeoutSeconds)) { + const rounded = Math.round(options.timeoutSeconds); + if (rounded > 0) { + timeoutSeconds = rounded; + } + } + const backendExtrasEntries = Object.entries(options?.backendExtras ?? {}) + .map(([key, value]) => [normalizeText(key), normalizeText(value)] as const) + .filter(([key, value]) => Boolean(key && value)) as Array<[string, string]>; + const backendExtras = + backendExtrasEntries.length > 0 ? Object.fromEntries(backendExtrasEntries) : undefined; + return { + ...(runtimeMode ? { runtimeMode } : {}), + ...(model ? { model } : {}), + ...(cwd ? { cwd } : {}), + ...(permissionProfile ? { permissionProfile } : {}), + ...(typeof timeoutSeconds === "number" ? { timeoutSeconds } : {}), + ...(backendExtras ? { backendExtras } : {}), + }; +} + +export function mergeRuntimeOptions(params: { + current?: AcpSessionRuntimeOptions; + patch?: Partial; +}): AcpSessionRuntimeOptions { + const current = normalizeRuntimeOptions(params.current); + const patch = normalizeRuntimeOptions(validateRuntimeOptionPatch(params.patch)); + const mergedExtras = { + ...current.backendExtras, + ...patch.backendExtras, + }; + return normalizeRuntimeOptions({ + ...current, + ...patch, + ...(Object.keys(mergedExtras).length > 0 ? { backendExtras: mergedExtras } : {}), + }); +} + +export function resolveRuntimeOptionsFromMeta(meta: SessionAcpMeta): AcpSessionRuntimeOptions { + const normalized = normalizeRuntimeOptions(meta.runtimeOptions); + if (normalized.cwd || !meta.cwd) { + return normalized; + } + return normalizeRuntimeOptions({ + ...normalized, + cwd: meta.cwd, + }); +} + +export function runtimeOptionsEqual( + a: AcpSessionRuntimeOptions | undefined, + b: AcpSessionRuntimeOptions | undefined, +): boolean { + return JSON.stringify(normalizeRuntimeOptions(a)) === JSON.stringify(normalizeRuntimeOptions(b)); +} + +export function buildRuntimeControlSignature(options: AcpSessionRuntimeOptions): string { + const normalized = normalizeRuntimeOptions(options); + const extras = Object.entries(normalized.backendExtras ?? {}).toSorted(([a], [b]) => + a.localeCompare(b), + ); + return JSON.stringify({ + runtimeMode: normalized.runtimeMode ?? null, + model: normalized.model ?? null, + permissionProfile: normalized.permissionProfile ?? null, + timeoutSeconds: normalized.timeoutSeconds ?? null, + backendExtras: extras, + }); +} + +export function buildRuntimeConfigOptionPairs( + options: AcpSessionRuntimeOptions, +): Array<[string, string]> { + const normalized = normalizeRuntimeOptions(options); + const pairs = new Map(); + if (normalized.model) { + pairs.set("model", normalized.model); + } + if (normalized.permissionProfile) { + pairs.set("approval_policy", normalized.permissionProfile); + } + if (typeof normalized.timeoutSeconds === "number") { + pairs.set("timeout", String(normalized.timeoutSeconds)); + } + for (const [key, value] of Object.entries(normalized.backendExtras ?? {})) { + if (!pairs.has(key)) { + pairs.set(key, value); + } + } + return [...pairs.entries()]; +} + +export function inferRuntimeOptionPatchFromConfigOption( + key: string, + value: string, +): Partial { + const validated = validateRuntimeConfigOptionInput(key, value); + const normalizedKey = validated.key.toLowerCase(); + if (normalizedKey === "model") { + return { model: validateRuntimeModelInput(validated.value) }; + } + if ( + normalizedKey === "approval_policy" || + normalizedKey === "permission_profile" || + normalizedKey === "permissions" + ) { + return { permissionProfile: validateRuntimePermissionProfileInput(validated.value) }; + } + if (normalizedKey === "timeout" || normalizedKey === "timeout_seconds") { + return { timeoutSeconds: parseRuntimeTimeoutSecondsInput(validated.value) }; + } + if (normalizedKey === "cwd") { + return { cwd: validateRuntimeCwdInput(validated.value) }; + } + return { + backendExtras: { + [validated.key]: validated.value, + }, + }; +} diff --git a/src/acp/control-plane/session-actor-queue.ts b/src/acp/control-plane/session-actor-queue.ts new file mode 100644 index 000000000000..67dd6119a3bc --- /dev/null +++ b/src/acp/control-plane/session-actor-queue.ts @@ -0,0 +1,53 @@ +export class SessionActorQueue { + private readonly tailBySession = new Map>(); + private readonly pendingBySession = new Map(); + + getTailMapForTesting(): Map> { + return this.tailBySession; + } + + getTotalPendingCount(): number { + let total = 0; + for (const count of this.pendingBySession.values()) { + total += count; + } + return total; + } + + getPendingCountForSession(actorKey: string): number { + return this.pendingBySession.get(actorKey) ?? 0; + } + + async run(actorKey: string, op: () => Promise): Promise { + const previous = this.tailBySession.get(actorKey) ?? Promise.resolve(); + this.pendingBySession.set(actorKey, (this.pendingBySession.get(actorKey) ?? 0) + 1); + let release: () => void = () => {}; + const marker = new Promise((resolve) => { + release = resolve; + }); + const queuedTail = previous + .catch(() => { + // Keep actor queue alive after an operation failure. + }) + .then(() => marker); + this.tailBySession.set(actorKey, queuedTail); + + await previous.catch(() => { + // Previous failures should not block newer commands. + }); + try { + return await op(); + } finally { + const pending = (this.pendingBySession.get(actorKey) ?? 1) - 1; + if (pending <= 0) { + this.pendingBySession.delete(actorKey); + } else { + this.pendingBySession.set(actorKey, pending); + } + release(); + if (this.tailBySession.get(actorKey) === queuedTail) { + this.tailBySession.delete(actorKey); + } + } + } +} diff --git a/src/acp/control-plane/spawn.ts b/src/acp/control-plane/spawn.ts new file mode 100644 index 000000000000..5d9790cb5e78 --- /dev/null +++ b/src/acp/control-plane/spawn.ts @@ -0,0 +1,77 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { callGateway } from "../../gateway/call.js"; +import { logVerbose } from "../../globals.js"; +import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js"; +import { getAcpSessionManager } from "./manager.js"; + +export type AcpSpawnRuntimeCloseHandle = { + runtime: { + close: (params: { + handle: { sessionKey: string; backend: string; runtimeSessionName: string }; + reason: string; + }) => Promise; + }; + handle: { sessionKey: string; backend: string; runtimeSessionName: string }; +}; + +export async function cleanupFailedAcpSpawn(params: { + cfg: OpenClawConfig; + sessionKey: string; + shouldDeleteSession: boolean; + deleteTranscript: boolean; + runtimeCloseHandle?: AcpSpawnRuntimeCloseHandle; +}): Promise { + if (params.runtimeCloseHandle) { + await params.runtimeCloseHandle.runtime + .close({ + handle: params.runtimeCloseHandle.handle, + reason: "spawn-failed", + }) + .catch((err) => { + logVerbose( + `acp-spawn: runtime cleanup close failed for ${params.sessionKey}: ${String(err)}`, + ); + }); + } + + const acpManager = getAcpSessionManager(); + await acpManager + .closeSession({ + cfg: params.cfg, + sessionKey: params.sessionKey, + reason: "spawn-failed", + allowBackendUnavailable: true, + requireAcpSession: false, + }) + .catch((err) => { + logVerbose( + `acp-spawn: manager cleanup close failed for ${params.sessionKey}: ${String(err)}`, + ); + }); + + await getSessionBindingService() + .unbind({ + targetSessionKey: params.sessionKey, + reason: "spawn-failed", + }) + .catch((err) => { + logVerbose( + `acp-spawn: binding cleanup unbind failed for ${params.sessionKey}: ${String(err)}`, + ); + }); + + if (!params.shouldDeleteSession) { + return; + } + await callGateway({ + method: "sessions.delete", + params: { + key: params.sessionKey, + deleteTranscript: params.deleteTranscript, + emitLifecycleHooks: false, + }, + timeoutMs: 10_000, + }).catch(() => { + // Best-effort cleanup only. + }); +} diff --git a/src/acp/policy.test.ts b/src/acp/policy.test.ts new file mode 100644 index 000000000000..3a623373a7ba --- /dev/null +++ b/src/acp/policy.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + isAcpAgentAllowedByPolicy, + isAcpDispatchEnabledByPolicy, + isAcpEnabledByPolicy, + resolveAcpAgentPolicyError, + resolveAcpDispatchPolicyError, + resolveAcpDispatchPolicyMessage, + resolveAcpDispatchPolicyState, +} from "./policy.js"; + +describe("acp policy", () => { + it("treats ACP as enabled by default", () => { + const cfg = {} satisfies OpenClawConfig; + expect(isAcpEnabledByPolicy(cfg)).toBe(true); + expect(isAcpDispatchEnabledByPolicy(cfg)).toBe(false); + expect(resolveAcpDispatchPolicyState(cfg)).toBe("dispatch_disabled"); + }); + + it("reports ACP disabled state when acp.enabled is false", () => { + const cfg = { + acp: { + enabled: false, + }, + } satisfies OpenClawConfig; + expect(isAcpEnabledByPolicy(cfg)).toBe(false); + expect(resolveAcpDispatchPolicyState(cfg)).toBe("acp_disabled"); + expect(resolveAcpDispatchPolicyMessage(cfg)).toContain("acp.enabled=false"); + expect(resolveAcpDispatchPolicyError(cfg)?.code).toBe("ACP_DISPATCH_DISABLED"); + }); + + it("reports dispatch-disabled state when dispatch gate is false", () => { + const cfg = { + acp: { + enabled: true, + dispatch: { + enabled: false, + }, + }, + } satisfies OpenClawConfig; + expect(isAcpDispatchEnabledByPolicy(cfg)).toBe(false); + expect(resolveAcpDispatchPolicyState(cfg)).toBe("dispatch_disabled"); + expect(resolveAcpDispatchPolicyMessage(cfg)).toContain("acp.dispatch.enabled=false"); + }); + + it("applies allowlist filtering for ACP agents", () => { + const cfg = { + acp: { + allowedAgents: ["Codex", "claude-code"], + }, + } satisfies OpenClawConfig; + expect(isAcpAgentAllowedByPolicy(cfg, "codex")).toBe(true); + expect(isAcpAgentAllowedByPolicy(cfg, "claude-code")).toBe(true); + expect(isAcpAgentAllowedByPolicy(cfg, "gemini")).toBe(false); + expect(resolveAcpAgentPolicyError(cfg, "gemini")?.code).toBe("ACP_SESSION_INIT_FAILED"); + expect(resolveAcpAgentPolicyError(cfg, "codex")).toBeNull(); + }); +}); diff --git a/src/acp/policy.ts b/src/acp/policy.ts new file mode 100644 index 000000000000..8297783b62d9 --- /dev/null +++ b/src/acp/policy.ts @@ -0,0 +1,69 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import { AcpRuntimeError } from "./runtime/errors.js"; + +const ACP_DISABLED_MESSAGE = "ACP is disabled by policy (`acp.enabled=false`)."; +const ACP_DISPATCH_DISABLED_MESSAGE = + "ACP dispatch is disabled by policy (`acp.dispatch.enabled=false`)."; + +export type AcpDispatchPolicyState = "enabled" | "acp_disabled" | "dispatch_disabled"; + +export function isAcpEnabledByPolicy(cfg: OpenClawConfig): boolean { + return cfg.acp?.enabled !== false; +} + +export function resolveAcpDispatchPolicyState(cfg: OpenClawConfig): AcpDispatchPolicyState { + if (!isAcpEnabledByPolicy(cfg)) { + return "acp_disabled"; + } + if (cfg.acp?.dispatch?.enabled !== true) { + return "dispatch_disabled"; + } + return "enabled"; +} + +export function isAcpDispatchEnabledByPolicy(cfg: OpenClawConfig): boolean { + return resolveAcpDispatchPolicyState(cfg) === "enabled"; +} + +export function resolveAcpDispatchPolicyMessage(cfg: OpenClawConfig): string | null { + const state = resolveAcpDispatchPolicyState(cfg); + if (state === "acp_disabled") { + return ACP_DISABLED_MESSAGE; + } + if (state === "dispatch_disabled") { + return ACP_DISPATCH_DISABLED_MESSAGE; + } + return null; +} + +export function resolveAcpDispatchPolicyError(cfg: OpenClawConfig): AcpRuntimeError | null { + const message = resolveAcpDispatchPolicyMessage(cfg); + if (!message) { + return null; + } + return new AcpRuntimeError("ACP_DISPATCH_DISABLED", message); +} + +export function isAcpAgentAllowedByPolicy(cfg: OpenClawConfig, agentId: string): boolean { + const allowed = (cfg.acp?.allowedAgents ?? []) + .map((entry) => normalizeAgentId(entry)) + .filter(Boolean); + if (allowed.length === 0) { + return true; + } + return allowed.includes(normalizeAgentId(agentId)); +} + +export function resolveAcpAgentPolicyError( + cfg: OpenClawConfig, + agentId: string, +): AcpRuntimeError | null { + if (isAcpAgentAllowedByPolicy(cfg, agentId)) { + return null; + } + return new AcpRuntimeError( + "ACP_SESSION_INIT_FAILED", + `ACP agent "${normalizeAgentId(agentId)}" is not allowed by policy.`, + ); +} diff --git a/src/acp/runtime/adapter-contract.testkit.ts b/src/acp/runtime/adapter-contract.testkit.ts new file mode 100644 index 000000000000..3c715b4777fb --- /dev/null +++ b/src/acp/runtime/adapter-contract.testkit.ts @@ -0,0 +1,114 @@ +import { randomUUID } from "node:crypto"; +import { expect } from "vitest"; +import { toAcpRuntimeError } from "./errors.js"; +import type { AcpRuntime, AcpRuntimeEvent } from "./types.js"; + +export type AcpRuntimeAdapterContractParams = { + createRuntime: () => Promise | AcpRuntime; + agentId?: string; + successPrompt?: string; + errorPrompt?: string; + assertSuccessEvents?: (events: AcpRuntimeEvent[]) => void | Promise; + assertErrorOutcome?: (params: { + events: AcpRuntimeEvent[]; + thrown: unknown; + }) => void | Promise; +}; + +export async function runAcpRuntimeAdapterContract( + params: AcpRuntimeAdapterContractParams, +): Promise { + const runtime = await params.createRuntime(); + const sessionKey = `agent:${params.agentId ?? "codex"}:acp:contract-${randomUUID()}`; + const agent = params.agentId ?? "codex"; + + const handle = await runtime.ensureSession({ + sessionKey, + agent, + mode: "persistent", + }); + expect(handle.sessionKey).toBe(sessionKey); + expect(handle.backend.trim()).not.toHaveLength(0); + expect(handle.runtimeSessionName.trim()).not.toHaveLength(0); + + const successEvents: AcpRuntimeEvent[] = []; + for await (const event of runtime.runTurn({ + handle, + text: params.successPrompt ?? "contract-success", + mode: "prompt", + requestId: `contract-success-${randomUUID()}`, + })) { + successEvents.push(event); + } + expect( + successEvents.some( + (event) => + event.type === "done" || + event.type === "text_delta" || + event.type === "status" || + event.type === "tool_call", + ), + ).toBe(true); + await params.assertSuccessEvents?.(successEvents); + + if (runtime.getStatus) { + const status = await runtime.getStatus({ handle }); + expect(status).toBeDefined(); + expect(typeof status).toBe("object"); + } + if (runtime.setMode) { + await runtime.setMode({ + handle, + mode: "contract", + }); + } + if (runtime.setConfigOption) { + await runtime.setConfigOption({ + handle, + key: "contract_key", + value: "contract_value", + }); + } + + let errorThrown: unknown = null; + const errorEvents: AcpRuntimeEvent[] = []; + const errorPrompt = params.errorPrompt?.trim(); + if (errorPrompt) { + try { + for await (const event of runtime.runTurn({ + handle, + text: errorPrompt, + mode: "prompt", + requestId: `contract-error-${randomUUID()}`, + })) { + errorEvents.push(event); + } + } catch (error) { + errorThrown = error; + } + const sawErrorEvent = errorEvents.some((event) => event.type === "error"); + expect(Boolean(errorThrown) || sawErrorEvent).toBe(true); + if (errorThrown) { + const acpError = toAcpRuntimeError({ + error: errorThrown, + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "ACP runtime contract expected an error turn failure.", + }); + expect(acpError.code.length).toBeGreaterThan(0); + expect(acpError.message.length).toBeGreaterThan(0); + } + } + await params.assertErrorOutcome?.({ + events: errorEvents, + thrown: errorThrown, + }); + + await runtime.cancel({ + handle, + reason: "contract-cancel", + }); + await runtime.close({ + handle, + reason: "contract-close", + }); +} diff --git a/src/acp/runtime/error-text.test.ts b/src/acp/runtime/error-text.test.ts new file mode 100644 index 000000000000..b58cd3ef4fbb --- /dev/null +++ b/src/acp/runtime/error-text.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { formatAcpRuntimeErrorText } from "./error-text.js"; +import { AcpRuntimeError } from "./errors.js"; + +describe("formatAcpRuntimeErrorText", () => { + it("adds actionable next steps for known ACP runtime error codes", () => { + const text = formatAcpRuntimeErrorText( + new AcpRuntimeError("ACP_BACKEND_MISSING", "backend missing"), + ); + expect(text).toContain("ACP error (ACP_BACKEND_MISSING): backend missing"); + expect(text).toContain("next:"); + }); + + it("returns consistent ACP error envelope for runtime failures", () => { + const text = formatAcpRuntimeErrorText(new AcpRuntimeError("ACP_TURN_FAILED", "turn failed")); + expect(text).toContain("ACP error (ACP_TURN_FAILED): turn failed"); + expect(text).toContain("next:"); + }); +}); diff --git a/src/acp/runtime/error-text.ts b/src/acp/runtime/error-text.ts new file mode 100644 index 000000000000..e4901e1c8694 --- /dev/null +++ b/src/acp/runtime/error-text.ts @@ -0,0 +1,45 @@ +import { type AcpRuntimeErrorCode, AcpRuntimeError, toAcpRuntimeError } from "./errors.js"; + +function resolveAcpRuntimeErrorNextStep(error: AcpRuntimeError): string | undefined { + if (error.code === "ACP_BACKEND_MISSING" || error.code === "ACP_BACKEND_UNAVAILABLE") { + return "Run `/acp doctor`, install/enable the backend plugin, then retry."; + } + if (error.code === "ACP_DISPATCH_DISABLED") { + return "Enable `acp.dispatch.enabled=true` to allow thread-message ACP turns."; + } + if (error.code === "ACP_SESSION_INIT_FAILED") { + return "If this session is stale, recreate it with `/acp spawn` and rebind the thread."; + } + if (error.code === "ACP_INVALID_RUNTIME_OPTION") { + return "Use `/acp status` to inspect options and pass valid values."; + } + if (error.code === "ACP_BACKEND_UNSUPPORTED_CONTROL") { + return "This backend does not support that control; use a supported command."; + } + if (error.code === "ACP_TURN_FAILED") { + return "Retry, or use `/acp cancel` and send the message again."; + } + return undefined; +} + +export function formatAcpRuntimeErrorText(error: AcpRuntimeError): string { + const next = resolveAcpRuntimeErrorNextStep(error); + if (!next) { + return `ACP error (${error.code}): ${error.message}`; + } + return `ACP error (${error.code}): ${error.message}\nnext: ${next}`; +} + +export function toAcpRuntimeErrorText(params: { + error: unknown; + fallbackCode: AcpRuntimeErrorCode; + fallbackMessage: string; +}): string { + return formatAcpRuntimeErrorText( + toAcpRuntimeError({ + error: params.error, + fallbackCode: params.fallbackCode, + fallbackMessage: params.fallbackMessage, + }), + ); +} diff --git a/src/acp/runtime/errors.test.ts b/src/acp/runtime/errors.test.ts new file mode 100644 index 000000000000..10ba3667d840 --- /dev/null +++ b/src/acp/runtime/errors.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { AcpRuntimeError, withAcpRuntimeErrorBoundary } from "./errors.js"; + +describe("withAcpRuntimeErrorBoundary", () => { + it("wraps generic errors with fallback code and source message", async () => { + await expect( + withAcpRuntimeErrorBoundary({ + run: async () => { + throw new Error("boom"); + }, + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "fallback", + }), + ).rejects.toMatchObject({ + name: "AcpRuntimeError", + code: "ACP_TURN_FAILED", + message: "boom", + }); + }); + + it("passes through existing ACP runtime errors", async () => { + const existing = new AcpRuntimeError("ACP_BACKEND_MISSING", "backend missing"); + await expect( + withAcpRuntimeErrorBoundary({ + run: async () => { + throw existing; + }, + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "fallback", + }), + ).rejects.toBe(existing); + }); +}); diff --git a/src/acp/runtime/errors.ts b/src/acp/runtime/errors.ts new file mode 100644 index 000000000000..0ac56251f8eb --- /dev/null +++ b/src/acp/runtime/errors.ts @@ -0,0 +1,61 @@ +export const ACP_ERROR_CODES = [ + "ACP_BACKEND_MISSING", + "ACP_BACKEND_UNAVAILABLE", + "ACP_BACKEND_UNSUPPORTED_CONTROL", + "ACP_DISPATCH_DISABLED", + "ACP_INVALID_RUNTIME_OPTION", + "ACP_SESSION_INIT_FAILED", + "ACP_TURN_FAILED", +] as const; + +export type AcpRuntimeErrorCode = (typeof ACP_ERROR_CODES)[number]; + +export class AcpRuntimeError extends Error { + readonly code: AcpRuntimeErrorCode; + override readonly cause?: unknown; + + constructor(code: AcpRuntimeErrorCode, message: string, options?: { cause?: unknown }) { + super(message); + this.name = "AcpRuntimeError"; + this.code = code; + this.cause = options?.cause; + } +} + +export function isAcpRuntimeError(value: unknown): value is AcpRuntimeError { + return value instanceof AcpRuntimeError; +} + +export function toAcpRuntimeError(params: { + error: unknown; + fallbackCode: AcpRuntimeErrorCode; + fallbackMessage: string; +}): AcpRuntimeError { + if (params.error instanceof AcpRuntimeError) { + return params.error; + } + if (params.error instanceof Error) { + return new AcpRuntimeError(params.fallbackCode, params.error.message, { + cause: params.error, + }); + } + return new AcpRuntimeError(params.fallbackCode, params.fallbackMessage, { + cause: params.error, + }); +} + +export async function withAcpRuntimeErrorBoundary(params: { + run: () => Promise; + fallbackCode: AcpRuntimeErrorCode; + fallbackMessage: string; +}): Promise { + try { + return await params.run(); + } catch (error) { + throw toAcpRuntimeError({ + error, + fallbackCode: params.fallbackCode, + fallbackMessage: params.fallbackMessage, + }); + } +} diff --git a/src/acp/runtime/registry.test.ts b/src/acp/runtime/registry.test.ts new file mode 100644 index 000000000000..fab6a1b51e73 --- /dev/null +++ b/src/acp/runtime/registry.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AcpRuntimeError } from "./errors.js"; +import { + __testing, + getAcpRuntimeBackend, + registerAcpRuntimeBackend, + requireAcpRuntimeBackend, + unregisterAcpRuntimeBackend, +} from "./registry.js"; +import type { AcpRuntime } from "./types.js"; + +function createRuntimeStub(): AcpRuntime { + return { + ensureSession: vi.fn(async (input) => ({ + sessionKey: input.sessionKey, + backend: "stub", + runtimeSessionName: `${input.sessionKey}:runtime`, + })), + runTurn: vi.fn(async function* () { + // no-op stream + }), + cancel: vi.fn(async () => {}), + close: vi.fn(async () => {}), + }; +} + +describe("acp runtime registry", () => { + beforeEach(() => { + __testing.resetAcpRuntimeBackendsForTests(); + }); + + it("registers and resolves backends by id", () => { + const runtime = createRuntimeStub(); + registerAcpRuntimeBackend({ id: "acpx", runtime }); + + const backend = getAcpRuntimeBackend("acpx"); + expect(backend?.id).toBe("acpx"); + expect(backend?.runtime).toBe(runtime); + }); + + it("prefers a healthy backend when resolving without explicit id", () => { + const unhealthyRuntime = createRuntimeStub(); + const healthyRuntime = createRuntimeStub(); + + registerAcpRuntimeBackend({ + id: "unhealthy", + runtime: unhealthyRuntime, + healthy: () => false, + }); + registerAcpRuntimeBackend({ + id: "healthy", + runtime: healthyRuntime, + healthy: () => true, + }); + + const backend = getAcpRuntimeBackend(); + expect(backend?.id).toBe("healthy"); + }); + + it("throws a typed missing-backend error when no backend is registered", () => { + expect(() => requireAcpRuntimeBackend()).toThrowError(AcpRuntimeError); + expect(() => requireAcpRuntimeBackend()).toThrowError(/ACP runtime backend is not configured/i); + }); + + it("throws a typed unavailable error when the requested backend is unhealthy", () => { + registerAcpRuntimeBackend({ + id: "acpx", + runtime: createRuntimeStub(), + healthy: () => false, + }); + + try { + requireAcpRuntimeBackend("acpx"); + throw new Error("expected requireAcpRuntimeBackend to throw"); + } catch (err) { + expect(err).toBeInstanceOf(AcpRuntimeError); + expect((err as AcpRuntimeError).code).toBe("ACP_BACKEND_UNAVAILABLE"); + } + }); + + it("unregisters a backend by id", () => { + registerAcpRuntimeBackend({ id: "acpx", runtime: createRuntimeStub() }); + unregisterAcpRuntimeBackend("acpx"); + expect(getAcpRuntimeBackend("acpx")).toBeNull(); + }); + + it("keeps backend state on a global registry for cross-loader access", () => { + const runtime = createRuntimeStub(); + const sharedState = __testing.getAcpRuntimeRegistryGlobalStateForTests(); + + sharedState.backendsById.set("acpx", { + id: "acpx", + runtime, + }); + + const backend = getAcpRuntimeBackend("acpx"); + expect(backend?.runtime).toBe(runtime); + }); +}); diff --git a/src/acp/runtime/registry.ts b/src/acp/runtime/registry.ts new file mode 100644 index 000000000000..4c0a3d73cd07 --- /dev/null +++ b/src/acp/runtime/registry.ts @@ -0,0 +1,118 @@ +import { AcpRuntimeError } from "./errors.js"; +import type { AcpRuntime } from "./types.js"; + +export type AcpRuntimeBackend = { + id: string; + runtime: AcpRuntime; + healthy?: () => boolean; +}; + +type AcpRuntimeRegistryGlobalState = { + backendsById: Map; +}; + +const ACP_RUNTIME_REGISTRY_STATE_KEY = Symbol.for("openclaw.acpRuntimeRegistryState"); + +function createAcpRuntimeRegistryGlobalState(): AcpRuntimeRegistryGlobalState { + return { + backendsById: new Map(), + }; +} + +function resolveAcpRuntimeRegistryGlobalState(): AcpRuntimeRegistryGlobalState { + const runtimeGlobal = globalThis as typeof globalThis & { + [ACP_RUNTIME_REGISTRY_STATE_KEY]?: AcpRuntimeRegistryGlobalState; + }; + if (!runtimeGlobal[ACP_RUNTIME_REGISTRY_STATE_KEY]) { + runtimeGlobal[ACP_RUNTIME_REGISTRY_STATE_KEY] = createAcpRuntimeRegistryGlobalState(); + } + return runtimeGlobal[ACP_RUNTIME_REGISTRY_STATE_KEY]; +} + +const ACP_BACKENDS_BY_ID = resolveAcpRuntimeRegistryGlobalState().backendsById; + +function normalizeBackendId(id: string | undefined): string { + return id?.trim().toLowerCase() || ""; +} + +function isBackendHealthy(backend: AcpRuntimeBackend): boolean { + if (!backend.healthy) { + return true; + } + try { + return backend.healthy(); + } catch { + return false; + } +} + +export function registerAcpRuntimeBackend(backend: AcpRuntimeBackend): void { + const id = normalizeBackendId(backend.id); + if (!id) { + throw new Error("ACP runtime backend id is required"); + } + if (!backend.runtime) { + throw new Error(`ACP runtime backend "${id}" is missing runtime implementation`); + } + ACP_BACKENDS_BY_ID.set(id, { + ...backend, + id, + }); +} + +export function unregisterAcpRuntimeBackend(id: string): void { + const normalized = normalizeBackendId(id); + if (!normalized) { + return; + } + ACP_BACKENDS_BY_ID.delete(normalized); +} + +export function getAcpRuntimeBackend(id?: string): AcpRuntimeBackend | null { + const normalized = normalizeBackendId(id); + if (normalized) { + return ACP_BACKENDS_BY_ID.get(normalized) ?? null; + } + if (ACP_BACKENDS_BY_ID.size === 0) { + return null; + } + for (const backend of ACP_BACKENDS_BY_ID.values()) { + if (isBackendHealthy(backend)) { + return backend; + } + } + return ACP_BACKENDS_BY_ID.values().next().value ?? null; +} + +export function requireAcpRuntimeBackend(id?: string): AcpRuntimeBackend { + const normalized = normalizeBackendId(id); + const backend = getAcpRuntimeBackend(normalized || undefined); + if (!backend) { + throw new AcpRuntimeError( + "ACP_BACKEND_MISSING", + "ACP runtime backend is not configured. Install and enable the acpx runtime plugin.", + ); + } + if (!isBackendHealthy(backend)) { + throw new AcpRuntimeError( + "ACP_BACKEND_UNAVAILABLE", + "ACP runtime backend is currently unavailable. Try again in a moment.", + ); + } + if (normalized && backend.id !== normalized) { + throw new AcpRuntimeError( + "ACP_BACKEND_MISSING", + `ACP runtime backend "${normalized}" is not registered.`, + ); + } + return backend; +} + +export const __testing = { + resetAcpRuntimeBackendsForTests() { + ACP_BACKENDS_BY_ID.clear(); + }, + getAcpRuntimeRegistryGlobalStateForTests() { + return resolveAcpRuntimeRegistryGlobalState(); + }, +}; diff --git a/src/acp/runtime/session-identifiers.test.ts b/src/acp/runtime/session-identifiers.test.ts new file mode 100644 index 000000000000..fe7b0d6c2bcd --- /dev/null +++ b/src/acp/runtime/session-identifiers.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { + resolveAcpSessionCwd, + resolveAcpSessionIdentifierLinesFromIdentity, + resolveAcpThreadSessionDetailLines, +} from "./session-identifiers.js"; + +describe("session identifier helpers", () => { + it("hides unresolved identifiers from thread intro details while pending", () => { + const lines = resolveAcpThreadSessionDetailLines({ + sessionKey: "agent:codex:acp:pending-1", + meta: { + backend: "acpx", + agent: "codex", + runtimeSessionName: "runtime-1", + identity: { + state: "pending", + source: "ensure", + lastUpdatedAt: Date.now(), + acpxSessionId: "acpx-123", + agentSessionId: "inner-123", + }, + mode: "persistent", + state: "idle", + lastActivityAt: Date.now(), + }, + }); + + expect(lines).toEqual([]); + }); + + it("adds a Codex resume hint when agent identity is resolved", () => { + const lines = resolveAcpThreadSessionDetailLines({ + sessionKey: "agent:codex:acp:resolved-1", + meta: { + backend: "acpx", + agent: "codex", + runtimeSessionName: "runtime-1", + identity: { + state: "resolved", + source: "status", + lastUpdatedAt: Date.now(), + acpxSessionId: "acpx-123", + agentSessionId: "inner-123", + }, + mode: "persistent", + state: "idle", + lastActivityAt: Date.now(), + }, + }); + + expect(lines).toContain("agent session id: inner-123"); + expect(lines).toContain("acpx session id: acpx-123"); + expect(lines).toContain( + "resume in Codex CLI: `codex resume inner-123` (continues this conversation).", + ); + }); + + it("shows pending identity text for status rendering", () => { + const lines = resolveAcpSessionIdentifierLinesFromIdentity({ + backend: "acpx", + mode: "status", + identity: { + state: "pending", + source: "status", + lastUpdatedAt: Date.now(), + agentSessionId: "inner-123", + }, + }); + + expect(lines).toEqual(["session ids: pending (available after the first reply)"]); + }); + + it("prefers runtimeOptions.cwd over legacy meta.cwd", () => { + const cwd = resolveAcpSessionCwd({ + backend: "acpx", + agent: "codex", + runtimeSessionName: "runtime-1", + mode: "persistent", + runtimeOptions: { + cwd: "/repo/new", + }, + cwd: "/repo/old", + state: "idle", + lastActivityAt: Date.now(), + }); + expect(cwd).toBe("/repo/new"); + }); +}); diff --git a/src/acp/runtime/session-identifiers.ts b/src/acp/runtime/session-identifiers.ts new file mode 100644 index 000000000000..d342d8b02eb9 --- /dev/null +++ b/src/acp/runtime/session-identifiers.ts @@ -0,0 +1,131 @@ +import type { SessionAcpIdentity, SessionAcpMeta } from "../../config/sessions/types.js"; +import { isSessionIdentityPending, resolveSessionIdentityFromMeta } from "./session-identity.js"; + +export const ACP_SESSION_IDENTITY_RENDERER_VERSION = "v1"; +export type AcpSessionIdentifierRenderMode = "status" | "thread"; + +type SessionResumeHintResolver = (params: { agentSessionId: string }) => string; + +const ACP_AGENT_RESUME_HINT_BY_KEY = new Map([ + [ + "codex", + ({ agentSessionId }) => + `resume in Codex CLI: \`codex resume ${agentSessionId}\` (continues this conversation).`, + ], + [ + "openai-codex", + ({ agentSessionId }) => + `resume in Codex CLI: \`codex resume ${agentSessionId}\` (continues this conversation).`, + ], + [ + "codex-cli", + ({ agentSessionId }) => + `resume in Codex CLI: \`codex resume ${agentSessionId}\` (continues this conversation).`, + ], +]); + +function normalizeText(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed || undefined; +} + +function normalizeAgentHintKey(value: unknown): string | undefined { + const normalized = normalizeText(value); + if (!normalized) { + return undefined; + } + return normalized.toLowerCase().replace(/[\s_]+/g, "-"); +} + +function resolveAcpAgentResumeHintLine(params: { + agentId?: string; + agentSessionId?: string; +}): string | undefined { + const agentSessionId = normalizeText(params.agentSessionId); + const agentKey = normalizeAgentHintKey(params.agentId); + if (!agentSessionId || !agentKey) { + return undefined; + } + const resolver = ACP_AGENT_RESUME_HINT_BY_KEY.get(agentKey); + return resolver ? resolver({ agentSessionId }) : undefined; +} + +export function resolveAcpSessionIdentifierLines(params: { + sessionKey: string; + meta?: SessionAcpMeta; +}): string[] { + const backend = normalizeText(params.meta?.backend) ?? "backend"; + const identity = resolveSessionIdentityFromMeta(params.meta); + return resolveAcpSessionIdentifierLinesFromIdentity({ + backend, + identity, + mode: "status", + }); +} + +export function resolveAcpSessionIdentifierLinesFromIdentity(params: { + backend: string; + identity?: SessionAcpIdentity; + mode?: AcpSessionIdentifierRenderMode; +}): string[] { + const backend = normalizeText(params.backend) ?? "backend"; + const mode = params.mode ?? "status"; + const identity = params.identity; + const agentSessionId = normalizeText(identity?.agentSessionId); + const acpxSessionId = normalizeText(identity?.acpxSessionId); + const acpxRecordId = normalizeText(identity?.acpxRecordId); + const hasIdentifier = Boolean(agentSessionId || acpxSessionId || acpxRecordId); + if (isSessionIdentityPending(identity) && hasIdentifier) { + if (mode === "status") { + return ["session ids: pending (available after the first reply)"]; + } + return []; + } + const lines: string[] = []; + if (agentSessionId) { + lines.push(`agent session id: ${agentSessionId}`); + } + if (acpxSessionId) { + lines.push(`${backend} session id: ${acpxSessionId}`); + } + if (acpxRecordId) { + lines.push(`${backend} record id: ${acpxRecordId}`); + } + return lines; +} + +export function resolveAcpSessionCwd(meta?: SessionAcpMeta): string | undefined { + const runtimeCwd = normalizeText(meta?.runtimeOptions?.cwd); + if (runtimeCwd) { + return runtimeCwd; + } + return normalizeText(meta?.cwd); +} + +export function resolveAcpThreadSessionDetailLines(params: { + sessionKey: string; + meta?: SessionAcpMeta; +}): string[] { + const meta = params.meta; + const identity = resolveSessionIdentityFromMeta(meta); + const backend = normalizeText(meta?.backend) ?? "backend"; + const lines = resolveAcpSessionIdentifierLinesFromIdentity({ + backend, + identity, + mode: "thread", + }); + if (lines.length === 0) { + return lines; + } + const hint = resolveAcpAgentResumeHintLine({ + agentId: meta?.agent, + agentSessionId: identity?.agentSessionId, + }); + if (hint) { + lines.push(hint); + } + return lines; +} diff --git a/src/acp/runtime/session-identity.ts b/src/acp/runtime/session-identity.ts new file mode 100644 index 000000000000..066a3cb71e56 --- /dev/null +++ b/src/acp/runtime/session-identity.ts @@ -0,0 +1,210 @@ +import type { + SessionAcpIdentity, + SessionAcpIdentitySource, + SessionAcpMeta, +} from "../../config/sessions/types.js"; +import type { AcpRuntimeHandle, AcpRuntimeStatus } from "./types.js"; + +function normalizeText(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed || undefined; +} + +function normalizeIdentityState(value: unknown): SessionAcpIdentity["state"] | undefined { + if (value !== "pending" && value !== "resolved") { + return undefined; + } + return value; +} + +function normalizeIdentitySource(value: unknown): SessionAcpIdentitySource | undefined { + if (value !== "ensure" && value !== "status" && value !== "event") { + return undefined; + } + return value; +} + +function normalizeIdentity( + identity: SessionAcpIdentity | undefined, +): SessionAcpIdentity | undefined { + if (!identity) { + return undefined; + } + const state = normalizeIdentityState(identity.state); + const source = normalizeIdentitySource(identity.source); + const acpxRecordId = normalizeText(identity.acpxRecordId); + const acpxSessionId = normalizeText(identity.acpxSessionId); + const agentSessionId = normalizeText(identity.agentSessionId); + const lastUpdatedAt = + typeof identity.lastUpdatedAt === "number" && Number.isFinite(identity.lastUpdatedAt) + ? identity.lastUpdatedAt + : undefined; + const hasAnyId = Boolean(acpxRecordId || acpxSessionId || agentSessionId); + if (!state && !source && !hasAnyId && lastUpdatedAt === undefined) { + return undefined; + } + const resolved = Boolean(acpxSessionId || agentSessionId); + const normalizedState = state ?? (resolved ? "resolved" : "pending"); + return { + state: normalizedState, + ...(acpxRecordId ? { acpxRecordId } : {}), + ...(acpxSessionId ? { acpxSessionId } : {}), + ...(agentSessionId ? { agentSessionId } : {}), + source: source ?? "status", + lastUpdatedAt: lastUpdatedAt ?? Date.now(), + }; +} + +export function resolveSessionIdentityFromMeta( + meta: SessionAcpMeta | undefined, +): SessionAcpIdentity | undefined { + if (!meta) { + return undefined; + } + return normalizeIdentity(meta.identity); +} + +export function identityHasStableSessionId(identity: SessionAcpIdentity | undefined): boolean { + return Boolean(identity?.acpxSessionId || identity?.agentSessionId); +} + +export function isSessionIdentityPending(identity: SessionAcpIdentity | undefined): boolean { + if (!identity) { + return true; + } + return identity.state === "pending"; +} + +export function identityEquals( + left: SessionAcpIdentity | undefined, + right: SessionAcpIdentity | undefined, +): boolean { + const a = normalizeIdentity(left); + const b = normalizeIdentity(right); + if (!a && !b) { + return true; + } + if (!a || !b) { + return false; + } + return ( + a.state === b.state && + a.acpxRecordId === b.acpxRecordId && + a.acpxSessionId === b.acpxSessionId && + a.agentSessionId === b.agentSessionId && + a.source === b.source + ); +} + +export function mergeSessionIdentity(params: { + current: SessionAcpIdentity | undefined; + incoming: SessionAcpIdentity | undefined; + now: number; +}): SessionAcpIdentity | undefined { + const current = normalizeIdentity(params.current); + const incoming = normalizeIdentity(params.incoming); + if (!current) { + if (!incoming) { + return undefined; + } + return { ...incoming, lastUpdatedAt: params.now }; + } + if (!incoming) { + return current; + } + + const currentResolved = current.state === "resolved"; + const incomingResolved = incoming.state === "resolved"; + const allowIncomingValue = !currentResolved || incomingResolved; + const nextRecordId = + allowIncomingValue && incoming.acpxRecordId ? incoming.acpxRecordId : current.acpxRecordId; + const nextAcpxSessionId = + allowIncomingValue && incoming.acpxSessionId ? incoming.acpxSessionId : current.acpxSessionId; + const nextAgentSessionId = + allowIncomingValue && incoming.agentSessionId + ? incoming.agentSessionId + : current.agentSessionId; + + const nextResolved = Boolean(nextAcpxSessionId || nextAgentSessionId); + const nextState: SessionAcpIdentity["state"] = nextResolved + ? "resolved" + : currentResolved + ? "resolved" + : incoming.state; + const nextSource = allowIncomingValue ? incoming.source : current.source; + const next: SessionAcpIdentity = { + state: nextState, + ...(nextRecordId ? { acpxRecordId: nextRecordId } : {}), + ...(nextAcpxSessionId ? { acpxSessionId: nextAcpxSessionId } : {}), + ...(nextAgentSessionId ? { agentSessionId: nextAgentSessionId } : {}), + source: nextSource, + lastUpdatedAt: params.now, + }; + return next; +} + +export function createIdentityFromEnsure(params: { + handle: AcpRuntimeHandle; + now: number; +}): SessionAcpIdentity | undefined { + const acpxRecordId = normalizeText((params.handle as { acpxRecordId?: unknown }).acpxRecordId); + const acpxSessionId = normalizeText(params.handle.backendSessionId); + const agentSessionId = normalizeText(params.handle.agentSessionId); + if (!acpxRecordId && !acpxSessionId && !agentSessionId) { + return undefined; + } + return { + state: "pending", + ...(acpxRecordId ? { acpxRecordId } : {}), + ...(acpxSessionId ? { acpxSessionId } : {}), + ...(agentSessionId ? { agentSessionId } : {}), + source: "ensure", + lastUpdatedAt: params.now, + }; +} + +export function createIdentityFromStatus(params: { + status: AcpRuntimeStatus | undefined; + now: number; +}): SessionAcpIdentity | undefined { + if (!params.status) { + return undefined; + } + const details = params.status.details; + const acpxRecordId = + normalizeText((params.status as { acpxRecordId?: unknown }).acpxRecordId) ?? + normalizeText(details?.acpxRecordId); + const acpxSessionId = + normalizeText(params.status.backendSessionId) ?? + normalizeText(details?.backendSessionId) ?? + normalizeText(details?.acpxSessionId); + const agentSessionId = + normalizeText(params.status.agentSessionId) ?? normalizeText(details?.agentSessionId); + if (!acpxRecordId && !acpxSessionId && !agentSessionId) { + return undefined; + } + const resolved = Boolean(acpxSessionId || agentSessionId); + return { + state: resolved ? "resolved" : "pending", + ...(acpxRecordId ? { acpxRecordId } : {}), + ...(acpxSessionId ? { acpxSessionId } : {}), + ...(agentSessionId ? { agentSessionId } : {}), + source: "status", + lastUpdatedAt: params.now, + }; +} + +export function resolveRuntimeHandleIdentifiersFromIdentity( + identity: SessionAcpIdentity | undefined, +): { backendSessionId?: string; agentSessionId?: string } { + if (!identity) { + return {}; + } + return { + ...(identity.acpxSessionId ? { backendSessionId: identity.acpxSessionId } : {}), + ...(identity.agentSessionId ? { agentSessionId: identity.agentSessionId } : {}), + }; +} diff --git a/src/acp/runtime/session-meta.ts b/src/acp/runtime/session-meta.ts new file mode 100644 index 000000000000..fd4a5813f9b5 --- /dev/null +++ b/src/acp/runtime/session-meta.ts @@ -0,0 +1,165 @@ +import path from "node:path"; +import { resolveAgentSessionDirs } from "../../agents/session-dirs.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { loadConfig } from "../../config/config.js"; +import { resolveStateDir } from "../../config/paths.js"; +import { loadSessionStore, resolveStorePath, updateSessionStore } from "../../config/sessions.js"; +import { + mergeSessionEntry, + type SessionAcpMeta, + type SessionEntry, +} from "../../config/sessions/types.js"; +import { parseAgentSessionKey } from "../../routing/session-key.js"; + +export type AcpSessionStoreEntry = { + cfg: OpenClawConfig; + storePath: string; + sessionKey: string; + storeSessionKey: string; + entry?: SessionEntry; + acp?: SessionAcpMeta; + storeReadFailed?: boolean; +}; + +function resolveStoreSessionKey(store: Record, sessionKey: string): string { + const normalized = sessionKey.trim(); + if (!normalized) { + return ""; + } + if (store[normalized]) { + return normalized; + } + const lower = normalized.toLowerCase(); + if (store[lower]) { + return lower; + } + for (const key of Object.keys(store)) { + if (key.toLowerCase() === lower) { + return key; + } + } + return lower; +} + +export function resolveSessionStorePathForAcp(params: { + sessionKey: string; + cfg?: OpenClawConfig; +}): { cfg: OpenClawConfig; storePath: string } { + const cfg = params.cfg ?? loadConfig(); + const parsed = parseAgentSessionKey(params.sessionKey); + const storePath = resolveStorePath(cfg.session?.store, { + agentId: parsed?.agentId, + }); + return { cfg, storePath }; +} + +export function readAcpSessionEntry(params: { + sessionKey: string; + cfg?: OpenClawConfig; +}): AcpSessionStoreEntry | null { + const sessionKey = params.sessionKey.trim(); + if (!sessionKey) { + return null; + } + const { cfg, storePath } = resolveSessionStorePathForAcp({ + sessionKey, + cfg: params.cfg, + }); + let store: Record; + let storeReadFailed = false; + try { + store = loadSessionStore(storePath); + } catch { + storeReadFailed = true; + store = {}; + } + const storeSessionKey = resolveStoreSessionKey(store, sessionKey); + const entry = store[storeSessionKey]; + return { + cfg, + storePath, + sessionKey, + storeSessionKey, + entry, + acp: entry?.acp, + storeReadFailed, + }; +} + +export async function listAcpSessionEntries(params: { + cfg?: OpenClawConfig; +}): Promise { + const cfg = params.cfg ?? loadConfig(); + const stateDir = resolveStateDir(process.env); + const sessionDirs = await resolveAgentSessionDirs(stateDir); + const entries: AcpSessionStoreEntry[] = []; + + for (const sessionsDir of sessionDirs) { + const storePath = path.join(sessionsDir, "sessions.json"); + let store: Record; + try { + store = loadSessionStore(storePath); + } catch { + continue; + } + for (const [sessionKey, entry] of Object.entries(store)) { + if (!entry?.acp) { + continue; + } + entries.push({ + cfg, + storePath, + sessionKey, + storeSessionKey: sessionKey, + entry, + acp: entry.acp, + }); + } + } + + return entries; +} + +export async function upsertAcpSessionMeta(params: { + sessionKey: string; + cfg?: OpenClawConfig; + mutate: ( + current: SessionAcpMeta | undefined, + entry: SessionEntry | undefined, + ) => SessionAcpMeta | null | undefined; +}): Promise { + const sessionKey = params.sessionKey.trim(); + if (!sessionKey) { + return null; + } + const { storePath } = resolveSessionStorePathForAcp({ + sessionKey, + cfg: params.cfg, + }); + return await updateSessionStore( + storePath, + (store) => { + const storeSessionKey = resolveStoreSessionKey(store, sessionKey); + const currentEntry = store[storeSessionKey]; + const nextMeta = params.mutate(currentEntry?.acp, currentEntry); + if (nextMeta === undefined) { + return currentEntry ?? null; + } + if (nextMeta === null && !currentEntry) { + return null; + } + + const nextEntry = mergeSessionEntry(currentEntry, { + acp: nextMeta ?? undefined, + }); + if (nextMeta === null) { + delete nextEntry.acp; + } + store[storeSessionKey] = nextEntry; + return nextEntry; + }, + { + activeSessionKey: sessionKey.toLowerCase(), + }, + ); +} diff --git a/src/acp/runtime/types.ts b/src/acp/runtime/types.ts new file mode 100644 index 000000000000..4e479eb8c8cb --- /dev/null +++ b/src/acp/runtime/types.ts @@ -0,0 +1,110 @@ +export type AcpRuntimePromptMode = "prompt" | "steer"; + +export type AcpRuntimeSessionMode = "persistent" | "oneshot"; + +export type AcpRuntimeControl = "session/set_mode" | "session/set_config_option" | "session/status"; + +export type AcpRuntimeHandle = { + sessionKey: string; + backend: string; + runtimeSessionName: string; + /** Effective runtime working directory for this ACP session, if exposed by adapter/runtime. */ + cwd?: string; + /** Backend-local record identifier, if exposed by adapter/runtime (for example acpx record id). */ + acpxRecordId?: string; + /** Backend-level ACP session identifier, if exposed by adapter/runtime. */ + backendSessionId?: string; + /** Upstream harness session identifier, if exposed by adapter/runtime. */ + agentSessionId?: string; +}; + +export type AcpRuntimeEnsureInput = { + sessionKey: string; + agent: string; + mode: AcpRuntimeSessionMode; + cwd?: string; + env?: Record; +}; + +export type AcpRuntimeTurnInput = { + handle: AcpRuntimeHandle; + text: string; + mode: AcpRuntimePromptMode; + requestId: string; + signal?: AbortSignal; +}; + +export type AcpRuntimeCapabilities = { + controls: AcpRuntimeControl[]; + /** + * Optional backend-advertised option keys for session/set_config_option. + * Empty/undefined means "backend accepts keys, but did not advertise a strict list". + */ + configOptionKeys?: string[]; +}; + +export type AcpRuntimeStatus = { + summary?: string; + /** Backend-local record identifier, if exposed by adapter/runtime. */ + acpxRecordId?: string; + /** Backend-level ACP session identifier, if known at status time. */ + backendSessionId?: string; + /** Upstream harness session identifier, if known at status time. */ + agentSessionId?: string; + details?: Record; +}; + +export type AcpRuntimeDoctorReport = { + ok: boolean; + code?: string; + message: string; + installCommand?: string; + details?: string[]; +}; + +export type AcpRuntimeEvent = + | { + type: "text_delta"; + text: string; + stream?: "output" | "thought"; + } + | { + type: "status"; + text: string; + } + | { + type: "tool_call"; + text: string; + } + | { + type: "done"; + stopReason?: string; + } + | { + type: "error"; + message: string; + code?: string; + retryable?: boolean; + }; + +export interface AcpRuntime { + ensureSession(input: AcpRuntimeEnsureInput): Promise; + + runTurn(input: AcpRuntimeTurnInput): AsyncIterable; + + getCapabilities?(input: { + handle?: AcpRuntimeHandle; + }): Promise | AcpRuntimeCapabilities; + + getStatus?(input: { handle: AcpRuntimeHandle }): Promise; + + setMode?(input: { handle: AcpRuntimeHandle; mode: string }): Promise; + + setConfigOption?(input: { handle: AcpRuntimeHandle; key: string; value: string }): Promise; + + doctor?(): Promise; + + cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise; + + close(input: { handle: AcpRuntimeHandle; reason: string }): Promise; +} diff --git a/src/agents/acp-binding-architecture.guardrail.test.ts b/src/agents/acp-binding-architecture.guardrail.test.ts new file mode 100644 index 000000000000..ab8f04a21667 --- /dev/null +++ b/src/agents/acp-binding-architecture.guardrail.test.ts @@ -0,0 +1,42 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +type GuardedSource = { + path: string; + forbiddenPatterns: RegExp[]; +}; + +const GUARDED_SOURCES: GuardedSource[] = [ + { + path: "agents/acp-spawn.ts", + forbiddenPatterns: [/\bgetThreadBindingManager\b/, /\bparseDiscordTarget\b/], + }, + { + path: "auto-reply/reply/commands-acp/lifecycle.ts", + forbiddenPatterns: [/\bgetThreadBindingManager\b/, /\bunbindThreadBindingsBySessionKey\b/], + }, + { + path: "auto-reply/reply/commands-acp/targets.ts", + forbiddenPatterns: [/\bgetThreadBindingManager\b/], + }, + { + path: "auto-reply/reply/commands-subagents/action-focus.ts", + forbiddenPatterns: [/\bgetThreadBindingManager\b/], + }, +]; + +describe("ACP/session binding architecture guardrails", () => { + it("keeps ACP/focus flows off Discord thread-binding manager APIs", () => { + for (const source of GUARDED_SOURCES) { + const absolutePath = resolve(ROOT_DIR, source.path); + const text = readFileSync(absolutePath, "utf8"); + for (const pattern of source.forbiddenPatterns) { + expect(text).not.toMatch(pattern); + } + } + }); +}); diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts new file mode 100644 index 000000000000..f722451d0c65 --- /dev/null +++ b/src/agents/acp-spawn.test.ts @@ -0,0 +1,373 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import type { SessionBindingRecord } from "../infra/outbound/session-binding-service.js"; + +const hoisted = vi.hoisted(() => { + const callGatewayMock = vi.fn(); + const sessionBindingCapabilitiesMock = vi.fn(); + const sessionBindingBindMock = vi.fn(); + const sessionBindingUnbindMock = vi.fn(); + const sessionBindingResolveByConversationMock = vi.fn(); + const sessionBindingListBySessionMock = vi.fn(); + const closeSessionMock = vi.fn(); + const initializeSessionMock = vi.fn(); + const state = { + cfg: { + acp: { + enabled: true, + backend: "acpx", + allowedAgents: ["codex"], + }, + session: { + mainKey: "main", + scope: "per-sender", + }, + channels: { + discord: { + threadBindings: { + enabled: true, + spawnAcpSessions: true, + }, + }, + }, + } as OpenClawConfig, + }; + return { + callGatewayMock, + sessionBindingCapabilitiesMock, + sessionBindingBindMock, + sessionBindingUnbindMock, + sessionBindingResolveByConversationMock, + sessionBindingListBySessionMock, + closeSessionMock, + initializeSessionMock, + state, + }; +}); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => hoisted.state.cfg, + }; +}); + +vi.mock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), +})); + +vi.mock("../acp/control-plane/manager.js", () => { + return { + getAcpSessionManager: () => ({ + initializeSession: (params: unknown) => hoisted.initializeSessionMock(params), + closeSession: (params: unknown) => hoisted.closeSessionMock(params), + }), + }; +}); + +vi.mock("../infra/outbound/session-binding-service.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getSessionBindingService: () => ({ + bind: (input: unknown) => hoisted.sessionBindingBindMock(input), + getCapabilities: (params: unknown) => hoisted.sessionBindingCapabilitiesMock(params), + listBySession: (targetSessionKey: string) => + hoisted.sessionBindingListBySessionMock(targetSessionKey), + resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref), + touch: vi.fn(), + unbind: (input: unknown) => hoisted.sessionBindingUnbindMock(input), + }), + }; +}); + +const { spawnAcpDirect } = await import("./acp-spawn.js"); + +function createSessionBinding(overrides?: Partial): SessionBindingRecord { + return { + bindingId: "default:child-thread", + targetSessionKey: "agent:codex:acp:s1", + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "child-thread", + parentConversationId: "parent-channel", + }, + status: "active", + boundAt: Date.now(), + metadata: { + agentId: "codex", + boundBy: "system", + }, + ...overrides, + }; +} + +describe("spawnAcpDirect", () => { + beforeEach(() => { + hoisted.state.cfg = { + acp: { + enabled: true, + backend: "acpx", + allowedAgents: ["codex"], + }, + session: { + mainKey: "main", + scope: "per-sender", + }, + channels: { + discord: { + threadBindings: { + enabled: true, + spawnAcpSessions: true, + }, + }, + }, + } satisfies OpenClawConfig; + + hoisted.callGatewayMock.mockReset().mockImplementation(async (argsUnknown: unknown) => { + const args = argsUnknown as { method?: string }; + if (args.method === "sessions.patch") { + return { ok: true }; + } + if (args.method === "agent") { + return { runId: "run-1" }; + } + if (args.method === "sessions.delete") { + return { ok: true }; + } + return {}; + }); + + hoisted.closeSessionMock.mockReset().mockResolvedValue({ + runtimeClosed: true, + metaCleared: false, + }); + hoisted.initializeSessionMock.mockReset().mockImplementation(async (argsUnknown: unknown) => { + const args = argsUnknown as { + sessionKey: string; + agent: string; + mode: "persistent" | "oneshot"; + cwd?: string; + }; + const runtimeSessionName = `${args.sessionKey}:runtime`; + const cwd = typeof args.cwd === "string" ? args.cwd : undefined; + return { + runtime: { + close: vi.fn().mockResolvedValue(undefined), + }, + handle: { + sessionKey: args.sessionKey, + backend: "acpx", + runtimeSessionName, + ...(cwd ? { cwd } : {}), + agentSessionId: "codex-inner-1", + backendSessionId: "acpx-1", + }, + meta: { + backend: "acpx", + agent: args.agent, + runtimeSessionName, + ...(cwd ? { runtimeOptions: { cwd }, cwd } : {}), + identity: { + state: "pending", + source: "ensure", + acpxSessionId: "acpx-1", + agentSessionId: "codex-inner-1", + lastUpdatedAt: Date.now(), + }, + mode: args.mode, + state: "idle", + lastActivityAt: Date.now(), + }, + }; + }); + + hoisted.sessionBindingCapabilitiesMock.mockReset().mockReturnValue({ + adapterAvailable: true, + bindSupported: true, + unbindSupported: true, + placements: ["current", "child"], + }); + hoisted.sessionBindingBindMock + .mockReset() + .mockImplementation( + async (input: { + targetSessionKey: string; + conversation: { accountId: string }; + metadata?: Record; + }) => + createSessionBinding({ + targetSessionKey: input.targetSessionKey, + conversation: { + channel: "discord", + accountId: input.conversation.accountId, + conversationId: "child-thread", + parentConversationId: "parent-channel", + }, + metadata: { + boundBy: + typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "system", + agentId: "codex", + webhookId: "wh-1", + }, + }), + ); + hoisted.sessionBindingResolveByConversationMock.mockReset().mockReturnValue(null); + hoisted.sessionBindingListBySessionMock.mockReset().mockReturnValue([]); + hoisted.sessionBindingUnbindMock.mockReset().mockResolvedValue([]); + }); + + it("spawns ACP session, binds a new thread, and dispatches initial task", async () => { + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + mode: "session", + thread: true, + }, + { + agentSessionKey: "agent:main:main", + agentChannel: "discord", + agentAccountId: "default", + agentTo: "channel:parent-channel", + agentThreadId: "requester-thread", + }, + ); + + expect(result.status).toBe("accepted"); + expect(result.childSessionKey).toMatch(/^agent:codex:acp:/); + expect(result.runId).toBe("run-1"); + expect(result.mode).toBe("session"); + expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( + expect.objectContaining({ + targetKind: "session", + placement: "child", + }), + ); + expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + introText: expect.not.stringContaining( + "session ids: pending (available after the first reply)", + ), + }), + }), + ); + + const agentCall = hoisted.callGatewayMock.mock.calls + .map((call: unknown[]) => call[0] as { method?: string; params?: Record }) + .find((request) => request.method === "agent"); + expect(agentCall?.params?.sessionKey).toMatch(/^agent:codex:acp:/); + expect(agentCall?.params?.to).toBe("channel:child-thread"); + expect(agentCall?.params?.threadId).toBe("child-thread"); + expect(agentCall?.params?.deliver).toBe(true); + expect(hoisted.initializeSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: expect.stringMatching(/^agent:codex:acp:/), + agent: "codex", + mode: "persistent", + }), + ); + }); + + it("includes cwd in ACP thread intro banner when provided at spawn time", async () => { + const result = await spawnAcpDirect( + { + task: "Check workspace", + agentId: "codex", + cwd: "/home/bob/clawd", + mode: "session", + thread: true, + }, + { + agentSessionKey: "agent:main:main", + agentChannel: "discord", + agentAccountId: "default", + agentTo: "channel:parent-channel", + }, + ); + + expect(result.status).toBe("accepted"); + expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + introText: expect.stringContaining("cwd: /home/bob/clawd"), + }), + }), + ); + }); + + it("rejects disallowed ACP agents", async () => { + hoisted.state.cfg = { + ...hoisted.state.cfg, + acp: { + enabled: true, + backend: "acpx", + allowedAgents: ["claudecode"], + }, + }; + + const result = await spawnAcpDirect( + { + task: "hello", + agentId: "codex", + }, + { + agentSessionKey: "agent:main:main", + }, + ); + + expect(result).toMatchObject({ + status: "forbidden", + }); + }); + + it("requires an explicit ACP agent when no config default exists", async () => { + const result = await spawnAcpDirect( + { + task: "hello", + }, + { + agentSessionKey: "agent:main:main", + }, + ); + + expect(result.status).toBe("error"); + expect(result.error).toContain("set `acp.defaultAgent`"); + }); + + it("fails fast when Discord ACP thread spawn is disabled", async () => { + hoisted.state.cfg = { + ...hoisted.state.cfg, + channels: { + discord: { + threadBindings: { + enabled: true, + spawnAcpSessions: false, + }, + }, + }, + }; + + const result = await spawnAcpDirect( + { + task: "hello", + agentId: "codex", + thread: true, + mode: "session", + }, + { + agentChannel: "discord", + agentAccountId: "default", + agentTo: "channel:parent-channel", + }, + ); + + expect(result.status).toBe("error"); + expect(result.error).toContain("spawnAcpSessions=true"); + }); +}); diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts new file mode 100644 index 000000000000..1ebd7b9d8563 --- /dev/null +++ b/src/agents/acp-spawn.ts @@ -0,0 +1,424 @@ +import crypto from "node:crypto"; +import { getAcpSessionManager } from "../acp/control-plane/manager.js"; +import { + cleanupFailedAcpSpawn, + type AcpSpawnRuntimeCloseHandle, +} from "../acp/control-plane/spawn.js"; +import { isAcpEnabledByPolicy, resolveAcpAgentPolicyError } from "../acp/policy.js"; +import { + resolveAcpSessionCwd, + resolveAcpThreadSessionDetailLines, +} from "../acp/runtime/session-identifiers.js"; +import type { AcpRuntimeSessionMode } from "../acp/runtime/types.js"; +import { + resolveThreadBindingIntroText, + resolveThreadBindingThreadName, +} from "../channels/thread-bindings-messages.js"; +import { + formatThreadBindingDisabledError, + formatThreadBindingSpawnDisabledError, + resolveThreadBindingSessionTtlMsForChannel, + resolveThreadBindingSpawnPolicy, +} from "../channels/thread-bindings-policy.js"; +import { loadConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { callGateway } from "../gateway/call.js"; +import { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js"; +import { + getSessionBindingService, + isSessionBindingError, + type SessionBindingRecord, +} from "../infra/outbound/session-binding-service.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import { normalizeDeliveryContext } from "../utils/delivery-context.js"; + +export const ACP_SPAWN_MODES = ["run", "session"] as const; +export type SpawnAcpMode = (typeof ACP_SPAWN_MODES)[number]; + +export type SpawnAcpParams = { + task: string; + label?: string; + agentId?: string; + cwd?: string; + mode?: SpawnAcpMode; + thread?: boolean; +}; + +export type SpawnAcpContext = { + agentSessionKey?: string; + agentChannel?: string; + agentAccountId?: string; + agentTo?: string; + agentThreadId?: string | number; +}; + +export type SpawnAcpResult = { + status: "accepted" | "forbidden" | "error"; + childSessionKey?: string; + runId?: string; + mode?: SpawnAcpMode; + note?: string; + error?: string; +}; + +export const ACP_SPAWN_ACCEPTED_NOTE = + "initial ACP task queued in isolated session; follow-ups continue in the bound thread."; +export const ACP_SPAWN_SESSION_ACCEPTED_NOTE = + "thread-bound ACP session stays active after this task; continue in-thread for follow-ups."; + +type PreparedAcpThreadBinding = { + channel: string; + accountId: string; + conversationId: string; +}; + +function resolveSpawnMode(params: { + requestedMode?: SpawnAcpMode; + threadRequested: boolean; +}): SpawnAcpMode { + if (params.requestedMode === "run" || params.requestedMode === "session") { + return params.requestedMode; + } + // Thread-bound spawns should default to persistent sessions. + return params.threadRequested ? "session" : "run"; +} + +function resolveAcpSessionMode(mode: SpawnAcpMode): AcpRuntimeSessionMode { + return mode === "session" ? "persistent" : "oneshot"; +} + +function resolveTargetAcpAgentId(params: { + requestedAgentId?: string; + cfg: OpenClawConfig; +}): { ok: true; agentId: string } | { ok: false; error: string } { + const requested = normalizeOptionalAgentId(params.requestedAgentId); + if (requested) { + return { ok: true, agentId: requested }; + } + + const configuredDefault = normalizeOptionalAgentId(params.cfg.acp?.defaultAgent); + if (configuredDefault) { + return { ok: true, agentId: configuredDefault }; + } + + return { + ok: false, + error: + "ACP target agent is not configured. Pass `agentId` in `sessions_spawn` or set `acp.defaultAgent` in config.", + }; +} + +function normalizeOptionalAgentId(value: string | undefined | null): string | undefined { + const trimmed = (value ?? "").trim(); + if (!trimmed) { + return undefined; + } + return normalizeAgentId(trimmed); +} + +function summarizeError(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + if (typeof err === "string") { + return err; + } + return "error"; +} + +function resolveConversationIdForThreadBinding(params: { + to?: string; + threadId?: string | number; +}): string | undefined { + return resolveConversationIdFromTargets({ + threadId: params.threadId, + targets: [params.to], + }); +} + +function prepareAcpThreadBinding(params: { + cfg: OpenClawConfig; + channel?: string; + accountId?: string; + to?: string; + threadId?: string | number; +}): { ok: true; binding: PreparedAcpThreadBinding } | { ok: false; error: string } { + const channel = params.channel?.trim().toLowerCase(); + if (!channel) { + return { + ok: false, + error: "thread=true for ACP sessions requires a channel context.", + }; + } + + const accountId = params.accountId?.trim() || "default"; + const policy = resolveThreadBindingSpawnPolicy({ + cfg: params.cfg, + channel, + accountId, + kind: "acp", + }); + if (!policy.enabled) { + return { + ok: false, + error: formatThreadBindingDisabledError({ + channel: policy.channel, + accountId: policy.accountId, + kind: "acp", + }), + }; + } + if (!policy.spawnEnabled) { + return { + ok: false, + error: formatThreadBindingSpawnDisabledError({ + channel: policy.channel, + accountId: policy.accountId, + kind: "acp", + }), + }; + } + const bindingService = getSessionBindingService(); + const capabilities = bindingService.getCapabilities({ + channel: policy.channel, + accountId: policy.accountId, + }); + if (!capabilities.adapterAvailable) { + return { + ok: false, + error: `Thread bindings are unavailable for ${policy.channel}.`, + }; + } + if (!capabilities.bindSupported || !capabilities.placements.includes("child")) { + return { + ok: false, + error: `Thread bindings do not support ACP thread spawn for ${policy.channel}.`, + }; + } + const conversationId = resolveConversationIdForThreadBinding({ + to: params.to, + threadId: params.threadId, + }); + if (!conversationId) { + return { + ok: false, + error: `Could not resolve a ${policy.channel} conversation for ACP thread spawn.`, + }; + } + + return { + ok: true, + binding: { + channel: policy.channel, + accountId: policy.accountId, + conversationId, + }, + }; +} + +export async function spawnAcpDirect( + params: SpawnAcpParams, + ctx: SpawnAcpContext, +): Promise { + const cfg = loadConfig(); + if (!isAcpEnabledByPolicy(cfg)) { + return { + status: "forbidden", + error: "ACP is disabled by policy (`acp.enabled=false`).", + }; + } + + const requestThreadBinding = params.thread === true; + const spawnMode = resolveSpawnMode({ + requestedMode: params.mode, + threadRequested: requestThreadBinding, + }); + if (spawnMode === "session" && !requestThreadBinding) { + return { + status: "error", + error: 'mode="session" requires thread=true so the ACP session can stay bound to a thread.', + }; + } + + const targetAgentResult = resolveTargetAcpAgentId({ + requestedAgentId: params.agentId, + cfg, + }); + if (!targetAgentResult.ok) { + return { + status: "error", + error: targetAgentResult.error, + }; + } + const targetAgentId = targetAgentResult.agentId; + const agentPolicyError = resolveAcpAgentPolicyError(cfg, targetAgentId); + if (agentPolicyError) { + return { + status: "forbidden", + error: agentPolicyError.message, + }; + } + + const sessionKey = `agent:${targetAgentId}:acp:${crypto.randomUUID()}`; + const runtimeMode = resolveAcpSessionMode(spawnMode); + + let preparedBinding: PreparedAcpThreadBinding | null = null; + if (requestThreadBinding) { + const prepared = prepareAcpThreadBinding({ + cfg, + channel: ctx.agentChannel, + accountId: ctx.agentAccountId, + to: ctx.agentTo, + threadId: ctx.agentThreadId, + }); + if (!prepared.ok) { + return { + status: "error", + error: prepared.error, + }; + } + preparedBinding = prepared.binding; + } + + const acpManager = getAcpSessionManager(); + const bindingService = getSessionBindingService(); + let binding: SessionBindingRecord | null = null; + let sessionCreated = false; + let initializedRuntime: AcpSpawnRuntimeCloseHandle | undefined; + try { + await callGateway({ + method: "sessions.patch", + params: { + key: sessionKey, + ...(params.label ? { label: params.label } : {}), + }, + timeoutMs: 10_000, + }); + sessionCreated = true; + const initialized = await acpManager.initializeSession({ + cfg, + sessionKey, + agent: targetAgentId, + mode: runtimeMode, + cwd: params.cwd, + backendId: cfg.acp?.backend, + }); + initializedRuntime = { + runtime: initialized.runtime, + handle: initialized.handle, + }; + + if (preparedBinding) { + binding = await bindingService.bind({ + targetSessionKey: sessionKey, + targetKind: "session", + conversation: { + channel: preparedBinding.channel, + accountId: preparedBinding.accountId, + conversationId: preparedBinding.conversationId, + }, + placement: "child", + metadata: { + threadName: resolveThreadBindingThreadName({ + agentId: targetAgentId, + label: params.label || targetAgentId, + }), + agentId: targetAgentId, + label: params.label || undefined, + boundBy: "system", + introText: resolveThreadBindingIntroText({ + agentId: targetAgentId, + label: params.label || undefined, + sessionTtlMs: resolveThreadBindingSessionTtlMsForChannel({ + cfg, + channel: preparedBinding.channel, + accountId: preparedBinding.accountId, + }), + sessionCwd: resolveAcpSessionCwd(initialized.meta), + sessionDetails: resolveAcpThreadSessionDetailLines({ + sessionKey, + meta: initialized.meta, + }), + }), + }, + }); + if (!binding?.conversation.conversationId) { + throw new Error( + `Failed to create and bind a ${preparedBinding.channel} thread for this ACP session.`, + ); + } + } + } catch (err) { + await cleanupFailedAcpSpawn({ + cfg, + sessionKey, + shouldDeleteSession: sessionCreated, + deleteTranscript: true, + runtimeCloseHandle: initializedRuntime, + }); + return { + status: "error", + error: isSessionBindingError(err) ? err.message : summarizeError(err), + }; + } + + const requesterOrigin = normalizeDeliveryContext({ + channel: ctx.agentChannel, + accountId: ctx.agentAccountId, + to: ctx.agentTo, + threadId: ctx.agentThreadId, + }); + // For thread-bound ACP spawns, force bootstrap delivery to the new child thread. + const boundThreadIdRaw = binding?.conversation.conversationId; + const boundThreadId = boundThreadIdRaw ? String(boundThreadIdRaw).trim() || undefined : undefined; + const fallbackThreadIdRaw = requesterOrigin?.threadId; + const fallbackThreadId = + fallbackThreadIdRaw != null ? String(fallbackThreadIdRaw).trim() || undefined : undefined; + const deliveryThreadId = boundThreadId ?? fallbackThreadId; + const inferredDeliveryTo = boundThreadId + ? `channel:${boundThreadId}` + : requesterOrigin?.to?.trim() || (deliveryThreadId ? `channel:${deliveryThreadId}` : undefined); + const hasDeliveryTarget = Boolean(requesterOrigin?.channel && inferredDeliveryTo); + const childIdem = crypto.randomUUID(); + let childRunId: string = childIdem; + try { + const response = await callGateway<{ runId?: string }>({ + method: "agent", + params: { + message: params.task, + sessionKey, + channel: hasDeliveryTarget ? requesterOrigin?.channel : undefined, + to: hasDeliveryTarget ? inferredDeliveryTo : undefined, + accountId: hasDeliveryTarget ? (requesterOrigin?.accountId ?? undefined) : undefined, + threadId: hasDeliveryTarget ? deliveryThreadId : undefined, + idempotencyKey: childIdem, + deliver: hasDeliveryTarget, + label: params.label || undefined, + }, + timeoutMs: 10_000, + }); + if (typeof response?.runId === "string" && response.runId.trim()) { + childRunId = response.runId.trim(); + } + } catch (err) { + await cleanupFailedAcpSpawn({ + cfg, + sessionKey, + shouldDeleteSession: true, + deleteTranscript: true, + }); + return { + status: "error", + error: summarizeError(err), + childSessionKey: sessionKey, + }; + } + + return { + status: "accepted", + childSessionKey: sessionKey, + runId: childRunId, + mode: spawnMode, + note: spawnMode === "session" ? ACP_SPAWN_SESSION_ACCEPTED_NOTE : ACP_SPAWN_ACCEPTED_NOTE, + }; +} diff --git a/src/agents/apply-patch.test.ts b/src/agents/apply-patch.test.ts index 5a2dae87e757..79d0aa0c07b5 100644 --- a/src/agents/apply-patch.test.ts +++ b/src/agents/apply-patch.test.ts @@ -159,6 +159,42 @@ describe("applyPatch", () => { }); }); + it("rejects hardlink alias escapes by default", async () => { + if (process.platform === "win32") { + return; + } + await withTempDir(async (dir) => { + const outside = path.join( + path.dirname(dir), + `outside-hardlink-${process.pid}-${Date.now()}.txt`, + ); + const linkPath = path.join(dir, "hardlink.txt"); + await fs.writeFile(outside, "initial\n", "utf8"); + try { + try { + await fs.link(outside, linkPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EXDEV") { + return; + } + throw err; + } + const patch = `*** Begin Patch +*** Update File: hardlink.txt +@@ +-initial ++pwned +*** End Patch`; + await expect(applyPatch(patch, { cwd: dir })).rejects.toThrow(/hardlink|sandbox/i); + const outsideContents = await fs.readFile(outside, "utf8"); + expect(outsideContents).toBe("initial\n"); + } finally { + await fs.rm(linkPath, { force: true }); + await fs.rm(outside, { force: true }); + } + }); + }); + it("allows symlinks that resolve within cwd by default", async () => { await withTempDir(async (dir) => { const target = path.join(dir, "target.txt"); diff --git a/src/agents/apply-patch.ts b/src/agents/apply-patch.ts index fecf4cf03bc1..4f1487d34ea1 100644 --- a/src/agents/apply-patch.ts +++ b/src/agents/apply-patch.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; +import { PATH_ALIAS_POLICIES, type PathAliasPolicy } from "../infra/path-alias-guards.js"; import { applyUpdateHunk } from "./apply-patch-update.js"; import { assertSandboxPath, resolveSandboxInputPath } from "./sandbox-paths.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; @@ -154,7 +155,7 @@ export async function applyPatch( } if (hunk.kind === "delete") { - const target = await resolvePatchPath(hunk.path, options, "unlink"); + const target = await resolvePatchPath(hunk.path, options, PATH_ALIAS_POLICIES.unlinkTarget); await fileOps.remove(target.resolved); recordSummary(summary, seen, "deleted", target.display); continue; @@ -253,7 +254,7 @@ async function ensureDir(filePath: string, ops: PatchFileOps) { async function resolvePatchPath( filePath: string, options: ApplyPatchOptions, - purpose: "readWrite" | "unlink" = "readWrite", + aliasPolicy: PathAliasPolicy = PATH_ALIAS_POLICIES.strict, ): Promise<{ resolved: string; display: string }> { if (options.sandbox) { const resolved = options.sandbox.bridge.resolvePath({ @@ -265,7 +266,8 @@ async function resolvePatchPath( filePath: resolved.hostPath, cwd: options.cwd, root: options.cwd, - allowFinalSymlink: purpose === "unlink", + allowFinalSymlinkForUnlink: aliasPolicy.allowFinalSymlinkForUnlink, + allowFinalHardlinkForUnlink: aliasPolicy.allowFinalHardlinkForUnlink, }); } return { @@ -281,7 +283,8 @@ async function resolvePatchPath( filePath, cwd: options.cwd, root: options.cwd, - allowFinalSymlink: purpose === "unlink", + allowFinalSymlinkForUnlink: aliasPolicy.allowFinalSymlinkForUnlink, + allowFinalHardlinkForUnlink: aliasPolicy.allowFinalHardlinkForUnlink, }) ).resolved : resolvePathFromCwd(filePath, options.cwd); diff --git a/src/agents/auth-profiles.markauthprofilefailure.test.ts b/src/agents/auth-profiles.markauthprofilefailure.test.ts index 1a30d8a91199..865fbf878164 100644 --- a/src/agents/auth-profiles.markauthprofilefailure.test.ts +++ b/src/agents/auth-profiles.markauthprofilefailure.test.ts @@ -114,6 +114,22 @@ describe("markAuthProfileFailure", () => { expect(reloaded.usageStats?.["anthropic:default"]?.cooldownUntil).toBe(firstCooldownUntil); }); }); + it("disables auth_permanent failures via disabledUntil (like billing)", async () => { + await withAuthProfileStore(async ({ agentDir, store }) => { + await markAuthProfileFailure({ + store, + profileId: "anthropic:default", + reason: "auth_permanent", + agentDir, + }); + + const stats = store.usageStats?.["anthropic:default"]; + expect(typeof stats?.disabledUntil).toBe("number"); + expect(stats?.disabledReason).toBe("auth_permanent"); + // Should NOT set cooldownUntil (that's for transient errors) + expect(stats?.cooldownUntil).toBeUndefined(); + }); + }); it("resets backoff counters outside the failure window", async () => { const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-")); try { diff --git a/src/agents/auth-profiles/types.ts b/src/agents/auth-profiles/types.ts index 7332d3048125..c23e6aa404d0 100644 --- a/src/agents/auth-profiles/types.ts +++ b/src/agents/auth-profiles/types.ts @@ -34,6 +34,7 @@ export type AuthProfileCredential = ApiKeyCredential | TokenCredential | OAuthCr export type AuthProfileFailureReason = | "auth" + | "auth_permanent" | "format" | "rate_limit" | "billing" diff --git a/src/agents/auth-profiles/usage.test.ts b/src/agents/auth-profiles/usage.test.ts index 0025007f7290..8c499654b494 100644 --- a/src/agents/auth-profiles/usage.test.ts +++ b/src/agents/auth-profiles/usage.test.ts @@ -141,6 +141,24 @@ describe("resolveProfilesUnavailableReason", () => { ).toBe("billing"); }); + it("returns auth_permanent for active permanent auth disables", () => { + const now = Date.now(); + const store = makeStore({ + "anthropic:default": { + disabledUntil: now + 60_000, + disabledReason: "auth_permanent", + }, + }); + + expect( + resolveProfilesUnavailableReason({ + store, + profileIds: ["anthropic:default"], + now, + }), + ).toBe("auth_permanent"); + }); + it("uses recorded non-rate-limit failure counts for active cooldown windows", () => { const now = Date.now(); const store = makeStore({ @@ -490,7 +508,7 @@ describe("markAuthProfileFailure — active windows do not extend on retry", () async function markFailureAt(params: { store: ReturnType; now: number; - reason: "rate_limit" | "billing"; + reason: "rate_limit" | "billing" | "auth_permanent"; }): Promise { vi.useFakeTimers(); vi.setSystemTime(params.now); @@ -528,6 +546,18 @@ describe("markAuthProfileFailure — active windows do not extend on retry", () }), readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil, }, + { + label: "disabledUntil(auth_permanent)", + reason: "auth_permanent" as const, + buildUsageStats: (now: number): WindowStats => ({ + disabledUntil: now + 20 * 60 * 60 * 1000, + disabledReason: "auth_permanent", + errorCount: 5, + failureCounts: { auth_permanent: 5 }, + lastFailureAt: now - 60_000, + }), + readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil, + }, ]; for (const testCase of activeWindowCases) { @@ -573,6 +603,19 @@ describe("markAuthProfileFailure — active windows do not extend on retry", () expectedUntil: (now: number) => now + 20 * 60 * 60 * 1000, readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil, }, + { + label: "disabledUntil(auth_permanent)", + reason: "auth_permanent" as const, + buildUsageStats: (now: number): WindowStats => ({ + disabledUntil: now - 60_000, + disabledReason: "auth_permanent", + errorCount: 5, + failureCounts: { auth_permanent: 2 }, + lastFailureAt: now - 60_000, + }), + expectedUntil: (now: number) => now + 20 * 60 * 60 * 1000, + readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil, + }, ]; for (const testCase of expiredWindowCases) { diff --git a/src/agents/auth-profiles/usage.ts b/src/agents/auth-profiles/usage.ts index 958e3ae127e4..60c43c9c3c8d 100644 --- a/src/agents/auth-profiles/usage.ts +++ b/src/agents/auth-profiles/usage.ts @@ -4,6 +4,7 @@ import { saveAuthProfileStore, updateAuthProfileStoreWithLock } from "./store.js import type { AuthProfileFailureReason, AuthProfileStore, ProfileUsageStats } from "./types.js"; const FAILURE_REASON_PRIORITY: AuthProfileFailureReason[] = [ + "auth_permanent", "auth", "billing", "format", @@ -394,8 +395,8 @@ function computeNextProfileUsageStats(params: { lastFailureAt: params.now, }; - if (params.reason === "billing") { - const billingCount = failureCounts.billing ?? 1; + if (params.reason === "billing" || params.reason === "auth_permanent") { + const billingCount = failureCounts[params.reason] ?? 1; const backoffMs = calculateAuthProfileBillingDisableMsWithConfig({ errorCount: billingCount, baseMs: params.cfgResolved.billingBackoffMs, @@ -408,7 +409,7 @@ function computeNextProfileUsageStats(params: { now: params.now, recomputedUntil: params.now + backoffMs, }); - updatedStats.disabledReason = "billing"; + updatedStats.disabledReason = params.reason; } else { const backoffMs = calculateAuthProfileCooldownMs(nextErrorCount); // Keep active cooldown windows immutable so retries within the window @@ -424,8 +425,9 @@ function computeNextProfileUsageStats(params: { } /** - * Mark a profile as failed for a specific reason. Billing failures are treated - * as "disabled" (longer backoff) vs the regular cooldown window. + * Mark a profile as failed for a specific reason. Billing and permanent-auth + * failures are treated as "disabled" (longer backoff) vs the regular cooldown + * window. */ export async function markAuthProfileFailure(params: { store: AuthProfileStore; diff --git a/src/agents/bash-tools.exec-approval-request.ts b/src/agents/bash-tools.exec-approval-request.ts index 83323845c0cf..cda30757e269 100644 --- a/src/agents/bash-tools.exec-approval-request.ts +++ b/src/agents/bash-tools.exec-approval-request.ts @@ -8,6 +8,7 @@ import { callGatewayTool } from "./tools/gateway.js"; export type RequestExecApprovalDecisionParams = { id: string; command: string; + commandArgv?: string[]; cwd: string; nodeId?: string; host: "gateway" | "node"; @@ -62,6 +63,7 @@ export async function registerExecApprovalRequest( { id: params.id, command: params.command, + commandArgv: params.commandArgv, cwd: params.cwd, nodeId: params.nodeId, host: params.host, @@ -116,6 +118,7 @@ export async function requestExecApprovalDecision( export async function requestExecApprovalDecisionForHost(params: { approvalId: string; command: string; + commandArgv?: string[]; workdir: string; host: "gateway" | "node"; nodeId?: string; @@ -128,6 +131,7 @@ export async function requestExecApprovalDecisionForHost(params: { return await requestExecApprovalDecision({ id: params.approvalId, command: params.command, + commandArgv: params.commandArgv, cwd: params.workdir, nodeId: params.nodeId, host: params.host, @@ -142,6 +146,7 @@ export async function requestExecApprovalDecisionForHost(params: { export async function registerExecApprovalRequestForHost(params: { approvalId: string; command: string; + commandArgv?: string[]; workdir: string; host: "gateway" | "node"; nodeId?: string; @@ -154,6 +159,7 @@ export async function registerExecApprovalRequestForHost(params: { return await registerExecApprovalRequest({ id: params.approvalId, command: params.command, + commandArgv: params.commandArgv, cwd: params.workdir, nodeId: params.nodeId, host: params.host, diff --git a/src/agents/bash-tools.exec-host-node.ts b/src/agents/bash-tools.exec-host-node.ts index 5a45c8692924..47f2931b980d 100644 --- a/src/agents/bash-tools.exec-host-node.ts +++ b/src/agents/bash-tools.exec-host-node.ts @@ -194,6 +194,7 @@ export async function executeNodeHostCommand( const registration = await registerExecApprovalRequestForHost({ approvalId, command: params.command, + commandArgv: argv, workdir: params.workdir, host: "node", nodeId, diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index e211e3df49c5..dbabca75faaa 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -93,6 +93,7 @@ export function buildSystemPrompt(params: { reasoningTagHint: false, heartbeatPrompt: params.heartbeatPrompt, docsPath: params.docsPath, + acpEnabled: params.config?.acp?.enabled !== false, runtimeInfo, toolNames: params.tools.map((tool) => tool.name), modelAliasLines: buildModelAliasLines(params.config), diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts index d7c1edccbe18..8b2cb8462980 100644 --- a/src/agents/failover-error.test.ts +++ b/src/agents/failover-error.test.ts @@ -4,6 +4,7 @@ import { describeFailoverError, isTimeoutError, resolveFailoverReasonFromError, + resolveFailoverStatus, } from "./failover-error.js"; describe("failover-error", () => { @@ -69,6 +70,36 @@ describe("failover-error", () => { expect(err?.status).toBe(400); }); + it("401/403 with generic message still returns auth (backward compat)", () => { + expect(resolveFailoverReasonFromError({ status: 401, message: "Unauthorized" })).toBe("auth"); + expect(resolveFailoverReasonFromError({ status: 403, message: "Forbidden" })).toBe("auth"); + }); + + it("401 with permanent auth message returns auth_permanent", () => { + expect(resolveFailoverReasonFromError({ status: 401, message: "invalid_api_key" })).toBe( + "auth_permanent", + ); + }); + + it("403 with revoked key message returns auth_permanent", () => { + expect(resolveFailoverReasonFromError({ status: 403, message: "api key revoked" })).toBe( + "auth_permanent", + ); + }); + + it("resolveFailoverStatus maps auth_permanent to 403", () => { + expect(resolveFailoverStatus("auth_permanent")).toBe(403); + }); + + it("coerces permanent auth error with correct reason", () => { + const err = coerceToFailoverError( + { status: 401, message: "invalid_api_key" }, + { provider: "anthropic", model: "claude-opus-4-6" }, + ); + expect(err?.reason).toBe("auth_permanent"); + expect(err?.provider).toBe("anthropic"); + }); + it("describes non-Error values consistently", () => { const described = describeFailoverError(123); expect(described.message).toBe("123"); diff --git a/src/agents/failover-error.ts b/src/agents/failover-error.ts index 4de2babde4dd..708af55e3226 100644 --- a/src/agents/failover-error.ts +++ b/src/agents/failover-error.ts @@ -1,4 +1,8 @@ -import { classifyFailoverReason, type FailoverReason } from "./pi-embedded-helpers.js"; +import { + classifyFailoverReason, + isAuthPermanentErrorMessage, + type FailoverReason, +} from "./pi-embedded-helpers.js"; const TIMEOUT_HINT_RE = /timeout|timed out|deadline exceeded|context deadline exceeded|stop reason:\s*abort|reason:\s*abort|unhandled stop reason:\s*abort/i; @@ -47,6 +51,8 @@ export function resolveFailoverStatus(reason: FailoverReason): number | undefine return 429; case "auth": return 401; + case "auth_permanent": + return 403; case "timeout": return 408; case "format": @@ -158,6 +164,10 @@ export function resolveFailoverReasonFromError(err: unknown): FailoverReason | n return "rate_limit"; } if (status === 401 || status === 403) { + const msg = getErrorMessage(err); + if (msg && isAuthPermanentErrorMessage(msg)) { + return "auth_permanent"; + } return "auth"; } if (status === 408) { diff --git a/src/agents/model-fallback.probe.test.ts b/src/agents/model-fallback.probe.test.ts index 0c222ec21155..3e36366c4adc 100644 --- a/src/agents/model-fallback.probe.test.ts +++ b/src/agents/model-fallback.probe.test.ts @@ -163,7 +163,7 @@ describe("runWithModelFallback – probe logic", () => { expectPrimaryProbeSuccess(result, run, "recovered"); }); - it("does NOT probe non-primary candidates during cooldown", async () => { + it("attempts non-primary fallbacks during rate-limit cooldown after primary probe failure", async () => { const cfg = makeCfg({ agents: { defaults: { @@ -182,25 +182,23 @@ describe("runWithModelFallback – probe logic", () => { const almostExpired = NOW + 30 * 1000; // 30s remaining mockedGetSoonestCooldownExpiry.mockReturnValue(almostExpired); - // Primary probe fails with 429 + // Primary probe fails with 429; fallback should still be attempted for rate_limit cooldowns. const run = vi .fn() .mockRejectedValueOnce(Object.assign(new Error("rate limited"), { status: 429 })) - .mockResolvedValue("should-not-reach"); + .mockResolvedValue("fallback-ok"); - try { - await runWithModelFallback({ - cfg, - provider: "openai", - model: "gpt-4.1-mini", - run, - }); - expect.unreachable("should have thrown since all candidates exhausted"); - } catch { - // Primary was probed (i === 0 + within margin), non-primary were skipped - expect(run).toHaveBeenCalledTimes(1); // only primary was actually called - expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini"); - } + const result = await runWithModelFallback({ + cfg, + provider: "openai", + model: "gpt-4.1-mini", + run, + }); + + expect(result.result).toBe("fallback-ok"); + expect(run).toHaveBeenCalledTimes(2); + expect(run).toHaveBeenNthCalledWith(1, "openai", "gpt-4.1-mini"); + expect(run).toHaveBeenNthCalledWith(2, "anthropic", "claude-haiku-3-5"); }); it("throttles probe when called within 30s interval", async () => { diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 16592cdb4560..cd0217faafcb 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -143,10 +143,22 @@ async function expectSkippedUnavailableProvider(params: { }) { const provider = `${params.providerPrefix}-${crypto.randomUUID()}`; const cfg = makeProviderFallbackCfg(provider); - const store = makeSingleProviderStore({ + const primaryStore = makeSingleProviderStore({ provider, usageStat: params.usageStat, }); + // Include fallback provider profile so the fallback is attempted (not skipped as no-profile). + const store: AuthProfileStore = { + ...primaryStore, + profiles: { + ...primaryStore.profiles, + "fallback:default": { + type: "api_key", + provider: "fallback", + key: "test-key", + }, + }, + }; const run = createFallbackOnlyRun(); const result = await runWithStoredAuth({ @@ -436,11 +448,11 @@ describe("runWithModelFallback", () => { run, }); - // Override model failed with model_not_found → falls back to configured primary. + // Override model failed with model_not_found → tries fallbacks first (same provider). expect(result.result).toBe("ok"); expect(run).toHaveBeenCalledTimes(2); - expect(run.mock.calls[1]?.[0]).toBe("openai"); - expect(run.mock.calls[1]?.[1]).toBe("gpt-4.1-mini"); + expect(run.mock.calls[1]?.[0]).toBe("anthropic"); + expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5"); }); it("skips providers when all profiles are in cooldown", async () => { @@ -794,6 +806,296 @@ describe("runWithModelFallback", () => { expect(result.provider).toBe("openai"); expect(result.model).toBe("gpt-4.1-mini"); }); + + // Tests for Bug A fix: Model fallback with session overrides + describe("fallback behavior with session model overrides", () => { + it("allows fallbacks when session model differs from config within same provider", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-6", + fallbacks: ["anthropic/claude-sonnet-4-5", "google/gemini-2.5-flash"], + }, + }, + }, + }); + + const run = vi + .fn() + .mockRejectedValueOnce(new Error("Rate limit exceeded")) // Session model fails + .mockResolvedValueOnce("fallback success"); // First fallback succeeds + + const result = await runWithModelFallback({ + cfg, + provider: "anthropic", + model: "claude-sonnet-4-20250514", // Different from config primary + run, + }); + + expect(result.result).toBe("fallback success"); + expect(run).toHaveBeenCalledTimes(2); + expect(run).toHaveBeenNthCalledWith(1, "anthropic", "claude-sonnet-4-20250514"); + expect(run).toHaveBeenNthCalledWith(2, "anthropic", "claude-sonnet-4-5"); // Fallback tried + }); + + it("allows fallbacks with model version differences within same provider", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-6", + fallbacks: ["groq/llama-3.3-70b-versatile"], + }, + }, + }, + }); + + const run = vi + .fn() + .mockRejectedValueOnce(new Error("Weekly quota exceeded")) + .mockResolvedValueOnce("groq success"); + + const result = await runWithModelFallback({ + cfg, + provider: "anthropic", + model: "claude-opus-4-5", // Version difference from config + run, + }); + + expect(result.result).toBe("groq success"); + expect(run).toHaveBeenCalledTimes(2); + expect(run).toHaveBeenNthCalledWith(2, "groq", "llama-3.3-70b-versatile"); + }); + + it("still skips fallbacks when using different provider than config", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-6", + fallbacks: [], // Empty fallbacks to match working pattern + }, + }, + }, + }); + + const run = vi + .fn() + .mockRejectedValueOnce(new Error('No credentials found for profile "openai:default".')) + .mockResolvedValueOnce("config primary worked"); + + const result = await runWithModelFallback({ + cfg, + provider: "openai", // Different provider + model: "gpt-4.1-mini", + run, + }); + + // Cross-provider requests should skip configured fallbacks but still try configured primary + expect(result.result).toBe("config primary worked"); + expect(run).toHaveBeenCalledTimes(2); + expect(run).toHaveBeenNthCalledWith(1, "openai", "gpt-4.1-mini"); // Original request + expect(run).toHaveBeenNthCalledWith(2, "anthropic", "claude-opus-4-6"); // Config primary as final fallback + }); + + it("uses fallbacks when session model exactly matches config primary", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-6", + fallbacks: ["groq/llama-3.3-70b-versatile"], + }, + }, + }, + }); + + const run = vi + .fn() + .mockRejectedValueOnce(new Error("Quota exceeded")) + .mockResolvedValueOnce("fallback worked"); + + const result = await runWithModelFallback({ + cfg, + provider: "anthropic", + model: "claude-opus-4-6", // Exact match + run, + }); + + expect(result.result).toBe("fallback worked"); + expect(run).toHaveBeenCalledTimes(2); + expect(run).toHaveBeenNthCalledWith(2, "groq", "llama-3.3-70b-versatile"); + }); + }); + + // Tests for Bug B fix: Rate limit vs auth/billing cooldown distinction + describe("fallback behavior with provider cooldowns", () => { + async function makeAuthStoreWithCooldown( + provider: string, + reason: "rate_limit" | "auth" | "billing", + ): Promise<{ store: AuthProfileStore; dir: string }> { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-")); + const now = Date.now(); + const store: AuthProfileStore = { + version: AUTH_STORE_VERSION, + profiles: { + [`${provider}:default`]: { type: "api_key", provider, key: "test-key" }, + }, + usageStats: { + [`${provider}:default`]: + reason === "rate_limit" + ? { + // Real rate-limit cooldowns are tracked through cooldownUntil + // and failureCounts, not disabledReason. + cooldownUntil: now + 300000, + failureCounts: { rate_limit: 1 }, + } + : { + // Auth/billing issues use disabledUntil + disabledUntil: now + 300000, + disabledReason: reason, + }, + }, + }; + saveAuthProfileStore(store, tmpDir); + return { store, dir: tmpDir }; + } + + it("attempts same-provider fallbacks during rate limit cooldown", async () => { + const { dir } = await makeAuthStoreWithCooldown("anthropic", "rate_limit"); + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-6", + fallbacks: ["anthropic/claude-sonnet-4-5", "groq/llama-3.3-70b-versatile"], + }, + }, + }, + }); + + const run = vi.fn().mockResolvedValueOnce("sonnet success"); // Fallback succeeds + + const result = await runWithModelFallback({ + cfg, + provider: "anthropic", + model: "claude-opus-4-6", + run, + agentDir: dir, + }); + + expect(result.result).toBe("sonnet success"); + expect(run).toHaveBeenCalledTimes(1); // Primary skipped, fallback attempted + expect(run).toHaveBeenNthCalledWith(1, "anthropic", "claude-sonnet-4-5"); + }); + + it("skips same-provider models on auth cooldown but still tries no-profile fallback providers", async () => { + const { dir } = await makeAuthStoreWithCooldown("anthropic", "auth"); + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-6", + fallbacks: ["anthropic/claude-sonnet-4-5", "groq/llama-3.3-70b-versatile"], + }, + }, + }, + }); + + const run = vi.fn().mockResolvedValueOnce("groq success"); + + const result = await runWithModelFallback({ + cfg, + provider: "anthropic", + model: "claude-opus-4-6", + run, + agentDir: dir, + }); + + expect(result.result).toBe("groq success"); + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenNthCalledWith(1, "groq", "llama-3.3-70b-versatile"); + }); + + it("skips same-provider models on billing cooldown but still tries no-profile fallback providers", async () => { + const { dir } = await makeAuthStoreWithCooldown("anthropic", "billing"); + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-6", + fallbacks: ["anthropic/claude-sonnet-4-5", "groq/llama-3.3-70b-versatile"], + }, + }, + }, + }); + + const run = vi.fn().mockResolvedValueOnce("groq success"); + + const result = await runWithModelFallback({ + cfg, + provider: "anthropic", + model: "claude-opus-4-6", + run, + agentDir: dir, + }); + + expect(result.result).toBe("groq success"); + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenNthCalledWith(1, "groq", "llama-3.3-70b-versatile"); + }); + + it("tries cross-provider fallbacks when same provider has rate limit", async () => { + // Anthropic in rate limit cooldown, Groq available + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-")); + const store: AuthProfileStore = { + version: AUTH_STORE_VERSION, + profiles: { + "anthropic:default": { type: "api_key", provider: "anthropic", key: "test-key" }, + "groq:default": { type: "api_key", provider: "groq", key: "test-key" }, + }, + usageStats: { + "anthropic:default": { + // Rate-limit reason is inferred from failureCounts for cooldown windows. + cooldownUntil: Date.now() + 300000, + failureCounts: { rate_limit: 2 }, + }, + // Groq not in cooldown + }, + }; + saveAuthProfileStore(store, tmpDir); + + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-6", + fallbacks: ["anthropic/claude-sonnet-4-5", "groq/llama-3.3-70b-versatile"], + }, + }, + }, + }); + + const run = vi + .fn() + .mockRejectedValueOnce(new Error("Still rate limited")) // Sonnet still fails + .mockResolvedValueOnce("groq success"); // Groq works + + const result = await runWithModelFallback({ + cfg, + provider: "anthropic", + model: "claude-opus-4-6", + run, + agentDir: tmpDir, + }); + + expect(result.result).toBe("groq success"); + expect(run).toHaveBeenCalledTimes(2); + expect(run).toHaveBeenNthCalledWith(1, "anthropic", "claude-sonnet-4-5"); // Rate limit allows attempt + expect(run).toHaveBeenNthCalledWith(2, "groq", "llama-3.3-70b-versatile"); // Cross-provider works + }); + }); }); describe("runWithImageModelFallback", () => { diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index e59d9e9357c7..da03d88d8472 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -224,21 +224,21 @@ function resolveFallbackCandidates(params: { const configuredFallbacks = resolveAgentModelFallbackValues( params.cfg?.agents?.defaults?.model, ); - if (sameModelCandidate(normalizedPrimary, configuredPrimary)) { - return configuredFallbacks; - } - // Preserve resilience after failover: when current model is one of the - // configured fallback refs, keep traversing the configured fallback chain. - const isConfiguredFallback = configuredFallbacks.some((raw) => { - const resolved = resolveModelRefFromString({ - raw: String(raw ?? ""), - defaultProvider, - aliasIndex, + // When user runs a different provider than config, only use configured fallbacks + // if the current model is already in that chain (e.g. session on first fallback). + if (normalizedPrimary.provider !== configuredPrimary.provider) { + const isConfiguredFallback = configuredFallbacks.some((raw) => { + const resolved = resolveModelRefFromString({ + raw: String(raw ?? ""), + defaultProvider, + aliasIndex, + }); + return resolved ? sameModelCandidate(resolved.ref, normalizedPrimary) : false; }); - return resolved ? sameModelCandidate(resolved.ref, normalizedPrimary) : false; - }); - // Keep legacy override behavior for ad-hoc models outside configured chain. - return isConfiguredFallback ? configuredFallbacks : []; + return isConfiguredFallback ? configuredFallbacks : []; + } + // Same provider: always use full fallback chain (model version differences within provider). + return configuredFallbacks; })(); for (const raw of modelFallbacks) { @@ -306,6 +306,76 @@ export const _probeThrottleInternals = { resolveProbeThrottleKey, } as const; +type CooldownDecision = + | { + type: "skip"; + reason: FailoverReason; + error: string; + } + | { + type: "attempt"; + reason: FailoverReason; + markProbe: boolean; + }; + +function resolveCooldownDecision(params: { + candidate: ModelCandidate; + isPrimary: boolean; + requestedModel: boolean; + hasFallbackCandidates: boolean; + now: number; + probeThrottleKey: string; + authStore: ReturnType; + profileIds: string[]; +}): CooldownDecision { + const shouldProbe = shouldProbePrimaryDuringCooldown({ + isPrimary: params.isPrimary, + hasFallbackCandidates: params.hasFallbackCandidates, + now: params.now, + throttleKey: params.probeThrottleKey, + authStore: params.authStore, + profileIds: params.profileIds, + }); + + const inferredReason = + resolveProfilesUnavailableReason({ + store: params.authStore, + profileIds: params.profileIds, + now: params.now, + }) ?? "rate_limit"; + const isPersistentIssue = + inferredReason === "auth" || + inferredReason === "auth_permanent" || + inferredReason === "billing"; + if (isPersistentIssue) { + return { + type: "skip", + reason: inferredReason, + error: `Provider ${params.candidate.provider} has ${inferredReason} issue (skipping all models)`, + }; + } + + // For primary: try when requested model or when probe allows. + // For same-provider fallbacks: only relax cooldown on rate_limit, which + // is commonly model-scoped and can recover on a sibling model. + const shouldAttemptDespiteCooldown = + (params.isPrimary && (!params.requestedModel || shouldProbe)) || + (!params.isPrimary && inferredReason === "rate_limit"); + if (!shouldAttemptDespiteCooldown) { + return { + type: "skip", + reason: inferredReason, + error: `Provider ${params.candidate.provider} is in cooldown (all profiles unavailable)`, + }; + } + + return { + type: "attempt", + reason: inferredReason, + markProbe: params.isPrimary && shouldProbe, + }; +} + export async function runWithModelFallback(params: { cfg: OpenClawConfig | undefined; provider: string; @@ -342,41 +412,38 @@ export async function runWithModelFallback(params: { if (profileIds.length > 0 && !isAnyProfileAvailable) { // All profiles for this provider are in cooldown. - // For the primary model (i === 0), probe it if the soonest cooldown - // expiry is close or already past. This avoids staying on a fallback - // model long after the real rate-limit window clears. + const isPrimary = i === 0; + const requestedModel = + params.provider === candidate.provider && params.model === candidate.model; const now = Date.now(); const probeThrottleKey = resolveProbeThrottleKey(candidate.provider, params.agentDir); - const shouldProbe = shouldProbePrimaryDuringCooldown({ - isPrimary: i === 0, + const decision = resolveCooldownDecision({ + candidate, + isPrimary, + requestedModel, hasFallbackCandidates, now, - throttleKey: probeThrottleKey, + probeThrottleKey, authStore, profileIds, }); - if (!shouldProbe) { - const inferredReason = - resolveProfilesUnavailableReason({ - store: authStore, - profileIds, - now, - }) ?? "rate_limit"; - // Skip without attempting + + if (decision.type === "skip") { attempts.push({ provider: candidate.provider, model: candidate.model, - error: `Provider ${candidate.provider} is in cooldown (all profiles unavailable)`, - reason: inferredReason, + error: decision.error, + reason: decision.reason, }); continue; } - // Primary model probe: attempt it despite cooldown to detect recovery. - // If it fails, the error is caught below and we fall through to the - // next candidate as usual. - lastProbeAttempt.set(probeThrottleKey, now); + + if (decision.markProbe) { + lastProbeAttempt.set(probeThrottleKey, now); + } } } + try { const result = await params.run(candidate.provider, candidate.model); return { diff --git a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts index c26142158e83..4abfa4f1ab48 100644 --- a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts +++ b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts @@ -134,6 +134,116 @@ describe("models-config", () => { }); }); + it("preserves non-empty agent apiKey/baseUrl for matching providers in merge mode", async () => { + await withTempHome(async () => { + const agentDir = resolveOpenClawAgentDir(); + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile( + path.join(agentDir, "models.json"), + JSON.stringify( + { + providers: { + custom: { + baseUrl: "https://agent.example/v1", + apiKey: "AGENT_KEY", + api: "openai-responses", + models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + await ensureOpenClawModelsJson({ + models: { + mode: "merge", + providers: { + custom: { + baseUrl: "https://config.example/v1", + apiKey: "CONFIG_KEY", + api: "openai-responses", + models: [ + { + id: "config-model", + name: "Config model", + input: ["text"], + reasoning: false, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8192, + maxTokens: 2048, + }, + ], + }, + }, + }, + }); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.custom?.apiKey).toBe("AGENT_KEY"); + expect(parsed.providers.custom?.baseUrl).toBe("https://agent.example/v1"); + }); + }); + + it("uses config apiKey/baseUrl when existing agent values are empty", async () => { + await withTempHome(async () => { + const agentDir = resolveOpenClawAgentDir(); + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile( + path.join(agentDir, "models.json"), + JSON.stringify( + { + providers: { + custom: { + baseUrl: "", + apiKey: "", + api: "openai-responses", + models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + await ensureOpenClawModelsJson({ + models: { + mode: "merge", + providers: { + custom: { + baseUrl: "https://config.example/v1", + apiKey: "CONFIG_KEY", + api: "openai-responses", + models: [ + { + id: "config-model", + name: "Config model", + input: ["text"], + reasoning: false, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8192, + maxTokens: 2048, + }, + ], + }, + }, + }, + }); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.custom?.apiKey).toBe("CONFIG_KEY"); + expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1"); + }); + }); + it("refreshes stale explicit moonshot model capabilities from implicit catalog", async () => { await withTempHome(async () => { const prevKey = process.env.MOONSHOT_API_KEY; diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 4b38b8243984..3b02737eb4c4 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -142,7 +142,30 @@ export async function ensureOpenClawModelsJson( string, NonNullable[string] >; - mergedProviders = { ...existingProviders, ...providers }; + mergedProviders = {}; + for (const [key, entry] of Object.entries(existingProviders)) { + mergedProviders[key] = entry; + } + for (const [key, newEntry] of Object.entries(providers)) { + const existing = existingProviders[key] as + | (NonNullable[string] & { + apiKey?: string; + baseUrl?: string; + }) + | undefined; + if (existing) { + const preserved: Record = {}; + if (typeof existing.apiKey === "string" && existing.apiKey) { + preserved.apiKey = existing.apiKey; + } + if (typeof existing.baseUrl === "string" && existing.baseUrl) { + preserved.baseUrl = existing.baseUrl; + } + mergedProviders[key] = { ...newEntry, ...preserved }; + } else { + mergedProviders[key] = newEntry; + } + } } } diff --git a/src/agents/openclaw-tools.camera.test.ts b/src/agents/openclaw-tools.camera.test.ts index 3082c849609f..96be774b2974 100644 --- a/src/agents/openclaw-tools.camera.test.ts +++ b/src/agents/openclaw-tools.camera.test.ts @@ -43,6 +43,41 @@ beforeEach(() => { }); describe("nodes camera_snap", () => { + it("uses front/high-quality defaults when params are omitted", async () => { + callGateway.mockImplementation(async ({ method, params }) => { + if (method === "node.list") { + return mockNodeList(); + } + if (method === "node.invoke") { + expect(params).toMatchObject({ + command: "camera.snap", + params: { + facing: "front", + maxWidth: 1600, + quality: 0.95, + }, + }); + return { + payload: { + format: "jpg", + base64: "aGVsbG8=", + width: 1, + height: 1, + }, + }; + } + return unexpectedGatewayMethod(method); + }); + + const result = await executeNodes({ + action: "camera_snap", + node: NODE_ID, + }); + + const images = (result.content ?? []).filter((block) => block.type === "image"); + expect(images).toHaveLength(1); + }); + it("maps jpg payloads to image/jpeg", async () => { callGateway.mockImplementation(async ({ method }) => { if (method === "node.list") { @@ -103,6 +138,42 @@ describe("nodes camera_snap", () => { }); }); +describe("nodes notifications_list", () => { + it("invokes notifications.list and returns payload", async () => { + callGateway.mockImplementation(async ({ method, params }) => { + if (method === "node.list") { + return mockNodeList(["notifications.list"]); + } + if (method === "node.invoke") { + expect(params).toMatchObject({ + nodeId: NODE_ID, + command: "notifications.list", + params: {}, + }); + return { + payload: { + enabled: true, + connected: true, + count: 1, + notifications: [{ key: "n1", packageName: "com.example.app" }], + }, + }; + } + return unexpectedGatewayMethod(method); + }); + + const result = await executeNodes({ + action: "notifications_list", + node: NODE_ID, + }); + + expect(result.content?.[0]).toMatchObject({ + type: "text", + text: expect.stringContaining('"notifications"'), + }); + }); +}); + describe("nodes run", () => { it("passes invoke and command timeouts", async () => { callGateway.mockImplementation(async ({ method, params }) => { diff --git a/src/agents/openclaw-tools.sessions.test.ts b/src/agents/openclaw-tools.sessions.test.ts index 42a3210fa801..753426a4c516 100644 --- a/src/agents/openclaw-tools.sessions.test.ts +++ b/src/agents/openclaw-tools.sessions.test.ts @@ -91,6 +91,8 @@ describe("sessions tools", () => { expect(schemaProp("sessions_spawn", "runTimeoutSeconds").type).toBe("number"); expect(schemaProp("sessions_spawn", "thread").type).toBe("boolean"); expect(schemaProp("sessions_spawn", "mode").type).toBe("string"); + expect(schemaProp("sessions_spawn", "runtime").type).toBe("string"); + expect(schemaProp("sessions_spawn", "cwd").type).toBe("string"); expect(schemaProp("subagents", "recentMinutes").type).toBe("number"); }); diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 638b6c24bb82..a109af6d89f7 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { classifyFailoverReason, isAuthErrorMessage, + isAuthPermanentErrorMessage, isBillingErrorMessage, isCloudCodeAssistFormatError, isCloudflareOrHtmlErrorPage, @@ -16,6 +17,39 @@ import { parseImageSizeError, } from "./pi-embedded-helpers.js"; +describe("isAuthPermanentErrorMessage", () => { + it("matches permanent auth failure patterns", () => { + const samples = [ + "invalid_api_key", + "api key revoked", + "api key deactivated", + "key has been disabled", + "key has been revoked", + "account has been deactivated", + "could not authenticate api key", + "could not validate credentials", + "API_KEY_REVOKED", + "api_key_deleted", + ]; + for (const sample of samples) { + expect(isAuthPermanentErrorMessage(sample)).toBe(true); + } + }); + it("does not match transient auth errors", () => { + const samples = [ + "unauthorized", + "invalid token", + "authentication failed", + "forbidden", + "access denied", + "token has expired", + ]; + for (const sample of samples) { + expect(isAuthPermanentErrorMessage(sample)).toBe(false); + } + }); +}); + describe("isAuthErrorMessage", () => { it("matches credential validation errors", () => { const samples = [ @@ -480,6 +514,12 @@ describe("classifyFailoverReason", () => { ), ).toBe("rate_limit"); }); + it("classifies permanent auth errors as auth_permanent", () => { + expect(classifyFailoverReason("invalid_api_key")).toBe("auth_permanent"); + expect(classifyFailoverReason("Your api key has been revoked")).toBe("auth_permanent"); + expect(classifyFailoverReason("key has been disabled")).toBe("auth_permanent"); + expect(classifyFailoverReason("account has been deactivated")).toBe("auth_permanent"); + }); it("classifies JSON api_error internal server failures as timeout", () => { expect( classifyFailoverReason( diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index 06bf2b1938bc..dd10fdca3d19 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -16,6 +16,7 @@ export { getApiErrorPayloadFingerprint, isAuthAssistantError, isAuthErrorMessage, + isAuthPermanentErrorMessage, isModelNotFoundErrorMessage, isBillingAssistantError, parseApiErrorInfo, diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 6eea521ede17..246f6c0ad246 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -649,6 +649,14 @@ const ERROR_PATTERNS = { "plans & billing", "insufficient balance", ], + authPermanent: [ + /api[_ ]?key[_ ]?(?:revoked|invalid|deactivated|deleted)/i, + "invalid_api_key", + "key has been disabled", + "key has been revoked", + "account has been deactivated", + /could not (?:authenticate|validate).*(?:api[_ ]?key|credentials)/i, + ], auth: [ /invalid[_ ]?api[_ ]?key/, "incorrect api key", @@ -755,6 +763,10 @@ export function isBillingAssistantError(msg: AssistantMessage | undefined): bool return isBillingErrorMessage(msg.errorMessage ?? ""); } +export function isAuthPermanentErrorMessage(raw: string): boolean { + return matchesErrorPatterns(raw, ERROR_PATTERNS.authPermanent); +} + export function isAuthErrorMessage(raw: string): boolean { return matchesErrorPatterns(raw, ERROR_PATTERNS.auth); } @@ -899,6 +911,9 @@ export function classifyFailoverReason(raw: string): FailoverReason | null { if (isTimeoutErrorMessage(raw)) { return "timeout"; } + if (isAuthPermanentErrorMessage(raw)) { + return "auth_permanent"; + } if (isAuthErrorMessage(raw)) { return "auth"; } diff --git a/src/agents/pi-embedded-helpers/types.ts b/src/agents/pi-embedded-helpers/types.ts index 2753e979eb28..2440473d9f6f 100644 --- a/src/agents/pi-embedded-helpers/types.ts +++ b/src/agents/pi-embedded-helpers/types.ts @@ -2,6 +2,7 @@ export type EmbeddedContextFile = { path: string; content: string }; export type FailoverReason = | "auth" + | "auth_permanent" | "format" | "rate_limit" | "billing" diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 44775949e1a8..94ae32be00b5 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -499,6 +499,7 @@ export async function compactEmbeddedPiSessionDirect( docsPath: docsPath ?? undefined, ttsHint, promptMode, + acpEnabled: params.config?.acp?.enabled !== false, runtimeInfo, reactionGuidance, messageToolHints, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 74916b9933d0..5bc954207d46 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -551,6 +551,7 @@ export async function runEmbeddedAttempt( workspaceNotes, reactionGuidance, promptMode, + acpEnabled: params.config?.acp?.enabled !== false, runtimeInfo, messageToolHints, sandboxInfo, diff --git a/src/agents/pi-embedded-runner/system-prompt.ts b/src/agents/pi-embedded-runner/system-prompt.ts index 67df44936952..ef246d1af23e 100644 --- a/src/agents/pi-embedded-runner/system-prompt.ts +++ b/src/agents/pi-embedded-runner/system-prompt.ts @@ -28,6 +28,8 @@ export function buildEmbeddedSystemPrompt(params: { workspaceNotes?: string[]; /** Controls which hardcoded sections to include. Defaults to "full". */ promptMode?: PromptMode; + /** Whether ACP-specific routing guidance should be included. Defaults to true. */ + acpEnabled?: boolean; runtimeInfo: { agentId?: string; host: string; @@ -67,6 +69,7 @@ export function buildEmbeddedSystemPrompt(params: { workspaceNotes: params.workspaceNotes, reactionGuidance: params.reactionGuidance, promptMode: params.promptMode, + acpEnabled: params.acpEnabled, runtimeInfo: params.runtimeInfo, messageToolHints: params.messageToolHints, sandboxInfo: params.sandboxInfo, diff --git a/src/agents/pi-tool-definition-adapter.test.ts b/src/agents/pi-tool-definition-adapter.test.ts index 1b11bbf49be5..6def07167cbb 100644 --- a/src/agents/pi-tool-definition-adapter.test.ts +++ b/src/agents/pi-tool-definition-adapter.test.ts @@ -25,6 +25,15 @@ async function executeThrowingTool(name: string, callId: string) { return await def.execute(callId, {}, undefined, undefined, extensionContext); } +async function executeTool(tool: AgentTool, callId: string) { + const defs = toToolDefinitions([tool]); + const def = defs[0]; + if (!def) { + throw new Error("missing tool definition"); + } + return await def.execute(callId, {}, undefined, undefined, extensionContext); +} + describe("pi tool definition adapter", () => { it("wraps tool errors into a tool result", async () => { const result = await executeThrowingTool("boom", "call1"); @@ -46,4 +55,46 @@ describe("pi tool definition adapter", () => { error: "nope", }); }); + + it("coerces details-only tool results to include content", async () => { + const tool = { + name: "memory_query", + label: "Memory Query", + description: "returns details only", + parameters: Type.Object({}), + execute: (async () => ({ + details: { + hits: [{ id: "a1", score: 0.9 }], + }, + })) as unknown as AgentTool["execute"], + } satisfies AgentTool; + + const result = await executeTool(tool, "call3"); + expect(result.details).toEqual({ + hits: [{ id: "a1", score: 0.9 }], + }); + expect(result.content[0]).toMatchObject({ type: "text" }); + expect((result.content[0] as { text?: string }).text).toContain('"hits"'); + }); + + it("coerces non-standard object results to include content", async () => { + const tool = { + name: "memory_query_raw", + label: "Memory Query Raw", + description: "returns plain object", + parameters: Type.Object({}), + execute: (async () => ({ + count: 2, + ids: ["m1", "m2"], + })) as unknown as AgentTool["execute"], + } satisfies AgentTool; + + const result = await executeTool(tool, "call4"); + expect(result.details).toEqual({ + count: 2, + ids: ["m1", "m2"], + }); + expect(result.content[0]).toMatchObject({ type: "text" }); + expect((result.content[0] as { text?: string }).text).toContain('"count"'); + }); }); diff --git a/src/agents/pi-tool-definition-adapter.ts b/src/agents/pi-tool-definition-adapter.ts index 9f7e1e06ee6e..196a1430efc4 100644 --- a/src/agents/pi-tool-definition-adapter.ts +++ b/src/agents/pi-tool-definition-adapter.ts @@ -82,6 +82,56 @@ function describeToolExecutionError(err: unknown): { return { message: String(err) }; } +function stringifyToolPayload(payload: unknown): string { + if (typeof payload === "string") { + return payload; + } + try { + const encoded = JSON.stringify(payload, null, 2); + if (typeof encoded === "string") { + return encoded; + } + } catch { + // Fall through to String(payload) for non-serializable values. + } + return String(payload); +} + +function normalizeToolExecutionResult(params: { + toolName: string; + result: unknown; +}): AgentToolResult { + const { toolName, result } = params; + if (result && typeof result === "object") { + const record = result as Record; + if (Array.isArray(record.content)) { + return result as AgentToolResult; + } + logDebug(`tools: ${toolName} returned non-standard result (missing content[]); coercing`); + const details = "details" in record ? record.details : record; + const safeDetails = details ?? { status: "ok", tool: toolName }; + return { + content: [ + { + type: "text", + text: stringifyToolPayload(safeDetails), + }, + ], + details: safeDetails, + }; + } + const safeDetails = result ?? { status: "ok", tool: toolName }; + return { + content: [ + { + type: "text", + text: stringifyToolPayload(safeDetails), + }, + ], + details: safeDetails, + }; +} + function splitToolExecuteArgs(args: ToolExecuteArgsAny): { toolCallId: string; params: unknown; @@ -131,7 +181,11 @@ export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] { } executeParams = hookOutcome.params; } - const result = await tool.execute(toolCallId, executeParams, signal, onUpdate); + const rawResult = await tool.execute(toolCallId, executeParams, signal, onUpdate); + const result = normalizeToolExecutionResult({ + toolName: normalizedName, + result: rawResult, + }); const afterParams = beforeHookWrapped ? (consumeAdjustedParamsForToolCall(toolCallId) ?? executeParams) : executeParams; diff --git a/src/agents/pi-tools.message-provider-policy.test.ts b/src/agents/pi-tools.message-provider-policy.test.ts new file mode 100644 index 000000000000..0bcdd5144f0f --- /dev/null +++ b/src/agents/pi-tools.message-provider-policy.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { createOpenClawCodingTools } from "./pi-tools.js"; + +describe("createOpenClawCodingTools message provider policy", () => { + it.each(["voice", "VOICE", " Voice "])( + "does not expose tts tool for normalized voice provider: %s", + (messageProvider) => { + const tools = createOpenClawCodingTools({ messageProvider }); + const names = new Set(tools.map((tool) => tool.name)); + expect(names.has("tts")).toBe(false); + }, + ); + + it("keeps tts tool for non-voice providers", () => { + const tools = createOpenClawCodingTools({ messageProvider: "discord" }); + const names = new Set(tools.map((tool) => tool.name)); + expect(names.has("tts")).toBe(true); + }); +}); diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 9db63efd9827..4d1f1676d410 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -67,6 +67,31 @@ function isOpenAIProvider(provider?: string) { return normalized === "openai" || normalized === "openai-codex"; } +const TOOL_DENY_BY_MESSAGE_PROVIDER: Readonly> = { + voice: ["tts"], +}; + +function normalizeMessageProvider(messageProvider?: string): string | undefined { + const normalized = messageProvider?.trim().toLowerCase(); + return normalized && normalized.length > 0 ? normalized : undefined; +} + +function applyMessageProviderToolPolicy( + tools: AnyAgentTool[], + messageProvider?: string, +): AnyAgentTool[] { + const normalizedProvider = normalizeMessageProvider(messageProvider); + if (!normalizedProvider) { + return tools; + } + const deniedTools = TOOL_DENY_BY_MESSAGE_PROVIDER[normalizedProvider]; + if (!deniedTools || deniedTools.length === 0) { + return tools; + } + const deniedSet = new Set(deniedTools); + return tools.filter((tool) => !deniedSet.has(tool.name)); +} + function isApplyPatchAllowedForModel(params: { modelProvider?: string; modelId?: string; @@ -486,9 +511,10 @@ export function createOpenClawCodingTools(options?: { senderIsOwner: options?.senderIsOwner, }), ]; + const toolsForMessageProvider = applyMessageProviderToolPolicy(tools, options?.messageProvider); // Security: treat unknown/undefined as unauthorized (opt-in, not opt-out) const senderIsOwner = options?.senderIsOwner === true; - const toolsByAuthorization = applyOwnerOnlyToolPolicy(tools, senderIsOwner); + const toolsByAuthorization = applyOwnerOnlyToolPolicy(toolsForMessageProvider, senderIsOwner); const subagentFiltered = applyToolPolicyPipeline({ tools: toolsByAuthorization, toolMeta: (tool) => getPluginToolMeta(tool), diff --git a/src/agents/pi-tools.workspace-paths.test.ts b/src/agents/pi-tools.workspace-paths.test.ts index 6fe98ff03f8f..4efa494555eb 100644 --- a/src/agents/pi-tools.workspace-paths.test.ts +++ b/src/agents/pi-tools.workspace-paths.test.ts @@ -151,6 +151,46 @@ describe("workspace path resolution", () => { ).rejects.toThrow(/Path escapes sandbox root/i); }); }); + + it("rejects hardlinked file aliases when workspaceOnly is enabled", async () => { + if (process.platform === "win32") { + return; + } + await withTempDir("openclaw-ws-", async (workspaceDir) => { + const cfg: OpenClawConfig = { tools: { fs: { workspaceOnly: true } } }; + const tools = createOpenClawCodingTools({ workspaceDir, config: cfg }); + const { readTool, writeTool } = expectReadWriteEditTools(tools); + const outsidePath = path.join( + path.dirname(workspaceDir), + `outside-hardlink-${process.pid}-${Date.now()}.txt`, + ); + const hardlinkPath = path.join(workspaceDir, "linked.txt"); + await fs.writeFile(outsidePath, "top-secret", "utf8"); + try { + try { + await fs.link(outsidePath, hardlinkPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EXDEV") { + return; + } + throw err; + } + await expect(readTool.execute("ws-read-hardlink", { path: "linked.txt" })).rejects.toThrow( + /hardlink|sandbox/i, + ); + await expect( + writeTool.execute("ws-write-hardlink", { + path: "linked.txt", + content: "pwned", + }), + ).rejects.toThrow(/hardlink|sandbox/i); + expect(await fs.readFile(outsidePath, "utf8")).toBe("top-secret"); + } finally { + await fs.rm(hardlinkPath, { force: true }); + await fs.rm(outsidePath, { force: true }); + } + }); + }); }); describe("sandboxed workspace paths", () => { diff --git a/src/agents/sandbox-paths.ts b/src/agents/sandbox-paths.ts index 761106e85740..7cb026c28a4e 100644 --- a/src/agents/sandbox-paths.ts +++ b/src/agents/sandbox-paths.ts @@ -1,8 +1,8 @@ -import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, URL } from "node:url"; -import { isNotFoundPathError, isPathInside } from "../infra/path-guards.js"; +import { assertNoPathAliasEscape, type PathAliasPolicy } from "../infra/path-alias-guards.js"; +import { isPathInside } from "../infra/path-guards.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; @@ -61,11 +61,19 @@ export async function assertSandboxPath(params: { filePath: string; cwd: string; root: string; - allowFinalSymlink?: boolean; + allowFinalSymlinkForUnlink?: boolean; + allowFinalHardlinkForUnlink?: boolean; }) { const resolved = resolveSandboxPath(params); - await assertNoSymlinkEscape(resolved.relative, path.resolve(params.root), { - allowFinalSymlink: params.allowFinalSymlink, + const policy: PathAliasPolicy = { + allowFinalSymlinkForUnlink: params.allowFinalSymlinkForUnlink, + allowFinalHardlinkForUnlink: params.allowFinalHardlinkForUnlink, + }; + await assertNoPathAliasEscape({ + absolutePath: resolved.resolved, + rootPath: path.resolve(params.root), + boundaryLabel: "sandbox root", + policy, }); return resolved; } @@ -194,76 +202,11 @@ async function assertNoTmpAliasEscape(params: { filePath: string; tmpRoot: string; }): Promise { - await assertNoSymlinkEscape(path.relative(params.tmpRoot, params.filePath), params.tmpRoot); - await assertNoHardlinkedFinalPath(params.filePath, params.tmpRoot); -} - -async function assertNoHardlinkedFinalPath(filePath: string, tmpRoot: string): Promise { - let stat: Awaited>; - try { - stat = await fs.stat(filePath); - } catch (err) { - if (isNotFoundPathError(err)) { - return; - } - throw err; - } - if (!stat.isFile()) { - return; - } - if (stat.nlink > 1) { - throw new Error( - `Hardlinked tmp media path is not allowed under tmp root (${shortPath(tmpRoot)}): ${shortPath(filePath)}`, - ); - } -} - -async function assertNoSymlinkEscape( - relative: string, - root: string, - options?: { allowFinalSymlink?: boolean }, -) { - if (!relative) { - return; - } - const rootReal = await tryRealpath(root); - const parts = relative.split(path.sep).filter(Boolean); - let current = root; - for (let idx = 0; idx < parts.length; idx += 1) { - const part = parts[idx]; - const isLast = idx === parts.length - 1; - current = path.join(current, part); - try { - const stat = await fs.lstat(current); - if (stat.isSymbolicLink()) { - // Unlinking a symlink itself is safe even if it points outside the root. What we - // must prevent is traversing through a symlink to reach targets outside root. - if (options?.allowFinalSymlink && isLast) { - return; - } - const target = await tryRealpath(current); - if (!isPathInside(rootReal, target)) { - throw new Error( - `Symlink escapes sandbox root (${shortPath(rootReal)}): ${shortPath(current)}`, - ); - } - current = target; - } - } catch (err) { - if (isNotFoundPathError(err)) { - return; - } - throw err; - } - } -} - -async function tryRealpath(value: string): Promise { - try { - return await fs.realpath(value); - } catch { - return path.resolve(value); - } + await assertNoPathAliasEscape({ + absolutePath: params.filePath, + rootPath: params.tmpRoot, + boundaryLabel: "tmp root", + }); } function shortPath(value: string) { diff --git a/src/agents/sandbox/fs-bridge.test.ts b/src/agents/sandbox/fs-bridge.test.ts index d3bcd735e9ea..f5c9aaedd6da 100644 --- a/src/agents/sandbox/fs-bridge.test.ts +++ b/src/agents/sandbox/fs-bridge.test.ts @@ -195,6 +195,42 @@ describe("sandbox fs bridge shell compatibility", () => { await fs.rm(stateDir, { recursive: true, force: true }); }); + it("rejects pre-existing host hardlink escapes before docker exec", async () => { + if (process.platform === "win32") { + return; + } + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fs-bridge-hardlink-")); + const workspaceDir = path.join(stateDir, "workspace"); + const outsideDir = path.join(stateDir, "outside"); + const outsideFile = path.join(outsideDir, "secret.txt"); + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.writeFile(outsideFile, "classified"); + const hardlinkPath = path.join(workspaceDir, "link.txt"); + try { + try { + await fs.link(outsideFile, hardlinkPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EXDEV") { + return; + } + throw err; + } + + const bridge = createSandboxFsBridge({ + sandbox: createSandbox({ + workspaceDir, + agentWorkspaceDir: workspaceDir, + }), + }); + + await expect(bridge.readFile({ filePath: "link.txt" })).rejects.toThrow(/hardlink|sandbox/i); + expect(mockedExecDockerRaw).not.toHaveBeenCalled(); + } finally { + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + it("rejects container-canonicalized paths outside allowed mounts", async () => { mockedExecDockerRaw.mockImplementation(async (args) => { const script = getDockerScript(args); diff --git a/src/agents/sandbox/fs-bridge.ts b/src/agents/sandbox/fs-bridge.ts index 226fc39ca1d4..23ebcce51b1a 100644 --- a/src/agents/sandbox/fs-bridge.ts +++ b/src/agents/sandbox/fs-bridge.ts @@ -1,6 +1,8 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { isNotFoundPathError, isPathInside } from "../../infra/path-guards.js"; +import { + assertNoPathAliasEscape, + PATH_ALIAS_POLICIES, + type PathAliasPolicy, +} from "../../infra/path-alias-guards.js"; import { execDockerRaw, type ExecDockerRawResult } from "./docker.js"; import { buildSandboxFsMounts, @@ -20,7 +22,7 @@ type RunCommandOptions = { type PathSafetyOptions = { action: string; - allowFinalSymlink?: boolean; + aliasPolicy?: PathAliasPolicy; requireWritable?: boolean; }; @@ -150,7 +152,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { await this.assertPathSafety(target, { action: "remove files", requireWritable: true, - allowFinalSymlink: true, + aliasPolicy: PATH_ALIAS_POLICIES.unlinkTarget, }); const flags = [params.force === false ? "" : "-f", params.recursive ? "-r" : ""].filter( Boolean, @@ -175,7 +177,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { await this.assertPathSafety(from, { action: "rename files", requireWritable: true, - allowFinalSymlink: true, + aliasPolicy: PATH_ALIAS_POLICIES.unlinkTarget, }); await this.assertPathSafety(to, { action: "rename files", @@ -252,15 +254,16 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { ); } - await assertNoHostSymlinkEscape({ + await assertNoPathAliasEscape({ absolutePath: target.hostPath, rootPath: lexicalMount.hostRoot, - allowFinalSymlink: options.allowFinalSymlink === true, + boundaryLabel: "sandbox mount root", + policy: options.aliasPolicy, }); const canonicalContainerPath = await this.resolveCanonicalContainerPath({ containerPath: target.containerPath, - allowFinalSymlink: options.allowFinalSymlink === true, + allowFinalSymlinkForUnlink: options.aliasPolicy?.allowFinalSymlinkForUnlink === true, }); const canonicalMount = this.resolveMountByContainerPath(canonicalContainerPath); if (!canonicalMount) { @@ -287,7 +290,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { private async resolveCanonicalContainerPath(params: { containerPath: string; - allowFinalSymlink: boolean; + allowFinalSymlinkForUnlink: boolean; }): Promise { const script = [ "set -eu", @@ -308,7 +311,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { 'printf "%s%s\\n" "$canonical" "$suffix"', ].join("\n"); const result = await this.runCommand(script, { - args: [params.containerPath, params.allowFinalSymlink ? "1" : "0"], + args: [params.containerPath, params.allowFinalSymlinkForUnlink ? "1" : "0"], }); const canonical = result.stdout.toString("utf8").trim(); if (!canonical.startsWith("/")) { @@ -351,53 +354,3 @@ function coerceStatType(typeRaw?: string): "file" | "directory" | "other" { } return "other"; } - -async function assertNoHostSymlinkEscape(params: { - absolutePath: string; - rootPath: string; - allowFinalSymlink: boolean; -}): Promise { - const root = path.resolve(params.rootPath); - const target = path.resolve(params.absolutePath); - if (!isPathInside(root, target)) { - throw new Error(`Sandbox path escapes mount root (${root}): ${params.absolutePath}`); - } - const relative = path.relative(root, target); - if (!relative) { - return; - } - const rootReal = await tryRealpath(root); - const parts = relative.split(path.sep).filter(Boolean); - let current = root; - for (let idx = 0; idx < parts.length; idx += 1) { - current = path.join(current, parts[idx] ?? ""); - const isLast = idx === parts.length - 1; - try { - const stat = await fs.lstat(current); - if (!stat.isSymbolicLink()) { - continue; - } - if (params.allowFinalSymlink && isLast) { - return; - } - const symlinkTarget = await tryRealpath(current); - if (!isPathInside(rootReal, symlinkTarget)) { - throw new Error(`Symlink escapes sandbox mount root (${rootReal}): ${current}`); - } - current = symlinkTarget; - } catch (error) { - if (isNotFoundPathError(error)) { - return; - } - throw error; - } - } -} - -async function tryRealpath(value: string): Promise { - try { - return await fs.realpath(value); - } catch { - return path.resolve(value); - } -} diff --git a/src/agents/skills/plugin-skills.test.ts b/src/agents/skills/plugin-skills.test.ts new file mode 100644 index 000000000000..4747d59bf5c9 --- /dev/null +++ b/src/agents/skills/plugin-skills.test.ts @@ -0,0 +1,103 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { PluginManifestRegistry } from "../../plugins/manifest-registry.js"; +import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js"; + +const hoisted = vi.hoisted(() => ({ + loadPluginManifestRegistry: vi.fn(), +})); + +vi.mock("../../plugins/manifest-registry.js", () => ({ + loadPluginManifestRegistry: (...args: unknown[]) => hoisted.loadPluginManifestRegistry(...args), +})); + +const { resolvePluginSkillDirs } = await import("./plugin-skills.js"); + +const tempDirs = createTrackedTempDirs(); + +function buildRegistry(params: { acpxRoot: string; helperRoot: string }): PluginManifestRegistry { + return { + diagnostics: [], + plugins: [ + { + id: "acpx", + name: "ACPX Runtime", + channels: [], + providers: [], + skills: ["./skills"], + origin: "workspace", + rootDir: params.acpxRoot, + source: params.acpxRoot, + manifestPath: path.join(params.acpxRoot, "openclaw.plugin.json"), + }, + { + id: "helper", + name: "Helper", + channels: [], + providers: [], + skills: ["./skills"], + origin: "workspace", + rootDir: params.helperRoot, + source: params.helperRoot, + manifestPath: path.join(params.helperRoot, "openclaw.plugin.json"), + }, + ], + }; +} + +afterEach(async () => { + hoisted.loadPluginManifestRegistry.mockReset(); + await tempDirs.cleanup(); +}); + +describe("resolvePluginSkillDirs", () => { + it("keeps acpx plugin skills when ACP is enabled", async () => { + const workspaceDir = await tempDirs.make("openclaw-"); + const acpxRoot = await tempDirs.make("openclaw-acpx-plugin-"); + const helperRoot = await tempDirs.make("openclaw-helper-plugin-"); + await fs.mkdir(path.join(acpxRoot, "skills"), { recursive: true }); + await fs.mkdir(path.join(helperRoot, "skills"), { recursive: true }); + + hoisted.loadPluginManifestRegistry.mockReturnValue( + buildRegistry({ + acpxRoot, + helperRoot, + }), + ); + + const dirs = resolvePluginSkillDirs({ + workspaceDir, + config: { + acp: { enabled: true }, + } as OpenClawConfig, + }); + + expect(dirs).toEqual([path.resolve(acpxRoot, "skills"), path.resolve(helperRoot, "skills")]); + }); + + it("skips acpx plugin skills when ACP is disabled", async () => { + const workspaceDir = await tempDirs.make("openclaw-"); + const acpxRoot = await tempDirs.make("openclaw-acpx-plugin-"); + const helperRoot = await tempDirs.make("openclaw-helper-plugin-"); + await fs.mkdir(path.join(acpxRoot, "skills"), { recursive: true }); + await fs.mkdir(path.join(helperRoot, "skills"), { recursive: true }); + + hoisted.loadPluginManifestRegistry.mockReturnValue( + buildRegistry({ + acpxRoot, + helperRoot, + }), + ); + + const dirs = resolvePluginSkillDirs({ + workspaceDir, + config: { + acp: { enabled: false }, + } as OpenClawConfig, + }); + + expect(dirs).toEqual([path.resolve(helperRoot, "skills")]); + }); +}); diff --git a/src/agents/skills/plugin-skills.ts b/src/agents/skills/plugin-skills.ts index 90c8711cd744..594bfcdabb3f 100644 --- a/src/agents/skills/plugin-skills.ts +++ b/src/agents/skills/plugin-skills.ts @@ -27,6 +27,7 @@ export function resolvePluginSkillDirs(params: { return []; } const normalizedPlugins = normalizePluginsConfig(params.config?.plugins); + const acpEnabled = params.config?.acp?.enabled !== false; const memorySlot = normalizedPlugins.slots.memory; let selectedMemoryPluginId: string | null = null; const seen = new Set(); @@ -45,6 +46,10 @@ export function resolvePluginSkillDirs(params: { if (!enableState.enabled) { continue; } + // ACP router skills should not be attached when ACP is explicitly disabled. + if (!acpEnabled && record.id === "acpx") { + continue; + } const memoryDecision = resolveMemorySlotDecision({ id: record.id, kind: record.kind, diff --git a/src/agents/subagent-announce-dispatch.test.ts b/src/agents/subagent-announce-dispatch.test.ts new file mode 100644 index 000000000000..fcc2f992e2b7 --- /dev/null +++ b/src/agents/subagent-announce-dispatch.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from "vitest"; +import { + mapQueueOutcomeToDeliveryResult, + runSubagentAnnounceDispatch, +} from "./subagent-announce-dispatch.js"; + +describe("mapQueueOutcomeToDeliveryResult", () => { + it("maps steered to delivered", () => { + expect(mapQueueOutcomeToDeliveryResult("steered")).toEqual({ + delivered: true, + path: "steered", + }); + }); + + it("maps queued to delivered", () => { + expect(mapQueueOutcomeToDeliveryResult("queued")).toEqual({ + delivered: true, + path: "queued", + }); + }); + + it("maps none to not-delivered", () => { + expect(mapQueueOutcomeToDeliveryResult("none")).toEqual({ + delivered: false, + path: "none", + }); + }); +}); + +describe("runSubagentAnnounceDispatch", () => { + it("uses queue-first ordering for non-completion mode", async () => { + const queue = vi.fn(async () => "none" as const); + const direct = vi.fn(async () => ({ delivered: true, path: "direct" as const })); + + const result = await runSubagentAnnounceDispatch({ + expectsCompletionMessage: false, + queue, + direct, + }); + + expect(queue).toHaveBeenCalledTimes(1); + expect(direct).toHaveBeenCalledTimes(1); + expect(result.delivered).toBe(true); + expect(result.path).toBe("direct"); + expect(result.phases).toEqual([ + { phase: "queue-primary", delivered: false, path: "none", error: undefined }, + { phase: "direct-primary", delivered: true, path: "direct", error: undefined }, + ]); + }); + + it("short-circuits direct send when non-completion queue delivers", async () => { + const queue = vi.fn(async () => "queued" as const); + const direct = vi.fn(async () => ({ delivered: true, path: "direct" as const })); + + const result = await runSubagentAnnounceDispatch({ + expectsCompletionMessage: false, + queue, + direct, + }); + + expect(queue).toHaveBeenCalledTimes(1); + expect(direct).not.toHaveBeenCalled(); + expect(result.path).toBe("queued"); + expect(result.phases).toEqual([ + { phase: "queue-primary", delivered: true, path: "queued", error: undefined }, + ]); + }); + + it("uses direct-first ordering for completion mode", async () => { + const queue = vi.fn(async () => "queued" as const); + const direct = vi.fn(async () => ({ delivered: true, path: "direct" as const })); + + const result = await runSubagentAnnounceDispatch({ + expectsCompletionMessage: true, + queue, + direct, + }); + + expect(direct).toHaveBeenCalledTimes(1); + expect(queue).not.toHaveBeenCalled(); + expect(result.path).toBe("direct"); + expect(result.phases).toEqual([ + { phase: "direct-primary", delivered: true, path: "direct", error: undefined }, + ]); + }); + + it("falls back to queue when completion direct send fails", async () => { + const queue = vi.fn(async () => "steered" as const); + const direct = vi.fn(async () => ({ + delivered: false, + path: "direct" as const, + error: "network", + })); + + const result = await runSubagentAnnounceDispatch({ + expectsCompletionMessage: true, + queue, + direct, + }); + + expect(direct).toHaveBeenCalledTimes(1); + expect(queue).toHaveBeenCalledTimes(1); + expect(result.path).toBe("steered"); + expect(result.phases).toEqual([ + { phase: "direct-primary", delivered: false, path: "direct", error: "network" }, + { phase: "queue-fallback", delivered: true, path: "steered", error: undefined }, + ]); + }); + + it("returns direct failure when completion fallback queue cannot deliver", async () => { + const queue = vi.fn(async () => "none" as const); + const direct = vi.fn(async () => ({ + delivered: false, + path: "direct" as const, + error: "failed", + })); + + const result = await runSubagentAnnounceDispatch({ + expectsCompletionMessage: true, + queue, + direct, + }); + + expect(result).toMatchObject({ + delivered: false, + path: "direct", + error: "failed", + }); + expect(result.phases).toEqual([ + { phase: "direct-primary", delivered: false, path: "direct", error: "failed" }, + { phase: "queue-fallback", delivered: false, path: "none", error: undefined }, + ]); + }); + + it("returns none immediately when signal is already aborted", async () => { + const queue = vi.fn(async () => "none" as const); + const direct = vi.fn(async () => ({ delivered: true, path: "direct" as const })); + const controller = new AbortController(); + controller.abort(); + + const result = await runSubagentAnnounceDispatch({ + expectsCompletionMessage: true, + signal: controller.signal, + queue, + direct, + }); + + expect(queue).not.toHaveBeenCalled(); + expect(direct).not.toHaveBeenCalled(); + expect(result).toEqual({ + delivered: false, + path: "none", + phases: [], + }); + }); +}); diff --git a/src/agents/subagent-announce-dispatch.ts b/src/agents/subagent-announce-dispatch.ts new file mode 100644 index 000000000000..93aa0dd90925 --- /dev/null +++ b/src/agents/subagent-announce-dispatch.ts @@ -0,0 +1,104 @@ +export type SubagentDeliveryPath = "queued" | "steered" | "direct" | "none"; + +export type SubagentAnnounceQueueOutcome = "steered" | "queued" | "none"; + +export type SubagentAnnounceDeliveryResult = { + delivered: boolean; + path: SubagentDeliveryPath; + error?: string; + phases?: SubagentAnnounceDispatchPhaseResult[]; +}; + +export type SubagentAnnounceDispatchPhase = "queue-primary" | "direct-primary" | "queue-fallback"; + +export type SubagentAnnounceDispatchPhaseResult = { + phase: SubagentAnnounceDispatchPhase; + delivered: boolean; + path: SubagentDeliveryPath; + error?: string; +}; + +export function mapQueueOutcomeToDeliveryResult( + outcome: SubagentAnnounceQueueOutcome, +): SubagentAnnounceDeliveryResult { + if (outcome === "steered") { + return { + delivered: true, + path: "steered", + }; + } + if (outcome === "queued") { + return { + delivered: true, + path: "queued", + }; + } + return { + delivered: false, + path: "none", + }; +} + +export async function runSubagentAnnounceDispatch(params: { + expectsCompletionMessage: boolean; + signal?: AbortSignal; + queue: () => Promise; + direct: () => Promise; +}): Promise { + const phases: SubagentAnnounceDispatchPhaseResult[] = []; + const appendPhase = ( + phase: SubagentAnnounceDispatchPhase, + result: SubagentAnnounceDeliveryResult, + ) => { + phases.push({ + phase, + delivered: result.delivered, + path: result.path, + error: result.error, + }); + }; + const withPhases = (result: SubagentAnnounceDeliveryResult): SubagentAnnounceDeliveryResult => ({ + ...result, + phases, + }); + + if (params.signal?.aborted) { + return withPhases({ + delivered: false, + path: "none", + }); + } + + if (!params.expectsCompletionMessage) { + const primaryQueue = mapQueueOutcomeToDeliveryResult(await params.queue()); + appendPhase("queue-primary", primaryQueue); + if (primaryQueue.delivered) { + return withPhases(primaryQueue); + } + + const primaryDirect = await params.direct(); + appendPhase("direct-primary", primaryDirect); + return withPhases(primaryDirect); + } + + const primaryDirect = await params.direct(); + appendPhase("direct-primary", primaryDirect); + if (primaryDirect.delivered) { + return withPhases(primaryDirect); + } + + if (params.signal?.aborted) { + return withPhases({ + delivered: false, + path: "none", + }); + } + + const fallbackQueue = mapQueueOutcomeToDeliveryResult(await params.queue()); + appendPhase("queue-fallback", fallbackQueue); + if (fallbackQueue.delivered) { + return withPhases(fallbackQueue); + } + + return withPhases(primaryDirect); +} diff --git a/src/agents/subagent-announce.format.test.ts b/src/agents/subagent-announce.format.test.ts index 91f4b0d6752d..8952e82cc687 100644 --- a/src/agents/subagent-announce.format.test.ts +++ b/src/agents/subagent-announce.format.test.ts @@ -825,6 +825,47 @@ describe("subagent announce formatting", () => { } }); + it("routes manual completion direct-send for telegram forum topics", async () => { + sendSpy.mockClear(); + agentSpy.mockClear(); + sessionStore = { + "agent:main:subagent:test": { + sessionId: "child-session-telegram-topic", + }, + "agent:main:main": { + sessionId: "requester-session-telegram-topic", + lastChannel: "telegram", + lastTo: "123:topic:999", + lastThreadId: 999, + }, + }; + chatHistoryMock.mockResolvedValueOnce({ + messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }], + }); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-telegram-topic", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { + channel: "telegram", + to: "123", + threadId: 42, + }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(agentSpy).not.toHaveBeenCalled(); + const call = sendSpy.mock.calls[0]?.[0] as { params?: Record }; + expect(call?.params?.channel).toBe("telegram"); + expect(call?.params?.to).toBe("123"); + expect(call?.params?.threadId).toBe("42"); + }); + it("uses hook-provided thread target across requester thread variants", async () => { const cases = [ { diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index 7d7fd7ceb48b..0d2f961c01e4 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -32,6 +32,10 @@ import { queueEmbeddedPiMessage, waitForEmbeddedPiRunEnd, } from "./pi-embedded.js"; +import { + runSubagentAnnounceDispatch, + type SubagentAnnounceDeliveryResult, +} from "./subagent-announce-dispatch.js"; import { type AnnounceQueueItem, enqueueAnnounce } from "./subagent-announce-queue.js"; import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; import type { SpawnSubagentMode } from "./subagent-spawn.js"; @@ -53,14 +57,6 @@ type ToolResultMessage = { content?: unknown; }; -type SubagentDeliveryPath = "queued" | "steered" | "direct" | "none"; - -type SubagentAnnounceDeliveryResult = { - delivered: boolean; - path: SubagentDeliveryPath; - error?: string; -}; - function resolveSubagentAnnounceTimeoutMs(cfg: ReturnType): number { const configured = cfg.agents?.defaults?.subagents?.announceTimeoutMs; if (typeof configured !== "number" || !Number.isFinite(configured)) { @@ -705,27 +701,6 @@ async function maybeQueueSubagentAnnounce(params: { return "none"; } -function queueOutcomeToDeliveryResult( - outcome: "steered" | "queued" | "none", -): SubagentAnnounceDeliveryResult { - if (outcome === "steered") { - return { - delivered: true, - path: "steered", - }; - } - if (outcome === "queued") { - return { - delivered: true, - path: "queued", - }; - } - return { - delivered: false, - path: "none", - }; -} - async function sendSubagentAnnounceDirectly(params: { targetRequesterSessionKey: string; triggerMessage: string; @@ -905,64 +880,34 @@ async function deliverSubagentAnnouncement(params: { directIdempotencyKey: string; signal?: AbortSignal; }): Promise { - if (params.signal?.aborted) { - return { - delivered: false, - path: "none", - }; - } - // Non-completion mode mirrors historical behavior: try queued/steered delivery first, - // then (only if not queued) attempt direct delivery. - if (!params.expectsCompletionMessage) { - const queueOutcome = await maybeQueueSubagentAnnounce({ - requesterSessionKey: params.requesterSessionKey, - announceId: params.announceId, - triggerMessage: params.triggerMessage, - summaryLine: params.summaryLine, - requesterOrigin: params.requesterOrigin, - signal: params.signal, - }); - const queued = queueOutcomeToDeliveryResult(queueOutcome); - if (queued.delivered) { - return queued; - } - } - - // Completion-mode uses direct send first so manual spawns can return immediately - // in the common ready-to-deliver case. - const direct = await sendSubagentAnnounceDirectly({ - targetRequesterSessionKey: params.targetRequesterSessionKey, - triggerMessage: params.triggerMessage, - completionMessage: params.completionMessage, - directIdempotencyKey: params.directIdempotencyKey, - completionDirectOrigin: params.completionDirectOrigin, - completionRouteMode: params.completionRouteMode, - spawnMode: params.spawnMode, - directOrigin: params.directOrigin, - requesterIsSubagent: params.requesterIsSubagent, + return await runSubagentAnnounceDispatch({ expectsCompletionMessage: params.expectsCompletionMessage, signal: params.signal, - bestEffortDeliver: params.bestEffortDeliver, - }); - if (direct.delivered || !params.expectsCompletionMessage) { - return direct; - } - - // If completion path failed direct delivery, try queueing as a fallback so the - // report can still be delivered once the requester session is idle. - const queueOutcome = await maybeQueueSubagentAnnounce({ - requesterSessionKey: params.requesterSessionKey, - announceId: params.announceId, - triggerMessage: params.triggerMessage, - summaryLine: params.summaryLine, - requesterOrigin: params.requesterOrigin, - signal: params.signal, + queue: async () => + await maybeQueueSubagentAnnounce({ + requesterSessionKey: params.requesterSessionKey, + announceId: params.announceId, + triggerMessage: params.triggerMessage, + summaryLine: params.summaryLine, + requesterOrigin: params.requesterOrigin, + signal: params.signal, + }), + direct: async () => + await sendSubagentAnnounceDirectly({ + targetRequesterSessionKey: params.targetRequesterSessionKey, + triggerMessage: params.triggerMessage, + completionMessage: params.completionMessage, + directIdempotencyKey: params.directIdempotencyKey, + completionDirectOrigin: params.completionDirectOrigin, + completionRouteMode: params.completionRouteMode, + spawnMode: params.spawnMode, + directOrigin: params.directOrigin, + requesterIsSubagent: params.requesterIsSubagent, + expectsCompletionMessage: params.expectsCompletionMessage, + signal: params.signal, + bestEffortDeliver: params.bestEffortDeliver, + }), }); - if (queueOutcome === "steered" || queueOutcome === "queued") { - return queueOutcomeToDeliveryResult(queueOutcome); - } - - return direct; } function loadSessionEntryByKey(sessionKey: string) { @@ -979,6 +924,8 @@ export function buildSubagentSystemPrompt(params: { childSessionKey: string; label?: string; task?: string; + /** Whether ACP-specific routing guidance should be included. Defaults to true. */ + acpEnabled?: boolean; /** Depth of the child being spawned (1 = sub-agent, 2 = sub-sub-agent). */ childDepth?: number; /** Config value: max allowed spawn depth. */ @@ -993,6 +940,7 @@ export function buildSubagentSystemPrompt(params: { typeof params.maxSpawnDepth === "number" ? params.maxSpawnDepth : DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH; + const acpEnabled = params.acpEnabled !== false; const canSpawn = childDepth < maxSpawnDepth; const parentLabel = childDepth >= 2 ? "parent orchestrator" : "main agent"; @@ -1038,6 +986,17 @@ export function buildSubagentSystemPrompt(params: { "Default workflow: spawn work, continue orchestrating, and wait for auto-announced completions.", "Do NOT repeatedly poll `subagents list` in a loop unless you are actively debugging or intervening.", "Coordinate their work and synthesize results before reporting back.", + ...(acpEnabled + ? [ + 'For ACP harness sessions (codex/claudecode/gemini), use `sessions_spawn` with `runtime: "acp"` (set `agentId` unless `acp.defaultAgent` is configured).', + '`agents_list` and `subagents` apply to OpenClaw sub-agents (`runtime: "subagent"`); ACP harness ids are controlled by `acp.allowedAgents`.', + "Do not ask users to run slash commands or CLI when `sessions_spawn` can do it directly.", + "Do not use `exec` (`openclaw ...`, `acpx ...`) to spawn ACP sessions.", + 'Use `subagents` only for OpenClaw subagents (`runtime: "subagent"`).', + "Subagent results auto-announce back to you; ACP sessions continue in their bound thread.", + "Avoid polling loops; spawn, orchestrate, and synthesize results.", + ] + : []), "", ); } else if (childDepth >= 2) { diff --git a/src/agents/subagent-registry.announce-loop-guard.test.ts b/src/agents/subagent-registry.announce-loop-guard.test.ts index 8389c53503c6..498b38aaedcf 100644 --- a/src/agents/subagent-registry.announce-loop-guard.test.ts +++ b/src/agents/subagent-registry.announce-loop-guard.test.ts @@ -155,4 +155,43 @@ describe("announce loop guard (#18264)", () => { const stored = runs.find((run) => run.runId === entry.runId); expect(stored?.cleanupCompletedAt).toBeDefined(); }); + + test("announce rejection resets cleanupHandled so retries can resume", async () => { + announceFn.mockReset(); + announceFn.mockRejectedValueOnce(new Error("announce failed")); + registry.resetSubagentRegistryForTests(); + + const now = Date.now(); + const runId = "test-announce-rejection"; + loadSubagentRegistryFromDisk.mockReturnValue( + new Map([ + [ + runId, + { + runId, + childSessionKey: "agent:main:subagent:child-1", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "agent:main:main", + task: "rejection test", + cleanup: "keep" as const, + createdAt: now - 30_000, + startedAt: now - 20_000, + endedAt: now - 10_000, + cleanupHandled: false, + }, + ], + ]), + ); + + registry.initSubagentRegistry(); + await Promise.resolve(); + await Promise.resolve(); + + const runs = registry.listSubagentRunsForRequester("agent:main:main"); + const stored = runs.find((run) => run.runId === runId); + expect(stored?.cleanupHandled).toBe(false); + expect(stored?.cleanupCompletedAt).toBeUndefined(); + expect(stored?.announceRetryCount).toBe(1); + expect(stored?.lastAnnounceRetryAt).toBeTypeOf("number"); + }); }); diff --git a/src/agents/subagent-registry.lifecycle-retry-grace.test.ts b/src/agents/subagent-registry.lifecycle-retry-grace.test.ts new file mode 100644 index 000000000000..7f919c4fd49f --- /dev/null +++ b/src/agents/subagent-registry.lifecycle-retry-grace.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const noop = () => {}; + +let lifecycleHandler: + | ((evt: { + stream?: string; + runId: string; + data?: { + phase?: string; + startedAt?: number; + endedAt?: number; + aborted?: boolean; + error?: string; + }; + }) => void) + | undefined; + +vi.mock("../gateway/call.js", () => ({ + callGateway: vi.fn(async (request: unknown) => { + const method = (request as { method?: string }).method; + if (method === "agent.wait") { + // Keep wait unresolved from the RPC path so lifecycle fallback logic is exercised. + return { status: "pending" }; + } + return {}; + }), +})); + +vi.mock("../infra/agent-events.js", () => ({ + onAgentEvent: vi.fn((handler: typeof lifecycleHandler) => { + lifecycleHandler = handler; + return noop; + }), +})); + +vi.mock("../config/config.js", () => ({ + loadConfig: vi.fn(() => ({ + agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } }, + })), +})); + +const announceSpy = vi.fn(async () => true); +vi.mock("./subagent-announce.js", () => ({ + runSubagentAnnounceFlow: announceSpy, +})); + +vi.mock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: vi.fn(() => null), +})); + +vi.mock("./subagent-registry.store.js", () => ({ + loadSubagentRegistryFromDisk: vi.fn(() => new Map()), + saveSubagentRegistryToDisk: vi.fn(() => {}), +})); + +describe("subagent registry lifecycle error grace", () => { + let mod: typeof import("./subagent-registry.js"); + + beforeAll(async () => { + mod = await import("./subagent-registry.js"); + }); + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + announceSpy.mockClear(); + lifecycleHandler = undefined; + mod.resetSubagentRegistryForTests({ persist: false }); + vi.useRealTimers(); + }); + + const flushAsync = async () => { + await Promise.resolve(); + await Promise.resolve(); + }; + + it("ignores transient lifecycle errors when run retries and then ends successfully", async () => { + mod.registerSubagentRun({ + runId: "run-transient-error", + childSessionKey: "agent:main:subagent:transient-error", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "transient error test", + cleanup: "keep", + expectsCompletionMessage: true, + }); + + lifecycleHandler?.({ + stream: "lifecycle", + runId: "run-transient-error", + data: { phase: "error", error: "rate limit", endedAt: 1_000 }, + }); + await flushAsync(); + expect(announceSpy).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(14_999); + expect(announceSpy).not.toHaveBeenCalled(); + + lifecycleHandler?.({ + stream: "lifecycle", + runId: "run-transient-error", + data: { phase: "start", startedAt: 1_050 }, + }); + await flushAsync(); + + await vi.advanceTimersByTimeAsync(20_000); + expect(announceSpy).not.toHaveBeenCalled(); + + lifecycleHandler?.({ + stream: "lifecycle", + runId: "run-transient-error", + data: { phase: "end", endedAt: 1_250 }, + }); + await flushAsync(); + + expect(announceSpy).toHaveBeenCalledTimes(1); + const announceCalls = announceSpy.mock.calls as unknown as Array>; + const first = (announceCalls[0]?.[0] ?? {}) as { + outcome?: { status?: string; error?: string }; + }; + expect(first.outcome?.status).toBe("ok"); + }); + + it("announces error when lifecycle error remains terminal after grace window", async () => { + mod.registerSubagentRun({ + runId: "run-terminal-error", + childSessionKey: "agent:main:subagent:terminal-error", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "terminal error test", + cleanup: "keep", + expectsCompletionMessage: true, + }); + + lifecycleHandler?.({ + stream: "lifecycle", + runId: "run-terminal-error", + data: { phase: "error", error: "fatal failure", endedAt: 2_000 }, + }); + await flushAsync(); + expect(announceSpy).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(15_000); + await flushAsync(); + + expect(announceSpy).toHaveBeenCalledTimes(1); + const announceCalls = announceSpy.mock.calls as unknown as Array>; + const first = (announceCalls[0]?.[0] ?? {}) as { + outcome?: { status?: string; error?: string }; + }; + expect(first.outcome?.status).toBe("error"); + expect(first.outcome?.error).toBe("fatal failure"); + }); +}); diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index edb8f228b07b..10a6416f4cef 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -66,6 +66,12 @@ const MAX_ANNOUNCE_RETRY_COUNT = 3; */ const ANNOUNCE_EXPIRY_MS = 5 * 60_000; // 5 minutes type SubagentRunOrphanReason = "missing-session-entry" | "missing-session-id"; +/** + * Embedded runs can emit transient lifecycle `error` events while provider/model + * retry is still in progress. Defer terminal error cleanup briefly so a + * subsequent lifecycle `start` / `end` can cancel premature failure announces. + */ +const LIFECYCLE_ERROR_RETRY_GRACE_MS = 15_000; function resolveAnnounceRetryDelayMs(retryCount: number) { const boundedRetryCount = Math.max(0, Math.min(retryCount, 10)); @@ -204,6 +210,66 @@ function reconcileOrphanedRestoredRuns() { const resumedRuns = new Set(); const endedHookInFlightRunIds = new Set(); +const pendingLifecycleErrorByRunId = new Map< + string, + { + timer: NodeJS.Timeout; + endedAt: number; + error?: string; + } +>(); + +function clearPendingLifecycleError(runId: string) { + const pending = pendingLifecycleErrorByRunId.get(runId); + if (!pending) { + return; + } + clearTimeout(pending.timer); + pendingLifecycleErrorByRunId.delete(runId); +} + +function clearAllPendingLifecycleErrors() { + for (const pending of pendingLifecycleErrorByRunId.values()) { + clearTimeout(pending.timer); + } + pendingLifecycleErrorByRunId.clear(); +} + +function schedulePendingLifecycleError(params: { runId: string; endedAt: number; error?: string }) { + clearPendingLifecycleError(params.runId); + const timer = setTimeout(() => { + const pending = pendingLifecycleErrorByRunId.get(params.runId); + if (!pending || pending.timer !== timer) { + return; + } + pendingLifecycleErrorByRunId.delete(params.runId); + const entry = subagentRuns.get(params.runId); + if (!entry) { + return; + } + if (entry.endedReason === SUBAGENT_ENDED_REASON_COMPLETE || entry.outcome?.status === "ok") { + return; + } + void completeSubagentRun({ + runId: params.runId, + endedAt: pending.endedAt, + outcome: { + status: "error", + error: pending.error, + }, + reason: SUBAGENT_ENDED_REASON_ERROR, + sendFarewell: true, + accountId: entry.requesterOrigin?.accountId, + triggerCleanup: true, + }); + }, LIFECYCLE_ERROR_RETRY_GRACE_MS); + timer.unref?.(); + pendingLifecycleErrorByRunId.set(params.runId, { + timer, + endedAt: params.endedAt, + error: params.error, + }); +} function suppressAnnounceForSteerRestart(entry?: SubagentRunRecord) { return entry?.suppressAnnounceReason === "steer-restart"; @@ -256,6 +322,7 @@ async function completeSubagentRun(params: { accountId?: string; triggerCleanup: boolean; }) { + clearPendingLifecycleError(params.runId); const entry = subagentRuns.get(params.runId); if (!entry) { return; @@ -331,9 +398,16 @@ function startSubagentAnnounceCleanupFlow(runId: string, entry: SubagentRunRecor outcome: entry.outcome, spawnMode: entry.spawnMode, expectsCompletionMessage: entry.expectsCompletionMessage, - }).then((didAnnounce) => { - void finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce); - }); + }) + .then((didAnnounce) => { + void finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce); + }) + .catch((error) => { + defaultRuntime.log( + `[warn] Subagent announce flow failed during cleanup for run ${runId}: ${String(error)}`, + ); + void finalizeSubagentCleanup(runId, entry.cleanup, false); + }); return true; } @@ -484,6 +558,7 @@ async function sweepSubagentRuns() { if (!entry.archiveAtMs || entry.archiveAtMs > now) { continue; } + clearPendingLifecycleError(runId); subagentRuns.delete(runId); mutated = true; try { @@ -524,6 +599,7 @@ function ensureListener() { } const phase = evt.data?.phase; if (phase === "start") { + clearPendingLifecycleError(evt.runId); const startedAt = typeof evt.data?.startedAt === "number" ? evt.data.startedAt : undefined; if (startedAt) { entry.startedAt = startedAt; @@ -536,17 +612,23 @@ function ensureListener() { } const endedAt = typeof evt.data?.endedAt === "number" ? evt.data.endedAt : Date.now(); const error = typeof evt.data?.error === "string" ? evt.data.error : undefined; - const outcome: SubagentRunOutcome = - phase === "error" - ? { status: "error", error } - : evt.data?.aborted - ? { status: "timeout" } - : { status: "ok" }; + if (phase === "error") { + schedulePendingLifecycleError({ + runId: evt.runId, + endedAt, + error, + }); + return; + } + clearPendingLifecycleError(evt.runId); + const outcome: SubagentRunOutcome = evt.data?.aborted + ? { status: "timeout" } + : { status: "ok" }; await completeSubagentRun({ runId: evt.runId, endedAt, outcome, - reason: phase === "error" ? SUBAGENT_ENDED_REASON_ERROR : SUBAGENT_ENDED_REASON_COMPLETE, + reason: SUBAGENT_ENDED_REASON_COMPLETE, sendFarewell: true, accountId: entry.requesterOrigin?.accountId, triggerCleanup: true, @@ -654,6 +736,7 @@ function completeCleanupBookkeeping(params: { completedAt: number; }) { if (params.cleanup === "delete") { + clearPendingLifecycleError(params.runId); subagentRuns.delete(params.runId); persistSubagentRuns(); retryDeferredCompletedAnnounces(params.runId); @@ -767,6 +850,7 @@ export function replaceSubagentRunAfterSteer(params: { } if (previousRunId !== nextRunId) { + clearPendingLifecycleError(previousRunId); subagentRuns.delete(previousRunId); resumedRuns.delete(previousRunId); } @@ -928,6 +1012,7 @@ export function resetSubagentRegistryForTests(opts?: { persist?: boolean }) { subagentRuns.clear(); resumedRuns.clear(); endedHookInFlightRunIds.clear(); + clearAllPendingLifecycleErrors(); resetAnnounceQueuesForTests(); stopSweeper(); restoreAttempted = false; @@ -946,6 +1031,7 @@ export function addSubagentRunForTests(entry: SubagentRunRecord) { } export function releaseSubagentRun(runId: string) { + clearPendingLifecycleError(runId); const didDelete = subagentRuns.delete(runId); if (didDelete) { persistSubagentRuns(); @@ -1013,6 +1099,7 @@ export function markSubagentRunTerminated(params: { let updated = 0; const entriesByChildSessionKey = new Map(); for (const runId of runIds) { + clearPendingLifecycleError(runId); const entry = subagentRuns.get(runId); if (!entry) { continue; diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 7d4f672f2f1e..37b612145ed1 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -385,6 +385,7 @@ export async function spawnSubagentDirect( childSessionKey, label: label || undefined, task, + acpEnabled: cfg.acp?.enabled !== false, childDepth, maxSpawnDepth, }); diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index cdef36ce7899..6a28d6c132c7 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -253,6 +253,9 @@ describe("buildAgentSystemPrompt", () => { ); expect(prompt).toContain("Completion is push-based: it will auto-announce when done."); expect(prompt).toContain("Do not poll `subagents list` / `sessions_list` in a loop"); + expect(prompt).toContain( + "When a first-class tool exists for an action, use the tool directly instead of asking the user to run equivalent CLI or slash commands.", + ); }); it("lists available tools when provided", () => { @@ -267,6 +270,52 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).toContain("sessions_send"); }); + it("documents ACP sessions_spawn agent targeting requirements", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + toolNames: ["sessions_spawn"], + }); + + expect(prompt).toContain("sessions_spawn"); + expect(prompt).toContain( + 'runtime="acp" requires `agentId` unless `acp.defaultAgent` is configured', + ); + expect(prompt).toContain("not agents_list"); + }); + + it("guides harness requests to ACP thread-bound spawns", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + toolNames: ["sessions_spawn", "subagents", "agents_list", "exec"], + }); + + expect(prompt).toContain( + 'For requests like "do this in codex/claude code/gemini", treat it as ACP harness intent', + ); + expect(prompt).toContain( + 'On Discord, default ACP harness requests to thread-bound persistent sessions (`thread: true`, `mode: "session"`)', + ); + expect(prompt).toContain( + "do not route ACP harness requests through `subagents`/`agents_list` or local PTY exec flows", + ); + }); + + it("omits ACP harness guidance when ACP is disabled", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + toolNames: ["sessions_spawn", "subagents", "agents_list", "exec"], + acpEnabled: false, + }); + + expect(prompt).not.toContain( + 'For requests like "do this in codex/claude code/gemini", treat it as ACP harness intent', + ); + expect(prompt).not.toContain('runtime="acp" requires `agentId`'); + expect(prompt).not.toContain("not ACP harness ids"); + expect(prompt).toContain("- sessions_spawn: Spawn an isolated sub-agent session"); + expect(prompt).toContain("- agents_list: List OpenClaw agent ids allowed for sessions_spawn"); + }); + it("preserves tool casing in the prompt", () => { const prompt = buildAgentSystemPrompt({ workspaceDir: "/tmp/openclaw", @@ -631,11 +680,18 @@ describe("buildSubagentSystemPrompt", () => { }); expect(prompt).toContain("## Sub-Agent Spawning"); - expect(prompt).toContain("You CAN spawn your own sub-agents"); + expect(prompt).toContain( + "You CAN spawn your own sub-agents for parallel or complex work using `sessions_spawn`.", + ); expect(prompt).toContain("sessions_spawn"); - expect(prompt).toContain("`subagents` tool"); - expect(prompt).toContain("announce their results back to you automatically"); - expect(prompt).toContain("Do NOT repeatedly poll `subagents list`"); + expect(prompt).toContain('runtime: "acp"'); + expect(prompt).toContain("For ACP harness sessions (codex/claudecode/gemini)"); + expect(prompt).toContain("set `agentId` unless `acp.defaultAgent` is configured"); + expect(prompt).toContain("Do not ask users to run slash commands or CLI"); + expect(prompt).toContain("Do not use `exec` (`openclaw ...`, `acpx ...`)"); + expect(prompt).toContain("Use `subagents` only for OpenClaw subagents"); + expect(prompt).toContain("Subagent results auto-announce back to you"); + expect(prompt).toContain("Avoid polling loops"); expect(prompt).toContain("spawned by the main agent"); expect(prompt).toContain("reported to the main agent"); expect(prompt).toContain("[compacted: tool output removed to free context]"); @@ -644,6 +700,21 @@ describe("buildSubagentSystemPrompt", () => { expect(prompt).toContain("instead of full-file `cat`"); }); + it("omits ACP spawning guidance when ACP is disabled", () => { + const prompt = buildSubagentSystemPrompt({ + childSessionKey: "agent:main:subagent:abc", + task: "research task", + childDepth: 1, + maxSpawnDepth: 2, + acpEnabled: false, + }); + + expect(prompt).not.toContain('runtime: "acp"'); + expect(prompt).not.toContain("For ACP harness sessions (codex/claudecode/gemini)"); + expect(prompt).not.toContain("set `agentId` unless `acp.defaultAgent` is configured"); + expect(prompt).toContain("You CAN spawn your own sub-agents"); + }); + it("renders depth-2 leaf guidance with parent orchestrator labels", () => { const prompt = buildSubagentSystemPrompt({ childSessionKey: "agent:main:subagent:abc:subagent:def", diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 47dd50ca6dae..8a438e16cafc 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -231,6 +231,8 @@ export function buildAgentSystemPrompt(params: { ttsHint?: string; /** Controls which hardcoded sections to include. Defaults to "full". */ promptMode?: PromptMode; + /** Whether ACP-specific routing guidance should be included. Defaults to true. */ + acpEnabled?: boolean; runtimeInfo?: { agentId?: string; host?: string; @@ -253,6 +255,7 @@ export function buildAgentSystemPrompt(params: { }; memoryCitationsMode?: MemoryCitationsMode; }) { + const acpEnabled = params.acpEnabled !== false; const coreToolSummaries: Record = { read: "Read file contents", write: "Create or overwrite files", @@ -272,11 +275,15 @@ export function buildAgentSystemPrompt(params: { cron: "Manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)", message: "Send messages and channel actions", gateway: "Restart, apply config, or run updates on the running OpenClaw process", - agents_list: "List agent ids allowed for sessions_spawn", + agents_list: acpEnabled + ? 'List OpenClaw agent ids allowed for sessions_spawn when runtime="subagent" (not ACP harness ids)' + : "List OpenClaw agent ids allowed for sessions_spawn", sessions_list: "List other sessions (incl. sub-agents) with filters/last", sessions_history: "Fetch history for another session/sub-agent", sessions_send: "Send a message to another session/sub-agent", - sessions_spawn: "Spawn a sub-agent session", + sessions_spawn: acpEnabled + ? 'Spawn an isolated sub-agent or ACP coding session (runtime="acp" requires `agentId` unless `acp.defaultAgent` is configured; ACP harness ids follow acp.allowedAgents, not agents_list)' + : "Spawn an isolated sub-agent session", subagents: "List, steer, or kill sub-agent runs for this requester session", session_status: "Show a /status-equivalent status card (usage + time + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override", @@ -331,6 +338,7 @@ export function buildAgentSystemPrompt(params: { const normalizedTools = canonicalToolNames.map((tool) => tool.toLowerCase()); const availableTools = new Set(normalizedTools); + const hasSessionsSpawn = availableTools.has("sessions_spawn"); const externalToolSummaries = new Map(); for (const [key, value] of Object.entries(params.toolSummaries ?? {})) { const normalized = key.trim().toLowerCase(); @@ -465,6 +473,13 @@ export function buildAgentSystemPrompt(params: { "TOOLS.md does not control tool availability; it is user guidance for how to use external tools.", `For long waits, avoid rapid poll loops: use ${execToolName} with enough yieldMs or ${processToolName}(action=poll, timeout=).`, "If a task is more complex or takes longer, spawn a sub-agent. Completion is push-based: it will auto-announce when done.", + ...(hasSessionsSpawn && acpEnabled + ? [ + 'For requests like "do this in codex/claude code/gemini", treat it as ACP harness intent and call `sessions_spawn` with `runtime: "acp"`.', + 'On Discord, default ACP harness requests to thread-bound persistent sessions (`thread: true`, `mode: "session"`) unless the user asks otherwise.', + "Set `agentId` explicitly unless `acp.defaultAgent` is configured, and do not route ACP harness requests through `subagents`/`agents_list` or local PTY exec flows.", + ] + : []), "Do not poll `subagents list` / `sessions_list` in a loop; only check status on-demand (for intervention, debugging, or when explicitly asked).", "", "## Tool Call Style", @@ -472,6 +487,7 @@ export function buildAgentSystemPrompt(params: { "Narrate only when it helps: multi-step work, complex/challenging problems, sensitive actions (e.g., deletions), or when the user explicitly asks.", "Keep narration brief and value-dense; avoid repeating obvious steps.", "Use plain human language for narration unless in a technical context.", + "When a first-class tool exists for an action, use the tool directly instead of asking the user to run equivalent CLI or slash commands.", "", "## Sending Images/Charts", "To send generated images (charts, plots, diagrams) back to the user:", @@ -499,6 +515,7 @@ export function buildAgentSystemPrompt(params: { ? [ "Get Updates (self-update) is ONLY allowed when the user explicitly asks for it.", "Do not run config.apply or update.run unless the user explicitly requests an update or config change; if it's not explicit, ask first.", + "Use config.schema to fetch the current JSON Schema (includes plugins/channels) before making config changes or answering config-field questions; avoid guessing field names/types.", "Actions: config.get, config.schema, config.apply (validate + write full config, then restart), update.run (update deps or git, then restart).", "After restart, OpenClaw pings the last active session automatically.", ].join("\n") diff --git a/src/agents/tools/agents-list-tool.ts b/src/agents/tools/agents-list-tool.ts index 277ac990647d..879ad96de068 100644 --- a/src/agents/tools/agents-list-tool.ts +++ b/src/agents/tools/agents-list-tool.ts @@ -26,7 +26,8 @@ export function createAgentsListTool(opts?: { return { label: "Agents", name: "agents_list", - description: "List agent ids you can target with sessions_spawn (based on allowlists).", + description: + 'List OpenClaw agent ids you can target with `sessions_spawn` when `runtime="subagent"` (based on subagent allowlists).', parameters: AgentsListToolSchema, execute: async () => { const cfg = loadConfig(); diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index c17ff9f9c488..25b194033528 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -18,6 +18,7 @@ import { } from "../../cli/nodes-screen.js"; import { parseDurationMs } from "../../cli/parse-duration.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { formatExecCommand } from "../../infra/system-run-command.js"; import { imageMimeFromFormat } from "../../media/mime.js"; import { resolveSessionAgentId } from "../agent-scope.js"; import { resolveImageSanitizationLimits } from "../image-sanitization.js"; @@ -39,6 +40,7 @@ const NODES_TOOL_ACTIONS = [ "camera_clip", "screen_record", "location_get", + "notifications_list", "run", "invoke", ] as const; @@ -47,6 +49,23 @@ const NOTIFY_PRIORITIES = ["passive", "active", "timeSensitive"] as const; const NOTIFY_DELIVERIES = ["system", "overlay", "auto"] as const; const CAMERA_FACING = ["front", "back", "both"] as const; const LOCATION_ACCURACY = ["coarse", "balanced", "precise"] as const; +type GatewayCallOptions = ReturnType; + +async function invokeNodeCommandPayload(params: { + gatewayOpts: GatewayCallOptions; + node: string; + command: string; + commandParams?: Record; +}): Promise { + const nodeId = await resolveNodeId(params.gatewayOpts, params.node); + const raw = await callGatewayTool<{ payload: unknown }>("node.invoke", params.gatewayOpts, { + nodeId, + command: params.command, + params: params.commandParams ?? {}, + idempotencyKey: crypto.randomUUID(), + }); + return raw?.payload ?? {}; +} function isPairingRequiredMessage(message: string): boolean { const lower = message.toLowerCase(); @@ -121,7 +140,7 @@ export function createNodesTool(options?: { label: "Nodes", name: "nodes", description: - "Discover and control paired nodes (status/describe/pairing/notify/camera/screen/location/run/invoke).", + "Discover and control paired nodes (status/describe/pairing/notify/camera/screen/location/notifications/run/invoke).", parameters: NodesToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; @@ -185,7 +204,7 @@ export function createNodesTool(options?: { const node = readStringParam(params, "node", { required: true }); const nodeId = await resolveNodeId(gatewayOpts, node); const facingRaw = - typeof params.facing === "string" ? params.facing.toLowerCase() : "both"; + typeof params.facing === "string" ? params.facing.toLowerCase() : "front"; const facings: CameraFacing[] = facingRaw === "both" ? ["front", "back"] @@ -197,11 +216,11 @@ export function createNodesTool(options?: { const maxWidth = typeof params.maxWidth === "number" && Number.isFinite(params.maxWidth) ? params.maxWidth - : undefined; + : 1600; const quality = typeof params.quality === "number" && Number.isFinite(params.quality) ? params.quality - : undefined; + : 0.95; const delayMs = typeof params.delayMs === "number" && Number.isFinite(params.delayMs) ? params.delayMs @@ -271,15 +290,13 @@ export function createNodesTool(options?: { } case "camera_list": { const node = readStringParam(params, "node", { required: true }); - const nodeId = await resolveNodeId(gatewayOpts, node); - const raw = await callGatewayTool<{ payload: unknown }>("node.invoke", gatewayOpts, { - nodeId, + const payloadRaw = await invokeNodeCommandPayload({ + gatewayOpts, + node, command: "camera.list", - params: {}, - idempotencyKey: crypto.randomUUID(), }); const payload = - raw && typeof raw.payload === "object" && raw.payload !== null ? raw.payload : {}; + payloadRaw && typeof payloadRaw === "object" && payloadRaw !== null ? payloadRaw : {}; return jsonResult(payload); } case "camera_clip": { @@ -377,7 +394,6 @@ export function createNodesTool(options?: { } case "location_get": { const node = readStringParam(params, "node", { required: true }); - const nodeId = await resolveNodeId(gatewayOpts, node); const maxAgeMs = typeof params.maxAgeMs === "number" && Number.isFinite(params.maxAgeMs) ? params.maxAgeMs @@ -393,17 +409,26 @@ export function createNodesTool(options?: { Number.isFinite(params.locationTimeoutMs) ? params.locationTimeoutMs : undefined; - const raw = await callGatewayTool<{ payload: unknown }>("node.invoke", gatewayOpts, { - nodeId, + const payload = await invokeNodeCommandPayload({ + gatewayOpts, + node, command: "location.get", - params: { + commandParams: { maxAgeMs, desiredAccuracy, timeoutMs: locationTimeoutMs, }, - idempotencyKey: crypto.randomUUID(), }); - return jsonResult(raw?.payload ?? {}); + return jsonResult(payload); + } + case "notifications_list": { + const node = readStringParam(params, "node", { required: true }); + const payload = await invokeNodeCommandPayload({ + gatewayOpts, + node, + command: "notifications.list", + }); + return jsonResult(payload); } case "run": { const node = readStringParam(params, "node", { required: true }); @@ -473,7 +498,7 @@ export function createNodesTool(options?: { // Node requires approval – create a pending approval request on // the gateway and wait for the user to approve/deny via the UI. const APPROVAL_TIMEOUT_MS = 120_000; - const cmdText = command.join(" "); + const cmdText = formatExecCommand(command); const approvalId = crypto.randomUUID(); const approvalResult = await callGatewayTool( "exec.approval.request", @@ -481,6 +506,7 @@ export function createNodesTool(options?: { { id: approvalId, command: cmdText, + commandArgv: command, cwd, nodeId, host: "node", diff --git a/src/agents/tools/sessions-spawn-tool.test.ts b/src/agents/tools/sessions-spawn-tool.test.ts new file mode 100644 index 000000000000..c18f5bb86827 --- /dev/null +++ b/src/agents/tools/sessions-spawn-tool.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const hoisted = vi.hoisted(() => { + const spawnSubagentDirectMock = vi.fn(); + const spawnAcpDirectMock = vi.fn(); + return { + spawnSubagentDirectMock, + spawnAcpDirectMock, + }; +}); + +vi.mock("../subagent-spawn.js", () => ({ + SUBAGENT_SPAWN_MODES: ["run", "session"], + spawnSubagentDirect: (...args: unknown[]) => hoisted.spawnSubagentDirectMock(...args), +})); + +vi.mock("../acp-spawn.js", () => ({ + ACP_SPAWN_MODES: ["run", "session"], + spawnAcpDirect: (...args: unknown[]) => hoisted.spawnAcpDirectMock(...args), +})); + +const { createSessionsSpawnTool } = await import("./sessions-spawn-tool.js"); + +describe("sessions_spawn tool", () => { + beforeEach(() => { + hoisted.spawnSubagentDirectMock.mockReset().mockResolvedValue({ + status: "accepted", + childSessionKey: "agent:main:subagent:1", + runId: "run-subagent", + }); + hoisted.spawnAcpDirectMock.mockReset().mockResolvedValue({ + status: "accepted", + childSessionKey: "agent:codex:acp:1", + runId: "run-acp", + }); + }); + + it("uses subagent runtime by default", async () => { + const tool = createSessionsSpawnTool({ + agentSessionKey: "agent:main:main", + agentChannel: "discord", + agentAccountId: "default", + agentTo: "channel:123", + agentThreadId: "456", + }); + + const result = await tool.execute("call-1", { + task: "build feature", + agentId: "main", + model: "anthropic/claude-sonnet-4-6", + thinking: "medium", + runTimeoutSeconds: 5, + thread: true, + mode: "session", + cleanup: "keep", + }); + + expect(result.details).toMatchObject({ + status: "accepted", + childSessionKey: "agent:main:subagent:1", + runId: "run-subagent", + }); + expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledWith( + expect.objectContaining({ + task: "build feature", + agentId: "main", + model: "anthropic/claude-sonnet-4-6", + thinking: "medium", + runTimeoutSeconds: 5, + thread: true, + mode: "session", + cleanup: "keep", + }), + expect.objectContaining({ + agentSessionKey: "agent:main:main", + }), + ); + expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled(); + }); + + it("routes to ACP runtime when runtime=acp", async () => { + const tool = createSessionsSpawnTool({ + agentSessionKey: "agent:main:main", + agentChannel: "discord", + agentAccountId: "default", + agentTo: "channel:123", + agentThreadId: "456", + }); + + const result = await tool.execute("call-2", { + runtime: "acp", + task: "investigate the failing CI run", + agentId: "codex", + cwd: "/workspace", + thread: true, + mode: "session", + }); + + expect(result.details).toMatchObject({ + status: "accepted", + childSessionKey: "agent:codex:acp:1", + runId: "run-acp", + }); + expect(hoisted.spawnAcpDirectMock).toHaveBeenCalledWith( + expect.objectContaining({ + task: "investigate the failing CI run", + agentId: "codex", + cwd: "/workspace", + thread: true, + mode: "session", + }), + expect.objectContaining({ + agentSessionKey: "agent:main:main", + }), + ); + expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 9102d24847d7..e8f23f75660b 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -1,16 +1,21 @@ import { Type } from "@sinclair/typebox"; import type { GatewayMessageChannel } from "../../utils/message-channel.js"; +import { ACP_SPAWN_MODES, spawnAcpDirect } from "../acp-spawn.js"; import { optionalStringEnum } from "../schema/typebox.js"; import { SUBAGENT_SPAWN_MODES, spawnSubagentDirect } from "../subagent-spawn.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readStringParam } from "./common.js"; +const SESSIONS_SPAWN_RUNTIMES = ["subagent", "acp"] as const; + const SessionsSpawnToolSchema = Type.Object({ task: Type.String(), label: Type.Optional(Type.String()), + runtime: optionalStringEnum(SESSIONS_SPAWN_RUNTIMES), agentId: Type.Optional(Type.String()), model: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()), + cwd: Type.Optional(Type.String()), runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), // Back-compat: older callers used timeoutSeconds for this tool. timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), @@ -36,15 +41,17 @@ export function createSessionsSpawnTool(opts?: { label: "Sessions", name: "sessions_spawn", description: - 'Spawn a sub-agent in an isolated session (mode="run" one-shot or mode="session" persistent) and route results back to the requester chat/thread.', + 'Spawn an isolated session (runtime="subagent" or runtime="acp"). mode="run" is one-shot and mode="session" is persistent/thread-bound.', parameters: SessionsSpawnToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; const task = readStringParam(params, "task", { required: true }); const label = typeof params.label === "string" ? params.label.trim() : ""; + const runtime = params.runtime === "acp" ? "acp" : "subagent"; const requestedAgentId = readStringParam(params, "agentId"); const modelOverride = readStringParam(params, "model"); const thinkingOverrideRaw = readStringParam(params, "thinking"); + const cwd = readStringParam(params, "cwd"); const mode = params.mode === "run" || params.mode === "session" ? params.mode : undefined; const cleanup = params.cleanup === "keep" || params.cleanup === "delete" ? params.cleanup : "keep"; @@ -61,31 +68,50 @@ export function createSessionsSpawnTool(opts?: { : undefined; const thread = params.thread === true; - const result = await spawnSubagentDirect( - { - task, - label: label || undefined, - agentId: requestedAgentId, - model: modelOverride, - thinking: thinkingOverrideRaw, - runTimeoutSeconds, - thread, - mode, - cleanup, - expectsCompletionMessage: true, - }, - { - agentSessionKey: opts?.agentSessionKey, - agentChannel: opts?.agentChannel, - agentAccountId: opts?.agentAccountId, - agentTo: opts?.agentTo, - agentThreadId: opts?.agentThreadId, - agentGroupId: opts?.agentGroupId, - agentGroupChannel: opts?.agentGroupChannel, - agentGroupSpace: opts?.agentGroupSpace, - requesterAgentIdOverride: opts?.requesterAgentIdOverride, - }, - ); + const result = + runtime === "acp" + ? await spawnAcpDirect( + { + task, + label: label || undefined, + agentId: requestedAgentId, + cwd, + mode: mode && ACP_SPAWN_MODES.includes(mode) ? mode : undefined, + thread, + }, + { + agentSessionKey: opts?.agentSessionKey, + agentChannel: opts?.agentChannel, + agentAccountId: opts?.agentAccountId, + agentTo: opts?.agentTo, + agentThreadId: opts?.agentThreadId, + }, + ) + : await spawnSubagentDirect( + { + task, + label: label || undefined, + agentId: requestedAgentId, + model: modelOverride, + thinking: thinkingOverrideRaw, + runTimeoutSeconds, + thread, + mode, + cleanup, + expectsCompletionMessage: true, + }, + { + agentSessionKey: opts?.agentSessionKey, + agentChannel: opts?.agentChannel, + agentAccountId: opts?.agentAccountId, + agentTo: opts?.agentTo, + agentThreadId: opts?.agentThreadId, + agentGroupId: opts?.agentGroupId, + agentGroupChannel: opts?.agentGroupChannel, + agentGroupSpace: opts?.agentGroupSpace, + requesterAgentIdOverride: opts?.requesterAgentIdOverride, + }, + ); return jsonResult(result); }, diff --git a/src/agents/tools/web-tools.fetch.test.ts b/src/agents/tools/web-tools.fetch.test.ts index 0c69e1e1767c..99066253be12 100644 --- a/src/agents/tools/web-tools.fetch.test.ts +++ b/src/agents/tools/web-tools.fetch.test.ts @@ -133,7 +133,7 @@ describe("web_fetch extraction fallbacks", () => { const priorFetch = global.fetch; beforeEach(() => { - vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation(async (hostname) => { + vi.spyOn(ssrf, "resolvePinnedHostnameWithPolicy").mockImplementation(async (hostname) => { const normalized = hostname.trim().toLowerCase().replace(/\.$/, ""); const addresses = ["93.184.216.34", "93.184.216.35"]; return { diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index eb3e6f6d5a2b..d6b031d1b817 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -311,6 +311,46 @@ function buildChatCommands(): ChatCommandDefinition[] { ], argsMenu: "auto", }), + defineChatCommand({ + key: "acp", + nativeName: "acp", + description: "Manage ACP sessions and runtime options.", + textAlias: "/acp", + category: "management", + args: [ + { + name: "action", + description: + "spawn | cancel | steer | close | sessions | status | set-mode | set | cwd | permissions | timeout | model | reset-options | doctor | install | help", + type: "string", + choices: [ + "spawn", + "cancel", + "steer", + "close", + "sessions", + "status", + "set-mode", + "set", + "cwd", + "permissions", + "timeout", + "model", + "reset-options", + "doctor", + "install", + "help", + ], + }, + { + name: "value", + description: "Action arguments", + type: "string", + captureRemaining: true, + }, + ], + argsMenu: "auto", + }), defineChatCommand({ key: "focus", nativeName: "focus", diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index b05e5ea839c8..acf81b48dce4 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -109,6 +109,30 @@ describe("commands registry", () => { expect(findCommandByNativeName("tts", "discord")).toBeUndefined(); }); + it("keeps ACP native action choices aligned with implemented handlers", () => { + const acp = listChatCommands().find((command) => command.key === "acp"); + expect(acp).toBeTruthy(); + const actionArg = acp?.args?.find((arg) => arg.name === "action"); + expect(actionArg?.choices).toEqual([ + "spawn", + "cancel", + "steer", + "close", + "sessions", + "status", + "set-mode", + "set", + "cwd", + "permissions", + "timeout", + "model", + "reset-options", + "doctor", + "install", + "help", + ]); + }); + it("detects known text commands", () => { const detection = getCommandDetection(); expect(detection.exact.has("/commands")).toBe(true); diff --git a/src/auto-reply/reply/acp-projector.test.ts b/src/auto-reply/reply/acp-projector.test.ts new file mode 100644 index 000000000000..829ef7cc4525 --- /dev/null +++ b/src/auto-reply/reply/acp-projector.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { createAcpReplyProjector } from "./acp-projector.js"; + +function createCfg(overrides?: Partial): OpenClawConfig { + return { + acp: { + enabled: true, + stream: { + coalesceIdleMs: 0, + maxChunkChars: 50, + }, + }, + ...overrides, + } as OpenClawConfig; +} + +describe("createAcpReplyProjector", () => { + it("coalesces text deltas into bounded block chunks", async () => { + const deliveries: Array<{ kind: string; text?: string }> = []; + const projector = createAcpReplyProjector({ + cfg: createCfg(), + shouldSendToolSummaries: true, + deliver: async (kind, payload) => { + deliveries.push({ kind, text: payload.text }); + return true; + }, + }); + + await projector.onEvent({ + type: "text_delta", + text: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }); + await projector.onEvent({ + type: "text_delta", + text: "bbbbbbbbbb", + }); + await projector.flush(true); + + expect(deliveries).toEqual([ + { + kind: "block", + text: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + { kind: "block", text: "aabbbbbbbbbb" }, + ]); + }); + + it("buffers tiny token deltas and flushes once at turn end", async () => { + const deliveries: Array<{ kind: string; text?: string }> = []; + const projector = createAcpReplyProjector({ + cfg: createCfg({ + acp: { + enabled: true, + stream: { + coalesceIdleMs: 0, + maxChunkChars: 256, + }, + }, + }), + shouldSendToolSummaries: true, + provider: "discord", + deliver: async (kind, payload) => { + deliveries.push({ kind, text: payload.text }); + return true; + }, + }); + + await projector.onEvent({ type: "text_delta", text: "What" }); + await projector.onEvent({ type: "text_delta", text: " do" }); + await projector.onEvent({ type: "text_delta", text: " you want to work on?" }); + + expect(deliveries).toEqual([]); + + await projector.flush(true); + + expect(deliveries).toEqual([{ kind: "block", text: "What do you want to work on?" }]); + }); + + it("filters thought stream text and suppresses tool summaries when disabled", async () => { + const deliver = vi.fn(async () => true); + const projector = createAcpReplyProjector({ + cfg: createCfg(), + shouldSendToolSummaries: false, + deliver, + }); + + await projector.onEvent({ type: "text_delta", text: "internal", stream: "thought" }); + await projector.onEvent({ type: "status", text: "running tool" }); + await projector.onEvent({ type: "tool_call", text: "ls" }); + await projector.flush(true); + + expect(deliver).not.toHaveBeenCalled(); + }); + + it("emits status and tool_call summaries when enabled", async () => { + const deliveries: Array<{ kind: string; text?: string }> = []; + const projector = createAcpReplyProjector({ + cfg: createCfg(), + shouldSendToolSummaries: true, + deliver: async (kind, payload) => { + deliveries.push({ kind, text: payload.text }); + return true; + }, + }); + + await projector.onEvent({ type: "status", text: "planning" }); + await projector.onEvent({ type: "tool_call", text: "exec ls" }); + + expect(deliveries).toEqual([ + { kind: "tool", text: "⚙️ planning" }, + { kind: "tool", text: "🧰 exec ls" }, + ]); + }); + + it("flushes pending streamed text before tool/status updates", async () => { + const deliveries: Array<{ kind: string; text?: string }> = []; + const projector = createAcpReplyProjector({ + cfg: createCfg({ + acp: { + enabled: true, + stream: { + coalesceIdleMs: 0, + maxChunkChars: 256, + }, + }, + }), + shouldSendToolSummaries: true, + provider: "discord", + deliver: async (kind, payload) => { + deliveries.push({ kind, text: payload.text }); + return true; + }, + }); + + await projector.onEvent({ type: "text_delta", text: "Hello" }); + await projector.onEvent({ type: "text_delta", text: " world" }); + await projector.onEvent({ type: "status", text: "running tool" }); + + expect(deliveries).toEqual([ + { kind: "block", text: "Hello world" }, + { kind: "tool", text: "⚙️ running tool" }, + ]); + }); +}); diff --git a/src/auto-reply/reply/acp-projector.ts b/src/auto-reply/reply/acp-projector.ts new file mode 100644 index 000000000000..8bbe643dc308 --- /dev/null +++ b/src/auto-reply/reply/acp-projector.ts @@ -0,0 +1,140 @@ +import type { AcpRuntimeEvent } from "../../acp/runtime/types.js"; +import { EmbeddedBlockChunker } from "../../agents/pi-embedded-block-chunker.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { ReplyPayload } from "../types.js"; +import { createBlockReplyPipeline } from "./block-reply-pipeline.js"; +import { resolveEffectiveBlockStreamingConfig } from "./block-streaming.js"; +import type { ReplyDispatchKind } from "./reply-dispatcher.js"; + +const DEFAULT_ACP_STREAM_COALESCE_IDLE_MS = 350; +const DEFAULT_ACP_STREAM_MAX_CHUNK_CHARS = 1800; +const ACP_BLOCK_REPLY_TIMEOUT_MS = 15_000; + +function clampPositiveInteger( + value: unknown, + fallback: number, + bounds: { min: number; max: number }, +): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return fallback; + } + const rounded = Math.round(value); + if (rounded < bounds.min) { + return bounds.min; + } + if (rounded > bounds.max) { + return bounds.max; + } + return rounded; +} + +function resolveAcpStreamCoalesceIdleMs(cfg: OpenClawConfig): number { + return clampPositiveInteger( + cfg.acp?.stream?.coalesceIdleMs, + DEFAULT_ACP_STREAM_COALESCE_IDLE_MS, + { + min: 0, + max: 5_000, + }, + ); +} + +function resolveAcpStreamMaxChunkChars(cfg: OpenClawConfig): number { + return clampPositiveInteger(cfg.acp?.stream?.maxChunkChars, DEFAULT_ACP_STREAM_MAX_CHUNK_CHARS, { + min: 50, + max: 4_000, + }); +} + +function resolveAcpStreamingConfig(params: { + cfg: OpenClawConfig; + provider?: string; + accountId?: string; +}) { + return resolveEffectiveBlockStreamingConfig({ + cfg: params.cfg, + provider: params.provider, + accountId: params.accountId, + maxChunkChars: resolveAcpStreamMaxChunkChars(params.cfg), + coalesceIdleMs: resolveAcpStreamCoalesceIdleMs(params.cfg), + }); +} + +export type AcpReplyProjector = { + onEvent: (event: AcpRuntimeEvent) => Promise; + flush: (force?: boolean) => Promise; +}; + +export function createAcpReplyProjector(params: { + cfg: OpenClawConfig; + shouldSendToolSummaries: boolean; + deliver: (kind: ReplyDispatchKind, payload: ReplyPayload) => Promise; + provider?: string; + accountId?: string; +}): AcpReplyProjector { + const streaming = resolveAcpStreamingConfig({ + cfg: params.cfg, + provider: params.provider, + accountId: params.accountId, + }); + const blockReplyPipeline = createBlockReplyPipeline({ + onBlockReply: async (payload) => { + await params.deliver("block", payload); + }, + timeoutMs: ACP_BLOCK_REPLY_TIMEOUT_MS, + coalescing: streaming.coalescing, + }); + const chunker = new EmbeddedBlockChunker(streaming.chunking); + + const drainChunker = (force: boolean) => { + chunker.drain({ + force, + emit: (chunk) => { + blockReplyPipeline.enqueue({ text: chunk }); + }, + }); + }; + + const flush = async (force = false): Promise => { + drainChunker(force); + await blockReplyPipeline.flush({ force }); + }; + + const emitToolSummary = async (prefix: string, text: string): Promise => { + if (!params.shouldSendToolSummaries || !text) { + return; + } + // Keep tool summaries ordered after any pending streamed text. + await flush(true); + await params.deliver("tool", { text: `${prefix} ${text}` }); + }; + + const onEvent = async (event: AcpRuntimeEvent): Promise => { + if (event.type === "text_delta") { + if (event.stream && event.stream !== "output") { + return; + } + if (event.text) { + chunker.append(event.text); + drainChunker(false); + } + return; + } + if (event.type === "status") { + await emitToolSummary("⚙️", event.text); + return; + } + if (event.type === "tool_call") { + await emitToolSummary("🧰", event.text); + return; + } + if (event.type === "done" || event.type === "error") { + await flush(true); + } + }; + + return { + onEvent, + flush, + }; +} diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index eb8605ccfe1c..32022f954531 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -572,6 +572,22 @@ export async function runAgentTurnWithFallback(params: { } } + // If the run completed but with an embedded context overflow error that + // wasn't recovered from (e.g. compaction reset already attempted), surface + // the error to the user instead of silently returning an empty response. + // See #26905: Slack DM sessions silently swallowed messages when context + // overflow errors were returned as embedded error payloads. + const finalEmbeddedError = runResult?.meta?.error; + const hasPayloadText = runResult?.payloads?.some((p) => p.text?.trim()); + if (finalEmbeddedError && isContextOverflowError(finalEmbeddedError.message) && !hasPayloadText) { + return { + kind: "final", + payload: { + text: "⚠️ Context overflow — this conversation is too large for the model. Use /new to start a fresh session.", + }, + }; + } + return { kind: "success", runId, diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts index 52d1e4550c20..ee8ddc251792 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts @@ -1188,6 +1188,54 @@ describe("runReplyAgent typing (heartbeat)", () => { }); }); + it("surfaces overflow fallback when embedded run returns empty payloads", async () => { + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ + payloads: [], + meta: { + durationMs: 1, + error: { + kind: "context_overflow", + message: 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', + }, + }, + })); + + const { run } = createMinimalRun(); + const res = await run(); + const payload = Array.isArray(res) ? res[0] : res; + expect(payload).toMatchObject({ + text: expect.stringContaining("conversation is too large"), + }); + if (!payload) { + throw new Error("expected payload"); + } + expect(payload.text).toContain("/new"); + }); + + it("surfaces overflow fallback when embedded payload text is whitespace-only", async () => { + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ + payloads: [{ text: " \n\t ", isError: true }], + meta: { + durationMs: 1, + error: { + kind: "context_overflow", + message: 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', + }, + }, + })); + + const { run } = createMinimalRun(); + const res = await run(); + const payload = Array.isArray(res) ? res[0] : res; + expect(payload).toMatchObject({ + text: expect.stringContaining("conversation is too large"), + }); + if (!payload) { + throw new Error("expected payload"); + } + expect(payload.text).toContain("/new"); + }); + it("resets the session after role ordering payloads", async () => { await withTempStateDir(async (stateDir) => { const sessionId = "session"; diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 8628fe33a514..9fb2af09ade9 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -41,7 +41,7 @@ import { runMemoryFlushIfNeeded } from "./agent-runner-memory.js"; import { buildReplyPayloads } from "./agent-runner-payloads.js"; import { appendUsageLine, formatResponseUsageLine } from "./agent-runner-utils.js"; import { createAudioAsVoiceBuffer, createBlockReplyPipeline } from "./block-reply-pipeline.js"; -import { resolveBlockStreamingCoalescing } from "./block-streaming.js"; +import { resolveEffectiveBlockStreamingConfig } from "./block-streaming.js"; import { createFollowupRunner } from "./followup-runner.js"; import { resolveOriginMessageProvider, resolveOriginMessageTo } from "./origin-routing.js"; import { @@ -195,12 +195,12 @@ export async function runReplyAgent(params: { const cfg = followupRun.run.config; const blockReplyCoalescing = blockStreamingEnabled && opts?.onBlockReply - ? resolveBlockStreamingCoalescing( + ? resolveEffectiveBlockStreamingConfig({ cfg, - sessionCtx.Provider, - sessionCtx.AccountId, - blockReplyChunking, - ) + provider: sessionCtx.Provider, + accountId: sessionCtx.AccountId, + chunking: blockReplyChunking, + }).coalescing : undefined; const blockReplyPipeline = blockStreamingEnabled && opts?.onBlockReply diff --git a/src/auto-reply/reply/block-streaming.test.ts b/src/auto-reply/reply/block-streaming.test.ts new file mode 100644 index 000000000000..29264ca99b3c --- /dev/null +++ b/src/auto-reply/reply/block-streaming.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { + resolveBlockStreamingChunking, + resolveEffectiveBlockStreamingConfig, +} from "./block-streaming.js"; + +describe("resolveEffectiveBlockStreamingConfig", () => { + it("applies ACP-style overrides while preserving chunk/coalescer bounds", () => { + const cfg = {} as OpenClawConfig; + const baseChunking = resolveBlockStreamingChunking(cfg, "discord"); + const resolved = resolveEffectiveBlockStreamingConfig({ + cfg, + provider: "discord", + maxChunkChars: 64, + coalesceIdleMs: 25, + }); + + expect(baseChunking.maxChars).toBeGreaterThanOrEqual(64); + expect(resolved.chunking.maxChars).toBe(64); + expect(resolved.chunking.minChars).toBeLessThanOrEqual(resolved.chunking.maxChars); + expect(resolved.coalescing.maxChars).toBeLessThanOrEqual(resolved.chunking.maxChars); + expect(resolved.coalescing.minChars).toBeLessThanOrEqual(resolved.coalescing.maxChars); + expect(resolved.coalescing.idleMs).toBe(25); + }); + + it("reuses caller-provided chunking for shared main/subagent/ACP config resolution", () => { + const resolved = resolveEffectiveBlockStreamingConfig({ + cfg: undefined, + chunking: { + minChars: 10, + maxChars: 20, + breakPreference: "paragraph", + }, + coalesceIdleMs: 0, + }); + + expect(resolved.chunking).toEqual({ + minChars: 10, + maxChars: 20, + breakPreference: "paragraph", + }); + expect(resolved.coalescing.maxChars).toBe(20); + expect(resolved.coalescing.idleMs).toBe(0); + }); + + it("allows ACP maxChunkChars overrides above base defaults up to provider text limits", () => { + const cfg = { + channels: { + discord: { + textChunkLimit: 4096, + }, + }, + } as OpenClawConfig; + + const baseChunking = resolveBlockStreamingChunking(cfg, "discord"); + expect(baseChunking.maxChars).toBeLessThan(1800); + + const resolved = resolveEffectiveBlockStreamingConfig({ + cfg, + provider: "discord", + maxChunkChars: 1800, + }); + + expect(resolved.chunking.maxChars).toBe(1800); + expect(resolved.chunking.minChars).toBeLessThanOrEqual(resolved.chunking.maxChars); + }); +}); diff --git a/src/auto-reply/reply/block-streaming.ts b/src/auto-reply/reply/block-streaming.ts index 318da9822385..67b7a4528a7a 100644 --- a/src/auto-reply/reply/block-streaming.ts +++ b/src/auto-reply/reply/block-streaming.ts @@ -59,16 +59,101 @@ export type BlockStreamingCoalescing = { flushOnEnqueue?: boolean; }; -export function resolveBlockStreamingChunking( - cfg: OpenClawConfig | undefined, - provider?: string, - accountId?: string | null, -): { +export type BlockStreamingChunking = { minChars: number; maxChars: number; breakPreference: "paragraph" | "newline" | "sentence"; flushOnParagraph?: boolean; +}; + +function clampPositiveInteger( + value: number | undefined, + fallback: number, + bounds: { min: number; max: number }, +): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return fallback; + } + const rounded = Math.round(value); + if (rounded < bounds.min) { + return bounds.min; + } + if (rounded > bounds.max) { + return bounds.max; + } + return rounded; +} + +export function resolveEffectiveBlockStreamingConfig(params: { + cfg: OpenClawConfig | undefined; + provider?: string; + accountId?: string | null; + chunking?: BlockStreamingChunking; + /** Optional upper bound for chunking/coalescing max chars. */ + maxChunkChars?: number; + /** Optional coalescer idle flush override in milliseconds. */ + coalesceIdleMs?: number; +}): { + chunking: BlockStreamingChunking; + coalescing: BlockStreamingCoalescing; } { + const providerKey = normalizeChunkProvider(params.provider); + const providerId = providerKey ? normalizeChannelId(providerKey) : null; + const providerChunkLimit = providerId + ? getChannelDock(providerId)?.outbound?.textChunkLimit + : undefined; + const textLimit = resolveTextChunkLimit(params.cfg, providerKey, params.accountId, { + fallbackLimit: providerChunkLimit, + }); + const chunkingDefaults = + params.chunking ?? resolveBlockStreamingChunking(params.cfg, params.provider, params.accountId); + const chunkingMax = clampPositiveInteger(params.maxChunkChars, chunkingDefaults.maxChars, { + min: 1, + max: Math.max(1, textLimit), + }); + const chunking: BlockStreamingChunking = { + ...chunkingDefaults, + minChars: Math.min(chunkingDefaults.minChars, chunkingMax), + maxChars: chunkingMax, + }; + const coalescingDefaults = resolveBlockStreamingCoalescing( + params.cfg, + params.provider, + params.accountId, + chunking, + ); + const coalescingMax = Math.max( + 1, + Math.min(coalescingDefaults?.maxChars ?? chunking.maxChars, chunking.maxChars), + ); + const coalescingMin = Math.min(coalescingDefaults?.minChars ?? chunking.minChars, coalescingMax); + const coalescingIdleMs = clampPositiveInteger( + params.coalesceIdleMs, + coalescingDefaults?.idleMs ?? DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS, + { min: 0, max: 5_000 }, + ); + const coalescing: BlockStreamingCoalescing = { + minChars: coalescingMin, + maxChars: coalescingMax, + idleMs: coalescingIdleMs, + joiner: + coalescingDefaults?.joiner ?? + (chunking.breakPreference === "sentence" + ? " " + : chunking.breakPreference === "newline" + ? "\n" + : "\n\n"), + flushOnEnqueue: coalescingDefaults?.flushOnEnqueue ?? chunking.flushOnParagraph === true, + }; + + return { chunking, coalescing }; +} + +export function resolveBlockStreamingChunking( + cfg: OpenClawConfig | undefined, + provider?: string, + accountId?: string | null, +): BlockStreamingChunking { const providerKey = normalizeChunkProvider(provider); const providerConfigKey = providerKey; const providerId = providerKey ? normalizeChannelId(providerKey) : null; diff --git a/src/auto-reply/reply/commands-acp.test.ts b/src/auto-reply/reply/commands-acp.test.ts new file mode 100644 index 000000000000..df3135f1b5b8 --- /dev/null +++ b/src/auto-reply/reply/commands-acp.test.ts @@ -0,0 +1,796 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AcpRuntimeError } from "../../acp/runtime/errors.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js"; + +const hoisted = vi.hoisted(() => { + const callGatewayMock = vi.fn(); + const requireAcpRuntimeBackendMock = vi.fn(); + const getAcpRuntimeBackendMock = vi.fn(); + const listAcpSessionEntriesMock = vi.fn(); + const readAcpSessionEntryMock = vi.fn(); + const upsertAcpSessionMetaMock = vi.fn(); + const resolveSessionStorePathForAcpMock = vi.fn(); + const loadSessionStoreMock = vi.fn(); + const sessionBindingCapabilitiesMock = vi.fn(); + const sessionBindingBindMock = vi.fn(); + const sessionBindingListBySessionMock = vi.fn(); + const sessionBindingResolveByConversationMock = vi.fn(); + const sessionBindingUnbindMock = vi.fn(); + const ensureSessionMock = vi.fn(); + const runTurnMock = vi.fn(); + const cancelMock = vi.fn(); + const closeMock = vi.fn(); + const getCapabilitiesMock = vi.fn(); + const getStatusMock = vi.fn(); + const setModeMock = vi.fn(); + const setConfigOptionMock = vi.fn(); + const doctorMock = vi.fn(); + return { + callGatewayMock, + requireAcpRuntimeBackendMock, + getAcpRuntimeBackendMock, + listAcpSessionEntriesMock, + readAcpSessionEntryMock, + upsertAcpSessionMetaMock, + resolveSessionStorePathForAcpMock, + loadSessionStoreMock, + sessionBindingCapabilitiesMock, + sessionBindingBindMock, + sessionBindingListBySessionMock, + sessionBindingResolveByConversationMock, + sessionBindingUnbindMock, + ensureSessionMock, + runTurnMock, + cancelMock, + closeMock, + getCapabilitiesMock, + getStatusMock, + setModeMock, + setConfigOptionMock, + doctorMock, + }; +}); + +vi.mock("../../gateway/call.js", () => ({ + callGateway: (args: unknown) => hoisted.callGatewayMock(args), +})); + +vi.mock("../../acp/runtime/registry.js", () => ({ + requireAcpRuntimeBackend: (id?: string) => hoisted.requireAcpRuntimeBackendMock(id), + getAcpRuntimeBackend: (id?: string) => hoisted.getAcpRuntimeBackendMock(id), +})); + +vi.mock("../../acp/runtime/session-meta.js", () => ({ + listAcpSessionEntries: (args: unknown) => hoisted.listAcpSessionEntriesMock(args), + readAcpSessionEntry: (args: unknown) => hoisted.readAcpSessionEntryMock(args), + upsertAcpSessionMeta: (args: unknown) => hoisted.upsertAcpSessionMetaMock(args), + resolveSessionStorePathForAcp: (args: unknown) => hoisted.resolveSessionStorePathForAcpMock(args), +})); + +vi.mock("../../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadSessionStore: (...args: unknown[]) => hoisted.loadSessionStoreMock(...args), + }; +}); + +vi.mock("../../infra/outbound/session-binding-service.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getSessionBindingService: () => ({ + bind: (input: unknown) => hoisted.sessionBindingBindMock(input), + getCapabilities: (params: unknown) => hoisted.sessionBindingCapabilitiesMock(params), + listBySession: (targetSessionKey: string) => + hoisted.sessionBindingListBySessionMock(targetSessionKey), + resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref), + touch: vi.fn(), + unbind: (input: unknown) => hoisted.sessionBindingUnbindMock(input), + }), + }; +}); + +// Prevent transitive import chain from reaching discord/monitor which needs https-proxy-agent. +vi.mock("../../discord/monitor/gateway-plugin.js", () => ({ + createDiscordGatewayPlugin: () => ({}), +})); + +const { handleAcpCommand } = await import("./commands-acp.js"); +const { buildCommandTestParams } = await import("./commands-spawn.test-harness.js"); +const { __testing: acpManagerTesting } = await import("../../acp/control-plane/manager.js"); + +type FakeBinding = { + bindingId: string; + targetSessionKey: string; + targetKind: "subagent" | "session"; + conversation: { + channel: "discord"; + accountId: string; + conversationId: string; + parentConversationId?: string; + }; + status: "active"; + boundAt: number; + metadata?: { + agentId?: string; + label?: string; + boundBy?: string; + webhookId?: string; + }; +}; + +function createSessionBinding(overrides?: Partial): FakeBinding { + return { + bindingId: "default:thread-created", + targetSessionKey: "agent:codex:acp:s1", + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "thread-created", + parentConversationId: "parent-1", + }, + status: "active", + boundAt: Date.now(), + metadata: { + agentId: "codex", + boundBy: "user-1", + }, + ...overrides, + }; +} + +const baseCfg = { + session: { mainKey: "main", scope: "per-sender" }, + acp: { + enabled: true, + dispatch: { enabled: true }, + backend: "acpx", + }, + channels: { + discord: { + threadBindings: { + enabled: true, + spawnAcpSessions: true, + }, + }, + }, +} satisfies OpenClawConfig; + +function createDiscordParams(commandBody: string, cfg: OpenClawConfig = baseCfg) { + const params = buildCommandTestParams(commandBody, cfg, { + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + OriginatingTo: "channel:parent-1", + AccountId: "default", + }); + params.command.senderId = "user-1"; + return params; +} + +describe("/acp command", () => { + beforeEach(() => { + acpManagerTesting.resetAcpSessionManagerForTests(); + hoisted.listAcpSessionEntriesMock.mockReset().mockResolvedValue([]); + hoisted.callGatewayMock.mockReset().mockResolvedValue({ ok: true }); + hoisted.readAcpSessionEntryMock.mockReset().mockReturnValue(null); + hoisted.upsertAcpSessionMetaMock.mockReset().mockResolvedValue({ + sessionId: "session-1", + updatedAt: Date.now(), + acp: { + backend: "acpx", + agent: "codex", + runtimeSessionName: "run-1", + mode: "persistent", + state: "idle", + lastActivityAt: Date.now(), + }, + }); + hoisted.resolveSessionStorePathForAcpMock.mockReset().mockReturnValue({ + cfg: baseCfg, + storePath: "/tmp/sessions-acp.json", + }); + hoisted.loadSessionStoreMock.mockReset().mockReturnValue({}); + hoisted.sessionBindingCapabilitiesMock.mockReset().mockReturnValue({ + adapterAvailable: true, + bindSupported: true, + unbindSupported: true, + placements: ["current", "child"], + }); + hoisted.sessionBindingBindMock + .mockReset() + .mockImplementation( + async (input: { + targetSessionKey: string; + conversation: { accountId: string; conversationId: string }; + placement: "current" | "child"; + metadata?: Record; + }) => + createSessionBinding({ + targetSessionKey: input.targetSessionKey, + conversation: { + channel: "discord", + accountId: input.conversation.accountId, + conversationId: + input.placement === "child" ? "thread-created" : input.conversation.conversationId, + parentConversationId: "parent-1", + }, + metadata: { + boundBy: + typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "user-1", + webhookId: "wh-1", + }, + }), + ); + hoisted.sessionBindingListBySessionMock.mockReset().mockReturnValue([]); + hoisted.sessionBindingResolveByConversationMock.mockReset().mockReturnValue(null); + hoisted.sessionBindingUnbindMock.mockReset().mockResolvedValue([]); + + hoisted.ensureSessionMock + .mockReset() + .mockImplementation(async (input: { sessionKey: string }) => ({ + sessionKey: input.sessionKey, + backend: "acpx", + runtimeSessionName: `${input.sessionKey}:runtime`, + })); + hoisted.runTurnMock.mockReset().mockImplementation(async function* () { + yield { type: "done" }; + }); + hoisted.cancelMock.mockReset().mockResolvedValue(undefined); + hoisted.closeMock.mockReset().mockResolvedValue(undefined); + hoisted.getCapabilitiesMock.mockReset().mockResolvedValue({ + controls: ["session/set_mode", "session/set_config_option", "session/status"], + }); + hoisted.getStatusMock.mockReset().mockResolvedValue({ + summary: "status=alive sessionId=sid-1 pid=1234", + details: { status: "alive", sessionId: "sid-1", pid: 1234 }, + }); + hoisted.setModeMock.mockReset().mockResolvedValue(undefined); + hoisted.setConfigOptionMock.mockReset().mockResolvedValue(undefined); + hoisted.doctorMock.mockReset().mockResolvedValue({ + ok: true, + message: "acpx command available", + }); + + const runtimeBackend = { + id: "acpx", + runtime: { + ensureSession: hoisted.ensureSessionMock, + runTurn: hoisted.runTurnMock, + getCapabilities: hoisted.getCapabilitiesMock, + getStatus: hoisted.getStatusMock, + setMode: hoisted.setModeMock, + setConfigOption: hoisted.setConfigOptionMock, + doctor: hoisted.doctorMock, + cancel: hoisted.cancelMock, + close: hoisted.closeMock, + }, + }; + hoisted.requireAcpRuntimeBackendMock.mockReset().mockReturnValue(runtimeBackend); + hoisted.getAcpRuntimeBackendMock.mockReset().mockReturnValue(runtimeBackend); + }); + + it("returns null when the message is not /acp", async () => { + const params = createDiscordParams("/status"); + const result = await handleAcpCommand(params, true); + expect(result).toBeNull(); + }); + + it("shows help by default", async () => { + const params = createDiscordParams("/acp"); + const result = await handleAcpCommand(params, true); + expect(result?.reply?.text).toContain("ACP commands:"); + expect(result?.reply?.text).toContain("/acp spawn"); + }); + + it("spawns an ACP session and binds a Discord thread", async () => { + hoisted.ensureSessionMock.mockResolvedValueOnce({ + sessionKey: "agent:codex:acp:s1", + backend: "acpx", + runtimeSessionName: "agent:codex:acp:s1:runtime", + agentSessionId: "codex-inner-1", + backendSessionId: "acpx-1", + }); + + const params = createDiscordParams("/acp spawn codex --cwd /home/bob/clawd"); + const result = await handleAcpCommand(params, true); + + expect(result?.reply?.text).toContain("Spawned ACP session agent:codex:acp:"); + expect(result?.reply?.text).toContain("Created thread thread-created and bound it"); + expect(hoisted.requireAcpRuntimeBackendMock).toHaveBeenCalledWith("acpx"); + expect(hoisted.ensureSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ + agent: "codex", + mode: "persistent", + cwd: "/home/bob/clawd", + }), + ); + expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( + expect.objectContaining({ + targetKind: "session", + placement: "child", + metadata: expect.objectContaining({ + introText: expect.stringContaining("cwd: /home/bob/clawd"), + }), + }), + ); + expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + introText: expect.not.stringContaining( + "session ids: pending (available after the first reply)", + ), + }), + }), + ); + expect(hoisted.callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "sessions.patch", + }), + ); + expect(hoisted.upsertAcpSessionMetaMock).toHaveBeenCalled(); + const upsertArgs = hoisted.upsertAcpSessionMetaMock.mock.calls[0]?.[0] as + | { + sessionKey: string; + mutate: ( + current: unknown, + entry: { sessionId: string; updatedAt: number } | undefined, + ) => { + backend?: string; + runtimeSessionName?: string; + }; + } + | undefined; + expect(upsertArgs?.sessionKey).toMatch(/^agent:codex:acp:/); + const seededWithoutEntry = upsertArgs?.mutate(undefined, undefined); + expect(seededWithoutEntry?.backend).toBe("acpx"); + expect(seededWithoutEntry?.runtimeSessionName).toContain(":runtime"); + }); + + it("requires explicit ACP target when acp.defaultAgent is not configured", async () => { + const params = createDiscordParams("/acp spawn"); + const result = await handleAcpCommand(params, true); + + expect(result?.reply?.text).toContain("ACP target agent is required"); + expect(hoisted.ensureSessionMock).not.toHaveBeenCalled(); + }); + + it("rejects thread-bound ACP spawn when spawnAcpSessions is disabled", async () => { + const cfg = { + ...baseCfg, + channels: { + discord: { + threadBindings: { + enabled: true, + spawnAcpSessions: false, + }, + }, + }, + } satisfies OpenClawConfig; + + const params = createDiscordParams("/acp spawn codex", cfg); + const result = await handleAcpCommand(params, true); + + expect(result?.reply?.text).toContain("spawnAcpSessions=true"); + expect(hoisted.closeMock).toHaveBeenCalledTimes(1); + expect(hoisted.callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "sessions.delete", + params: expect.objectContaining({ + key: expect.stringMatching(/^agent:codex:acp:/), + deleteTranscript: false, + emitLifecycleHooks: false, + }), + }), + ); + expect(hoisted.callGatewayMock).not.toHaveBeenCalledWith( + expect.objectContaining({ method: "sessions.patch" }), + ); + }); + + it("cancels the ACP session bound to the current thread", async () => { + hoisted.sessionBindingResolveByConversationMock.mockReturnValue( + createSessionBinding({ + targetSessionKey: "agent:codex:acp:s1", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "thread-1", + parentConversationId: "parent-1", + }, + }), + ); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:s1", + storeSessionKey: "agent:codex:acp:s1", + acp: { + backend: "acpx", + agent: "codex", + runtimeSessionName: "runtime-1", + mode: "persistent", + state: "running", + lastActivityAt: Date.now(), + }, + }); + + const params = createDiscordParams("/acp cancel", baseCfg); + params.ctx.MessageThreadId = "thread-1"; + + const result = await handleAcpCommand(params, true); + expect(result?.reply?.text).toContain("Cancel requested for ACP session agent:codex:acp:s1"); + expect(hoisted.cancelMock).toHaveBeenCalledWith({ + handle: expect.objectContaining({ + sessionKey: "agent:codex:acp:s1", + backend: "acpx", + }), + reason: "manual-cancel", + }); + }); + + it("sends steer instructions via ACP runtime", async () => { + hoisted.callGatewayMock.mockImplementation(async (request: { method?: string }) => { + if (request.method === "sessions.resolve") { + return { key: "agent:codex:acp:s1" }; + } + return { ok: true }; + }); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:s1", + storeSessionKey: "agent:codex:acp:s1", + acp: { + backend: "acpx", + agent: "codex", + runtimeSessionName: "runtime-1", + mode: "persistent", + state: "idle", + lastActivityAt: Date.now(), + }, + }); + hoisted.runTurnMock.mockImplementation(async function* () { + yield { type: "text_delta", text: "Applied steering." }; + yield { type: "done" }; + }); + + const params = createDiscordParams("/acp steer --session agent:codex:acp:s1 tighten logging"); + const result = await handleAcpCommand(params, true); + + expect(hoisted.runTurnMock).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "steer", + text: "tighten logging", + }), + ); + expect(result?.reply?.text).toContain("Applied steering."); + }); + + it("blocks /acp steer when ACP dispatch is disabled by policy", async () => { + const cfg = { + ...baseCfg, + acp: { + ...baseCfg.acp, + dispatch: { enabled: false }, + }, + } satisfies OpenClawConfig; + const params = createDiscordParams("/acp steer tighten logging", cfg); + const result = await handleAcpCommand(params, true); + expect(result?.reply?.text).toContain("ACP dispatch is disabled by policy"); + expect(hoisted.runTurnMock).not.toHaveBeenCalled(); + }); + + it("closes an ACP session, unbinds thread targets, and clears metadata", async () => { + hoisted.sessionBindingResolveByConversationMock.mockReturnValue( + createSessionBinding({ + targetSessionKey: "agent:codex:acp:s1", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "thread-1", + parentConversationId: "parent-1", + }, + }), + ); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:s1", + storeSessionKey: "agent:codex:acp:s1", + acp: { + backend: "acpx", + agent: "codex", + runtimeSessionName: "runtime-1", + mode: "persistent", + state: "idle", + lastActivityAt: Date.now(), + }, + }); + hoisted.sessionBindingUnbindMock.mockResolvedValue([ + createSessionBinding({ + targetSessionKey: "agent:codex:acp:s1", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "thread-1", + parentConversationId: "parent-1", + }, + }) as SessionBindingRecord, + ]); + + const params = createDiscordParams("/acp close", baseCfg); + params.ctx.MessageThreadId = "thread-1"; + + const result = await handleAcpCommand(params, true); + + expect(hoisted.closeMock).toHaveBeenCalledTimes(1); + expect(hoisted.sessionBindingUnbindMock).toHaveBeenCalledWith( + expect.objectContaining({ + targetSessionKey: "agent:codex:acp:s1", + reason: "manual", + }), + ); + expect(hoisted.upsertAcpSessionMetaMock).toHaveBeenCalled(); + expect(result?.reply?.text).toContain("Removed 1 binding"); + }); + + it("lists ACP sessions from the session store", async () => { + hoisted.sessionBindingListBySessionMock.mockImplementation((key: string) => + key === "agent:codex:acp:s1" + ? [ + createSessionBinding({ + targetSessionKey: key, + conversation: { + channel: "discord", + accountId: "default", + conversationId: "thread-1", + parentConversationId: "parent-1", + }, + }) as SessionBindingRecord, + ] + : [], + ); + hoisted.loadSessionStoreMock.mockReturnValue({ + "agent:codex:acp:s1": { + sessionId: "sess-1", + updatedAt: Date.now(), + label: "codex-main", + acp: { + backend: "acpx", + agent: "codex", + runtimeSessionName: "runtime-1", + mode: "persistent", + state: "idle", + lastActivityAt: Date.now(), + }, + }, + "agent:main:main": { + sessionId: "sess-main", + updatedAt: Date.now(), + }, + }); + + const params = createDiscordParams("/acp sessions", baseCfg); + const result = await handleAcpCommand(params, true); + + expect(result?.reply?.text).toContain("ACP sessions:"); + expect(result?.reply?.text).toContain("codex-main"); + expect(result?.reply?.text).toContain("thread:thread-1"); + }); + + it("shows ACP status for the thread-bound ACP session", async () => { + hoisted.sessionBindingResolveByConversationMock.mockReturnValue( + createSessionBinding({ + targetSessionKey: "agent:codex:acp:s1", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "thread-1", + parentConversationId: "parent-1", + }, + }), + ); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:s1", + storeSessionKey: "agent:codex:acp:s1", + acp: { + backend: "acpx", + agent: "codex", + runtimeSessionName: "runtime-1", + identity: { + state: "resolved", + source: "status", + acpxSessionId: "acpx-sid-1", + agentSessionId: "codex-sid-1", + lastUpdatedAt: Date.now(), + }, + mode: "persistent", + state: "idle", + lastActivityAt: Date.now(), + }, + }); + const params = createDiscordParams("/acp status", baseCfg); + params.ctx.MessageThreadId = "thread-1"; + + const result = await handleAcpCommand(params, true); + + expect(result?.reply?.text).toContain("ACP status:"); + expect(result?.reply?.text).toContain("session: agent:codex:acp:s1"); + expect(result?.reply?.text).toContain("agent session id: codex-sid-1"); + expect(result?.reply?.text).toContain("acpx session id: acpx-sid-1"); + expect(result?.reply?.text).toContain("capabilities:"); + expect(hoisted.getStatusMock).toHaveBeenCalledTimes(1); + }); + + it("updates ACP runtime mode via /acp set-mode", async () => { + hoisted.sessionBindingResolveByConversationMock.mockReturnValue( + createSessionBinding({ + targetSessionKey: "agent:codex:acp:s1", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "thread-1", + parentConversationId: "parent-1", + }, + }), + ); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:s1", + storeSessionKey: "agent:codex:acp:s1", + acp: { + backend: "acpx", + agent: "codex", + runtimeSessionName: "runtime-1", + mode: "persistent", + state: "idle", + lastActivityAt: Date.now(), + }, + }); + const params = createDiscordParams("/acp set-mode plan", baseCfg); + params.ctx.MessageThreadId = "thread-1"; + + const result = await handleAcpCommand(params, true); + + expect(hoisted.setModeMock).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "plan", + }), + ); + expect(result?.reply?.text).toContain("Updated ACP runtime mode"); + }); + + it("updates ACP config options and keeps cwd local when using /acp set", async () => { + hoisted.sessionBindingResolveByConversationMock.mockReturnValue( + createSessionBinding({ + targetSessionKey: "agent:codex:acp:s1", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "thread-1", + parentConversationId: "parent-1", + }, + }), + ); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:s1", + storeSessionKey: "agent:codex:acp:s1", + acp: { + backend: "acpx", + agent: "codex", + runtimeSessionName: "runtime-1", + mode: "persistent", + state: "idle", + lastActivityAt: Date.now(), + }, + }); + + const setModelParams = createDiscordParams("/acp set model gpt-5.3-codex", baseCfg); + setModelParams.ctx.MessageThreadId = "thread-1"; + const setModel = await handleAcpCommand(setModelParams, true); + expect(hoisted.setConfigOptionMock).toHaveBeenCalledWith( + expect.objectContaining({ + key: "model", + value: "gpt-5.3-codex", + }), + ); + expect(setModel?.reply?.text).toContain("Updated ACP config option"); + + hoisted.setConfigOptionMock.mockClear(); + const setCwdParams = createDiscordParams("/acp set cwd /tmp/worktree", baseCfg); + setCwdParams.ctx.MessageThreadId = "thread-1"; + const setCwd = await handleAcpCommand(setCwdParams, true); + expect(hoisted.setConfigOptionMock).not.toHaveBeenCalled(); + expect(setCwd?.reply?.text).toContain("Updated ACP cwd"); + }); + + it("rejects non-absolute cwd values via ACP runtime option validation", async () => { + hoisted.sessionBindingResolveByConversationMock.mockReturnValue( + createSessionBinding({ + targetSessionKey: "agent:codex:acp:s1", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "thread-1", + parentConversationId: "parent-1", + }, + }), + ); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:s1", + storeSessionKey: "agent:codex:acp:s1", + acp: { + backend: "acpx", + agent: "codex", + runtimeSessionName: "runtime-1", + mode: "persistent", + state: "idle", + lastActivityAt: Date.now(), + }, + }); + + const params = createDiscordParams("/acp cwd relative/path", baseCfg); + params.ctx.MessageThreadId = "thread-1"; + const result = await handleAcpCommand(params, true); + + expect(result?.reply?.text).toContain("ACP error (ACP_INVALID_RUNTIME_OPTION)"); + expect(result?.reply?.text).toContain("absolute path"); + }); + + it("rejects invalid timeout values before backend config writes", async () => { + hoisted.sessionBindingResolveByConversationMock.mockReturnValue( + createSessionBinding({ + targetSessionKey: "agent:codex:acp:s1", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "thread-1", + parentConversationId: "parent-1", + }, + }), + ); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:s1", + storeSessionKey: "agent:codex:acp:s1", + acp: { + backend: "acpx", + agent: "codex", + runtimeSessionName: "runtime-1", + mode: "persistent", + state: "idle", + lastActivityAt: Date.now(), + }, + }); + + const params = createDiscordParams("/acp timeout 10s", baseCfg); + params.ctx.MessageThreadId = "thread-1"; + const result = await handleAcpCommand(params, true); + + expect(result?.reply?.text).toContain("ACP error (ACP_INVALID_RUNTIME_OPTION)"); + expect(hoisted.setConfigOptionMock).not.toHaveBeenCalled(); + }); + + it("returns actionable doctor output when backend is missing", async () => { + hoisted.getAcpRuntimeBackendMock.mockReturnValue(null); + hoisted.requireAcpRuntimeBackendMock.mockImplementation(() => { + throw new AcpRuntimeError( + "ACP_BACKEND_MISSING", + "ACP runtime backend is not configured. Install and enable the acpx runtime plugin.", + ); + }); + + const params = createDiscordParams("/acp doctor", baseCfg); + const result = await handleAcpCommand(params, true); + + expect(result?.reply?.text).toContain("ACP doctor:"); + expect(result?.reply?.text).toContain("healthy: no"); + expect(result?.reply?.text).toContain("next:"); + }); + + it("shows deterministic install instructions via /acp install", async () => { + const params = createDiscordParams("/acp install", baseCfg); + const result = await handleAcpCommand(params, true); + + expect(result?.reply?.text).toContain("ACP install:"); + expect(result?.reply?.text).toContain("run:"); + expect(result?.reply?.text).toContain("then: /acp doctor"); + }); +}); diff --git a/src/auto-reply/reply/commands-acp.ts b/src/auto-reply/reply/commands-acp.ts new file mode 100644 index 000000000000..2eef395c9a2c --- /dev/null +++ b/src/auto-reply/reply/commands-acp.ts @@ -0,0 +1,83 @@ +import { logVerbose } from "../../globals.js"; +import { + handleAcpDoctorAction, + handleAcpInstallAction, + handleAcpSessionsAction, +} from "./commands-acp/diagnostics.js"; +import { + handleAcpCancelAction, + handleAcpCloseAction, + handleAcpSpawnAction, + handleAcpSteerAction, +} from "./commands-acp/lifecycle.js"; +import { + handleAcpCwdAction, + handleAcpModelAction, + handleAcpPermissionsAction, + handleAcpResetOptionsAction, + handleAcpSetAction, + handleAcpSetModeAction, + handleAcpStatusAction, + handleAcpTimeoutAction, +} from "./commands-acp/runtime-options.js"; +import { + COMMAND, + type AcpAction, + resolveAcpAction, + resolveAcpHelpText, + stopWithText, +} from "./commands-acp/shared.js"; +import type { + CommandHandler, + CommandHandlerResult, + HandleCommandsParams, +} from "./commands-types.js"; + +type AcpActionHandler = ( + params: HandleCommandsParams, + tokens: string[], +) => Promise; + +const ACP_ACTION_HANDLERS: Record, AcpActionHandler> = { + spawn: handleAcpSpawnAction, + cancel: handleAcpCancelAction, + steer: handleAcpSteerAction, + close: handleAcpCloseAction, + status: handleAcpStatusAction, + "set-mode": handleAcpSetModeAction, + set: handleAcpSetAction, + cwd: handleAcpCwdAction, + permissions: handleAcpPermissionsAction, + timeout: handleAcpTimeoutAction, + model: handleAcpModelAction, + "reset-options": handleAcpResetOptionsAction, + doctor: handleAcpDoctorAction, + install: async (params, tokens) => handleAcpInstallAction(params, tokens), + sessions: async (params, tokens) => handleAcpSessionsAction(params, tokens), +}; + +export const handleAcpCommand: CommandHandler = async (params, allowTextCommands) => { + if (!allowTextCommands) { + return null; + } + + const normalized = params.command.commandBodyNormalized; + if (!normalized.startsWith(COMMAND)) { + return null; + } + + if (!params.command.isAuthorizedSender) { + logVerbose(`Ignoring /acp from unauthorized sender: ${params.command.senderId || ""}`); + return { shouldContinue: false }; + } + + const rest = normalized.slice(COMMAND.length).trim(); + const tokens = rest.split(/\s+/).filter(Boolean); + const action = resolveAcpAction(tokens); + if (action === "help") { + return stopWithText(resolveAcpHelpText()); + } + + const handler = ACP_ACTION_HANDLERS[action]; + return handler ? await handler(params, tokens) : stopWithText(resolveAcpHelpText()); +}; diff --git a/src/auto-reply/reply/commands-acp/context.test.ts b/src/auto-reply/reply/commands-acp/context.test.ts new file mode 100644 index 000000000000..92952ad749f1 --- /dev/null +++ b/src/auto-reply/reply/commands-acp/context.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../../config/config.js"; +import { buildCommandTestParams } from "../commands-spawn.test-harness.js"; +import { + isAcpCommandDiscordChannel, + resolveAcpCommandBindingContext, + resolveAcpCommandConversationId, +} from "./context.js"; + +const baseCfg = { + session: { mainKey: "main", scope: "per-sender" }, +} satisfies OpenClawConfig; + +describe("commands-acp context", () => { + it("resolves channel/account/thread context from originating fields", () => { + const params = buildCommandTestParams("/acp sessions", baseCfg, { + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + OriginatingTo: "channel:parent-1", + AccountId: "work", + MessageThreadId: "thread-42", + }); + + expect(resolveAcpCommandBindingContext(params)).toEqual({ + channel: "discord", + accountId: "work", + threadId: "thread-42", + conversationId: "thread-42", + }); + expect(isAcpCommandDiscordChannel(params)).toBe(true); + }); + + it("falls back to default account and target-derived conversation id", () => { + const params = buildCommandTestParams("/acp status", baseCfg, { + Provider: "slack", + Surface: "slack", + OriginatingChannel: "slack", + To: "<#123456789>", + }); + + expect(resolveAcpCommandBindingContext(params)).toEqual({ + channel: "slack", + accountId: "default", + threadId: undefined, + conversationId: "123456789", + }); + expect(resolveAcpCommandConversationId(params)).toBe("123456789"); + expect(isAcpCommandDiscordChannel(params)).toBe(false); + }); +}); diff --git a/src/auto-reply/reply/commands-acp/context.ts b/src/auto-reply/reply/commands-acp/context.ts new file mode 100644 index 000000000000..f9ac901ec92e --- /dev/null +++ b/src/auto-reply/reply/commands-acp/context.ts @@ -0,0 +1,58 @@ +import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js"; +import { resolveConversationIdFromTargets } from "../../../infra/outbound/conversation-id.js"; +import type { HandleCommandsParams } from "../commands-types.js"; + +function normalizeString(value: unknown): string { + if (typeof value === "string") { + return value.trim(); + } + if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") { + return `${value}`.trim(); + } + return ""; +} + +export function resolveAcpCommandChannel(params: HandleCommandsParams): string { + const raw = + params.ctx.OriginatingChannel ?? + params.command.channel ?? + params.ctx.Surface ?? + params.ctx.Provider; + return normalizeString(raw).toLowerCase(); +} + +export function resolveAcpCommandAccountId(params: HandleCommandsParams): string { + const accountId = normalizeString(params.ctx.AccountId); + return accountId || "default"; +} + +export function resolveAcpCommandThreadId(params: HandleCommandsParams): string | undefined { + const threadId = + params.ctx.MessageThreadId != null ? normalizeString(String(params.ctx.MessageThreadId)) : ""; + return threadId || undefined; +} + +export function resolveAcpCommandConversationId(params: HandleCommandsParams): string | undefined { + return resolveConversationIdFromTargets({ + threadId: params.ctx.MessageThreadId, + targets: [params.ctx.OriginatingTo, params.command.to, params.ctx.To], + }); +} + +export function isAcpCommandDiscordChannel(params: HandleCommandsParams): boolean { + return resolveAcpCommandChannel(params) === DISCORD_THREAD_BINDING_CHANNEL; +} + +export function resolveAcpCommandBindingContext(params: HandleCommandsParams): { + channel: string; + accountId: string; + threadId?: string; + conversationId?: string; +} { + return { + channel: resolveAcpCommandChannel(params), + accountId: resolveAcpCommandAccountId(params), + threadId: resolveAcpCommandThreadId(params), + conversationId: resolveAcpCommandConversationId(params), + }; +} diff --git a/src/auto-reply/reply/commands-acp/diagnostics.ts b/src/auto-reply/reply/commands-acp/diagnostics.ts new file mode 100644 index 000000000000..d521ac7ae5fd --- /dev/null +++ b/src/auto-reply/reply/commands-acp/diagnostics.ts @@ -0,0 +1,203 @@ +import { getAcpSessionManager } from "../../../acp/control-plane/manager.js"; +import { formatAcpRuntimeErrorText } from "../../../acp/runtime/error-text.js"; +import { toAcpRuntimeError } from "../../../acp/runtime/errors.js"; +import { getAcpRuntimeBackend, requireAcpRuntimeBackend } from "../../../acp/runtime/registry.js"; +import { resolveSessionStorePathForAcp } from "../../../acp/runtime/session-meta.js"; +import { loadSessionStore } from "../../../config/sessions.js"; +import type { SessionEntry } from "../../../config/sessions/types.js"; +import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js"; +import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js"; +import { resolveAcpCommandBindingContext } from "./context.js"; +import { + ACP_DOCTOR_USAGE, + ACP_INSTALL_USAGE, + ACP_SESSIONS_USAGE, + formatAcpCapabilitiesText, + resolveAcpInstallCommandHint, + resolveConfiguredAcpBackendId, + stopWithText, +} from "./shared.js"; +import { resolveBoundAcpThreadSessionKey } from "./targets.js"; + +export async function handleAcpDoctorAction( + params: HandleCommandsParams, + restTokens: string[], +): Promise { + if (restTokens.length > 0) { + return stopWithText(`⚠️ ${ACP_DOCTOR_USAGE}`); + } + + const backendId = resolveConfiguredAcpBackendId(params.cfg); + const installHint = resolveAcpInstallCommandHint(params.cfg); + const registeredBackend = getAcpRuntimeBackend(backendId); + const managerSnapshot = getAcpSessionManager().getObservabilitySnapshot(params.cfg); + const lines = ["ACP doctor:", "-----", `configuredBackend: ${backendId}`]; + lines.push(`activeRuntimeSessions: ${managerSnapshot.runtimeCache.activeSessions}`); + lines.push(`runtimeIdleTtlMs: ${managerSnapshot.runtimeCache.idleTtlMs}`); + lines.push(`evictedIdleRuntimes: ${managerSnapshot.runtimeCache.evictedTotal}`); + lines.push(`activeTurns: ${managerSnapshot.turns.active}`); + lines.push(`queueDepth: ${managerSnapshot.turns.queueDepth}`); + lines.push( + `turnLatencyMs: avg=${managerSnapshot.turns.averageLatencyMs}, max=${managerSnapshot.turns.maxLatencyMs}`, + ); + lines.push( + `turnCounts: completed=${managerSnapshot.turns.completed}, failed=${managerSnapshot.turns.failed}`, + ); + const errorStatsText = + Object.entries(managerSnapshot.errorsByCode) + .map(([code, count]) => `${code}=${count}`) + .join(", ") || "(none)"; + lines.push(`errorCodes: ${errorStatsText}`); + if (registeredBackend) { + lines.push(`registeredBackend: ${registeredBackend.id}`); + } else { + lines.push("registeredBackend: (none)"); + } + + if (registeredBackend?.runtime.doctor) { + try { + const report = await registeredBackend.runtime.doctor(); + lines.push(`runtimeDoctor: ${report.ok ? "ok" : "error"} (${report.message})`); + if (report.code) { + lines.push(`runtimeDoctorCode: ${report.code}`); + } + if (report.installCommand) { + lines.push(`runtimeDoctorInstall: ${report.installCommand}`); + } + for (const detail of report.details ?? []) { + lines.push(`runtimeDoctorDetail: ${detail}`); + } + } catch (error) { + lines.push( + `runtimeDoctor: error (${ + toAcpRuntimeError({ + error, + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "Runtime doctor failed.", + }).message + })`, + ); + } + } + + try { + const backend = requireAcpRuntimeBackend(backendId); + const capabilities = backend.runtime.getCapabilities + ? await backend.runtime.getCapabilities({}) + : { controls: [] as string[], configOptionKeys: [] as string[] }; + lines.push("healthy: yes"); + lines.push(`capabilities: ${formatAcpCapabilitiesText(capabilities.controls ?? [])}`); + if ((capabilities.configOptionKeys?.length ?? 0) > 0) { + lines.push(`configKeys: ${capabilities.configOptionKeys?.join(", ")}`); + } + return stopWithText(lines.join("\n")); + } catch (error) { + const acpError = toAcpRuntimeError({ + error, + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "ACP backend doctor failed.", + }); + lines.push("healthy: no"); + lines.push(formatAcpRuntimeErrorText(acpError)); + lines.push(`next: ${installHint}`); + lines.push(`next: openclaw config set plugins.entries.${backendId}.enabled true`); + if (backendId.toLowerCase() === "acpx") { + lines.push("next: verify acpx is installed (`acpx --help`)."); + } + return stopWithText(lines.join("\n")); + } +} + +export function handleAcpInstallAction( + params: HandleCommandsParams, + restTokens: string[], +): CommandHandlerResult { + if (restTokens.length > 0) { + return stopWithText(`⚠️ ${ACP_INSTALL_USAGE}`); + } + const backendId = resolveConfiguredAcpBackendId(params.cfg); + const installHint = resolveAcpInstallCommandHint(params.cfg); + const lines = [ + "ACP install:", + "-----", + `configuredBackend: ${backendId}`, + `run: ${installHint}`, + `then: openclaw config set plugins.entries.${backendId}.enabled true`, + "then: /acp doctor", + ]; + return stopWithText(lines.join("\n")); +} + +function formatAcpSessionLine(params: { + key: string; + entry: SessionEntry; + currentSessionKey?: string; + threadId?: string; +}): string { + const acp = params.entry.acp; + if (!acp) { + return ""; + } + const marker = params.currentSessionKey === params.key ? "*" : " "; + const label = params.entry.label?.trim() || acp.agent; + const threadText = params.threadId ? `, thread:${params.threadId}` : ""; + return `${marker} ${label} (${acp.mode}, ${acp.state}, backend:${acp.backend}${threadText}) -> ${params.key}`; +} + +export function handleAcpSessionsAction( + params: HandleCommandsParams, + restTokens: string[], +): CommandHandlerResult { + if (restTokens.length > 0) { + return stopWithText(ACP_SESSIONS_USAGE); + } + + const currentSessionKey = resolveBoundAcpThreadSessionKey(params) || params.sessionKey; + if (!currentSessionKey) { + return stopWithText("⚠️ Missing session key."); + } + + const { storePath } = resolveSessionStorePathForAcp({ + cfg: params.cfg, + sessionKey: currentSessionKey, + }); + + let store: Record; + try { + store = loadSessionStore(storePath); + } catch { + store = {}; + } + + const bindingContext = resolveAcpCommandBindingContext(params); + const normalizedChannel = bindingContext.channel; + const normalizedAccountId = bindingContext.accountId || undefined; + const bindingService = getSessionBindingService(); + + const rows = Object.entries(store) + .filter(([, entry]) => Boolean(entry?.acp)) + .toSorted(([, a], [, b]) => (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0)) + .slice(0, 20) + .map(([key, entry]) => { + const bindingThreadId = bindingService + .listBySession(key) + .find( + (binding) => + (!normalizedChannel || binding.conversation.channel === normalizedChannel) && + (!normalizedAccountId || binding.conversation.accountId === normalizedAccountId), + )?.conversation.conversationId; + return formatAcpSessionLine({ + key, + entry, + currentSessionKey, + threadId: bindingThreadId, + }); + }) + .filter(Boolean); + + if (rows.length === 0) { + return stopWithText("ACP sessions:\n-----\n(none)"); + } + + return stopWithText(["ACP sessions:", "-----", ...rows].join("\n")); +} diff --git a/src/auto-reply/reply/commands-acp/lifecycle.ts b/src/auto-reply/reply/commands-acp/lifecycle.ts new file mode 100644 index 000000000000..9039cfe64e00 --- /dev/null +++ b/src/auto-reply/reply/commands-acp/lifecycle.ts @@ -0,0 +1,588 @@ +import { randomUUID } from "node:crypto"; +import { getAcpSessionManager } from "../../../acp/control-plane/manager.js"; +import { + cleanupFailedAcpSpawn, + type AcpSpawnRuntimeCloseHandle, +} from "../../../acp/control-plane/spawn.js"; +import { + isAcpEnabledByPolicy, + resolveAcpAgentPolicyError, + resolveAcpDispatchPolicyError, + resolveAcpDispatchPolicyMessage, +} from "../../../acp/policy.js"; +import { AcpRuntimeError } from "../../../acp/runtime/errors.js"; +import { + resolveAcpSessionCwd, + resolveAcpThreadSessionDetailLines, +} from "../../../acp/runtime/session-identifiers.js"; +import { + resolveThreadBindingIntroText, + resolveThreadBindingThreadName, +} from "../../../channels/thread-bindings-messages.js"; +import { + formatThreadBindingDisabledError, + formatThreadBindingSpawnDisabledError, + resolveThreadBindingSessionTtlMsForChannel, + resolveThreadBindingSpawnPolicy, +} from "../../../channels/thread-bindings-policy.js"; +import type { OpenClawConfig } from "../../../config/config.js"; +import type { SessionAcpMeta } from "../../../config/sessions/types.js"; +import { callGateway } from "../../../gateway/call.js"; +import { + getSessionBindingService, + type SessionBindingRecord, +} from "../../../infra/outbound/session-binding-service.js"; +import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js"; +import { + resolveAcpCommandAccountId, + resolveAcpCommandBindingContext, + resolveAcpCommandThreadId, +} from "./context.js"; +import { + ACP_STEER_OUTPUT_LIMIT, + collectAcpErrorText, + parseSpawnInput, + parseSteerInput, + resolveCommandRequestId, + stopWithText, + type AcpSpawnThreadMode, + withAcpCommandErrorBoundary, +} from "./shared.js"; +import { resolveAcpTargetSessionKey } from "./targets.js"; + +async function bindSpawnedAcpSessionToThread(params: { + commandParams: HandleCommandsParams; + sessionKey: string; + agentId: string; + label?: string; + threadMode: AcpSpawnThreadMode; + sessionMeta?: SessionAcpMeta; +}): Promise<{ ok: true; binding: SessionBindingRecord } | { ok: false; error: string }> { + const { commandParams, threadMode } = params; + if (threadMode === "off") { + return { + ok: false, + error: "internal: thread binding is disabled for this spawn", + }; + } + + const bindingContext = resolveAcpCommandBindingContext(commandParams); + const channel = bindingContext.channel; + if (!channel) { + return { + ok: false, + error: "ACP thread binding requires a channel context.", + }; + } + + const accountId = resolveAcpCommandAccountId(commandParams); + const spawnPolicy = resolveThreadBindingSpawnPolicy({ + cfg: commandParams.cfg, + channel, + accountId, + kind: "acp", + }); + if (!spawnPolicy.enabled) { + return { + ok: false, + error: formatThreadBindingDisabledError({ + channel: spawnPolicy.channel, + accountId: spawnPolicy.accountId, + kind: "acp", + }), + }; + } + if (!spawnPolicy.spawnEnabled) { + return { + ok: false, + error: formatThreadBindingSpawnDisabledError({ + channel: spawnPolicy.channel, + accountId: spawnPolicy.accountId, + kind: "acp", + }), + }; + } + + const bindingService = getSessionBindingService(); + const capabilities = bindingService.getCapabilities({ + channel: spawnPolicy.channel, + accountId: spawnPolicy.accountId, + }); + if (!capabilities.adapterAvailable) { + return { + ok: false, + error: `Thread bindings are unavailable for ${channel}.`, + }; + } + if (!capabilities.bindSupported) { + return { + ok: false, + error: `Thread bindings are unavailable for ${channel}.`, + }; + } + + const currentThreadId = bindingContext.threadId ?? ""; + + if (threadMode === "here" && !currentThreadId) { + return { + ok: false, + error: `--thread here requires running /acp spawn inside an active ${channel} thread/conversation.`, + }; + } + + const threadId = currentThreadId || undefined; + const placement = threadId ? "current" : "child"; + if (!capabilities.placements.includes(placement)) { + return { + ok: false, + error: `Thread bindings do not support ${placement} placement for ${channel}.`, + }; + } + const channelId = placement === "child" ? bindingContext.conversationId : undefined; + + if (placement === "child" && !channelId) { + return { + ok: false, + error: `Could not resolve a ${channel} conversation for ACP thread spawn.`, + }; + } + + const senderId = commandParams.command.senderId?.trim() || ""; + if (threadId) { + const existingBinding = bindingService.resolveByConversation({ + channel: spawnPolicy.channel, + accountId: spawnPolicy.accountId, + conversationId: threadId, + }); + const boundBy = + typeof existingBinding?.metadata?.boundBy === "string" + ? existingBinding.metadata.boundBy.trim() + : ""; + if (existingBinding && boundBy && boundBy !== "system" && senderId && senderId !== boundBy) { + return { + ok: false, + error: `Only ${boundBy} can rebind this thread.`, + }; + } + } + + const label = params.label || params.agentId; + const conversationId = threadId || channelId; + if (!conversationId) { + return { + ok: false, + error: `Could not resolve a ${channel} conversation for ACP thread spawn.`, + }; + } + + try { + const binding = await bindingService.bind({ + targetSessionKey: params.sessionKey, + targetKind: "session", + conversation: { + channel: spawnPolicy.channel, + accountId: spawnPolicy.accountId, + conversationId, + }, + placement, + metadata: { + threadName: resolveThreadBindingThreadName({ + agentId: params.agentId, + label, + }), + agentId: params.agentId, + label, + boundBy: senderId || "unknown", + introText: resolveThreadBindingIntroText({ + agentId: params.agentId, + label, + sessionTtlMs: resolveThreadBindingSessionTtlMsForChannel({ + cfg: commandParams.cfg, + channel: spawnPolicy.channel, + accountId: spawnPolicy.accountId, + }), + sessionCwd: resolveAcpSessionCwd(params.sessionMeta), + sessionDetails: resolveAcpThreadSessionDetailLines({ + sessionKey: params.sessionKey, + meta: params.sessionMeta, + }), + }), + }, + }); + return { + ok: true, + binding, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + ok: false, + error: message || `Failed to bind a ${channel} thread/conversation to the new ACP session.`, + }; + } +} + +async function cleanupFailedSpawn(params: { + cfg: OpenClawConfig; + sessionKey: string; + shouldDeleteSession: boolean; + initializedRuntime?: AcpSpawnRuntimeCloseHandle; +}) { + await cleanupFailedAcpSpawn({ + cfg: params.cfg, + sessionKey: params.sessionKey, + shouldDeleteSession: params.shouldDeleteSession, + deleteTranscript: false, + runtimeCloseHandle: params.initializedRuntime, + }); +} + +export async function handleAcpSpawnAction( + params: HandleCommandsParams, + restTokens: string[], +): Promise { + if (!isAcpEnabledByPolicy(params.cfg)) { + return stopWithText("ACP is disabled by policy (`acp.enabled=false`)."); + } + + const parsed = parseSpawnInput(params, restTokens); + if (!parsed.ok) { + return stopWithText(`⚠️ ${parsed.error}`); + } + + const spawn = parsed.value; + const agentPolicyError = resolveAcpAgentPolicyError(params.cfg, spawn.agentId); + if (agentPolicyError) { + return stopWithText( + collectAcpErrorText({ + error: agentPolicyError, + fallbackCode: "ACP_SESSION_INIT_FAILED", + fallbackMessage: "ACP target agent is not allowed by policy.", + }), + ); + } + + const acpManager = getAcpSessionManager(); + const sessionKey = `agent:${spawn.agentId}:acp:${randomUUID()}`; + + let initializedBackend = ""; + let initializedMeta: SessionAcpMeta | undefined; + let initializedRuntime: AcpSpawnRuntimeCloseHandle | undefined; + try { + const initialized = await acpManager.initializeSession({ + cfg: params.cfg, + sessionKey, + agent: spawn.agentId, + mode: spawn.mode, + cwd: spawn.cwd, + }); + initializedRuntime = { + runtime: initialized.runtime, + handle: initialized.handle, + }; + initializedBackend = initialized.handle.backend || initialized.meta.backend; + initializedMeta = initialized.meta; + } catch (err) { + return stopWithText( + collectAcpErrorText({ + error: err, + fallbackCode: "ACP_SESSION_INIT_FAILED", + fallbackMessage: "Could not initialize ACP session runtime.", + }), + ); + } + + let binding: SessionBindingRecord | null = null; + if (spawn.thread !== "off") { + const bound = await bindSpawnedAcpSessionToThread({ + commandParams: params, + sessionKey, + agentId: spawn.agentId, + label: spawn.label, + threadMode: spawn.thread, + sessionMeta: initializedMeta, + }); + if (!bound.ok) { + await cleanupFailedSpawn({ + cfg: params.cfg, + sessionKey, + shouldDeleteSession: true, + initializedRuntime, + }); + return stopWithText(`⚠️ ${bound.error}`); + } + binding = bound.binding; + } + + try { + await callGateway({ + method: "sessions.patch", + params: { + key: sessionKey, + ...(spawn.label ? { label: spawn.label } : {}), + }, + timeoutMs: 10_000, + }); + } catch (err) { + await cleanupFailedSpawn({ + cfg: params.cfg, + sessionKey, + shouldDeleteSession: true, + initializedRuntime, + }); + const message = err instanceof Error ? err.message : String(err); + return stopWithText(`⚠️ ACP spawn failed: ${message}`); + } + + const parts = [ + `✅ Spawned ACP session ${sessionKey} (${spawn.mode}, backend ${initializedBackend}).`, + ]; + if (binding) { + const currentThreadId = resolveAcpCommandThreadId(params) ?? ""; + const boundConversationId = binding.conversation.conversationId.trim(); + if (currentThreadId && boundConversationId === currentThreadId) { + parts.push(`Bound this thread to ${sessionKey}.`); + } else { + parts.push(`Created thread ${boundConversationId} and bound it to ${sessionKey}.`); + } + } else { + parts.push("Session is unbound (use /focus to bind this thread/conversation)."); + } + + const dispatchNote = resolveAcpDispatchPolicyMessage(params.cfg); + if (dispatchNote) { + parts.push(`ℹ️ ${dispatchNote}`); + } + + return stopWithText(parts.join(" ")); +} + +export async function handleAcpCancelAction( + params: HandleCommandsParams, + restTokens: string[], +): Promise { + const acpManager = getAcpSessionManager(); + const token = restTokens.join(" ").trim() || undefined; + const target = await resolveAcpTargetSessionKey({ + commandParams: params, + token, + }); + if (!target.ok) { + return stopWithText(`⚠️ ${target.error}`); + } + + const resolved = acpManager.resolveSession({ + cfg: params.cfg, + sessionKey: target.sessionKey, + }); + if (resolved.kind === "none") { + return stopWithText( + collectAcpErrorText({ + error: new AcpRuntimeError( + "ACP_SESSION_INIT_FAILED", + `Session is not ACP-enabled: ${target.sessionKey}`, + ), + fallbackCode: "ACP_SESSION_INIT_FAILED", + fallbackMessage: "Session is not ACP-enabled.", + }), + ); + } + if (resolved.kind === "stale") { + return stopWithText( + collectAcpErrorText({ + error: resolved.error, + fallbackCode: "ACP_SESSION_INIT_FAILED", + fallbackMessage: resolved.error.message, + }), + ); + } + + return await withAcpCommandErrorBoundary({ + run: async () => + await acpManager.cancelSession({ + cfg: params.cfg, + sessionKey: target.sessionKey, + reason: "manual-cancel", + }), + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "ACP cancel failed before completion.", + onSuccess: () => stopWithText(`✅ Cancel requested for ACP session ${target.sessionKey}.`), + }); +} + +async function runAcpSteer(params: { + cfg: OpenClawConfig; + sessionKey: string; + instruction: string; + requestId: string; +}): Promise { + const acpManager = getAcpSessionManager(); + let output = ""; + + await acpManager.runTurn({ + cfg: params.cfg, + sessionKey: params.sessionKey, + text: params.instruction, + mode: "steer", + requestId: params.requestId, + onEvent: (event) => { + if (event.type !== "text_delta") { + return; + } + if (event.stream && event.stream !== "output") { + return; + } + if (event.text) { + output += event.text; + if (output.length > ACP_STEER_OUTPUT_LIMIT) { + output = `${output.slice(0, ACP_STEER_OUTPUT_LIMIT)}…`; + } + } + }, + }); + return output.trim(); +} + +export async function handleAcpSteerAction( + params: HandleCommandsParams, + restTokens: string[], +): Promise { + const dispatchPolicyError = resolveAcpDispatchPolicyError(params.cfg); + if (dispatchPolicyError) { + return stopWithText( + collectAcpErrorText({ + error: dispatchPolicyError, + fallbackCode: "ACP_DISPATCH_DISABLED", + fallbackMessage: dispatchPolicyError.message, + }), + ); + } + + const parsed = parseSteerInput(restTokens); + if (!parsed.ok) { + return stopWithText(`⚠️ ${parsed.error}`); + } + const acpManager = getAcpSessionManager(); + + const target = await resolveAcpTargetSessionKey({ + commandParams: params, + token: parsed.value.sessionToken, + }); + if (!target.ok) { + return stopWithText(`⚠️ ${target.error}`); + } + + const resolved = acpManager.resolveSession({ + cfg: params.cfg, + sessionKey: target.sessionKey, + }); + if (resolved.kind === "none") { + return stopWithText( + collectAcpErrorText({ + error: new AcpRuntimeError( + "ACP_SESSION_INIT_FAILED", + `Session is not ACP-enabled: ${target.sessionKey}`, + ), + fallbackCode: "ACP_SESSION_INIT_FAILED", + fallbackMessage: "Session is not ACP-enabled.", + }), + ); + } + if (resolved.kind === "stale") { + return stopWithText( + collectAcpErrorText({ + error: resolved.error, + fallbackCode: "ACP_SESSION_INIT_FAILED", + fallbackMessage: resolved.error.message, + }), + ); + } + + return await withAcpCommandErrorBoundary({ + run: async () => + await runAcpSteer({ + cfg: params.cfg, + sessionKey: target.sessionKey, + instruction: parsed.value.instruction, + requestId: `${resolveCommandRequestId(params)}:steer`, + }), + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "ACP steer failed before completion.", + onSuccess: (steerOutput) => { + if (!steerOutput) { + return stopWithText(`✅ ACP steer sent to ${target.sessionKey}.`); + } + return stopWithText(`✅ ACP steer sent to ${target.sessionKey}.\n${steerOutput}`); + }, + }); +} + +export async function handleAcpCloseAction( + params: HandleCommandsParams, + restTokens: string[], +): Promise { + const acpManager = getAcpSessionManager(); + const token = restTokens.join(" ").trim() || undefined; + const target = await resolveAcpTargetSessionKey({ + commandParams: params, + token, + }); + if (!target.ok) { + return stopWithText(`⚠️ ${target.error}`); + } + + const resolved = acpManager.resolveSession({ + cfg: params.cfg, + sessionKey: target.sessionKey, + }); + if (resolved.kind === "none") { + return stopWithText( + collectAcpErrorText({ + error: new AcpRuntimeError( + "ACP_SESSION_INIT_FAILED", + `Session is not ACP-enabled: ${target.sessionKey}`, + ), + fallbackCode: "ACP_SESSION_INIT_FAILED", + fallbackMessage: "Session is not ACP-enabled.", + }), + ); + } + if (resolved.kind === "stale") { + return stopWithText( + collectAcpErrorText({ + error: resolved.error, + fallbackCode: "ACP_SESSION_INIT_FAILED", + fallbackMessage: resolved.error.message, + }), + ); + } + + let runtimeNotice = ""; + try { + const closed = await acpManager.closeSession({ + cfg: params.cfg, + sessionKey: target.sessionKey, + reason: "manual-close", + allowBackendUnavailable: true, + clearMeta: true, + }); + runtimeNotice = closed.runtimeNotice ? ` (${closed.runtimeNotice})` : ""; + } catch (error) { + return stopWithText( + collectAcpErrorText({ + error, + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "ACP close failed before completion.", + }), + ); + } + + const removedBindings = await getSessionBindingService().unbind({ + targetSessionKey: target.sessionKey, + reason: "manual", + }); + + return stopWithText( + `✅ Closed ACP session ${target.sessionKey}${runtimeNotice}. Removed ${removedBindings.length} binding${removedBindings.length === 1 ? "" : "s"}.`, + ); +} diff --git a/src/auto-reply/reply/commands-acp/runtime-options.ts b/src/auto-reply/reply/commands-acp/runtime-options.ts new file mode 100644 index 000000000000..359b712e0e33 --- /dev/null +++ b/src/auto-reply/reply/commands-acp/runtime-options.ts @@ -0,0 +1,348 @@ +import { getAcpSessionManager } from "../../../acp/control-plane/manager.js"; +import { + parseRuntimeTimeoutSecondsInput, + validateRuntimeConfigOptionInput, + validateRuntimeCwdInput, + validateRuntimeModeInput, + validateRuntimeModelInput, + validateRuntimePermissionProfileInput, +} from "../../../acp/control-plane/runtime-options.js"; +import { resolveAcpSessionIdentifierLinesFromIdentity } from "../../../acp/runtime/session-identifiers.js"; +import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js"; +import { + ACP_CWD_USAGE, + ACP_MODEL_USAGE, + ACP_PERMISSIONS_USAGE, + ACP_RESET_OPTIONS_USAGE, + ACP_SET_MODE_USAGE, + ACP_STATUS_USAGE, + ACP_TIMEOUT_USAGE, + formatAcpCapabilitiesText, + formatRuntimeOptionsText, + parseOptionalSingleTarget, + parseSetCommandInput, + parseSingleValueCommandInput, + stopWithText, + withAcpCommandErrorBoundary, +} from "./shared.js"; +import { resolveAcpTargetSessionKey } from "./targets.js"; + +export async function handleAcpStatusAction( + params: HandleCommandsParams, + restTokens: string[], +): Promise { + const parsed = parseOptionalSingleTarget(restTokens, ACP_STATUS_USAGE); + if (!parsed.ok) { + return stopWithText(`⚠️ ${parsed.error}`); + } + const target = await resolveAcpTargetSessionKey({ + commandParams: params, + token: parsed.sessionToken, + }); + if (!target.ok) { + return stopWithText(`⚠️ ${target.error}`); + } + + return await withAcpCommandErrorBoundary({ + run: async () => + await getAcpSessionManager().getSessionStatus({ + cfg: params.cfg, + sessionKey: target.sessionKey, + }), + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "Could not read ACP session status.", + onSuccess: (status) => { + const sessionIdentifierLines = resolveAcpSessionIdentifierLinesFromIdentity({ + backend: status.backend, + identity: status.identity, + }); + const lines = [ + "ACP status:", + "-----", + `session: ${status.sessionKey}`, + `backend: ${status.backend}`, + `agent: ${status.agent}`, + ...sessionIdentifierLines, + `sessionMode: ${status.mode}`, + `state: ${status.state}`, + `runtimeOptions: ${formatRuntimeOptionsText(status.runtimeOptions)}`, + `capabilities: ${formatAcpCapabilitiesText(status.capabilities.controls)}`, + `lastActivityAt: ${new Date(status.lastActivityAt).toISOString()}`, + ...(status.lastError ? [`lastError: ${status.lastError}`] : []), + ...(status.runtimeStatus?.summary ? [`runtime: ${status.runtimeStatus.summary}`] : []), + ...(status.runtimeStatus?.details + ? [`runtimeDetails: ${JSON.stringify(status.runtimeStatus.details)}`] + : []), + ]; + return stopWithText(lines.join("\n")); + }, + }); +} + +export async function handleAcpSetModeAction( + params: HandleCommandsParams, + restTokens: string[], +): Promise { + const parsed = parseSingleValueCommandInput(restTokens, ACP_SET_MODE_USAGE); + if (!parsed.ok) { + return stopWithText(`⚠️ ${parsed.error}`); + } + const target = await resolveAcpTargetSessionKey({ + commandParams: params, + token: parsed.value.sessionToken, + }); + if (!target.ok) { + return stopWithText(`⚠️ ${target.error}`); + } + + return await withAcpCommandErrorBoundary({ + run: async () => { + const runtimeMode = validateRuntimeModeInput(parsed.value.value); + const options = await getAcpSessionManager().setSessionRuntimeMode({ + cfg: params.cfg, + sessionKey: target.sessionKey, + runtimeMode, + }); + return { + runtimeMode, + options, + }; + }, + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "Could not update ACP runtime mode.", + onSuccess: ({ runtimeMode, options }) => + stopWithText( + `✅ Updated ACP runtime mode for ${target.sessionKey}: ${runtimeMode}. Effective options: ${formatRuntimeOptionsText(options)}`, + ), + }); +} + +export async function handleAcpSetAction( + params: HandleCommandsParams, + restTokens: string[], +): Promise { + const parsed = parseSetCommandInput(restTokens); + if (!parsed.ok) { + return stopWithText(`⚠️ ${parsed.error}`); + } + const target = await resolveAcpTargetSessionKey({ + commandParams: params, + token: parsed.value.sessionToken, + }); + if (!target.ok) { + return stopWithText(`⚠️ ${target.error}`); + } + const key = parsed.value.key.trim(); + const value = parsed.value.value.trim(); + + return await withAcpCommandErrorBoundary({ + run: async () => { + const lowerKey = key.toLowerCase(); + if (lowerKey === "cwd") { + const cwd = validateRuntimeCwdInput(value); + const options = await getAcpSessionManager().updateSessionRuntimeOptions({ + cfg: params.cfg, + sessionKey: target.sessionKey, + patch: { cwd }, + }); + return { + text: `✅ Updated ACP cwd for ${target.sessionKey}: ${cwd}. Effective options: ${formatRuntimeOptionsText(options)}`, + }; + } + const validated = validateRuntimeConfigOptionInput(key, value); + const options = await getAcpSessionManager().setSessionConfigOption({ + cfg: params.cfg, + sessionKey: target.sessionKey, + key: validated.key, + value: validated.value, + }); + return { + text: `✅ Updated ACP config option for ${target.sessionKey}: ${validated.key}=${validated.value}. Effective options: ${formatRuntimeOptionsText(options)}`, + }; + }, + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "Could not update ACP config option.", + onSuccess: ({ text }) => stopWithText(text), + }); +} + +export async function handleAcpCwdAction( + params: HandleCommandsParams, + restTokens: string[], +): Promise { + const parsed = parseSingleValueCommandInput(restTokens, ACP_CWD_USAGE); + if (!parsed.ok) { + return stopWithText(`⚠️ ${parsed.error}`); + } + const target = await resolveAcpTargetSessionKey({ + commandParams: params, + token: parsed.value.sessionToken, + }); + if (!target.ok) { + return stopWithText(`⚠️ ${target.error}`); + } + + return await withAcpCommandErrorBoundary({ + run: async () => { + const cwd = validateRuntimeCwdInput(parsed.value.value); + const options = await getAcpSessionManager().updateSessionRuntimeOptions({ + cfg: params.cfg, + sessionKey: target.sessionKey, + patch: { cwd }, + }); + return { + cwd, + options, + }; + }, + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "Could not update ACP cwd.", + onSuccess: ({ cwd, options }) => + stopWithText( + `✅ Updated ACP cwd for ${target.sessionKey}: ${cwd}. Effective options: ${formatRuntimeOptionsText(options)}`, + ), + }); +} + +export async function handleAcpPermissionsAction( + params: HandleCommandsParams, + restTokens: string[], +): Promise { + const parsed = parseSingleValueCommandInput(restTokens, ACP_PERMISSIONS_USAGE); + if (!parsed.ok) { + return stopWithText(`⚠️ ${parsed.error}`); + } + const target = await resolveAcpTargetSessionKey({ + commandParams: params, + token: parsed.value.sessionToken, + }); + if (!target.ok) { + return stopWithText(`⚠️ ${target.error}`); + } + return await withAcpCommandErrorBoundary({ + run: async () => { + const permissionProfile = validateRuntimePermissionProfileInput(parsed.value.value); + const options = await getAcpSessionManager().setSessionConfigOption({ + cfg: params.cfg, + sessionKey: target.sessionKey, + key: "approval_policy", + value: permissionProfile, + }); + return { + permissionProfile, + options, + }; + }, + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "Could not update ACP permissions profile.", + onSuccess: ({ permissionProfile, options }) => + stopWithText( + `✅ Updated ACP permissions profile for ${target.sessionKey}: ${permissionProfile}. Effective options: ${formatRuntimeOptionsText(options)}`, + ), + }); +} + +export async function handleAcpTimeoutAction( + params: HandleCommandsParams, + restTokens: string[], +): Promise { + const parsed = parseSingleValueCommandInput(restTokens, ACP_TIMEOUT_USAGE); + if (!parsed.ok) { + return stopWithText(`⚠️ ${parsed.error}`); + } + const target = await resolveAcpTargetSessionKey({ + commandParams: params, + token: parsed.value.sessionToken, + }); + if (!target.ok) { + return stopWithText(`⚠️ ${target.error}`); + } + + return await withAcpCommandErrorBoundary({ + run: async () => { + const timeoutSeconds = parseRuntimeTimeoutSecondsInput(parsed.value.value); + const options = await getAcpSessionManager().setSessionConfigOption({ + cfg: params.cfg, + sessionKey: target.sessionKey, + key: "timeout", + value: String(timeoutSeconds), + }); + return { + timeoutSeconds, + options, + }; + }, + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "Could not update ACP timeout.", + onSuccess: ({ timeoutSeconds, options }) => + stopWithText( + `✅ Updated ACP timeout for ${target.sessionKey}: ${timeoutSeconds}s. Effective options: ${formatRuntimeOptionsText(options)}`, + ), + }); +} + +export async function handleAcpModelAction( + params: HandleCommandsParams, + restTokens: string[], +): Promise { + const parsed = parseSingleValueCommandInput(restTokens, ACP_MODEL_USAGE); + if (!parsed.ok) { + return stopWithText(`⚠️ ${parsed.error}`); + } + const target = await resolveAcpTargetSessionKey({ + commandParams: params, + token: parsed.value.sessionToken, + }); + if (!target.ok) { + return stopWithText(`⚠️ ${target.error}`); + } + return await withAcpCommandErrorBoundary({ + run: async () => { + const model = validateRuntimeModelInput(parsed.value.value); + const options = await getAcpSessionManager().setSessionConfigOption({ + cfg: params.cfg, + sessionKey: target.sessionKey, + key: "model", + value: model, + }); + return { + model, + options, + }; + }, + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "Could not update ACP model.", + onSuccess: ({ model, options }) => + stopWithText( + `✅ Updated ACP model for ${target.sessionKey}: ${model}. Effective options: ${formatRuntimeOptionsText(options)}`, + ), + }); +} + +export async function handleAcpResetOptionsAction( + params: HandleCommandsParams, + restTokens: string[], +): Promise { + const parsed = parseOptionalSingleTarget(restTokens, ACP_RESET_OPTIONS_USAGE); + if (!parsed.ok) { + return stopWithText(`⚠️ ${parsed.error}`); + } + const target = await resolveAcpTargetSessionKey({ + commandParams: params, + token: parsed.sessionToken, + }); + if (!target.ok) { + return stopWithText(`⚠️ ${target.error}`); + } + + return await withAcpCommandErrorBoundary({ + run: async () => + await getAcpSessionManager().resetSessionRuntimeOptions({ + cfg: params.cfg, + sessionKey: target.sessionKey, + }), + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "Could not reset ACP runtime options.", + onSuccess: () => stopWithText(`✅ Reset ACP runtime options for ${target.sessionKey}.`), + }); +} diff --git a/src/auto-reply/reply/commands-acp/shared.ts b/src/auto-reply/reply/commands-acp/shared.ts new file mode 100644 index 000000000000..adf31247b6da --- /dev/null +++ b/src/auto-reply/reply/commands-acp/shared.ts @@ -0,0 +1,500 @@ +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { toAcpRuntimeErrorText } from "../../../acp/runtime/error-text.js"; +import type { AcpRuntimeError } from "../../../acp/runtime/errors.js"; +import type { AcpRuntimeSessionMode } from "../../../acp/runtime/types.js"; +import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js"; +import type { OpenClawConfig } from "../../../config/config.js"; +import type { AcpSessionRuntimeOptions } from "../../../config/sessions/types.js"; +import { normalizeAgentId } from "../../../routing/session-key.js"; +import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js"; +import { resolveAcpCommandChannel, resolveAcpCommandThreadId } from "./context.js"; + +export const COMMAND = "/acp"; +export const ACP_SPAWN_USAGE = + "Usage: /acp spawn [agentId] [--mode persistent|oneshot] [--thread auto|here|off] [--cwd ] [--label