diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 306fc1ccf5b5..66afce1889e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -251,7 +251,6 @@ jobs: ], }; }); - const createMatrix = (include) => ({ include }); const outputPath = process.env.GITHUB_OUTPUT; const isCanonicalRepository = process.env.OPENCLAW_CI_REPOSITORY === "openclaw/openclaw"; @@ -285,6 +284,7 @@ jobs: if (runNodeFull) { checksFastCoreTasks.push( { check_name: "checks-fast-bundled-protocol", runtime: "node", task: "bundled-protocol" }, + { check_name: "QA Smoke CI", runtime: "node", task: "qa-smoke-ci" }, { check_name: "checks-fast-bun-launcher", runtime: "bun", task: "bun-launcher" }, ); } else { @@ -922,6 +922,26 @@ jobs: pnpm test:bundled pnpm protocol:check ;; + qa-smoke-ci) + output_dir=".artifacts/qa-e2e/smoke-ci-profile" + export OPENCLAW_BUILD_PRIVATE_QA=1 + export OPENCLAW_ENABLE_PRIVATE_QA_CLI=1 + export OPENCLAW_DISABLE_BUNDLED_PLUGINS=0 + export OPENCLAW_QA_REDACT_PUBLIC_METADATA=1 + export OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS=180000 + NODE_OPTIONS=--max-old-space-size=8192 node scripts/build-all.mjs qaRuntime + qa_exit_code=0 + pnpm openclaw qa run \ + --repo-root . \ + --qa-profile smoke-ci \ + --concurrency 8 \ + --output-dir "$output_dir" || qa_exit_code=$? + echo "QA smoke profile evidence: \`${output_dir}\`" >> "$GITHUB_STEP_SUMMARY" + if [ "$qa_exit_code" -ne 0 ]; then + echo "::error title=QA smoke profile failed::smoke-ci exited ${qa_exit_code}; evidence upload will still run" + exit "$qa_exit_code" + fi + ;; contracts-plugins-ci-routing) pnpm test:contracts:plugins pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/ci-workflow-guards.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts @@ -938,6 +958,15 @@ jobs: ;; esac + - name: Upload QA smoke profile evidence + if: always() && matrix.task == 'qa-smoke-ci' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: qa-smoke-profile-${{ github.run_id }}-${{ github.run_attempt }} + path: .artifacts/qa-e2e/smoke-ci-profile/ + if-no-files-found: warn + retention-days: 7 + checks-fast-plugin-contracts-shard: permissions: contents: read @@ -2390,7 +2419,8 @@ jobs: - macos-swift - ios-build - android - if: ${{ !cancelled() && always() && github.event_name != 'push' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }} + # Re-enable this job when we want to collect CI timing data for timing optimization. + if: ${{ false && !cancelled() && always() && github.event_name != 'push' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }} runs-on: ubuntu-24.04 timeout-minutes: 5 steps: diff --git a/.github/workflows/maturity-scorecard.yml b/.github/workflows/maturity-scorecard.yml index 7371f6464b27..6a10a29b0eb4 100644 --- a/.github/workflows/maturity-scorecard.yml +++ b/.github/workflows/maturity-scorecard.yml @@ -134,7 +134,7 @@ jobs: with: ref: ${{ inputs.ref }} expected_sha: ${{ needs.validate_selected_ref.outputs.selected_revision }} - qa_profile: release + qa_profile: all secrets: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} @@ -238,8 +238,8 @@ jobs: } const evidence = JSON.parse(fs.readFileSync(evidencePath, "utf8")); - if (evidence.profile !== "release") { - throw new Error(`qa-evidence.json profile must be release, got ${JSON.stringify(evidence.profile)}`); + if (evidence.profile !== "all") { + throw new Error(`qa-evidence.json profile must be all, got ${JSON.stringify(evidence.profile)}`); } const artifactDir = path.dirname(evidencePath); @@ -256,8 +256,8 @@ jobs: const manifestPath = path.join(artifactDir, manifestNames[0]); const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); const manifestProfile = manifest.qaProfile ?? evidence.profile; - if (manifestProfile !== "release") { - throw new Error(`QA evidence manifest profile must be release, got ${JSON.stringify(manifestProfile)}`); + if (manifestProfile !== "all") { + throw new Error(`QA evidence manifest profile must be all, got ${JSON.stringify(manifestProfile)}`); } if (manifest.targetSha !== targetSha) { throw new Error(`QA evidence manifest targetSha ${manifest.targetSha} does not match selected ref ${targetSha}`); @@ -428,14 +428,14 @@ jobs: cat > "$body_file" < [vitest args...]`, `pnpm test:changed`, `pnpm test:serial`, `pnpm test:coverage`; never raw `vitest`. - If raw Vitest is unavoidable, use `vitest run ...`; bare `vitest ...` starts local watch mode and will not exit on its own. - Tests in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm test*`; use `node scripts/run-vitest.mjs ` for tiny explicit-file proof, or Crabbox/Testbox for anything broader. -- Checks in a normal source checkout: `pnpm check:changed` delegates to Crabbox/Testbox; lanes: `pnpm changed:lanes --json`; staged: `pnpm check:changed --staged`; full: `pnpm check`. +- Checks/lint in a normal source checkout: `pnpm check:changed` delegates to Crabbox/Testbox; lanes: `pnpm changed:lanes --json`; staged/path-scoped: `pnpm check:changed --staged` or `pnpm check:changed -- `; full `pnpm check`/`pnpm lint` only when required. - Checks in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm check*`; use `node scripts/crabbox-wrapper.mjs run ... -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed` so pnpm runs inside Testbox, not locally. - Extension tests: `pnpm test:extensions`, `pnpm test extensions`, `pnpm test extensions/`. - Typecheck: `tsgo` lanes only (`pnpm tsgo*`, `pnpm check:test-types`); never add `tsc --noEmit`, `typecheck`, `check:types`. -- Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `pnpm lint:*`, `scripts/run-oxlint.mjs`). +- Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `scripts/run-oxlint.mjs`; full `pnpm lint:*` only when scope requires). - Build before push when build output, packaging, lazy/module boundaries, dynamic imports, or published surfaces can change. ## Validation diff --git a/CHANGELOG.md b/CHANGELOG.md index 60502de9ea80..52ce2ce73f54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,44 +2,100 @@ Docs: https://docs.openclaw.ai -## 2026.6.10 - -### Highlights - -- **Automatic fast mode for talks:** OpenClaw can enable fast mode for short conversational turns, then return to normal mode for longer runs with bounded fallback and delivery behavior. (#85104) Thanks @alexph-dev and @vincentkoc. -- **More reliable model routing:** Zai model synthesis, GLM overload failover, and native reasoning-level selection now follow the active model catalog more consistently. (#94461, #93241, #94067, #94136) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @openperf, @civiltox, and @BorClaw. -- **Safer session and channel state:** channel switches reset stale origin fields, and cron delivery awareness stays attached to the target session. (#95328, #93580) Thanks @ZengWen-DT, @jalehman, @gorkem2020, and @scotthuang. -- **Trusted policies survive hook composition:** composed hook registries keep the trusted tool policies required by approval-sensitive flows. (#94545) Thanks @jesse-merhi. - -### Changes - -- **Agent and channel runtime:** fast-mode state now survives retries, fallback transitions, progress events, and embedded/CLI/ACP normalization; session and channel routing retain the current target and delivery context. (#85104, #93580, #95328) Thanks @alexph-dev, @vincentkoc, @scotthuang, @ZengWen-DT, @jalehman, and @gorkem2020. -- **Provider behavior:** model catalogs now supply the correct Zai base URL, overload classification, and native reasoning controls for live-discovered models. (#94461, #93241, #94067, #94136) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @openperf, @civiltox, and @BorClaw. +## Unreleased ### Fixes -- **Fast-mode and policy correctness:** fallback cutoffs and reset notices are bounded, repeated progress events remain visible, Codex service-tier state is normalized, and trusted policies are not lost when hook registries are composed. (#85104, #94545) Thanks @alexph-dev, @vincentkoc, and @jesse-merhi. -- **Model and delivery edge cases:** Zai and GLM failover paths use the right runtime metadata, while stale channel-origin state no longer leaks across session changes. (#94461, #93241, #95328) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @ZengWen-DT, @jalehman, and @gorkem2020. -- **Provider plugin onboarding:** setup refreshes provider plugin registry metadata after installing setup-selected provider plugins, so auth continuation uses the newly installed provider instead of stale registry state. (#95792) Thanks @snowzlmbot. +- **WeChat account routing:** `startAccount` preserves session routing by resolving manifest channel account config from raw account keys with opaque provider ids, while still ignoring manifest account keys that normalize to blocked object keys. (#93686) Thanks @zhangguiping-xydt. -### Complete contribution record +## 2026.6.10 -This audited record covers the complete v2026.6.9..HEAD history: 12 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact. +Automatic fast mode starts short conversations quickly, then returns longer or fallback work to normal mode without losing visible state. Provider routing, channel progress, session identity, and trusted tool policies are more reliable, with smaller improvements spanning provider setup, diagnostics, and transcript tooling. -#### Pull requests +### Highlights -- **PR #86627** Keep core doctor health in contribution order. Thanks @giodl73-repo. -- **PR #93580** fix: preserve cron delivery awareness for target sessions. Thanks @scotthuang and @jalehman. -- **PR #95030** refactor: add SDK transcript identity target API. Thanks @jalehman. -- **PR #94838** refactor(copilot): complete harness lifecycle parity. Thanks @vincentkoc. -- **PR #95328** fix(sessions): reset stale per-channel origin fields on channel switch. Related #95325. Thanks @ZengWen-DT and @jalehman and @gorkem2020. -- **PR #94461** fix(zai): fall back to manifest baseUrl for synthesized GLM-5 models. Related #94269. Thanks @Pandah97 and @chrysb. -- **PR #93241** fix(agents): classify Zhipu GLM overload as overloaded for failover. Related #93211. Thanks @0xghost42 and @zhengli0922. -- **PR #94067** fix(channels): resolve native /think menu levels via runtime catalog for live-discovered models. Related #93835. Thanks @openperf and @civiltox. -- **PR #94136** fix(zai): expose GLM-5.2 reasoning levels [AI-assisted]. Thanks @BorClaw. -- **PR #85104** feat: fast talks auto mode. Related #85087. Thanks @alexph-dev. -- **PR #94545** fix: keep trusted policies with hook registry. Thanks @jesse-merhi. -- **PR #95792** fix(onboard): refresh provider plugin registry after setup installs. Related #95765. Thanks @snowzlmbot. +#### Automatic fast mode + +- Adds [`/fast auto`](https://docs.openclaw.ai/tools/thinking) so short conversational calls can start quickly, while longer or fallback work returns to normal mode with the effective state still visible. [PR #85104](https://github.com/openclaw/openclaw/pull/85104), [Issue #85087](https://github.com/openclaw/openclaw/issues/85087). Thanks @alexph-dev and @vincentkoc. +- Shows the effective automatic fast-mode state in status instead of reducing it to on/off, and avoids carrying a cleared Codex service-tier choice into later runs. [8845f2f](https://github.com/openclaw/openclaw/commit/8845f2fd6143becc37110ab5021dd5e1517f0cdc). Thanks @vincentkoc. +- Keeps automatic fast-mode timing consistent when a turn switches to a fallback model. [075091d](https://github.com/openclaw/openclaw/commit/075091d0cab94053ff094268efc0acb225d514f4). Thanks @vincentkoc. +- Keeps the original fast-mode timing and progress behavior when a live model switch retries a turn. [d1e190f](https://github.com/openclaw/openclaw/commit/d1e190fbe822ad6ae4e660ce376b60ec9fdb0fba). Thanks @vincentkoc. +- Keeps automatic fast-mode progress and reset behavior distinct from explicit fast mode after a run switches modes. [20aec98](https://github.com/openclaw/openclaw/commit/20aec985545db7a24ea066e5bff1c47b789cbded). Thanks @vincentkoc. +- Shows the effective fast-mode value in connected-agent sessions instead of the configured value, so status reflects what the session is actually using. [9509aa0](https://github.com/openclaw/openclaw/commit/9509aa063c0ef3e32be1516fcb0c23606b6d5c7b). Thanks @vincentkoc. +- Keeps the effective automatic fast-mode setting visible through fallback transitions in connected-agent sessions. [7f5423c](https://github.com/openclaw/openclaw/commit/7f5423ca97174a3f16c211db54a6c96e5b3a6089). Thanks @vincentkoc. +- Keeps automatic fast-mode timing and progress consistent when reply and [scheduled-agent runs](https://docs.openclaw.ai/automation/cron-jobs) retry or switch models. [6c29f88](https://github.com/openclaw/openclaw/commit/6c29f88913796bfe05696556cd82246670b126f0). Thanks @vincentkoc. +- Keeps fast-mode cleanup and status consistent when a run switches between fallback models. [c4694f8](https://github.com/openclaw/openclaw/commit/c4694f84ffd52064f89609098cc4f8570fb72e1b). Thanks @vincentkoc. +- Shows the automatic fast-mode reset only when fallback work is finished, so status messages match the end of the transition. [f4d93c8](https://github.com/openclaw/openclaw/commit/f4d93c855bff6930f5e5d739b95e0c2612ec4899). Thanks @vincentkoc. +- Shows reset and delivery progress at the right time when auto-reply or other follow-up runs retry or leave automatic fast mode. [684e440](https://github.com/openclaw/openclaw/commit/684e44013778bd47d159e64b2595e4d09a92ebea). Thanks @vincentkoc. + +### Channels and Messaging + +#### Channel delivery and progress updates + +- Prevents the next turn after a [scheduled message](https://docs.openclaw.ai/automation/cron-jobs) from losing what was delivered or whether delivery failed, so replies can use that context without exposing cron details in the channel. [PR #93580](https://github.com/openclaw/openclaw/pull/93580). Thanks @jalehman and @scotthuang. +- Prevents streamed channel progress from dropping a repeated status that represents a separate step, so each meaningful step remains visible in the draft. [2d42e52](https://github.com/openclaw/openclaw/commit/2d42e52ac5513e0bd824b8a0e069db83e04bc056). Thanks @vincentkoc. +- Prevents keyed streamed progress from staying on an older status, so viewers see the latest state instead of stale text. [8bb6472](https://github.com/openclaw/openclaw/commit/8bb6472c4de2eea06f1ba31d6ed679e2ac4581b0). Thanks @vincentkoc. + +### Providers and Models + +#### Provider model catalogs and reasoning controls + +- Treats Zhipu/GLM overload responses as overloads, so a configured fallback is selected for the right reason instead of following the wrong failover path. [PR #93241](https://github.com/openclaw/openclaw/pull/93241), [Issue #93211](https://github.com/openclaw/openclaw/issues/93211). Thanks @0xghost42 and @zhengli0922. +- Prevents Telegram, Slack, and Discord `/think` menus for live Ollama models from hiding supported levels, so users can choose valid reasoning settings without guessing. [PR #94067](https://github.com/openclaw/openclaw/pull/94067), [Issue #93835](https://github.com/openclaw/openclaw/issues/93835). Thanks @civiltox and @openperf. +- Expands [`zai/glm-5.2` thinking choices](https://docs.openclaw.ai/tools/thinking) beyond binary on/off and sends high or max requests as the intended Z.AI reasoning effort. [PR #94136](https://github.com/openclaw/openclaw/pull/94136). Thanks @borclaw. +- Prevents bundled [Z.ai GLM-5 models](https://docs.openclaw.ai/providers/zai) from falling through to OpenAI and producing misleading API-key errors, so they use Z.AI by default. [PR #94461](https://github.com/openclaw/openclaw/pull/94461), [Issue #94269](https://github.com/openclaw/openclaw/issues/94269). Thanks @chrysb and @pandah97. +- Adds GLM-5.2 and Kimi K2.7 Code to the [OpenCode Go catalog](https://docs.openclaw.ai/providers/opencode-go) with current limits, so users can select the models from OpenClaw. [66f84a9](https://github.com/openclaw/openclaw/commit/66f84a9bf1082de26f92b2b3741cc2f34aba34fa). Thanks @samson1357924. +- Corrects `kimi-k2.7-code` capability listings so OpenCode Go users are not offered unsupported video prompts when the model accepts text and images. [715dc71](https://github.com/openclaw/openclaw/commit/715dc718fc5a2a5d6f7e9ec16e0269382b726e83). + +#### Provider plugin onboarding + +- Prevents first-run setup from skipping the selected provider's credential prompt after plugin installation, so onboarding continues with that provider instead of falling back to OpenAI. [PR #95792](https://github.com/openclaw/openclaw/pull/95792), [Issue #95765](https://github.com/openclaw/openclaw/issues/95765). Thanks @snowzlmbot. + +### Memory, Sessions, and State + +#### Session transcript SDK helpers + +- Adds a durable [session-transcript SDK contract](https://docs.openclaw.ai/plugins/sdk-runtime) so plugins can read, append, publish, and lock the intended transcript without treating [legacy file paths](https://docs.openclaw.ai/plugins/sdk-subpaths) as identity. [PR #95030](https://github.com/openclaw/openclaw/pull/95030). Thanks @jalehman. + +#### Cross-channel session identity + +- Prevents a shared direct-message [session](https://docs.openclaw.ai/concepts/session) from carrying the previous [channel's identity](https://docs.openclaw.ai/channels/channel-routing) after a switch, so status, reactions, threads, and message references target the current channel. [PR #95328](https://github.com/openclaw/openclaw/pull/95328), [Issue #95325](https://github.com/openclaw/openclaw/issues/95325). Thanks @gorkem2020, @jalehman, and @zengwen-dt. + +### Gateway, Security, and Trust + +#### Prompt context boundaries + +- Keeps empty prompts separate from hook-added context during compaction or session reuse in [Copilot and Codex sessions](https://docs.openclaw.ai/plugins/copilot), so prompt boundaries remain consistent. [PR #94838](https://github.com/openclaw/openclaw/pull/94838). Thanks @vincentkoc. + +#### Trusted tool policy enforcement + +- Keeps [approval-sensitive Gateway and plugin tools](https://docs.openclaw.ai/plugins/hooks) protected when connected extensions change, so configured safeguards continue to apply. [PR #94545](https://github.com/openclaw/openclaw/pull/94545). Thanks @jesse-merhi. + +#### Trusted package redirects + +- Prevents authenticated package-source tokens from being sent to an allowed redirect on another origin, while the valid redirected download still completes. [b0df6dc](https://github.com/openclaw/openclaw/commit/b0df6dc10eb5b9e9fdca93063a16316f8589954e). + +### Clients and Interfaces + +#### Docker and Podman setup timeouts + +- Prevents [Docker](https://docs.openclaw.ai/install/docker) and [Podman](https://docs.openclaw.ai/install/podman) setup from running unbounded on hosts where GNU timeout is installed as `gtimeout`, so image pulls, builds, and detached startup receive the intended guard. [62b2e9e](https://github.com/openclaw/openclaw/commit/62b2e9ef14b4be6fd396621c8e5e248331f08695). + +### Plugins, Packaging, and QA + +#### Codex service-tier clearing + +- Prevents cleared [Codex service tiers](https://docs.openclaw.ai/tools/thinking) from being persisted as explicit stale state, so resumed or switched conversations use the normal default instead. [cd32d9f](https://github.com/openclaw/openclaw/commit/cd32d9ff91caf84c0ead38796ef096cdc5bea06e). Thanks @vincentkoc. + +#### StepFun provider installation + +- Restores [ClawHub discovery](https://docs.openclaw.ai/plugins/reference/stepfun) for the [StepFun provider](https://docs.openclaw.ai/providers/stepfun) plugin, so operators can install it through either ClawHub or npm. [ecb82f1](https://github.com/openclaw/openclaw/commit/ecb82f1be93024be23c1b191ebea92c63230b6c0). Thanks @vincentkoc. + +### Docs and Operator Workflows + +#### Doctor check ordering + +- Keeps core [`openclaw doctor`](https://docs.openclaw.ai/gateway/doctor) diagnostics in their normal order before extension checks, making lint and repair output easier to follow. [PR #86627](https://github.com/openclaw/openclaw/pull/86627). Thanks @giodl73-repo. ## 2026.6.9 diff --git a/apps/ios/APP-REVIEW-NOTES.md b/apps/ios/APP-REVIEW-NOTES.md index 0449a75643f8..6c21468f811b 100644 --- a/apps/ios/APP-REVIEW-NOTES.md +++ b/apps/ios/APP-REVIEW-NOTES.md @@ -105,6 +105,19 @@ Reopen OpenClaw, confirm Talk is still active, then tap `Stop Talk`. 4. Confirm at least one `agent` row is connected. 5. Confirm the iPhone review device appears in the connected instances list. +## Live Activity / Dynamic Island + +1. Tap `Settings`. +2. Tap `Reconnect`. +3. Immediately send OpenClaw to the background by returning to the Home Screen + or locking the iPhone. +4. Watch the Lock Screen or Dynamic Island while the Gateway reconnects. + +Expected result: while reconnecting, iOS can show an `OpenClaw` Live Activity +with connection status such as `Connecting...` or `Reconnecting...`. On a fast +network this status may be brief because OpenClaw ends the Live Activity after +the Gateway reconnects successfully. + ## Push Notification 1. Tap the `Chat` tab. diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index d9b06cfaec24..6a5656dd3792 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -57,7 +57,7 @@ NSCalendarsWriteOnlyAccessUsageDescription OpenClaw uses your calendars to add events when you enable calendar access. NSCameraUsageDescription - OpenClaw can capture photos or short video clips when requested via the gateway. + OpenClaw uses the camera when you scan a Gateway setup QR code or ask your paired Gateway or assistant to capture a photo or short video from this iPhone, for example to connect to your Gateway or show your assistant a document, device screen, or workspace. NSContactsUsageDescription OpenClaw uses your contacts so you can search and reference people while using the assistant. NSLocalNetworkUsageDescription diff --git a/apps/ios/project.yml b/apps/ios/project.yml index b00515cd701f..889297de9f81 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -156,7 +156,7 @@ targets: NSAllowsLocalNetworking: true NSBonjourServices: - _openclaw-gw._tcp - NSCameraUsageDescription: OpenClaw can capture photos or short video clips when requested via the gateway. + NSCameraUsageDescription: OpenClaw uses the camera when you scan a Gateway setup QR code or ask your paired Gateway or assistant to capture a photo or short video from this iPhone, for example to connect to your Gateway or show your assistant a document, device screen, or workspace. NSCalendarsUsageDescription: OpenClaw uses your calendars to show events and scheduling context when you enable calendar access. NSCalendarsFullAccessUsageDescription: OpenClaw uses your calendars to show events and scheduling context when you enable calendar access. NSCalendarsWriteOnlyAccessUsageDescription: OpenClaw uses your calendars to add events when you enable calendar access. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index f6f9bf92a793..1d79002dcd74 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -9246475f5771612a5fd12de38b153783c4a4cbb8b2682a5c40115916661c90f2 config-baseline.json -6349131baaa1828f2a071f42e4d7b17c8966c59b6588c8a4c1a32ea5ea4dcd5e config-baseline.core.json +f5a5855ddd7aa8c23a732f257eceaa20fd163b1d5f342c909f4aef15aa8643cf config-baseline.json +b8dffdb1a328aaf728a0707ab04d21c65f1a225a2360042e10832aa608699716 config-baseline.core.json 671979e86e4c4f59415d0a20879e838f9bbd883b3d29eeb02cb5131db8d187fe config-baseline.channel.json 94529978588d6e3776a86780b22cf9ff46a6f9957f2f178d3829403fad451ca7 config-baseline.plugin.json diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 0d200326e874..56e56f2e71b0 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -ebb0ae07e4d6f6ea1faccba7604c9da71a5401b3aa2bc3618963e1e44a8dbcce plugin-sdk-api-baseline.json -9b7aee16d91c6a1b042a7d7e6f92a77b3e234337cc5fcf5a797de05fa9e9a02e plugin-sdk-api-baseline.jsonl +760812c17f7e48d7ceafeebbbe348dad13916ccb9ecaf41b3abc9a09b1e690c1 plugin-sdk-api-baseline.json +4d9b76016b2f845e101949a3d2ac92437f49783906d1c263d65f3534bb333de5 plugin-sdk-api-baseline.jsonl diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index 32631d3577c5..7ccc97639426 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -579,7 +579,7 @@ When `imsg launch` is running and `openclaw channels status --probe` reports `pr - When the private API bridge is up, accepted inbound chats are marked read before dispatch and a typing bubble is shown to the sender while the agent generates. Disable read-marking with: + When the private API bridge is up, accepted inbound chats are marked read and direct chats show a typing bubble as soon as the turn is accepted, while the agent prepares context and generates. Disable read-marking with: ```json5 { diff --git a/docs/ci.md b/docs/ci.md index 2fb79bddde4d..6543af64f93b 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -30,7 +30,7 @@ or an explicit manual dispatch. | `security-fast` | Private key detection, changed-workflow audit via `zizmor`, and production lockfile audit | Always on non-draft pushes and PRs | | `check-dependencies` | Production Knip dependency-only pass plus the unused-file allowlist guard | Node-relevant changes | | `build-artifacts` | Build `dist/`, Control UI, built-CLI smoke checks, embedded built-artifact checks, and reusable artifacts | Node-relevant changes | -| `checks-fast-core` | Fast Linux correctness lanes such as bundled, protocol, and CI-routing checks | Node-relevant changes | +| `checks-fast-core` | Fast Linux correctness lanes such as bundled, protocol, QA Smoke CI, and CI-routing checks | Node-relevant changes | | `checks-fast-contracts-plugins-*` | Two sharded plugin contract checks | Node-relevant changes | | `checks-fast-contracts-channels-*` | Two sharded channel contract checks | Node-relevant changes | | `checks-node-core-*` | Core Node test shards, excluding channel, bundled, contract, and extension lanes | Node-relevant changes | diff --git a/docs/clawhub/cli.md b/docs/clawhub/cli.md index 7f2bcf359531..d6180ba5f0c6 100644 --- a/docs/clawhub/cli.md +++ b/docs/clawhub/cli.md @@ -24,17 +24,31 @@ OpenClaw agent or Gateway. ```bash openclaw skills search "calendar" openclaw skills install @owner/ +openclaw skills install @owner/ --acknowledge-clawhub-risk openclaw skills update @owner/ +openclaw skills update @owner/ --acknowledge-clawhub-risk openclaw skills verify @owner/ openclaw plugins search "calendar" openclaw plugins install clawhub: +openclaw plugins install clawhub: --acknowledge-clawhub-risk openclaw plugins update ``` Skill installs target the active workspace `skills/` directory by default. Add `--global` to install into the shared managed skills directory. +OpenClaw checks the selected community ClawHub skill or plugin trust state +before downloading it. Versioned community skill and plugin releases use +exact-release trust metadata; resolver-backed GitHub skills rely on ClawHub's +install resolver to enforce scan and force-install policy before it returns a +pinned commit. Malicious or blocked community releases are refused. Risky +community releases require review and `--acknowledge-clawhub-risk` when a +non-interactive command should continue after that review. + +Official ClawHub publishers/packages and bundled OpenClaw sources bypass this +release-trust prompt and security-verdict fetch during install and update. + Plugin installs use the `clawhub:` prefix when you want ClawHub resolution instead of npm or another install source. diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index aeabd4f33929..33106b99f744 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -40,6 +40,7 @@ openclaw doctor openclaw doctor --lint openclaw doctor --lint --json openclaw doctor --lint --severity-min warning +openclaw doctor --lint --all openclaw doctor --lint --allow-exec openclaw doctor --deep openclaw doctor --fix @@ -73,6 +74,7 @@ The targeted Discord capabilities probe reports the bot's effective channel perm - `--post-upgrade`: run post-upgrade plugin compatibility probes; emits findings to stdout; exits with code 1 if any error-level findings are present - `--json`: with `--lint`, emit JSON findings instead of human output; with `--post-upgrade`, emit a machine-readable JSON envelope (`{ probesRun, findings }`) - `--severity-min `: with `--lint`, drop findings below `info`, `warning`, or `error` +- `--all`: with `--lint`, run all registered checks, including opt-in checks excluded from the default automation set - `--skip `: with `--lint`, skip a check id; repeat to skip more than one - `--only `: with `--lint`, run only a check id; repeat to run a small selected set @@ -82,13 +84,14 @@ The targeted Discord capabilities probe reports the bot's effective channel perm It uses the structured health-check path, does not prompt, and does not repair or rewrite config/state. Use it in CI, preflight scripts, and review workflows when you want machine-readable findings instead of guided repair prompts. -Lint-output options such as `--json`, `--severity-min`, `--only`, and `--skip` +Lint-output options such as `--json`, `--severity-min`, `--all`, `--only`, and `--skip` are only accepted with `--lint`. ```bash openclaw doctor --lint openclaw doctor --lint --severity-min warning openclaw doctor --lint --json +openclaw doctor --lint --all openclaw doctor --lint --allow-exec openclaw doctor --lint --only core/doctor/gateway-config --json ``` @@ -130,6 +133,13 @@ Exit behavior: example, `openclaw doctor --lint --severity-min error` can print no findings and exit `0` even when lower-severity `info` or `warning` findings exist. +`--all` controls which checks are selected before severity filtering. The +default lint run is the stable automation gate and excludes checks that are +intentionally opt-in because they are deep, historical, or more likely to +surface repairable legacy residue. Use `--all` when you want the complete lint +inventory without listing each check id. `--only ` remains the most precise +selector and can run any registered check by id. + ## Structured Health Checks Modern doctor checks use a small structured contract: @@ -186,6 +196,7 @@ Use `--only` and `--skip` when a workflow wants a focused gate: ```bash openclaw doctor --lint --only core/doctor/gateway-config --json openclaw doctor --lint --skip core/doctor/skills-readiness +openclaw doctor --lint --all --skip core/doctor/session-locks ``` `--only` and `--skip` accept full check ids and may be repeated. If an `--only` diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 7716fa3f8eb0..e8bddba90a89 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -111,6 +111,7 @@ openclaw plugins install git:github.com// # git repo openclaw plugins install git:github.com//@ openclaw plugins install --force # overwrite existing install openclaw plugins install --pin # pin version +openclaw plugins install clawhub: --acknowledge-clawhub-risk openclaw plugins install --dangerously-force-unsafe-install openclaw plugins install # local path openclaw plugins install @ # marketplace @@ -163,6 +164,12 @@ is available, then fall back to `latest`. If a plugin you published on ClawHub is hidden or blocked by a registry scan, use the publisher steps in [ClawHub publishing](/clawhub/publishing). `--dangerously-force-unsafe-install` does not ask ClawHub to rescan the plugin or make a blocked release public. + + + Community ClawHub installs check the selected release trust record before downloading the package. If ClawHub disables download for the release, reports malicious scan findings, or puts the release in a blocking moderation state such as quarantine, OpenClaw refuses the release. For non-blocking risky scan statuses, risky moderation states, or registry reasons, OpenClaw shows the trust details and asks for confirmation before continuing. + + Use `--acknowledge-clawhub-risk` only after reviewing the ClawHub warning and deciding to continue without an interactive prompt. Pending or stale clean trust records warn but do not require acknowledgement. Official ClawHub packages and bundled OpenClaw plugin sources bypass this release-trust prompt. + `plugins install` is also the install surface for hook packs that expose `openclaw.hooks` in `package.json`. Use `openclaw hooks` for filtered hook visibility and per-hook enablement, not package installation. @@ -390,6 +397,7 @@ openclaw plugins update openclaw plugins update --all openclaw plugins update --dry-run openclaw plugins update @openclaw/voice-call +openclaw plugins update openclaw-codex-app-server --acknowledge-clawhub-risk openclaw plugins update openclaw-codex-app-server --dangerously-force-unsafe-install ``` @@ -399,13 +407,17 @@ Updates apply to tracked plugin installs in the managed plugin index and tracked When you pass a plugin id, OpenClaw reuses the recorded install spec for that plugin. That means previously stored dist-tags such as `@beta` and exact pinned versions continue to be used on later `update ` runs. + That targeted-update rule is different from the bulk `openclaw plugins update --all` maintenance path. Bulk updates still respect ordinary tracked install specs, but trusted official OpenClaw plugin records can sync to the current official catalog target instead of staying on a stale exact official package. Use targeted `update ` when you intentionally want to keep an exact or tagged official spec untouched. + For npm installs, you can also pass an explicit npm package spec with a dist-tag or exact version. OpenClaw resolves that package name back to the tracked plugin record, updates that installed plugin, and records the new npm spec for future id-based updates. Passing the npm package name without a version or tag also resolves back to the tracked plugin record. Use this when a plugin was pinned to an exact version and you want to move it back to the registry's default release line. - `openclaw plugins update` reuses the tracked plugin spec unless you pass a new spec. `openclaw update` additionally knows the active OpenClaw update channel: on the beta channel, default-line npm and ClawHub plugin records try `@beta` first. They fall back to the recorded default/latest spec if no plugin beta release exists; npm plugins also fall back when the beta package exists but fails install validation. That fallback is reported as a warning and does not fail the core update. Exact versions and explicit tags stay pinned to that selector. + Targeted `openclaw plugins update ` reuses the tracked plugin spec unless you pass a new spec. Bulk `openclaw plugins update --all` uses the configured `update.channel` when it syncs trusted official plugin records to the official catalog target, so beta-channel installs can stay on the beta release line instead of being silently normalized to stable/latest. + + `openclaw update` also knows the active OpenClaw update channel: on the beta channel, default-line npm and ClawHub plugin records try `@beta` first. They fall back to the recorded default/latest spec if no plugin beta release exists; npm plugins also fall back when the beta package exists but fails install validation. That fallback is reported as a warning and does not fail the core update. Exact versions and explicit tags stay pinned to that selector for targeted updates. @@ -417,6 +429,9 @@ Updates apply to tracked plugin installs in the managed plugin index and tracked `--dangerously-force-unsafe-install` is also accepted on `plugins update` for compatibility, but it is deprecated and no longer changes plugin update behavior. Operator `security.installPolicy` can still block updates; plugin `before_install` hooks only apply in processes where plugin hooks are loaded. + + Community ClawHub-backed plugin updates run the same exact-release trust check as installs before downloading the replacement package. Use `--acknowledge-clawhub-risk` for reviewed automation that should continue when the selected ClawHub release has a risky trust warning. Official ClawHub packages and bundled OpenClaw plugin sources bypass this release-trust prompt. + ### Inspect diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md index 4e2b6600490e..556e1416f7ee 100644 --- a/docs/cli/sessions.md +++ b/docs/cli/sessions.md @@ -120,6 +120,7 @@ openclaw sessions cleanup --json - Scope note: `openclaw sessions cleanup` maintains session stores, transcripts, and trajectory sidecars. It does not prune cron run history, which is managed by `cron.runLog.keepLines` in [Cron configuration](/automation/cron-jobs#configuration) and explained in [Cron maintenance](/automation/cron-jobs#maintenance). - Cleanup also prunes unreferenced primary transcripts, compaction checkpoints, and trajectory sidecars older than `session.maintenance.pruneAfter`; files still referenced by `sessions.json` are preserved. +- Cleanup reports short-lived gateway model-run probe cleanup separately as `modelRunPruned`. This only matches strict explicit keys shaped like `agent:*:explicit:model-run-`. The fixed retention is `24h`, but it is pressure-gated: it only removes stale probe rows when session-entry maintenance/cap pressure is reached. When it runs, model-run cleanup happens before global stale cleanup and capping. - `--dry-run`: preview how many entries would be pruned/capped without writing. - In text mode, dry-run prints a per-session action table (`Action`, `Key`, `Age`, `Model`, `Flags`) plus a summary grouped by session label so you can see what would be kept vs removed. diff --git a/docs/cli/skills.md b/docs/cli/skills.md index bdbb39932b93..148014adbe90 100644 --- a/docs/cli/skills.md +++ b/docs/cli/skills.md @@ -31,9 +31,11 @@ openclaw skills install git:owner/repo openclaw skills install git:owner/repo@main openclaw skills install ./path/to/skill --as custom-name openclaw skills install @owner/ --force +openclaw skills install @owner/ --acknowledge-clawhub-risk openclaw skills install @owner/ --agent openclaw skills install @owner/ --global openclaw skills update @owner/ +openclaw skills update @owner/ --acknowledge-clawhub-risk openclaw skills update @owner/ --global openclaw skills update --all openclaw skills update --all --agent @@ -97,6 +99,14 @@ Notes: - `install --version ` applies only to ClawHub skill refs. - `install --force` overwrites an existing workspace skill folder for the same slug. +- Community ClawHub skill installs and updates check trust before downloading. + Versioned community archive releases use exact-release trust metadata. + Resolver-backed GitHub skills rely on ClawHub's install resolver to enforce + scan and force-install policy before it returns a pinned commit. Malicious or + blocked community releases are refused. Risky community releases require + review and `--acknowledge-clawhub-risk` when a non-interactive command should + continue after that review. Official ClawHub skill publishers and bundled + OpenClaw skill sources bypass this release-trust prompt. - `--global` targets the shared managed skills directory and cannot be combined with `--agent `. - `--agent ` targets one configured agent workspace and overrides current diff --git a/docs/cli/update.md b/docs/cli/update.md index fb2ce17f2df5..459d42ee0fe4 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -28,6 +28,7 @@ openclaw update --tag main openclaw update --dry-run openclaw update --no-restart openclaw update --yes +openclaw update --acknowledge-clawhub-risk openclaw update --json openclaw --update ``` @@ -45,6 +46,11 @@ openclaw --update when npm plugin artifact drift is detected during post-update plugin sync. - `--timeout `: per-step timeout (default is 1800s). - `--yes`: skip confirmation prompts (for example downgrade confirmation). +- `--acknowledge-clawhub-risk`: after reviewing community ClawHub trust + warnings, allow post-update plugin sync to continue without an interactive + prompt. Without this, risky community ClawHub plugin releases are skipped and + left unchanged when OpenClaw cannot prompt. Official ClawHub packages and + bundled OpenClaw plugin sources bypass this release-trust prompt. `openclaw update` does not have a `--verbose` flag. Use `--dry-run` to preview the planned channel/tag/install/restart actions, `--json` for machine-readable @@ -88,6 +94,7 @@ converge. ```bash openclaw update repair openclaw update repair --channel beta +openclaw update repair --acknowledge-clawhub-risk openclaw update repair --json ``` @@ -98,6 +105,10 @@ Options: - `--json`: print machine-readable finalization JSON. - `--timeout `: timeout for repair steps (default `1800`). - `--yes`: skip confirmation prompts. +- `--acknowledge-clawhub-risk`: after reviewing community ClawHub trust + warnings, allow repair-time plugin convergence to continue without an + interactive prompt. Official ClawHub packages and bundled OpenClaw plugin + sources bypass this release-trust prompt. - `--no-restart`: accepted for update command parity; repair never restarts the Gateway. diff --git a/docs/concepts/agent-loop.md b/docs/concepts/agent-loop.md index ba35ceb7b135..10a752defa4d 100644 --- a/docs/concepts/agent-loop.md +++ b/docs/concepts/agent-loop.md @@ -167,7 +167,7 @@ surfaces, while Codex native hooks remain a separate lower-level Codex mechanism - Agent runtime: `agents.defaults.timeoutSeconds` default 172800s (48 hours); enforced in `runEmbeddedAgent` abort timer. - Cron runtime: isolated agent-turn `timeoutSeconds` is owned by cron. The scheduler starts that timer when execution begins, aborts the underlying run at the configured deadline, then runs bounded cleanup before recording the timeout so a stale child session cannot keep the lane stuck. - Session liveness diagnostics: with diagnostics enabled, `diagnostics.stuckSessionWarnMs` classifies long `processing` sessions that have no observed reply, tool, status, block, or ACP progress. Active embedded runs, model calls, and tool calls report as `session.long_running`; owned silent model calls also stay `session.long_running` until `diagnostics.stuckSessionAbortMs` so slow or non-streaming providers are not reported as stalled too early. Active work with no recent progress reports as `session.stalled`; owned model calls switch to `session.stalled` at or after the abort threshold, and ownerless stale model/tool activity is not hidden as long-running. `session.stuck` is reserved for recoverable stale session bookkeeping, including idle queued sessions with stale ownerless model/tool activity. Stale session bookkeeping releases the affected session lane immediately after recovery gates pass; stalled embedded runs are abort-drained only after `diagnostics.stuckSessionAbortMs` (default: at least 5 minutes and 3x the warning threshold) so queued work can resume without cutting off merely slow runs. Recovery emits structured requested/completed outcomes, and diagnostic state is marked idle only if the same processing generation is still current. Repeated `session.stuck` diagnostics back off while the session remains unchanged. -- Model idle timeout: OpenClaw aborts a model request when no response chunks arrive before the idle window. `models.providers..timeoutSeconds` extends this idle watchdog for slow local/self-hosted providers, but it is still bounded by any lower `agents.defaults.timeoutSeconds` or run-specific timeout because those control the whole agent run. Otherwise OpenClaw uses `agents.defaults.timeoutSeconds` when configured, capped at 120s by default. Cron-triggered cloud model runs with no explicit model or agent timeout use the same default idle watchdog; cron-triggered local or self-hosted model runs disable the implicit watchdog unless an explicit timeout is configured, so slow local providers should set `models.providers..timeoutSeconds`. +- Model idle timeout: OpenClaw aborts a model request when no response chunks arrive before the idle window. `models.providers..timeoutSeconds` extends this idle watchdog for slow local/self-hosted providers, but it is still bounded by any lower `agents.defaults.timeoutSeconds` or run-specific timeout because those control the whole agent run. Otherwise OpenClaw uses `agents.defaults.timeoutSeconds` when configured, capped at 120s by default. Cron-triggered cloud model runs with no explicit model or agent timeout use the same default idle watchdog; with an explicit cron run timeout, cloud model stream stalls are capped at 60s so configured model fallbacks can run before the outer cron deadline. Cron-triggered local or self-hosted model runs disable the implicit watchdog unless an explicit timeout is configured, and explicit cron run timeouts remain the idle window for local/self-hosted providers, so slow local providers should set `models.providers..timeoutSeconds`. - Provider HTTP request timeout: `models.providers..timeoutSeconds` applies to that provider's model HTTP fetches, including connect, headers, body, SDK request timeout, total guarded-fetch abort handling, and model stream idle watchdog. Use this for slow local/self-hosted providers such as Ollama before raising the whole agent runtime timeout, and keep the agent/runtime timeout at least as high when the model request needs to run longer. ## Where things can end early diff --git a/docs/concepts/session.md b/docs/concepts/session.md index d49b56cd4733..80013e703c20 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -127,6 +127,14 @@ in `enforce` mode and applies cleanup during maintenance. Set For production-sized `maxEntries` limits, Gateway runtime writes use a small high-water buffer and clean back down to the configured cap in batches. Session store reads do not prune or cap entries during Gateway startup. This avoids running full store cleanup on every startup or isolated cron session. `openclaw sessions cleanup --enforce` applies the cap immediately. +Gateway model-run probe sessions are short-lived by default. Matching rows with +strict explicit keys like `agent:*:explicit:model-run-` use fixed `24h` +retention, but cleanup is pressure-gated: it only removes stale probe rows when +session-entry maintenance/cap pressure is reached. When model-run cleanup runs, +it runs before the broader stale-entry age cutoff and entry cap. Normal direct, +group, thread, cron, hook, heartbeat, ACP, and sub-agent sessions do not inherit +this 24h retention. + Maintenance preserves durable external conversation pointers, including group sessions and thread-scoped chat sessions, while still allowing synthetic cron, hook, heartbeat, ACP, and sub-agent entries to age out. diff --git a/docs/concepts/typing-indicators.md b/docs/concepts/typing-indicators.md index 485dd809ad7c..61c8aca6aea8 100644 --- a/docs/concepts/typing-indicators.md +++ b/docs/concepts/typing-indicators.md @@ -15,7 +15,8 @@ When `agents.defaults.typingMode` is **unset**, OpenClaw keeps the legacy behavi - **Direct chats**: typing starts immediately once the model loop begins. - **Group chats with a mention**: typing starts immediately. -- **Group chats without a mention**: typing starts only when message text begins streaming. +- **Group chats without a mention**: typing starts when the admitted run has + user-visible activity, such as harness execution activity or message text. - **Heartbeat runs**: typing starts when the heartbeat run begins if the resolved heartbeat target is a typing-capable chat and typing is not disabled. @@ -26,13 +27,14 @@ Set `agents.defaults.typingMode` to one of: - `never` - no typing indicator, ever. - `instant` - start typing **as soon as the model loop begins**, even if the run later returns only the silent reply token. -- `thinking` - start typing on the **first reasoning delta** (requires - `reasoningLevel: "stream"` for the run). -- `message` - start typing on the **first non-silent text delta** (ignores - the `NO_REPLY` silent token). +- `thinking` - start typing on the **first reasoning delta** or on active + harness execution after the turn is accepted. +- `message` - start typing on the **first user-visible reply activity**, such as + active harness execution or a non-silent text delta. Silent reply tokens such + as `NO_REPLY` do not count as text activity. Order of "how early it fires": -`never` → `message` → `thinking` → `instant` +`never` → `message`/`thinking` → `instant` ## Configuration @@ -62,11 +64,10 @@ Override mode or cadence per session: ## Notes -- `message` mode won't show typing for silent-only replies when the whole - payload is the exact silent token (for example `NO_REPLY` / `no_reply`, - matched case-insensitively). -- `thinking` only fires if the run streams reasoning (`reasoningLevel: "stream"`). - If the model doesn't emit reasoning deltas, typing won't start. +- `message` mode does not start from silent reply tokens, but active execution + can still show typing before any assistant text is available. +- `thinking` still reacts to streamed reasoning (`reasoningLevel: "stream"`), + and it can also start from active execution before reasoning deltas arrive. - Heartbeat typing is a liveness signal for the resolved delivery target. It starts at heartbeat run start instead of following `message` or `thinking` stream timing. Set `typingMode: "never"` to disable it. diff --git a/docs/concepts/usage-tracking.md b/docs/concepts/usage-tracking.md index 6cdf9ff94447..9fe31f01e351 100644 --- a/docs/concepts/usage-tracking.md +++ b/docs/concepts/usage-tracking.md @@ -30,6 +30,68 @@ title: "Usage tracking" - CLI: `openclaw channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip). - macOS menu bar: "Usage" section under Context (only if available). +## Default usage footer mode + +`/usage off|tokens|full` sets the footer for a session and is remembered for that +session. `messages.responseUsage` seeds that mode for sessions that have not +chosen one, so the footer can be on by default without typing `/usage` each time. + +Set one mode for every channel, or a per-channel map with a `default` fallback: + +```jsonc +{ + "messages": { + "responseUsage": "tokens", + // or: { "default": "off", "discord": "full" } + }, +} +``` + +### Three distinct session states + +A session's `responseUsage` field has three representable states, each with +different semantics: + +| State | Stored value | Effective mode | +| ------------------- | ------------------------------- | --------------------------------------------------------------------- | +| **Unset / inherit** | `undefined` (absent) | Falls through to `messages.responseUsage` config default, then `off`. | +| **Explicit off** | `"off"` (stored) | Always off — a non-off config default cannot re-enable the footer. | +| **Explicit on** | `"tokens"` or `"full"` (stored) | That mode, regardless of config default. | + +### Precedence + +Effective mode = session override → channel config entry → `default` → `off`. + +An explicit `/usage off` is **persisted** as the literal value `"off"` in the +session, not the same as "unset." This means a non-off `messages.responseUsage` +default cannot turn the footer back on once the user has explicitly disabled it. + +### Resetting vs. turning off + +- `/usage off` — forces the footer off and persists that choice. A configured + non-off default cannot override this. +- `/usage reset` (aliases: `inherit`, `clear`, `default`) — clears the session + override. The session then **inherits** the effective config default + (`messages.responseUsage`). If no default is configured, the footer is off + (unchanged from before). Use this to "go back to default" without explicitly + turning the footer on. +- A full session reset (`/reset` or `/new`) or a session rollover **preserves** + the explicit usage-mode preference so the user's display choice survives + session rollovers. Only `/usage reset` (and its aliases) actually clears the + override. + +### Toggle behavior + +`/usage` with no arguments cycles: off → tokens → full → off. The starting point +for the cycle is the **effective** current mode (session override falling through +to the config default when unset), so the cycle is always consistent with what +the user sees in the footer. + +### Config + +With no config the prior behavior holds (footer off until `/usage`). Use +`/usage reset` to clear a session override and re-inherit the configured default. + ## Custom `/usage full` footer `/usage full` shows a built-in compact footer with model, reasoning, fast/slow, diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index c257634e93df..4d793a0359aa 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -1316,6 +1316,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden - `mode`: `enforce` applies cleanup and is the default; `warn` emits warnings only. - `pruneAfter`: age cutoff for stale entries (default `30d`). - `maxEntries`: maximum number of entries in `sessions.json` (default `500`). Runtime writes batch cleanup with a small high-water buffer for production-sized caps; `openclaw sessions cleanup --enforce` applies the cap immediately. + - Short-lived gateway model-run probe sessions use fixed `24h` retention, but cleanup is pressure-gated: it only removes stale strict model-run probe rows when session-entry maintenance/cap pressure is reached. Only strict explicit probe keys matching `agent:*:explicit:model-run-` are eligible; normal direct, group, thread, cron, hook, heartbeat, ACP, and sub-agent sessions do not inherit this 24h retention. When model-run cleanup runs, it runs before the broader `pruneAfter` stale-entry cleanup and `maxEntries` cap. - `rotateBytes`: deprecated and ignored; `openclaw doctor --fix` removes it from older configs. - `resetArchiveRetention`: retention for `*.reset.` transcript archives. Defaults to `pruneAfter`; set `false` to disable. - `maxDiskBytes`: optional sessions-directory disk budget. In `warn` mode it logs warnings; in `enforce` mode it removes oldest artifacts/sessions first. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 5b90a631e926..0e82e469a850 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -104,6 +104,7 @@ Examples: openclaw doctor --lint openclaw doctor --lint --severity-min warning openclaw doctor --lint --json +openclaw doctor --lint --all openclaw doctor --lint --only core/doctor/gateway-config --json ``` @@ -111,7 +112,7 @@ JSON output includes: - `ok`: whether any visible finding met the selected severity threshold - `checksRun`: number of health checks executed -- `checksSkipped`: checks skipped by `--only` or `--skip` +- `checksSkipped`: checks skipped by the selected profile, `--only`, or `--skip` - `findings`: structured diagnostics with `checkId`, `severity`, `message`, and optional `path`, `line`, `column`, `ocPath`, and `fixHint` @@ -122,11 +123,13 @@ Exit codes: - `2`: command/runtime failure before lint findings could be emitted Use `--severity-min info|warning|error` to control both what is printed and what -causes a non-zero lint exit. Use `--only ` for narrow preflight gates and +causes a non-zero lint exit. Use `--all` to run the complete lint inventory, +including deeper opt-in checks excluded from the default automation set. Use `--only ` for narrow preflight gates and `--skip ` to temporarily exclude a noisy check while keeping the rest of the lint run active. -Lint-output options such as `--json`, `--severity-min`, `--only`, and `--skip` -must be paired with `--lint`; regular doctor and repair runs reject them. +Lint-output options such as `--json`, `--severity-min`, `--all`, `--only`, and +`--skip` must be paired with `--lint`; regular doctor and repair runs reject +them. ## What it does (summary) diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 8bf468d30728..f3503a0d8aeb 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -415,7 +415,7 @@ If you installed OpenClaw via `npm install -g openclaw`, use the inline `docker - For a more functional sandbox image with common tooling (for example `curl`, `jq`, `nodejs`, `python3`, `git`): + For a more functional sandbox image with common tooling (for example `curl`, `jq`, Node 24, pnpm, `python3`, and `git`): From a source checkout: diff --git a/docs/maturity/scorecard.md b/docs/maturity/scorecard.md index c01a7b3e8867..677a996fdd34 100644 --- a/docs/maturity/scorecard.md +++ b/docs/maturity/scorecard.md @@ -19,42 +19,23 @@ Use this page to answer one question: which OpenClaw surfaces are credible choic ## At a glance
-
-
- 1% - Coverage -
-
-
- Experimental - QA profile evidence -
-
- 63% - Quality + 67% + Maturity score
-
+
Alpha - Reliability and operator confidence -
-
-
-
- 70% - Completeness -
-
-
- Beta - Expected workflow coverage + Quality + completeness + Coverage Experimental - 4% + Quality Alpha - 63% + Completeness Beta - 70%
-Coverage is deliberately evidence-led: an area does not become "ready" just because the implementation exists. +Coverage is deliberately evidence-led: an area does not become "ready" just because the implementation exists. It is not an input to the maturity score, but OpenClaw aims to keep end-to-end coverage above 90% for mature Stable-or-better features over time. ## Score bands @@ -78,14 +59,14 @@ Surfaces are ordered by maturity level, completeness, and quality. LTS support i
SurfaceCoverageQualityCompletenessSupport
CLIM4Stable7 areas -
CoverageExperimental2%
+
CoverageExperimental4%
QualityStable83%
CompletenessStable90%
Partial - 6
Gateway runtimeM4Stable13 areas -
CoverageExperimental3%
+
CoverageExperimental6%
QualityStable81%
CompletenessStable89%
Partial - 12
@@ -113,91 +94,91 @@ Surfaces are ordered by maturity level, completeness, and quality. LTS support i
Agent RuntimeM3Beta9 areas -
CoverageExperimental2%
+
CoverageExperimental33%
QualityBeta78%
CompletenessBeta79%
Partial - 6
Session, memory, and context engineM3Beta9 areas -
CoverageExperimental0%
+
CoverageExperimental30%
QualityBeta77%
CompletenessBeta79%
Partial - 6
Channel frameworkM3Beta8 areas -
CoverageExperimental0%
+
CoverageExperimental13%
QualityBeta76%
CompletenessBeta79%
Partial - 5
Browser automation, exec, and sandbox toolsM3Beta3 areas -
CoverageExperimental15%
+
CoverageExperimental21%
QualityBeta75%
CompletenessBeta79%
Partial - 2
ObservabilityM3Beta5 areas -
CoverageExperimental6%
+
CoverageExperimental18%
QualityBeta75%
CompletenessBeta79%
Partial - 3
OpenAI and Codex provider pathM3Beta5 areas -
CoverageExperimental8%
+
CoverageExperimental26%
QualityBeta74%
CompletenessBeta79%
Partial - 3
Gateway Web AppM3Beta6 areas -
CoverageExperimental0%
+
CoverageExperimental4%
QualityBeta74%
CompletenessBeta79%
None
Web search toolsM3Beta4 areas -
CoverageExperimental7%
+
CoverageExperimental9%
QualityBeta74%
CompletenessBeta79%
None
PluginsM3Beta9 areas -
CoverageExperimental2%
+
CoverageExperimental12%
QualityBeta72%
CompletenessBeta79%
Partial - 7
Security, auth, pairing, and secretsM3Beta6 areas -
CoverageExperimental0%
+
CoverageExperimental16%
QualityBeta72%
CompletenessBeta79%
Partial - 5
Automation: cron, hooks, tasks, pollingM3Beta6 areas -
CoverageExperimental0%
+
CoverageExperimental2%
QualityBeta72%
CompletenessBeta79%
None
Docker and Podman hostingM3Beta4 areas -
CoverageExperimental5%
+
CoverageExperimental7%
QualityBeta71%
CompletenessBeta79%
None
Windows via WSL2M3Beta6 areas -
CoverageExperimental3%
+
CoverageExperimental6%
QualityAlpha69%
CompletenessBeta79%
Partial - 5
@@ -267,7 +248,7 @@ Surfaces are ordered by maturity level, completeness, and quality. LTS support i
Media understanding and media generationM2Alpha6 areas -
CoverageExperimental1%
+
CoverageExperimental2%
QualityAlpha64%
CompletenessAlpha68%
None
@@ -379,7 +360,7 @@ Surfaces are ordered by maturity level, completeness, and quality. LTS support i
OpenClaw App SDKM2Alpha6 areas -
CoverageExperimental0%
+
CoverageExperimental3%
QualityAlpha54%
CompletenessAlpha53%
None
@@ -433,77 +414,77 @@ Surfaces are ordered by maturity level, completeness, and quality. LTS support i
SurfaceCoverageQualityCompletenessSupport
CLIM4Stable7 areas -
CoverageExperimental2%
+
CoverageExperimental4%
QualityStable83%
CompletenessStable90%
Partial - 6
Gateway runtimeM4Stable13 areas -
CoverageExperimental3%
+
CoverageExperimental6%
QualityStable81%
CompletenessStable89%
Partial - 12
Agent RuntimeM3Beta9 areas -
CoverageExperimental2%
+
CoverageExperimental33%
QualityBeta78%
CompletenessBeta79%
Partial - 6
Session, memory, and context engineM3Beta9 areas -
CoverageExperimental0%
+
CoverageExperimental30%
QualityBeta77%
CompletenessBeta79%
Partial - 6
Channel frameworkM3Beta8 areas -
CoverageExperimental0%
+
CoverageExperimental13%
QualityBeta76%
CompletenessBeta79%
Partial - 5
ObservabilityM3Beta5 areas -
CoverageExperimental6%
+
CoverageExperimental18%
QualityBeta75%
CompletenessBeta79%
Partial - 3
Gateway Web AppM3Beta6 areas -
CoverageExperimental0%
+
CoverageExperimental4%
QualityBeta74%
CompletenessBeta79%
None
PluginsM3Beta9 areas -
CoverageExperimental2%
+
CoverageExperimental12%
QualityBeta72%
CompletenessBeta79%
Partial - 7
Security, auth, pairing, and secretsM3Beta6 areas -
CoverageExperimental0%
+
CoverageExperimental16%
QualityBeta72%
CompletenessBeta79%
Partial - 5
Automation: cron, hooks, tasks, pollingM3Beta6 areas -
CoverageExperimental0%
+
CoverageExperimental2%
QualityBeta72%
CompletenessBeta79%
None
Media understanding and media generationM2Alpha6 areas -
CoverageExperimental1%
+
CoverageExperimental2%
QualityAlpha64%
CompletenessAlpha68%
None
@@ -531,7 +512,7 @@ Surfaces are ordered by maturity level, completeness, and quality. LTS support i
OpenClaw App SDKM2Alpha6 areas -
CoverageExperimental0%
+
CoverageExperimental3%
QualityAlpha54%
CompletenessAlpha53%
None
@@ -557,14 +538,14 @@ Surfaces are ordered by maturity level, completeness, and quality. LTS support i
Docker and Podman hostingM3Beta4 areas -
CoverageExperimental5%
+
CoverageExperimental7%
QualityBeta71%
CompletenessBeta79%
None
Windows via WSL2M3Beta6 areas -
CoverageExperimental3%
+
CoverageExperimental6%
QualityAlpha69%
CompletenessBeta79%
Partial - 5
@@ -735,21 +716,21 @@ Surfaces are ordered by maturity level, completeness, and quality. LTS support i
SurfaceCoverageQualityCompletenessSupport
Browser automation, exec, and sandbox toolsM3Beta3 areas -
CoverageExperimental15%
+
CoverageExperimental21%
QualityBeta75%
CompletenessBeta79%
Partial - 2
OpenAI and Codex provider pathM3Beta5 areas -
CoverageExperimental8%
+
CoverageExperimental26%
QualityBeta74%
CompletenessBeta79%
Partial - 3
Web search toolsM3Beta4 areas -
CoverageExperimental7%
+
CoverageExperimental9%
QualityBeta74%
CompletenessBeta79%
None
@@ -807,9 +788,9 @@ The checks below show which scorecard areas were exercised by QA profile evidenc
Full taxonomy validation - 2026-06-23T08:05:04.411Z - 0 checks - 0 passed - 0 of 281 (0%) areas - 0 of 1675 (1%) capabilities + 2026-06-23T07:24:36.128Z + 96 checks - 94 passed, 2 blocked + 0 of 281 (0%) areas - 20 of 1675 (1.2%) features - 77 of 1665 (4.6%) coverage IDs
@@ -818,1316 +799,81 @@ The checks below show which scorecard areas were exercised by QA profile evidenc Open a surface to inspect the evidence state of each category. The list stays collapsed so the page remains useful at a glance. - -

13 partially reviewed

-
-
AreaCapabilitiesFollow-up
-
-
- Approvals and Remote Execution - Partially reviewed - Full taxonomy validation -
- 0 of 6 (3%) - None -
-
-
- HTTP APIs - Partially reviewed - Full taxonomy validation -
- 0 of 4 (3%) - None -
-
-
- Hosted Web Surface - Partially reviewed - Full taxonomy validation -
- 0 of 4 (3%) - None -
-
-
- Gateway RPC APIs and Events - Partially reviewed - Full taxonomy validation -
- 1 of 20 (3%) - None -
-
-
- Device Auth and Pairing - Partially reviewed - Full taxonomy validation -
- 0 of 10 (3%) - None -
-
-
- Network Access and Discovery - Partially reviewed - Full taxonomy validation -
- 0 of 6 (3%) - None -
-
-
- Nodes and Remote Capabilities - Partially reviewed - Full taxonomy validation -
- 0 of 8 (3%) - None -
-
-
- Health, Diagnostics, and Repair - Partially reviewed - Full taxonomy validation -
- 0 of 7 (3%) - None -
-
-
- Protocol Compatibility - Partially reviewed - Full taxonomy validation -
- 0 of 7 (3%) - None -
-
-
- Roles and Permissions - Partially reviewed - Full taxonomy validation -
- 0 of 5 (3%) - None -
-
-
- Gateway Lifecycle - Partially reviewed - Full taxonomy validation -
- 0 of 7 (3%) - None -
-
-
- Security Controls - Partially reviewed - Full taxonomy validation -
- 0 of 6 (3%) - None -
-
-
- WebSocket Connection - Partially reviewed - Full taxonomy validation -
- 0 of 8 (3%) - None -
-
-
- - -

7 partially reviewed

-
-
AreaCapabilitiesFollow-up
-
-
- CLI Setup - Partially reviewed - Full taxonomy validation -
- 0 of 6 (2%) - None -
-
-
- Onboarding and Auth Setup - Partially reviewed - Full taxonomy validation -
- 0 of 5 (2%) - None -
-
-
- Plugin and Channel Setup - Partially reviewed - Full taxonomy validation -
- 0 of 5 (2%) - None -
-
-
- Gateway Service Management - Partially reviewed - Full taxonomy validation -
- 0 of 5 (2%) - None -
-
-
- CLI Observability - Partially reviewed - Full taxonomy validation -
- 0 of 5 (2%) - None -
-
-
- Doctor - Partially reviewed - Full taxonomy validation -
- 0 of 10 (2%) - None -
-
-
- Updates and Upgrades - Partially reviewed - Full taxonomy validation -
- 0 of 5 (2%) - None -
-
-
- - -

9 partially reviewed

-
-
AreaCapabilitiesFollow-up
-
-
- Authoring and Packaging plugins - Partially reviewed - Full taxonomy validation -
- 0 of 8 (2%) - None -
-
-
- Bundled plugins - Partially reviewed - Full taxonomy validation -
- 0 of 5 (2%) - None -
-
-
- Canvas plugin - Partially reviewed - Full taxonomy validation -
- 0 of 6 (2%) - None -
-
-
- Installing and running plugins - Partially reviewed - Full taxonomy validation -
- 0 of 6 (2%) - None -
-
-
- Channel plugins - Partially reviewed - Full taxonomy validation -
- 0 of 5 (2%) - None -
-
-
- Provider and tool plugins - Partially reviewed - Full taxonomy validation -
- 0 of 6 (2%) - None -
-
-
- Plugin approvals - Partially reviewed - Full taxonomy validation -
- 0 of 6 (2%) - None -
-
-
- Publishing plugins - Partially reviewed - Full taxonomy validation -
- 0 of 6 (2%) - None -
-
-
- Testing plugins - Partially reviewed - Full taxonomy validation -
- 0 of 6 (2%) - None -
-
-
- -

9 partially reviewed

+

8 partially reviewed / 1 needs review

-
AreaCapabilitiesFollow-up
+
AreaFeatures / coverage IDsFollow-up
Agent Turn Execution Partially reviewed - Full taxonomy validation
- 0 of 3 (2%) - None + 0 of 3 (0%) / 7 of 24 (29.2%) + 17 capability gaps
External Runtimes and Subagents Partially reviewed - Full taxonomy validation
- 0 of 4 (2%) - None + 0 of 4 (0%) / 3 of 10 (30%) + 7 capability gaps
Hosted Provider Execution Partially reviewed - Full taxonomy validation
- 0 of 5 (2%) - None + 1 of 5 (20%) / 1 of 5 (20%) + 4 capability gaps
Local and Self-hosted Providers - Partially reviewed - Full taxonomy validation + Needs review - Full taxonomy validation
- 0 of 5 (2%) - None + 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps
Model and Runtime Selection Partially reviewed - Full taxonomy validation
- 0 of 4 (2%) - None + 0 of 4 (0%) / 2 of 8 (25%) + 6 capability gaps
Provider Auth Partially reviewed - Full taxonomy validation
- 0 of 10 (2%) - None + 0 of 10 (0%) / 4 of 17 (23.5%) + 13 capability gaps
Streaming and Progress Partially reviewed - Full taxonomy validation
- 0 of 2 (2%) - None + 0 of 2 (0%) / 5 of 9 (55.6%) + 4 capability gaps
Tool Calls and Response Handling Partially reviewed - Full taxonomy validation
- 0 of 3 (2%) - None + 0 of 3 (0%) / 15 of 23 (65.2%) + 8 capability gaps
Tool Execution Controls Partially reviewed - Full taxonomy validation
- 0 of 6 (2%) - None -
-
-
- - -

9 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- CLI Session and Transcript Management - Needs review - Full taxonomy validation -
- 0 of 2 (0%) - None -
-
-
- Token Management - Needs review - Full taxonomy validation -
- 0 of 3 (0%) - None -
-
-
- Context Engine - Needs review - Full taxonomy validation -
- 0 of 2 (0%) - None -
-
-
- Cross-client History and Session Parity - Needs review - Full taxonomy validation -
- 0 of 2 (0%) - None -
-
-
- Diagnostics, Maintenance, and Recovery - Needs review - Full taxonomy validation -
- 0 of 3 (0%) - None -
-
-
- Core Prompts and Context - Needs review - Full taxonomy validation -
- 0 of 2 (0%) - None -
-
-
- Memory - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Session Routing - Needs review - Full taxonomy validation -
- 0 of 2 (0%) - None -
-
-
- Transcript Persistence - Needs review - Full taxonomy validation -
- 0 of 2 (0%) - None -
-
-
- - -

8 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Channel Actions Commands and Approvals - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Channel Setup - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Group Thread and Ambient Room Behavior - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Inbound Access and Identity Gates - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Media Attachments and Rich Channel Data - Needs review - Full taxonomy validation -
- 0 of 4 (0%) - None -
-
-
- Outbound Delivery and Reply Pipeline - Needs review - Full taxonomy validation -
- 0 of 4 (0%) - None -
-
-
- Conversation Routing and Delivery - Needs review - Full taxonomy validation -
- 0 of 10 (0%) - None -
-
-
- Status Health and Operator Controls - Needs review - Full taxonomy validation -
- 0 of 4 (0%) - None -
-
-
- - -

6 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Approval Policy and Tool Safeguards - Needs review - Full taxonomy validation -
- 0 of 2 (0%) - None -
-
-
- Gateway Auth and Remote Access - Needs review - Full taxonomy validation -
- 0 of 9 (0%) - None -
-
-
- Channel Access Control - Needs review - Full taxonomy validation -
- 0 of 3 (0%) - None -
-
-
- Device and Node Pairing - Needs review - Full taxonomy validation -
- 0 of 11 (0%) - None -
-
-
- Plugin Trust - Needs review - Full taxonomy validation -
- 0 of 2 (0%) - None -
-
-
- Credential and Secret Hygiene - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- - -

5 partially reviewed

-
-
AreaCapabilitiesFollow-up
-
-
- Health and Repair - Partially reviewed - Full taxonomy validation -
- 1 of 12 (6%) - None -
-
-
- Logging - Partially reviewed - Full taxonomy validation -
- 0 of 5 (6%) - None -
-
-
- Diagnostic Collection - Partially reviewed - Full taxonomy validation -
- 0 of 8 (6%) - None -
-
-
- Telemetry Export - Partially reviewed - Full taxonomy validation -
- 1 of 13 (6%) - None -
-
-
- Session Diagnostics - Partially reviewed - Full taxonomy validation -
- 0 of 4 (6%) - None -
-
-
- - -

6 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Cron Jobs - Needs review - Full taxonomy validation -
- 0 of 15 (0%) - None -
-
-
- Event Ingress - Needs review - Full taxonomy validation -
- 0 of 15 (0%) - None -
-
-
- Automation Hooks - Needs review - Full taxonomy validation -
- 0 of 11 (0%) - None -
-
-
- Background Tasks and Flows - Needs review - Full taxonomy validation -
- 0 of 10 (0%) - None -
-
-
- Heartbeat - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Polling Controls - Needs review - Full taxonomy validation -
- 0 of 10 (0%) - None -
-
-
- - -

6 partially reviewed

-
-
AreaCapabilitiesFollow-up
-
-
- Media Intake and Access - Partially reviewed - Full taxonomy validation -
- 0 of 8 (1%) - None -
-
-
- Channel Media Handling - Partially reviewed - Full taxonomy validation -
- 0 of 5 (1%) - None -
-
-
- Media Configuration - Partially reviewed - Full taxonomy validation -
- 0 of 1 (1%) - None -
-
-
- Text-to-Speech Delivery - Partially reviewed - Full taxonomy validation -
- 0 of 2 (1%) - None -
-
-
- Media Understanding - Partially reviewed - Full taxonomy validation -
- 0 of 12 (1%) - None -
-
-
- Media Generation - Partially reviewed - Full taxonomy validation -
- 0 of 17 (1%) - None -
-
-
- - -

6 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Talk Providers - Needs review - Full taxonomy validation -
- 0 of 7 (0%) - None -
-
-
- Realtime Talk Sessions - Needs review - Full taxonomy validation -
- 0 of 11 (0%) - None -
-
-
- Speech and Transcription - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Native App Talk - Needs review - Full taxonomy validation -
- 0 of 4 (0%) - None -
-
-
- Voice Wake and Routing - Needs review - Full taxonomy validation -
- 0 of 4 (0%) - None -
-
-
- Talk Observability - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- - -

6 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Browser Realtime Talk - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Browser Access and Trust - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Configuration - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Browser UI - Needs review - Full taxonomy validation -
- 0 of 10 (0%) - None -
-
-
- WebChat Conversations - Needs review - Full taxonomy validation -
- 0 of 15 (0%) - None -
-
-
- Operator Console - Needs review - Full taxonomy validation -
- 0 of 10 (0%) - None -
-
-
- - -

5 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Runtime Modes - Needs review - Full taxonomy validation -
- 0 of 14 (0%) - None -
-
-
- Input and Commands - Needs review - Full taxonomy validation -
- 0 of 8 (0%) - None -
-
-
- Session Management - Needs review - Full taxonomy validation -
- 0 of 3 (0%) - None -
-
-
- Local Shell Execution - Needs review - Full taxonomy validation -
- 0 of 4 (0%) - None -
-
-
- Rendering and Output Safety - Needs review - Full taxonomy validation -
- 0 of 4 (0%) - None -
-
-
- - -

4 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Publishing - Needs review - Full taxonomy validation -
- 0 of 7 (0%) - None -
-
-
- Catalog Discovery - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Compatibility and Trust - Needs review - Full taxonomy validation -
- 0 of 12 (0%) - None -
-
-
- Plugin Lifecycle and Health - Needs review - Full taxonomy validation -
- 0 of 26 (0%) - None -
-
-
- - -

6 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Client API - Needs review - Full taxonomy validation -
- 0 of 4 (0%) - None -
-
-
- Gateway Access - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Agent Conversations - Needs review - Full taxonomy validation -
- 0 of 6 (0%) - None -
-
-
- Events and Approvals - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Resource Helpers - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Compatibility - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- - -

7 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- CLI Setup - Needs review - Full taxonomy validation -
- 0 of 4 (0%) - None -
-
-
- Local Gateway Integration - Needs review - Full taxonomy validation -
- 0 of 9 (0%) - None -
-
-
- Remote Gateway Mode - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Gateway Service Lifecycle - Needs review - Full taxonomy validation -
- 0 of 10 (0%) - None -
-
-
- Diagnostics and Observability - Needs review - Full taxonomy validation -
- 0 of 4 (0%) - None -
-
-
- Permissions and Native Capabilities - Needs review - Full taxonomy validation -
- 0 of 4 (0%) - None -
-
-
- Profiles and Isolation - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- - -

8 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Canvas - Needs review - Full taxonomy validation -
- 0 of 4 (0%) - None -
-
-
- Local Setup - Needs review - Full taxonomy validation -
- 0 of 7 (0%) - None -
-
-
- Status and Settings - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Native Capabilities - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Remote Connections - Needs review - Full taxonomy validation -
- 0 of 3 (0%) - None -
-
-
- Voice and Talk - Needs review - Full taxonomy validation -
- 0 of 3 (0%) - None -
-
-
- WebChat - Needs review - Full taxonomy validation -
- 0 of 3 (0%) - None -
-
-
- Remote WebChat - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- - -

5 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Host Setup and Updates - Needs review - Full taxonomy validation -
- 0 of 4 (0%) - None -
-
-
- Gateway Runtime and Service Control - Needs review - Full taxonomy validation -
- 0 of 6 (0%) - None -
-
-
- Remote Access and Security - Needs review - Full taxonomy validation -
- 0 of 6 (0%) - None -
-
-
- Diagnostics and Repair - Needs review - Full taxonomy validation -
- 0 of 4 (0%) - None -
-
-
- Deployment Targets - Needs review - Full taxonomy validation -
- 0 of 3 (0%) - None -
-
-
- - -

5 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- App Distribution - Needs review - Full taxonomy validation -
- 0 of 3 (0%) - None -
-
-
- Gateway Connectivity - Needs review - Full taxonomy validation -
- 0 of 4 (0%) - None -
-
-
- Chat and Sessions - Needs review - Full taxonomy validation -
- 0 of 3 (0%) - None -
-
-
- Desktop Capabilities - Needs review - Full taxonomy validation -
- 0 of 9 (0%) - None -
-
-
- Status and Diagnostics - Needs review - Full taxonomy validation -
- 0 of 7 (0%) - None -
-
-
- - -

6 partially reviewed

-
-
AreaCapabilitiesFollow-up
-
-
- WSL Setup - Partially reviewed - Full taxonomy validation -
- 0 of 6 (3%) - None -
-
-
- CLI - Partially reviewed - Full taxonomy validation -
- 0 of 8 (3%) - None -
-
-
- Gateway Service Lifecycle - Partially reviewed - Full taxonomy validation -
- 0 of 10 (3%) - None -
-
-
- Gateway Access and Exposure - Partially reviewed - Full taxonomy validation -
- 0 of 11 (3%) - None -
-
-
- Diagnostics and Repair - Partially reviewed - Full taxonomy validation -
- 0 of 6 (3%) - None -
-
-
- Browser and Control UI - Partially reviewed - Full taxonomy validation -
- 0 of 6 (3%) - None -
-
-
- - -

4 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- CLI - Needs review - Full taxonomy validation -
- 0 of 9 (0%) - None -
-
-
- Gateway Management - Needs review - Full taxonomy validation -
- 0 of 11 (0%) - None -
-
-
- Networking - Needs review - Full taxonomy validation -
- 0 of 4 (0%) - None -
-
-
- Updates - Needs review - Full taxonomy validation -
- 0 of 4 (0%) - None -
-
-
- - -

5 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Installation and Updates - Needs review - Full taxonomy validation -
- 0 of 4 (0%) - None -
-
-
- Gateway Connection - Needs review - Full taxonomy validation -
- 0 of 3 (0%) - None -
-
-
- Chat Sessions - Needs review - Full taxonomy validation -
- 0 of 2 (0%) - None -
-
-
- Status and Repair - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Desktop Tools and Permissions - Needs review - Full taxonomy validation -
- 0 of 10 (0%) - None + 0 of 6 (0%) / 6 of 12 (50%) + 6 capability gaps
@@ -2135,955 +881,62 @@ Open a surface to inspect the evidence state of each category. The list stays co

7 needs review

-
AreaCapabilitiesFollow-up
-
-
- Media Capture - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None -
-
-
- Mobile Chat - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None -
+
AreaFeatures / coverage IDsFollow-up
Connection Setup Needs review - Full taxonomy validation
- 0 of 1 (0%) - None -
-
-
- Distribution - Needs review - Full taxonomy validation -
- 0 of 3 (0%) - None -
-
-
- Settings - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None -
-
-
- Voice - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None + 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap
Device Runtime Needs review - Full taxonomy validation
- 0 of 2 (0%) - None -
-
-
- - -

8 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Media and Sharing - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None -
-
-
- Canvas and Screen - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None -
-
-
- Chat and Sessions - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None -
-
-
- Gateway Setup and Diagnostics - Needs review - Full taxonomy validation -
- 0 of 7 (0%) - None + 0 of 2 (0%) / 0 of 2 (0%) + 2 capability gaps
Distribution Needs review - Full taxonomy validation
- 0 of 1 (0%) - None + 0 of 3 (0%) / 0 of 3 (0%) + 3 capability gaps
- Device Commands + Media Capture Needs review - Full taxonomy validation
- 0 of 2 (0%) - None + 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap
- Notifications and Background + Mobile Chat Needs review - Full taxonomy validation
- 0 of 1 (0%) - None + 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ Settings + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap
Voice Needs review - Full taxonomy validation
- 0 of 1 (0%) - None -
-
-
- - -

5 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Delivery and Recovery - Needs review - Full taxonomy validation -
- 0 of 7 (0%) - None -
-
-
- Exec Approvals - Needs review - Full taxonomy validation -
- 0 of 3 (0%) - None -
-
-
- Distribution and Support - Needs review - Full taxonomy validation -
- 0 of 6 (0%) - None -
-
-
- Notifications and Replies - Needs review - Full taxonomy validation -
- 0 of 7 (0%) - None -
-
-
- Watch App UI - Needs review - Full taxonomy validation -
- 0 of 3 (0%) - None -
-
-
- - -

4 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Setup and Compatibility - Needs review - Full taxonomy validation -
- 0 of 12 (0%) - None -
-
-
- Remote Access and Auth - Needs review - Full taxonomy validation -
- 0 of 9 (0%) - None -
-
-
- Gateway Runtime - Needs review - Full taxonomy validation -
- 0 of 10 (0%) - None -
-
-
- Performance and Diagnostics - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- - -

4 partially reviewed

-
-
AreaCapabilitiesFollow-up
-
-
- Container Setup - Partially reviewed - Full taxonomy validation -
- 0 of 6 (5%) - None -
-
-
- Container Operations - Partially reviewed - Full taxonomy validation -
- 1 of 11 (5%) - None -
-
-
- Image Release and Validation - Partially reviewed - Full taxonomy validation -
- 0 of 5 (5%) - None -
-
-
- Agent Sandbox and Tooling - Partially reviewed - Full taxonomy validation -
- 0 of 3 (5%) - None -
-
-
- - -

4 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Deployment Setup - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Configuration and Secrets - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Access and Exposure - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Cluster Lifecycle - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- - -

5 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Install Handoff - Needs review - Full taxonomy validation -
- 0 of 4 (0%) - None -
-
-
- Plugin Lifecycle - Needs review - Full taxonomy validation -
- 0 of 4 (0%) - None -
-
-
- Activation and App UX - Needs review - Full taxonomy validation -
- 0 of 7 (0%) - None -
-
-
- Config and State - Needs review - Full taxonomy validation -
- 0 of 7 (0%) - None -
-
-
- Service Runtime and Guards - Needs review - Full taxonomy validation -
- 0 of 8 (0%) - None -
-
-
- - -

6 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Channel Setup and Operations - Needs review - Full taxonomy validation -
- 0 of 10 (0%) - None -
-
-
- Access and Identity - Needs review - Full taxonomy validation -
- 0 of 6 (0%) - None -
-
-
- Conversation Routing and Delivery - Needs review - Full taxonomy validation -
- 0 of 12 (0%) - None -
-
-
- Media and Rich Content - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None -
-
-
- Native Controls and Approvals - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Realtime Voice and Calls - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- - -

5 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Channel Setup and Operations - Needs review - Full taxonomy validation -
- 0 of 10 (0%) - None -
-
-
- Access and Identity - Needs review - Full taxonomy validation -
- 0 of 10 (0%) - None -
-
-
- Conversation Routing and Delivery - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None -
-
-
- Media and Rich Content - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None -
-
-
- Native Controls and Approvals - Needs review - Full taxonomy validation -
- 0 of 9 (0%) - None -
-
-
- - -

5 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Channel Setup and Operations - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Access and Identity - Needs review - Full taxonomy validation -
- 0 of 7 (0%) - None -
-
-
- Conversation Routing and Delivery - Needs review - Full taxonomy validation -
- 0 of 4 (0%) - None -
-
-
- Media and Rich Content - Needs review - Full taxonomy validation -
- 0 of 2 (0%) - None -
-
-
- Native Controls and Approvals - Needs review - Full taxonomy validation -
- 0 of 2 (0%) - None -
-
-
- - -

5 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Channel Setup and Operations - Needs review - Full taxonomy validation -
- 0 of 10 (0%) - None -
-
-
- Access and Identity - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None -
-
-
- Conversation Routing and Delivery - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Media and Rich Content - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None -
-
-
- Native Controls and Approvals - Needs review - Full taxonomy validation -
- 0 of 8 (0%) - None -
-
-
- - -

5 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Channel Setup and Operations - Needs review - Full taxonomy validation -
- 0 of 11 (0%) - None -
-
-
- Access and Identity - Needs review - Full taxonomy validation -
- 0 of 6 (0%) - None -
-
-
- Conversation Routing and Delivery - Needs review - Full taxonomy validation -
- 0 of 4 (0%) - None -
-
-
- Media and Rich Content - Needs review - Full taxonomy validation -
- 0 of 7 (0%) - None -
-
-
- Native Controls and Approvals - Needs review - Full taxonomy validation -
- 0 of 3 (0%) - None -
-
-
- - -

5 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Channel Setup and Operations - Needs review - Full taxonomy validation -
- 0 of 7 (0%) - None -
-
-
- Access and Identity - Needs review - Full taxonomy validation -
- 0 of 6 (0%) - None -
-
-
- Conversation Routing and Delivery - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None -
-
-
- Media and Rich Content - Needs review - Full taxonomy validation -
- 0 of 7 (0%) - None -
-
-
- Native Controls and Approvals - Needs review - Full taxonomy validation -
- 0 of 3 (0%) - None -
-
-
- - -

5 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Channel Setup and Operations - Needs review - Full taxonomy validation -
- 0 of 16 (0%) - None -
-
-
- Access and Identity - Needs review - Full taxonomy validation -
- 0 of 11 (0%) - None -
-
-
- Conversation Routing and Delivery - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None -
-
-
- Media and Rich Content - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None -
-
-
- Native Controls and Approvals - Needs review - Full taxonomy validation -
- 0 of 16 (0%) - None -
-
-
- - -

6 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Channel Setup and Operations - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Access and Identity - Needs review - Full taxonomy validation -
- 0 of 7 (0%) - None -
-
-
- Conversation Routing and Delivery - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None -
-
-
- Media and Rich Content - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None -
-
-
- Native Controls and Approvals - Needs review - Full taxonomy validation -
- 0 of 6 (0%) - None -
-
-
- Encryption and Verification - Needs review - Full taxonomy validation -
- 0 of 3 (0%) - None -
-
-
- - -

5 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Channel Setup and Operations - Needs review - Full taxonomy validation -
- 0 of 9 (0%) - None -
-
-
- Access and Identity - Needs review - Full taxonomy validation -
- 0 of 9 (0%) - None -
-
-
- Conversation Routing and Delivery - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Media and Rich Content - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Native Controls and Approvals - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- - -

4 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Channel Setup and Operations - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None -
-
-
- Access and Identity - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None -
-
-
- Conversation Routing and Delivery - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None -
-
-
- Media and Rich Content - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None -
-
-
- - -

4 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Channel Setup and Operations - Needs review - Full taxonomy validation -
- 0 of 6 (0%) - None -
-
-
- Access and Identity - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None -
-
-
- Conversation Routing and Delivery - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None -
-
-
- Media and Rich Content - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None -
-
-
- - -

5 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Channel Setup and Operations - Needs review - Full taxonomy validation -
- 0 of 2 (0%) - None -
-
-
- Access and Identity - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None -
-
-
- Conversation Routing and Delivery - Needs review - Full taxonomy validation -
- 0 of 1 (0%) - None -
-
-
- Media and Rich Content - Needs review - Full taxonomy validation -
- 0 of 2 (0%) - None -
-
-
- Realtime Voice and Calls - Needs review - Full taxonomy validation -
- 0 of 2 (0%) - None -
-
-
- - -

5 partially reviewed

-
-
AreaCapabilitiesFollow-up
-
-
- Model and Auth - Partially reviewed - Full taxonomy validation -
- 0 of 6 (8%) - None -
-
-
- Responses and Tool Compatibility - Partially reviewed - Full taxonomy validation -
- 0 of 4 (8%) - None -
-
-
- Native Codex Harness - Partially reviewed - Full taxonomy validation -
- 0 of 2 (8%) - None -
-
-
- Image and Multimodal Input - Partially reviewed - Full taxonomy validation -
- 0 of 2 (8%) - None -
-
-
- Voice and Realtime Audio - Partially reviewed - Full taxonomy validation -
- 0 of 2 (8%) - None + 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap
@@ -3091,46 +944,651 @@ Open a surface to inspect the evidence state of each category. The list stays co

5 needs review

-
AreaCapabilitiesFollow-up
+
AreaFeatures / coverage IDsFollow-up
- Provider Auth and Recovery + Media Inputs Needs review - Full taxonomy validation
- 0 of 9 (0%) - None + 0 of 4 (0%) / 0 of 4 (0%) + 4 capability gaps
Model and Runtime Selection Needs review - Full taxonomy validation
- 0 of 10 (0%) - None -
-
-
- Request Transport and Turn Semantics - Needs review - Full taxonomy validation -
- 0 of 10 (0%) - None + 0 of 10 (0%) / 0 of 12 (0%) + 12 capability gaps
Prompt Cache and Context Needs review - Full taxonomy validation
- 0 of 5 (0%) - None + 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps
- Media Inputs + Provider Auth and Recovery Needs review - Full taxonomy validation
- 0 of 4 (0%) - None + 0 of 9 (0%) / 0 of 9 (0%) + 9 capability gaps +
+
+
+ Request Transport and Turn Semantics + Needs review - Full taxonomy validation +
+ 0 of 10 (0%) / 0 of 10 (0%) + 10 capability gaps +
+
+
+ + +

5 needs review / 1 partially reviewed

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Automation Hooks + Needs review - Full taxonomy validation +
+ 0 of 11 (0%) / 0 of 11 (0%) + 11 capability gaps +
+
+
+ Background Tasks and Flows + Needs review - Full taxonomy validation +
+ 0 of 10 (0%) / 0 of 10 (0%) + 10 capability gaps +
+
+
+ Cron Jobs + Needs review - Full taxonomy validation +
+ 0 of 15 (0%) / 0 of 15 (0%) + 15 capability gaps +
+
+
+ Event Ingress + Needs review - Full taxonomy validation +
+ 0 of 15 (0%) / 0 of 15 (0%) + 15 capability gaps +
+
+
+ Heartbeat + Partially reviewed - Full taxonomy validation +
+ 0 of 5 (0%) / 1 of 7 (14.3%) + 6 capability gaps +
+
+
+ Polling Controls + Needs review - Full taxonomy validation +
+ 0 of 10 (0%) / 0 of 10 (0%) + 10 capability gaps +
+
+
+ + +

2 partially reviewed / 1 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Browser Automation + Partially reviewed - Full taxonomy validation +
+ 1 of 8 (12.5%) / 1 of 8 (12.5%) + 7 capability gaps +
+
+
+ Sandbox and Tool Policy + Needs review - Full taxonomy validation +
+ 0 of 6 (0%) / 0 of 6 (0%) + 6 capability gaps +
+
+
+ Tool Invocation and Execution + Partially reviewed - Full taxonomy validation +
+ 2 of 6 (33.3%) / 4 of 8 (50%) + 4 capability gaps +
+
+
+ + +

3 needs review / 3 partially reviewed

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Browser Access and Trust + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Browser Realtime Talk + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Browser UI + Partially reviewed - Full taxonomy validation +
+ 0 of 10 (0%) / 1 of 12 (8.3%) + 11 capability gaps +
+
+
+ Configuration + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Operator Console + Partially reviewed - Full taxonomy validation +
+ 0 of 10 (0%) / 1 of 12 (8.3%) + 11 capability gaps +
+
+
+ WebChat Conversations + Partially reviewed - Full taxonomy validation +
+ 0 of 15 (0%) / 2 of 20 (10%) + 18 capability gaps +
+
+
+ + +

4 needs review / 4 partially reviewed

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Channel Actions Commands and Approvals + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Channel Setup + Partially reviewed - Full taxonomy validation +
+ 0 of 5 (0%) / 1 of 7 (14.3%) + 6 capability gaps +
+
+
+ Conversation Routing and Delivery + Partially reviewed - Full taxonomy validation +
+ 0 of 10 (0%) / 5 of 27 (18.5%) + 22 capability gaps +
+
+
+ Group Thread and Ambient Room Behavior + Partially reviewed - Full taxonomy validation +
+ 0 of 5 (0%) / 4 of 11 (36.4%) + 7 capability gaps +
+
+
+ Inbound Access and Identity Gates + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Media Attachments and Rich Channel Data + Needs review - Full taxonomy validation +
+ 0 of 4 (0%) / 0 of 4 (0%) + 4 capability gaps +
+
+
+ Outbound Delivery and Reply Pipeline + Partially reviewed - Full taxonomy validation +
+ 0 of 4 (0%) / 8 of 21 (38.1%) + 13 capability gaps +
+
+
+ Status Health and Operator Controls + Needs review - Full taxonomy validation +
+ 0 of 4 (0%) / 0 of 6 (0%) + 6 capability gaps +
+
+
+ + +

4 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Catalog Discovery + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Compatibility and Trust + Needs review - Full taxonomy validation +
+ 0 of 12 (0%) / 0 of 12 (0%) + 12 capability gaps +
+
+
+ Plugin Lifecycle and Health + Needs review - Full taxonomy validation +
+ 0 of 26 (0%) / 0 of 26 (0%) + 26 capability gaps +
+
+
+ Publishing + Needs review - Full taxonomy validation +
+ 0 of 7 (0%) / 0 of 7 (0%) + 7 capability gaps +
+
+
+ + +

5 needs review / 2 partially reviewed

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ CLI Observability + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ CLI Setup + Partially reviewed - Full taxonomy validation +
+ 1 of 6 (16.7%) / 1 of 6 (16.7%) + 5 capability gaps +
+
+
+ Doctor + Needs review - Full taxonomy validation +
+ 0 of 10 (0%) / 0 of 10 (0%) + 10 capability gaps +
+
+
+ Gateway Service Management + Partially reviewed - Full taxonomy validation +
+ 0 of 5 (0%) / 1 of 7 (14.3%) + 6 capability gaps +
+
+
+ Onboarding and Auth Setup + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Plugin and Channel Setup + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Updates and Upgrades + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ + +

6 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Access and Identity + Needs review - Full taxonomy validation +
+ 0 of 6 (0%) / 0 of 6 (0%) + 6 capability gaps +
+
+
+ Channel Setup and Operations + Needs review - Full taxonomy validation +
+ 0 of 10 (0%) / 0 of 10 (0%) + 10 capability gaps +
+
+
+ Conversation Routing and Delivery + Needs review - Full taxonomy validation +
+ 0 of 12 (0%) / 0 of 12 (0%) + 12 capability gaps +
+
+
+ Media and Rich Content + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ Native Controls and Approvals + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Realtime Voice and Calls + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ + +

3 needs review / 1 partially reviewed

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Agent Sandbox and Tooling + Needs review - Full taxonomy validation +
+ 0 of 3 (0%) / 0 of 3 (0%) + 3 capability gaps +
+
+
+ Container Operations + Needs review - Full taxonomy validation +
+ 0 of 11 (0%) / 0 of 11 (0%) + 11 capability gaps +
+
+
+ Container Setup + Needs review - Full taxonomy validation +
+ 0 of 6 (0%) / 0 of 6 (0%) + 6 capability gaps +
+
+
+ Image Release and Validation + Partially reviewed - Full taxonomy validation +
+ 1 of 5 (20%) / 2 of 7 (28.6%) + 5 capability gaps +
+
+
+ + +

4 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Access and Identity + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ Channel Setup and Operations + Needs review - Full taxonomy validation +
+ 0 of 6 (0%) / 0 of 6 (0%) + 6 capability gaps +
+
+
+ Conversation Routing and Delivery + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ Media and Rich Content + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ + +

9 needs review / 4 partially reviewed

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Approvals and Remote Execution + Needs review - Full taxonomy validation +
+ 0 of 6 (0%) / 0 of 6 (0%) + 6 capability gaps +
+
+
+ Device Auth and Pairing + Needs review - Full taxonomy validation +
+ 0 of 10 (0%) / 0 of 10 (0%) + 10 capability gaps +
+
+
+ Gateway Lifecycle + Partially reviewed - Full taxonomy validation +
+ 0 of 7 (0%) / 4 of 12 (33.3%) + 8 capability gaps +
+
+
+ Gateway RPC APIs and Events + Partially reviewed - Full taxonomy validation +
+ 0 of 20 (0%) / 2 of 22 (9.1%) + 20 capability gaps +
+
+
+ Health, Diagnostics, and Repair + Needs review - Full taxonomy validation +
+ 0 of 7 (0%) / 0 of 7 (0%) + 7 capability gaps +
+
+
+ Hosted Web Surface + Needs review - Full taxonomy validation +
+ 0 of 4 (0%) / 0 of 4 (0%) + 4 capability gaps +
+
+
+ HTTP APIs + Partially reviewed - Full taxonomy validation +
+ 1 of 4 (25%) / 1 of 4 (25%) + 3 capability gaps +
+
+
+ Network Access and Discovery + Needs review - Full taxonomy validation +
+ 0 of 6 (0%) / 0 of 6 (0%) + 6 capability gaps +
+
+
+ Nodes and Remote Capabilities + Needs review - Full taxonomy validation +
+ 0 of 8 (0%) / 0 of 8 (0%) + 8 capability gaps +
+
+
+ Protocol Compatibility + Needs review - Full taxonomy validation +
+ 0 of 7 (0%) / 0 of 7 (0%) + 7 capability gaps +
+
+
+ Roles and Permissions + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Security Controls + Needs review - Full taxonomy validation +
+ 0 of 6 (0%) / 0 of 6 (0%) + 6 capability gaps +
+
+
+ WebSocket Connection + Partially reviewed - Full taxonomy validation +
+ 1 of 8 (12.5%) / 1 of 8 (12.5%) + 7 capability gaps +
+
+
+ + +

5 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Access and Identity + Needs review - Full taxonomy validation +
+ 0 of 11 (0%) / 0 of 11 (0%) + 11 capability gaps +
+
+
+ Channel Setup and Operations + Needs review - Full taxonomy validation +
+ 0 of 16 (0%) / 0 of 16 (0%) + 16 capability gaps +
+
+
+ Conversation Routing and Delivery + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ Media and Rich Content + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ Native Controls and Approvals + Needs review - Full taxonomy validation +
+ 0 of 16 (0%) / 0 of 16 (0%) + 16 capability gaps
@@ -3138,233 +1596,46 @@ Open a surface to inspect the evidence state of each category. The list stays co

5 needs review

-
AreaCapabilitiesFollow-up
-
-
- Provider Setup and Credentials - Needs review - Full taxonomy validation -
- 0 of 10 (0%) - None -
-
-
- Model Routing and Endpoints - Needs review - Full taxonomy validation -
- 0 of 10 (0%) - None -
+
AreaFeatures / coverage IDsFollow-up
Direct Gemini Runtime Needs review - Full taxonomy validation
- 0 of 9 (0%) - None + 0 of 9 (0%) / 0 of 9 (0%) + 9 capability gaps
Media, Search, and Realtime Needs review - Full taxonomy validation
- 0 of 10 (0%) - None + 0 of 10 (0%) / 0 of 10 (0%) + 10 capability gaps +
+
+
+ Model Routing and Endpoints + Needs review - Full taxonomy validation +
+ 0 of 10 (0%) / 0 of 10 (0%) + 10 capability gaps
Prompt Caching Needs review - Full taxonomy validation
- 0 of 5 (0%) - None + 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps
-
-
- - -

4 needs review

-
-
AreaCapabilitiesFollow-up
- Provider Setup and Auth + Provider Setup and Credentials Needs review - Full taxonomy validation
- 0 of 14 (0%) - None -
-
-
- Chat Runtime and Normalization - Needs review - Full taxonomy validation -
- 0 of 15 (0%) - None -
-
-
- Provider Recovery and Diagnostics - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Media Generation and Speech - Needs review - Full taxonomy validation -
- 0 of 7 (0%) - None -
-
-
- - -

5 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Provider Setup, Lifecycle, and Diagnostics - Needs review - Full taxonomy validation -
- 0 of 12 (0%) - None -
-
-
- Native Provider Plugins - Needs review - Full taxonomy validation -
- 0 of 10 (0%) - None -
-
-
- OpenAI-Compatible Runtime Compatibility - Needs review - Full taxonomy validation -
- 0 of 8 (0%) - None -
-
-
- Local Memory and Embeddings - Needs review - Full taxonomy validation -
- 0 of 5 (0%) - None -
-
-
- Network Safety and Prompt Controls - Needs review - Full taxonomy validation -
- 0 of 2 (0%) - None -
-
-
- - -

3 needs review

-
-
AreaCapabilitiesFollow-up
-
-
- Hosted LLM Providers - Needs review - Full taxonomy validation -
- 0 of 12 (0%) - None -
-
-
- Hosted Media Providers - Needs review - Full taxonomy validation -
- 0 of 8 (0%) - None -
-
-
- Provider Operations - Needs review - Full taxonomy validation -
- 0 of 12 (0%) - None -
-
-
- - -

4 partially reviewed

-
-
AreaCapabilitiesFollow-up
-
-
- Search Providers - Partially reviewed - Full taxonomy validation -
- 1 of 19 (7%) - None -
-
-
- Setup and Diagnostics - Partially reviewed - Full taxonomy validation -
- 1 of 9 (7%) - None -
-
-
- Network Safety - Partially reviewed - Full taxonomy validation -
- 0 of 4 (7%) - None -
-
-
- Tool Availability and Fetch - Partially reviewed - Full taxonomy validation -
- 1 of 11 (7%) - None -
-
-
- - -

3 partially reviewed

-
-
AreaCapabilitiesFollow-up
-
-
- Browser Automation - Partially reviewed - Full taxonomy validation -
- 1 of 8 (15%) - None -
-
-
- Tool Invocation and Execution - Partially reviewed - Full taxonomy validation -
- 1 of 6 (15%) - None -
-
-
- Sandbox and Tool Policy - Partially reviewed - Full taxonomy validation -
- 1 of 6 (15%) - None + 0 of 10 (0%) / 0 of 10 (0%) + 10 capability gaps
@@ -3372,46 +1643,1756 @@ Open a surface to inspect the evidence state of each category. The list stays co

5 needs review

-
AreaCapabilitiesFollow-up
-
-
- Media Routing and Discovery - Needs review - Full taxonomy validation -
- 0 of 4 (0%) - None -
-
-
- Task Lifecycle and Delivery - Needs review - Full taxonomy validation -
- 0 of 12 (0%) - None -
+
AreaFeatures / coverage IDsFollow-up
Image Generation Needs review - Full taxonomy validation
- 0 of 9 (0%) - None + 0 of 9 (0%) / 0 of 9 (0%) + 9 capability gaps
- Video Generation + Media Routing and Discovery Needs review - Full taxonomy validation
- 0 of 11 (0%) - None + 0 of 4 (0%) / 0 of 4 (0%) + 4 capability gaps
Music Generation Needs review - Full taxonomy validation
- 0 of 6 (0%) - None + 0 of 6 (0%) / 0 of 6 (0%) + 6 capability gaps +
+
+
+ Task Lifecycle and Delivery + Needs review - Full taxonomy validation +
+ 0 of 12 (0%) / 0 of 12 (0%) + 12 capability gaps +
+
+
+ Video Generation + Needs review - Full taxonomy validation +
+ 0 of 11 (0%) / 0 of 11 (0%) + 11 capability gaps +
+
+
+ + +

5 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Access and Identity + Needs review - Full taxonomy validation +
+ 0 of 6 (0%) / 0 of 6 (0%) + 6 capability gaps +
+
+
+ Channel Setup and Operations + Needs review - Full taxonomy validation +
+ 0 of 11 (0%) / 0 of 11 (0%) + 11 capability gaps +
+
+
+ Conversation Routing and Delivery + Needs review - Full taxonomy validation +
+ 0 of 4 (0%) / 0 of 4 (0%) + 4 capability gaps +
+
+
+ Media and Rich Content + Needs review - Full taxonomy validation +
+ 0 of 7 (0%) / 0 of 7 (0%) + 7 capability gaps +
+
+
+ Native Controls and Approvals + Needs review - Full taxonomy validation +
+ 0 of 3 (0%) / 0 of 3 (0%) + 3 capability gaps +
+
+
+ + +

8 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Canvas and Screen + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ Chat and Sessions + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ Device Commands + Needs review - Full taxonomy validation +
+ 0 of 2 (0%) / 0 of 2 (0%) + 2 capability gaps +
+
+
+ Distribution + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ Gateway Setup and Diagnostics + Needs review - Full taxonomy validation +
+ 0 of 7 (0%) / 0 of 7 (0%) + 7 capability gaps +
+
+
+ Media and Sharing + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ Notifications and Background + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ Voice + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ + +

4 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Access and Exposure + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Cluster Lifecycle + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Configuration and Secrets + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Deployment Setup + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ + +

5 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ App Distribution + Needs review - Full taxonomy validation +
+ 0 of 3 (0%) / 0 of 3 (0%) + 3 capability gaps +
+
+
+ Chat and Sessions + Needs review - Full taxonomy validation +
+ 0 of 3 (0%) / 0 of 3 (0%) + 3 capability gaps +
+
+
+ Desktop Capabilities + Needs review - Full taxonomy validation +
+ 0 of 9 (0%) / 0 of 9 (0%) + 9 capability gaps +
+
+
+ Gateway Connectivity + Needs review - Full taxonomy validation +
+ 0 of 4 (0%) / 0 of 4 (0%) + 4 capability gaps +
+
+
+ Status and Diagnostics + Needs review - Full taxonomy validation +
+ 0 of 7 (0%) / 0 of 7 (0%) + 7 capability gaps +
+
+
+ + +

5 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Deployment Targets + Needs review - Full taxonomy validation +
+ 0 of 3 (0%) / 0 of 3 (0%) + 3 capability gaps +
+
+
+ Diagnostics and Repair + Needs review - Full taxonomy validation +
+ 0 of 4 (0%) / 0 of 4 (0%) + 4 capability gaps +
+
+
+ Gateway Runtime and Service Control + Needs review - Full taxonomy validation +
+ 0 of 6 (0%) / 0 of 6 (0%) + 6 capability gaps +
+
+
+ Host Setup and Updates + Needs review - Full taxonomy validation +
+ 0 of 4 (0%) / 0 of 4 (0%) + 4 capability gaps +
+
+
+ Remote Access and Security + Needs review - Full taxonomy validation +
+ 0 of 6 (0%) / 0 of 6 (0%) + 6 capability gaps +
+
+
+ + +

5 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Local Memory and Embeddings + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Native Provider Plugins + Needs review - Full taxonomy validation +
+ 0 of 10 (0%) / 0 of 10 (0%) + 10 capability gaps +
+
+
+ Network Safety and Prompt Controls + Needs review - Full taxonomy validation +
+ 0 of 2 (0%) / 0 of 2 (0%) + 2 capability gaps +
+
+
+ OpenAI-Compatible Runtime Compatibility + Needs review - Full taxonomy validation +
+ 0 of 8 (0%) / 0 of 8 (0%) + 8 capability gaps +
+
+
+ Provider Setup, Lifecycle, and Diagnostics + Needs review - Full taxonomy validation +
+ 0 of 12 (0%) / 0 of 12 (0%) + 12 capability gaps +
+
+
+ + +

3 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Hosted LLM Providers + Needs review - Full taxonomy validation +
+ 0 of 12 (0%) / 0 of 12 (0%) + 12 capability gaps +
+
+
+ Hosted Media Providers + Needs review - Full taxonomy validation +
+ 0 of 8 (0%) / 0 of 8 (0%) + 8 capability gaps +
+
+
+ Provider Operations + Needs review - Full taxonomy validation +
+ 0 of 12 (0%) / 0 of 12 (0%) + 12 capability gaps +
+
+
+ + +

8 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Canvas + Needs review - Full taxonomy validation +
+ 0 of 4 (0%) / 0 of 4 (0%) + 4 capability gaps +
+
+
+ Local Setup + Needs review - Full taxonomy validation +
+ 0 of 7 (0%) / 0 of 7 (0%) + 7 capability gaps +
+
+
+ Native Capabilities + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Remote Connections + Needs review - Full taxonomy validation +
+ 0 of 3 (0%) / 0 of 3 (0%) + 3 capability gaps +
+
+
+ Remote WebChat + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Status and Settings + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Voice and Talk + Needs review - Full taxonomy validation +
+ 0 of 3 (0%) / 0 of 3 (0%) + 3 capability gaps +
+
+
+ WebChat + Needs review - Full taxonomy validation +
+ 0 of 3 (0%) / 0 of 3 (0%) + 3 capability gaps +
+
+
+ + +

7 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ CLI Setup + Needs review - Full taxonomy validation +
+ 0 of 4 (0%) / 0 of 4 (0%) + 4 capability gaps +
+
+
+ Diagnostics and Observability + Needs review - Full taxonomy validation +
+ 0 of 4 (0%) / 0 of 4 (0%) + 4 capability gaps +
+
+
+ Gateway Service Lifecycle + Needs review - Full taxonomy validation +
+ 0 of 10 (0%) / 0 of 10 (0%) + 10 capability gaps +
+
+
+ Local Gateway Integration + Needs review - Full taxonomy validation +
+ 0 of 9 (0%) / 0 of 9 (0%) + 9 capability gaps +
+
+
+ Permissions and Native Capabilities + Needs review - Full taxonomy validation +
+ 0 of 4 (0%) / 0 of 4 (0%) + 4 capability gaps +
+
+
+ Profiles and Isolation + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Remote Gateway Mode + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ + +

6 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Access and Identity + Needs review - Full taxonomy validation +
+ 0 of 7 (0%) / 0 of 7 (0%) + 7 capability gaps +
+
+
+ Channel Setup and Operations + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Conversation Routing and Delivery + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ Encryption and Verification + Needs review - Full taxonomy validation +
+ 0 of 3 (0%) / 0 of 3 (0%) + 3 capability gaps +
+
+
+ Media and Rich Content + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ Native Controls and Approvals + Needs review - Full taxonomy validation +
+ 0 of 6 (0%) / 0 of 6 (0%) + 6 capability gaps +
+
+
+ + +

4 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Access and Identity + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ Channel Setup and Operations + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ Conversation Routing and Delivery + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ Media and Rich Content + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ + +

4 needs review / 2 partially reviewed

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Channel Media Handling + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Media Configuration + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ Media Generation + Partially reviewed - Full taxonomy validation +
+ 1 of 17 (5.9%) / 1 of 19 (5.3%) + 18 capability gaps +
+
+
+ Media Intake and Access + Needs review - Full taxonomy validation +
+ 0 of 8 (0%) / 0 of 8 (0%) + 8 capability gaps +
+
+
+ Media Understanding + Partially reviewed - Full taxonomy validation +
+ 0 of 12 (0%) / 1 of 14 (7.1%) + 13 capability gaps +
+
+
+ Text-to-Speech Delivery + Needs review - Full taxonomy validation +
+ 0 of 2 (0%) / 0 of 2 (0%) + 2 capability gaps +
+
+
+ + +

5 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Access and Identity + Needs review - Full taxonomy validation +
+ 0 of 9 (0%) / 0 of 9 (0%) + 9 capability gaps +
+
+
+ Channel Setup and Operations + Needs review - Full taxonomy validation +
+ 0 of 9 (0%) / 0 of 9 (0%) + 9 capability gaps +
+
+
+ Conversation Routing and Delivery + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Media and Rich Content + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Native Controls and Approvals + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ + +

4 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ CLI + Needs review - Full taxonomy validation +
+ 0 of 9 (0%) / 0 of 9 (0%) + 9 capability gaps +
+
+
+ Gateway Management + Needs review - Full taxonomy validation +
+ 0 of 11 (0%) / 0 of 11 (0%) + 11 capability gaps +
+
+
+ Networking + Needs review - Full taxonomy validation +
+ 0 of 4 (0%) / 0 of 4 (0%) + 4 capability gaps +
+
+
+ Updates + Needs review - Full taxonomy validation +
+ 0 of 4 (0%) / 0 of 4 (0%) + 4 capability gaps +
+
+
+ + +

5 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Chat Sessions + Needs review - Full taxonomy validation +
+ 0 of 2 (0%) / 0 of 2 (0%) + 2 capability gaps +
+
+
+ Desktop Tools and Permissions + Needs review - Full taxonomy validation +
+ 0 of 10 (0%) / 0 of 10 (0%) + 10 capability gaps +
+
+
+ Gateway Connection + Needs review - Full taxonomy validation +
+ 0 of 3 (0%) / 0 of 3 (0%) + 3 capability gaps +
+
+
+ Installation and Updates + Needs review - Full taxonomy validation +
+ 0 of 4 (0%) / 0 of 4 (0%) + 4 capability gaps +
+
+
+ Status and Repair + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ + +

5 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Activation and App UX + Needs review - Full taxonomy validation +
+ 0 of 7 (0%) / 0 of 7 (0%) + 7 capability gaps +
+
+
+ Config and State + Needs review - Full taxonomy validation +
+ 0 of 7 (0%) / 0 of 7 (0%) + 7 capability gaps +
+
+
+ Install Handoff + Needs review - Full taxonomy validation +
+ 0 of 4 (0%) / 0 of 4 (0%) + 4 capability gaps +
+
+
+ Plugin Lifecycle + Needs review - Full taxonomy validation +
+ 0 of 4 (0%) / 0 of 4 (0%) + 4 capability gaps +
+
+
+ Service Runtime and Guards + Needs review - Full taxonomy validation +
+ 0 of 8 (0%) / 0 of 8 (0%) + 8 capability gaps +
+
+
+ + +

2 needs review / 3 partially reviewed

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Image and Multimodal Input + Needs review - Full taxonomy validation +
+ 0 of 2 (0%) / 0 of 2 (0%) + 2 capability gaps +
+
+
+ Model and Auth + Partially reviewed - Full taxonomy validation +
+ 1 of 6 (16.7%) / 4 of 9 (44.4%) + 5 capability gaps +
+
+
+ Native Codex Harness + Partially reviewed - Full taxonomy validation +
+ 0 of 2 (0%) / 4 of 9 (44.4%) + 5 capability gaps +
+
+
+ Responses and Tool Compatibility + Partially reviewed - Full taxonomy validation +
+ 1 of 4 (25%) / 2 of 5 (40%) + 3 capability gaps +
+
+
+ Voice and Realtime Audio + Needs review - Full taxonomy validation +
+ 0 of 2 (0%) / 0 of 2 (0%) + 2 capability gaps +
+
+
+ + +

5 needs review / 1 partially reviewed

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Agent Conversations + Needs review - Full taxonomy validation +
+ 0 of 6 (0%) / 0 of 6 (0%) + 6 capability gaps +
+
+
+ Client API + Needs review - Full taxonomy validation +
+ 0 of 4 (0%) / 0 of 4 (0%) + 4 capability gaps +
+
+
+ Compatibility + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Events and Approvals + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Gateway Access + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Resource Helpers + Partially reviewed - Full taxonomy validation +
+ 0 of 5 (0%) / 1 of 6 (16.7%) + 5 capability gaps +
+
+
+ + +

4 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Chat Runtime and Normalization + Needs review - Full taxonomy validation +
+ 0 of 15 (0%) / 0 of 15 (0%) + 15 capability gaps +
+
+
+ Media Generation and Speech + Needs review - Full taxonomy validation +
+ 0 of 7 (0%) / 0 of 7 (0%) + 7 capability gaps +
+
+
+ Provider Recovery and Diagnostics + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Provider Setup and Auth + Needs review - Full taxonomy validation +
+ 0 of 14 (0%) / 0 of 14 (0%) + 14 capability gaps +
+
+
+ + +

6 needs review / 3 partially reviewed

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Authoring and Packaging plugins + Needs review - Full taxonomy validation +
+ 0 of 8 (0%) / 0 of 8 (0%) + 8 capability gaps +
+
+
+ Bundled plugins + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Canvas plugin + Needs review - Full taxonomy validation +
+ 0 of 6 (0%) / 0 of 6 (0%) + 6 capability gaps +
+
+
+ Channel plugins + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Installing and running plugins + Partially reviewed - Full taxonomy validation +
+ 0 of 6 (0%) / 7 of 20 (35%) + 13 capability gaps +
+
+
+ Plugin approvals + Needs review - Full taxonomy validation +
+ 0 of 6 (0%) / 0 of 6 (0%) + 6 capability gaps +
+
+
+ Provider and tool plugins + Partially reviewed - Full taxonomy validation +
+ 1 of 6 (16.7%) / 9 of 21 (42.9%) + 12 capability gaps +
+
+
+ Publishing plugins + Needs review - Full taxonomy validation +
+ 0 of 6 (0%) / 0 of 6 (0%) + 6 capability gaps +
+
+
+ Testing plugins + Partially reviewed - Full taxonomy validation +
+ 0 of 6 (0%) / 3 of 11 (27.3%) + 8 capability gaps +
+
+
+ + +

4 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Gateway Runtime + Needs review - Full taxonomy validation +
+ 0 of 10 (0%) / 0 of 10 (0%) + 10 capability gaps +
+
+
+ Performance and Diagnostics + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Remote Access and Auth + Needs review - Full taxonomy validation +
+ 0 of 9 (0%) / 0 of 9 (0%) + 9 capability gaps +
+
+
+ Setup and Compatibility + Needs review - Full taxonomy validation +
+ 0 of 12 (0%) / 0 of 12 (0%) + 12 capability gaps +
+
+
+ + +

2 partially reviewed / 4 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Approval Policy and Tool Safeguards + Partially reviewed - Full taxonomy validation +
+ 0 of 2 (0%) / 3 of 6 (50%) + 3 capability gaps +
+
+
+ Channel Access Control + Needs review - Full taxonomy validation +
+ 0 of 3 (0%) / 0 of 3 (0%) + 3 capability gaps +
+
+
+ Credential and Secret Hygiene + Partially reviewed - Full taxonomy validation +
+ 0 of 5 (0%) / 5 of 11 (45.5%) + 6 capability gaps +
+
+
+ Device and Node Pairing + Needs review - Full taxonomy validation +
+ 0 of 11 (0%) / 0 of 11 (0%) + 11 capability gaps +
+
+
+ Gateway Auth and Remote Access + Needs review - Full taxonomy validation +
+ 0 of 9 (0%) / 0 of 9 (0%) + 9 capability gaps +
+
+
+ Plugin Trust + Needs review - Full taxonomy validation +
+ 0 of 2 (0%) / 0 of 2 (0%) + 2 capability gaps +
+
+
+ + +

2 needs review / 7 partially reviewed

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ CLI Session and Transcript Management + Needs review - Full taxonomy validation +
+ 0 of 2 (0%) / 0 of 2 (0%) + 2 capability gaps +
+
+
+ Context Engine + Partially reviewed - Full taxonomy validation +
+ 0 of 2 (0%) / 4 of 7 (57.1%) + 3 capability gaps +
+
+
+ Core Prompts and Context + Partially reviewed - Full taxonomy validation +
+ 0 of 2 (0%) / 3 of 8 (37.5%) + 5 capability gaps +
+
+
+ Cross-client History and Session Parity + Partially reviewed - Full taxonomy validation +
+ 0 of 2 (0%) / 2 of 5 (40%) + 3 capability gaps +
+
+
+ Diagnostics, Maintenance, and Recovery + Partially reviewed - Full taxonomy validation +
+ 0 of 3 (0%) / 4 of 10 (40%) + 6 capability gaps +
+
+
+ Memory + Partially reviewed - Full taxonomy validation +
+ 0 of 5 (0%) / 6 of 13 (46.2%) + 7 capability gaps +
+
+
+ Session Routing + Partially reviewed - Full taxonomy validation +
+ 0 of 2 (0%) / 1 of 4 (25%) + 3 capability gaps +
+
+
+ Token Management + Partially reviewed - Full taxonomy validation +
+ 0 of 3 (0%) / 2 of 10 (20%) + 8 capability gaps +
+
+
+ Transcript Persistence + Needs review - Full taxonomy validation +
+ 0 of 2 (0%) / 0 of 2 (0%) + 2 capability gaps +
+
+
+ + +

5 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Access and Identity + Needs review - Full taxonomy validation +
+ 0 of 6 (0%) / 0 of 6 (0%) + 6 capability gaps +
+
+
+ Channel Setup and Operations + Needs review - Full taxonomy validation +
+ 0 of 7 (0%) / 0 of 7 (0%) + 7 capability gaps +
+
+
+ Conversation Routing and Delivery + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ Media and Rich Content + Needs review - Full taxonomy validation +
+ 0 of 7 (0%) / 0 of 7 (0%) + 7 capability gaps +
+
+
+ Native Controls and Approvals + Needs review - Full taxonomy validation +
+ 0 of 3 (0%) / 0 of 3 (0%) + 3 capability gaps +
+
+
+ + +

5 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Access and Identity + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ Channel Setup and Operations + Needs review - Full taxonomy validation +
+ 0 of 10 (0%) / 0 of 10 (0%) + 10 capability gaps +
+
+
+ Conversation Routing and Delivery + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Media and Rich Content + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ Native Controls and Approvals + Needs review - Full taxonomy validation +
+ 0 of 8 (0%) / 0 of 8 (0%) + 8 capability gaps +
+
+
+ + +

5 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Access and Identity + Needs review - Full taxonomy validation +
+ 0 of 10 (0%) / 0 of 10 (0%) + 10 capability gaps +
+
+
+ Channel Setup and Operations + Needs review - Full taxonomy validation +
+ 0 of 10 (0%) / 0 of 10 (0%) + 10 capability gaps +
+
+
+ Conversation Routing and Delivery + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ Media and Rich Content + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ Native Controls and Approvals + Needs review - Full taxonomy validation +
+ 0 of 9 (0%) / 0 of 9 (0%) + 9 capability gaps +
+
+
+ + +

3 partially reviewed / 2 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Diagnostic Collection + Partially reviewed - Full taxonomy validation +
+ 1 of 8 (12.5%) / 3 of 10 (30%) + 7 capability gaps +
+
+
+ Health and Repair + Partially reviewed - Full taxonomy validation +
+ 1 of 12 (8.3%) / 5 of 18 (27.8%) + 13 capability gaps +
+
+
+ Logging + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Session Diagnostics + Needs review - Full taxonomy validation +
+ 0 of 4 (0%) / 0 of 4 (0%) + 4 capability gaps +
+
+
+ Telemetry Export + Partially reviewed - Full taxonomy validation +
+ 1 of 13 (7.7%) / 7 of 21 (33.3%) + 14 capability gaps +
+
+
+ + +

5 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Input and Commands + Needs review - Full taxonomy validation +
+ 0 of 8 (0%) / 0 of 8 (0%) + 8 capability gaps +
+
+
+ Local Shell Execution + Needs review - Full taxonomy validation +
+ 0 of 4 (0%) / 0 of 4 (0%) + 4 capability gaps +
+
+
+ Rendering and Output Safety + Needs review - Full taxonomy validation +
+ 0 of 4 (0%) / 0 of 4 (0%) + 4 capability gaps +
+
+
+ Runtime Modes + Needs review - Full taxonomy validation +
+ 0 of 14 (0%) / 0 of 14 (0%) + 14 capability gaps +
+
+
+ Session Management + Needs review - Full taxonomy validation +
+ 0 of 3 (0%) / 0 of 3 (0%) + 3 capability gaps +
+
+
+ + +

6 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Native App Talk + Needs review - Full taxonomy validation +
+ 0 of 4 (0%) / 0 of 4 (0%) + 4 capability gaps +
+
+
+ Realtime Talk Sessions + Needs review - Full taxonomy validation +
+ 0 of 11 (0%) / 0 of 11 (0%) + 11 capability gaps +
+
+
+ Speech and Transcription + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Talk Observability + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Talk Providers + Needs review - Full taxonomy validation +
+ 0 of 7 (0%) / 0 of 7 (0%) + 7 capability gaps +
+
+
+ Voice Wake and Routing + Needs review - Full taxonomy validation +
+ 0 of 4 (0%) / 0 of 4 (0%) + 4 capability gaps +
+
+
+ + +

5 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Access and Identity + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ Channel Setup and Operations + Needs review - Full taxonomy validation +
+ 0 of 2 (0%) / 0 of 2 (0%) + 2 capability gaps +
+
+
+ Conversation Routing and Delivery + Needs review - Full taxonomy validation +
+ 0 of 1 (0%) / 0 of 1 (0%) + 1 capability gap +
+
+
+ Media and Rich Content + Needs review - Full taxonomy validation +
+ 0 of 2 (0%) / 0 of 2 (0%) + 2 capability gaps +
+
+
+ Realtime Voice and Calls + Needs review - Full taxonomy validation +
+ 0 of 2 (0%) / 0 of 2 (0%) + 2 capability gaps +
+
+
+ + +

5 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Delivery and Recovery + Needs review - Full taxonomy validation +
+ 0 of 7 (0%) / 0 of 7 (0%) + 7 capability gaps +
+
+
+ Distribution and Support + Needs review - Full taxonomy validation +
+ 0 of 6 (0%) / 0 of 6 (0%) + 6 capability gaps +
+
+
+ Exec Approvals + Needs review - Full taxonomy validation +
+ 0 of 3 (0%) / 0 of 3 (0%) + 3 capability gaps +
+
+
+ Notifications and Replies + Needs review - Full taxonomy validation +
+ 0 of 7 (0%) / 0 of 7 (0%) + 7 capability gaps +
+
+
+ Watch App UI + Needs review - Full taxonomy validation +
+ 0 of 3 (0%) / 0 of 3 (0%) + 3 capability gaps +
+
+
+ + +

2 needs review / 2 partially reviewed

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Network Safety + Needs review - Full taxonomy validation +
+ 0 of 4 (0%) / 0 of 4 (0%) + 4 capability gaps +
+
+
+ Search Providers + Partially reviewed - Full taxonomy validation +
+ 2 of 19 (10.5%) / 2 of 19 (10.5%) + 17 capability gaps +
+
+
+ Setup and Diagnostics + Needs review - Full taxonomy validation +
+ 0 of 9 (0%) / 0 of 9 (0%) + 9 capability gaps +
+
+
+ Tool Availability and Fetch + Partially reviewed - Full taxonomy validation +
+ 2 of 11 (18.2%) / 3 of 12 (25%) + 9 capability gaps +
+
+
+ + +

5 needs review

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Access and Identity + Needs review - Full taxonomy validation +
+ 0 of 7 (0%) / 0 of 7 (0%) + 7 capability gaps +
+
+
+ Channel Setup and Operations + Needs review - Full taxonomy validation +
+ 0 of 5 (0%) / 0 of 5 (0%) + 5 capability gaps +
+
+
+ Conversation Routing and Delivery + Needs review - Full taxonomy validation +
+ 0 of 4 (0%) / 0 of 4 (0%) + 4 capability gaps +
+
+
+ Media and Rich Content + Needs review - Full taxonomy validation +
+ 0 of 2 (0%) / 0 of 2 (0%) + 2 capability gaps +
+
+
+ Native Controls and Approvals + Needs review - Full taxonomy validation +
+ 0 of 2 (0%) / 0 of 2 (0%) + 2 capability gaps +
+
+
+ + +

5 needs review / 1 partially reviewed

+
+
AreaFeatures / coverage IDsFollow-up
+
+
+ Browser and Control UI + Needs review - Full taxonomy validation +
+ 0 of 6 (0%) / 0 of 6 (0%) + 6 capability gaps +
+
+
+ CLI + Needs review - Full taxonomy validation +
+ 0 of 8 (0%) / 0 of 8 (0%) + 8 capability gaps +
+
+
+ Diagnostics and Repair + Partially reviewed - Full taxonomy validation +
+ 1 of 6 (16.7%) / 3 of 8 (37.5%) + 5 capability gaps +
+
+
+ Gateway Access and Exposure + Needs review - Full taxonomy validation +
+ 0 of 11 (0%) / 0 of 11 (0%) + 11 capability gaps +
+
+
+ Gateway Service Lifecycle + Needs review - Full taxonomy validation +
+ 0 of 10 (0%) / 0 of 10 (0%) + 10 capability gaps +
+
+
+ WSL Setup + Needs review - Full taxonomy validation +
+ 0 of 6 (0%) / 0 of 6 (0%) + 6 capability gaps
diff --git a/docs/maturity/taxonomy.md b/docs/maturity/taxonomy.md index 940d437076cc..1291c8970bc7 100644 --- a/docs/maturity/taxonomy.md +++ b/docs/maturity/taxonomy.md @@ -308,7 +308,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Normal setup and repair paths are documented across install, CLI, and gateway docs. Platform-specific Windows paths are tracked in the Windows via WSL2 and Native Windows rows. -
Coverage Experimental - 2%Quality Stable - 83%Completeness Stable - 90%Partial - 6
+
Coverage Experimental - 4%Quality Stable - 83%Completeness Stable - 90%Partial - 6
AreaCoverageQualityCompletenessDocs
@@ -317,7 +317,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. CLI Setup 6 capabilities / LTS-supported
-
Experimental2%
+
Experimental17%
Stable89%
Stable90%
[Index](/install/index), [Installer](/install/installer), [Node](/install/node), [Updating](/install/updating)
@@ -327,7 +327,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Onboarding and Auth Setup 5 capabilities / LTS-supported
-
Experimental2%
+
Experimental0%
Beta75%
Stable89%
[Onboard](/cli/onboard), [Configure](/cli/configure), [Onboarding Overview](/start/onboarding-overview)
@@ -337,7 +337,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Plugin and Channel Setup 5 capabilities
-
Experimental2%
+
Experimental0%
Beta75%
Stable89%
[Onboard](/cli/onboard), [Plugins](/cli/plugins), [Channels](/cli/channels)
@@ -347,7 +347,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Gateway Service Management 5 capabilities / LTS-supported
-
Experimental2%
+
Experimental14%
Stable87%
Stable90%
[Gateway](/cli/gateway), [Updating](/install/updating), [Troubleshooting](/gateway/troubleshooting)
@@ -357,7 +357,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. CLI Observability 5 capabilities / LTS-supported -
Experimental2%
+
Experimental0%
Stable89%
Stable90%
[Status](/cli/status), [Health](/cli/health), [Logs](/cli/logs), [Diagnostics](/gateway/diagnostics)
@@ -367,7 +367,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Doctor 10 capabilities / LTS-supported -
Experimental2%
+
Experimental0%
Stable89%
Stable90%
[Doctor](/cli/doctor), [Doctor](/gateway/doctor), [Secrets](/gateway/secrets), [Troubleshooting](/gateway/troubleshooting)
@@ -377,7 +377,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Updates and Upgrades 5 capabilities / LTS-supported -
Experimental2%
+
Experimental0%
Beta75%
Stable89%
[Updating](/install/updating), [Update](/cli/update), [Troubleshooting](/gateway/troubleshooting)
@@ -391,7 +391,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Core architecture, auth, pairing, protocol docs, daemon docs, and CLI runbooks are broad and current. -
Coverage Experimental - 3%Quality Stable - 81%Completeness Stable - 89%Partial - 12
+
Coverage Experimental - 6%Quality Stable - 81%Completeness Stable - 89%Partial - 12
AreaCoverageQualityCompletenessDocs
@@ -400,7 +400,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Approvals and Remote Execution 6 capabilities / LTS-supported
-
Experimental3%
+
Experimental0%
Beta75%
Stable89%
[Protocol](/gateway/protocol), [Index](/gateway/security/index)
@@ -410,7 +410,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. HTTP APIs 4 capabilities / LTS-supported -
Experimental3%
+
Experimental25%
Stable90%
Stable90%
[Index](/gateway/index), [Openai Http Api](/gateway/openai-http-api), [Openresponses Http Api](/gateway/openresponses-http-api), [Tools Invoke Http Api](/gateway/tools-invoke-http-api), [Hooks](/automation/hooks), [Index](/web/index)
@@ -420,7 +420,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Hosted Web Surface 4 capabilities / LTS-supported -
Experimental3%
+
Experimental0%
Stable89%
Stable90%
[Index](/gateway/index), [Architecture](/concepts/architecture), [Control Ui](/web/control-ui), [Webchat](/web/webchat), [Canvas](/refactor/canvas)
@@ -430,7 +430,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Gateway RPC APIs and Events 20 capabilities / LTS-supported -
Experimental3%
+
Experimental9%
Stable90%
Stable90%
[Protocol](/gateway/protocol), [Index](/gateway/index), [Architecture](/concepts/architecture)
@@ -440,7 +440,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Device Auth and Pairing 10 capabilities / LTS-supported -
Experimental3%
+
Experimental0%
Beta75%
Stable89%
[Protocol](/gateway/protocol), [Pairing](/gateway/pairing), [Index](/gateway/security/index)
@@ -450,7 +450,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Network Access and Discovery 6 capabilities / LTS-supported -
Experimental3%
+
Experimental0%
Beta75%
Stable89%
[Index](/gateway/index), [Discovery](/gateway/discovery), [Protocol](/gateway/protocol)
@@ -460,7 +460,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Nodes and Remote Capabilities 8 capabilities -
Experimental3%
+
Experimental0%
Beta75%
Stable89%
[Protocol](/gateway/protocol), [Architecture](/concepts/architecture), [Index](/nodes/index)
@@ -470,7 +470,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Health, Diagnostics, and Repair 7 capabilities / LTS-supported -
Experimental3%
+
Experimental0%
Beta75%
Stable89%
[Index](/gateway/index), [Diagnostics](/gateway/diagnostics), [Doctor](/gateway/doctor)
@@ -480,7 +480,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Protocol Compatibility 7 capabilities / LTS-supported -
Experimental3%
+
Experimental0%
Beta75%
Stable89%
[Protocol](/gateway/protocol), [Architecture](/concepts/architecture), [Typebox](/concepts/typebox), [Bridge Protocol](/gateway/bridge-protocol)
@@ -490,7 +490,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Roles and Permissions 5 capabilities / LTS-supported -
Experimental3%
+
Experimental0%
Beta75%
Stable89%
[Protocol](/gateway/protocol), [Index](/gateway/security/index)
@@ -500,7 +500,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Gateway Lifecycle 7 capabilities / LTS-supported -
Experimental3%
+
Experimental33%
Stable90%
Stable90%
[Index](/gateway/index), [Architecture](/concepts/architecture)
@@ -510,7 +510,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Security Controls 6 capabilities / LTS-supported -
Experimental3%
+
Experimental0%
Beta75%
Stable89%
[Index](/gateway/security/index), [Protocol](/gateway/protocol), [Discovery](/gateway/discovery)
@@ -520,7 +520,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. WebSocket Connection 8 capabilities / LTS-supported -
Experimental3%
+
Experimental13%
Stable90%
Stable90%
[Protocol](/gateway/protocol), [Architecture](/concepts/architecture)
@@ -534,7 +534,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Main loop, models, provider routing, and tool streaming are first-class, but provider behavior shifts weekly and needs scenario proof per release. -
Coverage Experimental - 2%Quality Beta - 78%Completeness Beta - 79%Partial - 6
+
Coverage Experimental - 33%Quality Beta - 78%Completeness Beta - 79%Partial - 6
AreaCoverageQualityCompletenessDocs
@@ -543,7 +543,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Agent Turn Execution 3 capabilities / LTS-supported
-
Experimental2%
+
Experimental29%
Beta79%
Beta79%
[Agent Loop](/concepts/agent-loop), [Agent](/cli/agent), [Agent Runtimes](/concepts/agent-runtimes)
@@ -553,7 +553,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. External Runtimes and Subagents 4 capabilities -
Experimental2%
+
Experimental30%
Beta79%
Beta79%
[Agent Runtimes](/concepts/agent-runtimes), [Anthropic](/providers/anthropic), [Google](/providers/google), [Subagents](/tools/subagents)
@@ -563,7 +563,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Hosted Provider Execution 5 capabilities / LTS-supported -
Experimental2%
+
Experimental20%
Beta79%
Beta79%
[Openai](/providers/openai), [Anthropic](/providers/anthropic), [Google](/providers/google), [Models](/concepts/models)
@@ -573,7 +573,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Local and Self-hosted Providers 5 capabilities -
Experimental2%
+
Experimental0%
Alpha68%
Beta79%
[Ollama](/providers/ollama), [Models](/concepts/models), [Agent](/cli/agent)
@@ -583,7 +583,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Model and Runtime Selection 4 capabilities / LTS-supported -
Experimental2%
+
Experimental25%
Beta79%
Beta79%
[Models](/concepts/models), [Models](/cli/models), [Openai](/providers/openai), [Agent Runtimes](/concepts/agent-runtimes)
@@ -593,7 +593,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Provider Auth 10 capabilities / LTS-supported -
Experimental2%
+
Experimental24%
Beta79%
Beta79%
[Models](/concepts/models), [Agent](/cli/agent), [Models](/cli/models), [Openai](/providers/openai), [Anthropic](/providers/anthropic), [Google](/providers/google), [Subagents](/tools/subagents)
@@ -603,7 +603,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Streaming and Progress 2 capabilities -
Experimental2%
+
Alpha56%
Beta79%
Beta79%
[Streaming](/concepts/streaming), [Agent Loop](/concepts/agent-loop)
@@ -613,7 +613,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Tool Calls and Response Handling 3 capabilities / LTS-supported -
Experimental2%
+
Alpha65%
Beta79%
Beta79%
[Agent Loop](/concepts/agent-loop), [Ollama](/providers/ollama)
@@ -623,7 +623,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Tool Execution Controls 6 capabilities / LTS-supported -
Experimental2%
+
Alpha50%
Beta79%
Beta79%
[Sandbox Vs Tool Policy Vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated), [Agent Loop](/concepts/agent-loop), [Subagents](/tools/subagents)
@@ -637,7 +637,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Strong docs and active implementation. Maturity depends on transcript durability, compaction quality, and cross-client parity. -
Coverage Experimental - 0%Quality Beta - 77%Completeness Beta - 79%Partial - 6
+
Coverage Experimental - 30%Quality Beta - 77%Completeness Beta - 79%Partial - 6
AreaCoverageQualityCompletenessDocs
@@ -656,7 +656,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Token Management 3 capabilities / LTS-supported
-
Experimental0%
+
Experimental20%
Beta79%
Beta79%
[Compaction](/concepts/compaction), [Context](/concepts/context), [Session Management Compaction](/reference/session-management-compaction)
@@ -666,7 +666,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Context Engine 2 capabilities / LTS-supported -
Experimental0%
+
Alpha57%
Beta79%
Beta79%
[Context](/concepts/context), [Context Engine](/concepts/context-engine), [Codex Context Engine Harness](/plan/codex-context-engine-harness)
@@ -676,7 +676,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Cross-client History and Session Parity 2 capabilities -
Experimental0%
+
Experimental40%
Beta79%
Beta79%
[Webchat](/web/webchat), [Android](/platforms/android), [Channel Routing](/channels/channel-routing)
@@ -686,7 +686,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Diagnostics, Maintenance, and Recovery 3 capabilities -
Experimental0%
+
Experimental40%
Beta79%
Beta79%
[Diagnostics](/gateway/diagnostics), [Session Management Compaction](/reference/session-management-compaction), [Flags](/diagnostics/flags)
@@ -696,7 +696,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Core Prompts and Context 2 capabilities / LTS-supported -
Experimental0%
+
Experimental38%
Beta79%
Beta79%
[Context](/concepts/context), [Transcript Hygiene](/reference/transcript-hygiene), [Discord](/channels/discord)
@@ -706,7 +706,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Memory 5 capabilities -
Experimental0%
+
Experimental46%
Beta79%
Beta79%
[Memory Config](/reference/memory-config), [Memory Qmd](/concepts/memory-qmd), [Memory](/concepts/memory), [Discord](/channels/discord)
@@ -716,7 +716,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Session Routing 2 capabilities / LTS-supported -
Experimental0%
+
Experimental25%
Beta79%
Beta79%
[Session](/concepts/session), [Channel Routing](/channels/channel-routing), [Discord](/channels/discord)
@@ -740,7 +740,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Many channels share Gateway delivery and routing contracts, but channel behavior varies by upstream API and account-policy constraints. -
Coverage Experimental - 0%Quality Beta - 76%Completeness Beta - 79%Partial - 5
+
Coverage Experimental - 13%Quality Beta - 76%Completeness Beta - 79%Partial - 5
AreaCoverageQualityCompletenessDocs
@@ -759,7 +759,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Channel Setup 5 capabilities / LTS-supported
-
Experimental0%
+
Experimental14%
Beta79%
Beta79%
[Index](/channels/index), [Pairing](/channels/pairing), [Troubleshooting](/channels/troubleshooting), [Sdk Channel Plugins](/plugins/sdk-channel-plugins)
@@ -769,7 +769,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Group Thread and Ambient Room Behavior 5 capabilities -
Experimental0%
+
Experimental36%
Beta79%
Beta79%
[Groups](/channels/groups), [Group Messages](/channels/group-messages), [Ambient Room Events](/channels/ambient-room-events), [Broadcast Groups](/channels/broadcast-groups), [Discord](/channels/discord)
@@ -799,7 +799,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Outbound Delivery and Reply Pipeline 4 capabilities / LTS-supported -
Experimental0%
+
Experimental38%
Beta79%
Beta79%
[Groups](/channels/groups), [Ambient Room Events](/channels/ambient-room-events), [Discord](/channels/discord), [Matrix](/channels/matrix), [Config Channels](/gateway/config-channels)
@@ -809,7 +809,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Conversation Routing and Delivery 10 capabilities / LTS-supported -
Experimental0%
+
Experimental19%
Beta79%
Beta79%
[Channel Routing](/channels/channel-routing), [Groups](/channels/groups), [Discord](/channels/discord), [Matrix](/channels/matrix), [Troubleshooting](/channels/troubleshooting), [Configuration Reference](/gateway/configuration-reference)
@@ -833,7 +833,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. OTel, Prometheus, logging, and diagnostics docs exist. Needs a public "what operators should look at first" maturity pass. -
Coverage Experimental - 6%Quality Beta - 75%Completeness Beta - 79%Partial - 3
+
Coverage Experimental - 18%Quality Beta - 75%Completeness Beta - 79%Partial - 3
AreaCoverageQualityCompletenessDocs
@@ -842,7 +842,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Health and Repair 12 capabilities / LTS-supported
-
Experimental6%
+
Experimental28%
Beta79%
Beta79%
[Health](/gateway/health), [Telegram](/channels/telegram), [Doctor](/cli/doctor), [Doctor](/gateway/doctor), [Sdk Subpaths](/plugins/sdk-subpaths), [Health](/cli/health), [Protocol](/gateway/protocol)
@@ -852,7 +852,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Logging 5 capabilities / LTS-supported -
Experimental6%
+
Experimental0%
Alpha68%
Beta79%
[Logging](/logging), [Logging](/gateway/logging), [Logs](/cli/logs)
@@ -862,7 +862,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Diagnostic Collection 8 capabilities -
Experimental6%
+
Experimental30%
Beta79%
Beta79%
[Diagnostics](/gateway/diagnostics), [Health](/gateway/health), [Codex Harness](/plugins/codex-harness), [Protocol](/gateway/protocol)
@@ -872,7 +872,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Telemetry Export 13 capabilities -
Experimental6%
+
Experimental33%
Beta79%
Beta79%
[Hooks](/plugins/hooks), [Opentelemetry](/gateway/opentelemetry), [Logging](/logging), [Sdk Subpaths](/plugins/sdk-subpaths), [Diagnostics Otel](/plugins/reference/diagnostics-otel), [Prometheus](/gateway/prometheus), [Diagnostics Prometheus](/plugins/reference/diagnostics-prometheus)
@@ -882,7 +882,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Session Diagnostics 4 capabilities / LTS-supported -
Experimental6%
+
Experimental0%
Alpha68%
Beta79%
[Opentelemetry](/gateway/opentelemetry), [Prometheus](/gateway/prometheus), [Diagnostics](/gateway/diagnostics), [Protocol](/gateway/protocol)
@@ -896,7 +896,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Web UI is documented with pairing, chat, PWA, Talk, push, and remote Gateway flows. Promote after cross-browser and mobile-PWA scorecards. -
Coverage Experimental - 0%Quality Beta - 74%Completeness Beta - 79%None
+
Coverage Experimental - 4%Quality Beta - 74%Completeness Beta - 79%None
AreaCoverageQualityCompletenessDocs
@@ -935,7 +935,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Browser UI 10 capabilities
-
Experimental0%
+
Experimental8%
Beta79%
Beta79%
[Control Ui](/web/control-ui), [Index](/web/index), [Dashboard](/web/dashboard), [Protocol](/gateway/protocol)
@@ -945,7 +945,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. WebChat Conversations 15 capabilities -
Experimental0%
+
Experimental10%
Beta79%
Beta79%
[Control Ui](/web/control-ui), [Webchat](/web/webchat), [Getting Started](/start/getting-started), [Channel Routing](/channels/channel-routing), [Secure File Operations](/gateway/security/secure-file-operations)
@@ -955,7 +955,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Operator Console 10 capabilities -
Experimental0%
+
Experimental8%
Beta79%
Beta79%
[Control Ui](/web/control-ui), [Health](/gateway/health), [Protocol](/gateway/protocol), [Dashboard](/web/dashboard)
@@ -969,7 +969,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Broad docs and strong internal runtime evidence exist across manifests, discovery, loading, provider/tool architecture, and approval boundaries. Keep the row at beta until public SDK API/subpaths and external distribution proof are stronger. -
Coverage Experimental - 2%Quality Beta - 72%Completeness Beta - 79%Partial - 7
+
Coverage Experimental - 12%Quality Beta - 72%Completeness Beta - 79%Partial - 7
AreaCoverageQualityCompletenessDocs
@@ -978,7 +978,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Authoring and Packaging plugins 8 capabilities / LTS-supported
-
Experimental2%
+
Experimental0%
Alpha68%
Beta79%
[Building Plugins](/plugins/building-plugins), [Sdk Overview](/plugins/sdk-overview), [Sdk Entrypoints](/plugins/sdk-entrypoints), [Sdk Subpaths](/plugins/sdk-subpaths), [Manifest](/plugins/manifest), [Reference](/plugins/reference)
@@ -988,7 +988,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Bundled plugins 5 capabilities / LTS-supported -
Experimental2%
+
Experimental0%
Alpha68%
Beta79%
[Plugin Inventory](/plugins/plugin-inventory), [Plugins](/cli/plugins), [Architecture Internals](/plugins/architecture-internals)
@@ -998,7 +998,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Canvas plugin 6 capabilities -
Experimental2%
+
Experimental0%
Alpha68%
Beta79%
[Canvas](/plugins/reference/canvas), [Canvas](/refactor/canvas), [Configuration Reference](/gateway/configuration-reference)
@@ -1008,7 +1008,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Installing and running plugins 6 capabilities / LTS-supported -
Experimental2%
+
Experimental35%
Beta79%
Beta79%
[Architecture](/plugins/architecture), [Architecture Internals](/plugins/architecture-internals), [Plugins](/cli/plugins)
@@ -1018,7 +1018,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Channel plugins 5 capabilities / LTS-supported -
Experimental2%
+
Experimental0%
Alpha68%
Beta79%
[Sdk Channel Plugins](/plugins/sdk-channel-plugins), [Sdk Channel Inbound](/plugins/sdk-channel-inbound), [Sdk Channel Outbound](/plugins/sdk-channel-outbound)
@@ -1028,7 +1028,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Provider and tool plugins 6 capabilities / LTS-supported -
Experimental2%
+
Experimental43%
Beta79%
Beta79%
[Sdk Provider Plugins](/plugins/sdk-provider-plugins), [Tool Plugins](/plugins/tool-plugins), [Adding Capabilities](/plugins/adding-capabilities)
@@ -1038,7 +1038,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Plugin approvals 6 capabilities / LTS-supported -
Experimental2%
+
Experimental0%
Alpha68%
Beta79%
[Plugin Permission Requests](/plugins/plugin-permission-requests), [Exec Approvals](/tools/exec-approvals), [Sdk Channel Plugins](/plugins/sdk-channel-plugins)
@@ -1048,7 +1048,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Publishing plugins 6 capabilities / LTS-supported -
Experimental2%
+
Experimental0%
Alpha68%
Beta79%
[Plugins](/cli/plugins), [Compatibility](/plugins/compatibility), [Publishing](/clawhub/publishing)
@@ -1058,7 +1058,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Testing plugins 6 capabilities -
Experimental2%
+
Experimental27%
Beta79%
Beta79%
[Sdk Testing](/plugins/sdk-testing), [Sdk Setup](/plugins/sdk-setup), [Codex Harness](/plugins/codex-harness)
@@ -1072,7 +1072,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Good docs and hardening surfaces exist. Promote after regular upgrade/security scenario runs prove no setup regressions. -
Coverage Experimental - 0%Quality Beta - 72%Completeness Beta - 79%Partial - 5
+
Coverage Experimental - 16%Quality Beta - 72%Completeness Beta - 79%Partial - 5
AreaCoverageQualityCompletenessDocs
@@ -1081,7 +1081,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Approval Policy and Tool Safeguards 2 capabilities / LTS-supported
-
Experimental0%
+
Alpha50%
Beta79%
Beta79%
[Exec Approvals](/tools/exec-approvals), [Approvals](/cli/approvals), [Plugin Permission Requests](/plugins/plugin-permission-requests), [Audit Checks](/gateway/security/audit-checks)
@@ -1131,7 +1131,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Credential and Secret Hygiene 5 capabilities / LTS-supported -
Experimental0%
+
Experimental46%
Beta79%
Beta79%
[Authentication](/gateway/authentication), [Models](/cli/models), [Openai](/providers/openai), [Oauth](/concepts/oauth), [Secrets](/gateway/secrets), [Secrets](/cli/secrets), [Secretref Credential Surface](/reference/secretref-credential-surface), [Audit Checks](/gateway/security/audit-checks)
@@ -1145,7 +1145,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Documented and usable, but scenario proof should cover unattended delivery, retries, and failure visibility. -
Coverage Experimental - 0%Quality Beta - 72%Completeness Beta - 79%None
+
Coverage Experimental - 2%Quality Beta - 72%Completeness Beta - 79%None
AreaCoverageQualityCompletenessDocs
@@ -1194,7 +1194,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Heartbeat 5 capabilities
-
Experimental0%
+
Experimental14%
Beta79%
Beta79%
[Index](/automation/index), [Heartbeat](/gateway/heartbeat), [Commitments](/concepts/commitments)
@@ -1218,7 +1218,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Broad capability surface exists, but provider variance, file limits, and node/app parity make this not stable yet. -
Coverage Experimental - 1%Quality Alpha - 64%Completeness Alpha - 68%None
+
Coverage Experimental - 2%Quality Alpha - 64%Completeness Alpha - 68%None
AreaCoverageQualityCompletenessDocs
@@ -1227,7 +1227,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Media Intake and Access 8 capabilities
-
Experimental1%
+
Experimental0%
Alpha61%
Alpha68%
[Media Overview](/tools/media-overview), [Media Understanding](/nodes/media-understanding), [Secure File Operations](/gateway/security/secure-file-operations), [Pdf](/tools/pdf), [Image Generation](/tools/image-generation), [Qr](/cli/qr), [Line](/channels/line), [Whatsapp](/channels/whatsapp)
@@ -1237,7 +1237,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Channel Media Handling 5 capabilities -
Experimental1%
+
Experimental0%
Alpha61%
Alpha68%
[Images](/nodes/images), [Media Overview](/tools/media-overview), [Discord](/channels/discord)
@@ -1247,7 +1247,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Media Configuration 1 capabilities -
Experimental1%
+
Experimental0%
Alpha61%
Alpha68%
[Media Overview](/tools/media-overview), [Image Generation](/tools/image-generation), [Manifest](/plugins/manifest), [Codex Harness](/plugins/codex-harness)
@@ -1257,7 +1257,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Text-to-Speech Delivery 2 capabilities -
Experimental1%
+
Experimental0%
Alpha61%
Alpha68%
[Tts](/tools/tts), [Media Overview](/tools/media-overview), [Discord](/channels/discord)
@@ -1267,7 +1267,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Media Understanding 12 capabilities -
Experimental1%
+
Experimental7%
Alpha69%
Alpha69%
[Audio](/nodes/audio), [Media Understanding](/nodes/media-understanding), [Media Overview](/tools/media-overview), [Whatsapp](/channels/whatsapp), [Images](/nodes/images), [Infer](/cli/infer), [Pdf](/tools/pdf)
@@ -1277,7 +1277,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Media Generation 17 capabilities -
Experimental1%
+
Experimental5%
Alpha69%
Alpha69%
[Image Generation](/tools/image-generation), [Media Overview](/tools/media-overview), [Skills](/tools/skills), [Music Generation](/tools/music-generation), [Video Generation](/tools/video-generation)
@@ -1480,7 +1480,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. OpenClaw App SDK is a distinct external app contract separate from Gateway runtime and Plugin SDK. Current scoring shows a real `@openclaw/sdk` path with gaps around public packaging, auto-discovery, approvals, helpers, and compatibility. -
Coverage Experimental - 0%Quality Alpha - 54%Completeness Alpha - 53%None
+
Coverage Experimental - 3%Quality Alpha - 54%Completeness Alpha - 53%None
AreaCoverageQualityCompletenessDocs
@@ -1529,7 +1529,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Resource Helpers 5 capabilities
-
Experimental0%
+
Experimental17%
Alpha62%
Alpha53%
[Openclaw Sdk](/gateway/external-apps), [Openclaw Sdk Api Design](/gateway/external-apps)
@@ -1704,7 +1704,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Install docs exist and are common deployment paths. Promote after recurring release smoke captures upgrade and volume behavior. -
Coverage Experimental - 5%Quality Beta - 71%Completeness Beta - 79%None
+
Coverage Experimental - 7%Quality Beta - 71%Completeness Beta - 79%None
AreaCoverageQualityCompletenessDocs
@@ -1713,7 +1713,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Container Setup 6 capabilities
-
Experimental5%
+
Experimental0%
Alpha68%
Beta79%
[Docker](/install/docker), [Podman](/install/podman)
@@ -1723,7 +1723,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Container Operations 11 capabilities -
Experimental5%
+
Experimental0%
Alpha68%
Beta79%
[Podman](/install/podman), [Docker Vm Runtime](/install/docker-vm-runtime), [Docker](/install/docker), [Hetzner](/install/hetzner), [Hostinger](/install/hostinger)
@@ -1733,7 +1733,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Image Release and Validation 5 capabilities -
Experimental5%
+
Experimental29%
Beta79%
Beta79%
[Docker](/install/docker), [Docker Vm Runtime](/install/docker-vm-runtime), [Full Release Validation](/reference/full-release-validation)
@@ -1743,7 +1743,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Agent Sandbox and Tooling 3 capabilities -
Experimental5%
+
Experimental0%
Alpha68%
Beta79%
[Docker](/install/docker), [Docker Vm Runtime](/install/docker-vm-runtime)
@@ -1757,7 +1757,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Recommended Windows path with systemd/user-service guidance and boot-chain docs. Promote after repeated install/update scorecards. -
Coverage Experimental - 3%Quality Alpha - 69%Completeness Beta - 79%Partial - 5
+
Coverage Experimental - 6%Quality Alpha - 69%Completeness Beta - 79%Partial - 5
AreaCoverageQualityCompletenessDocs
@@ -1766,7 +1766,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. WSL Setup 6 capabilities / LTS-supported
-
Experimental3%
+
Experimental0%
Alpha67%
Beta79%
[Windows](/platforms/windows), [Getting Started](/start/getting-started)
@@ -1776,7 +1776,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. CLI 8 capabilities / LTS-supported -
Experimental3%
+
Experimental0%
Alpha67%
Beta79%
[Windows](/platforms/windows), [Getting Started](/start/getting-started), [Updating](/install/updating), [Onboard](/cli/onboard), [Doctor](/cli/doctor), [Status](/cli/status), [Logs](/cli/logs)
@@ -1786,7 +1786,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Gateway Service Lifecycle 10 capabilities / LTS-supported -
Experimental3%
+
Experimental0%
Alpha67%
Beta79%
[Windows](/platforms/windows), [Index](/gateway/index), [Doctor](/gateway/doctor)
@@ -1796,7 +1796,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Gateway Access and Exposure 11 capabilities / LTS-supported -
Experimental3%
+
Experimental0%
Alpha67%
Beta79%
[Authentication](/gateway/authentication), [Secrets](/gateway/secrets), [Remote](/gateway/remote), [Exposure Runbook](/gateway/security/exposure-runbook), [Windows](/platforms/windows)
@@ -1806,7 +1806,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Diagnostics and Repair 6 capabilities / LTS-supported -
Experimental3%
+
Experimental38%
Beta79%
Beta79%
[Windows](/platforms/windows), [Status](/cli/status), [Logs](/cli/logs), [Doctor](/cli/doctor), [Doctor](/gateway/doctor)
@@ -1816,7 +1816,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Browser and Control UI 6 capabilities -
Experimental3%
+
Experimental0%
Alpha67%
Beta79%
[Browser Wsl2 Windows Remote Cdp Troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting), [Browser](/tools/browser), [Control Ui](/web/control-ui)
@@ -3276,7 +3276,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Core tools are documented, but host security and permission UX should stay under active scorecard review. -
Coverage Experimental - 15%Quality Beta - 75%Completeness Beta - 79%Partial - 2
+
Coverage Experimental - 21%Quality Beta - 75%Completeness Beta - 79%Partial - 2
AreaCoverageQualityCompletenessDocs
@@ -3285,7 +3285,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Browser Automation 8 capabilities
-
Experimental15%
+
Experimental13%
Beta79%
Beta79%
[Browser Control](/tools/browser-control), [Testing](/help/testing), [Browser](/tools/browser), [Index](/gateway/security/index), [Audit Checks](/gateway/security/audit-checks)
@@ -3295,7 +3295,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Tool Invocation and Execution 6 capabilities / LTS-supported -
Experimental15%
+
Alpha50%
Beta79%
Beta79%
[Exec](/tools/exec), [Background Process](/gateway/background-process), [Tools Invoke Http Api](/gateway/tools-invoke-http-api), [Operator Scopes](/gateway/operator-scopes), [Protocol](/gateway/protocol), [Exec Approvals](/tools/exec-approvals), [Exec Approvals Advanced](/tools/exec-approvals-advanced), [Elevated](/tools/elevated)
@@ -3305,7 +3305,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Sandbox and Tool Policy 6 capabilities / LTS-supported -
Experimental15%
+
Experimental0%
Alpha68%
Beta79%
[Sandboxing](/gateway/sandboxing), [Sandbox Vs Tool Policy Vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated), [Multi Agent Sandbox Tools](/tools/multi-agent-sandbox-tools), [Codex Harness Reference](/plugins/codex-harness-reference), [Config Tools](/gateway/config-tools)
@@ -3319,7 +3319,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Deep docs, OAuth/subscription path, realtime voice, image, and compatibility behavior. Provider churn keeps this from Stable without release-scorecard proof. -
Coverage Experimental - 8%Quality Beta - 74%Completeness Beta - 79%Partial - 3
+
Coverage Experimental - 26%Quality Beta - 74%Completeness Beta - 79%Partial - 3
AreaCoverageQualityCompletenessDocs
@@ -3328,7 +3328,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Model and Auth 6 capabilities / LTS-supported
-
Experimental8%
+
Experimental44%
Beta79%
Beta79%
[Openai](/providers/openai), [Codex Harness](/plugins/codex-harness), [Models](/concepts/models), [Oauth](/concepts/oauth), [Codex Harness Reference](/plugins/codex-harness-reference), [Auth Monitoring](/automation/auth-monitoring)
@@ -3338,7 +3338,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Responses and Tool Compatibility 4 capabilities / LTS-supported -
Experimental8%
+
Experimental40%
Beta79%
Beta79%
[Openai](/providers/openai), [Openresponses Http Api](/gateway/openresponses-http-api), [Openai Http Api](/gateway/openai-http-api), [Codex Native Plugins](/plugins/codex-native-plugins)
@@ -3348,7 +3348,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Native Codex Harness 2 capabilities / LTS-supported -
Experimental8%
+
Experimental44%
Beta79%
Beta79%
[Codex Harness](/plugins/codex-harness), [Codex Harness Runtime](/plugins/codex-harness-runtime), [Codex Harness Reference](/plugins/codex-harness-reference), [Codex Native Plugins](/plugins/codex-native-plugins)
@@ -3358,7 +3358,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Image and Multimodal Input 2 capabilities -
Experimental8%
+
Experimental0%
Alpha67%
Beta79%
[Openai](/providers/openai), [Image Generation](/tools/image-generation), [Images](/nodes/images)
@@ -3368,7 +3368,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Voice and Realtime Audio 2 capabilities -
Experimental8%
+
Experimental0%
Alpha67%
Beta79%
[Openai](/providers/openai), [Discord](/channels/discord), [Voice Call](/plugins/voice-call)
@@ -3382,7 +3382,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Multiple providers and docs exist. Needs quota/error/SSRF proof per provider family. -
Coverage Experimental - 7%Quality Beta - 74%Completeness Beta - 79%None
+
Coverage Experimental - 9%Quality Beta - 74%Completeness Beta - 79%None
AreaCoverageQualityCompletenessDocs
@@ -3391,7 +3391,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Search Providers 19 capabilities
-
Experimental7%
+
Experimental11%
Beta79%
Beta79%
[Web](/tools/web), [Brave Search](/tools/brave-search), [Tavily](/tools/tavily), [Exa Search](/tools/exa-search), [Firecrawl](/tools/firecrawl), [Perplexity Search](/tools/perplexity-search), [Duckduckgo Search](/tools/duckduckgo-search), [Searxng Search](/tools/searxng-search), [Gemini Search](/tools/gemini-search), [Grok Search](/tools/grok-search), [Kimi Search](/tools/kimi-search), [Minimax Search](/tools/minimax-search), [Ollama Search](/tools/ollama-search), [Sdk Subpaths](/plugins/sdk-subpaths), [Sdk Overview](/plugins/sdk-overview), [Manifest](/plugins/manifest)
@@ -3401,7 +3401,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Setup and Diagnostics 9 capabilities -
Experimental7%
+
Experimental0%
Alpha68%
Beta79%
[Web](/tools/web), [Web Fetch](/tools/web-fetch), [Faq](/help/faq), [Api Usage Costs](/reference/api-usage-costs), [Brave Search](/tools/brave-search), [Perplexity Search](/tools/perplexity-search), [Tavily](/tools/tavily), [Firecrawl](/tools/firecrawl)
@@ -3411,7 +3411,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Network Safety 4 capabilities -
Experimental7%
+
Experimental0%
Alpha68%
Beta79%
[Web](/tools/web), [Web Fetch](/tools/web-fetch), [Firecrawl](/tools/firecrawl), [Searxng Search](/tools/searxng-search)
@@ -3421,7 +3421,7 @@ A surface is a product area such as Gateway runtime, Discord, or the macOS app. Tool Availability and Fetch 11 capabilities -
Experimental7%
+
Experimental25%
Beta79%
Beta79%
[Config Tools](/gateway/config-tools), [Web Fetch](/tools/web-fetch), [Web](/tools/web), [Faq](/help/faq)
diff --git a/docs/plugins/architecture-internals.md b/docs/plugins/architecture-internals.md index dc3c5f3419c8..deff067d188e 100644 --- a/docs/plugins/architecture-internals.md +++ b/docs/plugins/architecture-internals.md @@ -737,6 +737,10 @@ outbound host generic and use the messaging adapter surface for provider rules: should be treated as `direct`, `group`, or `channel` before directory lookup. - `messaging.targetResolver.looksLikeId(raw, normalized)` tells core whether an input should skip straight to id-like resolution instead of directory search. +- `messaging.targetResolver.reservedLiterals` lists bare words that are + channel/session references for that provider. Resolution preserves configured + directory entries before rejecting reserved literals, then fails closed on a + directory miss. - `messaging.targetResolver.resolveTarget(...)` is the plugin fallback when core needs a final provider-owned resolution after normalization or after a directory miss. diff --git a/docs/plugins/codex-computer-use.md b/docs/plugins/codex-computer-use.md index 0f25fa03e579..5f2dd7e96d6d 100644 --- a/docs/plugins/codex-computer-use.md +++ b/docs/plugins/codex-computer-use.md @@ -115,6 +115,17 @@ before the thread starts. After changing Computer Use config, use `/new` or `/reset` in the affected chat before testing if an existing Codex thread has already started. +On macOS managed stdio startup, OpenClaw prefers the signed desktop Codex app +bundle at `/Applications/Codex.app/Contents/Resources/codex` when it exists. +That keeps Computer Use under the app bundle that owns the local desktop-control +permissions. If the desktop app is not installed, OpenClaw falls back to the +managed Codex binary installed beside the plugin. If an installed desktop app +initializes with an unsupported app-server version, OpenClaw closes that child +and retries the next managed binary candidate instead of letting a stale +desktop app shadow the plugin-local fallback. Explicit `appServer.command` +config or `OPENCLAW_CODEX_APP_SERVER_BIN` still overrides this managed +selection. + ## Commands Use the `/codex computer-use` commands from any chat surface where the `codex` @@ -276,7 +287,13 @@ Codex app-server MCP status, or macOS permissions. **Status or a probe times out on `computer-use.list_apps`.** The plugin and MCP server are present, but the local Computer Use bridge did not answer. Quit or restart Codex Computer Use, relaunch Codex Desktop if needed, then retry in a -fresh OpenClaw session. +fresh OpenClaw session. If the host previously ran Computer Use through an older +managed Codex app-server, refresh the installed plugin from the desktop bundled +marketplace: + +```text +/codex computer-use install --source /Applications/Codex.app/Contents/Resources/plugins/openai-bundled +``` **A Computer Use tool says `Native hook relay unavailable`.** The Codex-native tool hook could not reach an active OpenClaw relay through the local bridge or diff --git a/docs/plugins/codex-harness-reference.md b/docs/plugins/codex-harness-reference.md index 86ec057b9906..63da4af324a5 100644 --- a/docs/plugins/codex-harness-reference.md +++ b/docs/plugins/codex-harness-reference.md @@ -155,9 +155,13 @@ shorthand before OpenClaw builds app-server start options, and unresolved structured SecretRefs fail before any token or header is sent. When native Codex plugins are configured, OpenClaw uses the connected app-server's plugin control plane to install or refresh those plugins and then refreshes app inventory so -plugin-owned apps are visible to the Codex thread. Only connect OpenClaw to -remote app-servers that are trusted to accept OpenClaw-managed plugin installs -and app inventory refreshes. +plugin-owned apps are visible to the Codex thread. `app/list` is still the +authoritative inventory and metadata source, but OpenClaw policy decides whether +`thread/start` sends `config.apps[appId].enabled = true` for a listed accessible +app even if Codex currently marks it disabled. Unknown or missing app ids remain +fail-closed; this path only activates marketplace plugins via `plugin/install` +and refreshes inventory. Only connect OpenClaw to remote app-servers that are +trusted to accept OpenClaw-managed plugin installs and app inventory refreshes. ## Approval and sandbox modes diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index edc34dcde231..a67c120e6d37 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -465,7 +465,13 @@ do not receive Gateway env API-key fallback; use an explicit auth profile or the remote app-server's own account. When native Codex plugins are configured, OpenClaw installs or refreshes those plugins through the connected app-server before exposing plugin-owned apps to -the Codex thread. +the Codex thread. `app/list` remains the source of truth for app ids, +accessibility, and metadata, but OpenClaw owns the per-thread enablement +decision: if policy allows a listed accessible app, OpenClaw sends +`thread/start.config.apps[appId].enabled = true` even when `app/list` currently +reports that app disabled. This path does not invent app installation for +unknown ids; OpenClaw only activates marketplace plugins with `plugin/install` +and then refreshes inventory. If a subscription profile hits a Codex usage limit, OpenClaw records the reset time when Codex reports one and tries the next ordered auth profile for the same diff --git a/docs/plugins/manage-plugins.md b/docs/plugins/manage-plugins.md index f095fb306449..86ca588f5dc1 100644 --- a/docs/plugins/manage-plugins.md +++ b/docs/plugins/manage-plugins.md @@ -110,6 +110,13 @@ When you pass a plugin id, OpenClaw reuses the tracked install spec. Stored dist-tags such as `@beta` and exact pinned versions continue to be used on later `update ` runs. +`openclaw plugins update --all` is the bulk maintenance path. It still respects +ordinary tracked install specs, but trusted official OpenClaw plugin records can +sync to the current official catalog target instead of staying on a stale exact +official package. If `update.channel` is set to `beta`, that bulk official sync +uses the beta-channel context. Use a targeted `update ` when you +intentionally want to keep an exact or tagged official spec untouched. + For npm installs, you can pass an explicit package spec to switch the tracked record: diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index 801fa27bde86..5e810ec601ab 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -739,7 +739,7 @@ Write colocated tests in `src/channel.test.ts`: describeMessageTool and action discovery - inferTargetChatType, looksLikeId, resolveTarget + inferTargetChatType, looksLikeId, reservedLiterals, resolveTarget TTS, STT, media, subagent via api.runtime diff --git a/docs/prose.md b/docs/prose.md index 55388706481a..1b32aedadf03 100644 --- a/docs/prose.md +++ b/docs/prose.md @@ -71,6 +71,11 @@ OpenProse registers `/prose` as a user-invocable skill command: `/prose run ` resolves to `https://p.prose.md//`. Direct URLs are fetched as-is using the `web_fetch` tool. +Top-level remote runs are explicit. Remote imports inside a `.prose` program are +transitive code dependencies: before OpenProse fetches any remote `use` target, +it shows the resolved import list and requires the operator to reply exactly +`approve remote prose imports` for that run. + ## What it can do - Multi-agent research and synthesis with explicit parallelism. @@ -167,9 +172,12 @@ User-level persistent agents live at: ## Security -Treat `.prose` files like code. Review them before running. Use OpenClaw tool -allowlists and approval gates to control side effects. For deterministic, -approval-gated workflows, compare with [Lobster](/tools/lobster). +Treat `.prose` files like code. Review them before running, including remote +`use` imports. Top-level `/prose run https://...` requests are explicit, but +transitive remote imports require per-run approval before they are fetched or +executed. Use OpenClaw tool allowlists and approval gates to control side +effects. For deterministic, approval-gated workflows, compare with +[Lobster](/tools/lobster). ## Related diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index 4c004f229a58..a64c16909f63 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -81,6 +81,7 @@ Session persistence has automatic maintenance controls (`session.maintenance`) f - `mode`: `enforce` (default) or `warn` - `pruneAfter`: stale-entry age cutoff (default `30d`) - `maxEntries`: cap entries in `sessions.json` (default `500`) +- Short-lived gateway model-run probe retention is fixed at `24h`, but it is pressure-gated: it only removes stale strict probe rows when session-entry maintenance/cap pressure is reached. This applies only to strict explicit probe keys matching `agent:*:explicit:model-run-` and runs before global stale-entry cleanup/capping when it runs. - `resetArchiveRetention`: retention for `*.reset.` transcript archives (default: same as `pruneAfter`; `false` disables cleanup) - `maxDiskBytes`: optional sessions-directory budget - `highWaterBytes`: optional target after cleanup (default `80%` of `maxDiskBytes`) @@ -90,7 +91,12 @@ Normal Gateway writes flow through a per-store session writer that serializes in Maintenance keeps durable external conversation pointers such as group sessions and thread-scoped chat sessions, but synthetic runtime entries for cron, hooks, heartbeat, ACP, and sub-agents can still be removed when they exceed the -configured age, count, or disk budget. +configured age, count, or disk budget. Gateway model-run probe sessions use the +separate `24h` model-run retention only when their key exactly matches +`agent:*:explicit:model-run-`; other explicit sessions are not part of +that retention. The model-run cleanup is applied only under session-entry cap +pressure. Isolated cron runs keep their own `cron.sessionRetention` control, +independent of model-run probe retention. OpenClaw no longer creates automatic `sessions.json.bak.*` rotation backups during Gateway writes. The legacy `session.maintenance.rotateBytes` key is ignored and `openclaw doctor --fix` removes it from older configs. diff --git a/docs/reference/token-use.md b/docs/reference/token-use.md index 801c00846b58..4cbb066d5aff 100644 --- a/docs/reference/token-use.md +++ b/docs/reference/token-use.md @@ -76,6 +76,8 @@ Use these in chat: configured for the active model. - `/usage off|tokens|full` → appends a **per-response usage footer** to every reply. - Persists per session (stored as `responseUsage`). + - `/usage reset` (aliases: `inherit`, `clear`, `default`) — clears the session + override so the session re-inherits the configured default. - `/usage full` shows estimated cost only when OpenClaw has usage metadata and local pricing for the active model. Otherwise it shows tokens only. - `/usage cost` → shows a local cost summary from OpenClaw session logs. diff --git a/docs/style.css b/docs/style.css index a042ffad2f98..a05006238078 100644 --- a/docs/style.css +++ b/docs/style.css @@ -269,7 +269,7 @@ html.dark .nav-tabs-underline { .maturity-summary-grid { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); margin: 14px 0 20px; border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 18%, transparent); border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 18%, transparent); diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index dcf528d95449..d8d6a206f009 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -240,7 +240,7 @@ plugins. | `/tasks` | List active/recent background tasks for the current session | | `/context [list\|detail\|map\|json]` | Explain how context is assembled | | `/whoami` | Show your sender id. Alias: `/id` | - | `/usage off\|tokens\|full\|cost` | Control the per-response usage footer or print a local cost summary | + | `/usage off\|tokens\|full\|reset\|cost` | Control the per-response usage footer (`reset`/`inherit`/`clear`/`default` clears the session override to re-inherit the configured default) or print a local cost summary |
diff --git a/docs/web/tui.md b/docs/web/tui.md index dce135ef7ef6..38b20eef3dd7 100644 --- a/docs/web/tui.md +++ b/docs/web/tui.md @@ -126,7 +126,7 @@ Session controls: - `/verbose ` - `/trace ` - `/reasoning ` -- `/usage ` +- `/usage ` (`reset`/`inherit`/`clear`/`default` clears the session override) - `/goal [status] | /goal start | /goal pause|resume|complete|block|clear` - `/elevated ` (alias: `/elev`) - `/activation ` diff --git a/extensions/azure-speech/tts.ts b/extensions/azure-speech/tts.ts index 1f5eeb1f9460..fb90ad11c930 100644 --- a/extensions/azure-speech/tts.ts +++ b/extensions/azure-speech/tts.ts @@ -2,7 +2,10 @@ * Azure Speech REST helpers. They normalize endpoints, build SSML, list voices, * and synthesize speech with response-size and SSRF guards. */ -import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/provider-http"; +import { + assertOkOrThrowProviderError, + readProviderJsonResponse, +} from "openclaw/plugin-sdk/provider-http"; import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; import type { SpeechVoiceOption } from "openclaw/plugin-sdk/speech-core"; import { trimToUndefined } from "openclaw/plugin-sdk/speech-core"; @@ -160,7 +163,10 @@ export async function listAzureSpeechVoices(params: { try { await assertOkOrThrowProviderError(response, "Azure Speech voices API error"); - const voices = (await response.json()) as AzureSpeechVoiceEntry[]; + const voices = await readProviderJsonResponse( + response, + "azure-speech.voices", + ); return Array.isArray(voices) ? voices .filter((voice) => !isDeprecatedVoice(voice)) diff --git a/extensions/byteplus/video-generation-provider.test.ts b/extensions/byteplus/video-generation-provider.test.ts index acac8b50e868..112439926ac3 100644 --- a/extensions/byteplus/video-generation-provider.test.ts +++ b/extensions/byteplus/video-generation-provider.test.ts @@ -1,12 +1,70 @@ // Byteplus tests cover video generation provider plugin behavior. -import { - getProviderHttpMocks, - installProviderHttpMockCleanup, -} from "openclaw/plugin-sdk/provider-http-test-mocks"; import { expectExplicitVideoGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts"; -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -const { postJsonRequestMock, fetchWithTimeoutMock } = getProviderHttpMocks(); +// Submit/poll transport is mocked locally so each test can inject the BytePlus task JSON +// bodies, while readProviderJsonResponse is kept REAL (via importActual) so the byte-bounded +// reader actually streams and cancels oversized bodies under test instead of a stub. +const { postJsonRequestMock, fetchWithTimeoutMock, resolveApiKeyForProviderMock } = vi.hoisted( + () => ({ + postJsonRequestMock: vi.fn(), + fetchWithTimeoutMock: vi.fn(), + resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "provider-key" })), + }), +); + +vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({ + resolveApiKeyForProvider: resolveApiKeyForProviderMock, +})); + +vi.mock("openclaw/plugin-sdk/provider-http", async (importActual) => { + const actual = await importActual(); + const resolveTimeoutMs = (timeoutMs: unknown): number => + typeof timeoutMs === "function" ? (timeoutMs() as number) : ((timeoutMs as number) ?? 60_000); + return { + // REAL byte-bounded JSON reader under test — not stubbed. + readProviderJsonResponse: actual.readProviderJsonResponse, + postJsonRequest: postJsonRequestMock, + fetchProviderOperationResponse: async (params: { + url: string; + init?: RequestInit; + timeoutMs?: unknown; + fetchFn: typeof fetch; + }) => fetchWithTimeoutMock(params.url, params.init ?? {}, resolveTimeoutMs(params.timeoutMs)), + fetchProviderDownloadResponse: async (params: { + url: string; + init?: RequestInit; + timeoutMs?: unknown; + fetchFn: typeof fetch; + }) => fetchWithTimeoutMock(params.url, params.init ?? {}, resolveTimeoutMs(params.timeoutMs)), + assertOkOrThrowHttpError: async () => {}, + createProviderOperationDeadline: ({ + label, + timeoutMs, + }: { + label: string; + timeoutMs?: number; + }) => ({ label, timeoutMs }), + createProviderOperationTimeoutResolver: + ({ defaultTimeoutMs }: { defaultTimeoutMs: number }) => + () => + defaultTimeoutMs, + resolveProviderOperationTimeoutMs: ({ defaultTimeoutMs }: { defaultTimeoutMs: number }) => + defaultTimeoutMs, + resolveProviderHttpRequestConfig: (params: { + baseUrl?: string; + defaultBaseUrl: string; + allowPrivateNetwork?: boolean; + defaultHeaders?: Record; + }) => ({ + baseUrl: params.baseUrl ?? params.defaultBaseUrl, + allowPrivateNetwork: params.allowPrivateNetwork === true, + headers: new Headers(params.defaultHeaders), + dispatcherPolicy: undefined, + }), + waitProviderOperationPollInterval: async () => {}, + }; +}); let buildBytePlusVideoGenerationProvider: typeof import("./video-generation-provider.js").buildBytePlusVideoGenerationProvider; @@ -14,20 +72,22 @@ beforeAll(async () => { ({ buildBytePlusVideoGenerationProvider } = await import("./video-generation-provider.js")); }); -installProviderHttpMockCleanup(); +afterEach(() => { + postJsonRequestMock.mockReset(); + fetchWithTimeoutMock.mockReset(); + resolveApiKeyForProviderMock.mockClear(); +}); function mockSuccessfulBytePlusTask(params?: { model?: string }) { postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => ({ - id: "task_123", - }), - }, + response: streamedJsonResponse({ + id: "task_123", + }), release: vi.fn(async () => {}), }); fetchWithTimeoutMock - .mockResolvedValueOnce({ - json: async () => ({ + .mockResolvedValueOnce( + streamedJsonResponse({ id: "task_123", status: "succeeded", content: { @@ -35,7 +95,7 @@ function mockSuccessfulBytePlusTask(params?: { model?: string }) { }, model: params?.model ?? "seedance-1-0-lite-t2v-250428", }), - }) + ) .mockResolvedValueOnce({ headers: new Headers({ "content-type": "video/webm" }), arrayBuffer: async () => Buffer.from("webm-bytes"), @@ -77,6 +137,53 @@ function streamedVideoResponse(bytes: string): Response { ); } +// BytePlus submit/poll task JSON is now read through the byte-bounded reader, so the +// mocked responses must expose a real readable body (not just a json() shortcut). +function streamedJsonResponse(payload: unknown): Response { + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(JSON.stringify(payload))); + controller.close(); + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); +} + +// Builds a JSON body larger than the shared 16 MiB readProviderJsonResponse cap so the +// bounded reader cancels the stream mid-flight; if the cap were removed the reader would +// buffer the whole advertised payload before parsing. Tracks how many bytes were pulled +// and whether the stream was canceled so callers can assert the body was not fully read. +function makeOversizedJsonStream(): { + body: ReadableStream; + maxBytes: number; + totalBytes: number; + state: { bytesPulled: number; canceled: boolean }; +} { + const maxBytes = 16 * 1024 * 1024; // matches PROVIDER_JSON_RESPONSE_MAX_BYTES. + const ONE_MIB = 1024 * 1024; + const TOTAL_CHUNKS = 32; // 32 MiB advertised body, double the cap. + const chunk = new Uint8Array(ONE_MIB); + const state = { bytesPulled: 0, canceled: false }; + let pulled = 0; + const body = new ReadableStream({ + pull(controller) { + if (pulled >= TOTAL_CHUNKS) { + controller.close(); + return; + } + pulled += 1; + state.bytesPulled += chunk.length; + controller.enqueue(chunk); + }, + cancel() { + state.canceled = true; + }, + }); + return { body, maxBytes, totalBytes: TOTAL_CHUNKS * ONE_MIB, state }; +} + describe("byteplus video generation provider", () => { it("declares explicit mode capabilities", () => { expectExplicitVideoGenerationCapabilities(buildBytePlusVideoGenerationProvider()); @@ -110,21 +217,19 @@ describe("byteplus video generation provider", () => { it("rejects generated video downloads that exceed the configured media cap", async () => { postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => ({ id: "task_too_large" }), - }, + response: streamedJsonResponse({ id: "task_too_large" }), release: vi.fn(async () => {}), }); fetchWithTimeoutMock - .mockResolvedValueOnce({ - json: async () => ({ + .mockResolvedValueOnce( + streamedJsonResponse({ id: "task_too_large", status: "succeeded", content: { video_url: "https://example.com/too-large.mp4", }, }), - }) + ) .mockResolvedValueOnce(streamedVideoResponse("too-large")); const provider = buildBytePlusVideoGenerationProvider(); @@ -222,16 +327,14 @@ describe("byteplus video generation provider", () => { it("drops malformed response duration metadata", async () => { postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => ({ - id: "task_123", - }), - }, + response: streamedJsonResponse({ + id: "task_123", + }), release: vi.fn(async () => {}), }); fetchWithTimeoutMock - .mockResolvedValueOnce({ - json: async () => ({ + .mockResolvedValueOnce( + streamedJsonResponse({ id: "task_123", status: "succeeded", content: { @@ -239,7 +342,7 @@ describe("byteplus video generation provider", () => { }, duration: 1.5, }), - }) + ) .mockResolvedValueOnce({ headers: new Headers({ "content-type": "video/mp4" }), arrayBuffer: async () => Buffer.from("mp4-bytes"), @@ -259,11 +362,15 @@ describe("byteplus video generation provider", () => { it("reports malformed create JSON with a provider-owned error", async () => { const release = vi.fn(async () => {}); postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => { - throw new SyntaxError("bad json"); - }, - }, + response: new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("{ not valid json")); + controller.close(); + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), release, }); @@ -281,19 +388,17 @@ describe("byteplus video generation provider", () => { it("rejects status responses missing a task status", async () => { postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => ({ id: "task_missing_status" }), - }, + response: streamedJsonResponse({ id: "task_missing_status" }), release: vi.fn(async () => {}), }); - fetchWithTimeoutMock.mockResolvedValueOnce({ - json: async () => ({ + fetchWithTimeoutMock.mockResolvedValueOnce( + streamedJsonResponse({ id: "task_missing_status", content: { video_url: "https://example.com/byteplus.mp4", }, }), - }); + ); const provider = buildBytePlusVideoGenerationProvider(); await expect( @@ -308,18 +413,16 @@ describe("byteplus video generation provider", () => { it("rejects malformed completed content", async () => { postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => ({ id: "task_malformed_content" }), - }, + response: streamedJsonResponse({ id: "task_malformed_content" }), release: vi.fn(async () => {}), }); - fetchWithTimeoutMock.mockResolvedValueOnce({ - json: async () => ({ + fetchWithTimeoutMock.mockResolvedValueOnce( + streamedJsonResponse({ id: "task_malformed_content", status: "succeeded", content: ["https://example.com/byteplus.mp4"], }), - }); + ); const provider = buildBytePlusVideoGenerationProvider(); await expect( @@ -331,4 +434,61 @@ describe("byteplus video generation provider", () => { }), ).rejects.toThrow("BytePlus video generation completed with malformed content"); }); + + it("bounds the submit task JSON body and cancels an oversized stream", async () => { + const stream = makeOversizedJsonStream(); + const release = vi.fn(async () => {}); + postJsonRequestMock.mockResolvedValue({ + response: new Response(stream.body, { + status: 200, + headers: { "content-type": "application/json" }, + }), + release, + }); + + const provider = buildBytePlusVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "byteplus", + model: "seedance-1-0-lite-t2v-250428", + prompt: "oversized submit response", + cfg: {}, + }), + ).rejects.toThrow( + `BytePlus video generation failed: JSON response exceeds ${stream.maxBytes} bytes`, + ); + expect(stream.state.canceled).toBe(true); + // Only the bounded prefix is pulled, never the full advertised stream. + expect(stream.state.bytesPulled).toBeLessThan(stream.totalBytes); + // The submit request must still be released even though the body overflowed. + expect(release).toHaveBeenCalledOnce(); + }); + + it("bounds the poll status JSON body and cancels an oversized stream", async () => { + postJsonRequestMock.mockResolvedValue({ + response: streamedJsonResponse({ id: "task_oversized_poll" }), + release: vi.fn(async () => {}), + }); + const stream = makeOversizedJsonStream(); + fetchWithTimeoutMock.mockResolvedValueOnce( + new Response(stream.body, { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const provider = buildBytePlusVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "byteplus", + model: "seedance-1-0-lite-t2v-250428", + prompt: "oversized poll response", + cfg: {}, + }), + ).rejects.toThrow( + `BytePlus video status request failed: JSON response exceeds ${stream.maxBytes} bytes`, + ); + expect(stream.state.canceled).toBe(true); + expect(stream.state.bytesPulled).toBeLessThan(stream.totalBytes); + }); }); diff --git a/extensions/byteplus/video-generation-provider.ts b/extensions/byteplus/video-generation-provider.ts index 1c1734e4afee..b8a39e51c0d3 100644 --- a/extensions/byteplus/video-generation-provider.ts +++ b/extensions/byteplus/video-generation-provider.ts @@ -11,6 +11,7 @@ import { fetchProviderDownloadResponse, fetchProviderOperationResponse, postJsonRequest, + readProviderJsonResponse, resolveProviderOperationTimeoutMs, resolveProviderHttpRequestConfig, waitProviderOperationPollInterval, @@ -55,16 +56,13 @@ type BytePlusTaskResponse = { type BytePlusTaskStatus = "running" | "failed" | "queued" | "succeeded" | "cancelled"; -async function readBytePlusJsonResponse( - response: Pick, - label: string, -): Promise { - let payload: unknown; - try { - payload = await response.json(); - } catch (cause) { - throw new Error(`${label}: malformed JSON response`, { cause }); - } +async function readBytePlusJsonResponse(response: Response, label: string): Promise { + // BytePlus submit/poll task bodies are read through the shared byte-bounded reader + // (readResponseWithLimit, via readProviderJsonResponse) so a hostile or buggy endpoint + // that streams an unbounded JSON body cannot force the runtime to buffer the whole + // payload before parsing. Overflow cancels the stream and throws a bounded error; + // malformed JSON keeps the existing `${label}: malformed JSON response` wrapping. + const payload = await readProviderJsonResponse(response, label); if (!isRecord(payload)) { throw new Error(`${label}: malformed JSON response`); } diff --git a/extensions/codex/src/app-server/client.ts b/extensions/codex/src/app-server/client.ts index 016e06d6a3dd..ba37a5f0714f 100644 --- a/extensions/codex/src/app-server/client.ts +++ b/extensions/codex/src/app-server/client.ts @@ -639,6 +639,15 @@ function assertSupportedCodexAppServerVersion(response: CodexInitializeResponse) return detectedVersion; } +export function isUnsupportedCodexAppServerVersionError(error: unknown): boolean { + return ( + error instanceof Error && + error.message.startsWith( + `Codex app-server ${MIN_CODEX_APP_SERVER_VERSION} or newer is required`, + ) + ); +} + function buildCodexAppServerRuntimeIdentity( response: CodexInitializeResponse, serverVersion: string, diff --git a/extensions/codex/src/app-server/config.ts b/extensions/codex/src/app-server/config.ts index 92cf4071d90b..381677189a44 100644 --- a/extensions/codex/src/app-server/config.ts +++ b/extensions/codex/src/app-server/config.ts @@ -167,6 +167,7 @@ export type CodexAppServerStartOptions = { transport: CodexAppServerTransportMode; command: string; commandSource?: CodexAppServerCommandSource; + managedFallbackCommandPaths?: string[]; args: string[]; url?: string; authToken?: string; @@ -332,7 +333,9 @@ const codexAppServerNetworkProxySchema = z baseProfile: z.enum(["read-only", "workspace"]).optional(), mode: z.enum(["limited", "full"]).optional(), domains: z.record(z.string(), codexAppServerNetworkProxyDomainPermissionSchema).optional(), - unixSockets: z.record(z.string(), codexAppServerNetworkProxyUnixSocketPermissionSchema).optional(), + unixSockets: z + .record(z.string(), codexAppServerNetworkProxyUnixSocketPermissionSchema) + .optional(), proxyUrl: z.string().trim().min(1).optional(), socksUrl: z.string().trim().min(1).optional(), enableSocks5: z.boolean().optional(), @@ -874,6 +877,7 @@ export function codexAppServerStartOptionsKey( transport: options.transport, command: options.command, commandSource: options.commandSource ?? null, + managedFallbackCommandPaths: [...(options.managedFallbackCommandPaths ?? [])], args: options.args, url: options.url ?? null, authToken: hashSecretForKey(options.authToken, "authToken"), diff --git a/extensions/codex/src/app-server/managed-binary.test.ts b/extensions/codex/src/app-server/managed-binary.test.ts index 08f0dec69780..7b1faa3dbd1f 100644 --- a/extensions/codex/src/app-server/managed-binary.test.ts +++ b/extensions/codex/src/app-server/managed-binary.test.ts @@ -27,6 +27,8 @@ function managedCommandPath(root: string, platform: NodeJS.Platform): string { return pathApi.join(root, "node_modules", ".bin", platform === "win32" ? "codex.cmd" : "codex"); } +const MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND = "/Applications/Codex.app/Contents/Resources/codex"; + describe("managed Codex app-server binary", () => { it("leaves explicit command overrides unchanged", async () => { const explicitOptions = startOptions("config"); @@ -41,10 +43,14 @@ describe("managed Codex app-server binary", () => { expect(pathExists).not.toHaveBeenCalled(); }); - it("resolves the plugin-local bundled Codex binary", async () => { + it("prefers the macOS desktop app bundle when it exists", async () => { const pluginRoot = path.join("/tmp", "openclaw", "extensions", "codex"); const paths = resolveManagedCodexAppServerPaths({ platform: "darwin", pluginRoot }); - const pathExists = vi.fn(async (filePath: string) => filePath === paths.commandPath); + const pluginLocalCommand = managedCommandPath(pluginRoot, "darwin"); + const pathExists = vi.fn( + async (filePath: string) => + filePath === MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND || filePath === pluginLocalCommand, + ); await expect( resolveManagedCodexAppServerStartOptions(startOptions("managed"), { @@ -54,10 +60,31 @@ describe("managed Codex app-server binary", () => { }), ).resolves.toEqual({ ...startOptions("managed"), - command: paths.commandPath, + command: MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND, + commandSource: "resolved-managed", + managedFallbackCommandPaths: [pluginLocalCommand], + }); + expect(paths.commandPath).toBe(MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND); + expect(paths.candidateCommandPaths).toContain(pluginLocalCommand); + }); + + it("falls back to the plugin-local bundled Codex binary on macOS", async () => { + const pluginRoot = path.join("/tmp", "openclaw", "extensions", "codex"); + const pluginLocalCommand = managedCommandPath(pluginRoot, "darwin"); + const pathExists = vi.fn(async (filePath: string) => filePath === pluginLocalCommand); + + await expect( + resolveManagedCodexAppServerStartOptions(startOptions("managed"), { + platform: "darwin", + pluginRoot, + pathExists, + }), + ).resolves.toEqual({ + ...startOptions("managed"), + command: pluginLocalCommand, commandSource: "resolved-managed", }); - expect(paths.commandPath).toBe(managedCommandPath(pluginRoot, "darwin")); + expect(pathExists).toHaveBeenCalledWith(MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND, "darwin"); }); it("resolves Windows Codex command shims", () => { diff --git a/extensions/codex/src/app-server/managed-binary.ts b/extensions/codex/src/app-server/managed-binary.ts index ab2956d5e6c8..e173dcfa2296 100644 --- a/extensions/codex/src/app-server/managed-binary.ts +++ b/extensions/codex/src/app-server/managed-binary.ts @@ -12,6 +12,7 @@ import { MANAGED_CODEX_APP_SERVER_PACKAGE } from "./version.js"; const CODEX_APP_SERVER_MODULE_DIR = path.dirname(fileURLToPath(import.meta.url)); const CODEX_PLUGIN_ROOT = resolveDefaultCodexPluginRoot(CODEX_APP_SERVER_MODULE_DIR); +const MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND = "/Applications/Codex.app/Contents/Resources/codex"; type ManagedCodexAppServerPaths = { commandPath: string; @@ -39,16 +40,19 @@ export async function resolveManagedCodexAppServerStartOptions( pluginRoot: options.pluginRoot, }); const pathExists = options.pathExists ?? commandPathExists; - const commandPath = await findManagedCodexAppServerCommandPath({ + const commandPaths = await findManagedCodexAppServerCommandPaths({ candidateCommandPaths: paths.candidateCommandPaths, pathExists, platform, }); + const commandPath = commandPaths[0]; + const managedFallbackCommandPaths = commandPaths.slice(1); return { ...startOptions, command: commandPath, commandSource: "resolved-managed", + ...(managedFallbackCommandPaths.length > 0 ? { managedFallbackCommandPaths } : {}), }; } @@ -77,12 +81,17 @@ function resolveManagedCodexAppServerCommandCandidates( const roots = resolveManagedCodexAppServerCandidateRoots(pluginRoot, platform); return [ ...new Set([ + ...resolveDesktopCodexAppServerCommandCandidates(platform), ...roots.map((root) => pathApi.join(root, "node_modules", ".bin", commandName)), ...resolveManagedCodexPackageBinCandidates(roots, platform), ]), ]; } +function resolveDesktopCodexAppServerCommandCandidates(platform: NodeJS.Platform): string[] { + return platform === "darwin" ? [MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND] : []; +} + function resolveDefaultCodexPluginRoot(moduleDir: string): string { const moduleBaseName = path.basename(moduleDir); if (moduleBaseName === "dist" || moduleBaseName === "dist-runtime") { @@ -195,16 +204,20 @@ function pathForPlatform(platform: NodeJS.Platform): typeof path { return platform === "win32" ? path.win32 : path.posix; } -async function findManagedCodexAppServerCommandPath(params: { +async function findManagedCodexAppServerCommandPaths(params: { candidateCommandPaths: readonly string[]; pathExists: (filePath: string, platform: NodeJS.Platform) => Promise; platform: NodeJS.Platform; -}): Promise { +}): Promise { + const commandPaths: string[] = []; for (const commandPath of params.candidateCommandPaths) { if (await params.pathExists(commandPath, params.platform)) { - return commandPath; + commandPaths.push(commandPath); } } + if (commandPaths.length > 0) { + return commandPaths; + } throw new Error( [ diff --git a/extensions/codex/src/app-server/plugin-thread-config.test.ts b/extensions/codex/src/app-server/plugin-thread-config.test.ts index e5431ed50047..b1bcfb594078 100644 --- a/extensions/codex/src/app-server/plugin-thread-config.test.ts +++ b/extensions/codex/src/app-server/plugin-thread-config.test.ts @@ -254,7 +254,7 @@ describe("Codex plugin thread config", () => { const request = vi.fn(async (method: string, params?: unknown) => { if (method === "app/list") { appListParams.push(params as v2.AppsListParams); - return { data: [appInfo("google-calendar-app", true)], nextCursor: null }; + return { data: [appInfo("google-calendar-app", true, false)], nextCursor: null }; } if (method === "plugin/list") { return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]); @@ -317,6 +317,117 @@ describe("Codex plugin thread config", () => { ]); }); + it("re-enables an OpenClaw-allowed app even when app/list reports it disabled", async () => { + const appCache = new CodexAppInventoryCache(); + await appCache.refreshNow({ + key: "runtime", + nowMs: 0, + request: async () => ({ + data: [appInfo("google-calendar-app", true, false)], + nextCursor: null, + }), + }); + + const config = await buildCodexPluginThreadConfig({ + pluginConfig: { + codexPlugins: { + enabled: true, + plugins: { + "google-calendar": { + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + }, + }, + }, + }, + appCache, + appCacheKey: "runtime", + nowMs: 1, + request: async (method) => { + if (method === "plugin/list") { + return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]); + } + if (method === "plugin/read") { + return pluginDetail("google-calendar", [appSummary("google-calendar-app")]); + } + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(config.inventory?.records[0]?.apps).toStrictEqual([ + { + id: "google-calendar-app", + name: "google-calendar-app", + accessible: true, + enabled: false, + needsAuth: false, + }, + ]); + expect(config.configPatch?.apps).toMatchObject({ + "google-calendar-app": { + enabled: true, + }, + }); + expect(config.diagnostics).toStrictEqual([]); + }); + + it("refreshes missing app inventory when plugin activation becomes unnecessary", async () => { + const appCache = new CodexAppInventoryCache(); + const appListParams: v2.AppsListParams[] = []; + let pluginListCalls = 0; + const request = vi.fn(async (method: string, params?: unknown) => { + if (method === "plugin/list") { + pluginListCalls += 1; + const active = pluginListCalls > 1; + return pluginList([ + pluginSummary("google-calendar", { installed: active, enabled: active }), + ]); + } + if (method === "plugin/read") { + return pluginDetail("google-calendar", [appSummary("google-calendar-app")]); + } + if (method === "app/list") { + appListParams.push(params as v2.AppsListParams); + return { + data: [appInfo("google-calendar-app", true)], + nextCursor: null, + } satisfies v2.AppsListResponse; + } + throw new Error(`unexpected request ${method}`); + }); + + const config = await buildCodexPluginThreadConfig({ + pluginConfig: { + codexPlugins: { + enabled: true, + plugins: { + "google-calendar": { + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + }, + }, + }, + }, + appCache, + appCacheKey: "runtime", + request, + }); + + expect(config.configPatch?.apps).toMatchObject({ + "google-calendar-app": { + enabled: true, + }, + }); + expect(request.mock.calls.map(([method]) => method)).not.toContain("plugin/install"); + expect(appListParams).toEqual([ + { + cursor: undefined, + limit: 100, + forceRefetch: true, + }, + ]); + }); + it("does not expose plugin apps missing from the app inventory snapshot", async () => { const appCache = new CodexAppInventoryCache(); await appCache.refreshNow({ @@ -375,11 +486,59 @@ describe("Codex plugin thread config", () => { allowDestructiveActions: true, destructiveApprovalMode: "allow", }, - message: "google-calendar-app is not accessible or enabled for google-calendar.", + message: "google-calendar-app is not accessible for google-calendar.", }, ]); }); + it("does not expose apps for plugins that OpenClaw policy leaves disabled", async () => { + const appCache = new CodexAppInventoryCache(); + await appCache.refreshNow({ + key: "runtime", + nowMs: 0, + request: async () => ({ + data: [appInfo("google-calendar-app", true)], + nextCursor: null, + }), + }); + + const config = await buildCodexPluginThreadConfig({ + pluginConfig: { + codexPlugins: { + enabled: true, + plugins: { + "google-calendar": { + enabled: false, + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + }, + }, + }, + }, + appCache, + appCacheKey: "runtime", + nowMs: 1, + request: async (method) => { + if (method === "plugin/list") { + return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]); + } + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(config.configPatch).toEqual({ + apps: { + _default: { + enabled: false, + destructive_enabled: false, + open_world_enabled: false, + }, + }, + }); + expect(config.policyContext.apps).toStrictEqual({}); + expect(config.diagnostics).toStrictEqual([]); + }); + it("force-refreshes app inventory when proven plugin apps are not ready", async () => { const appCache = new CodexAppInventoryCache(); await appCache.refreshNow({ @@ -572,9 +731,7 @@ describe("Codex plugin thread config", () => { let installed = false; const request = vi.fn(async (method: string, params?: unknown) => { if (method === "plugin/list") { - return pluginList([ - pluginSummary("google-calendar", { installed, enabled: installed }), - ]); + return pluginList([pluginSummary("google-calendar", { installed, enabled: installed })]); } if (method === "plugin/read") { return pluginDetail("google-calendar", [appSummary("google-calendar-app")]); @@ -738,6 +895,70 @@ describe("Codex plugin thread config", () => { ]); }); + it("fails closed when app inventory entries are malformed", async () => { + const appCache = new CodexAppInventoryCache(); + await appCache.refreshNow({ + key: "runtime", + nowMs: 0, + request: async () => + ({ + data: [{ ...appInfo("google-calendar-app", true), id: "" }] as unknown as v2.AppInfo[], + nextCursor: null, + }) satisfies v2.AppsListResponse, + }); + + const config = await buildCodexPluginThreadConfig({ + pluginConfig: { + codexPlugins: { + enabled: true, + plugins: { + "google-calendar": { + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + }, + }, + }, + }, + appCache, + appCacheKey: "runtime", + nowMs: 1, + request: async (method) => { + if (method === "plugin/list") { + return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]); + } + if (method === "plugin/read") { + return pluginDetail("google-calendar", [appSummary("google-calendar-app")]); + } + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(config.configPatch).toEqual({ + apps: { + _default: { + enabled: false, + destructive_enabled: false, + open_world_enabled: false, + }, + }, + }); + expect(config.policyContext.apps).toStrictEqual({}); + expect(config.diagnostics).toStrictEqual([ + { + code: "app_not_ready", + plugin: { + configKey: "google-calendar", + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + enabled: true, + allowDestructiveActions: true, + destructiveApprovalMode: "allow", + }, + message: "google-calendar-app is not accessible for google-calendar.", + }, + ]); + }); + it("uses durable policy and app cache key in the cheap input fingerprint", async () => { const appCache = new CodexAppInventoryCache(); const first = buildCodexPluginThreadConfigInputFingerprint({ diff --git a/extensions/codex/src/app-server/plugin-thread-config.ts b/extensions/codex/src/app-server/plugin-thread-config.ts index a3a6e815512a..e9dd72a1ba30 100644 --- a/extensions/codex/src/app-server/plugin-thread-config.ts +++ b/extensions/codex/src/app-server/plugin-thread-config.ts @@ -125,6 +125,9 @@ export async function buildCodexPluginThreadConfig( nowMs: params.nowMs, suppressAppInventoryRefresh: true, }); + const appInventoryRefreshDeferredForActivation = + inventory.records.some((record) => record.activationRequired) && + shouldRefreshMissingAppInventory(params, policy, inventory); if (shouldWaitForInitialAppInventory(params, policy, inventory)) { await refreshAppInventoryNow(params, appCache, { forceRefetch: true, @@ -166,10 +169,19 @@ export async function buildCodexPluginThreadConfig( }); } } - if (activationResults.some((activation) => activation.ok && activation.installAttempted)) { + const postInstallRefreshRequired = activationResults.some( + (activation) => activation.ok && activation.installAttempted, + ); + // Activation can become unnecessary or fail before it refreshes apps. Rebuild the + // deferred missing snapshot so unrelated active plugin apps are not silently erased. + const deferredMissingRefreshRequired = + appInventoryRefreshDeferredForActivation && + !postInstallRefreshRequired && + shouldRefreshMissingAppInventory(params, policy, inventory); + if (postInstallRefreshRequired || deferredMissingRefreshRequired) { await refreshAppInventoryNow(params, appCache, { forceRefetch: true, - reason: "post_install", + reason: postInstallRefreshRequired ? "post_install" : "deferred_missing", targetAppIds: collectInventoryOwnedAppIds(inventory), }); inventory = await readCodexPluginInventory({ @@ -219,24 +231,22 @@ export async function buildCodexPluginThreadConfig( const policyApps: Record = {}; const pluginAppIds: Record = {}; for (const record of inventory.records) { - if (record.activationRequired) { - const activation = activationResults.find( - (item) => item.identity.configKey === record.policy.configKey, - ); - if (!activation?.ok) { - continue; - } + const activation = activationResults.find( + (item) => item.identity.configKey === record.policy.configKey, + ); + if (activation?.ok === false || (record.activationRequired && !activation?.ok)) { + continue; } if (record.appOwnership !== "proven") { continue; } pluginAppIds[record.policy.configKey] = [...record.ownedAppIds].toSorted(); for (const app of resolveThreadConfigAppsForRecord({ record, inventory })) { - if (!app.accessible || !app.enabled) { + if (!isPluginAppReadyForThreadStart(app)) { diagnostics.push({ code: "app_not_ready", plugin: record.policy, - message: `${app.id} is not accessible or enabled for ${record.policy.pluginName}.`, + message: `${app.id} is not accessible for ${record.policy.pluginName}.`, }); continue; } @@ -362,9 +372,18 @@ function shouldWaitForInitialAppInventory( policy: ResolvedCodexPluginsPolicy, inventory: CodexPluginInventory, ): boolean { + // Install/enable first so the initial app/list can observe newly activated plugin apps. if (inventory.records.some((record) => record.activationRequired)) { return false; } + return shouldRefreshMissingAppInventory(params, policy, inventory); +} + +function shouldRefreshMissingAppInventory( + params: BuildCodexPluginThreadConfigParams, + policy: ResolvedCodexPluginsPolicy, + inventory: CodexPluginInventory, +): boolean { return Boolean( params.appCacheKey && policy.pluginPolicies.some((plugin) => plugin.enabled) && @@ -419,6 +438,13 @@ function resolveThreadConfigAppsForRecord(params: { return params.record.apps; } +function isPluginAppReadyForThreadStart(app: CodexPluginOwnedApp): boolean { + // `app/list` is the source of truth for inventory and access posture, but + // OpenClaw owns the per-thread enablement decision. A listed app that is + // accessible can be re-enabled for this thread via `config.apps[app.id]`. + return app.accessible; +} + function shouldForceRefreshForNotReadyPluginApps( params: BuildCodexPluginThreadConfigParams, policy: ResolvedCodexPluginsPolicy, @@ -434,7 +460,7 @@ function shouldForceRefreshForNotReadyPluginApps( (record) => record.appOwnership === "proven" && record.ownedAppIds.length > 0 && - (record.apps.length === 0 || record.apps.some((app) => !app.accessible || !app.enabled)), + (record.apps.length === 0 || record.apps.some((app) => !app.accessible)), ); } diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 4aae04099155..9300ec29a9da 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -4416,6 +4416,131 @@ describe("runCodexAppServerAttempt", () => { expect(requests.map((entry) => entry.method)).not.toContain("app/list"); }); + it("sends a thread/start app enable override when app/list cached the app as disabled", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const agentDir = path.join(tempDir, "agent"); + const pluginConfig = { + codexPlugins: { + enabled: true, + plugins: { + "google-calendar": { + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }, + }, + }; + const appServer = resolveCodexAppServerRuntimeOptions({ + pluginConfig: readCodexPluginConfig(pluginConfig), + }); + defaultCodexAppInventoryCache.clear(); + await defaultCodexAppInventoryCache.refreshNow({ + key: buildCodexPluginAppCacheKey({ + appServer, + agentDir, + runtimeIdentity: getMockRuntimeIdentity(), + }), + request: async () => ({ + data: [ + { + id: "google-calendar-app", + name: "Google Calendar", + description: null, + logoUrl: null, + logoUrlDark: null, + distributionChannel: null, + branding: null, + appMetadata: null, + labels: null, + installUrl: null, + isAccessible: true, + isEnabled: false, + pluginDisplayNames: [], + }, + ], + nextCursor: null, + }), + }); + const { requests, waitForMethod, completeTurn } = createStartedThreadHarness(async (method) => { + if (method === "plugin/list") { + return { + marketplaces: [ + { + name: "openai-curated", + path: "/marketplaces/openai-curated", + interface: null, + plugins: [ + { + id: "google-calendar", + name: "google-calendar", + source: { type: "remote" }, + installed: true, + enabled: true, + installPolicy: "AVAILABLE", + authPolicy: "ON_USE", + availability: "AVAILABLE", + interface: null, + }, + ], + }, + ], + marketplaceLoadErrors: [], + featuredPluginIds: [], + }; + } + if (method === "plugin/read") { + return { + plugin: { + marketplaceName: "openai-curated", + marketplacePath: "/marketplaces/openai-curated", + summary: { + id: "google-calendar", + name: "google-calendar", + source: { type: "remote" }, + installed: true, + enabled: true, + installPolicy: "AVAILABLE", + authPolicy: "ON_USE", + availability: "AVAILABLE", + interface: null, + }, + description: null, + skills: [], + apps: [ + { + id: "google-calendar-app", + name: "Google Calendar", + description: null, + installUrl: null, + needsAuth: false, + }, + ], + mcpServers: ["google-calendar"], + }, + }; + } + if (method === "app/list") { + throw new Error("app/list should use the cached inventory entry"); + } + return undefined; + }); + const params = createParams(sessionFile, workspaceDir); + params.agentDir = agentDir; + + const run = runCodexAppServerAttempt(params, { pluginConfig }); + await waitForMethod("turn/start"); + await completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await run; + + const threadStart = requests.find((entry) => entry.method === "thread/start"); + const threadStartParams = threadStart?.params as + | { config?: { apps?: Record } } + | undefined; + expect(threadStartParams?.config?.apps?.["google-calendar-app"]?.enabled).toBe(true); + expect(requests.map((entry) => entry.method)).not.toContain("app/list"); + }); + it("keys plugin app inventory by inherited API key fallback credentials", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); diff --git a/extensions/codex/src/app-server/shared-client.test.ts b/extensions/codex/src/app-server/shared-client.test.ts index f120ccb37199..d7b30271be1c 100644 --- a/extensions/codex/src/app-server/shared-client.test.ts +++ b/extensions/codex/src/app-server/shared-client.test.ts @@ -187,6 +187,41 @@ describe("shared Codex app-server client", () => { startSpy.mockRestore(); }); + it("falls back to the next managed app-server when desktop initialize is unsupported", async () => { + const desktop = createClientHarness(); + const pluginLocal = createClientHarness(); + const startSpy = vi + .spyOn(CodexAppServerClient, "start") + .mockReturnValueOnce(desktop.client) + .mockReturnValueOnce(pluginLocal.client); + mocks.resolveManagedCodexAppServerStartOptions.mockImplementationOnce(async (startOptions) => ({ + ...startOptions, + command: "/Applications/Codex.app/Contents/Resources/codex", + commandSource: "resolved-managed", + managedFallbackCommandPaths: ["/cache/openclaw/codex"], + })); + + const listPromise = listCodexAppServerModels({ timeoutMs: 1000 }); + await sendInitializeResult(desktop, "openclaw/0.124.9 (macOS; test)"); + await sendInitializeResult(pluginLocal, "openclaw/0.125.0 (macOS; test)"); + await sendEmptyModelList(pluginLocal); + + await expect(listPromise).resolves.toEqual({ models: [] }); + expect(desktop.process.stdin.destroyed).toBe(true); + expect(pluginLocal.process.stdin.destroyed).toBe(false); + expect(startSpy).toHaveBeenCalledTimes(2); + expect(startSpy.mock.calls[0]?.[0]).toMatchObject({ + command: "/Applications/Codex.app/Contents/Resources/codex", + commandSource: "resolved-managed", + managedFallbackCommandPaths: ["/cache/openclaw/codex"], + }); + expect(startSpy.mock.calls[1]?.[0]).toMatchObject({ + command: "/cache/openclaw/codex", + commandSource: "resolved-managed", + }); + expect(startSpy.mock.calls[1]?.[0]).not.toHaveProperty("managedFallbackCommandPaths"); + }); + it("closes and clears a shared app-server when initialize times out", async () => { const first = createClientHarness(); const second = createClientHarness(); diff --git a/extensions/codex/src/app-server/shared-client.ts b/extensions/codex/src/app-server/shared-client.ts index 0e6fdb7fad58..0fed612321fb 100644 --- a/extensions/codex/src/app-server/shared-client.ts +++ b/extensions/codex/src/app-server/shared-client.ts @@ -11,7 +11,7 @@ import { resolveCodexAppServerAuthProfileStore, resolveCodexAppServerFallbackApiKeyCacheKey, } from "./auth-bridge.js"; -import { CodexAppServerClient } from "./client.js"; +import { CodexAppServerClient, isUnsupportedCodexAppServerVersionError } from "./client.js"; import { codexAppServerStartOptionsKey, resolveCodexAppServerRuntimeOptions, @@ -242,27 +242,23 @@ async function acquireSharedCodexAppServerClient( const sharedPromise = entry.promise ?? (entry.promise = (async () => { - const client = CodexAppServerClient.start(startOptions); + const client = await startInitializedCodexAppServerClient({ + startOptions, + agentDir, + authProfileId: usesNativeAuth ? null : authProfileId, + config: options?.config, + onStartedClient: (startedClient) => { + entry.client = startedClient; + startedClient.setActiveSharedLeaseCountProviderForUnscopedNotifications( + () => entry.activeLeases, + ); + options?.onStartedClient?.(startedClient); + }, + }); entry.client = client; - options?.onStartedClient?.(client); client.setActiveSharedLeaseCountProviderForUnscopedNotifications(() => entry.activeLeases); client.addCloseHandler((closedClient) => clearSharedClientEntryIfCurrent(key, closedClient)); - try { - await client.initialize(); - await applyCodexAppServerAuthProfile({ - client, - agentDir, - authProfileId: usesNativeAuth ? null : authProfileId, - startOptions, - config: options?.config, - }); - return client; - } catch (error) { - // Startup failures happen before callers own the shared client, so close - // the child here instead of leaving a rejected daemon attached to stdio. - client.close(); - throw error; - } + return client; })()); try { const client = await withTimeout( @@ -291,39 +287,110 @@ export async function createIsolatedCodexAppServerClient( ): Promise { const { agentDir, usesNativeAuth, authProfileId, authProfileStore, startOptions } = await resolveCodexAppServerClientStartContext(options); - const client = CodexAppServerClient.start(startOptions); - if (authProfileId) { - // Profile-backed Codex auth is ephemeral. Keep the host refresh callback - // available whether the profile came from a scoped store or persisted state. - client.addRequestHandler(async (request) => { - if (request.method !== "account/chatgptAuthTokens/refresh") { - return undefined; + return await startInitializedCodexAppServerClient({ + startOptions, + agentDir, + authProfileId: usesNativeAuth ? null : authProfileId, + authProfileStore, + config: options?.config, + timeoutMs: options?.timeoutMs, + onStartedClient: options?.onStartedClient, + }); +} + +async function startInitializedCodexAppServerClient(params: { + startOptions: CodexAppServerStartOptions; + agentDir: string; + authProfileId: string | null | undefined; + authProfileStore?: AuthProfileStore; + config?: CodexAppServerClientOptions["config"]; + timeoutMs?: number; + onStartedClient?: (client: CodexAppServerClient) => void; +}): Promise { + const startOptionsCandidates = resolveManagedFallbackStartOptions(params.startOptions); + for (let index = 0; index < startOptionsCandidates.length; index += 1) { + const startOptions = startOptionsCandidates[index]; + const client = CodexAppServerClient.start(startOptions); + params.onStartedClient?.(client); + const initialize = client.initialize(); + try { + await withTimeout(initialize, params.timeoutMs ?? 0, "codex app-server initialize timed out"); + } catch (error) { + client.close(); + void initialize.catch(() => undefined); + if (shouldTryManagedFallbackStartOption(error, startOptions, index, startOptionsCandidates)) { + continue; } - return await refreshCodexAppServerAuthTokens({ - agentDir, - authProfileId, - ...(authProfileStore ? { authProfileStore } : {}), - config: options?.config, + throw error; + } + + if (params.authProfileId) { + // Profile-backed Codex auth is ephemeral. Keep the host refresh callback + // available whether the profile came from a scoped store or persisted state. + client.addRequestHandler(async (request) => { + if (request.method !== "account/chatgptAuthTokens/refresh") { + return undefined; + } + return await refreshCodexAppServerAuthTokens({ + agentDir: params.agentDir, + authProfileId: params.authProfileId!, + ...(params.authProfileStore ? { authProfileStore: params.authProfileStore } : {}), + config: params.config, + }); }); - }); + } + + try { + await applyCodexAppServerAuthProfile({ + client, + agentDir: params.agentDir, + authProfileId: params.authProfileId, + startOptions, + config: params.config, + ...(params.authProfileStore ? { authProfileStore: params.authProfileStore } : {}), + }); + return client; + } catch (error) { + client.close(); + throw error; + } } - const initialize = client.initialize(); - try { - await withTimeout(initialize, options?.timeoutMs ?? 0, "codex app-server initialize timed out"); - await applyCodexAppServerAuthProfile({ - client, - agentDir, - authProfileId: usesNativeAuth ? null : authProfileId, - startOptions, - config: options?.config, - ...(authProfileStore ? { authProfileStore } : {}), - }); - return client; - } catch (error) { - client.close(); - void initialize.catch(() => undefined); - throw error; + throw new Error("Managed Codex app-server fallback candidates were exhausted."); +} + +function resolveManagedFallbackStartOptions( + startOptions: CodexAppServerStartOptions, +): CodexAppServerStartOptions[] { + const commands = [startOptions.command, ...(startOptions.managedFallbackCommandPaths ?? [])]; + const candidates: CodexAppServerStartOptions[] = []; + for (let index = 0; index < commands.length; index += 1) { + const command = commands[index]; + const managedFallbackCommandPaths = commands.slice(index + 1); + const candidate = { + ...startOptions, + command, + }; + if (managedFallbackCommandPaths.length === 0) { + delete candidate.managedFallbackCommandPaths; + } else { + candidate.managedFallbackCommandPaths = managedFallbackCommandPaths; + } + candidates.push(candidate); } + return candidates; +} + +function shouldTryManagedFallbackStartOption( + error: unknown, + startOptions: CodexAppServerStartOptions, + index: number, + startOptionsCandidates: readonly CodexAppServerStartOptions[], +): boolean { + return ( + startOptions.commandSource === "resolved-managed" && + index < startOptionsCandidates.length - 1 && + isUnsupportedCodexAppServerVersionError(error) + ); } /** Clears and closes all shared clients for deterministic tests. */ diff --git a/extensions/codex/src/conversation-binding.ts b/extensions/codex/src/conversation-binding.ts index bf2ebee1cb2f..be1d743987aa 100644 --- a/extensions/codex/src/conversation-binding.ts +++ b/extensions/codex/src/conversation-binding.ts @@ -11,11 +11,7 @@ import type { PluginHookInboundClaimEvent, } from "openclaw/plugin-sdk/plugin-entry"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload"; -import { - loadSessionStore, - resolveSessionStoreEntry, - resolveStorePath, -} from "openclaw/plugin-sdk/session-store-runtime"; +import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; import { resolveCodexAppServerForModelProvider } from "./app-server/app-server-policy.js"; import { resolveCodexAppServerAuthProfileIdForAgent } from "./app-server/auth-bridge.js"; import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js"; @@ -881,10 +877,11 @@ function readSessionExecOverrides(params: { return undefined; } const storePath = resolveStorePath(params.config.session?.store, { agentId: params.agentId }); - const entry = resolveSessionStoreEntry({ - store: loadSessionStore(storePath, { skipCache: true }), + const entry = getSessionEntry({ + storePath, sessionKey, - }).existing; + readConsistency: "latest", + }); if (!entry?.execSecurity && !entry?.execAsk) { return undefined; } diff --git a/extensions/deepinfra/image-generation-provider.test.ts b/extensions/deepinfra/image-generation-provider.test.ts index 245b90e47aa3..14028c65c47f 100644 --- a/extensions/deepinfra/image-generation-provider.test.ts +++ b/extensions/deepinfra/image-generation-provider.test.ts @@ -31,15 +31,21 @@ vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({ resolveApiKeyForProvider: resolveApiKeyForProviderMock, })); -vi.mock("openclaw/plugin-sdk/provider-http", () => ({ - assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock, - createProviderOperationDeadline: createProviderOperationDeadlineMock, - postJsonRequest: postJsonRequestMock, - postMultipartRequest: postMultipartRequestMock, - resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock, - resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock, - sanitizeConfiguredModelProviderRequest: vi.fn((request) => request), -})); +vi.mock("openclaw/plugin-sdk/provider-http", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/provider-http", + ); + return { + assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock, + createProviderOperationDeadline: createProviderOperationDeadlineMock, + postJsonRequest: postJsonRequestMock, + postMultipartRequest: postMultipartRequestMock, + readProviderJsonResponse: actual.readProviderJsonResponse, + resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock, + resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock, + sanitizeConfiguredModelProviderRequest: vi.fn((request) => request), + }; +}); afterAll(() => { vi.doUnmock("openclaw/plugin-sdk/provider-auth-runtime"); @@ -63,6 +69,13 @@ function requireFirstMockObjectArg(mock: ReturnType, label: string return value; } +function jsonResponse(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + describe("deepinfra image generation provider", () => { afterEach(() => { assertOkOrThrowHttpErrorMock.mockClear(); @@ -86,11 +99,9 @@ describe("deepinfra image generation provider", () => { const release = vi.fn(async () => {}); const jpegBytes = Buffer.from([0xff, 0xd8, 0xff, 0x00]); postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => ({ - data: [{ b64_json: jpegBytes.toString("base64"), revised_prompt: "red square" }], - }), - }, + response: jsonResponse({ + data: [{ b64_json: jpegBytes.toString("base64"), revised_prompt: "red square" }], + }), release, }); @@ -168,17 +179,15 @@ describe("deepinfra image generation provider", () => { it("sends image edits as multipart OpenAI-compatible requests", async () => { postMultipartRequestMock.mockResolvedValue({ - response: { - json: async () => ({ - data: [ - { - b64_json: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).toString( - "base64", - ), - }, - ], - }), - }, + response: jsonResponse({ + data: [ + { + b64_json: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).toString( + "base64", + ), + }, + ], + }), release: vi.fn(async () => {}), }); diff --git a/extensions/diffs/src/viewer-client.test.ts b/extensions/diffs/src/viewer-client.test.ts index a94626bb2bb6..55a847070fdb 100644 --- a/extensions/diffs/src/viewer-client.test.ts +++ b/extensions/diffs/src/viewer-client.test.ts @@ -172,6 +172,24 @@ describe("hydrateViewer", () => { expect(document.documentElement.dataset.openclawDiffsError).toBeUndefined(); warn.mockRestore(); }); + + it("replaces stale controllers when hydrating the current cards again", async () => { + renderCard(); + const { controllers, hydrateViewer } = await import("./viewer-client.js"); + controllers.splice(0); + + await hydrateViewer(); + expect(controllers).toHaveLength(1); + const firstController = controllers[0]; + + document.body.innerHTML = ""; + renderCard(); + await hydrateViewer(); + + expect(controllers).toHaveLength(1); + expect(controllers[0]).not.toBe(firstController); + expect(fileDiffHydrateMock).toHaveBeenCalledTimes(2); + }); }); describe("viewerState initialization", () => { diff --git a/extensions/diffs/src/viewer-client.ts b/extensions/diffs/src/viewer-client.ts index 789f7e2d4b84..cd6810a08f54 100644 --- a/extensions/diffs/src/viewer-client.ts +++ b/extensions/diffs/src/viewer-client.ts @@ -287,6 +287,9 @@ function syncAllControllers(): void { } export async function hydrateViewer(): Promise { + // Rehydration replaces the current DOM card set; do not retain controllers + // from a previous render because they can keep stale DOM references alive. + controllers.length = 0; const cards = await Promise.all( getCards().map(async ({ host, payload }) => ({ host, diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index f5c7ef648f95..5f4608bd6a3f 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -175,6 +175,7 @@ type DispatchInboundParams = { }) => Promise | void; onReplyStart?: () => Promise | void; sourceReplyDeliveryMode?: "automatic" | "message_tool_only"; + typingKeepalive?: boolean; disableBlockStreaming?: boolean; suppressDefaultToolProgressMessages?: boolean; queuedDeliveryCorrelations?: Array<{ begin: () => () => void }>; @@ -944,6 +945,7 @@ describe("processDiscordMessage ack reactions", () => { expect(replyTypingFeedback.onReplyStart).toHaveBeenCalledTimes(1); expect(replyTypingFeedback.onIdle).toHaveBeenCalledTimes(1); expect(replyTypingFeedback.onCleanup).toHaveBeenCalledTimes(1); + expect(getLastDispatchReplyOptions()?.typingKeepalive).toBe(false); expect(typingMocks.sendTyping).not.toHaveBeenCalled(); }); @@ -984,6 +986,33 @@ describe("processDiscordMessage ack reactions", () => { } }); + it("keeps one typing refresh loop for default message-tool replies", async () => { + vi.useFakeTimers(); + try { + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.replyOptions?.onReplyStart?.(); + await vi.advanceTimersByTimeAsync(3_500); + return createNoQueuedDispatchResult(); + }); + const ctx = await createBaseContext({ + shouldRequireMention: false, + effectiveWasMentioned: false, + cfg: { + messages: { groupChat: { visibleReplies: "message_tool" } }, + session: { store: "/tmp/openclaw-discord-process-test-sessions.json" }, + }, + route: BASE_CHANNEL_ROUTE, + }); + + await runProcessDiscordMessage(ctx); + + expect(getLastDispatchReplyOptions()?.typingKeepalive).toBe(false); + expect(typingMocks.sendTyping).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + it("debounces intermediate phase reactions and jumps to done for short runs", async () => { dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { await params?.replyOptions?.onReasoningStream?.(); @@ -1532,6 +1561,7 @@ describe("processDiscordMessage session routing", () => { expectRecordFields(requireRecord(getLastDispatchReplyOptions(), "dispatch reply options"), { sourceReplyDeliveryMode: "message_tool_only", + typingKeepalive: false, disableBlockStreaming: true, }); expect(createDiscordDraftStream).not.toHaveBeenCalled(); diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index 085b1e9f8f51..6786cef48df3 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -251,6 +251,14 @@ async function processDiscordMessageInner( }, }); const sourceRepliesAreToolOnly = sourceReplyDeliveryMode === "message_tool_only"; + const configuredTypingMode = cfg.session?.typingMode ?? cfg.agents?.defaults?.typingMode; + const configuredTypingInterval = + cfg.agents?.defaults?.typingIntervalSeconds ?? cfg.session?.typingIntervalSeconds; + const shouldDisableCoreTypingKeepalive = + Boolean(replyTypingFeedback) || + (sourceRepliesAreToolOnly && + configuredTypingMode === undefined && + configuredTypingInterval === undefined); const ackReaction = resolveAckReaction(cfg, route.agentId, { channel: "discord", accountId, @@ -460,6 +468,7 @@ async function processDiscordMessageInner( channelId: typingChannelId, rest: feedbackRest, log: logVerbose, + keepaliveIntervalMs: shouldDisableCoreTypingKeepalive ? undefined : 0, }); if (replyTypingFeedback) { // A carried prestart only covers queue wait time; dispatch needs a fresh @@ -955,6 +964,7 @@ async function processDiscordMessageInner( abortSignal, skillFilter: channelConfig?.skills, sourceReplyDeliveryMode, + typingKeepalive: shouldDisableCoreTypingKeepalive ? false : undefined, queuedDeliveryCorrelations: isRoomEvent ? [{ begin: beginDeliveryCorrelation }] : undefined, suppressTyping: isRoomEvent ? true : undefined, allowProgressCallbacksWhenSourceDeliverySuppressed: diff --git a/extensions/discord/src/monitor/message-handler.queue.test.ts b/extensions/discord/src/monitor/message-handler.queue.test.ts index e882da8c0086..991472b788df 100644 --- a/extensions/discord/src/monitor/message-handler.queue.test.ts +++ b/extensions/discord/src/monitor/message-handler.queue.test.ts @@ -222,6 +222,34 @@ describe("createDiscordMessageHandler queue behavior", () => { ); }); + it("keeps the configured typing cadence for prestarted feedback", async () => { + preflightDiscordMessageMock.mockReset(); + processDiscordMessageMock.mockReset(); + preflightDiscordMessageMock.mockImplementation(async () => + createAcceptedDmPreflightContext({ + cfg: { + ...createPreflightContext().cfg, + agents: { defaults: { typingIntervalSeconds: 7 } }, + session: { typingIntervalSeconds: 5 }, + }, + }), + ); + processDiscordMessageMock.mockResolvedValue(undefined); + const replyTypingFeedback = createReplyTypingFeedbackMock("dm-1"); + const createReplyTypingFeedback = vi.fn(() => replyTypingFeedback); + + const handler = createDiscordMessageHandler({ + ...createDiscordHandlerParams(), + testing: { createReplyTypingFeedback }, + }); + await handler(createMessageData("m-typing-cadence", "dm-1") as never, {} as never); + await flushQueueWork(); + + expect(createReplyTypingFeedback).toHaveBeenCalledWith( + expect.objectContaining({ keepaliveIntervalMs: 7_000 }), + ); + }); + it("keeps accepted DM dispatch running when accepted typing feedback fails", async () => { preflightDiscordMessageMock.mockReset(); processDiscordMessageMock.mockReset(); diff --git a/extensions/discord/src/monitor/message-handler.ts b/extensions/discord/src/monitor/message-handler.ts index a928f4b7a6a8..9a5e28b028fc 100644 --- a/extensions/discord/src/monitor/message-handler.ts +++ b/extensions/discord/src/monitor/message-handler.ts @@ -3,6 +3,7 @@ import { createChannelInboundDebouncer, shouldDebounceTextInbound, } from "openclaw/plugin-sdk/channel-inbound"; +import { finiteSecondsToTimerSafeMilliseconds } from "openclaw/plugin-sdk/number-runtime"; import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy"; import type { Client } from "../internal/discord.js"; @@ -102,6 +103,9 @@ function startAcceptedTypingFeedback(params: { accountId: ctx.accountId, channelId: ctx.messageChannelId, log: logVerbose, + keepaliveIntervalMs: finiteSecondsToTimerSafeMilliseconds( + ctx.cfg.agents?.defaults?.typingIntervalSeconds ?? ctx.cfg.session?.typingIntervalSeconds, + ), }); const cleanup = replyTypingFeedback.onCleanup; replyTypingFeedback.onCleanup = () => { diff --git a/extensions/discord/src/monitor/native-command-model-picker-ui.ts b/extensions/discord/src/monitor/native-command-model-picker-ui.ts index 9d016792ee46..81be1b018e5b 100644 --- a/extensions/discord/src/monitor/native-command-model-picker-ui.ts +++ b/extensions/discord/src/monitor/native-command-model-picker-ui.ts @@ -8,7 +8,7 @@ import { } from "openclaw/plugin-sdk/command-auth-native"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; -import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; +import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -202,11 +202,10 @@ export async function resolveDiscordNativeChoiceContext(params: { const storePath = resolveStorePath(params.cfg.session?.store, { agentId: route.agentId, }); - const sessionStore = loadSessionStore(storePath); - const sessionEntry = sessionStore[route.sessionKey]; + const sessionEntry = getSessionEntry({ storePath, sessionKey: route.sessionKey }); const override = resolveStoredModelOverride({ sessionEntry, - sessionStore, + loadSessionEntry: (sessionKey) => getSessionEntry({ storePath, sessionKey }), sessionKey: route.sessionKey, defaultProvider: fallback.provider, }); @@ -238,11 +237,15 @@ export function resolveDiscordModelPickerCurrentModel(params: { const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.route.agentId, }); - const sessionStore = loadSessionStore(storePath, { skipCache: true }); - const sessionEntry = sessionStore[params.route.sessionKey]; + const sessionEntry = getSessionEntry({ + storePath, + sessionKey: params.route.sessionKey, + readConsistency: "latest", + }); const override = resolveStoredModelOverride({ sessionEntry, - sessionStore, + loadSessionEntry: (sessionKey) => + getSessionEntry({ storePath, sessionKey, readConsistency: "latest" }), sessionKey: params.route.sessionKey, defaultProvider: params.data.resolvedDefault.provider, }); @@ -267,9 +270,12 @@ export function resolveDiscordModelPickerCurrentRuntime(params: { const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.route.agentId, }); - const sessionStore = loadSessionStore(storePath, { skipCache: true }); const sessionRuntime = normalizeOptionalString( - sessionStore[params.route.sessionKey]?.agentRuntimeOverride, + getSessionEntry({ + storePath, + sessionKey: params.route.sessionKey, + readConsistency: "latest", + })?.agentRuntimeOverride, ); if (sessionRuntime) { return sessionRuntime; diff --git a/extensions/discord/src/monitor/reply-typing-feedback.ts b/extensions/discord/src/monitor/reply-typing-feedback.ts index 7e292d3e8958..335da5558180 100644 --- a/extensions/discord/src/monitor/reply-typing-feedback.ts +++ b/extensions/discord/src/monitor/reply-typing-feedback.ts @@ -24,6 +24,7 @@ export function createDiscordReplyTypingFeedback(params: { rest?: RequestClient; log: (message: string) => void; maxDurationMs?: number; + keepaliveIntervalMs?: number; }): DiscordReplyTypingFeedback { let channelId = params.channelId; const rest = @@ -44,6 +45,7 @@ export function createDiscordReplyTypingFeedback(params: { error: err, }); }, + keepaliveIntervalMs: params.keepaliveIntervalMs, maxDurationMs: params.maxDurationMs ?? DISCORD_REPLY_TYPING_MAX_DURATION_MS, }); const updateChannelId = (nextChannelId: string) => { diff --git a/extensions/discord/src/outbound-adapter.test.ts b/extensions/discord/src/outbound-adapter.test.ts index 231240de6546..a0ad41df245c 100644 --- a/extensions/discord/src/outbound-adapter.test.ts +++ b/extensions/discord/src/outbound-adapter.test.ts @@ -345,7 +345,7 @@ describe("discordOutbound", () => { 2, ); expect(messageOptions.accountId).toBe("default"); - expect(messageOptions.replyTo).toBeUndefined(); + expect(messageOptions.replyTo).toBe("reply-1"); const mediaCall = mockCall(hoisted.sendMessageDiscordMock, "sendMessageDiscord", 1); expect(mediaCall[0]).toBe("channel:123456"); @@ -353,7 +353,7 @@ describe("discordOutbound", () => { const mediaOptions = mockObjectArg(hoisted.sendMessageDiscordMock, "sendMessageDiscord", 1, 2); expect(mediaOptions.accountId).toBe("default"); expect(mediaOptions.mediaUrl).toBe("https://example.com/extra.png"); - expect(mediaOptions.replyTo).toBeUndefined(); + expect(mediaOptions.replyTo).toBe("reply-1"); expect(result).toEqual({ channel: "discord", messageId: "msg-1", @@ -361,6 +361,31 @@ describe("discordOutbound", () => { }); }); + it("keeps captured replyTo on audioAsVoice sends when replyToMode is batched", async () => { + await discordOutbound.sendPayload?.({ + cfg: {}, + to: "channel:123456", + text: "", + payload: { + text: "voice note", + mediaUrls: ["https://example.com/voice.ogg", "https://example.com/extra.png"], + audioAsVoice: true, + }, + accountId: "default", + replyToId: "reply-1", + replyToMode: "batched", + }); + + expect( + mockObjectArg(hoisted.sendVoiceMessageDiscordMock, "sendVoiceMessageDiscord", 0, 2).replyTo, + ).toBe("reply-1"); + expect( + hoisted.sendMessageDiscordMock.mock.calls.map( + (call) => (call[2] as { replyTo?: unknown } | undefined)?.replyTo, + ), + ).toEqual(["reply-1", "reply-1"]); + }); + it("keeps replyToId on every internal audioAsVoice send when replyToMode is all", async () => { await discordOutbound.sendPayload?.({ cfg: {}, diff --git a/extensions/discord/src/outbound-payload.ts b/extensions/discord/src/outbound-payload.ts index cbb8b40118db..70632528ff83 100644 --- a/extensions/discord/src/outbound-payload.ts +++ b/extensions/discord/src/outbound-payload.ts @@ -84,13 +84,15 @@ export async function sendDiscordOutboundPayload(params: { const sendContext = await createDiscordPayloadSendContext(ctx); if (payload.audioAsVoice && mediaUrls.length > 0) { + // audioAsVoice emits one logical Discord reply across voice/text/media sends. + // Capture before helper calls consume implicit single-use reply targets. + const voiceReplyTo = sendContext.resolveReplyTo(); let lastResult = await sendContext.withRetry( async () => - await sendContext.sendVoice( - sendContext.target, - mediaUrls[0], - resolveDiscordDeliveryOptions(ctx, sendContext), - ), + await sendContext.sendVoice(sendContext.target, mediaUrls[0], { + ...resolveDiscordDeliveryOptions(ctx, sendContext), + replyTo: voiceReplyTo, + }), ); if (payload.text?.trim()) { lastResult = await sendContext.withRetry( @@ -98,6 +100,7 @@ export async function sendDiscordOutboundPayload(params: { await sendContext.send(sendContext.target, payload.text, { verbose: false, ...resolveDiscordFormattedDeliveryOptions(ctx, sendContext), + replyTo: voiceReplyTo, }), ); } @@ -107,6 +110,7 @@ export async function sendDiscordOutboundPayload(params: { await sendContext.send(sendContext.target, "", { verbose: false, ...resolveDiscordMediaDeliveryOptions(ctx, sendContext, mediaUrl), + replyTo: voiceReplyTo, }), ); } diff --git a/extensions/document-extract/document-extractor.test.ts b/extensions/document-extract/document-extractor.test.ts index f80c6e6a4dc3..9155474a1bb6 100644 --- a/extensions/document-extract/document-extractor.test.ts +++ b/extensions/document-extract/document-extractor.test.ts @@ -55,20 +55,35 @@ describe("PDF document extractor", () => { }); }); - it("extracts text first and renders fallback images through clawpdf", async () => { - pdfDocument.extract.mockResolvedValueOnce({ text: "", images: [] }).mockResolvedValueOnce({ - text: "", - images: [ - { - type: "image", - bytes: Uint8Array.from(Buffer.from("png")), - mimeType: "image/png", - page: 1, - width: 10, - height: 10, - }, - ], - }); + it("extracts text first and renders each fallback page with its own pixel budget", async () => { + pdfDocument.extract + .mockResolvedValueOnce({ text: "", images: [] }) + .mockResolvedValueOnce({ + text: "", + images: [ + { + type: "image", + bytes: Uint8Array.from(Buffer.from("png1")), + mimeType: "image/png", + page: 1, + width: 5, + height: 10, + }, + ], + }) + .mockResolvedValueOnce({ + text: "", + images: [ + { + type: "image", + bytes: Uint8Array.from(Buffer.from("png2")), + mimeType: "image/png", + page: 2, + width: 5, + height: 10, + }, + ], + }); const extractor = createPdfDocumentExtractor(); const result = await extractor.extract(request()); @@ -82,18 +97,24 @@ describe("PDF document extractor", () => { maxPages: 2, maxTextChars: 200_000, }); + // Each page renders in its own extract() call, with the aggregate pixel cap + // allocated across selected pages so later pages are not starved. expect(pdfDocument.extract).toHaveBeenNthCalledWith(2, { mode: "images", - maxPages: 2, - image: { - maxDimension: 10_000, - maxPixels: 100, - forms: true, - }, + pages: [1], + image: { maxDimension: 10_000, maxPixels: 50, forms: true }, + }); + expect(pdfDocument.extract).toHaveBeenNthCalledWith(3, { + mode: "images", + pages: [2], + image: { maxDimension: 10_000, maxPixels: 50, forms: true }, }); expect(result).toEqual({ text: "", - images: [{ type: "image", data: "cG5n", mimeType: "image/png" }], + images: [ + { type: "image", data: "cG5nMQ==", mimeType: "image/png" }, + { type: "image", data: "cG5nMg==", mimeType: "image/png" }, + ], }); expect(pdfDocument.destroy).toHaveBeenCalledTimes(1); }); @@ -131,8 +152,9 @@ describe("PDF document extractor", () => { expect(pdfDocument.destroy).not.toHaveBeenCalled(); }); - it("filters selected pages before passing them to clawpdf", async () => { + it("filters selected pages and renders them one page per image call", async () => { pdfDocument.extract + .mockResolvedValueOnce({ text: "", images: [] }) .mockResolvedValueOnce({ text: "", images: [] }) .mockResolvedValueOnce({ text: "", images: [] }); const extractor = createPdfDocumentExtractor(); @@ -141,11 +163,15 @@ describe("PDF document extractor", () => { expect(pdfDocument.extract).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ pages: [2, 1] }), + expect.objectContaining({ mode: "text", pages: [2, 1] }), ); expect(pdfDocument.extract).toHaveBeenNthCalledWith( 2, - expect.objectContaining({ pages: [2, 1] }), + expect.objectContaining({ mode: "images", pages: [2] }), + ); + expect(pdfDocument.extract).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ mode: "images", pages: [1] }), ); }); diff --git a/extensions/document-extract/document-extractor.ts b/extensions/document-extract/document-extractor.ts index 22059442ad5b..e99ba5bc7a06 100644 --- a/extensions/document-extract/document-extractor.ts +++ b/extensions/document-extract/document-extractor.ts @@ -83,17 +83,38 @@ async function extractPdfContent( return { text, images: [] }; } + // clawpdf's image render budget (maxPixels) is shared across every page in one + // extract() call: the first page consumes it and later pages collapse to 1x1 + // PNGs that vision models reject. Render each page separately, allocating the + // remaining aggregate budget across pages that still need rendering. + const imagePages = + pages ?? Array.from({ length: Math.min(pdf.pageCount, request.maxPages) }, (_, i) => i + 1); + try { - const imageResult = await pdf.extract({ - mode: "images", - ...pageSelection, - image: { - maxDimension: MAX_RENDER_DIMENSION, - maxPixels: request.maxPixels, - forms: true, - }, - }); - return { text, images: imageResult.images.map(toDocumentImage) }; + const images: DocumentExtractedImage[] = []; + let remainingPixels = request.maxPixels; + for (let index = 0; index < imagePages.length; index += 1) { + if (remainingPixels <= 0) { + break; + } + const pagesRemaining = imagePages.length - index; + const maxPixelsPerPage = Math.max(1, Math.ceil(remainingPixels / pagesRemaining)); + const pageNumber = imagePages[index]; + const imageResult = await pdf.extract({ + mode: "images", + pages: [pageNumber], + image: { + maxDimension: MAX_RENDER_DIMENSION, + maxPixels: maxPixelsPerPage, + forms: true, + }, + }); + for (const image of imageResult.images) { + images.push(toDocumentImage(image)); + remainingPixels -= image.width * image.height; + } + } + return { text, images }; } catch (err) { request.onImageExtractionError?.(err); return { text, images: [] }; diff --git a/extensions/duckduckgo/src/ddg-client.ts b/extensions/duckduckgo/src/ddg-client.ts index bcecbc7b4b0a..c1e9a60c1215 100644 --- a/extensions/duckduckgo/src/ddg-client.ts +++ b/extensions/duckduckgo/src/ddg-client.ts @@ -1,5 +1,6 @@ // Duckduckgo plugin module implements ddg client behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { readProviderTextResponse } from "openclaw/plugin-sdk/provider-http"; import { DEFAULT_CACHE_TTL_MINUTES, DEFAULT_SEARCH_COUNT, @@ -36,21 +37,49 @@ type DuckDuckGoResult = { }; function decodeHtmlEntities(text: string): string { - return text - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/'/g, "'") - .replace(/'/g, "'") - .replace(///g, "/") - .replace(/ /g, " ") - .replace(/–/g, "-") - .replace(/—/g, "--") - .replace(/…/g, "...") - .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code))) - .replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(Number.parseInt(code, 16))); + return text.replace( + /&(?:lt|gt|quot|apos|#39|#x27|#x2F|nbsp|ndash|mdash|hellip|amp|#\d+|#x[0-9a-f]+);/gi, + (entity) => { + const normalized = entity.toLowerCase(); + if (normalized === "<") { + return "<"; + } + if (normalized === ">") { + return ">"; + } + if (normalized === """) { + return '"'; + } + if (normalized === "'" || normalized === "'" || normalized === "'") { + return "'"; + } + if (normalized === "/") { + return "/"; + } + if (normalized === " ") { + return " "; + } + if (normalized === "–") { + return "-"; + } + if (normalized === "—") { + return "--"; + } + if (normalized === "…") { + return "..."; + } + if (normalized === "&") { + return "&"; + } + if (normalized.startsWith("&#x")) { + return String.fromCodePoint(Number.parseInt(normalized.slice(3, -1), 16)); + } + if (normalized.startsWith("&#")) { + return String.fromCodePoint(Number.parseInt(normalized.slice(2, -1), 10)); + } + return entity; + }, + ); } function stripHtml(html: string): string { @@ -85,6 +114,10 @@ function isBotChallenge(html: string): boolean { return /g-recaptcha|are you a human|id="challenge-form"|name="challenge"/i.test(html); } +async function readDuckDuckGoHtmlResponse(response: Response): Promise { + return await readProviderTextResponse(response, "DuckDuckGo search"); +} + function parseDuckDuckGoHtml(html: string): DuckDuckGoResult[] { const results: DuckDuckGoResult[] = []; const resultRegex = /]*\bclass="[^"]*\bresult__a\b[^"]*")([^>]*)>([\s\S]*?)<\/a>/gi; @@ -174,7 +207,7 @@ export async function runDuckDuckGoSearch(params: { ); } - const html = await response.text(); + const html = await readDuckDuckGoHtmlResponse(response); if (isBotChallenge(html)) { throw new Error("DuckDuckGo returned a bot-detection challenge."); } @@ -210,5 +243,6 @@ export const testing = { decodeHtmlEntities, isBotChallenge, parseDuckDuckGoHtml, + readDuckDuckGoHtmlResponse, }; export { testing as __testing }; diff --git a/extensions/duckduckgo/src/ddg-search-provider.test.ts b/extensions/duckduckgo/src/ddg-search-provider.test.ts index 2728f3ca9a43..6640f351d0cd 100644 --- a/extensions/duckduckgo/src/ddg-search-provider.test.ts +++ b/extensions/duckduckgo/src/ddg-search-provider.test.ts @@ -1,5 +1,6 @@ // Duckduckgo tests cover ddg search provider plugin behavior. import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createStreamingResponse } from "../../test-support/streaming-error-response.js"; import { createDuckDuckGoWebSearchProvider as createDuckDuckGoWebSearchContractProvider } from "../web-search-contract-api.js"; import { DEFAULT_DDG_SAFE_SEARCH, resolveDdgRegion, resolveDdgSafeSearch } from "./config.js"; @@ -104,6 +105,24 @@ describe("duckduckgo web search provider", () => { expect(runDuckDuckGoSearch).not.toHaveBeenCalled(); }); + it("bounds successful DuckDuckGo HTML bodies without using response.text()", async () => { + const streamed = createStreamingResponse({ + chunkCount: 32, + chunkSize: 1024 * 1024, + text: "x", + headers: { "Content-Type": "text/html" }, + }); + const textSpy = vi.spyOn(streamed.response, "text").mockRejectedValue(new Error("unbounded")); + + await expect(ddgClientTesting.readDuckDuckGoHtmlResponse(streamed.response)).rejects.toThrow( + "DuckDuckGo search: text response exceeds 16777216 bytes", + ); + + expect(streamed.getReadCount()).toBeLessThan(32); + expect(streamed.wasCanceled()).toBe(true); + expect(textSpy).not.toHaveBeenCalled(); + }); + it("reads region from plugin config and normalizes empty values away", () => { expect( resolveDdgRegion({ @@ -186,6 +205,17 @@ describe("duckduckgo web search provider", () => { ); }); + it("does not double-decode escaped entities (decodes & last)", () => { + // A result whose text literally shows "<" arrives double-encoded as + // "&lt;". Decoding & first would re-decode it into "<", corrupting + // the snippet; & must be decoded last. + expect(ddgClientTesting.decodeHtmlEntities("How to escape &lt; in HTML")).toBe( + "How to escape < in HTML", + ); + expect(ddgClientTesting.decodeHtmlEntities("a&#39;b")).toBe("a'b"); + expect(ddgClientTesting.decodeHtmlEntities("a&amp;b")).toBe("a&b"); + }); + it("parses results when href appears before class", () => { const html = ` diff --git a/extensions/elevenlabs/speech-provider.ts b/extensions/elevenlabs/speech-provider.ts index 063ade2bb96d..b3e0f4f9640b 100644 --- a/extensions/elevenlabs/speech-provider.ts +++ b/extensions/elevenlabs/speech-provider.ts @@ -1,7 +1,10 @@ // Elevenlabs provider module implements model/runtime integration. import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { parseStrictFiniteNumber, parseStrictInteger } from "openclaw/plugin-sdk/number-runtime"; -import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/provider-http"; +import { + assertOkOrThrowProviderError, + readProviderJsonResponse, +} from "openclaw/plugin-sdk/provider-http"; import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; import type { SpeechDirectiveTokenParseContext, @@ -367,14 +370,14 @@ async function listElevenLabsVoices(params: { }); try { await assertOkOrThrowProviderError(response, "ElevenLabs voices API error"); - const json = (await response.json()) as { + const json = await readProviderJsonResponse<{ voices?: Array<{ voice_id?: string; name?: string; category?: string; description?: string; }>; - }; + }>(response, "elevenlabs.voices"); return Array.isArray(json.voices) ? json.voices .map((voice) => ({ diff --git a/extensions/exa/src/exa-web-search-provider.runtime.ts b/extensions/exa/src/exa-web-search-provider.runtime.ts index ce6af91c25ed..f07bcdcbcae0 100644 --- a/extensions/exa/src/exa-web-search-provider.runtime.ts +++ b/extensions/exa/src/exa-web-search-provider.runtime.ts @@ -20,6 +20,7 @@ import { wrapWebContent, writeCachedSearchPayload, } from "openclaw/plugin-sdk/provider-web-search"; +import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; import { normalizeOptionalLowercaseString, normalizeOptionalString, @@ -30,6 +31,10 @@ const EXA_SEARCH_TYPES = ["auto", "neural", "fast", "deep", "deep-reasoning", "i const EXA_FRESHNESS_VALUES = ["day", "week", "month", "year"] as const; const EXA_MAX_SEARCH_COUNT = 100; const EXA_ERROR_BODY_LIMIT_BYTES = 8 * 1024; +// Exa search responses are untrusted external bodies. Cap the success JSON the +// same way other bundled providers do (16 MiB) so a misbehaving or hostile +// endpoint cannot stream an unbounded body into memory before we parse it. +const EXA_SEARCH_JSON_MAX_BYTES = 16 * 1024 * 1024; type ExaConfig = { apiKey?: string; @@ -70,9 +75,17 @@ type ExaSearchResponse = { results?: unknown; }; -async function readExaSearchResults(response: Response): Promise { +async function readExaSearchResults( + response: Response, + opts?: { maxBytes?: number }, +): Promise { + const maxBytes = opts?.maxBytes ?? EXA_SEARCH_JSON_MAX_BYTES; + const bytes = await readResponseWithLimit(response, maxBytes, { + onOverflow: ({ maxBytes: maxBytesLocal }) => + new Error(`Exa API response exceeds ${maxBytesLocal} bytes`), + }); try { - return normalizeExaResults(await response.json()); + return normalizeExaResults(JSON.parse(new TextDecoder().decode(bytes))); } catch (cause) { throw new Error("Exa API returned malformed JSON", { cause }); } diff --git a/extensions/exa/src/exa-web-search-provider.test.ts b/extensions/exa/src/exa-web-search-provider.test.ts index d39102847c84..01315c459b15 100644 --- a/extensions/exa/src/exa-web-search-provider.test.ts +++ b/extensions/exa/src/exa-web-search-provider.test.ts @@ -26,6 +26,33 @@ function cancelTrackedResponse( }; } +function streamingJsonResponse(params: { chunkCount: number; chunkSize: number }): { + response: Response; + getReadCount: () => number; +} { + // Streaming fixture proves an oversized success body stops being read before + // the whole payload is buffered into memory. + let reads = 0; + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + pull(controller) { + if (reads >= params.chunkCount) { + controller.close(); + return; + } + reads += 1; + controller.enqueue(encoder.encode("a".repeat(params.chunkSize))); + }, + }); + return { + response: new Response(stream, { + status: 200, + headers: { "content-type": "application/json" }, + }), + getReadCount: () => reads, + }; +} + describe("exa web search provider", () => { it("exposes the expected metadata and selection wiring", () => { const provider = createExaWebSearchProvider(); @@ -265,6 +292,27 @@ describe("exa web search provider", () => { ); }); + it("parses well-formed Exa search JSON under the byte cap", async () => { + const response = new Response( + JSON.stringify({ results: [{ url: "https://example.com", title: "Example" }] }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + + await expect(testing.readExaSearchResults(response)).resolves.toEqual([ + { url: "https://example.com", title: "Example" }, + ]); + }); + + it("caps oversized Exa search JSON instead of buffering the whole body", async () => { + const streamed = streamingJsonResponse({ chunkCount: 64, chunkSize: 1024 }); + + await expect( + testing.readExaSearchResults(streamed.response, { maxBytes: 4096 }), + ).rejects.toThrow(/Exa API response exceeds 4096 bytes/); + + expect(streamed.getReadCount()).toBeLessThan(64); + }); + it("bounds Exa API error bodies without using response.text()", async () => { const tracked = cancelTrackedResponse(`${"exa upstream unavailable ".repeat(1024)}tail`, { status: 503, diff --git a/extensions/fal/image-generation-provider.ts b/extensions/fal/image-generation-provider.ts index 68a1e2b541df..3a302ee9bce3 100644 --- a/extensions/fal/image-generation-provider.ts +++ b/extensions/fal/image-generation-provider.ts @@ -12,6 +12,7 @@ import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth"; import { assertOkOrThrowHttpError, assertOkOrThrowProviderError, + readProviderJsonResponse, } from "openclaw/plugin-sdk/provider-http"; import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; import { @@ -645,7 +646,9 @@ export function buildFalImageGenerationProvider(): ImageGenerationProvider { try { await assertOkOrThrowHttpError(response, "fal image generation failed"); - const payload = parseFalImageGenerationResponse(await response.json()); + const payload = parseFalImageGenerationResponse( + await readProviderJsonResponse(response, "fal.image-generation"), + ); const images: GeneratedImageAsset[] = []; let imageIndex = 0; for (const entry of payload.images) { diff --git a/extensions/feishu/runtime-api.ts b/extensions/feishu/runtime-api.ts index 397f53eac212..dbbff63576ce 100644 --- a/extensions/feishu/runtime-api.ts +++ b/extensions/feishu/runtime-api.ts @@ -43,10 +43,7 @@ export { filterSupplementalContextItems, resolveChannelContextVisibilityMode, } from "openclaw/plugin-sdk/context-visibility-runtime"; -export { - loadSessionStore, - resolveSessionStoreEntry, -} from "openclaw/plugin-sdk/session-store-runtime"; +export { getSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; export { readJsonFileWithFallback } from "openclaw/plugin-sdk/json-store"; export { normalizeAgentId } from "openclaw/plugin-sdk/routing"; export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking"; diff --git a/extensions/feishu/src/accounts.test.ts b/extensions/feishu/src/accounts.test.ts index 14a12eb9b298..cc7e32c098e6 100644 --- a/extensions/feishu/src/accounts.test.ts +++ b/extensions/feishu/src/accounts.test.ts @@ -29,21 +29,30 @@ function expectExplicitDefaultAccountSelection( expect(account.appId).toBe(appId); } -function withEnvVar(key: string, value: string | undefined, run: () => void) { +function setTestEnvValue(key: string, value: string | undefined): () => void { const prev = process.env[key]; if (value === undefined) { - delete process.env[key]; + Reflect.deleteProperty(process.env, key); } else { - process.env[key] = value; + Reflect.set(process.env, key, value); } + return () => restoreTestEnvValue(key, prev); +} + +function restoreTestEnvValue(key: string, value: string | undefined): void { + if (value === undefined) { + Reflect.deleteProperty(process.env, key); + } else { + Reflect.set(process.env, key, value); + } +} + +function withEnvVar(key: string, value: string | undefined, run: () => void): void { + const restore = setTestEnvValue(key, value); try { run(); } finally { - if (prev === undefined) { - delete process.env[key]; - } else { - process.env[key] = prev; - } + restore(); } } @@ -214,8 +223,7 @@ describe("resolveFeishuCredentials", () => { it("resolves env SecretRef objects when unresolved refs are allowed", () => { const key = "FEISHU_APP_SECRET_TEST"; - const prev = process.env[key]; - process.env[key] = " secret_from_env "; + const restore = setTestEnvValue(key, " secret_from_env "); try { const creds = resolveFeishuCredentials( @@ -234,18 +242,13 @@ describe("resolveFeishuCredentials", () => { domain: "feishu", }); } finally { - if (prev === undefined) { - delete process.env[key]; - } else { - process.env[key] = prev; - } + restore(); } }); it("resolves env SecretRef with custom provider alias when unresolved refs are allowed", () => { const key = "FEISHU_APP_SECRET_CUSTOM_PROVIDER_TEST"; - const prev = process.env[key]; - process.env[key] = " secret_from_env_alias "; + const restore = setTestEnvValue(key, " secret_from_env_alias "); try { const creds = resolveFeishuCredentials( @@ -258,11 +261,7 @@ describe("resolveFeishuCredentials", () => { expect(creds?.appSecret).toBe("secret_from_env_alias"); } finally { - if (prev === undefined) { - delete process.env[key]; - } else { - process.env[key] = prev; - } + restore(); } }); diff --git a/extensions/feishu/src/app-registration.test.ts b/extensions/feishu/src/app-registration.test.ts index 1f8a8e88a440..47ea9318a4a3 100644 --- a/extensions/feishu/src/app-registration.test.ts +++ b/extensions/feishu/src/app-registration.test.ts @@ -1,16 +1,21 @@ // Feishu tests cover app registration plugin behavior. import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { beginAppRegistration, pollAppRegistration } from "./app-registration.js"; +import { beginAppRegistration, pollAppRegistration, printQrCode } from "./app-registration.js"; -const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({ +const { fetchWithSsrFGuardMock, renderQrTerminalMock } = vi.hoisted(() => ({ fetchWithSsrFGuardMock: vi.fn(), + renderQrTerminalMock: vi.fn(async () => "terminal-qr"), })); vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ fetchWithSsrFGuard: fetchWithSsrFGuardMock, })); +vi.mock("./qr-terminal.js", () => ({ + renderQrTerminal: renderQrTerminalMock, +})); + function mockFeishuJson(payload: unknown) { fetchWithSsrFGuardMock.mockResolvedValueOnce({ response: new Response(JSON.stringify(payload), { status: 200 }), @@ -23,6 +28,7 @@ describe("Feishu app registration", () => { vi.useRealTimers(); vi.restoreAllMocks(); fetchWithSsrFGuardMock.mockReset(); + renderQrTerminalMock.mockClear(); }); it("defaults unsafe begin polling lifetimes from provider responses", async () => { @@ -59,4 +65,16 @@ describe("Feishu app registration", () => { await vi.runOnlyPendingTimersAsync(); await expect(poll).resolves.toEqual({ status: "timeout" }); }); + + it("prints scan-to-create QR codes with compact terminal rendering", async () => { + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await printQrCode("https://accounts.feishu.cn/verify?device_code=long-device-code"); + + expect(renderQrTerminalMock).toHaveBeenCalledWith( + "https://accounts.feishu.cn/verify?device_code=long-device-code", + { small: true }, + ); + expect(writeSpy).toHaveBeenCalledWith("terminal-qr\n"); + }); }); diff --git a/extensions/feishu/src/app-registration.ts b/extensions/feishu/src/app-registration.ts index 5528525b2446..cb4c5204aa28 100644 --- a/extensions/feishu/src/app-registration.ts +++ b/extensions/feishu/src/app-registration.ts @@ -266,7 +266,7 @@ export async function pollAppRegistration(params: { * otherwise the pattern is corrupted and cannot be scanned. */ export async function printQrCode(url: string): Promise { - const output = await renderQrTerminal(url); + const output = await renderQrTerminal(url, { small: true }); process.stdout.write(output.endsWith("\n") ? output : `${output}\n`); } diff --git a/extensions/feishu/src/bot-runtime-api.ts b/extensions/feishu/src/bot-runtime-api.ts index e65c1f7e7024..50dd05afdef8 100644 --- a/extensions/feishu/src/bot-runtime-api.ts +++ b/extensions/feishu/src/bot-runtime-api.ts @@ -10,4 +10,4 @@ export { filterSupplementalContextItems, normalizeAgentId, } from "../runtime-api.js"; -export { loadSessionStore, resolveSessionStoreEntry } from "../runtime-api.js"; +export { getSessionEntry } from "../runtime-api.js"; diff --git a/extensions/feishu/src/client.test.ts b/extensions/feishu/src/client.test.ts index b4c9da4c7b0e..e0b107808dc8 100644 --- a/extensions/feishu/src/client.test.ts +++ b/extensions/feishu/src/client.test.ts @@ -83,6 +83,14 @@ let FEISHU_USER_AGENT: string; let priorProxyEnv: Partial> = {}; let priorFeishuTimeoutEnv: string | undefined; +function setFeishuTestEnvValue(key: string, value: string | undefined): void { + if (value === undefined) { + Reflect.deleteProperty(process.env, key); + } else { + Reflect.set(process.env, key, value); + } +} + vi.mock("./channel.js", () => ({ feishuPlugin: feishuPluginMock, })); @@ -213,10 +221,10 @@ beforeAll(async () => { beforeEach(() => { priorProxyEnv = {}; priorFeishuTimeoutEnv = process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR]; - delete process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR]; + setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, undefined); for (const key of proxyEnvKeys) { priorProxyEnv[key] = process.env[key]; - delete process.env[key]; + setFeishuTestEnvValue(key, undefined); } vi.clearAllMocks(); clearClientCache(); @@ -238,18 +246,9 @@ beforeEach(() => { afterEach(() => { for (const key of proxyEnvKeys) { - const value = priorProxyEnv[key]; - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - if (priorFeishuTimeoutEnv === undefined) { - delete process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR]; - } else { - process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = priorFeishuTimeoutEnv; + setFeishuTestEnvValue(key, priorProxyEnv[key]); } + setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, priorFeishuTimeoutEnv); setFeishuClientRuntimeForTest(); }); @@ -359,7 +358,7 @@ describe("createFeishuClient HTTP timeout", () => { }); it("uses env timeout override when provided and no direct timeout is set", async () => { - process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = "60000"; + setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, "60000"); createFeishuClient({ appId: "app_8", @@ -373,7 +372,7 @@ describe("createFeishuClient HTTP timeout", () => { it("ignores non-decimal env timeout overrides", async () => { for (const value of ["0x10", "1e3", "10.5"]) { - process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = value; + setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, value); createFeishuClient({ appId: `app-${value}`, @@ -387,7 +386,7 @@ describe("createFeishuClient HTTP timeout", () => { }); it("prefers direct timeout over env override", async () => { - process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = "60000"; + setFeishuTestEnvValue(FEISHU_HTTP_TIMEOUT_ENV_VAR, "60000"); createFeishuClient({ appId: "app_10", @@ -401,7 +400,10 @@ describe("createFeishuClient HTTP timeout", () => { }); it("clamps env timeout override to max bound", async () => { - process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = String(FEISHU_HTTP_TIMEOUT_MAX_MS + 123_456); + setFeishuTestEnvValue( + FEISHU_HTTP_TIMEOUT_ENV_VAR, + String(FEISHU_HTTP_TIMEOUT_MAX_MS + 123_456), + ); createFeishuClient({ appId: "app_9", @@ -505,7 +507,7 @@ describe("createFeishuWSClient proxy handling", () => { }); it("creates a ws proxy agent when lowercase https_proxy is set", async () => { - process.env.https_proxy = "http://lower-https:8001"; + setFeishuTestEnvValue("https_proxy", "http://lower-https:8001"); await createFeishuWSClient(baseAccount); @@ -515,7 +517,7 @@ describe("createFeishuWSClient proxy handling", () => { }); it("creates a ws proxy agent when uppercase HTTPS_PROXY is set", async () => { - process.env.HTTPS_PROXY = "http://upper-https:8002"; + setFeishuTestEnvValue("HTTPS_PROXY", "http://upper-https:8002"); await createFeishuWSClient(baseAccount); @@ -525,7 +527,7 @@ describe("createFeishuWSClient proxy handling", () => { }); it("falls back to HTTP_PROXY for ws proxy agent creation", async () => { - process.env.HTTP_PROXY = "http://upper-http:8999"; + setFeishuTestEnvValue("HTTP_PROXY", "http://upper-http:8999"); await createFeishuWSClient(baseAccount); diff --git a/extensions/feishu/src/reasoning-preview.test.ts b/extensions/feishu/src/reasoning-preview.test.ts index 5ef967918ff9..c44de6ff3a93 100644 --- a/extensions/feishu/src/reasoning-preview.test.ts +++ b/extensions/feishu/src/reasoning-preview.test.ts @@ -3,8 +3,8 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ClawdbotConfig } from "./bot-runtime-api.js"; import { resolveFeishuReasoningPreviewEnabled } from "./reasoning-preview.js"; -const { loadSessionStoreMock } = vi.hoisted(() => ({ - loadSessionStoreMock: vi.fn(), +const { getSessionEntryMock } = vi.hoisted(() => ({ + getSessionEntryMock: vi.fn(), })); vi.mock("./bot-runtime-api.js", async () => { @@ -12,7 +12,7 @@ vi.mock("./bot-runtime-api.js", async () => { await vi.importActual("./bot-runtime-api.js"); return { ...actual, - loadSessionStore: loadSessionStoreMock, + getSessionEntry: getSessionEntryMock, }; }); @@ -29,9 +29,12 @@ describe("resolveFeishuReasoningPreviewEnabled", () => { }); it("enables previews only for stream reasoning sessions", () => { - loadSessionStoreMock.mockReturnValue({ - "agent:main:feishu:dm:ou_sender_1": { reasoningLevel: "stream" }, - "agent:main:feishu:dm:ou_sender_2": { reasoningLevel: "on" }, + getSessionEntryMock.mockImplementation(({ sessionKey }) => { + const entries = { + "agent:main:feishu:dm:ou_sender_1": { reasoningLevel: "stream" }, + "agent:main:feishu:dm:ou_sender_2": { reasoningLevel: "on" }, + }; + return entries[sessionKey as keyof typeof entries]; }); expect( @@ -50,10 +53,15 @@ describe("resolveFeishuReasoningPreviewEnabled", () => { sessionKey: "agent:main:feishu:dm:ou_sender_2", }), ).toBe(false); + expect(getSessionEntryMock).toHaveBeenCalledWith({ + storePath: "/tmp/feishu-sessions.json", + sessionKey: "agent:main:feishu:dm:ou_sender_1", + readConsistency: "latest", + }); }); it("returns false for missing sessions or load failures", () => { - loadSessionStoreMock.mockImplementationOnce(() => { + getSessionEntryMock.mockImplementationOnce(() => { throw new Error("disk unavailable"); }); @@ -75,9 +83,12 @@ describe("resolveFeishuReasoningPreviewEnabled", () => { }); it("falls back to configured stream defaults", () => { - loadSessionStoreMock.mockReturnValue({ - "agent:main:feishu:dm:ou_sender_1": {}, - "agent:main:feishu:dm:ou_sender_2": { reasoningLevel: "off" }, + getSessionEntryMock.mockImplementation(({ sessionKey }) => { + const entries = { + "agent:main:feishu:dm:ou_sender_1": {}, + "agent:main:feishu:dm:ou_sender_2": { reasoningLevel: "off" }, + }; + return entries[sessionKey as keyof typeof entries]; }); const cfg: ClawdbotConfig = { diff --git a/extensions/feishu/src/reasoning-preview.ts b/extensions/feishu/src/reasoning-preview.ts index e38e49a7f82f..db22a42eabbb 100644 --- a/extensions/feishu/src/reasoning-preview.ts +++ b/extensions/feishu/src/reasoning-preview.ts @@ -1,6 +1,6 @@ // Feishu plugin module implements reasoning preview behavior. import { resolveFeishuConfigReasoningDefault } from "./agent-config.js"; -import { loadSessionStore, resolveSessionStoreEntry } from "./bot-runtime-api.js"; +import { getSessionEntry } from "./bot-runtime-api.js"; import type { ClawdbotConfig } from "./bot-runtime-api.js"; export function resolveFeishuReasoningPreviewEnabled(params: { @@ -16,9 +16,11 @@ export function resolveFeishuReasoningPreviewEnabled(params: { } try { - const store = loadSessionStore(params.storePath, { skipCache: true }); - const level = resolveSessionStoreEntry({ store, sessionKey: params.sessionKey }).existing - ?.reasoningLevel; + const level = getSessionEntry({ + storePath: params.storePath, + sessionKey: params.sessionKey, + readConsistency: "latest", + })?.reasoningLevel; if (level === "on" || level === "stream" || level === "off") { return level === "stream"; } diff --git a/extensions/firecrawl/src/firecrawl-client.ts b/extensions/firecrawl/src/firecrawl-client.ts index 162bf6042d27..fdbb4c14ff25 100644 --- a/extensions/firecrawl/src/firecrawl-client.ts +++ b/extensions/firecrawl/src/firecrawl-client.ts @@ -1,5 +1,6 @@ // Firecrawl plugin module implements firecrawl client behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; import { DEFAULT_CACHE_TTL_MINUTES, markdownToText, @@ -41,6 +42,7 @@ const SCRAPE_CACHE = new Map< >(); const DEFAULT_SEARCH_COUNT = 5; const DEFAULT_SCRAPE_MAX_CHARS = 50_000; +const FIRECRAWL_SCRAPE_RESPONSE_MAX_BYTES = 64 * 1024 * 1024; const ALLOWED_FIRECRAWL_HOSTS = new Set(["api.firecrawl.dev"]); const FIRECRAWL_SELF_HOSTED_PRIVATE_ERROR = "Firecrawl custom baseUrl must target a private or internal self-hosted endpoint."; @@ -65,12 +67,9 @@ type FirecrawlSearchItem = { async function readFirecrawlJsonResponse( response: Response, label: string, + opts?: { maxBytes?: number }, ): Promise> { - try { - return (await response.json()) as Record; - } catch (cause) { - throw new Error(`${label}: malformed JSON response`, { cause }); - } + return await readProviderJsonResponse>(response, label, opts); } export type FirecrawlSearchParams = { @@ -220,11 +219,9 @@ async function postFirecrawlJson( const readJsonPayload = async (): Promise | null> => { const candidate = response as Response & { clone?: () => Response }; const jsonResponse = typeof candidate.clone === "function" ? candidate.clone() : response; - if (typeof jsonResponse.json !== "function") { - return null; - } try { - const payload = await jsonResponse.json(); + const body = await readResponseText(jsonResponse, { maxBytes: 64_000 }); + const payload = JSON.parse(body.text) as unknown; return payload && typeof payload === "object" && !Array.isArray(payload) ? (payload as Record) : null; @@ -579,7 +576,10 @@ export async function runFirecrawlScrape( }, }, async (response) => { - const payloadLocal = await readFirecrawlJsonResponse(response, "Firecrawl fetch failed"); + const payloadLocal = await readFirecrawlJsonResponse(response, "Firecrawl fetch failed", { + // Scrape can legitimately return page bodies before maxChars truncates parsed output. + maxBytes: FIRECRAWL_SCRAPE_RESPONSE_MAX_BYTES, + }); if (payloadLocal.success === false) { const detail = typeof payloadLocal.error === "string" @@ -613,6 +613,7 @@ export const testing = { assertFirecrawlScrapeTargetAllowed, parseFirecrawlScrapePayload, postFirecrawlJson, + readFirecrawlJsonResponse, resolveEndpoint, validateFirecrawlBaseUrl, resolveSearchItems, diff --git a/extensions/firecrawl/src/firecrawl-tools.test.ts b/extensions/firecrawl/src/firecrawl-tools.test.ts index fa2168e7b829..8ea338dfabb5 100644 --- a/extensions/firecrawl/src/firecrawl-tools.test.ts +++ b/extensions/firecrawl/src/firecrawl-tools.test.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createStreamingResponse } from "../../test-support/streaming-error-response.js"; import { DEFAULT_FIRECRAWL_BASE_URL, DEFAULT_FIRECRAWL_MAX_AGE_MS, @@ -966,6 +967,27 @@ describe("firecrawl tools", () => { ).rejects.toThrow("Firecrawl Search API error: malformed JSON response"); }); + it("bounds successful Firecrawl JSON bodies before parsing", async () => { + const streamed = createStreamingResponse({ + chunkCount: 32, + chunkSize: 1024 * 1024, + text: "x", + headers: { "content-type": "application/json" }, + }); + const jsonSpy = vi.spyOn(streamed.response, "json").mockRejectedValue(new Error("unbounded")); + + await expect( + firecrawlClientTesting.readFirecrawlJsonResponse( + streamed.response, + "Firecrawl Search API error", + ), + ).rejects.toThrow("Firecrawl Search API error: JSON response exceeds 16777216 bytes"); + + expect(streamed.getReadCount()).toBeLessThan(32); + expect(streamed.wasCanceled()).toBe(true); + expect(jsonSpy).not.toHaveBeenCalled(); + }); + it("reports malformed Firecrawl scrape JSON with a stable provider error", async () => { global.fetch = vi.fn( async () => diff --git a/extensions/github-copilot/embeddings.test.ts b/extensions/github-copilot/embeddings.test.ts index 4db618a58149..c973883d4b61 100644 --- a/extensions/github-copilot/embeddings.test.ts +++ b/extensions/github-copilot/embeddings.test.ts @@ -75,13 +75,16 @@ function mockDiscoveryResponse(spec: { json?: unknown; text?: string; }) { + const status = spec.status ?? (spec.ok ? 200 : 500); + const response = + spec.json !== undefined + ? new Response(JSON.stringify(spec.json), { + status, + headers: { "Content-Type": "application/json" }, + }) + : new Response(spec.text ?? "", { status }); fetchWithSsrFGuardMock.mockImplementationOnce(async () => ({ - response: { - ok: spec.ok, - status: spec.status ?? (spec.ok ? 200 : 500), - json: async () => spec.json, - text: async () => spec.text ?? "", - }, + response, release: vi.fn(async () => {}), })); } @@ -228,20 +231,16 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => { it("wraps invalid discovery JSON as a setup error", async () => { fetchWithSsrFGuardMock.mockImplementationOnce(async () => ({ - response: { - ok: true, + response: new Response("not-valid-json{{{", { status: 200, - json: async () => { - throw new SyntaxError("bad json"); - }, - text: async () => "", - }, + headers: { "Content-Type": "application/json" }, + }), release: vi.fn(async () => {}), })); await expect( githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions()), - ).rejects.toThrow("GitHub Copilot model discovery returned invalid JSON"); + ).rejects.toThrow("github-copilot.model-discovery: malformed JSON response"); }); it("bounds model discovery error bodies", async () => { @@ -360,7 +359,7 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => { ).toBe(true); expect( shouldContinueAutoSelection( - new Error("GitHub Copilot model discovery returned invalid JSON"), + new Error("github-copilot.model-discovery: malformed JSON response"), ), ).toBe(true); expect(shouldContinueAutoSelection(new Error("Network timeout"))).toBe(false); diff --git a/extensions/github-copilot/embeddings.ts b/extensions/github-copilot/embeddings.ts index e682dfac22c3..bd5cb091d4c5 100644 --- a/extensions/github-copilot/embeddings.ts +++ b/extensions/github-copilot/embeddings.ts @@ -7,7 +7,10 @@ import { type MemoryEmbeddingProviderAdapter, } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; import { buildCopilotIdeHeaders } from "openclaw/plugin-sdk/provider-auth"; -import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; +import { + readProviderJsonResponse, + readResponseTextLimited, +} from "openclaw/plugin-sdk/provider-http"; import { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime"; import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; import { resolveFirstGithubToken } from "./auth.js"; @@ -29,6 +32,7 @@ const COPILOT_HEADERS_STATIC: Record = { ...buildCopilotIdeHeaders(), }; const COPILOT_ERROR_BODY_LIMIT_BYTES = 8 * 1024; +const COPILOT_EMBEDDINGS_RESPONSE_MAX_BYTES = 64 * 1024 * 1024; function buildSsrfPolicy(baseUrl: string): SsrFPolicy | undefined { try { @@ -70,6 +74,7 @@ function isCopilotSetupError(err: unknown): boolean { err.message.includes("Copilot token response") || err.message.includes("No embedding models available") || err.message.includes("GitHub Copilot model discovery") || + err.message.includes("github-copilot.model-discovery") || err.message.includes("GitHub Copilot embedding model") || err.message.includes("Unexpected response from GitHub Copilot token endpoint") ); @@ -100,12 +105,7 @@ async function discoverEmbeddingModels(params: { const detail = await readResponseTextLimited(response, COPILOT_ERROR_BODY_LIMIT_BYTES); throw new Error(`GitHub Copilot model discovery HTTP ${response.status}: ${detail}`); } - let payload: unknown; - try { - payload = await response.json(); - } catch { - throw new Error("GitHub Copilot model discovery returned invalid JSON"); - } + const payload = await readProviderJsonResponse(response, "github-copilot.model-discovery"); const allModels = Array.isArray((payload as { data?: unknown })?.data) ? ((payload as { data: CopilotModelEntry[] }).data ?? []) : []; @@ -246,12 +246,9 @@ async function createGitHubCopilotEmbeddingProvider( throw new Error(`GitHub Copilot embeddings HTTP ${response.status}: ${detail}`); } - let payload: unknown; - try { - payload = await response.json(); - } catch { - throw new Error("GitHub Copilot embeddings returned invalid JSON"); - } + const payload = await readProviderJsonResponse(response, "github-copilot.embeddings", { + maxBytes: COPILOT_EMBEDDINGS_RESPONSE_MAX_BYTES, + }); return parseGitHubCopilotEmbeddingPayload(payload, input.length); }, }); diff --git a/extensions/github-copilot/models.test.ts b/extensions/github-copilot/models.test.ts index 5c3b7d4c1ce8..88e118d13c44 100644 --- a/extensions/github-copilot/models.test.ts +++ b/extensions/github-copilot/models.test.ts @@ -267,6 +267,47 @@ describe("fetchCopilotUsage", () => { plan: "free", }); }); + + it("bounds the usage read and cancels the stream when the body exceeds the JSON byte cap", async () => { + // Larger than the shared 16 MiB readProviderJsonResponse cap so the bounded reader cancels the + // stream mid-flight; if the cap were removed the unbounded res.json() would buffer the whole body. + const ONE_MIB = 1024 * 1024; + const TOTAL_CHUNKS = 32; // 32 MiB advertised body, double the cap. + const chunk = new Uint8Array(ONE_MIB); + + let bytesPulled = 0; + let canceled = false; + const makeOversizedJsonResponse = (): Response => { + let pulled = 0; + const body = new ReadableStream({ + pull(controller) { + if (pulled >= TOTAL_CHUNKS) { + controller.close(); + return; + } + pulled += 1; + bytesPulled += chunk.length; + controller.enqueue(chunk); + }, + cancel() { + canceled = true; + }, + }); + return new Response(body, { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const mockFetch = createProviderUsageFetch(async () => makeOversizedJsonResponse()); + + await expect(fetchCopilotUsage("token", 5000, mockFetch)).rejects.toThrow( + /github-copilot-usage: JSON response exceeds/, + ); + // The bounded reader cancels the body and never pulls the full advertised 32 MiB stream. + expect(canceled).toBe(true); + expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB); + }); }); describe("github-copilot token", () => { diff --git a/extensions/github-copilot/usage.ts b/extensions/github-copilot/usage.ts index c9957458f68a..1058b55f6892 100644 --- a/extensions/github-copilot/usage.ts +++ b/extensions/github-copilot/usage.ts @@ -1,5 +1,6 @@ // Github Copilot plugin module implements usage behavior. import { buildCopilotIdeHeaders } from "openclaw/plugin-sdk/provider-auth"; +import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; import { buildUsageHttpErrorSnapshot, fetchJson, @@ -41,7 +42,10 @@ export async function fetchCopilotUsage( }); } - const data = (await res.json()) as CopilotUsageResponse; + const data = await readProviderJsonResponse( + res, + "github-copilot-usage", + ); const windows: UsageWindow[] = []; if (data.quota_snapshots?.premium_interactions) { diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 97181708a127..10df44180968 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -855,7 +855,7 @@ describe("google-meet plugin", () => { }); it("registers the node-host command used by chrome-node transport", () => { - const { nodeHostCommands } = setup(); + const { nodeHostCommands, nodeInvokePolicies } = setup(); const command = nodeHostCommands.find( (entry): entry is Record => @@ -865,7 +865,13 @@ describe("google-meet plugin", () => { throw new Error("expected googlemeet.chrome node host command"); } expect(command.cap).toBe("google-meet"); + expect(command.dangerous).toBe(true); expect(typeof command.handle).toBe("function"); + expect(nodeInvokePolicies).toHaveLength(1); + expect(nodeInvokePolicies[0]).toMatchObject({ + commands: ["googlemeet.chrome"], + dangerous: true, + }); }); it("keeps the agent tool visible on non-macOS hosts but blocks local Chrome talk-back joins", async () => { @@ -2239,6 +2245,9 @@ describe("google-meet plugin", () => { try { const { methods, runCommandWithTimeout } = setup({ defaultMode: "transcribe", + chrome: { + browserProfile: "meet-devtools", + }, }); const callGatewayFromCli = mockLocalMeetBrowserRequest({ inCall: true, @@ -3428,7 +3437,12 @@ describe("google-meet plugin", () => { }, ); chromeTransportTesting.setDepsForTest({ callGatewayFromCli }); - const { tools, nodesInvoke } = setup({ defaultTransport: "chrome" }); + const { tools, nodesInvoke } = setup({ + defaultTransport: "chrome", + chrome: { + browserProfile: "meet-devtools", + }, + }); const tool = tools[0] as { execute: ( id: string, @@ -3458,6 +3472,7 @@ describe("google-meet plugin", () => { expect(focusCall[0]).toBe("browser.request"); expect(requireRecord(focusCall[2], "focus request").method).toBe("POST"); expect(requireRecord(focusCall[2], "focus request").path).toBe("/tabs/focus"); + expect(requireRecord(focusCall[2], "focus request").query).toBeUndefined(); expect(focusCall[3]).toEqual({ progress: false }); expect(nodesInvoke).not.toHaveBeenCalled(); }); diff --git a/extensions/google-meet/index.ts b/extensions/google-meet/index.ts index fb28baa4f0d8..cef79e5608f2 100644 --- a/extensions/google-meet/index.ts +++ b/extensions/google-meet/index.ts @@ -35,6 +35,10 @@ import { fetchGoogleMeetSpace, } from "./src/meet.js"; import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js"; +import { + createGoogleMeetChromeNodeInvokePolicy, + GOOGLE_MEET_CHROME_NODE_COMMAND, +} from "./src/node-invoke-policy.js"; import { GoogleMeetRuntime } from "./src/runtime.js"; import { isGoogleMeetBrowserManualActionError } from "./src/transports/chrome-create.js"; @@ -1196,10 +1200,12 @@ export default definePluginEntry({ ); api.registerNodeHostCommand({ - command: "googlemeet.chrome", + command: GOOGLE_MEET_CHROME_NODE_COMMAND, cap: "google-meet", + dangerous: true, handle: handleGoogleMeetNodeHostCommand, }); + api.registerNodeInvokePolicy(createGoogleMeetChromeNodeInvokePolicy(config)); api.registerCli( async ({ program }) => { diff --git a/extensions/google-meet/node-host.test.ts b/extensions/google-meet/node-host.test.ts index f046ccfa91b9..5ae1e9b0df9f 100644 --- a/extensions/google-meet/node-host.test.ts +++ b/extensions/google-meet/node-host.test.ts @@ -91,6 +91,41 @@ describe("google-meet node host bridge sessions", () => { } }); + it("passes the Meet URL before Chrome profile args when launching a profiled browser", async () => { + const originalPlatform = process.platform; + children.length = 0; + vi.mocked(spawnSync).mockClear(); + + Object.defineProperty(process, "platform", { configurable: true, value: "darwin" }); + try { + const start = JSON.parse( + await handleGoogleMeetNodeHostCommand( + JSON.stringify({ + action: "start", + url: "https://meet.google.com/xyz-abcd-uvw", + mode: "transcribe", + browserProfile: "Profile 2", + }), + ), + ); + + expect(start.launched).toBe(true); + expect(spawnSync).toHaveBeenCalledWith( + "open", + [ + "-a", + "Google Chrome", + "https://meet.google.com/xyz-abcd-uvw", + "--args", + "--profile-directory=Profile 2", + ], + expect.objectContaining({ encoding: "utf8" }), + ); + } finally { + Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform }); + } + }); + it("clears output playback without closing the active bridge when the old output exits", async () => { const originalPlatform = process.platform; children.length = 0; diff --git a/extensions/google-meet/src/node-host.ts b/extensions/google-meet/src/node-host.ts index 27ddae829420..46ba0cb7141e 100644 --- a/extensions/google-meet/src/node-host.ts +++ b/extensions/google-meet/src/node-host.ts @@ -332,12 +332,11 @@ function startChrome(params: Record) { } if (params.launch !== false) { - const argv = ["open", "-a", "Google Chrome"]; + const argv = ["open", "-a", "Google Chrome", url]; const browserProfile = readString(params.browserProfile); if (browserProfile) { argv.push("--args", `--profile-directory=${browserProfile}`); } - argv.push(url); const result = runCommandWithTimeout(argv, timeoutMs); if (result.code !== 0) { if (bridgeId) { diff --git a/extensions/google-meet/src/node-invoke-policy.test.ts b/extensions/google-meet/src/node-invoke-policy.test.ts new file mode 100644 index 000000000000..7b87bfd9f1bb --- /dev/null +++ b/extensions/google-meet/src/node-invoke-policy.test.ts @@ -0,0 +1,134 @@ +// Google Meet node.invoke policy tests cover caller-controlled command sanitization. +import type { OpenClawPluginNodeInvokePolicyContext } from "openclaw/plugin-sdk/plugin-entry"; +import { describe, expect, it, vi } from "vitest"; +import { resolveGoogleMeetConfig } from "./config.js"; +import { + createGoogleMeetChromeNodeInvokePolicy, + GOOGLE_MEET_CHROME_NODE_COMMAND, +} from "./node-invoke-policy.js"; + +function createContext(params: unknown, pluginConfig: Record = {}) { + const invokeNode = vi.fn(async () => ({ + ok: true, + payload: { ok: true }, + })); + const ctx: OpenClawPluginNodeInvokePolicyContext = { + nodeId: "node-1", + command: GOOGLE_MEET_CHROME_NODE_COMMAND, + params, + config: {} as never, + pluginConfig, + invokeNode, + }; + return { ctx, invokeNode }; +} + +describe("Google Meet node invoke policy", () => { + it("rewrites start executable fields from trusted config", async () => { + const policy = createGoogleMeetChromeNodeInvokePolicy( + resolveGoogleMeetConfig({ + chrome: { + launch: false, + browserProfile: "Trusted Profile", + joinTimeoutMs: 45_000, + audioInputCommand: ["trusted-capture", "--raw"], + audioOutputCommand: ["trusted-play", "--raw"], + }, + }), + ); + const { ctx, invokeNode } = createContext({ + action: "start", + url: "https://meet.google.com/abc-defg-hij", + mode: "bidi", + launch: true, + browserProfile: "Attacker Profile", + joinTimeoutMs: 1, + audioBridgeCommand: ["node", "-e", "process.exit(99)"], + audioBridgeHealthCommand: ["node", "-e", "process.exit(98)"], + audioInputCommand: ["malicious-capture"], + audioOutputCommand: ["malicious-play"], + }); + + await expect(policy.handle(ctx)).resolves.toEqual({ ok: true, payload: { ok: true } }); + + expect(invokeNode).toHaveBeenCalledTimes(1); + expect(invokeNode).toHaveBeenCalledWith({ + params: { + action: "start", + url: "https://meet.google.com/abc-defg-hij", + mode: "bidi", + launch: false, + browserProfile: "Trusted Profile", + joinTimeoutMs: 45_000, + audioInputCommand: ["trusted-capture", "--raw"], + audioOutputCommand: ["trusted-play", "--raw"], + }, + }); + }); + + it("uses trusted configured external bridge commands for start", async () => { + const policy = createGoogleMeetChromeNodeInvokePolicy( + resolveGoogleMeetConfig({ + chrome: { + audioBridgeHealthCommand: ["trusted-bridge", "status"], + audioBridgeCommand: ["trusted-bridge", "start"], + }, + }), + ); + const { ctx, invokeNode } = createContext({ + action: "start", + url: "https://meet.google.com/abc-defg-hij", + mode: "bidi", + audioBridgeHealthCommand: ["node", "-e", "process.exit(98)"], + audioBridgeCommand: ["node", "-e", "process.exit(99)"], + }); + + await policy.handle(ctx); + + const call = invokeNode.mock.calls[0]?.[0]; + expect(call?.params).toMatchObject({ + action: "start", + audioBridgeHealthCommand: ["trusted-bridge", "status"], + audioBridgeCommand: ["trusted-bridge", "start"], + }); + }); + + it("rejects direct start for non-Meet URLs before node dispatch", async () => { + const policy = createGoogleMeetChromeNodeInvokePolicy(resolveGoogleMeetConfig({})); + const { ctx, invokeNode } = createContext({ + action: "start", + url: "https://example.com/private", + mode: "bidi", + }); + + await expect(policy.handle(ctx)).resolves.toMatchObject({ + ok: false, + code: "GOOGLE_MEET_NODE_POLICY_DENIED", + message: "url must be an explicit https://meet.google.com/... URL", + }); + expect(invokeNode).not.toHaveBeenCalled(); + }); + + it("keeps direct setup diagnostics but strips extra fields", async () => { + const policy = createGoogleMeetChromeNodeInvokePolicy(resolveGoogleMeetConfig({})); + const { ctx, invokeNode } = createContext({ + action: "setup", + audioBridgeCommand: ["node", "-e", "process.exit(99)"], + }); + + await policy.handle(ctx); + + expect(invokeNode).toHaveBeenCalledWith({ params: { action: "setup" } }); + }); + + it("rejects unsupported googlemeet.chrome actions before node dispatch", async () => { + const policy = createGoogleMeetChromeNodeInvokePolicy(resolveGoogleMeetConfig({})); + const { ctx, invokeNode } = createContext({ action: "exec", command: ["id"] }); + + await expect(policy.handle(ctx)).resolves.toMatchObject({ + ok: false, + code: "GOOGLE_MEET_NODE_POLICY_DENIED", + }); + expect(invokeNode).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/google-meet/src/node-invoke-policy.ts b/extensions/google-meet/src/node-invoke-policy.ts new file mode 100644 index 000000000000..56052f0f0c42 --- /dev/null +++ b/extensions/google-meet/src/node-invoke-policy.ts @@ -0,0 +1,192 @@ +import type { + OpenClawPluginNodeInvokePolicy, + OpenClawPluginNodeInvokePolicyContext, + OpenClawPluginNodeInvokePolicyResult, +} from "openclaw/plugin-sdk/plugin-entry"; +import type { GoogleMeetConfig } from "./config.js"; +import { normalizeMeetUrl } from "./runtime.js"; + +export const GOOGLE_MEET_CHROME_NODE_COMMAND = "googlemeet.chrome"; + +const START_MODES = new Set(["agent", "bidi", "realtime", "transcribe"]); + +type PolicyDecision = + | { approved: true; params: Record } + | { approved: false; result: OpenClawPluginNodeInvokePolicyResult }; + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function readPositiveNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; +} + +function copyCommand(command: string[] | undefined): string[] | undefined { + return command && command.length > 0 ? [...command] : undefined; +} + +function denied(message: string, code = "GOOGLE_MEET_NODE_POLICY_DENIED") { + return { ok: false as const, code, message }; +} + +function approved(params: Record): PolicyDecision { + return { approved: true, params }; +} + +function buildStartParams( + params: Record, + config: GoogleMeetConfig, +): PolicyDecision { + let url: string; + try { + url = normalizeMeetUrl(params.url); + } catch (error) { + return { + approved: false, + result: denied( + error instanceof Error ? error.message : "googlemeet.chrome start requires url", + ), + }; + } + const mode = readString(params.mode); + if (mode && !START_MODES.has(mode)) { + return { + approved: false, + result: denied(`googlemeet.chrome start mode is unsupported: ${mode}`), + }; + } + const startParams: Record = { + action: "start", + url, + launch: params.launch === false ? false : config.chrome.launch, + browserProfile: config.chrome.browserProfile, + joinTimeoutMs: config.chrome.joinTimeoutMs, + }; + if (mode) { + startParams.mode = mode; + } + const audioInputCommand = copyCommand(config.chrome.audioInputCommand); + if (audioInputCommand) { + startParams.audioInputCommand = audioInputCommand; + } + const audioOutputCommand = copyCommand(config.chrome.audioOutputCommand); + if (audioOutputCommand) { + startParams.audioOutputCommand = audioOutputCommand; + } + const audioBridgeCommand = copyCommand(config.chrome.audioBridgeCommand); + if (audioBridgeCommand) { + startParams.audioBridgeCommand = audioBridgeCommand; + } + const audioBridgeHealthCommand = copyCommand(config.chrome.audioBridgeHealthCommand); + if (audioBridgeHealthCommand) { + startParams.audioBridgeHealthCommand = audioBridgeHealthCommand; + } + return approved(startParams); +} + +function buildForwardParams(params: Record): Record | null { + const action = readString(params.action); + switch (action) { + case "setup": + return { action }; + case "status": { + const bridgeId = readString(params.bridgeId); + return bridgeId ? { action, bridgeId } : { action }; + } + case "list": { + const forwarded: Record = { action }; + const url = readString(params.url); + const mode = readString(params.mode); + if (url) { + forwarded.url = url; + } + if (mode) { + forwarded.mode = mode; + } + return forwarded; + } + case "stopByUrl": { + const forwarded: Record = { action }; + const url = readString(params.url); + const mode = readString(params.mode); + const exceptBridgeId = readString(params.exceptBridgeId); + if (url) { + forwarded.url = url; + } + if (mode) { + forwarded.mode = mode; + } + if (exceptBridgeId) { + forwarded.exceptBridgeId = exceptBridgeId; + } + return forwarded; + } + case "pullAudio": { + const forwarded: Record = { action }; + const bridgeId = readString(params.bridgeId); + const timeoutMs = readPositiveNumber(params.timeoutMs); + if (bridgeId) { + forwarded.bridgeId = bridgeId; + } + if (timeoutMs) { + forwarded.timeoutMs = timeoutMs; + } + return forwarded; + } + case "pushAudio": { + const forwarded: Record = { action }; + const bridgeId = readString(params.bridgeId); + const base64 = readString(params.base64); + if (bridgeId) { + forwarded.bridgeId = bridgeId; + } + if (base64) { + forwarded.base64 = base64; + } + return forwarded; + } + case "clearAudio": + case "stop": { + const bridgeId = readString(params.bridgeId); + return bridgeId ? { action, bridgeId } : { action }; + } + default: + return null; + } +} + +export function createGoogleMeetChromeNodeInvokePolicy( + config: GoogleMeetConfig, +): OpenClawPluginNodeInvokePolicy { + return { + commands: [GOOGLE_MEET_CHROME_NODE_COMMAND], + dangerous: true, + async handle(ctx: OpenClawPluginNodeInvokePolicyContext) { + if (ctx.command !== GOOGLE_MEET_CHROME_NODE_COMMAND) { + return denied(`unsupported Google Meet node command: ${ctx.command}`); + } + const params = asRecord(ctx.params); + const action = readString(params.action); + let decision: PolicyDecision; + if (action === "start") { + decision = buildStartParams(params, config); + } else { + const forwardParams = buildForwardParams(params); + decision = forwardParams + ? approved(forwardParams) + : { approved: false, result: denied("unsupported googlemeet.chrome action") }; + } + if (!decision.approved) { + return decision.result; + } + return await ctx.invokeNode({ params: decision.params }); + }, + }; +} diff --git a/extensions/google-meet/src/test-support/plugin-harness.ts b/extensions/google-meet/src/test-support/plugin-harness.ts index 60b92918588a..3fecebc21d62 100644 --- a/extensions/google-meet/src/test-support/plugin-harness.ts +++ b/extensions/google-meet/src/test-support/plugin-harness.ts @@ -69,6 +69,7 @@ export function setupGoogleMeetPlugin( const tools: unknown[] = []; const cliRegistrations: unknown[] = []; const nodeHostCommands: unknown[] = []; + const nodeInvokePolicies: unknown[] = []; const nodesList = vi.fn( async () => options.nodesListResult ?? { @@ -165,6 +166,7 @@ export function setupGoogleMeetPlugin( }, registerCli: (_registrar: unknown, opts: unknown) => cliRegistrations.push(opts), registerNodeHostCommand: (command: unknown) => nodeHostCommands.push(command), + registerNodeInvokePolicy: (policy: unknown) => nodeInvokePolicies.push(policy), }); const originalPlatform = process.platform; Object.defineProperty(process, "platform", { @@ -184,6 +186,7 @@ export function setupGoogleMeetPlugin( nodesList, nodesInvoke, nodeHostCommands, + nodeInvokePolicies, }; } diff --git a/extensions/google/image-generation-provider.test.ts b/extensions/google/image-generation-provider.test.ts index 8010876e12f7..a530e91d94f8 100644 --- a/extensions/google/image-generation-provider.test.ts +++ b/extensions/google/image-generation-provider.test.ts @@ -8,6 +8,13 @@ import { testing as geminiWebSearchTesting } from "./src/gemini-web-search-provi let ssrfMock: { mockRestore: () => void } | undefined; +function jsonResponse(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + function mockGoogleApiKeyAuth() { vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ apiKey: "google-test-key", @@ -24,9 +31,8 @@ function installGoogleFetchMock(params?: { const mimeType = params?.mimeType ?? "image/png"; const data = params?.data ?? "png-data"; const inlineDataKey = params?.inlineDataKey ?? "inlineData"; - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ candidates: [ { content: { @@ -42,7 +48,7 @@ function installGoogleFetchMock(params?: { }, ], }), - }); + ); vi.stubGlobal("fetch", fetchMock); return fetchMock; } @@ -100,9 +106,8 @@ describe("Google image-generation provider", () => { source: "env", mode: "api-key", }); - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ candidates: [ { content: { @@ -119,7 +124,7 @@ describe("Google image-generation provider", () => { }, ], }), - }); + ); vi.stubGlobal("fetch", fetchMock); const provider = buildGoogleImageGenerationProvider(); @@ -208,10 +213,7 @@ describe("Google image-generation provider", () => { mockGoogleApiKeyAuth(); vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ candidates: { content: { parts: [] } } }), - }), + vi.fn().mockResolvedValue(jsonResponse({ candidates: { content: { parts: [] } } })), ); const provider = buildGoogleImageGenerationProvider(); @@ -229,9 +231,8 @@ describe("Google image-generation provider", () => { mockGoogleApiKeyAuth(); vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ + vi.fn().mockResolvedValue( + jsonResponse({ candidates: [ { content: { @@ -240,7 +241,7 @@ describe("Google image-generation provider", () => { }, ], }), - }), + ), ); const provider = buildGoogleImageGenerationProvider(); @@ -260,9 +261,8 @@ describe("Google image-generation provider", () => { source: "profile", mode: "token", }); - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ candidates: [ { content: { @@ -278,7 +278,7 @@ describe("Google image-generation provider", () => { }, ], }), - }); + ); vi.stubGlobal("fetch", fetchMock); const provider = buildGoogleImageGenerationProvider(); @@ -305,6 +305,74 @@ describe("Google image-generation provider", () => { }); }); + it("accepts valid multi-image inline JSON responses above the generic provider JSON cap", async () => { + mockGoogleApiKeyAuth(); + const imageBytes = Buffer.alloc(6 * 1024 * 1024, 1); + const imagePayload = imageBytes.toString("base64"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse({ + candidates: [ + { + content: { + parts: Array.from({ length: 3 }, () => ({ + inlineData: { + mimeType: "image/png", + data: imagePayload, + }, + })), + }, + }, + ], + }), + ), + ); + + const provider = buildGoogleImageGenerationProvider(); + const result = await provider.generateImage({ + provider: "google", + model: "gemini-3.1-flash-image-preview", + prompt: "draw a cat", + cfg: {}, + }); + + expect(result.images).toHaveLength(3); + expect(result.images.map((image) => image.buffer.byteLength)).toEqual([ + imageBytes.byteLength, + imageBytes.byteLength, + imageBytes.byteLength, + ]); + }); + + it("still rejects oversized Google image JSON responses", async () => { + mockGoogleApiKeyAuth(); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse({ + candidates: [ + { + content: { + parts: [{ text: "x".repeat(35 * 1024 * 1024) }], + }, + }, + ], + }), + ), + ); + + const provider = buildGoogleImageGenerationProvider(); + await expect( + provider.generateImage({ + provider: "google", + model: "gemini-3.1-flash-image-preview", + prompt: "draw a cat", + cfg: {}, + }), + ).rejects.toThrow("google.image-generation: JSON response exceeds"); + }); + it("sends reference images and explicit resolution for edit flows", async () => { mockGoogleApiKeyAuth(); const fetchMock = installGoogleFetchMock(); diff --git a/extensions/google/image-generation-provider.ts b/extensions/google/image-generation-provider.ts index 974512dfcd01..7006b5fac6d5 100644 --- a/extensions/google/image-generation-provider.ts +++ b/extensions/google/image-generation-provider.ts @@ -1,15 +1,18 @@ // Google provider module implements model/runtime integration. import { generatedImageAssetFromBase64, + resolveInlineImageJsonResponseMaxBytes, type GeneratedImageAsset, type ImageGenerationProvider, } from "openclaw/plugin-sdk/image-generation"; +import { MAX_IMAGE_BYTES } from "openclaw/plugin-sdk/media-runtime"; import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth"; import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime"; import { assertOkOrThrowHttpError, postJsonRequest, + readProviderJsonResponse, sanitizeConfiguredModelProviderRequest, } from "openclaw/plugin-sdk/provider-http"; import { @@ -22,6 +25,8 @@ import { normalizeGoogleModelId, resolveGoogleGenerativeAiHttpRequestConfig } fr const DEFAULT_GOOGLE_IMAGE_MODEL = "gemini-3.1-flash-image-preview"; const DEFAULT_IMAGE_TIMEOUT_MS = 180_000; const DEFAULT_OUTPUT_MIME = "image/png"; +const GOOGLE_MAX_IMAGE_RESULTS = 4; +const MB = 1024 * 1024; const GOOGLE_SUPPORTED_SIZES = [ "1024x1024", "1024x1536", @@ -49,6 +54,16 @@ function normalizeGoogleImageModel(model: string | undefined): string { return normalizeGoogleModelId(trimmed || DEFAULT_GOOGLE_IMAGE_MODEL); } +function resolveGeneratedImageMaxBytes(req: { + cfg: { agents?: { defaults?: { mediaMaxMb?: number } } }; +}): number { + const configured = req.cfg.agents?.defaults?.mediaMaxMb; + if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) { + return Math.floor(configured * MB); + } + return MAX_IMAGE_BYTES; +} + function mapSizeToImageConfig( size: string | undefined, ): { aspectRatio?: string; imageSize?: "2K" | "4K" } | undefined { @@ -149,14 +164,14 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider { }), capabilities: { generate: { - maxCount: 4, + maxCount: GOOGLE_MAX_IMAGE_RESULTS, supportsSize: true, supportsAspectRatio: true, supportsResolution: true, }, edit: { enabled: true, - maxCount: 4, + maxCount: GOOGLE_MAX_IMAGE_RESULTS, maxInputImages: 5, supportsSize: true, supportsAspectRatio: true, @@ -231,7 +246,12 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider { try { await assertOkOrThrowHttpError(res, "Google image generation failed"); - const payload = await res.json(); + const payload = await readProviderJsonResponse(res, "google.image-generation", { + maxBytes: resolveInlineImageJsonResponseMaxBytes( + GOOGLE_MAX_IMAGE_RESULTS, + resolveGeneratedImageMaxBytes(req), + ), + }); let imageIndex = 0; const images: GeneratedImageAsset[] = []; for (const part of googleResponseParts(payload)) { diff --git a/extensions/google/video-generation-provider.test.ts b/extensions/google/video-generation-provider.test.ts index 5a16b6604d37..1fc680411afb 100644 --- a/extensions/google/video-generation-provider.test.ts +++ b/extensions/google/video-generation-provider.test.ts @@ -94,6 +94,39 @@ function fetchInputUrl(fetchMock: ReturnType, index: number): stri return input.url; } +function oversizedJsonResponse(params: { chunkCount: number; chunkSize: number }): { + response: Response; + getReadCount: () => number; + wasCanceled: () => boolean; +} { + const chunk = new Uint8Array(params.chunkSize); + let readCount = 0; + let canceled = false; + return { + response: new Response( + new ReadableStream({ + pull(controller) { + if (readCount >= params.chunkCount) { + controller.close(); + return; + } + readCount += 1; + controller.enqueue(chunk); + }, + cancel() { + canceled = true; + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + getReadCount: () => readCount, + wasCanceled: () => canceled, + }; +} + let ssrfMock: { mockRestore: () => void } | undefined; describe("google video generation provider", () => { @@ -486,6 +519,33 @@ describe("google video generation provider", () => { expect(result.videos[0]?.buffer).toEqual(Buffer.from("rest-video")); }); + it("bounds successful Google REST operation JSON bodies instead of buffering the whole response", async () => { + vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-key", + source: "env", + mode: "api-key", + }); + generateVideosMock.mockRejectedValue(Object.assign(new Error("sdk 404"), { status: 404 })); + const streamed = oversizedJsonResponse({ chunkCount: 64, chunkSize: 1024 * 1024 }); + const fetchMock = vi.fn(async () => streamed.response); + vi.stubGlobal("fetch", fetchMock); + + const provider = buildGoogleVideoGenerationProvider(); + await expect( + provider.generateVideo({ + provider: "google", + model: "veo-3.1-fast-generate-preview", + prompt: "A tiny robot watering a windowsill garden", + cfg: {}, + durationSeconds: 3, + }), + ).rejects.toThrow("Google video operation response exceeds 16777216 bytes"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(streamed.getReadCount()).toBeLessThan(64); + expect(streamed.wasCanceled()).toBe(true); + }); + it("retries transient Google REST poll failures with empty bodies", async () => { vi.useFakeTimers(); vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ diff --git a/extensions/google/video-generation-provider.ts b/extensions/google/video-generation-provider.ts index 4257daa3ff02..3b9b295dcab3 100644 --- a/extensions/google/video-generation-provider.ts +++ b/extensions/google/video-generation-provider.ts @@ -28,6 +28,7 @@ const DEFAULT_TIMEOUT_MS = 180_000; const POLL_INTERVAL_MS = 10_000; const MAX_POLL_ATTEMPTS = 120; const DEFAULT_GENERATED_VIDEO_MAX_BYTES = 16 * 1024 * 1024; +const GOOGLE_VIDEO_OPERATION_RESPONSE_MAX_BYTES = 16 * 1024 * 1024; const GOOGLE_VIDEO_EMPTY_RESULT_MESSAGE = "Google video generation response missing generated videos"; @@ -349,7 +350,15 @@ async function requestGoogleVideoJson(params: { signal: controller.signal, }); try { - const text = await response.text(); + const buffer = await readResponseWithLimit( + response, + GOOGLE_VIDEO_OPERATION_RESPONSE_MAX_BYTES, + { + onOverflow: ({ maxBytes }) => + new Error(`Google video operation response exceeds ${maxBytes} bytes`), + }, + ); + const text = new TextDecoder().decode(buffer); if (!response.ok) { let detail: unknown = text; if (text) { diff --git a/extensions/imessage/src/monitor.last-route.test.ts b/extensions/imessage/src/monitor.last-route.test.ts index daabe0e2a05b..e5c6a3c7ef94 100644 --- a/extensions/imessage/src/monitor.last-route.test.ts +++ b/extensions/imessage/src/monitor.last-route.test.ts @@ -256,6 +256,183 @@ describe("iMessage monitor last-route updates", () => { }); }); + it("keeps direct progress options when imsg lacks native typing support", async () => { + setCachedIMessagePrivateApiStatus("imsg", { + available: true, + v2Ready: true, + selectors: {}, + rpcMethods: ["watch.subscribe", "send", "read"], + }); + dispatchInboundMessageMock.mockImplementationOnce(async (params) => { + expect(params.replyOptions?.suppressDefaultToolProgressMessages).toBe(true); + expect(params.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true); + expect(params.replyOptions?.onToolStart).toBeUndefined(); + return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } } as const; + }); + + let onNotification: ((message: { method: string; params: unknown }) => void) | undefined; + const client = { + request: vi.fn(async (method: string) => { + if (method === "watch.subscribe") { + return { subscription: 1 }; + } + if (method === "typing") { + throw new Error("typing should not start without native typing support"); + } + throw new Error(`unexpected imsg method ${method}`); + }), + waitForClose: vi.fn(async () => { + onNotification?.({ + method: "message", + params: { + message: { + id: 13, + chat_id: 123, + sender: "+15550001111", + is_from_me: false, + text: "run a long script without native typing", + is_group: false, + created_at: new Date().toISOString(), + }, + }, + }); + await Promise.resolve(); + await Promise.resolve(); + }), + stop: vi.fn(async () => {}), + }; + createIMessageRpcClientMock.mockImplementation(async (params) => { + if (!params?.onNotification) { + throw new Error("expected iMessage notification handler"); + } + onNotification = params.onNotification; + return client as never; + }); + + await monitorIMessageProvider({ + config: { + channels: { + imessage: { + dmPolicy: "allowlist", + allowFrom: ["+15550001111"], + sendReadReceipts: false, + }, + }, + messages: { inbound: { debounceMs: 0 } }, + session: { mainKey: "main" }, + } as never, + runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() }, + }); + + await vi.waitFor(() => { + expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1); + }); + expect(client.request).not.toHaveBeenCalledWith( + "typing", + expect.objectContaining({ typing: true }), + expect.anything(), + ); + }); + + it("starts direct typing before dispatching the inbound turn", async () => { + setCachedIMessagePrivateApiStatus("imsg", { + available: true, + v2Ready: true, + selectors: {}, + rpcMethods: ["watch.subscribe", "send", "typing"], + }); + + let onNotification: ((message: { method: string; params: unknown }) => void) | undefined; + const earlyTypingClient = { + request: vi.fn(async (method: string) => { + if (method === "typing") { + return { ok: true }; + } + throw new Error(`unexpected imsg typing-client method ${method}`); + }), + stop: vi.fn(async () => {}), + }; + const watchClient = { + request: vi.fn(async (method: string) => { + if (method === "watch.subscribe") { + return { subscription: 1 }; + } + if (method === "typing") { + return { ok: true }; + } + throw new Error(`unexpected imsg watch-client method ${method}`); + }), + waitForClose: vi.fn(async () => { + onNotification?.({ + method: "message", + params: { + message: { + id: 12, + chat_id: 123, + sender: "+15550001111", + is_from_me: false, + text: "respond after a slow context build", + is_group: false, + created_at: new Date().toISOString(), + }, + }, + }); + await vi.waitFor(() => { + expect(earlyTypingClient.request).toHaveBeenCalledWith( + "typing", + expect.objectContaining({ typing: true, to: "+15550001111" }), + expect.any(Object), + ); + expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1); + }); + }), + stop: vi.fn(async () => {}), + }; + createIMessageRpcClientMock.mockImplementation(async (params) => { + if (params?.onNotification) { + onNotification = params.onNotification; + return watchClient as never; + } + return earlyTypingClient as never; + }); + dispatchInboundMessageMock.mockImplementationOnce(async () => { + expect(earlyTypingClient.request).toHaveBeenCalledWith( + "typing", + expect.objectContaining({ typing: true, to: "+15550001111" }), + expect.any(Object), + ); + return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } } as const; + }); + + await monitorIMessageProvider({ + config: { + channels: { + imessage: { + dmPolicy: "allowlist", + allowFrom: ["+15550001111"], + sendReadReceipts: false, + }, + }, + messages: { inbound: { debounceMs: 0 } }, + session: { mainKey: "main" }, + } as never, + runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() }, + }); + + expect(watchClient.request).not.toHaveBeenCalledWith( + "typing", + expect.objectContaining({ typing: true }), + expect.anything(), + ); + await vi.waitFor(() => { + expect(earlyTypingClient.request).toHaveBeenCalledWith( + "typing", + expect.objectContaining({ typing: false, to: "+15550001111" }), + expect.any(Object), + ); + }); + }); + it.each(["never", "message", "thinking"] as const)( "does not start direct tool typing when typingMode is %s", async (typingMode) => { @@ -420,6 +597,87 @@ describe("iMessage monitor last-route updates", () => { ); }); + it("does not wait for read receipts before dispatching the inbound turn", async () => { + setCachedIMessagePrivateApiStatus("imsg", { + available: true, + v2Ready: true, + selectors: {}, + rpcMethods: ["watch.subscribe", "read"], + }); + + let onNotification: ((message: { method: string; params: unknown }) => void) | undefined; + const readClient = { + request: vi.fn((method: string) => { + if (method === "read") { + return new Promise(() => {}); + } + return Promise.reject(new Error(`unexpected imsg read-client method ${method}`)); + }), + stop: vi.fn(async () => {}), + }; + const watchClient = { + request: vi.fn((method: string) => { + if (method === "watch.subscribe") { + return Promise.resolve({ subscription: 1 }); + } + return Promise.reject(new Error(`unexpected imsg watch-client method ${method}`)); + }), + waitForClose: vi.fn(async () => { + onNotification?.({ + method: "message", + params: { + message: { + id: 11, + chat_id: 123, + sender: "+15550001111", + is_from_me: false, + text: "respond without waiting for read receipt", + is_group: false, + created_at: new Date().toISOString(), + }, + }, + }); + await vi.waitFor(() => { + expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1); + }); + }), + stop: vi.fn(async () => {}), + }; + createIMessageRpcClientMock.mockImplementation(async (params) => { + if (params?.onNotification) { + onNotification = params.onNotification; + return watchClient as never; + } + return readClient as never; + }); + + await monitorIMessageProvider({ + config: { + channels: { + imessage: { + dmPolicy: "allowlist", + allowFrom: ["+15550001111"], + }, + }, + messages: { inbound: { debounceMs: 0 } }, + session: { mainKey: "main" }, + } as never, + runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() }, + }); + + expect(readClient.request).toHaveBeenCalledWith( + "read", + expect.objectContaining({ to: "+15550001111" }), + expect.any(Object), + ); + expect(watchClient.request).not.toHaveBeenCalledWith( + "read", + expect.anything(), + expect.anything(), + ); + expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1); + }); + it.each([ { label: "flat true", diff --git a/extensions/imessage/src/monitor/inbound-processing.ts b/extensions/imessage/src/monitor/inbound-processing.ts index 91cce708d2cc..6ca0b17fb162 100644 --- a/extensions/imessage/src/monitor/inbound-processing.ts +++ b/extensions/imessage/src/monitor/inbound-processing.ts @@ -1087,7 +1087,7 @@ function buildIMessageEchoScope(params: { return scopes; } -function buildDirectIMessageReplyTarget(params: { +export function buildDirectIMessageReplyTarget(params: { cfg: OpenClawConfig; accountId?: string | null; sender: string; diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts index b0dfc15d1e5e..eb82475538a3 100644 --- a/extensions/imessage/src/monitor/monitor-provider.ts +++ b/extensions/imessage/src/monitor/monitor-provider.ts @@ -94,6 +94,7 @@ import { releaseIMessageInboundReplay, } from "./inbound-dedupe.js"; import { + buildDirectIMessageReplyTarget, buildIMessageInboundContext, rememberIMessageSkippedFromMeForSelfChatDedupe, resolveIMessageReactionContext, @@ -1039,6 +1040,87 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P const storePath = resolveStorePath(cfg.session?.store, { agentId: decision.route.agentId, }); + const privateApiStatus = getCachedIMessagePrivateApiStatus(cliPath); + const supportsTyping = imessageRpcSupportsMethod(privateApiStatus, "typing"); + const supportsRead = imessageRpcSupportsMethod(privateApiStatus, "read"); + if (privateApiStatus?.available === true) { + // Surface a single warning per restart when the bridge is up but we + // had to gate off typing/read because the imsg build pre-dates the + // capability list. Otherwise the user sees no typing bubble / no + // "Read" receipt with no visible reason. + if (!supportsTyping || !supportsRead) { + warnIfImsgUpgradeNeeded.fireOnce(privateApiStatus.rpcMethods, runtime); + } + } + const configuredTypingMode = resolveConfiguredIMessageTypingMode(cfg); + const sendPolicy = resolveSendPolicy({ + cfg, + entry: getSessionEntry({ storePath, sessionKey: decision.route.sessionKey }), + sessionKey: decision.route.sessionKey, + channel: "imessage", + chatType: decision.isGroup ? "group" : "direct", + }); + const shouldUseDirectToolTypingOptions = + !decision.isGroup && + sendPolicy !== "deny" && + (configuredTypingMode === undefined || configuredTypingMode === "instant"); + const shouldStartDirectTyping = supportsTyping && shouldUseDirectToolTypingOptions; + const earlyDirectTypingTarget = shouldStartDirectTyping + ? buildDirectIMessageReplyTarget({ + cfg, + accountId: decision.route.accountId, + sender: decision.sender, + }) + : undefined; + let stopEarlyDirectTyping: (() => void) | undefined; + if (earlyDirectTypingTarget) { + // Start channel-native feedback before the expensive history/context/model + // path. Use a short-lived client so a slow typing RPC cannot block the + // monitor client's watch stream. Stop is sequenced after start so fast + // command replies cannot leave a late true after typing:false. + const earlyDirectTypingStarted = sendIMessageTyping(earlyDirectTypingTarget, true, { + cfg, + accountId: accountInfo.accountId, + }).then( + () => true, + (err: unknown) => { + logTypingFailure({ + log: (msg) => logVerbose(msg), + channel: "imessage", + action: "start", + target: earlyDirectTypingTarget, + error: err, + }); + return false; + }, + ); + let earlyTypingStopQueued = false; + stopEarlyDirectTyping = () => { + if (earlyTypingStopQueued) { + return; + } + earlyTypingStopQueued = true; + void earlyDirectTypingStarted + .then(async (started) => { + if (!started) { + return; + } + await sendIMessageTyping(earlyDirectTypingTarget, false, { + cfg, + accountId: accountInfo.accountId, + }); + }) + .catch((err: unknown) => { + logTypingFailure({ + log: (msg) => logVerbose(msg), + channel: "imessage", + action: "stop", + target: earlyDirectTypingTarget, + error: err, + }); + }); + }; + } const stagedAttachments = remoteHost ? [] : await stageIMessageAttachments(validAttachments, { @@ -1107,31 +1189,20 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P ); } - const privateApiStatus = getCachedIMessagePrivateApiStatus(cliPath); - const supportsTyping = imessageRpcSupportsMethod(privateApiStatus, "typing"); - const supportsRead = imessageRpcSupportsMethod(privateApiStatus, "read"); - if (privateApiStatus?.available === true) { - // Surface a single warning per restart when the bridge is up but we - // had to gate off typing/read because the imsg build pre-dates the - // capability list. Otherwise the user sees no typing bubble / no - // "Read" receipt with no visible reason. - if (!supportsTyping || !supportsRead) { - warnIfImsgUpgradeNeeded.fireOnce(privateApiStatus.rpcMethods, runtime); - } - } const sendReadReceipts = imessageCfg.sendReadReceipts !== false; const typingTarget = ctxPayload.To; if (supportsRead && sendReadReceipts && typingTarget) { - try { - await markIMessageChatRead(typingTarget, { - cfg, - accountId: accountInfo.accountId, - client: getActiveClient(), - }); - } catch (err) { + // Read receipts are best-effort channel UI. Do not put them on the + // critical path before model dispatch; slow private-API reads otherwise + // make accepted iMessage turns feel stuck before the agent starts. Use + // a short-lived client so a stuck read cannot block monitor-client typing. + void markIMessageChatRead(typingTarget, { + cfg, + accountId: accountInfo.accountId, + }).catch((err: unknown) => { runtime.error?.(`imessage: mark read failed: ${String(err)}`); - } + }); } const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({ @@ -1234,35 +1305,27 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P }, }); let directTypingController: IMessageTypingController | undefined; - const configuredTypingMode = resolveConfiguredIMessageTypingMode(cfg); - const sendPolicy = resolveSendPolicy({ - cfg, - entry: getSessionEntry({ storePath, sessionKey: decision.route.sessionKey }), - sessionKey: decision.route.sessionKey, - channel: "imessage", - chatType: decision.isGroup ? "group" : "direct", - }); - const shouldStartToolTyping = - !decision.isGroup && - sendPolicy !== "deny" && - (configuredTypingMode === undefined || configuredTypingMode === "instant"); - const directToolTypingOptions = shouldStartToolTyping + const directToolTypingOptions = shouldUseDirectToolTypingOptions ? ({ // iMessage's native typing bubble is channel-owned UI, not a // visible tool-progress message. The suppress flag is what lets // dispatch forward this callback even when verbose progress is off; // allowProgress covers message_tool_only source delivery. Keep this on - // the direct instant/default path so configured typingMode values still - // decide when typing can begin. + // the direct instant/default path even when older imsg builds do not + // report native typing support. suppressDefaultToolProgressMessages: true, allowProgressCallbacksWhenSourceDeliverySuppressed: true, onTypingController: (typing: IMessageTypingController) => { directTypingController = typing; typingReplyOptions.onTypingController?.(typing); }, - onToolStart: async () => { - await directTypingController?.startTypingLoop(); - }, + ...(supportsTyping + ? { + onToolStart: async () => { + await directTypingController?.startTypingLoop(); + }, + } + : {}), } as const) : {}; const configuredBlockStreaming = resolveChannelStreamingBlockEnabled(accountInfo.config); @@ -1325,11 +1388,13 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P historyMap: groupHistories, limit: historyLimit, }, - onPreDispatchFailure: () => - settleReplyDispatcher({ + onPreDispatchFailure: () => { + stopEarlyDirectTyping?.(); + void settleReplyDispatcher({ dispatcher, onSettled: () => markDispatchIdle(), - }), + }); + }, runDispatch: async () => { try { return await dispatchInboundMessage({ @@ -1348,6 +1413,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P }); } finally { markDispatchIdle(); + stopEarlyDirectTyping?.(); } }, }), diff --git a/extensions/imessage/src/monitor/sanitize-outbound.test.ts b/extensions/imessage/src/monitor/sanitize-outbound.test.ts index ab49a0770374..6459fd4f87a6 100644 --- a/extensions/imessage/src/monitor/sanitize-outbound.test.ts +++ b/extensions/imessage/src/monitor/sanitize-outbound.test.ts @@ -49,6 +49,15 @@ describe("sanitizeOutboundText", () => { expect(result).not.toMatch(/^assistant:$/m); }); + it("preserves prose lines that merely end with 'user:'/'system:'", () => { + expect(sanitizeOutboundText("Please send this reply to the user:")).toBe( + "Please send this reply to the user:", + ); + expect(sanitizeOutboundText("Here is a note for the system:")).toBe( + "Here is a note for the system:", + ); + }); + it("collapses excessive blank lines after stripping", () => { const text = "Hello\n\n\n\n\nWorld"; expect(sanitizeOutboundText(text)).toBe("Hello\n\nWorld"); diff --git a/extensions/imessage/src/monitor/sanitize-outbound.ts b/extensions/imessage/src/monitor/sanitize-outbound.ts index f861f700de25..cd230ee1baef 100644 --- a/extensions/imessage/src/monitor/sanitize-outbound.ts +++ b/extensions/imessage/src/monitor/sanitize-outbound.ts @@ -7,7 +7,9 @@ import { stripAssistantInternalScaffolding } from "openclaw/plugin-sdk/text-chun */ const INTERNAL_SEPARATOR_RE = /(?:#\+){2,}#?/g; const ASSISTANT_ROLE_MARKER_RE = /\bassistant\s+to\s*=\s*\w+/gi; -const ROLE_TURN_MARKER_RE = /\b(?:user|system|assistant)\s*:\s*$/gm; +// Only a standalone role marker on its own line (a leaked turn boundary) — not +// any line that merely ends with the word "user/system/assistant:" in prose. +const ROLE_TURN_MARKER_RE = /^[ \t]*(?:user|system|assistant)\s*:\s*$/gm; /** * Strip all assistant-internal scaffolding from outbound text before delivery. diff --git a/extensions/litellm/image-generation-provider.test.ts b/extensions/litellm/image-generation-provider.test.ts index 965598e09768..9674c763e7f4 100644 --- a/extensions/litellm/image-generation-provider.test.ts +++ b/extensions/litellm/image-generation-provider.test.ts @@ -33,15 +33,21 @@ vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({ resolveApiKeyForProvider: resolveApiKeyForProviderMock, })); -vi.mock("openclaw/plugin-sdk/provider-http", () => ({ - assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock, - createProviderOperationDeadline: createProviderOperationDeadlineMock, - postJsonRequest: postJsonRequestMock, - postMultipartRequest: postMultipartRequestMock, - resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock, - resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock, - sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock, -})); +vi.mock("openclaw/plugin-sdk/provider-http", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/provider-http", + ); + return { + assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock, + createProviderOperationDeadline: createProviderOperationDeadlineMock, + postJsonRequest: postJsonRequestMock, + postMultipartRequest: postMultipartRequestMock, + readProviderJsonResponse: actual.readProviderJsonResponse, + resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock, + resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock, + sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock, + }; +}); afterAll(() => { vi.doUnmock("openclaw/plugin-sdk/provider-auth-runtime"); @@ -49,13 +55,18 @@ afterAll(() => { vi.resetModules(); }); +function jsonResponse(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + function mockGeneratedPngResponse() { postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => ({ - data: [{ b64_json: Buffer.from("png-bytes").toString("base64") }], - }), - }, + response: jsonResponse({ + data: [{ b64_json: Buffer.from("png-bytes").toString("base64") }], + }), release: vi.fn(async () => {}), }); } diff --git a/extensions/lmstudio/src/models.fetch.ts b/extensions/lmstudio/src/models.fetch.ts index 2b53c959e938..2cd2dbdd9927 100644 --- a/extensions/lmstudio/src/models.fetch.ts +++ b/extensions/lmstudio/src/models.fetch.ts @@ -3,6 +3,7 @@ import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core"; import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; import { readProviderJsonArrayFieldResponse, + readProviderJsonResponse, readResponseTextLimited, } from "openclaw/plugin-sdk/provider-http"; import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared"; @@ -285,12 +286,13 @@ export async function ensureLmstudioModelLoaded(params: { `LM Studio model load failed (${response.status})${body ? `: ${body}` : ""}`, ); } - let payload: LmstudioLoadResponse; - try { - payload = (await response.json()) as LmstudioLoadResponse; - } catch (cause) { - throw new Error("LM Studio model load returned malformed JSON", { cause }); - } + // Read the success body through the shared byte-capped reader so a misbehaving + // or compromised LM Studio server cannot stream an unbounded JSON payload into + // memory before we parse it. Malformed JSON is wrapped with our own label. + const payload = await readProviderJsonResponse( + response, + "LM Studio model load", + ); if (typeof payload.status === "string" && payload.status.toLowerCase() !== "loaded") { throw new Error(`LM Studio model load returned unexpected status: ${payload.status}`); } diff --git a/extensions/lmstudio/src/models.test.ts b/extensions/lmstudio/src/models.test.ts index 91a45a4f5333..c7185968ff67 100644 --- a/extensions/lmstudio/src/models.test.ts +++ b/extensions/lmstudio/src/models.test.ts @@ -582,7 +582,53 @@ describe("lmstudio-models", () => { baseUrl: "http://localhost:1234/v1", modelKey: "qwen3-8b-instruct", }), - ).rejects.toThrow("LM Studio model load returned malformed JSON"); + ).rejects.toThrow("LM Studio model load: malformed JSON response"); + }); + + it("bounds oversized model load success bodies", async () => { + // A misbehaving server may stream an unbounded success JSON body; the load + // path must stop reading at the byte cap instead of buffering it all. + let canceled = false; + let bytesEmitted = 0; + const oversizedStream = new ReadableStream({ + pull(controller) { + // Far exceeds the 16 MiB provider JSON cap if read to completion. + if (bytesEmitted >= 32 * 1024 * 1024) { + controller.close(); + return; + } + bytesEmitted += 64 * 1024; + controller.enqueue(new Uint8Array(64 * 1024).fill(0x61)); + }, + cancel() { + canceled = true; + }, + }); + const fetchMock = vi.fn(async (url: string | URL) => { + if (String(url).endsWith("/api/v1/models")) { + return jsonResponse({ + models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }], + }); + } + if (String(url).endsWith("/api/v1/models/load")) { + return new Response(oversizedStream, { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + throw new Error(`Unexpected fetch URL: ${String(url)}`); + }); + vi.stubGlobal("fetch", asFetch(fetchMock)); + + const error = await ensureLmstudioModelLoaded({ + baseUrl: "http://localhost:1234/v1", + modelKey: "qwen3-8b-instruct", + }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/JSON response exceeds \d+ bytes/); + expect(canceled).toBe(true); + expect(bytesEmitted).toBeLessThan(32 * 1024 * 1024); }); it("bounds model load error bodies", async () => { diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 564d62765cfd..bec17ec64663 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -40,10 +40,7 @@ import { import type { GetReplyOptions } from "openclaw/plugin-sdk/reply-runtime"; import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing"; import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime"; -import { - loadSessionStore, - resolveSessionStoreEntry, -} from "openclaw/plugin-sdk/session-store-runtime"; +import { getSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { CoreConfig, @@ -347,12 +344,11 @@ function resolveMatrixSharedDmContextNotice(params: { } try { - const store = loadSessionStore(params.storePath); const currentSession = resolveMatrixStoredSessionMeta( - resolveSessionStoreEntry({ - store, + getSessionEntry({ + storePath: params.storePath, sessionKey: params.sessionKey, - }).existing, + }), ); if (!currentSession) { return null; diff --git a/extensions/matrix/src/session-route.ts b/extensions/matrix/src/session-route.ts index c02bc98f804b..e4e420e9003b 100644 --- a/extensions/matrix/src/session-route.ts +++ b/extensions/matrix/src/session-route.ts @@ -6,11 +6,7 @@ import { type ChannelOutboundSessionRouteParams, } from "openclaw/plugin-sdk/channel-core"; import { parseThreadSessionSuffix } from "openclaw/plugin-sdk/routing"; -import { - loadSessionStore, - resolveSessionStoreEntry, - resolveStorePath, -} from "openclaw/plugin-sdk/session-store-runtime"; +import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; import { resolveMatrixAccountConfig } from "./matrix/account-config.js"; import { resolveDefaultMatrixAccountId } from "./matrix/accounts.js"; import { resolveMatrixStoredSessionMeta } from "./matrix/session-store-metadata.js"; @@ -51,11 +47,10 @@ function resolveMatrixCurrentDmRoomId(params: { const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId, }); - const store = loadSessionStore(storePath); - const existing = resolveSessionStoreEntry({ - store, + const existing = getSessionEntry({ + storePath, sessionKey, - }).existing; + }); const currentSession = resolveMatrixStoredSessionMeta(existing); if (!currentSession) { return undefined; diff --git a/extensions/mattermost/runtime-api.ts b/extensions/mattermost/runtime-api.ts index 3c59240f1ef3..524793a44ad7 100644 --- a/extensions/mattermost/runtime-api.ts +++ b/extensions/mattermost/runtime-api.ts @@ -46,7 +46,7 @@ export { warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk/runtime-group-policy"; export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; -export { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; +export { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; export { formatInboundFromLabel } from "openclaw/plugin-sdk/channel-inbound"; export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound"; export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; diff --git a/extensions/mattermost/src/mattermost/model-picker.test.ts b/extensions/mattermost/src/mattermost/model-picker.test.ts index 6f50e97faee8..74ecc0db3047 100644 --- a/extensions/mattermost/src/mattermost/model-picker.test.ts +++ b/extensions/mattermost/src/mattermost/model-picker.test.ts @@ -214,4 +214,66 @@ describe("Mattermost model picker", () => { fs.rmSync(testDir, { recursive: true, force: true }); } }); + + it("resolves current and parent model overrides from targeted session entries", () => { + const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-model-picker-")); + try { + const storePath = path.join(testDir, "{agentId}.json"); + const supportStorePath = path.join(testDir, "support.json"); + const parentSessionKey = "agent:support:mattermost:default:channel-1"; + const childSessionKey = "agent:support:mattermost:default:child-with-explicit-parent"; + const directSessionKey = "agent:support:mattermost:default:direct-1"; + fs.writeFileSync( + supportStorePath, + JSON.stringify( + { + [parentSessionKey]: { + providerOverride: "anthropic", + modelOverride: "claude-sonnet-4-5", + sessionId: "parent-session", + }, + [childSessionKey]: { + parentSessionKey, + sessionId: "child-session", + }, + [directSessionKey]: { + providerOverride: "openai", + modelOverride: "gpt-5", + sessionId: "direct-session", + }, + }, + null, + 2, + ), + ); + const cfg: OpenClawConfig = { + session: { + store: storePath, + }, + }; + + expect( + resolveMattermostModelPickerCurrentModel({ + cfg, + route: { + agentId: "support", + sessionKey: directSessionKey, + }, + data, + }), + ).toBe("openai/gpt-5"); + expect( + resolveMattermostModelPickerCurrentModel({ + cfg, + route: { + agentId: "support", + sessionKey: childSessionKey, + }, + data, + }), + ).toBe("anthropic/claude-sonnet-4-5"); + } finally { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); }); diff --git a/extensions/mattermost/src/mattermost/model-picker.ts b/extensions/mattermost/src/mattermost/model-picker.ts index f678559f8ab2..28e945986b97 100644 --- a/extensions/mattermost/src/mattermost/model-picker.ts +++ b/extensions/mattermost/src/mattermost/model-picker.ts @@ -7,7 +7,7 @@ import { import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; import { parseStrictInteger } from "openclaw/plugin-sdk/number-runtime"; import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; -import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; +import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; import { normalizeOptionalString, normalizeStringifiedOptionalString, @@ -237,21 +237,28 @@ export function resolveMattermostModelPickerCurrentModel(params: { cfg: OpenClawConfig; route: { agentId: string; sessionKey: string }; data: ModelsProviderData; - skipCache?: boolean; + readConsistency?: "latest"; }): string { const fallback = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`; try { const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.route.agentId, }); - const sessionStore = params.skipCache - ? loadSessionStore(storePath, { skipCache: true }) - : loadSessionStore(storePath); - const sessionEntry = sessionStore[params.route.sessionKey]; + const sessionEntry = getSessionEntry({ + storePath, + sessionKey: params.route.sessionKey, + ...(params.readConsistency === "latest" ? { readConsistency: "latest" as const } : {}), + }); const override = resolveStoredModelOverride({ sessionEntry, - sessionStore, + loadSessionEntry: (sessionKey) => + getSessionEntry({ + storePath, + sessionKey, + ...(params.readConsistency === "latest" ? { readConsistency: "latest" as const } : {}), + }), sessionKey: params.route.sessionKey, + parentSessionKey: sessionEntry?.parentSessionKey, defaultProvider: params.data.resolvedDefault.provider, }); if (!override?.model) { diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 9c290c07fd64..2eb628e7c80e 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -1256,7 +1256,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} cfg, route: modelSessionRoute, data, - skipCache: true, + readConsistency: "latest", }); const view = renderMattermostModelsPickerView({ ownerUserId: pickerState.ownerUserId, diff --git a/extensions/mattermost/src/runtime-api.ts b/extensions/mattermost/src/runtime-api.ts index f9bdc4bf46b2..142147a397d7 100644 --- a/extensions/mattermost/src/runtime-api.ts +++ b/extensions/mattermost/src/runtime-api.ts @@ -36,7 +36,6 @@ export { isTrustedProxyAddress, listSkillCommandsForAgents, loadOutboundMediaFromUrl, - loadSessionStore, logInboundDrop, logTypingFailure, migrateBaseNameToDefaultAccount, diff --git a/extensions/memory-core/src/cli.test.ts b/extensions/memory-core/src/cli.test.ts index 48365994fedf..2f4cc0c3ca1e 100644 --- a/extensions/memory-core/src/cli.test.ts +++ b/extensions/memory-core/src/cli.test.ts @@ -2342,7 +2342,7 @@ describe("memory cli", () => { lastRecalledAt: "", queryHashes: [""], recallDays: [""], - conceptTags: ["backup", "backups", "glacier"], + conceptTags: ["backup", "backups", "glacier", "s3"], }); expect(close).toHaveBeenCalled(); }); diff --git a/extensions/memory-core/src/concept-vocabulary.test.ts b/extensions/memory-core/src/concept-vocabulary.test.ts index 952051270881..23ca7b261b02 100644 --- a/extensions/memory-core/src/concept-vocabulary.test.ts +++ b/extensions/memory-core/src/concept-vocabulary.test.ts @@ -29,6 +29,30 @@ describe("concept vocabulary", () => { expect(tags).not.toContain("2026-04-04.md"); }); + it("preserves short protected-glossary terms past the latin minimum-length gate", () => { + const tags = deriveConceptTags({ + path: "memory/2026-04-04.md", + snippet: "Store the session in kv and back up to s3 nightly.", + }); + + // "kv" and "s3" are 2-char latin glossary entries that the generic min-length-3 gate would drop. + expect(tags).toContain("kv"); + expect(tags).toContain("s3"); + }); + + it("does not surface short glossary terms that only appear inside longer words", () => { + const tags = deriveConceptTags({ + path: "memory/2026-04-04.md", + snippet: "Played the mkv recording and tuned the css3 layout.", + }); + + // "kv"/"s3" are substrings of "mkv"/"css3"; whole-word matching must not emit them as tags. + expect(tags).not.toContain("kv"); + expect(tags).not.toContain("s3"); + expect(tags).toContain("mkv"); + expect(tags).toContain("css3"); + }); + it("extracts protected and segmented CJK concept tags", () => { const tags = deriveConceptTags({ path: "memory/2026-04-04.md", diff --git a/extensions/memory-core/src/concept-vocabulary.ts b/extensions/memory-core/src/concept-vocabulary.ts index 6876fa0ec608..78093d9c1c8c 100644 --- a/extensions/memory-core/src/concept-vocabulary.ts +++ b/extensions/memory-core/src/concept-vocabulary.ts @@ -330,7 +330,7 @@ function isKanaOnlyToken(value: string): boolean { ); } -function normalizeConceptToken(rawToken: string): string | null { +function normalizeConceptToken(rawToken: string, fromGlossary = false): string | null { const normalized = normalizeLowercaseStringOrEmpty( rawToken .normalize("NFKC") @@ -348,7 +348,9 @@ function normalizeConceptToken(rawToken: string): string | null { return null; } const script = classifyConceptTagScript(normalized); - if (normalized.length < minimumTokenLengthForScript(script)) { + // Glossary entries are an explicit allowlist of short technical terms (e.g. "kv", "s3"); they + // bypass the per-script minimum length that would otherwise discard them. + if (!fromGlossary && normalized.length < minimumTokenLengthForScript(script)) { return null; } if (isKanaOnlyToken(normalized) && normalized.length < 3) { @@ -360,14 +362,43 @@ function normalizeConceptToken(rawToken: string): string | null { return normalized; } +// Only entries shorter than their script's minimum token length rely on the glossary bypass, and +// only those need whole-word matching so they don't fire inside longer words ("kv" in "mkv"). Longer +// entries keep substring containment (the shipped behavior, e.g. "backup" tagging inside "backups"). +// Precomputed so derive() does not reclassify on every call. +const GLOSSARY_ENTRIES = PROTECTED_GLOSSARY.map((entry) => ({ + entry, + wholeWord: entry.length < minimumTokenLengthForScript(classifyConceptTagScript(entry)), +})); + +function isAlphanumericAt(source: string, index: number): boolean { + const ch = source[index]; + return ch !== undefined && LETTER_OR_NUMBER_RE.test(ch); +} + +// True when `entry` occurs as a delimiter-bounded token, not inside a longer word. Keeps short +// glossary entries like "kv"/"s3" from firing inside "mkv"/"css3" once they bypass the length gate. +function includesStandaloneTerm(source: string, entry: string): boolean { + let from = source.indexOf(entry); + while (from !== -1) { + if (!isAlphanumericAt(source, from - 1) && !isAlphanumericAt(source, from + entry.length)) { + return true; + } + from = source.indexOf(entry, from + 1); + } + return false; +} + function collectGlossaryMatches(source: string): string[] { const normalizedSource = normalizeLowercaseStringOrEmpty(source.normalize("NFKC")); const matches: string[] = []; - for (const entry of PROTECTED_GLOSSARY) { - if (!normalizedSource.includes(entry)) { - continue; + for (const { entry, wholeWord } of GLOSSARY_ENTRIES) { + const present = wholeWord + ? includesStandaloneTerm(normalizedSource, entry) + : normalizedSource.includes(entry); + if (present) { + matches.push(entry); } - matches.push(entry); } return matches; } @@ -385,8 +416,13 @@ function collectSegmentTokens(source: string): string[] { return source.split(/[^\p{L}\p{N}]+/u).filter(Boolean); } -function pushNormalizedTag(tags: string[], rawToken: string, limit: number): void { - const normalized = normalizeConceptToken(rawToken); +function pushNormalizedTag( + tags: string[], + rawToken: string, + limit: number, + fromGlossary = false, +): void { + const normalized = normalizeConceptToken(rawToken, fromGlossary); if (!normalized || tags.includes(normalized)) { return; } @@ -410,14 +446,17 @@ export function deriveConceptTags(params: { } const tags: string[] = []; - for (const rawToken of [ - ...collectGlossaryMatches(source), - ...collectCompoundTokens(source), - ...collectSegmentTokens(source), - ]) { - pushNormalizedTag(tags, rawToken, limit); - if (tags.length >= limit) { - break; + const tokenSources: Array<{ tokens: string[]; fromGlossary: boolean }> = [ + { tokens: collectGlossaryMatches(source), fromGlossary: true }, + { tokens: collectCompoundTokens(source), fromGlossary: false }, + { tokens: collectSegmentTokens(source), fromGlossary: false }, + ]; + for (const { tokens, fromGlossary } of tokenSources) { + for (const rawToken of tokens) { + pushNormalizedTag(tags, rawToken, limit, fromGlossary); + if (tags.length >= limit) { + return tags; + } } } return tags; diff --git a/extensions/memory-core/src/dreaming-narrative.test.ts b/extensions/memory-core/src/dreaming-narrative.test.ts index 55e7df982fc7..2a4dab12e86b 100644 --- a/extensions/memory-core/src/dreaming-narrative.test.ts +++ b/extensions/memory-core/src/dreaming-narrative.test.ts @@ -35,6 +35,19 @@ const NARRATIVE_SESSION_LOCKS_KEY = Symbol.for( "openclaw.memoryCore.dreamingNarrative.sessionLocks", ); const EXPECTS_POSIX_PRIVATE_FILE_MODE = process.platform !== "win32"; +const originalNarrativeStateDir = process.env.OPENCLAW_STATE_DIR; + +function setNarrativeTestEnv(stateDir: string): void { + Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir); +} + +function restoreNarrativeTestEnv(): void { + if (originalNarrativeStateDir === undefined) { + Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR"); + } else { + Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalNarrativeStateDir); + } +} type MockCallSource = { mock: { calls: Array> } }; @@ -89,7 +102,7 @@ async function expectPathMissing(targetPath: string): Promise { afterEach(() => { vi.restoreAllMocks(); - vi.unstubAllEnvs(); + restoreNarrativeTestEnv(); resolveGlobalMap(DREAMS_FILE_LOCKS_KEY).clear(); resolveGlobalMap(NARRATIVE_SESSION_LOCKS_KEY).clear(); }); @@ -1228,7 +1241,7 @@ describe("generateAndAppendDreamNarrative", () => { vi.spyOn(runtimeConfigSnapshotModule, "getRuntimeConfig").mockReturnValue({ session: {}, } as never); - vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + setNarrativeTestEnv(stateDir); vi.spyOn(memoryCoreHostRuntimeCoreModule, "resolveStateDir").mockReturnValue(stateDir); const subagent = createMockSubagent("The repository whispered of forgotten endpoints."); @@ -1297,7 +1310,7 @@ describe("generateAndAppendDreamNarrative", () => { vi.spyOn(runtimeConfigSnapshotModule, "getRuntimeConfig").mockReturnValue({ session: {}, } as never); - vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + setNarrativeTestEnv(stateDir); vi.spyOn(memoryCoreHostRuntimeCoreModule, "resolveStateDir").mockReturnValue(stateDir); const subagent = createMockSubagent("A forgotten endpoint hummed in the dark."); diff --git a/extensions/memory-core/src/dreaming-phases.test.ts b/extensions/memory-core/src/dreaming-phases.test.ts index 9c11a27735bf..b4dcf5b66c3a 100644 --- a/extensions/memory-core/src/dreaming-phases.test.ts +++ b/extensions/memory-core/src/dreaming-phases.test.ts @@ -11,7 +11,7 @@ import { resolveMemoryRemDreamingConfig, } from "openclaw/plugin-sdk/memory-core-host-status"; import { saveSessionStore } from "openclaw/plugin-sdk/session-store-runtime"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { testing, filterRecallEntriesWithinLookback, @@ -30,6 +30,8 @@ import { createMemoryCoreTestHarness } from "./test-helpers.js"; const { createTempWorkspace } = createMemoryCoreTestHarness(); const DREAMING_TEST_BASE_TIME = new Date("2026-04-05T10:00:00.000Z"); const DREAMING_TEST_DAY = "2026-04-05"; +const originalDreamingTestFast = process.env.OPENCLAW_TEST_FAST; +const originalDreamingStateDir = process.env.OPENCLAW_STATE_DIR; const EMPTY_SESSION_CONTENT_HASH = "75a11da44c802486bc6f65640aa48a730f0f684c5c07a42ba3cd1735eb3fb070"; const LIGHT_DREAMING_TEST_CONFIG: OpenClawConfig = { @@ -59,6 +61,28 @@ const LIGHT_DREAMING_TEST_CONFIG: OpenClawConfig = { }, }; +function setDreamingTestEnv(stateDir: string): void { + Reflect.set(process.env, "OPENCLAW_TEST_FAST", "1"); + Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir); +} + +function restoreDreamingTestEnv(): void { + if (originalDreamingTestFast === undefined) { + Reflect.deleteProperty(process.env, "OPENCLAW_TEST_FAST"); + } else { + Reflect.set(process.env, "OPENCLAW_TEST_FAST", originalDreamingTestFast); + } + if (originalDreamingStateDir === undefined) { + Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR"); + } else { + Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalDreamingStateDir); + } +} + +afterEach(() => { + restoreDreamingTestEnv(); +}); + function requireCandidateByKey(candidates: T[], key: string): T { const candidate = candidates.find((entry) => entry.key === key); if (!candidate) { @@ -947,8 +971,7 @@ describe("memory-core dreaming phases", () => { it("checkpoints session transcript ingestion and skips unchanged transcripts", async () => { const workspaceDir = await createDreamingWorkspace(); - vi.stubEnv("OPENCLAW_TEST_FAST", "1"); - vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state")); + setDreamingTestEnv(path.join(workspaceDir, ".state")); const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); await fs.mkdir(sessionsDir, { recursive: true }); const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl"); @@ -1022,7 +1045,7 @@ describe("memory-core dreaming phases", () => { ([target]) => typeof target === "string" && target === transcriptPath, ).length; readSpy.mockRestore(); - vi.unstubAllEnvs(); + restoreDreamingTestEnv(); } expect(transcriptReadCount).toBeLessThanOrEqual(1); @@ -1051,8 +1074,7 @@ describe("memory-core dreaming phases", () => { it("keeps primary session transcripts out of configured subagent workspaces", async () => { const workspaceDir = await createDreamingWorkspace(); const subagentWorkspaceDir = await createDreamingWorkspace(); - vi.stubEnv("OPENCLAW_TEST_FAST", "1"); - vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state")); + setDreamingTestEnv(path.join(workspaceDir, ".state")); const mainSessionsDir = resolveSessionTranscriptsDirForAgent("main"); const subagentSessionsDir = resolveSessionTranscriptsDirForAgent("agi-ceo"); @@ -1122,7 +1144,7 @@ describe("memory-core dreaming phases", () => { await triggerLightDreaming(beforeAgentReply, workspaceDir, 5); }); } finally { - vi.unstubAllEnvs(); + restoreDreamingTestEnv(); } const mainCorpus = await fs.readFile( @@ -1141,8 +1163,7 @@ describe("memory-core dreaming phases", () => { it("redacts sensitive session content before writing session corpus", async () => { const workspaceDir = await createDreamingWorkspace(); - vi.stubEnv("OPENCLAW_TEST_FAST", "1"); - vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state")); + setDreamingTestEnv(path.join(workspaceDir, ".state")); const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); await fs.mkdir(sessionsDir, { recursive: true }); const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl"); @@ -1198,7 +1219,7 @@ describe("memory-core dreaming phases", () => { await triggerLightDreaming(beforeAgentReply, workspaceDir, 5); }); } finally { - vi.unstubAllEnvs(); + restoreDreamingTestEnv(); } const corpusPath = path.join( @@ -1215,8 +1236,7 @@ describe("memory-core dreaming phases", () => { it("skips dreaming-generated narrative transcripts during session ingestion", async () => { const workspaceDir = await createDreamingWorkspace(); - vi.stubEnv("OPENCLAW_TEST_FAST", "1"); - vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state")); + setDreamingTestEnv(path.join(workspaceDir, ".state")); const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); await fs.mkdir(sessionsDir, { recursive: true }); const transcriptPath = path.join(sessionsDir, "dreaming-narrative.jsonl"); @@ -1291,7 +1311,7 @@ describe("memory-core dreaming phases", () => { { trigger: "heartbeat", workspaceDir }, ); } finally { - vi.unstubAllEnvs(); + restoreDreamingTestEnv(); } await expectPathMissing( @@ -1308,8 +1328,7 @@ describe("memory-core dreaming phases", () => { it("skips dreaming transcripts when the session store identifies them before bootstrap lands", async () => { const workspaceDir = await createDreamingWorkspace(); - vi.stubEnv("OPENCLAW_TEST_FAST", "1"); - vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state")); + setDreamingTestEnv(path.join(workspaceDir, ".state")); const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); await fs.mkdir(sessionsDir, { recursive: true }); const transcriptPath = path.join(sessionsDir, "dreaming-narrative.jsonl"); @@ -1387,7 +1406,7 @@ describe("memory-core dreaming phases", () => { { trigger: "heartbeat", workspaceDir }, ); } finally { - vi.unstubAllEnvs(); + restoreDreamingTestEnv(); } await expectPathMissing( @@ -1404,8 +1423,7 @@ describe("memory-core dreaming phases", () => { it("skips isolated cron run transcripts during session ingestion", async () => { const workspaceDir = await createDreamingWorkspace(); - vi.stubEnv("OPENCLAW_TEST_FAST", "1"); - vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state")); + setDreamingTestEnv(path.join(workspaceDir, ".state")); const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); await fs.mkdir(sessionsDir, { recursive: true }); const transcriptPath = path.join(sessionsDir, "cron-run.jsonl"); @@ -1480,7 +1498,7 @@ describe("memory-core dreaming phases", () => { { trigger: "heartbeat", workspaceDir }, ); } finally { - vi.unstubAllEnvs(); + restoreDreamingTestEnv(); } await expectPathMissing( @@ -1496,8 +1514,7 @@ describe("memory-core dreaming phases", () => { it("drops generated system wrapper text without suppressing paired assistant replies", async () => { const workspaceDir = await createDreamingWorkspace(); - vi.stubEnv("OPENCLAW_TEST_FAST", "1"); - vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state")); + setDreamingTestEnv(path.join(workspaceDir, ".state")); const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); await fs.mkdir(sessionsDir, { recursive: true }); const transcriptPath = path.join(sessionsDir, "ordinary-session.jsonl"); @@ -1580,7 +1597,7 @@ describe("memory-core dreaming phases", () => { ); } finally { vi.useRealTimers(); - vi.unstubAllEnvs(); + restoreDreamingTestEnv(); } const corpus = await fs.readFile( @@ -1595,8 +1612,7 @@ describe("memory-core dreaming phases", () => { it("drops archive, cron, and heartbeat chatter from fresh session corpus output", async () => { const workspaceDir = await createDreamingWorkspace(); - vi.stubEnv("OPENCLAW_TEST_FAST", "1"); - vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state")); + setDreamingTestEnv(path.join(workspaceDir, ".state")); const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); await fs.mkdir(sessionsDir, { recursive: true }); @@ -1729,7 +1745,7 @@ describe("memory-core dreaming phases", () => { ); } finally { vi.useRealTimers(); - vi.unstubAllEnvs(); + restoreDreamingTestEnv(); } const corpus = await fs.readFile( @@ -1781,8 +1797,7 @@ describe("memory-core dreaming phases", () => { it("does not reread unchanged dreaming-generated transcripts after checkpointing skip state", async () => { const workspaceDir = await createDreamingWorkspace(); - vi.stubEnv("OPENCLAW_TEST_FAST", "1"); - vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state")); + setDreamingTestEnv(path.join(workspaceDir, ".state")); const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); await fs.mkdir(sessionsDir, { recursive: true }); const transcriptPath = path.join(sessionsDir, "dreaming-narrative.jsonl"); @@ -1859,14 +1874,13 @@ describe("memory-core dreaming phases", () => { readFileSpy.mockRestore(); } finally { vi.restoreAllMocks(); - vi.unstubAllEnvs(); + restoreDreamingTestEnv(); } }); it("dedupes reset/deleted session archives instead of double-ingesting", async () => { const workspaceDir = await createDreamingWorkspace(); - vi.stubEnv("OPENCLAW_TEST_FAST", "1"); - vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state")); + setDreamingTestEnv(path.join(workspaceDir, ".state")); const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); await fs.mkdir(sessionsDir, { recursive: true }); const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl"); @@ -1958,7 +1972,7 @@ describe("memory-core dreaming phases", () => { await triggerLightDreaming(beforeAgentReply, workspaceDir, 910); }); } finally { - vi.unstubAllEnvs(); + restoreDreamingTestEnv(); } const ranked = await rankShortTermPromotionCandidates({ @@ -1987,10 +2001,80 @@ describe("memory-core dreaming phases", () => { expect(newOccurrences).toBe(1); }); + it("skips reset/deleted archive artifacts without active transcripts during session ingestion", async () => { + const workspaceDir = await createDreamingWorkspace(); + setDreamingTestEnv(path.join(workspaceDir, ".state")); + const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); + await fs.mkdir(sessionsDir, { recursive: true }); + const archivePath = path.join( + sessionsDir, + "archived-only.jsonl.deleted.2026-04-06T01-00-00.000Z", + ); + await fs.writeFile( + archivePath, + [ + JSON.stringify({ + type: "message", + message: { + role: "user", + timestamp: "2026-04-05T18:01:00.000Z", + content: [{ type: "text", text: "Archived session should not be dreamed." }], + }, + }), + ].join("\n") + "\n", + "utf-8", + ); + const mtime = new Date("2026-04-06T01:05:00.000Z"); + await fs.utimes(archivePath, mtime, mtime); + + const { beforeAgentReply } = createHarness( + { + agents: { + defaults: { + workspace: workspaceDir, + }, + }, + plugins: { + entries: { + "memory-core": { + config: { + dreaming: { + enabled: true, + phases: { + light: { + enabled: true, + limit: 20, + lookbackDays: 7, + }, + }, + }, + }, + }, + }, + }, + }, + workspaceDir, + ); + + try { + await withDreamingTestClock(async () => { + await triggerLightDreaming(beforeAgentReply, workspaceDir, 5); + }); + } finally { + restoreDreamingTestEnv(); + } + + await expectPathMissing( + path.join(workspaceDir, "memory", ".dreams", "session-corpus", "2026-04-05.txt"), + ); + + const sessionIngestion = await testing.readSessionIngestionState(workspaceDir); + expect(Object.keys(sessionIngestion.files)).toHaveLength(0); + }); + it("buckets session snippets by per-message day rather than file mtime", async () => { const workspaceDir = await createDreamingWorkspace(); - vi.stubEnv("OPENCLAW_TEST_FAST", "1"); - vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state")); + setDreamingTestEnv(path.join(workspaceDir, ".state")); const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); await fs.mkdir(sessionsDir, { recursive: true }); const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl"); @@ -2055,7 +2139,7 @@ describe("memory-core dreaming phases", () => { await triggerLightDreaming(beforeAgentReply, workspaceDir, 5); }); } finally { - vi.unstubAllEnvs(); + restoreDreamingTestEnv(); } const corpusDir = path.join(workspaceDir, "memory", ".dreams", "session-corpus"); @@ -2070,8 +2154,7 @@ describe("memory-core dreaming phases", () => { it("drains >80 unseen transcript messages across multiple unchanged sweeps", async () => { const workspaceDir = await createDreamingWorkspace(); - vi.stubEnv("OPENCLAW_TEST_FAST", "1"); - vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state")); + setDreamingTestEnv(path.join(workspaceDir, ".state")); const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); await fs.mkdir(sessionsDir, { recursive: true }); const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl"); @@ -2128,7 +2211,7 @@ describe("memory-core dreaming phases", () => { await triggerLightDreaming(beforeAgentReply, workspaceDir, 7); }); } finally { - vi.unstubAllEnvs(); + restoreDreamingTestEnv(); } const corpusPath = path.join( @@ -2150,8 +2233,7 @@ describe("memory-core dreaming phases", () => { it("re-ingests rewritten session transcripts after truncate/reset", async () => { const workspaceDir = await createDreamingWorkspace(); - vi.stubEnv("OPENCLAW_TEST_FAST", "1"); - vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state")); + setDreamingTestEnv(path.join(workspaceDir, ".state")); const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); await fs.mkdir(sessionsDir, { recursive: true }); const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl"); @@ -2228,7 +2310,7 @@ describe("memory-core dreaming phases", () => { await triggerLightDreaming(beforeAgentReply, workspaceDir, 910); }); } finally { - vi.unstubAllEnvs(); + restoreDreamingTestEnv(); } const ranked = await rankShortTermPromotionCandidates({ @@ -2245,8 +2327,7 @@ describe("memory-core dreaming phases", () => { it("ingests sessions when dreaming is enabled even if memorySearch is disabled", async () => { const workspaceDir = await createDreamingWorkspace(); - vi.stubEnv("OPENCLAW_TEST_FAST", "1"); - vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state")); + setDreamingTestEnv(path.join(workspaceDir, ".state")); const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); await fs.mkdir(sessionsDir, { recursive: true }); const transcriptPath = path.join(sessionsDir, "dreaming-main.jsonl"); @@ -2304,7 +2385,7 @@ describe("memory-core dreaming phases", () => { await triggerLightDreaming(beforeAgentReply, workspaceDir, 5); }); } finally { - vi.unstubAllEnvs(); + restoreDreamingTestEnv(); } const ranked = await rankShortTermPromotionCandidates({ diff --git a/extensions/memory-core/src/dreaming-phases.ts b/extensions/memory-core/src/dreaming-phases.ts index 9e1a7ca6dbc7..aa0fc5b0840d 100644 --- a/extensions/memory-core/src/dreaming-phases.ts +++ b/extensions/memory-core/src/dreaming-phases.ts @@ -848,7 +848,12 @@ async function collectSessionIngestionBatches(params: { for (const agentId of agentIds) { for (const entry of await listSessionTranscriptCorpusEntriesForAgent(agentId)) { const absolutePath = entry.sessionFile; - if (isCheckpointSessionTranscriptPath(absolutePath)) { + if ( + // Dreaming learns only from the live corpus. Retained reset/delete + // archives stay in the shared corpus for QMD and memory_search. + entry.artifactKind === "archive-artifact" || + isCheckpointSessionTranscriptPath(absolutePath) + ) { continue; } sessionFiles.push({ diff --git a/extensions/memory-core/src/memory/index.test.ts b/extensions/memory-core/src/memory/index.test.ts index 329612f0831d..70e542bc67a5 100644 --- a/extensions/memory-core/src/memory/index.test.ts +++ b/extensions/memory-core/src/memory/index.test.ts @@ -43,6 +43,7 @@ let providerCloseGate: Promise | null = null; let providerInitGate: Promise | null = null; let providerCalls: Array<{ provider?: string; model?: string; outputDimensionality?: number }> = []; let forceNoProvider = false; +const originalMemoryIndexStateDir = process.env.OPENCLAW_STATE_DIR; const identityAliasFixture = vi.hoisted(() => ({ provider: "identity-alias-test", @@ -58,6 +59,18 @@ function createLocalWorkerExitError(): Error { }); } +function setMemoryIndexStateDir(stateDir: string): void { + Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir); +} + +function restoreMemoryIndexStateDir(): void { + if (originalMemoryIndexStateDir === undefined) { + Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR"); + } else { + Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalMemoryIndexStateDir); + } +} + vi.mock("./embeddings.js", () => { const embedText = (text: string) => { const lower = text.toLowerCase(); @@ -276,7 +289,7 @@ describe("memory index", () => { closeOpenClawStateDatabaseForTest(); clearRegistry(); managersForCleanup.clear(); - vi.unstubAllEnvs(); + restoreMemoryIndexStateDir(); }); beforeEach(async () => { @@ -298,7 +311,7 @@ describe("memory index", () => { rmSync(workspaceDir, { recursive: true, force: true }); mkdirSync(memoryDir, { recursive: true }); - vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-memory-index")); + setMemoryIndexStateDir(path.join(workspaceDir, ".state-memory-index")); await fs.writeFile( path.join(memoryDir, "2026-01-12.md"), "# Log\nAlpha memory line.\nZebra memory line.", @@ -488,7 +501,7 @@ describe("memory index", () => { stateDirName: string; }): Promise { forceNoProvider = true; - vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, params.stateDirName)); + setMemoryIndexStateDir(path.join(workspaceDir, params.stateDirName)); const cfg = createCfg({ sources: ["memory", "sessions"], sessionMemory: true, @@ -573,7 +586,7 @@ describe("memory index", () => { it("reindexes memory tables in place without deleting unrelated agent rows", async () => { const stateDir = path.join(workspaceDir, "managed-memory-state"); - vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + setMemoryIndexStateDir(stateDir); const agentDbPath = resolveOpenClawAgentSqlitePath({ agentId: "main" }); const agentDb = openOpenClawAgentDatabase({ agentId: "main" }); agentDb.db @@ -1117,7 +1130,7 @@ describe("memory index", () => { it("clears dirty after sessions-only identity reindex", async () => { try { - vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-sessions-only-reindex")); + setMemoryIndexStateDir(path.join(workspaceDir, ".state-sessions-only-reindex")); const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); await fs.mkdir(sessionsDir, { recursive: true }); await fs.writeFile( @@ -1167,13 +1180,13 @@ describe("memory index", () => { await nextManager.close?.(); } } finally { - vi.unstubAllEnvs(); + restoreMemoryIndexStateDir(); } }); it("marks sessions-only indexes dirty when metadata is missing but chunks exist", async () => { try { - vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-sessions-missing-meta")); + setMemoryIndexStateDir(path.join(workspaceDir, ".state-sessions-missing-meta")); const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); await fs.mkdir(sessionsDir, { recursive: true }); await fs.writeFile( @@ -1223,13 +1236,13 @@ describe("memory index", () => { await nextManager.close?.(); } } finally { - vi.unstubAllEnvs(); + restoreMemoryIndexStateDir(); } }); it("keeps provider cutover vector search paused during targeted session sync", async () => { try { - vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-targeted-cutover")); + setMemoryIndexStateDir(path.join(workspaceDir, ".state-targeted-cutover")); const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); await fs.mkdir(sessionsDir, { recursive: true }); const sessionFile = path.join(sessionsDir, "session-targeted-cutover.jsonl"); @@ -1287,13 +1300,13 @@ describe("memory index", () => { await nextManager.close?.(); } } finally { - vi.unstubAllEnvs(); + restoreMemoryIndexStateDir(); } }); it("preserves memory dirty events raised during session identity reindex", async () => { try { - vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-dirty-during-session")); + setMemoryIndexStateDir(path.join(workspaceDir, ".state-dirty-during-session")); const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); await fs.mkdir(sessionsDir, { recursive: true }); await fs.writeFile( @@ -1351,7 +1364,7 @@ describe("memory index", () => { await nextManager.close?.(); } } finally { - vi.unstubAllEnvs(); + restoreMemoryIndexStateDir(); } }); @@ -2230,7 +2243,7 @@ describe("memory index", () => { expect(results[0]?.source).toBe("sessions"); expect(results[0]?.snippet).toContain("ORBIT-10"); } finally { - vi.unstubAllEnvs(); + restoreMemoryIndexStateDir(); } }); @@ -2274,7 +2287,7 @@ describe("memory index", () => { expect(results[0]?.source).toBe("sessions"); expect(results[0]?.snippet).toContain("ORBIT-10"); } finally { - vi.unstubAllEnvs(); + restoreMemoryIndexStateDir(); } }); }); diff --git a/extensions/memory-core/src/memory/manager-sync-ops.startup-catchup.test.ts b/extensions/memory-core/src/memory/manager-sync-ops.startup-catchup.test.ts index 2c69251c59d3..e5e7c3c61bfd 100644 --- a/extensions/memory-core/src/memory/manager-sync-ops.startup-catchup.test.ts +++ b/extensions/memory-core/src/memory/manager-sync-ops.startup-catchup.test.ts @@ -56,9 +56,32 @@ type MemoryTranscriptUpdateSubscriber = ( const MEMORY_CORE_TRANSCRIPT_UPDATE_SUBSCRIBER_KEY = Symbol.for( "openclaw.memoryCore.sessionTranscriptUpdateSubscriber", ); +const originalStartupStateDir = process.env.OPENCLAW_STATE_DIR; +const originalStartupConfigPath = process.env.OPENCLAW_CONFIG_PATH; type SourceStateRow = { path: string; hash: string; mtime: number; size: number }; +function setStartupStateDir(stateDir: string): void { + Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir); +} + +function setStartupConfigPath(configPath: string): void { + Reflect.set(process.env, "OPENCLAW_CONFIG_PATH", configPath); +} + +function restoreStartupEnv(): void { + if (originalStartupStateDir === undefined) { + Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR"); + } else { + Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalStartupStateDir); + } + if (originalStartupConfigPath === undefined) { + Reflect.deleteProperty(process.env, "OPENCLAW_CONFIG_PATH"); + } else { + Reflect.set(process.env, "OPENCLAW_CONFIG_PATH", originalStartupConfigPath); + } +} + class SessionStartupCatchupHarness extends MemoryManagerSyncOps { protected readonly cfg = {} as OpenClawConfig; protected readonly agentId = "main"; @@ -230,13 +253,13 @@ describe("session startup catch-up", () => { beforeEach(async () => { stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-startup-")); - vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + setStartupStateDir(stateDir); }); afterEach(async () => { vi.clearAllTimers(); vi.useRealTimers(); - vi.unstubAllEnvs(); + restoreStartupEnv(); clearRuntimeConfigSnapshot(); clearConfigCache(); await fs.rm(stateDir, { recursive: true, force: true }); @@ -458,7 +481,7 @@ describe("session startup catch-up", () => { "utf-8", ); await fs.writeFile(configPath, JSON.stringify({ session: { store: storePath } }), "utf-8"); - vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath); + setStartupConfigPath(configPath); clearRuntimeConfigSnapshot(); clearConfigCache(); const harness = new SessionStartupCatchupHarness([]); @@ -505,7 +528,7 @@ describe("session startup catch-up", () => { "utf-8", ); await fs.writeFile(configPath, JSON.stringify({ session: { store: storePath } }), "utf-8"); - vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath); + setStartupConfigPath(configPath); clearRuntimeConfigSnapshot(); clearConfigCache(); const harness = new SessionStartupCatchupHarness([]); @@ -553,7 +576,7 @@ describe("session startup catch-up", () => { "utf-8", ); await fs.writeFile(configPath, JSON.stringify({ session: { store: storePath } }), "utf-8"); - vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath); + setStartupConfigPath(configPath); clearRuntimeConfigSnapshot(); clearConfigCache(); const harness = new SessionStartupCatchupHarness([]); @@ -660,7 +683,7 @@ describe("session startup catch-up", () => { "utf-8", ); await fs.writeFile(configPath, JSON.stringify({ session: { store: storePath } }), "utf-8"); - vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath); + setStartupConfigPath(configPath); clearRuntimeConfigSnapshot(); clearConfigCache(); const harness = new SessionStartupCatchupHarness([]); diff --git a/extensions/memory-core/src/memory/manager-sync-yield.test.ts b/extensions/memory-core/src/memory/manager-sync-yield.test.ts index 0a65ee0a881f..1f56111b4117 100644 --- a/extensions/memory-core/src/memory/manager-sync-yield.test.ts +++ b/extensions/memory-core/src/memory/manager-sync-yield.test.ts @@ -13,6 +13,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { buildSessionEntryMock } = vi.hoisted(() => ({ buildSessionEntryMock: vi.fn(), })); +const originalSyncYieldStateDir = process.env.OPENCLAW_STATE_DIR; + +function setSyncYieldStateDir(): void { + Reflect.set( + process.env, + "OPENCLAW_STATE_DIR", + path.join(os.tmpdir(), "openclaw-session-sync-yield"), + ); +} + +function restoreSyncYieldStateDir(): void { + if (originalSyncYieldStateDir === undefined) { + Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR"); + } else { + Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalSyncYieldStateDir); + } +} vi.mock("undici", async () => { const actual = await vi.importActual("undici"); @@ -162,7 +179,7 @@ class SessionSyncYieldHarness extends MemoryManagerSyncOps { describe("session sync responsiveness", () => { beforeEach(() => { - vi.stubEnv("OPENCLAW_STATE_DIR", path.join(os.tmpdir(), "openclaw-session-sync-yield")); + setSyncYieldStateDir(); buildSessionEntryMock.mockImplementation(async (absPath: string) => { const name = path.basename(absPath); return { @@ -177,7 +194,7 @@ describe("session sync responsiveness", () => { }); afterEach(() => { - vi.unstubAllEnvs(); + restoreSyncYieldStateDir(); vi.clearAllMocks(); }); diff --git a/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts b/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts index c95be90d3138..32125e557a15 100644 --- a/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts +++ b/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts @@ -18,6 +18,19 @@ const createEmbeddingProviderMock = vi.hoisted(() => providerUnavailableReason: "No embeddings provider available.", })), ); +const originalFtsOnlyStateDir = process.env.OPENCLAW_STATE_DIR; + +function setFtsOnlyStateDir(stateDir: string): void { + Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir); +} + +function restoreFtsOnlyStateDir(): void { + if (originalFtsOnlyStateDir === undefined) { + Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR"); + } else { + Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalFtsOnlyStateDir); + } +} vi.mock("./embeddings.js", () => ({ createEmbeddingProvider: createEmbeddingProviderMock, @@ -44,7 +57,7 @@ describe("memory manager FTS-only reindex", () => { workspaceDir = path.join(fixtureRoot, `case-${caseId++}`); await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true }); await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "Alpha topic\n\nKeep this note."); - vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, "state")); + setFtsOnlyStateDir(path.join(workspaceDir, "state")); indexPath = resolveOpenClawAgentSqlitePath({ agentId: "main" }); }); @@ -54,7 +67,7 @@ describe("memory manager FTS-only reindex", () => { manager = null; } await closeAllMemorySearchManagers(); - vi.unstubAllEnvs(); + restoreFtsOnlyStateDir(); }); afterAll(async () => { diff --git a/extensions/memory-core/src/memory/manager.reindex-recovery.test.ts b/extensions/memory-core/src/memory/manager.reindex-recovery.test.ts index 821656012121..1417c8bfc786 100644 --- a/extensions/memory-core/src/memory/manager.reindex-recovery.test.ts +++ b/extensions/memory-core/src/memory/manager.reindex-recovery.test.ts @@ -13,6 +13,19 @@ import type { MemoryIndexMeta } from "./manager-reindex-state.js"; type SessionDeltaState = { lastSize: number; pendingBytes: number; pendingMessages: number }; type SyncSessionParams = { needsFullReindex: boolean; targetSessionFiles?: string[] }; +const originalReindexStateDir = process.env.OPENCLAW_STATE_DIR; + +function setReindexStateDir(stateDir: string): void { + Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir); +} + +function restoreReindexStateDir(): void { + if (originalReindexStateDir === undefined) { + Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR"); + } else { + Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalReindexStateDir); + } +} type ReindexHarness = { sync: (params: { reason?: string; force?: boolean }) => Promise; @@ -42,11 +55,11 @@ describe("memory manager reindex recovery", () => { workspaceDir = path.join(fixtureRoot, "workspace"); memoryDir = path.join(workspaceDir, "memory"); await fs.mkdir(memoryDir, { recursive: true }); - vi.stubEnv("OPENCLAW_STATE_DIR", path.join(fixtureRoot, "state")); + setReindexStateDir(path.join(fixtureRoot, "state")); }); afterEach(async () => { - vi.unstubAllEnvs(); + restoreReindexStateDir(); vi.restoreAllMocks(); if (manager) { await manager.close(); diff --git a/extensions/memory-core/src/memory/manager.self-heal-missing-identity.test.ts b/extensions/memory-core/src/memory/manager.self-heal-missing-identity.test.ts index 6804ee09a96e..43cc39aa8b7f 100644 --- a/extensions/memory-core/src/memory/manager.self-heal-missing-identity.test.ts +++ b/extensions/memory-core/src/memory/manager.self-heal-missing-identity.test.ts @@ -16,6 +16,19 @@ const createEmbeddingProviderMock = vi.hoisted(() => providerUnavailableReason: "No embeddings provider available.", })), ); +const originalSelfHealStateDir = process.env.OPENCLAW_STATE_DIR; + +function setSelfHealStateDir(stateDir: string): void { + Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir); +} + +function restoreSelfHealStateDir(): void { + if (originalSelfHealStateDir === undefined) { + Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR"); + } else { + Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalSelfHealStateDir); + } +} vi.mock("./embeddings.js", () => ({ createEmbeddingProvider: createEmbeddingProviderMock, @@ -49,7 +62,7 @@ describe("memory manager self-heal missing identity with FTS-only chunks", () => workspaceDir = path.join(fixtureRoot, `case-${caseId++}`); await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true }); await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "Alpha topic\n\nKeep this note."); - vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, "state")); + setSelfHealStateDir(path.join(workspaceDir, "state")); indexPath = resolveOpenClawAgentSqlitePath({ agentId: "main" }); }); @@ -59,7 +72,7 @@ describe("memory manager self-heal missing identity with FTS-only chunks", () => manager = null; } await closeAllMemorySearchManagers(); - vi.unstubAllEnvs(); + restoreSelfHealStateDir(); }); afterAll(async () => { diff --git a/extensions/memory-core/src/memory/manager.sync-errors-do-not-crash.test.ts b/extensions/memory-core/src/memory/manager.sync-errors-do-not-crash.test.ts index b29aae3fb7ea..f35808a9d0d7 100644 --- a/extensions/memory-core/src/memory/manager.sync-errors-do-not-crash.test.ts +++ b/extensions/memory-core/src/memory/manager.sync-errors-do-not-crash.test.ts @@ -17,18 +17,21 @@ describe("memory manager sync failures", () => { unhandled.push(reason); }; process.on("unhandledRejection", handler); - const syncSpy = vi - .fn() - .mockRejectedValueOnce(new Error("openai embeddings failed: 400 bad request")); - setTimeout(() => { - runDetachedMemorySync(syncSpy, "watch"); - }, 1); + try { + const syncSpy = vi + .fn() + .mockRejectedValueOnce(new Error("openai embeddings failed: 400 bad request")); + setTimeout(() => { + runDetachedMemorySync(syncSpy, "watch"); + }, 1); - await vi.runOnlyPendingTimersAsync(); - vi.useRealTimers(); - await syncSpy.mock.results[0]?.value?.catch(() => undefined); + await vi.runOnlyPendingTimersAsync(); + vi.useRealTimers(); + await syncSpy.mock.results[0]?.value?.catch(() => undefined); - process.off("unhandledRejection", handler); - expect(unhandled).toHaveLength(0); + expect(unhandled).toHaveLength(0); + } finally { + process.off("unhandledRejection", handler); + } }); }); diff --git a/extensions/memory-core/src/memory/manager.watcher-config.test.ts b/extensions/memory-core/src/memory/manager.watcher-config.test.ts index 0c525c2ca2d4..f2ad8ed61510 100644 --- a/extensions/memory-core/src/memory/manager.watcher-config.test.ts +++ b/extensions/memory-core/src/memory/manager.watcher-config.test.ts @@ -121,6 +121,19 @@ const { const CHOKIDAR_FACTORY_KEY = Symbol.for("openclaw.test.memoryWatchFactory"); const NATIVE_FACTORY_KEY = Symbol.for("openclaw.test.memoryNativeWatchFactory"); +const originalWatcherStateDir = process.env.OPENCLAW_STATE_DIR; + +function setWatcherStateDir(stateDir: string): void { + Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir); +} + +function restoreWatcherStateDir(): void { + if (originalWatcherStateDir === undefined) { + Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR"); + } else { + Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalWatcherStateDir); + } +} vi.mock("openclaw/plugin-sdk/memory-core-host-engine-foundation", async (importOriginal) => { const actual = @@ -194,7 +207,7 @@ describe("memory watcher config", () => { } await closeAllMemorySearchManagers(); clearRegistry(); - vi.unstubAllEnvs(); + restoreWatcherStateDir(); if (workspaceDir) { await fs.rm(workspaceDir, { recursive: true, force: true }); workspaceDir = ""; @@ -204,7 +217,7 @@ describe("memory watcher config", () => { async function setupWatcherWorkspace(seedFile: { name: string; contents: string }) { workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-watch-")); - vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, "state")); + setWatcherStateDir(path.join(workspaceDir, "state")); extraDir = path.join(workspaceDir, "extra"); await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true }); await fs.mkdir(extraDir, { recursive: true }); diff --git a/extensions/memory-core/src/memory/qmd-manager.slugified-paths.test.ts b/extensions/memory-core/src/memory/qmd-manager.slugified-paths.test.ts index 449a61ea47d8..44e4240664cc 100644 --- a/extensions/memory-core/src/memory/qmd-manager.slugified-paths.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.slugified-paths.test.ts @@ -76,6 +76,19 @@ import { resolveMemoryBackendConfig } from "openclaw/plugin-sdk/memory-core-host import { QmdMemoryManager } from "./qmd-manager.js"; const spawnMock = mockedSpawn as unknown as Mock; +const originalQmdStateDir = process.env.OPENCLAW_STATE_DIR; + +function setQmdStateDir(stateDir: string): void { + Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir); +} + +function restoreQmdStateDir(): void { + if (originalQmdStateDir === undefined) { + Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR"); + } else { + Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalQmdStateDir); + } +} describe("QmdMemoryManager slugified path resolution", () => { let tmpRoot: string; @@ -172,7 +185,7 @@ describe("QmdMemoryManager slugified path resolution", () => { workspaceDir = path.join(tmpRoot, "workspace"); stateDir = path.join(tmpRoot, "state"); await fs.mkdir(workspaceDir, { recursive: true }); - process.env.OPENCLAW_STATE_DIR = stateDir; + setQmdStateDir(stateDir); cfg = { agents: { @@ -197,7 +210,7 @@ describe("QmdMemoryManager slugified path resolution", () => { ); openManagers.clear(); await fs.rm(tmpRoot, { recursive: true, force: true }); - delete process.env.OPENCLAW_STATE_DIR; + restoreQmdStateDir(); }); it("maps slugified workspace qmd URIs back to the indexed filesystem path", async () => { diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index 20da24357109..9f271afed3c1 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -199,11 +199,17 @@ vi.mock("openclaw/plugin-sdk/file-lock", async () => { import { spawn as mockedSpawn } from "node:child_process"; import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-engine-foundation"; import { + type MemorySearchRuntimeDebug, requireNodeSqlite, resolveMemoryBackendConfig, } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; import { formatSessionTranscriptMemoryHitKey } from "openclaw/plugin-sdk/session-transcript-hit"; +import { + configureMemoryCoreDreamingState, + configureMemoryCoreDreamingStateForTests, + resetMemoryCoreDreamingStateForTests, +} from "../dreaming-state.js"; import { resolveQmdSessionArtifactIdentity } from "../qmd-session-artifacts.js"; import { QmdMemoryManager, resolveQmdMcporterSearchProcessTimeoutMs } from "./qmd-manager.js"; @@ -211,6 +217,19 @@ const spawnMock = mockedSpawn as unknown as Mock; const originalPath = process.env.PATH; const originalPathExt = process.env.PATHEXT; const originalWindowsPath = process.env.Path; +const originalQmdStateDir = process.env.OPENCLAW_STATE_DIR; + +function setQmdStateDir(stateDir: string): void { + Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir); +} + +function restoreQmdStateDir(): void { + if (originalQmdStateDir === undefined) { + Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR"); + } else { + Reflect.set(process.env, "OPENCLAW_STATE_DIR", originalQmdStateDir); + } +} describe("QmdMemoryManager", () => { let fixtureRoot: string; @@ -257,6 +276,14 @@ describe("QmdMemoryManager", () => { return mock.mock.calls.map((call: unknown[]) => String(call[0])); } + function qmdCommandCalls(): string[][] { + return spawnMock.mock.calls.map((call: unknown[]) => call[1] as string[]); + } + + function countQmdCommand(predicate: (args: string[]) => boolean): number { + return qmdCommandCalls().filter(predicate).length; + } + function expectMockMessageContains(mock: Mock, text: string): void { expect(mockMessages(mock).join("\n")).toContain(text); } @@ -277,6 +304,387 @@ describe("QmdMemoryManager", () => { ); }); + it("reuses persisted collection validation across transient cli managers", async () => { + await configureMemoryCoreDreamingStateForTests(); + const first = await createManager({ mode: "cli" }); + await first.manager.close(); + expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1); + + spawnMock.mockClear(); + const second = await createManager({ mode: "cli" }); + await second.manager.close(); + + expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(0); + expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "show")).toBe(0); + expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "add")).toBe(0); + }); + + it("does not cache incomplete collection validation", async () => { + await configureMemoryCoreDreamingStateForTests(); + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "collection" && args[1] === "add") { + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stderr", "permission denied", 1); + return child; + } + return createMockChild(); + }); + + const first = await createManager({ mode: "cli" }); + await first.manager.close(); + + spawnMock.mockClear(); + spawnMock.mockImplementation(() => createMockChild()); + const second = await createManager({ mode: "cli" }); + await second.manager.close(); + + expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1); + expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "add")).toBe(1); + }); + + it("runs collection validation when the runtime cache store is unavailable", async () => { + configureMemoryCoreDreamingState(() => { + throw new Error("state store unavailable"); + }); + try { + const manager = await createManager({ mode: "cli" }); + await manager.manager.close(); + } finally { + await configureMemoryCoreDreamingStateForTests(); + } + + expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1); + expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "add")).toBe(1); + }); + + it("reports collection validation debug only once per validation run", async () => { + await configureMemoryCoreDreamingStateForTests(); + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "query" || args[0] === "search" || args[0] === "vsearch") { + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stdout", "[]"); + return child; + } + return createMockChild(); + }); + const { manager } = await createManager({ mode: "cli" }); + const firstDebug: MemorySearchRuntimeDebug[] = []; + const secondDebug: MemorySearchRuntimeDebug[] = []; + + await manager.search("fact", { + sessionKey: "agent:main:slack:dm:u123", + onDebug: (entry) => { + firstDebug.push(entry); + }, + }); + await manager.search("fact again", { + sessionKey: "agent:main:slack:dm:u123", + onDebug: (entry) => { + secondDebug.push(entry); + }, + }); + + expect(firstDebug.at(-1)?.qmd?.collectionValidation?.cacheState).toBe("write"); + expect(secondDebug.at(-1)?.qmd?.collectionValidation).toBeUndefined(); + }); + + it("misses collection validation cache when managed collection config changes", async () => { + await configureMemoryCoreDreamingStateForTests(); + const first = await createManager({ mode: "cli" }); + await first.manager.close(); + + const otherWorkspaceDir = path.join(tmpRoot, "other-workspace"); + await fs.mkdir(otherWorkspaceDir, { recursive: true }); + const changedCfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + ...cfg.memory?.qmd, + paths: [{ path: otherWorkspaceDir, pattern: "**/*.md", name: "workspace" }], + }, + }, + } as OpenClawConfig; + + spawnMock.mockClear(); + const second = await createManager({ mode: "cli", cfg: changedCfg }); + await second.manager.close(); + + expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1); + }); + + it("bypasses validation cache for missing-collection search repair", async () => { + await configureMemoryCoreDreamingStateForTests(); + const { manager } = await createManager(); + spawnMock.mockClear(); + let searchAttempts = 0; + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "query" || args[0] === "search" || args[0] === "vsearch") { + const child = createMockChild({ autoClose: false }); + searchAttempts += 1; + if (searchAttempts === 1) { + emitAndClose(child, "stderr", "collection workspace-main not found", 1); + } else { + emitAndClose(child, "stdout", "[]"); + } + return child; + } + return createMockChild(); + }); + const debug: MemorySearchRuntimeDebug[] = []; + + await manager.search("fact", { + sessionKey: "agent:main:slack:dm:u123", + onDebug: (entry) => { + debug.push(entry); + }, + }); + + expect(searchAttempts).toBe(2); + expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1); + expect(debug.at(-1)?.qmd?.collectionValidation?.cacheState).toBe("bypass-force"); + }); + + it("reuses persisted qmd multi-collection support probe across managers", async () => { + await configureMemoryCoreDreamingStateForTests(); + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + sessions: { enabled: true }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + }, + }, + } as OpenClawConfig; + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "--help") { + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stdout", "Usage: qmd search -c one or more collections"); + return child; + } + if (args[0] === "search") { + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stdout", "[]"); + return child; + } + return createMockChild(); + }); + + const first = await createManager({ mode: "cli" }); + await first.manager.search("fact", { + sessionKey: "agent:main:slack:dm:u123", + }); + await first.manager.close(); + expect(countQmdCommand((args) => args[0] === "--help")).toBe(1); + + spawnMock.mockClear(); + const second = await createManager({ mode: "cli" }); + const debug: MemorySearchRuntimeDebug[] = []; + await second.manager.search("fact", { + sessionKey: "agent:main:slack:dm:u123", + onDebug: (entry) => { + debug.push(entry); + }, + }); + await second.manager.close(); + + expect(countQmdCommand((args) => args[0] === "--help")).toBe(0); + expect(debug.at(-1)?.qmd?.multiCollectionProbe?.cacheState).toBe("hit"); + expect(debug.at(-1)?.qmd?.searchPlan?.groupCount).toBe(2); + }); + + it("reports multi-collection probe debug only when the probe runs", async () => { + await configureMemoryCoreDreamingStateForTests(); + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + sessions: { enabled: true }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + }, + }, + } as OpenClawConfig; + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "--help") { + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stdout", "Usage: qmd search -c one or more collections"); + return child; + } + if (args[0] === "search") { + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stdout", "[]"); + return child; + } + return createMockChild(); + }); + const { manager } = await createManager({ mode: "cli" }); + const firstDebug: MemorySearchRuntimeDebug[] = []; + const secondDebug: MemorySearchRuntimeDebug[] = []; + + await manager.search("fact", { + sessionKey: "agent:main:slack:dm:u123", + onDebug: (entry) => { + firstDebug.push(entry); + }, + }); + await manager.search("fact again", { + sessionKey: "agent:main:slack:dm:u123", + onDebug: (entry) => { + secondDebug.push(entry); + }, + }); + + expect(firstDebug.at(-1)?.qmd?.multiCollectionProbe?.cacheState).toBe("write"); + expect(secondDebug.at(-1)?.qmd?.multiCollectionProbe).toBeUndefined(); + }); + + it("keeps concurrent search debug isolated on a shared qmd manager", async () => { + await configureMemoryCoreDreamingStateForTests(); + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + sessions: { enabled: true }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + }, + }, + } as OpenClawConfig; + let firstSearchChild: MockChild | undefined; + let searchCalls = 0; + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "search") { + searchCalls += 1; + const child = createMockChild({ autoClose: false }); + if (searchCalls === 1) { + firstSearchChild = child; + return child; + } + emitAndClose(child, "stdout", "[]"); + return child; + } + if (args[0] === "--version") { + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stdout", "qmd 1.0.0"); + return child; + } + return createMockChild(); + }); + const { manager } = await createManager({ mode: "full" }); + const firstDebug: MemorySearchRuntimeDebug[] = []; + const secondDebug: MemorySearchRuntimeDebug[] = []; + + const firstSearch = manager.search("memory fact", { + sessionKey: "agent:main:slack:dm:u123", + sources: ["memory"], + onDebug: (entry) => { + firstDebug.push(entry); + }, + }); + await waitUntil(() => searchCalls === 1); + const secondSearch = manager.search("session fact", { + sessionKey: "agent:main:slack:dm:u123", + sources: ["sessions"], + onDebug: (entry) => { + secondDebug.push(entry); + }, + }); + await waitUntil(() => searchCalls === 2); + emitAndClose(requireValue(firstSearchChild, "first search child missing"), "stdout", "[]"); + + await Promise.all([firstSearch, secondSearch]); + + expect(firstDebug.at(-1)?.qmd?.searchPlan?.sources).toEqual(["memory"]); + expect(secondDebug.at(-1)?.qmd?.searchPlan?.sources).toEqual(["sessions"]); + }); + + it("rewrites stale multi-collection probe cache when combined filters are rejected", async () => { + await configureMemoryCoreDreamingStateForTests(); + const otherWorkspaceDir = path.join(tmpRoot, "other-workspace"); + await fs.mkdir(otherWorkspaceDir, { recursive: true }); + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [ + { path: workspaceDir, pattern: "**/*.md", name: "workspace" }, + { path: otherWorkspaceDir, pattern: "**/*.md", name: "other" }, + ], + }, + }, + } as OpenClawConfig; + const isCombinedSearch = (args: string[]) => + (args[0] === "search" || args[0] === "query") && + args.filter((token) => token === "-c").length > 1; + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "--version") { + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stdout", "qmd 1.0.0"); + return child; + } + if (args[0] === "--help") { + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stdout", "Usage: qmd search -c one or more collections"); + return child; + } + if (isCombinedSearch(args)) { + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stderr", "unknown flag: -c", 1); + return child; + } + if (args[0] === "search" || args[0] === "query" || args[0] === "vsearch") { + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stdout", "[]"); + return child; + } + return createMockChild(); + }); + + const first = await createManager({ mode: "cli" }); + const firstDebug: MemorySearchRuntimeDebug[] = []; + await first.manager.search("fact", { + sessionKey: "agent:main:slack:dm:u123", + onDebug: (entry) => { + firstDebug.push(entry); + }, + }); + await first.manager.close(); + + expect(firstDebug.at(-1)?.qmd?.multiCollectionProbe).toMatchObject({ + cacheState: "write", + supported: false, + }); + + spawnMock.mockClear(); + const second = await createManager({ mode: "cli" }); + const secondDebug: MemorySearchRuntimeDebug[] = []; + await second.manager.search("fact", { + sessionKey: "agent:main:slack:dm:u123", + onDebug: (entry) => { + secondDebug.push(entry); + }, + }); + await second.manager.close(); + + expect(countQmdCommand((args) => args[0] === "--help")).toBe(0); + expect(countQmdCommand(isCombinedSearch)).toBe(0); + expect(secondDebug.at(-1)?.qmd?.multiCollectionProbe).toMatchObject({ + cacheState: "hit", + supported: false, + }); + }); + async function expectPathMissing(targetPath: string): Promise { try { await fs.lstat(targetPath); @@ -340,7 +748,7 @@ describe("QmdMemoryManager", () => { // Only workspace must exist for configured collection paths; state paths are // created lazily by manager code when needed. await fs.mkdir(workspaceDir, { recursive: true }); - process.env.OPENCLAW_STATE_DIR = stateDir; + setQmdStateDir(stateDir); // Keep the default Windows path unresolved for most tests so spawn mocks can // match the logical package command. Tests that verify wrapper resolution // install explicit shim fixtures inline. @@ -387,7 +795,7 @@ describe("QmdMemoryManager", () => { embedStartupJitterSpy?.mockRestore(); embedStartupJitterSpy = null; vi.useRealTimers(); - delete process.env.OPENCLAW_STATE_DIR; + restoreQmdStateDir(); if (originalPath === undefined) { delete process.env.PATH; } else { @@ -406,6 +814,7 @@ describe("QmdMemoryManager", () => { delete (globalThis as Record)[MCPORTER_STATE_KEY]; delete (globalThis as Record)[QMD_EMBED_QUEUE_KEY]; delete (globalThis as Record)[MEMORY_EMBEDDING_PROVIDERS_KEY]; + resetMemoryCoreDreamingStateForTests(); }); it("debounces back-to-back sync calls", async () => { @@ -6450,7 +6859,7 @@ describe("QmdMemoryManager", () => { // directory instead of the real ~/.cache. savedXdgCacheHome = process.env.XDG_CACHE_HOME; const fakeCacheHome = path.join(tmpRoot, "fake-cache"); - process.env.XDG_CACHE_HOME = fakeCacheHome; + Reflect.set(process.env, "XDG_CACHE_HOME", fakeCacheHome); defaultModelsDir = path.join(fakeCacheHome, "qmd", "models"); await fs.mkdir(defaultModelsDir, { recursive: true }); @@ -6461,9 +6870,9 @@ describe("QmdMemoryManager", () => { afterEach(() => { if (savedXdgCacheHome === undefined) { - delete process.env.XDG_CACHE_HOME; + Reflect.deleteProperty(process.env, "XDG_CACHE_HOME"); } else { - process.env.XDG_CACHE_HOME = savedXdgCacheHome; + Reflect.set(process.env, "XDG_CACHE_HOME", savedXdgCacheHome); } }); diff --git a/extensions/memory-core/src/memory/qmd-manager.ts b/extensions/memory-core/src/memory/qmd-manager.ts index 7baac6860a46..dbc9eaefa5d1 100644 --- a/extensions/memory-core/src/memory/qmd-manager.ts +++ b/extensions/memory-core/src/memory/qmd-manager.ts @@ -74,6 +74,16 @@ import { type QmdSessionArtifactMapping, } from "../qmd-session-artifacts.js"; import { resolveQmdCollectionPatternFlags, type QmdCollectionPatternFlag } from "./qmd-compat.js"; +import { + clearQmdMultiCollectionProbeCache, + readQmdCollectionValidationCache, + readQmdMultiCollectionProbeCache, + writeQmdCollectionValidationCache, + writeQmdMultiCollectionProbeCache, + type QmdRuntimeCollectionValidationCacheContext, + type QmdRuntimeManagedCollection, + type QmdRuntimeMultiCollectionProbeCacheContext, +} from "./qmd-runtime-cache.js"; import { countChokidarWatchedEntries, type MemoryWatchPressureWarningState, @@ -324,6 +334,19 @@ type ManagedCollection = { kind: "memory" | "custom" | "sessions"; }; +type QmdCollectionValidationDebug = NonNullable< + NonNullable["collectionValidation"] +>; +type QmdMultiCollectionProbeDebug = NonNullable< + NonNullable["multiCollectionProbe"] +>; +type QmdSearchPlanDebug = NonNullable["searchPlan"]>; +type QmdSearchRuntimeDebugContext = { + collectionValidation?: QmdCollectionValidationDebug; + multiCollectionProbe?: QmdMultiCollectionProbeDebug; + searchPlan?: QmdSearchPlanDebug; +}; + type QmdManagerMode = "full" | "status" | "cli"; type QmdManagerRuntimeConfig = { workspaceDir: string; @@ -441,6 +464,7 @@ export class QmdMemoryManager implements MemorySearchManager { private mode: QmdManagerMode = "full"; private readonly closeSignal: Promise; private resolveCloseSignal!: () => void; + private qmdRuntimeIdentityPromise: Promise | null = null; private db: SqliteDatabase | null = null; private lastUpdateAt: number | null = null; private lastEmbedAt: number | null = null; @@ -453,6 +477,7 @@ export class QmdMemoryManager implements MemorySearchManager { private readonly sessionWarm = new Set(); private collectionPatternFlag: QmdCollectionPatternFlag | null = "--mask"; private multiCollectionFilterSupported: boolean | null = null; + private pendingCollectionValidationDebug: QmdCollectionValidationDebug | undefined; private constructor(params: { agentId: string; @@ -612,11 +637,171 @@ export class QmdMemoryManager implements MemorySearchManager { } } - private async ensureCollections(): Promise { + private qmdRuntimeCacheSources(): string[] { + return [...this.sources].toSorted(); + } + + private qmdRuntimeCacheCollections(): QmdRuntimeManagedCollection[] { + return this.qmd.collections.map((collection) => ({ + name: collection.name, + kind: collection.kind, + path: collection.path, + pattern: collection.pattern, + })); + } + + private buildQmdRuntimeEnvironmentHash(): string { + const relevantEnv = Object.fromEntries( + Object.keys(this.env) + .filter( + (key) => + key === "PATH" || + key === "HOME" || + key === "LOCALAPPDATA" || + key === "XDG_CONFIG_HOME" || + key === "XDG_CACHE_HOME" || + key === "QMD_CONFIG_DIR" || + key.startsWith("QMD_"), + ) + .toSorted() + .map((key) => [key, this.env[key] ?? ""]), + ); + return crypto.createHash("sha256").update(JSON.stringify(relevantEnv)).digest("hex"); + } + + private async buildQmdCollectionValidationCacheContext(): Promise { + return { + workspaceDir: this.workspaceDir, + agentId: this.agentId, + qmdCommand: this.qmd.command, + qmdVersion: await this.resolveQmdRuntimeIdentity(), + qmdEnvironmentHash: this.buildQmdRuntimeEnvironmentHash(), + qmdIndexPath: this.indexPath, + searchMode: this.qmd.searchMode, + collections: this.qmdRuntimeCacheCollections(), + sources: this.qmdRuntimeCacheSources(), + }; + } + + private async buildQmdMultiCollectionProbeCacheContext(): Promise { + return { + workspaceDir: this.workspaceDir, + agentId: this.agentId, + qmdCommand: this.qmd.command, + qmdVersion: await this.resolveQmdRuntimeIdentity(), + qmdEnvironmentHash: this.buildQmdRuntimeEnvironmentHash(), + qmdIndexPath: this.indexPath, + searchMode: this.qmd.searchMode, + sources: this.qmdRuntimeCacheSources(), + }; + } + + private resolveQmdRuntimeIdentity(): Promise { + this.qmdRuntimeIdentityPromise ??= this.readQmdRuntimeIdentity(); + return this.qmdRuntimeIdentityPromise; + } + + private async readQmdRuntimeIdentity(): Promise { + const commandIdentity = `command:${this.qmd.command}`; + try { + const result = await this.runQmd(["--version"], { + timeoutMs: Math.min(this.qmd.limits.timeoutMs, 2_000), + }); + const versionText = `${result.stdout}\n${result.stderr}`.trim(); + return versionText ? `${commandIdentity};version:${versionText}` : commandIdentity; + } catch { + return commandIdentity; + } + } + + private recordSearchPlanDebug(params: { + debugContext: QmdSearchRuntimeDebugContext; + command: "query" | "search" | "vsearch"; + collectionNames: string[]; + collectionGroups: string[][]; + }): void { + const sources = uniqueValues( + params.collectionNames + .map((collectionName) => this.collectionRoots.get(collectionName)?.kind) + .filter((source): source is MemorySource => Boolean(source)), + ); + params.debugContext.searchPlan = { + command: params.command, + collectionCount: params.collectionNames.length, + groupCount: params.collectionGroups.length, + sources, + }; + } + + private beginQmdSearchRuntimeDebug(): QmdSearchRuntimeDebugContext { + const debugContext: QmdSearchRuntimeDebugContext = {}; + if (this.pendingCollectionValidationDebug) { + debugContext.collectionValidation = this.pendingCollectionValidationDebug; + this.pendingCollectionValidationDebug = undefined; + } + return debugContext; + } + + private consumeQmdRuntimeDebug( + debugContext: QmdSearchRuntimeDebugContext, + ): MemorySearchRuntimeDebug["qmd"] | undefined { + const debug: NonNullable = {}; + if (debugContext.collectionValidation) { + debug.collectionValidation = debugContext.collectionValidation; + } + if (debugContext.multiCollectionProbe) { + debug.multiCollectionProbe = debugContext.multiCollectionProbe; + } + if (debugContext.searchPlan) { + debug.searchPlan = debugContext.searchPlan; + } + return Object.keys(debug).length > 0 ? debug : undefined; + } + + private async ensureCollectionPathsBestEffort(): Promise { + for (const collection of this.qmd.collections) { + try { + await this.ensureCollectionPath(collection); + } catch (err) { + log.warn( + `qmd collection path prepare failed for ${collection.name}: ${formatErrorMessage(err)}`, + ); + } + } + } + + private async ensureCollections(options?: { + force?: boolean; + debugContext?: QmdSearchRuntimeDebugContext; + }): Promise { + const startedAt = Date.now(); + const cacheContext = await this.buildQmdCollectionValidationCacheContext(); + if (!options?.force) { + const cached = await readQmdCollectionValidationCache(cacheContext); + if (cached.state === "hit") { + await this.ensureCollectionPathsBestEffort(); + const debug: QmdCollectionValidationDebug = { + cacheState: "hit", + elapsedMs: Math.max(0, Date.now() - startedAt), + collectionCount: cached.value.validation.collectionCount, + listCalls: 0, + showCalls: 0, + }; + if (options?.debugContext) { + options.debugContext.collectionValidation = debug; + } else { + this.pendingCollectionValidationDebug = debug; + } + return; + } + } + + const stats = { listCalls: 0, showCalls: 0 }; + let validationComplete = true; // QMD collections are persisted inside the index database and must be created // via the CLI. Prefer listing existing collections when supported, otherwise // fall back to best-effort idempotent `qmd collection add`. - const existing = await this.listCollectionsBestEffort(); + const existing = await this.listCollectionsBestEffort(stats); await this.migrateLegacyUnscopedCollections(existing); @@ -631,6 +816,7 @@ export class QmdMemoryManager implements MemorySearchManager { } catch (err) { const message = formatErrorMessage(err); if (!this.isCollectionMissingError(message)) { + validationComplete = false; log.warn(`qmd collection remove failed for ${collection.name}: ${message}`); } } @@ -661,13 +847,36 @@ export class QmdMemoryManager implements MemorySearchManager { pattern: collection.pattern, }); } else { + validationComplete = false; log.warn(`qmd collection add skipped for ${collection.name}: ${message}`); } continue; } + validationComplete = false; log.warn(`qmd collection add failed for ${collection.name}: ${message}`); } } + const wroteCache = validationComplete + ? await writeQmdCollectionValidationCache(cacheContext) + : false; + const debug: QmdCollectionValidationDebug = { + cacheState: validationComplete + ? options?.force + ? "bypass-force" + : wroteCache + ? "write" + : "error" + : "error", + elapsedMs: Math.max(0, Date.now() - startedAt), + collectionCount: this.qmd.collections.length, + listCalls: stats.listCalls, + showCalls: stats.showCalls, + }; + if (options?.debugContext) { + options.debugContext.collectionValidation = debug; + } else { + this.pendingCollectionValidationDebug = debug; + } } private async tryRebindSameNameCollection(params: { @@ -713,9 +922,15 @@ export class QmdMemoryManager implements MemorySearchManager { ); } - private async listCollectionsBestEffort(): Promise> { + private async listCollectionsBestEffort(stats?: { + listCalls: number; + showCalls: number; + }): Promise> { const existing = new Map(); try { + if (stats) { + stats.listCalls += 1; + } const result = await this.runQmd(["collection", "list", "--json"], { timeoutMs: this.qmd.update.commandTimeoutMs, }); @@ -737,6 +952,9 @@ export class QmdMemoryManager implements MemorySearchManager { continue; } try { + if (stats) { + stats.showCalls += 1; + } const showResult = await this.runQmd(["collection", "show", collection.name], { timeoutMs: this.qmd.update.commandTimeoutMs, }); @@ -956,14 +1174,17 @@ export class QmdMemoryManager implements MemorySearchManager { ); } - private async tryRepairMissingCollectionSearch(err: unknown): Promise { + private async tryRepairMissingCollectionSearch( + err: unknown, + debugContext: QmdSearchRuntimeDebugContext, + ): Promise { if (!this.isMissingCollectionSearchError(err)) { return false; } log.warn( "qmd search failed because a managed collection is missing; repairing collections and retrying once", ); - await this.ensureCollections(); + await this.ensureCollections({ force: true, debugContext }); return true; } @@ -1318,6 +1539,7 @@ export class QmdMemoryManager implements MemorySearchManager { if (searchSignal?.aborted) { throw asAbortError(searchSignal); } + const debugContext = this.beginQmdSearchRuntimeDebug(); const trimmed = query.trim(); if (!trimmed) { return []; @@ -1344,6 +1566,7 @@ export class QmdMemoryManager implements MemorySearchManager { const runSearchAttempt = async ( allowMissingCollectionRepair: boolean, ): Promise => { + let attemptedCombinedCollectionFilter = false; try { if (mcporterEnabled) { const minScore = opts?.minScore ?? 0; @@ -1402,7 +1625,15 @@ export class QmdMemoryManager implements MemorySearchManager { const collectionGroups = await this.resolveCollectionSearchGroups( collectionNames, searchSignal, + debugContext, ); + this.recordSearchPlanDebug({ + debugContext, + command: qmdSearchCommand, + collectionNames, + collectionGroups, + }); + attemptedCombinedCollectionFilter = collectionGroups.some((group) => group.length > 1); if (collectionGroups.length > 1) { return await this.runQueryAcrossCollectionGroups( trimmed, @@ -1424,6 +1655,9 @@ export class QmdMemoryManager implements MemorySearchManager { qmdSearchCommand !== "query" && this.isUnsupportedQmdOptionError(err) ) { + if (attemptedCombinedCollectionFilter) { + await this.markQmdMultiCollectionFiltersUnsupported(debugContext); + } effectiveSearchMode = "query"; searchFallbackReason = "unsupported-search-flags"; log.warn( @@ -1433,7 +1667,14 @@ export class QmdMemoryManager implements MemorySearchManager { const collectionGroups = await this.resolveCollectionSearchGroups( collectionNames, searchSignal, + debugContext, ); + this.recordSearchPlanDebug({ + debugContext, + command: "query", + collectionNames, + collectionGroups, + }); if (collectionGroups.length > 1) { return await this.runQueryAcrossCollectionGroups( trimmed, @@ -1463,7 +1704,7 @@ export class QmdMemoryManager implements MemorySearchManager { try { parsed = await runSearchAttempt(true); } catch (err) { - if (!(await this.tryRepairMissingCollectionSearch(err))) { + if (!(await this.tryRepairMissingCollectionSearch(err, debugContext))) { throw err instanceof Error ? err : new Error(String(err)); } parsed = await runSearchAttempt(false); @@ -1512,6 +1753,7 @@ export class QmdMemoryManager implements MemorySearchManager { configuredMode: qmdSearchCommand, effectiveMode: effectiveSearchMode, fallback: searchFallbackReason, + qmd: this.consumeQmdRuntimeDebug(debugContext), }); let ranked = results; if (opts?.sources?.length) { @@ -3370,23 +3612,41 @@ export class QmdMemoryManager implements MemorySearchManager { private async resolveCollectionSearchGroups( collectionNames: string[], signal?: AbortSignal, + debugContext?: QmdSearchRuntimeDebugContext, ): Promise { if (collectionNames.length <= 1) { return [collectionNames]; } - if (!(await this.supportsQmdMultiCollectionFilters(signal))) { + if (!(await this.supportsQmdMultiCollectionFilters(signal, debugContext))) { return collectionNames.map((collectionName) => [collectionName]); } return this.groupCollectionNamesBySource(collectionNames); } - private async supportsQmdMultiCollectionFilters(signal?: AbortSignal): Promise { + private async supportsQmdMultiCollectionFilters( + signal?: AbortSignal, + debugContext?: QmdSearchRuntimeDebugContext, + ): Promise { if (signal?.aborted) { throw asAbortError(signal); } if (this.multiCollectionFilterSupported !== null) { return this.multiCollectionFilterSupported; } + const startedAt = Date.now(); + const cacheContext = await this.buildQmdMultiCollectionProbeCacheContext(); + const cached = await readQmdMultiCollectionProbeCache(cacheContext); + if (cached.state === "hit") { + this.multiCollectionFilterSupported = cached.value.multiCollectionProbe.supported; + if (debugContext) { + debugContext.multiCollectionProbe = { + cacheState: "hit", + elapsedMs: Math.max(0, Date.now() - startedAt), + supported: this.multiCollectionFilterSupported, + }; + } + return this.multiCollectionFilterSupported; + } try { const result = await this.runQmd(["--help"], { timeoutMs: Math.min(this.qmd.limits.timeoutMs, 5_000), @@ -3395,17 +3655,50 @@ export class QmdMemoryManager implements MemorySearchManager { const helpText = `${result.stdout}\n${result.stderr}`; this.multiCollectionFilterSupported = /\b(?:one or more collections|collection\(s\)|multiple -c flags)\b/i.test(helpText); + const wroteCache = await writeQmdMultiCollectionProbeCache( + cacheContext, + this.multiCollectionFilterSupported, + ); + if (debugContext) { + debugContext.multiCollectionProbe = { + cacheState: wroteCache ? "write" : "error", + elapsedMs: Math.max(0, Date.now() - startedAt), + supported: this.multiCollectionFilterSupported, + }; + } } catch (err) { // Cancellation says nothing about QMD capabilities; leave the probe uncached. if (signal?.aborted) { throw asAbortError(signal); } this.multiCollectionFilterSupported = false; + if (debugContext) { + debugContext.multiCollectionProbe = { + cacheState: "error", + elapsedMs: Math.max(0, Date.now() - startedAt), + supported: false, + }; + } log.debug(`qmd multi-collection filter probe failed: ${String(err)}`); } return this.multiCollectionFilterSupported; } + private async markQmdMultiCollectionFiltersUnsupported( + debugContext: QmdSearchRuntimeDebugContext, + ): Promise { + const startedAt = Date.now(); + const cacheContext = await this.buildQmdMultiCollectionProbeCacheContext(); + this.multiCollectionFilterSupported = false; + await clearQmdMultiCollectionProbeCache(cacheContext); + const wroteCache = await writeQmdMultiCollectionProbeCache(cacheContext, false); + debugContext.multiCollectionProbe = { + cacheState: wroteCache ? "write" : "error", + elapsedMs: Math.max(0, Date.now() - startedAt), + supported: false, + }; + } + private async runQueryAcrossCollectionGroups( query: string, limit: number, diff --git a/extensions/memory-core/src/memory/qmd-runtime-cache.test.ts b/extensions/memory-core/src/memory/qmd-runtime-cache.test.ts new file mode 100644 index 000000000000..04c6d2d5b5bf --- /dev/null +++ b/extensions/memory-core/src/memory/qmd-runtime-cache.test.ts @@ -0,0 +1,323 @@ +import path from "node:path"; +import { withTempDir } from "openclaw/plugin-sdk/test-env"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + configureMemoryCoreDreamingState, + configureMemoryCoreDreamingStateForTests, + openMemoryCoreStateStore, + memoryCoreWorkspaceEntryKey, + resetMemoryCoreDreamingStateForTests, +} from "../dreaming-state.js"; +import { + QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE, + QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS, + QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE, + QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS, + buildQmdMultiCollectionProbeCacheContextHash, + clearQmdCollectionValidationCache, + clearQmdMultiCollectionProbeCache, + readQmdCollectionValidationCache, + readQmdMultiCollectionProbeCache, + type QmdRuntimeCollectionValidationCacheContext, + type QmdRuntimeManagedCollection, + type QmdRuntimeMultiCollectionProbeCacheContext, + writeQmdCollectionValidationCache, + writeQmdMultiCollectionProbeCache, +} from "./qmd-runtime-cache.js"; + +beforeAll(async () => { + await configureMemoryCoreDreamingStateForTests(); +}); + +afterAll(async () => { + resetMemoryCoreDreamingStateForTests(); +}); + +async function clearStore(namespace: string): Promise { + try { + await openMemoryCoreStateStore({ + namespace, + maxEntries: 1_000, + }).clear(); + } catch { + // fail open + } +} + +afterEach(async () => { + await clearStore(QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE); + await clearStore(QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE); +}); + +async function withWorkspace(run: (workspaceDir: string) => Promise): Promise { + return await withTempDir("qmd-runtime-cache-", run); +} + +function managedCollections(): QmdRuntimeManagedCollection[] { + return [ + { + name: "project-notes", + kind: "memory", + path: "/repo/project-notes", + pattern: "*.md", + }, + { + name: "sessions", + kind: "sessions", + path: "/repo/sessions", + pattern: "*", + }, + ]; +} + +function collectionValidationContext( + workspaceDir: string, +): QmdRuntimeCollectionValidationCacheContext { + return { + workspaceDir, + agentId: "agent-a", + qmdCommand: "qmd", + qmdIndexPath: path.join(workspaceDir, ".openclaw", "index.sqlite"), + searchMode: "search", + collections: managedCollections(), + sources: ["memory", "sessions"], + }; +} + +function multiCollectionProbeContext( + workspaceDir: string, +): QmdRuntimeMultiCollectionProbeCacheContext { + return { + workspaceDir, + agentId: "agent-a", + qmdCommand: "qmd", + qmdIndexPath: path.join(workspaceDir, ".openclaw", "index.sqlite"), + searchMode: "search", + sources: ["memory", "sessions"], + }; +} + +describe("qmd-runtime-cache", () => { + it("writes and reads collection validation cache entries", async () => { + await withWorkspace(async (workspaceDir) => { + const context = collectionValidationContext(workspaceDir); + const writeStartedAtMs = 1_000; + + const writeOk = await writeQmdCollectionValidationCache(context, writeStartedAtMs); + expect(writeOk).toBe(true); + + const read = await readQmdCollectionValidationCache( + { ...context, sources: ["sessions", "memory"] }, + writeStartedAtMs + 1, + ); + expect(read).toMatchObject({ + state: "hit", + value: { + validation: { + ok: true, + collectionCount: context.collections.length, + }, + }, + }); + }); + }); + + it("writes and reads multi-collection probe cache entries", async () => { + await withWorkspace(async (workspaceDir) => { + const context = multiCollectionProbeContext(workspaceDir); + const writeStartedAtMs = 2_000; + + const writeOk = await writeQmdMultiCollectionProbeCache(context, true, writeStartedAtMs); + expect(writeOk).toBe(true); + + const read = await readQmdMultiCollectionProbeCache(context, writeStartedAtMs + 1); + expect(read).toMatchObject({ + state: "hit", + value: { + multiCollectionProbe: { + supported: true, + }, + }, + }); + }); + }); + + it("scopes cache entries by workspace", async () => { + await withWorkspace(async (firstWorkspace) => { + await withWorkspace(async (secondWorkspace) => { + const context = collectionValidationContext(firstWorkspace); + + expect(await writeQmdCollectionValidationCache(context, 3_000)).toBe(true); + + const sameLogicalDifferentWorkspace: QmdRuntimeCollectionValidationCacheContext = { + ...context, + workspaceDir: secondWorkspace, + qmdIndexPath: path.join(secondWorkspace, ".openclaw", "index.sqlite"), + }; + + const miss = await readQmdCollectionValidationCache(sameLogicalDifferentWorkspace, 3_001); + expect(miss).toStrictEqual({ state: "miss" }); + }); + }); + }); + + it("misses collection validation cache when managed collection paths change", async () => { + await withWorkspace(async (workspaceDir) => { + const context = collectionValidationContext(workspaceDir); + + expect(await writeQmdCollectionValidationCache(context, 3_500)).toBe(true); + + const changedContext: QmdRuntimeCollectionValidationCacheContext = { + ...context, + collections: context.collections.map((collection) => + collection.name === "project-notes" + ? { + name: collection.name, + kind: collection.kind, + path: `${collection.path}-moved`, + pattern: collection.pattern, + } + : collection, + ), + }; + + expect(await readQmdCollectionValidationCache(changedContext, 3_501)).toStrictEqual({ + state: "miss", + }); + }); + }); + + it("misses validation and probe caches when qmd runtime environment changes", async () => { + await withWorkspace(async (workspaceDir) => { + const validationContext = { + ...collectionValidationContext(workspaceDir), + qmdEnvironmentHash: "env-a", + }; + const probeContext = { + ...multiCollectionProbeContext(workspaceDir), + qmdEnvironmentHash: "env-a", + }; + + expect(await writeQmdCollectionValidationCache(validationContext, 3_600)).toBe(true); + expect(await writeQmdMultiCollectionProbeCache(probeContext, true, 3_600)).toBe(true); + + expect( + await readQmdCollectionValidationCache( + { ...validationContext, qmdEnvironmentHash: "env-b" }, + 3_601, + ), + ).toStrictEqual({ state: "miss" }); + expect( + await readQmdMultiCollectionProbeCache( + { ...probeContext, qmdEnvironmentHash: "env-b" }, + 3_601, + ), + ).toStrictEqual({ state: "miss" }); + }); + }); + + it("treats cache misses for malformed values and expired entries", async () => { + await withWorkspace(async (workspaceDir) => { + const context = multiCollectionProbeContext(workspaceDir); + const nowMs = 4_000; + await writeQmdMultiCollectionProbeCache(context, false, nowMs); + + const key = memoryCoreWorkspaceEntryKey( + workspaceDir, + `qmd-runtime-cache.multi-collection-probe:${buildQmdMultiCollectionProbeCacheContextHash(context)}`, + ); + const store = openMemoryCoreStateStore({ + namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE, + maxEntries: 1_000, + }); + + await store.register(key, { + version: 1, + createdAtMs: "bad", + expiresAtMs: 0, + keyHash: "bad", + multiCollectionProbe: { supported: true }, + }); + + const malformed = await readQmdMultiCollectionProbeCache(context, nowMs + 1); + expect(malformed).toStrictEqual({ state: "miss" }); + + const expired = await readQmdMultiCollectionProbeCache( + context, + nowMs + QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS + 1, + ); + expect(expired).toStrictEqual({ state: "miss" }); + }); + }); + + it("uses separate namespaces for validation and probe entries", async () => { + await withWorkspace(async (workspaceDir) => { + const validationContext = collectionValidationContext(workspaceDir); + const probeContext = multiCollectionProbeContext(workspaceDir); + + expect(await writeQmdCollectionValidationCache(validationContext, 5_000)).toBe(true); + expect(await writeQmdMultiCollectionProbeCache(probeContext, true, 5_000)).toBe(true); + + const validationStore = openMemoryCoreStateStore({ + namespace: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE, + maxEntries: 1_000, + }); + const probeStore = openMemoryCoreStateStore({ + namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE, + maxEntries: 1_000, + }); + + expect((await validationStore.entries()).length).toBeGreaterThan(0); + expect((await probeStore.entries()).length).toBeGreaterThan(0); + }); + }); + + it("fails open when state store is unavailable", async () => { + await withWorkspace(async (workspaceDir) => { + const validationContext = collectionValidationContext(workspaceDir); + const probeContext = multiCollectionProbeContext(workspaceDir); + + configureMemoryCoreDreamingState(() => { + throw new Error("state store unavailable"); + }); + + try { + expect(await readQmdCollectionValidationCache(validationContext)).toStrictEqual({ + state: "miss", + }); + expect(await writeQmdCollectionValidationCache(validationContext)).toBe(false); + expect(await readQmdMultiCollectionProbeCache(probeContext)).toStrictEqual({ + state: "miss", + }); + expect(await writeQmdMultiCollectionProbeCache(probeContext, true)).toBe(false); + } finally { + await configureMemoryCoreDreamingStateForTests(); + } + }); + }); + + it("exposes bounded TTL windows", () => { + expect(QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS).toBe(5 * 60_000); + expect(QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS).toBe(10 * 60_000); + }); + + it("can clear cache keys explicitly", async () => { + await withWorkspace(async (workspaceDir) => { + const validationContext = collectionValidationContext(workspaceDir); + const probeContext = multiCollectionProbeContext(workspaceDir); + + expect(await writeQmdCollectionValidationCache(validationContext)).toBe(true); + expect(await writeQmdMultiCollectionProbeCache(probeContext, true)).toBe(true); + + await clearQmdCollectionValidationCache(validationContext); + await clearQmdMultiCollectionProbeCache(probeContext); + + expect(await readQmdCollectionValidationCache(validationContext)).toStrictEqual({ + state: "miss", + }); + expect(await readQmdMultiCollectionProbeCache(probeContext)).toStrictEqual({ + state: "miss", + }); + }); + }); +}); diff --git a/extensions/memory-core/src/memory/qmd-runtime-cache.ts b/extensions/memory-core/src/memory/qmd-runtime-cache.ts new file mode 100644 index 000000000000..8e1be67ce33b --- /dev/null +++ b/extensions/memory-core/src/memory/qmd-runtime-cache.ts @@ -0,0 +1,435 @@ +// Memory Core QMD runtime cache helpers. +import { createHash } from "node:crypto"; +import type { PluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime"; +import { memoryCoreWorkspaceEntryKey, openMemoryCoreStateStore } from "../dreaming-state.js"; + +export const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE = + "qmd-runtime-cache.collection-validation"; +export const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE = + "qmd-runtime-cache.multi-collection-probe"; +export const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_MAX_ENTRIES = 1_000; +export const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_MAX_ENTRIES = 1_000; +export const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS = 5 * 60_000; +export const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS = 10 * 60_000; + +const QMD_RUNTIME_CACHE_ENTRY_VERSION = 1; + +export type QmdRuntimeManagedCollection = { + name: string; + kind: "memory" | "custom" | "sessions"; + path: string; + pattern: string; +}; + +type QmdRuntimeCacheContextBase = { + workspaceDir: string; + agentId: string; + qmdCommand: string; + qmdVersion?: string; + qmdEnvironmentHash?: string; + qmdIndexPath: string; + searchMode: string; +}; + +export type QmdRuntimeCollectionValidationCacheContext = QmdRuntimeCacheContextBase & { + collections: readonly QmdRuntimeManagedCollection[]; + sources: readonly string[]; +}; + +export type QmdRuntimeMultiCollectionProbeCacheContext = QmdRuntimeCacheContextBase & { + sources: readonly string[]; +}; + +export type QmdRuntimeCacheCollectionValidationEntry = { + version: 1; + createdAtMs: number; + expiresAtMs: number; + keyHash: string; + validation: { + ok: true; + collectionConfigHash: string; + collectionCount: number; + }; +}; + +export type QmdRuntimeCacheMultiCollectionProbeEntry = { + version: 1; + createdAtMs: number; + expiresAtMs: number; + keyHash: string; + multiCollectionProbe: { + supported: boolean; + }; +}; + +export type QmdRuntimeCacheResult = + | { + state: "hit"; + value: T; + } + | { state: "miss" }; + +function normalizeText(value: string): string { + return value.trim(); +} + +function normalizeCollection(collection: QmdRuntimeManagedCollection) { + return { + name: normalizeText(collection.name), + kind: collection.kind, + pathHash: normalizePathIdentity(collection.path), + pattern: normalizeText(collection.pattern), + }; +} + +function hashText(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function normalizePathIdentity(value: string): string { + const normalized = + process.platform === "win32" ? normalizeText(value).toLowerCase() : normalizeText(value); + return hashText(normalized); +} + +function sortedUnique(values: readonly string[]): string[] { + return [...new Set(values.map((value) => normalizeText(value)).filter(Boolean))].toSorted(); +} + +function buildCollectionConfigHash(collections: readonly QmdRuntimeManagedCollection[]): string { + const normalized = collections + .map((collection) => ({ + ...normalizeCollection(collection), + })) + .toSorted( + (left, right) => + left.name.localeCompare(right.name) || + left.kind.localeCompare(right.kind) || + left.pathHash.localeCompare(right.pathHash) || + left.pattern.localeCompare(right.pattern), + ) + .map((entry) => `${entry.name}|${entry.kind}|${entry.pathHash}|${entry.pattern}`) + .join(";"); + return hashText(normalized); +} + +function buildCollectionValidationCacheContextInput( + params: QmdRuntimeCollectionValidationCacheContext, +): string { + return JSON.stringify({ + agentId: normalizeText(params.agentId), + commandHash: hashText(normalizeText(params.qmdCommand)), + environmentHash: normalizeText(params.qmdEnvironmentHash ?? ""), + indexPathHash: normalizePathIdentity(params.qmdIndexPath), + qmdVersion: normalizeText(params.qmdVersion ?? ""), + searchMode: params.searchMode, + sourceSet: sortedUnique(params.sources), + collectionConfigHash: buildCollectionConfigHash(params.collections), + }); +} + +function buildMultiCollectionProbeCacheContextInput( + params: QmdRuntimeMultiCollectionProbeCacheContext, +): string { + return JSON.stringify({ + agentId: normalizeText(params.agentId), + commandHash: hashText(normalizeText(params.qmdCommand)), + environmentHash: normalizeText(params.qmdEnvironmentHash ?? ""), + indexPathHash: normalizePathIdentity(params.qmdIndexPath), + qmdVersion: normalizeText(params.qmdVersion ?? ""), + searchMode: params.searchMode, + sourceSet: sortedUnique(params.sources), + }); +} + +function buildCollectionValidationCacheHash( + params: QmdRuntimeCollectionValidationCacheContext, +): string { + return hashText(buildCollectionValidationCacheContextInput(params)); +} + +function buildMultiCollectionProbeCacheHash( + params: QmdRuntimeMultiCollectionProbeCacheContext, +): string { + return hashText(buildMultiCollectionProbeCacheContextInput(params)); +} + +export function buildQmdCollectionValidationCacheContextHash( + params: QmdRuntimeCollectionValidationCacheContext, +): string { + return buildCollectionValidationCacheHash(params); +} + +export function buildQmdMultiCollectionProbeCacheContextHash( + params: QmdRuntimeMultiCollectionProbeCacheContext, +): string { + return buildMultiCollectionProbeCacheHash(params); +} + +function collectionValidationStore(): PluginStateKeyedStore { + return openMemoryCoreStateStore({ + namespace: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE, + maxEntries: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_MAX_ENTRIES, + }); +} + +function multiCollectionProbeStore(): PluginStateKeyedStore { + return openMemoryCoreStateStore({ + namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE, + maxEntries: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_MAX_ENTRIES, + }); +} + +function collectionValidationEntryKey(params: QmdRuntimeCollectionValidationCacheContext): string { + return memoryCoreWorkspaceEntryKey( + params.workspaceDir, + `qmd-runtime-cache.collection-validation:${buildCollectionValidationCacheHash(params)}`, + ); +} + +function multiCollectionProbeEntryKey(params: QmdRuntimeMultiCollectionProbeCacheContext): string { + return memoryCoreWorkspaceEntryKey( + params.workspaceDir, + `qmd-runtime-cache.multi-collection-probe:${buildMultiCollectionProbeCacheHash(params)}`, + ); +} + +function normalizeCollectionValidationEntry( + value: unknown, + nowMs: number, + expectedKeyHash: string, +): QmdRuntimeCacheCollectionValidationEntry | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + const record = value as Record; + if (record.version !== QMD_RUNTIME_CACHE_ENTRY_VERSION) { + return undefined; + } + + const createdAtMs = + typeof record.createdAtMs === "number" + ? Math.max(0, Math.floor(record.createdAtMs)) + : Number.NaN; + const expiresAtMs = + typeof record.expiresAtMs === "number" + ? Math.max(0, Math.floor(record.expiresAtMs)) + : Number.NaN; + if ( + !Number.isFinite(createdAtMs) || + !Number.isFinite(expiresAtMs) || + !Number.isFinite(nowMs) || + nowMs >= expiresAtMs + ) { + return undefined; + } + + const keyHash = normalizeText(typeof record.keyHash === "string" ? record.keyHash : ""); + if (keyHash !== expectedKeyHash) { + return undefined; + } + + const validation = record.validation; + if (typeof validation !== "object" || validation === null) { + return undefined; + } + const validationRecord = validation as Record; + if (validationRecord.ok !== true) { + return undefined; + } + if (typeof validationRecord.collectionConfigHash !== "string") { + return undefined; + } + if (typeof validationRecord.collectionCount !== "number") { + return undefined; + } + + return { + version: QMD_RUNTIME_CACHE_ENTRY_VERSION, + createdAtMs, + expiresAtMs, + keyHash, + validation: { + ok: true, + collectionConfigHash: normalizeText(validationRecord.collectionConfigHash), + collectionCount: Math.max(0, Math.floor(validationRecord.collectionCount)), + }, + }; +} + +function normalizeMultiCollectionProbeEntry( + value: unknown, + nowMs: number, + expectedKeyHash: string, +): QmdRuntimeCacheMultiCollectionProbeEntry | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + const record = value as Record; + if (record.version !== QMD_RUNTIME_CACHE_ENTRY_VERSION) { + return undefined; + } + + const createdAtMs = + typeof record.createdAtMs === "number" + ? Math.max(0, Math.floor(record.createdAtMs)) + : Number.NaN; + const expiresAtMs = + typeof record.expiresAtMs === "number" + ? Math.max(0, Math.floor(record.expiresAtMs)) + : Number.NaN; + if ( + !Number.isFinite(createdAtMs) || + !Number.isFinite(expiresAtMs) || + !Number.isFinite(nowMs) || + nowMs >= expiresAtMs + ) { + return undefined; + } + + const keyHash = normalizeText(typeof record.keyHash === "string" ? record.keyHash : ""); + if (keyHash !== expectedKeyHash) { + return undefined; + } + + const probe = record.multiCollectionProbe; + if (typeof probe !== "object" || probe === null) { + return undefined; + } + const probeRecord = probe as Record; + if (typeof probeRecord.supported !== "boolean") { + return undefined; + } + + return { + version: QMD_RUNTIME_CACHE_ENTRY_VERSION, + createdAtMs, + expiresAtMs, + keyHash, + multiCollectionProbe: { + supported: probeRecord.supported, + }, + }; +} + +export async function readQmdCollectionValidationCache( + params: QmdRuntimeCollectionValidationCacheContext, + nowMs = Date.now(), +): Promise> { + try { + const store = collectionValidationStore(); + const key = collectionValidationEntryKey(params); + const expectedKeyHash = buildCollectionValidationCacheHash(params); + const raw = await store.lookup(key); + if (!raw) { + return { state: "miss" }; + } + const validated = normalizeCollectionValidationEntry(raw, nowMs, expectedKeyHash); + return validated ? { state: "hit", value: validated } : { state: "miss" }; + } catch { + return { state: "miss" }; + } +} + +export async function writeQmdCollectionValidationCache( + params: QmdRuntimeCollectionValidationCacheContext, + nowMs = Date.now(), +): Promise { + try { + const key = collectionValidationEntryKey(params); + const keyHash = buildCollectionValidationCacheHash(params); + const collectionConfigHash = buildCollectionConfigHash(params.collections); + const createdAtMs = Math.max(0, Math.floor(nowMs)); + const ttlMs = QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS; + const store = collectionValidationStore(); + await store.register( + key, + { + version: QMD_RUNTIME_CACHE_ENTRY_VERSION, + createdAtMs, + expiresAtMs: createdAtMs + ttlMs, + keyHash, + validation: { + ok: true, + collectionConfigHash, + collectionCount: params.collections.length, + }, + }, + { ttlMs }, + ); + return true; + } catch { + return false; + } +} + +export async function clearQmdCollectionValidationCache( + params: QmdRuntimeCollectionValidationCacheContext, +): Promise { + try { + const store = collectionValidationStore(); + await store.delete(collectionValidationEntryKey(params)); + } catch { + // fail open + } +} + +export async function readQmdMultiCollectionProbeCache( + params: QmdRuntimeMultiCollectionProbeCacheContext, + nowMs = Date.now(), +): Promise> { + try { + const store = multiCollectionProbeStore(); + const key = multiCollectionProbeEntryKey(params); + const expectedKeyHash = buildMultiCollectionProbeCacheHash(params); + const raw = await store.lookup(key); + if (!raw) { + return { state: "miss" }; + } + const validated = normalizeMultiCollectionProbeEntry(raw, nowMs, expectedKeyHash); + return validated ? { state: "hit", value: validated } : { state: "miss" }; + } catch { + return { state: "miss" }; + } +} + +export async function writeQmdMultiCollectionProbeCache( + params: QmdRuntimeMultiCollectionProbeCacheContext, + supported: boolean, + nowMs = Date.now(), +): Promise { + try { + const key = multiCollectionProbeEntryKey(params); + const keyHash = buildMultiCollectionProbeCacheHash(params); + const createdAtMs = Math.max(0, Math.floor(nowMs)); + const ttlMs = QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS; + const store = multiCollectionProbeStore(); + await store.register( + key, + { + version: QMD_RUNTIME_CACHE_ENTRY_VERSION, + createdAtMs, + expiresAtMs: createdAtMs + ttlMs, + keyHash, + multiCollectionProbe: { + supported, + }, + }, + { ttlMs }, + ); + return true; + } catch { + return false; + } +} + +export async function clearQmdMultiCollectionProbeCache( + params: QmdRuntimeMultiCollectionProbeCacheContext, +): Promise { + try { + const store = multiCollectionProbeStore(); + await store.delete(multiCollectionProbeEntryKey(params)); + } catch { + // fail open + } +} diff --git a/extensions/memory-core/src/memory/search-manager.test.ts b/extensions/memory-core/src/memory/search-manager.test.ts index cf9f03a38fbe..df618684c8eb 100644 --- a/extensions/memory-core/src/memory/search-manager.test.ts +++ b/extensions/memory-core/src/memory/search-manager.test.ts @@ -326,6 +326,10 @@ describe("getMemorySearchManager caching", () => { expect(first.manager).toBe(second.manager); expect(createQmdManagerMock.mock.calls).toHaveLength(1); + expect(first.debug?.managerCacheState).toBe("cached-full-miss"); + expect(second.debug?.managerCacheState).toBe("cached-full-hit"); + expect(first.debug?.qmdIdentityHash).toMatch(/^[0-9a-f]{64}$/); + expect(second.debug?.qmdIdentityHash).toBe(first.debug?.qmdIdentityHash); }); it("keeps the cached QMD manager active when the caller cancels a search", async () => { @@ -806,6 +810,10 @@ describe("getMemorySearchManager caching", () => { const fullManager = requireManager(full); const cliManager = requireManager(cli); + expect(cli.debug?.managerCacheState).toBe("transient-cli"); + expect(full.debug?.managerCacheState).toBe("cached-full-miss"); + expect(full.debug?.qmdIdentityHash).toMatch(/^[0-9a-f]{64}$/); + expect(cli.debug?.qmdIdentityHash).toBe(full.debug?.qmdIdentityHash); expect(cliManager).toBe(cliPrimary); expect(cliManager).not.toBe(fullManager); const fullCreateParams = qmdCreateParams(); diff --git a/extensions/memory-core/src/memory/search-manager.ts b/extensions/memory-core/src/memory/search-manager.ts index 3f21f1b52f2b..c7d6e305ae5c 100644 --- a/extensions/memory-core/src/memory/search-manager.ts +++ b/extensions/memory-core/src/memory/search-manager.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; // Memory Core plugin module implements search manager behavior. import fs from "node:fs/promises"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; @@ -48,6 +49,24 @@ type QmdManagerOpenFailure = { retryAfterMs: number; }; +type MemorySearchManagerCacheState = + | "cached-full-hit" + | "cached-full-miss" + | "transient-cli" + | "transient-status" + | "pending-create-wait" + | "fallback-builtin" + | "recent-failure-cooldown"; + +export type MemorySearchManagerDebug = { + backend?: "builtin" | "qmd"; + purpose?: MemorySearchManagerPurpose; + managerMs?: number; + managerCacheState?: MemorySearchManagerCacheState; + qmdIdentityHash?: string; + failureCode?: "qmd-unavailable"; +}; + type MemorySearchManagerCacheStore = { qmdManagerCache: Map; pendingQmdManagerCreates: Map; @@ -109,6 +128,7 @@ function loadQmdManagerModule() { export type MemorySearchManagerResult = { manager: Maybe; error?: string; + debug?: MemorySearchManagerDebug; }; export type MemorySearchManagerPurpose = "default" | "status" | "cli"; @@ -149,11 +169,42 @@ function clearQmdManagerOpenFailure(scopeKey: string, identityKey: string): void } } +function hashQmdManagerIdentity(identityKey: string): string { + return createHash("sha256").update(identityKey).digest("hex"); +} + +function applyManagerDebug( + result: MemorySearchManagerResult, + debug: MemorySearchManagerDebug, +): MemorySearchManagerResult { + if (result.debug && Object.keys(result.debug).length > 0 && Object.keys(debug).length === 0) { + return result; + } + return { + ...result, + debug: { + ...result.debug, + ...debug, + }, + }; +} + export async function getMemorySearchManager(params: { cfg: OpenClawConfig; agentId: string; purpose?: MemorySearchManagerPurpose; }): Promise { + const acquireStartedAt = Date.now(); + const purpose = params.purpose ?? "default"; + const finish = ( + result: MemorySearchManagerResult, + debug: MemorySearchManagerDebug, + ): MemorySearchManagerResult => + applyManagerDebug(result, { + purpose, + managerMs: Math.max(0, Date.now() - acquireStartedAt), + ...debug, + }); const resolved = resolveMemoryBackendConfig(params); if (resolved.backend === "qmd" && resolved.qmd) { const qmdResolved = resolved.qmd; @@ -163,6 +214,7 @@ export async function getMemorySearchManager(params: { const transient = params.purpose === "status" || params.purpose === "cli"; const scopeKey = buildQmdManagerScopeKey(normalizedAgentId); const identityKey = buildQmdManagerIdentityKey(normalizedAgentId, qmdResolved, runtimeConfig); + const debugIdentityHash = hashQmdManagerIdentity(identityKey); const createPrimaryQmdManager = async ( mode: "full" | "status" | "cli", @@ -254,10 +306,24 @@ export async function getMemorySearchManager(params: { // Status callers often close the manager they receive. Wrap the live // full manager with a no-op close so health/status probes do not tear // down the active QMD manager for the process. - return { manager: new BorrowedMemoryManager(cached.manager) }; + return finish( + { manager: new BorrowedMemoryManager(cached.manager) }, + { + backend: "qmd", + managerCacheState: "cached-full-hit", + qmdIdentityHash: debugIdentityHash, + }, + ); } if (params.purpose !== "cli") { - return { manager: cached.manager }; + return finish( + { manager: cached.manager }, + { + backend: "qmd", + managerCacheState: "cached-full-hit", + qmdIdentityHash: debugIdentityHash, + }, + ); } } @@ -266,20 +332,44 @@ export async function getMemorySearchManager(params: { params.purpose === "cli" ? "cli" : "status", ); return manager - ? { manager } - : await getBuiltinMemorySearchManagerAfterQmdFailure(params, failureReason); + ? finish( + { manager }, + { + backend: "qmd", + managerCacheState: params.purpose === "cli" ? "transient-cli" : "transient-status", + qmdIdentityHash: debugIdentityHash, + }, + ) + : finish(await getBuiltinMemorySearchManagerAfterQmdFailure(params, failureReason), { + backend: "qmd", + managerCacheState: "fallback-builtin", + qmdIdentityHash: debugIdentityHash, + failureCode: "qmd-unavailable", + }); } const recentFailure = getActiveQmdManagerOpenFailure(scopeKey, identityKey); if (recentFailure) { log.debug?.(`qmd memory unavailable; using builtin during cooldown: ${recentFailure.reason}`); - return await getBuiltinMemorySearchManagerAfterQmdFailure(params, recentFailure.reason); + return finish( + await getBuiltinMemorySearchManagerAfterQmdFailure(params, recentFailure.reason), + { + backend: "qmd", + managerCacheState: "recent-failure-cooldown", + qmdIdentityHash: debugIdentityHash, + failureCode: "qmd-unavailable", + }, + ); } const pending = PENDING_QMD_MANAGER_CREATES.get(scopeKey); if (pending) { await pending.promise; - return await getMemorySearchManager(params); + return finish(await getMemorySearchManager(params), { + backend: "qmd", + managerCacheState: "pending-create-wait", + qmdIdentityHash: debugIdentityHash, + }); } let pendingFailureReason: string | undefined; @@ -309,11 +399,25 @@ export async function getMemorySearchManager(params: { PENDING_QMD_MANAGER_CREATES.set(scopeKey, pendingCreate); const manager = await pendingCreate.promise; return manager - ? { manager } - : await getBuiltinMemorySearchManagerAfterQmdFailure(params, pendingFailureReason); + ? finish( + { manager }, + { + backend: "qmd", + managerCacheState: "cached-full-miss", + qmdIdentityHash: debugIdentityHash, + }, + ) + : finish(await getBuiltinMemorySearchManagerAfterQmdFailure(params, pendingFailureReason), { + backend: "qmd", + managerCacheState: "fallback-builtin", + qmdIdentityHash: debugIdentityHash, + failureCode: "qmd-unavailable", + }); } - return await getBuiltinMemorySearchManager(params); + return finish(await getBuiltinMemorySearchManager(params), { + backend: "builtin", + }); } async function getBuiltinMemorySearchManagerAfterQmdFailure( diff --git a/extensions/memory-core/src/runtime-provider.test.ts b/extensions/memory-core/src/runtime-provider.test.ts new file mode 100644 index 000000000000..b625e8aba482 --- /dev/null +++ b/extensions/memory-core/src/runtime-provider.test.ts @@ -0,0 +1,44 @@ +// Memory Core provider tests cover plugin runtime integration. +import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core"; +import { describe, expect, it, vi } from "vitest"; + +const managerDebug = { + backend: "qmd" as const, + purpose: "default" as const, + managerMs: 7, + managerCacheState: "cached-full-hit" as const, + qmdIdentityHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", +}; + +const getMemorySearchManagerMock = vi.hoisted(() => + vi.fn(async () => ({ + manager: null, + debug: managerDebug, + error: undefined, + })), +); + +vi.mock("./memory/index.js", () => ({ + closeAllMemorySearchManagers: vi.fn(async () => {}), + closeMemorySearchManager: vi.fn(async () => {}), + getMemorySearchManager: getMemorySearchManagerMock, +})); + +import { memoryRuntime } from "./runtime-provider.js"; + +describe("memoryRuntime", () => { + it("preserves manager debug metadata", async () => { + const cfg = {} as OpenClawConfig; + + const result = await memoryRuntime.getMemorySearchManager({ + cfg, + agentId: "main", + }); + + expect(result.debug).toEqual(managerDebug); + expect(getMemorySearchManagerMock).toHaveBeenCalledWith({ + cfg, + agentId: "main", + }); + }); +}); diff --git a/extensions/memory-core/src/runtime-provider.ts b/extensions/memory-core/src/runtime-provider.ts index cde3337f2077..8c0a2531bfad 100644 --- a/extensions/memory-core/src/runtime-provider.ts +++ b/extensions/memory-core/src/runtime-provider.ts @@ -9,9 +9,10 @@ import { export const memoryRuntime: MemoryPluginRuntime = { async getMemorySearchManager(params) { - const { manager, error } = await getMemorySearchManager(params); + const { manager, debug, error } = await getMemorySearchManager(params); return { manager, + debug, error, }; }, diff --git a/extensions/memory-core/src/short-term-promotion.test.ts b/extensions/memory-core/src/short-term-promotion.test.ts index 3422425ee042..8d4f868fa701 100644 --- a/extensions/memory-core/src/short-term-promotion.test.ts +++ b/extensions/memory-core/src/short-term-promotion.test.ts @@ -3189,7 +3189,9 @@ describe("short-term promotion", () => { path: "memory/2026-04-03.md", snippet: "Move backups to S3 Glacier and sync QMD router notes.", }), - ).toStrictEqual(["backup", "backups", "glacier", "qmd", "router", "sync"]); + // "s3" is a protected-glossary term; it now surfaces as a standalone token past the + // per-script min-length gate (the longer terms still match as substrings). + ).toStrictEqual(["backup", "backups", "glacier", "qmd", "router", "s3", "sync"]); }); it("extracts multilingual concept tags across latin and cjk snippets", () => { diff --git a/extensions/memory-core/src/tools.shared.ts b/extensions/memory-core/src/tools.shared.ts index 82e3abbe4514..67175836cae9 100644 --- a/extensions/memory-core/src/tools.shared.ts +++ b/extensions/memory-core/src/tools.shared.ts @@ -67,18 +67,28 @@ export async function getMemoryManagerContextWithPurpose(params: { }): Promise< | { manager: NonNullable; + debug?: NonNullable; } | { error: string | undefined; } > { const { getMemorySearchManager } = await loadMemoryToolRuntime(); - const { manager, error } = await getMemorySearchManager({ + const startedAt = Date.now(); + const { manager, debug, error } = await getMemorySearchManager({ cfg: params.cfg, agentId: params.agentId, purpose: params.purpose, }); - return manager ? { manager } : { error }; + return manager + ? { + manager, + debug: { + ...debug, + managerMs: debug?.managerMs ?? Math.max(0, Date.now() - startedAt), + }, + } + : { error }; } export function createMemoryTool(params: { diff --git a/extensions/memory-core/src/tools.test.ts b/extensions/memory-core/src/tools.test.ts index 1273a28352e1..b8a11d445689 100644 --- a/extensions/memory-core/src/tools.test.ts +++ b/extensions/memory-core/src/tools.test.ts @@ -1,4 +1,5 @@ // Memory Core tests cover tools plugin behavior. +import type { MemorySearchRuntimeDebug } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { getMemoryCloseMockCalls, @@ -381,6 +382,95 @@ describe("memory_search unavailable payloads", () => { expect(searchCalls).toBe(2); }); + it("merges qmd runtime debug across zero-hit retry attempts", async () => { + setMemoryBackend("qmd"); + let searchCalls = 0; + setMemorySearchImpl(async (opts) => { + searchCalls += 1; + if (searchCalls === 1) { + opts?.onDebug?.({ + backend: "qmd", + configuredMode: "search", + effectiveMode: "search", + qmd: { + collectionValidation: { + cacheState: "hit", + elapsedMs: 2, + collectionCount: 2, + listCalls: 0, + showCalls: 0, + }, + multiCollectionProbe: { + cacheState: "hit", + elapsedMs: 1, + supported: true, + }, + }, + }); + return []; + } + opts?.onDebug?.({ + backend: "qmd", + configuredMode: "search", + effectiveMode: "query", + fallback: "unsupported-search-flags", + qmd: { + searchPlan: { + command: "query", + collectionCount: 2, + groupCount: 2, + sources: ["memory", "sessions"], + }, + }, + }); + return [ + { + path: "MEMORY.md", + startLine: 1, + endLine: 1, + score: 0.9, + snippet: "Thread-hidden codename: ORBIT-22.", + source: "memory" as const, + }, + ]; + }); + + const tool = createMemorySearchToolOrThrow({ + config: { + agents: { list: [{ id: "main", default: true }] }, + memory: { backend: "qmd", citations: "off" }, + }, + }); + const result = await tool.execute("zero-hit-debug-retry", { + query: "hidden thread codename", + }); + const details = result.details as { + debug?: { + effectiveMode?: string; + fallback?: string; + qmd?: MemorySearchRuntimeDebug["qmd"]; + }; + }; + + expect(searchCalls).toBe(2); + expect(details.debug?.effectiveMode).toBe("query"); + expect(details.debug?.fallback).toBe("unsupported-search-flags"); + expect(details.debug?.qmd?.collectionValidation).toMatchObject({ + cacheState: "hit", + collectionCount: 2, + }); + expect(details.debug?.qmd?.multiCollectionProbe).toMatchObject({ + cacheState: "hit", + supported: true, + }); + expect(details.debug?.qmd?.searchPlan).toEqual({ + command: "query", + collectionCount: 2, + groupCount: 2, + sources: ["memory", "sessions"], + }); + }); + it("returns unavailable metadata when the index identity is paused", async () => { let searchCalls = 0; setMemorySearchImpl(async () => { @@ -422,6 +512,14 @@ describe("memory_search unavailable payloads", () => { configuredMode: opts.qmdSearchModeOverride ?? "query", effectiveMode: "query", fallback: "unsupported-search-flags", + qmd: { + searchPlan: { + command: "query", + collectionCount: 2, + groupCount: 2, + sources: ["memory", "sessions"], + }, + }, }); return [ { @@ -470,6 +568,18 @@ describe("memory_search unavailable payloads", () => { fallback?: unknown; hits?: unknown; searchMs?: number; + toolMs?: number; + managerMs?: number; + outsideSearchMs?: number; + managerCacheState?: unknown; + qmd?: { + searchPlan?: { + command?: unknown; + collectionCount?: unknown; + groupCount?: unknown; + sources?: unknown; + }; + }; }; }; expect(details.mode).toBe("query"); @@ -479,6 +589,94 @@ describe("memory_search unavailable payloads", () => { expect(details.debug?.fallback).toBe("unsupported-search-flags"); expect(details.debug?.hits).toBe(1); expect(details.debug?.searchMs).toBeGreaterThanOrEqual(0); + expect(details.debug?.toolMs).toBeGreaterThanOrEqual(details.debug?.searchMs ?? 0); + expect(details.debug?.outsideSearchMs).toBeGreaterThanOrEqual(0); + expect(details.debug?.managerMs).toBeGreaterThanOrEqual(0); + expect(details.debug?.managerCacheState).toBeUndefined(); + expect(details.debug?.qmd?.searchPlan).toEqual({ + command: "query", + collectionCount: 2, + groupCount: 2, + sources: ["memory", "sessions"], + }); + }); + + it("includes manager acquisition timing and cache-state debug payload", async () => { + setMemorySearchManagerImpl( + async () => + ({ + manager: { + search: vi.fn(async () => { + return [ + { + path: "MEMORY.md", + startLine: 1, + endLine: 2, + score: 0.9, + snippet: "ramen", + source: "memory", + }, + ]; + }), + readFile: vi.fn(), + status: vi.fn(() => ({ + backend: "qmd", + provider: "qmd", + model: "qmd", + requestedProvider: "qmd", + files: 0, + chunks: 0, + dirty: false, + workspaceDir: "/tmp/workspace", + dbPath: "/tmp/workspace/index.sqlite", + sources: ["memory"], + sourceCounts: [{ source: "memory", files: 0, chunks: 0 }], + })), + sync: vi.fn(async () => {}), + probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })), + probeVectorAvailability: vi.fn(async () => true), + }, + debug: { + managerMs: 17, + managerCacheState: "cached-full-hit", + }, + }) as any, + ); + setMemorySearchImpl(async () => [ + { + path: "MEMORY.md", + startLine: 1, + endLine: 2, + score: 0.9, + snippet: "ramen", + source: "memory", + }, + ]); + + const tool = createMemorySearchToolOrThrow({ + config: { + agents: { list: [{ id: "main", default: true }] }, + memory: { backend: "qmd" }, + }, + }); + const result = await tool.execute("manager-debug", { query: "favorite food" }); + const details = result.details as { + debug?: { + backend?: string; + managerMs?: number; + toolMs?: number; + outsideSearchMs?: number; + managerCacheState?: string; + hits?: number; + searchMs?: number; + }; + }; + + expect(details.debug?.backend).toBe("qmd"); + expect(details.debug?.managerMs).toBe(17); + expect(details.debug?.toolMs).toBeGreaterThanOrEqual(details.debug?.searchMs ?? 0); + expect(details.debug?.outsideSearchMs).toBeGreaterThanOrEqual(0); + expect(details.debug?.managerCacheState).toBe("cached-full-hit"); }); }); diff --git a/extensions/memory-core/src/tools.ts b/extensions/memory-core/src/tools.ts index 603e967fc86e..c907e33f3c50 100644 --- a/extensions/memory-core/src/tools.ts +++ b/extensions/memory-core/src/tools.ts @@ -44,12 +44,35 @@ type MemorySearchToolResult = | MemoryCorpusSearchResult; type MemoryManagerContext = Awaited>; type ActiveMemoryManagerContext = Extract; +type QmdRuntimeDebug = NonNullable; const MEMORY_SEARCH_TOOL_TIMEOUT_MS = 15_000; const MEMORY_SEARCH_TOOL_COOLDOWN_MS = 60_000; const memorySearchToolCooldowns = new Map(); +function mergeQmdRuntimeDebug( + entries: readonly MemorySearchRuntimeDebug[], +): MemorySearchRuntimeDebug["qmd"] | undefined { + const merged: QmdRuntimeDebug = {}; + for (const entry of entries) { + const qmd = entry.qmd; + if (!qmd) { + continue; + } + if (!merged.collectionValidation && qmd.collectionValidation) { + merged.collectionValidation = qmd.collectionValidation; + } + if (qmd.multiCollectionProbe) { + merged.multiCollectionProbe = qmd.multiCollectionProbe; + } + if (qmd.searchPlan) { + merged.searchPlan = qmd.searchPlan; + } + } + return Object.keys(merged).length > 0 ? merged : undefined; +} + function resolveMemorySearchToolCooldownKey(options: { agentId?: string; agentSessionKey?: string; @@ -415,6 +438,7 @@ export function createMemorySearchTool(options: { const outcome = await runMemorySearchToolWithDeadline({ timeoutMs: MEMORY_SEARCH_TOOL_TIMEOUT_MS, run: async (deadlineSignal) => { + const toolStartedAt = Date.now(); const { resolveMemoryBackendConfig } = await loadMemoryToolRuntime(); const shouldQuerySupplements = requestedCorpus === "wiki" || requestedCorpus === "all"; const shouldQueryMemory = requestedCorpus !== "wiki" && !cooldown; @@ -471,13 +495,20 @@ export function createMemorySearchTool(options: { let fallback: unknown; let searchMode: string | undefined; let pausedIndexIdentityReason: string | undefined; + let managerMs: number | undefined; + let managerCacheState: string | undefined; let searchDebug: | { backend: string; configuredMode?: string; effectiveMode?: string; fallback?: string; + toolMs?: number; + managerMs?: number; + outsideSearchMs?: number; searchMs: number; + managerCacheState?: string; + qmd?: MemorySearchRuntimeDebug["qmd"]; hits: number; } | undefined; @@ -506,6 +537,8 @@ export function createMemorySearchTool(options: { }, ...(searchSources ? { sources: searchSources } : {}), }; + managerMs = memory.debug?.managerMs; + managerCacheState = memory.debug?.managerCacheState; try { rawResults = await activeMemory.manager.search(query, searchOptions); } catch (error) { @@ -522,6 +555,8 @@ export function createMemorySearchTool(options: { if ("error" in refreshed) { throw error; } + managerMs = refreshed.debug?.managerMs; + managerCacheState = refreshed.debug?.managerCacheState; activeMemory = refreshed; rawResults = await activeMemory.manager.search(query, searchOptions); } @@ -580,7 +615,9 @@ export function createMemorySearchTool(options: { model = status.model; fallback = status.fallback; const latestDebug = runtimeDebug.at(-1); + const qmdDebug = mergeQmdRuntimeDebug(runtimeDebug); searchMode = latestDebug?.effectiveMode; + const searchMs = Math.max(0, Date.now() - searchStartedAt); searchDebug = { backend: status.backend, configuredMode: latestDebug?.configuredMode, @@ -589,7 +626,10 @@ export function createMemorySearchTool(options: { ? (latestDebug?.effectiveMode ?? latestDebug?.configuredMode) : "n/a", fallback: latestDebug?.fallback, - searchMs: Math.max(0, Date.now() - searchStartedAt), + managerMs, + searchMs, + managerCacheState, + qmd: qmdDebug, hits: rawResults.length, }; }); @@ -620,6 +660,14 @@ export function createMemorySearchTool(options: { maxResults: effectiveMax, balanceCorpora: requestedCorpus === "all", }); + if (searchDebug) { + const finalToolMs = Math.max(0, Date.now() - toolStartedAt); + searchDebug = { + ...searchDebug, + toolMs: finalToolMs, + outsideSearchMs: Math.max(0, finalToolMs - searchDebug.searchMs), + }; + } return jsonResult({ results, provider, diff --git a/extensions/microsoft-foundry/image-generation-provider.test.ts b/extensions/microsoft-foundry/image-generation-provider.test.ts index 2a85a06c7cf1..b35e1c94b114 100644 --- a/extensions/microsoft-foundry/image-generation-provider.test.ts +++ b/extensions/microsoft-foundry/image-generation-provider.test.ts @@ -49,15 +49,21 @@ vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({ resolveApiKeyForProvider: resolveApiKeyForProviderMock, })); -vi.mock("openclaw/plugin-sdk/provider-http", () => ({ - assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock, - createProviderOperationDeadline: createProviderOperationDeadlineMock, - postJsonRequest: postJsonRequestMock, - postMultipartRequest: postMultipartRequestMock, - resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock, - resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock, - sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock, -})); +vi.mock("openclaw/plugin-sdk/provider-http", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/provider-http", + ); + return { + assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock, + createProviderOperationDeadline: createProviderOperationDeadlineMock, + postJsonRequest: postJsonRequestMock, + postMultipartRequest: postMultipartRequestMock, + readProviderJsonResponse: actual.readProviderJsonResponse, + resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock, + resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock, + sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock, + }; +}); vi.mock("./runtime.js", () => ({ prepareFoundryRuntimeAuth: prepareFoundryRuntimeAuthMock, @@ -69,12 +75,16 @@ function buildConfig( modelName?: string; baseUrl?: string; includeModel?: boolean; + mediaMaxMb?: number; } = {}, ): OpenClawConfig { const baseUrl = params.baseUrl ?? "https://example.services.ai.azure.com/openai/v1"; const modelId = params.modelId ?? "image-deployment"; const modelName = params.modelName ?? "MAI-Image-2.5"; return { + ...(params.mediaMaxMb !== undefined + ? { agents: { defaults: { mediaMaxMb: params.mediaMaxMb } } } + : {}), models: { providers: { [PROVIDER_ID]: { @@ -227,6 +237,64 @@ describe("microsoft foundry image generation provider", () => { expect(result.images[0]?.mimeType).toBe("image/png"); }); + it("accepts a valid max-size MAI image JSON response", async () => { + const imageBytes = Buffer.alloc(6 * 1024 * 1024, 1); + postJsonRequestMock.mockResolvedValue( + releasedJson({ + data: [{ b64_json: imageBytes.toString("base64") }], + }), + ); + const provider = buildMicrosoftFoundryImageGenerationProvider(); + + const result = await provider.generateImage({ + provider: PROVIDER_ID, + model: "image-deployment", + prompt: "draw it", + cfg: buildConfig(), + }); + + expect(result.images).toHaveLength(1); + expect(result.images[0]?.buffer.byteLength).toBe(imageBytes.byteLength); + }); + + it("honors configured generated media caps above the default image limit", async () => { + const imageBytes = Buffer.alloc(7 * 1024 * 1024, 1); + postJsonRequestMock.mockResolvedValue( + releasedJson({ + data: [{ b64_json: imageBytes.toString("base64") }], + }), + ); + const provider = buildMicrosoftFoundryImageGenerationProvider(); + + const result = await provider.generateImage({ + provider: PROVIDER_ID, + model: "image-deployment", + prompt: "draw it", + cfg: buildConfig({ mediaMaxMb: 8 }), + }); + + expect(result.images).toHaveLength(1); + expect(result.images[0]?.buffer.byteLength).toBe(imageBytes.byteLength); + }); + + it("rejects oversized MAI image JSON responses", async () => { + postJsonRequestMock.mockResolvedValue( + releasedJson({ + data: [{ b64_json: "x".repeat(10 * 1024 * 1024) }], + }), + ); + const provider = buildMicrosoftFoundryImageGenerationProvider(); + + await expect( + provider.generateImage({ + provider: PROVIDER_ID, + model: "image-deployment", + prompt: "draw it", + cfg: buildConfig(), + }), + ).rejects.toThrow("microsoft-foundry.image-generation: JSON response exceeds"); + }); + it("uses AZURE_OPENAI_ENDPOINT when env API-key auth has no configured base URL", async () => { vi.stubEnv("AZURE_OPENAI_ENDPOINT", "https://env.services.ai.azure.com"); postJsonRequestMock.mockResolvedValue( diff --git a/extensions/microsoft-foundry/image-generation-provider.ts b/extensions/microsoft-foundry/image-generation-provider.ts index 7131778eeb9c..513f4b5e75cf 100644 --- a/extensions/microsoft-foundry/image-generation-provider.ts +++ b/extensions/microsoft-foundry/image-generation-provider.ts @@ -10,7 +10,9 @@ import type { import { imageSourceUploadFileName, parseOpenAiCompatibleImageResponse, + resolveInlineImageJsonResponseMaxBytes, } from "openclaw/plugin-sdk/image-generation"; +import { MAX_IMAGE_BYTES } from "openclaw/plugin-sdk/media-runtime"; import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth"; import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime"; import { @@ -18,6 +20,7 @@ import { createProviderOperationDeadline, postJsonRequest, postMultipartRequest, + readProviderJsonResponse, resolveProviderHttpRequestConfig, resolveProviderOperationTimeoutMs, sanitizeConfiguredModelProviderRequest, @@ -40,7 +43,9 @@ const DEFAULT_IMAGE_SIZE = { width: 1024, height: 1024 }; const MAI_MIN_IMAGE_SIDE_PX = 768; const MAI_MAX_IMAGE_PIXELS = 1_048_576; const MAI_IMAGE_BASE_PATH = "/mai/v1"; +const MAI_IMAGE_MAX_RESULTS = 1; const MAI_IMAGE_OUTPUT_MIME = "image/png"; +const MB = 1024 * 1024; const MAI_IMAGE_UPLOAD_MIME_TYPES = new Set(["image/jpeg", "image/jpg", "image/png"]); type ModelProviderConfig = NonNullable["providers"]>[string]; @@ -108,6 +113,16 @@ function resolveMaiImageSize(size: string | undefined): { width: number; height: return { width, height }; } +function resolveGeneratedImageMaxBytes(req: { + cfg: { agents?: { defaults?: { mediaMaxMb?: number } } }; +}): number { + const configured = req.cfg.agents?.defaults?.mediaMaxMb; + if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) { + return Math.floor(configured * MB); + } + return MAX_IMAGE_BYTES; +} + function assertSingleImageCount(count: number | undefined): void { if (count === undefined || count === 1) { return; @@ -256,12 +271,12 @@ export function buildMicrosoftFoundryImageGenerationProvider(): ImageGenerationP }), capabilities: { generate: { - maxCount: 1, + maxCount: MAI_IMAGE_MAX_RESULTS, supportsSize: true, }, edit: { enabled: true, - maxCount: 1, + maxCount: MAI_IMAGE_MAX_RESULTS, maxInputImages: 1, supportsSize: false, }, @@ -367,8 +382,18 @@ export function buildMicrosoftFoundryImageGenerationProvider(): ImageGenerationP const { response, release } = await request; try { await assertOkOrThrowHttpError(response, `${label} failed`); + const payload = await readProviderJsonResponse( + response, + "microsoft-foundry.image-generation", + { + maxBytes: resolveInlineImageJsonResponseMaxBytes( + MAI_IMAGE_MAX_RESULTS, + resolveGeneratedImageMaxBytes(req), + ), + }, + ); return { - images: parseMaiImageResponse(await response.json(), label), + images: parseMaiImageResponse(payload, label), model, }; } finally { diff --git a/extensions/microsoft/speech-provider.ts b/extensions/microsoft/speech-provider.ts index 2e4cdabebced..a8ec98163e92 100644 --- a/extensions/microsoft/speech-provider.ts +++ b/extensions/microsoft/speech-provider.ts @@ -7,7 +7,10 @@ import { generateSecMsGecToken, } from "node-edge-tts/dist/drm.js"; import { isVoiceCompatibleAudio } from "openclaw/plugin-sdk/media-runtime"; -import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/provider-http"; +import { + assertOkOrThrowProviderError, + readProviderJsonResponse, +} from "openclaw/plugin-sdk/provider-http"; import { captureHttpExchange, isDebugProxyGlobalFetchPatchInstalled, @@ -166,7 +169,10 @@ export async function listMicrosoftVoices(): Promise { }); } await assertOkOrThrowProviderError(response, "Microsoft voices API error"); - const voices = (await response.json()) as MicrosoftVoiceListEntry[]; + const voices = await readProviderJsonResponse( + response, + "microsoft.speech-voices", + ); return Array.isArray(voices) ? voices .map((voice) => ({ diff --git a/extensions/minimax/image-generation-provider.ts b/extensions/minimax/image-generation-provider.ts index 300cc9104f9f..e006b6f9a164 100644 --- a/extensions/minimax/image-generation-provider.ts +++ b/extensions/minimax/image-generation-provider.ts @@ -1,11 +1,15 @@ // Minimax provider module implements model/runtime integration. -import type { ImageGenerationProvider } from "openclaw/plugin-sdk/image-generation"; -import { canonicalizeBase64 } from "openclaw/plugin-sdk/media-runtime"; +import { + resolveInlineImageJsonResponseMaxBytes, + type ImageGenerationProvider, +} from "openclaw/plugin-sdk/image-generation"; +import { canonicalizeBase64, MAX_IMAGE_BYTES } from "openclaw/plugin-sdk/media-runtime"; import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth"; import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime"; import { assertOkOrThrowHttpError, postJsonRequest, + readProviderJsonResponse, resolveProviderHttpRequestConfig, } from "openclaw/plugin-sdk/provider-http"; @@ -13,6 +17,8 @@ const DEFAULT_MINIMAX_IMAGE_BASE_URL = "https://api.minimax.io"; const CN_MINIMAX_IMAGE_BASE_URL = "https://api.minimaxi.com"; const DEFAULT_MODEL = "image-01"; const DEFAULT_OUTPUT_MIME = "image/png"; +const MINIMAX_MAX_IMAGE_RESULTS = 9; +const MB = 1024 * 1024; const MINIMAX_SUPPORTED_ASPECT_RATIOS = [ "1:1", "16:9", @@ -72,6 +78,16 @@ function resolveMinimaxImageBaseUrl( return DEFAULT_MINIMAX_IMAGE_BASE_URL; } +function resolveGeneratedImageMaxBytes(req: { + cfg: { agents?: { defaults?: { mediaMaxMb?: number } } }; +}): number { + const configured = req.cfg.agents?.defaults?.mediaMaxMb; + if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) { + return Math.floor(configured * MB); + } + return MAX_IMAGE_BYTES; +} + function buildMinimaxImageProvider(providerId: string): ImageGenerationProvider { return { id: providerId, @@ -85,14 +101,14 @@ function buildMinimaxImageProvider(providerId: string): ImageGenerationProvider }), capabilities: { generate: { - maxCount: 9, + maxCount: MINIMAX_MAX_IMAGE_RESULTS, supportsSize: false, supportsAspectRatio: true, supportsResolution: false, }, edit: { enabled: true, - maxCount: 9, + maxCount: MINIMAX_MAX_IMAGE_RESULTS, maxInputImages: 1, supportsSize: false, supportsAspectRatio: true, @@ -163,7 +179,16 @@ function buildMinimaxImageProvider(providerId: string): ImageGenerationProvider try { await assertOkOrThrowHttpError(response, "MiniMax image generation failed"); - const data = (await response.json()) as MinimaxImageApiResponse; + const data = await readProviderJsonResponse( + response, + "minimax.image-generation", + { + maxBytes: resolveInlineImageJsonResponseMaxBytes( + MINIMAX_MAX_IMAGE_RESULTS, + resolveGeneratedImageMaxBytes(req), + ), + }, + ); const baseResp = data.base_resp; if (baseResp && typeof baseResp.status_code === "number" && baseResp.status_code !== 0) { diff --git a/extensions/minimax/tts.ts b/extensions/minimax/tts.ts index aaac67d5da87..8b9507ef08aa 100644 --- a/extensions/minimax/tts.ts +++ b/extensions/minimax/tts.ts @@ -1,6 +1,9 @@ // Minimax plugin module implements tts behavior. import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; -import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/provider-http"; +import { + assertOkOrThrowProviderError, + readProviderJsonResponse, +} from "openclaw/plugin-sdk/provider-http"; import { fetchWithSsrFGuard, ssrfPolicyFromHttpBaseUrlAllowedHostname, @@ -105,10 +108,10 @@ export async function minimaxTTS(params: { try { await assertOkOrThrowProviderError(response, "MiniMax TTS API error"); - const body = (await response.json()) as { + const body = await readProviderJsonResponse<{ data?: { audio?: string }; base_resp?: { status_code?: number; status_msg?: string }; - }; + }>(response, "minimax.tts"); // Check base_resp for envelope errors (HTTP 200 with non-zero status_code). // Other MiniMax providers (image, video, music, web-search) already check this. @@ -119,9 +122,7 @@ export async function minimaxTTS(params: { body.base_resp.status_code !== 0 ) { const msg = body.base_resp.status_msg ?? "unknown error"; - throw new Error( - `MiniMax TTS API error (${body.base_resp.status_code}): ${msg}`, - ); + throw new Error(`MiniMax TTS API error (${body.base_resp.status_code}): ${msg}`); } const hexAudio = body?.data?.audio; diff --git a/extensions/msteams/src/graph-thread.test.ts b/extensions/msteams/src/graph-thread.test.ts index b34b76ccc958..77164b1c4697 100644 --- a/extensions/msteams/src/graph-thread.test.ts +++ b/extensions/msteams/src/graph-thread.test.ts @@ -37,6 +37,15 @@ describe("stripHtmlFromTeamsMessage", () => { ); }); + it("does not double-decode escaped entities (decodes & last)", () => { + // Graph encodes literally-typed entity text by escaping its '&' to '&'. + // Decoding '&' first would re-decode the now-bare '<'/'>' into + // angle brackets, corrupting the user's literal text. + expect(stripHtmlFromTeamsMessage("The token is &lt;APIKEY&gt;")).toBe( + "The token is <APIKEY>", + ); + }); + it("normalizes multiple whitespace to single space", () => { expect(stripHtmlFromTeamsMessage("hello world")).toBe("hello world"); }); diff --git a/extensions/msteams/src/graph-thread.ts b/extensions/msteams/src/graph-thread.ts index 88b37311991c..4254af7698f2 100644 --- a/extensions/msteams/src/graph-thread.ts +++ b/extensions/msteams/src/graph-thread.ts @@ -35,14 +35,16 @@ export function stripHtmlFromTeamsMessage(html: string): string { let text = html.replace(/]*>(.*?)<\/at>/gi, "@$1"); // Strip remaining HTML tags. text = text.replace(/<[^>]*>/g, " "); - // Decode common HTML entities. + // Decode common HTML entities. & must be decoded LAST to prevent + // double-decoding (e.g. &lt; → < not <), matching decodeHtmlEntities + // in inbound.ts. text = text - .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'") - .replace(/ /g, " "); + .replace(/ /g, " ") + .replace(/&/g, "&"); // Normalize whitespace. return text.replace(/\s+/g, " ").trim(); } diff --git a/extensions/ollama/src/embedding-provider.test.ts b/extensions/ollama/src/embedding-provider.test.ts index 95d00edd0efc..52957c2792bb 100644 --- a/extensions/ollama/src/embedding-provider.test.ts +++ b/extensions/ollama/src/embedding-provider.test.ts @@ -1,6 +1,7 @@ // Ollama tests cover embedding provider plugin behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createStreamingResponse } from "../../test-support/streaming-error-response.js"; const { fetchConfiguredLocalOriginWithSsrFGuardMock } = vi.hoisted(() => ({ fetchConfiguredLocalOriginWithSsrFGuardMock: vi.fn( @@ -412,10 +413,40 @@ describe("ollama embedding provider", () => { }); await expect(provider.embedQuery("hello")).rejects.toThrow( - "Ollama embed response returned malformed JSON", + "Ollama embed response: malformed JSON response", ); }); + it("bounds successful embed JSON bodies before parsing", async () => { + const streamed = createStreamingResponse({ + chunkCount: 32, + chunkSize: 1024 * 1024, + text: "x", + headers: { "content-type": "application/json" }, + }); + const jsonSpy = vi.spyOn(streamed.response, "json").mockRejectedValue(new Error("unbounded")); + vi.stubGlobal( + "fetch", + vi.fn(async () => streamed.response), + ); + + const { provider } = await createOllamaEmbeddingProvider({ + config: {} as OpenClawConfig, + provider: "ollama", + model: "nomic-embed-text", + fallback: "none", + remote: { baseUrl: "http://127.0.0.1:11434" }, + }); + + await expect(provider.embedQuery("hello")).rejects.toThrow( + "Ollama embed response: JSON response exceeds 16777216 bytes", + ); + + expect(streamed.getReadCount()).toBeLessThan(32); + expect(streamed.wasCanceled()).toBe(true); + expect(jsonSpy).not.toHaveBeenCalled(); + }); + it("rejects non-number embedding values instead of zeroing them", async () => { vi.stubGlobal( "fetch", diff --git a/extensions/ollama/src/embedding-provider.ts b/extensions/ollama/src/embedding-provider.ts index 9471fae8bc17..1fd0521e6c60 100644 --- a/extensions/ollama/src/embedding-provider.ts +++ b/extensions/ollama/src/embedding-provider.ts @@ -6,7 +6,10 @@ import { normalizeOptionalSecretInput, } from "openclaw/plugin-sdk/provider-auth"; import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime"; -import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; +import { + readProviderJsonResponse, + readResponseTextLimited, +} from "openclaw/plugin-sdk/provider-http"; import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; import { hasConfiguredSecretInput, @@ -117,14 +120,9 @@ async function withRemoteHttpResponse(params: { } async function readOllamaEmbeddingJsonResponse( - response: Pick, + response: Response, ): Promise<{ embeddings?: unknown }> { - let payload: unknown; - try { - payload = await response.json(); - } catch (cause) { - throw new Error("Ollama embed response returned malformed JSON", { cause }); - } + const payload = await readProviderJsonResponse(response, "Ollama embed response"); if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { throw new Error("Ollama embed response returned a non-object JSON payload"); } diff --git a/extensions/ollama/src/provider-models.test.ts b/extensions/ollama/src/provider-models.test.ts index aeede6adedc2..387a0cf2e35d 100644 --- a/extensions/ollama/src/provider-models.test.ts +++ b/extensions/ollama/src/provider-models.test.ts @@ -5,7 +5,9 @@ import { buildOllamaProvider, buildOllamaModelDefinition, enrichOllamaModelsWithContext, + fetchOllamaModels, parseOllamaNumCtxParameter, + queryOllamaModelShowInfo, resetOllamaModelShowInfoCacheForTest, resolveOllamaApiBase, type OllamaTagModel, @@ -380,4 +382,57 @@ describe("ollama provider models", () => { expect(parseOllamaNumCtxParameter('stop "<|eot_id|>"')).toBeUndefined(); expect(parseOllamaNumCtxParameter({ num_ctx: 8192 })).toBeUndefined(); }); + + it("fails soft and stops reading when discovery streams exceed the JSON byte cap", async () => { + // Larger than the shared 16 MiB readProviderJsonResponse cap so the bounded reader cancels + // the stream mid-flight; if the cap were removed the reader would buffer the whole payload. + const ONE_MIB = 1024 * 1024; + const TOTAL_CHUNKS = 32; // 32 MiB advertised body, double the cap. + const chunk = new Uint8Array(ONE_MIB); + + let bytesPulled = 0; + let canceled = false; + const makeOversizedJsonResponse = (): Response => { + bytesPulled = 0; + canceled = false; + let pulled = 0; + const body = new ReadableStream({ + pull(controller) { + if (pulled >= TOTAL_CHUNKS) { + controller.close(); + return; + } + pulled += 1; + bytesPulled += chunk.length; + controller.enqueue(chunk); + }, + cancel() { + canceled = true; + }, + }); + return new Response(body, { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + vi.stubGlobal( + "fetch", + vi.fn(async () => makeOversizedJsonResponse()), + ); + const tags = await fetchOllamaModels("http://127.0.0.1:11434"); + expect(tags).toEqual({ reachable: false, models: [] }); + expect(canceled).toBe(true); + // Only the bounded prefix is pulled, never the full advertised 32 MiB stream. + expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB); + + vi.stubGlobal( + "fetch", + vi.fn(async () => makeOversizedJsonResponse()), + ); + const showInfo = await queryOllamaModelShowInfo("http://127.0.0.1:11434", "evil-model:latest"); + expect(showInfo).toEqual({}); + expect(canceled).toBe(true); + expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB); + }); }); diff --git a/extensions/ollama/src/provider-models.ts b/extensions/ollama/src/provider-models.ts index 2974033b940a..35f80ff03bb6 100644 --- a/extensions/ollama/src/provider-models.ts +++ b/extensions/ollama/src/provider-models.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-onboard"; +import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { OLLAMA_DEFAULT_BASE_URL, @@ -146,11 +147,11 @@ export async function queryOllamaModelShowInfo( if (!response.ok) { return {}; } - const data = (await response.json()) as { + const data = await readProviderJsonResponse<{ model_info?: Record; capabilities?: unknown; parameters?: unknown; - }; + }>(response, "ollama-provider-models.show"); let contextWindow: number | undefined; if (data.model_info) { @@ -314,7 +315,10 @@ export async function fetchOllamaModels( if (!response.ok) { return { reachable: true, models: [] }; } - const data = (await response.json()) as OllamaTagsResponse; + const data = await readProviderJsonResponse( + response, + "ollama-provider-models.tags", + ); const models = (data.models ?? []).filter((m) => m.name); return { reachable: true, models }; } finally { diff --git a/extensions/ollama/src/web-search-provider.test.ts b/extensions/ollama/src/web-search-provider.test.ts index 7b98109276dc..7e4dcc515780 100644 --- a/extensions/ollama/src/web-search-provider.test.ts +++ b/extensions/ollama/src/web-search-provider.test.ts @@ -1,6 +1,7 @@ // Ollama tests cover web search provider plugin behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createStreamingResponse } from "../../test-support/streaming-error-response.js"; import { createOllamaWebSearchProvider as createContractOllamaWebSearchProvider } from "../web-search-contract-api.js"; import { testing, @@ -403,7 +404,32 @@ describe("ollama web search provider", () => { config: createOllamaConfig(), query: "openclaw", }), - ).rejects.toThrow("Ollama web search returned malformed JSON"); + ).rejects.toThrow("Ollama web search: malformed JSON response"); + }); + + it("bounds successful Ollama web search JSON bodies before parsing", async () => { + const streamed = createStreamingResponse({ + chunkCount: 32, + chunkSize: 1024 * 1024, + text: "x", + headers: { "content-type": "application/json" }, + }); + const jsonSpy = vi.spyOn(streamed.response, "json").mockRejectedValue(new Error("unbounded")); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: streamed.response, + release: vi.fn(async () => {}), + }); + + await expect( + runOllamaWebSearch({ + config: createOllamaConfig(), + query: "openclaw", + }), + ).rejects.toThrow("Ollama web search: JSON response exceeds 16777216 bytes"); + + expect(streamed.getReadCount()).toBeLessThan(32); + expect(streamed.wasCanceled()).toBe(true); + expect(jsonSpy).not.toHaveBeenCalled(); }); it("warns when Ollama is not reachable during setup without cancelling", async () => { diff --git a/extensions/ollama/src/web-search-provider.ts b/extensions/ollama/src/web-search-provider.ts index 1ef4ca8be3a8..55f13b037b68 100644 --- a/extensions/ollama/src/web-search-provider.ts +++ b/extensions/ollama/src/web-search-provider.ts @@ -5,6 +5,7 @@ import { normalizeOptionalSecretInput, } from "openclaw/plugin-sdk/provider-auth"; import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime"; +import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; import { enablePluginInConfig, readPositiveIntegerParam, @@ -67,11 +68,7 @@ type OllamaWebSearchAttempt = { }; async function readOllamaWebSearchResponse(response: Response): Promise { - try { - return (await response.json()) as OllamaWebSearchResponse; - } catch (cause) { - throw new Error("Ollama web search returned malformed JSON", { cause }); - } + return await readProviderJsonResponse(response, "Ollama web search"); } function isOllamaCloudBaseUrl(baseUrl: string): boolean { diff --git a/extensions/open-prose/skills/prose/SKILL.md b/extensions/open-prose/skills/prose/SKILL.md index c6c2ed06d097..71b87d4eb6f8 100644 --- a/extensions/open-prose/skills/prose/SKILL.md +++ b/extensions/open-prose/skills/prose/SKILL.md @@ -89,13 +89,27 @@ prose run alice/code-review 2. Fetch the `.prose` content 3. Load the VM and execute as normal -This same resolution applies to `use` statements inside `.prose` files: +Top-level remote runs are explicit user requests. Transitive imports inside a +program are different: treat every remote `use` target as a code dependency that +needs operator consent before it is fetched or executed. + +This same resolution applies to `use` statements inside `.prose` files, but the +VM must fail closed until the operator approves the remote dependency list: ```prose use "https://example.com/my-program.prose" # Direct URL use "alice/research" as research # Registry shorthand ``` +When a program contains any remote `use` target (`http://`, `https://`, or +registry shorthand): + +1. Collect and display the exact resolved remote targets. +2. Explain that these are transitive code dependencies for this run. +3. Ask the operator to reply exactly `approve remote prose imports` to continue. +4. Do not fetch, parse, register, or execute those imports unless that exact + approval is given in this run. + --- ## File Locations diff --git a/extensions/open-prose/skills/prose/compiler.md b/extensions/open-prose/skills/prose/compiler.md index 1220fa745fff..25f7d39026b5 100644 --- a/extensions/open-prose/skills/prose/compiler.md +++ b/extensions/open-prose/skills/prose/compiler.md @@ -339,21 +339,24 @@ Please provide final recommendations. ## Use Statements (Program Composition) -Use statements import other OpenProse programs from the registry at `p.prose.md`, enabling modular workflows. +Use statements import other OpenProse programs from registry paths or direct +HTTP(S) URLs, enabling modular workflows. ### Syntax ```prose use "@handle/slug" use "@handle/slug" as alias +use "https://example.com/program.prose" as alias ``` ### Path Format -Import paths follow the format `@handle/slug`: +Import paths are either registry references or direct HTTP(S) URLs: -- `@handle` identifies the program author/organization -- `slug` is the program name +- `@handle/slug` identifies a program author/organization and slug. +- `handle/slug` resolves to the same registry host used by the runtime. +- `https://example.com/program.prose` fetches that exact URL after approval. An optional alias (`as name`) allows referencing by a shorter name. @@ -371,16 +374,20 @@ use "@bob/critique" as critic When the OpenProse VM encounters a `use` statement: -1. Fetch the program from `https://p.prose.md/@handle/slug` -2. Parse the program to extract its contract (inputs/outputs) -3. Register the program in the Import Registry +1. Resolve the import target. +2. If the target is remote (`http://`, `https://`, or registry shorthand), pause + before fetching and require the operator to approve the full remote import + list with `approve remote prose imports` for this run. +3. Fetch the program only after approval. +4. Parse the program to extract its contract (inputs/outputs). +5. Register the program in the Import Registry. ### Validation Rules | Check | Severity | Message | | --------------------- | -------- | -------------------------------------- | | Empty path | Error | Use path cannot be empty | -| Invalid path format | Error | Path must be @handle/slug format | +| Invalid path format | Error | Path must be registry path or URL | | Duplicate import | Error | Program already imported | | Missing alias for dup | Error | Alias required when importing multiple | @@ -388,9 +395,11 @@ When the OpenProse VM encounters a `use` statement: Use statements are processed before any agent definitions or sessions. The OpenProse VM: -1. Fetches and validates all imported programs at the start of execution -2. Extracts input/output contracts from each program -3. Registers programs in the Import Registry for later invocation +1. Resolves all imported program targets at the start of execution. +2. Requires operator approval before fetching any remote imports. +3. Fetches and validates approved imported programs. +4. Extracts input/output contracts from each program. +5. Registers programs in the Import Registry for later invocation. --- diff --git a/extensions/open-prose/skills/prose/guidance/system-prompt.md b/extensions/open-prose/skills/prose/guidance/system-prompt.md index 27df6980a87a..40ce644edd6e 100644 --- a/extensions/open-prose/skills/prose/guidance/system-prompt.md +++ b/extensions/open-prose/skills/prose/guidance/system-prompt.md @@ -162,8 +162,10 @@ For general programming tasks, please use a general-purpose agent instance. ## Execution Algorithm (Simplified) 1. Parse program structure (use statements, inputs, agents, blocks) -2. Bind inputs from caller or prompt user if missing -3. For each statement in order: +2. Resolve `use` imports. If any import is remote, require the operator to + approve the full list with `approve remote prose imports` before fetching. +3. Bind inputs from caller or prompt user if missing +4. For each statement in order: - `session` → Task tool call, await result - `resume` → Load memory, Task tool call, await result - `let/const` → Execute RHS, bind result @@ -172,8 +174,8 @@ For general programming tasks, please use a general-purpose agent instance. - `try/catch` → Execute try, catch on error, always finally - `choice/if` → Evaluate conditions, execute matching branch - `do block` → Push frame, bind args, execute body, pop frame -4. Collect output bindings -5. Return outputs to caller +5. Collect output bindings +6. Return outputs to caller ## Remember diff --git a/extensions/open-prose/skills/prose/prose.md b/extensions/open-prose/skills/prose/prose.md index 32412a64346d..a844ee19c30d 100644 --- a/extensions/open-prose/skills/prose/prose.md +++ b/extensions/open-prose/skills/prose/prose.md @@ -63,6 +63,13 @@ use "https://example.com/my-program.prose" # Direct URL use "alice/research" as research # Registry shorthand ``` +Top-level remote runs are explicit user requests. Remote `use` statements are +transitive code dependencies. Before fetching any remote `use` target, collect +the exact resolved targets, show them to the operator, and require the operator +to reply exactly `approve remote prose imports` for this run. If approval is not +given, abort the run before fetching, parsing, registering, or executing the +remote imports. + --- ## Why This Is a VM @@ -113,18 +120,18 @@ When you execute a `.prose` program, you ARE the virtual machine. This is not a Traditional dependency injection containers wire up components from configuration. You do the same—but with understanding: -| Declared Primitive | Your Responsibility | -| --------------------------- | ---------------------------------------------------------- | -| `use "handle/slug" as name` | Fetch program from p.prose.md, register in Import Registry | -| `input topic: "..."` | Bind value from caller, make available as variable | -| `output findings = ...` | Mark value as output, return to caller on completion | -| `agent researcher:` | Register this agent template for later use | -| `session: researcher` | Resolve the agent, merge properties, spawn the session | -| `resume: captain` | Load agent memory, spawn session with memory context | -| `context: { a, b }` | Wire the outputs of `a` and `b` into this session's input | -| `parallel:` branches | Coordinate concurrent execution, collect results | -| `block review(topic):` | Store this reusable component, invoke when called | -| `name(input: value)` | Invoke imported program with inputs, receive outputs | +| Declared Primitive | Your Responsibility | +| --------------------------- | ----------------------------------------------------------------------- | +| `use "handle/slug" as name` | Resolve import, require approval if remote, register in Import Registry | +| `input topic: "..."` | Bind value from caller, make available as variable | +| `output findings = ...` | Mark value as output, return to caller on completion | +| `agent researcher:` | Register this agent template for later use | +| `session: researcher` | Resolve the agent, merge properties, spawn the session | +| `resume: captain` | Load agent memory, spawn session with memory context | +| `context: { a, b }` | Wire the outputs of `a` and `b` into this session's input | +| `parallel:` branches | Coordinate concurrent execution, collect results | +| `block review(topic):` | Store this reusable component, invoke when called | +| `name(input: value)` | Invoke imported program with inputs, receive outputs | You are the container that holds these declarations and wires them together at runtime. The program declares _what_; you determine _how_ to connect them. @@ -698,7 +705,9 @@ Query the database to access the content. ## Program Composition -Programs can import and invoke other programs, enabling modular workflows. Programs are fetched from the registry at `p.prose.md`. +Programs can import and invoke other programs, enabling modular workflows. +Registry and direct-URL imports are remote code dependencies and require +operator approval before fetching. ### Importing Programs @@ -709,15 +718,20 @@ use "alice/research" use "bob/critique" as critic ``` -The import path follows the format `handle/slug`. An optional alias (`as name`) allows referencing by a shorter name. +The import path can be a registry reference (`handle/slug`) or a direct HTTP(S) +URL. An optional alias (`as name`) allows referencing by a shorter name. ### Program URL Resolution When the VM encounters a `use` statement: -1. Fetch the program from `https://p.prose.md/handle/slug` -2. Parse the program to extract its contract (inputs/outputs) -3. Register the program in the Import Registry +1. Resolve the import target. +2. If the target is remote (`http://`, `https://`, or registry shorthand), pause + before fetching and require the operator to approve the full remote import + list with `approve remote prose imports` for this run. +3. Fetch the program only after approval. +4. Parse the program to extract its contract (inputs/outputs). +5. Register the program in the Import Registry. ### Input Declarations @@ -1156,11 +1170,13 @@ Before spawning, substitute `{varname}` with variable values. ``` function execute(program, inputs?): - 1. Collect all use statements, fetch and register imports - 2. Collect all input declarations, bind values from caller - 3. Collect all agent definitions - 4. Collect all block definitions - 5. For each statement in order: + 1. Collect all use statements, resolve import targets + 2. If remote imports are present, require operator approval before fetch + 3. Fetch approved imports and register them + 4. Collect all input declarations, bind values from caller + 5. Collect all agent definitions + 6. Collect all block definitions + 7. For each statement in order: - If session: spawn via Task, await result - If resume: load memory, spawn via Task, await result - If let/const: execute RHS, bind result @@ -1219,7 +1235,7 @@ When passing context to sessions: The OpenProse VM: -1. **Imports** programs from `p.prose.md` via `use` statements +1. **Imports** approved programs via `use` statements 2. **Binds** inputs from caller to program variables 3. **Parses** the program structure 4. **Collects** definitions (agents, blocks) diff --git a/extensions/open-prose/skills/prose/state/in-context.md b/extensions/open-prose/skills/prose/state/in-context.md index 98f6a29017f4..437af789a5fc 100644 --- a/extensions/open-prose/skills/prose/state/in-context.md +++ b/extensions/open-prose/skills/prose/state/in-context.md @@ -210,6 +210,8 @@ For variable resolution across scopes: ``` [Import] Importing: @alice/research + Remote dependency requires approval: https://p.prose.md/@alice/research + Operator approved: approve remote prose imports Fetching from: https://p.prose.md/@alice/research Inputs expected: [topic, depth] Outputs provided: [findings, sources] diff --git a/extensions/openai/image-generation-provider.test.ts b/extensions/openai/image-generation-provider.test.ts index 6b965ede15d6..749fc7e0fadc 100644 --- a/extensions/openai/image-generation-provider.test.ts +++ b/extensions/openai/image-generation-provider.test.ts @@ -64,6 +64,8 @@ vi.mock("openclaw/plugin-sdk/provider-http", () => ({ assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock, postJsonRequest: postJsonRequestMock, postMultipartRequest: postMultipartRequestMock, + // Pass-through: bounded-reader enforcement is tested via bounded-reader unit tests. + readProviderJsonResponse: async (response: { json(): Promise }) => response.json(), resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock, sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock, })); diff --git a/extensions/openai/image-generation-provider.ts b/extensions/openai/image-generation-provider.ts index 778b1f8daa09..8921f5ca0302 100644 --- a/extensions/openai/image-generation-provider.ts +++ b/extensions/openai/image-generation-provider.ts @@ -8,11 +8,13 @@ import type { } from "openclaw/plugin-sdk/image-generation"; import { parseOpenAiCompatibleImageResponse, + resolveInlineImageJsonResponseMaxBytes, toImageDataUrl, } from "openclaw/plugin-sdk/image-generation"; import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core"; import { resolveClosestSize } from "openclaw/plugin-sdk/media-generation-runtime"; import { extensionForMime } from "openclaw/plugin-sdk/media-mime"; +import { MAX_IMAGE_BYTES } from "openclaw/plugin-sdk/media-runtime"; import { ensureAuthProfileStore, isProviderApiKeyConfigured, @@ -24,6 +26,7 @@ import { assertOkOrThrowHttpError, postJsonRequest, postMultipartRequest, + readProviderJsonResponse, resolveProviderHttpRequestConfig, sanitizeConfiguredModelProviderRequest, } from "openclaw/plugin-sdk/provider-http"; @@ -66,6 +69,7 @@ const MOCK_OPENAI_PROVIDER_ID = "mock-openai"; const OPENAI_OUTPUT_FORMATS = ["png", "jpeg", "webp"] as const; const OPENAI_BACKGROUNDS = ["transparent", "opaque", "auto"] as const; const OPENAI_QUALITIES = ["low", "medium", "high", "auto"] as const; +const MB = 1024 * 1024; const OPENAI_IMAGE_MODELS = [ DEFAULT_OPENAI_IMAGE_MODEL, OPENAI_TRANSPARENT_BACKGROUND_IMAGE_MODEL, @@ -120,6 +124,14 @@ function resolveOpenAIImageCount(count: number | undefined): number { return Math.max(1, Math.min(OPENAI_MAX_IMAGE_RESULTS, Math.trunc(count))); } +function resolveGeneratedImageMaxBytes(cfg: OpenClawConfig): number { + const configured = cfg.agents?.defaults?.mediaMaxMb; + if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) { + return Math.floor(configured * MB); + } + return MAX_IMAGE_BYTES; +} + function isPublicOpenAIImageBaseUrl(baseUrl: string): boolean { const trimmed = baseUrl.trim(); if (!trimmed) { @@ -1012,7 +1024,12 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider { isEdit ? "OpenAI image edit failed" : "OpenAI image generation failed", ); - const data = await response.json(); + const data = await readProviderJsonResponse(response, "openai.image-generation", { + maxBytes: resolveInlineImageJsonResponseMaxBytes( + count, + resolveGeneratedImageMaxBytes(req.cfg), + ), + }); const output = resolveOutputMime(req.outputFormat); const images = parseOpenAiCompatibleImageResponse(data, { defaultMimeType: output.mimeType, diff --git a/extensions/openai/index.test.ts b/extensions/openai/index.test.ts index 56ccd0edf3a7..3715aca3d130 100644 --- a/extensions/openai/index.test.ts +++ b/extensions/openai/index.test.ts @@ -86,6 +86,18 @@ function mockOpenAIImageApiResponse(params: { imageData: string; revisedPrompt?: string; }) { + const response = () => + new Response( + JSON.stringify({ + data: [ + { + b64_json: Buffer.from(params.imageData).toString("base64"), + ...(params.revisedPrompt ? { revised_prompt: params.revisedPrompt } : {}), + }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); const resolveApiKeySpy = vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ apiKey: "sk-test", source: "env", @@ -93,32 +105,12 @@ function mockOpenAIImageApiResponse(params: { }); const postJsonRequestSpy = vi.spyOn(providerHttp, "postJsonRequest").mockResolvedValue({ finalUrl: params.finalUrl, - response: { - ok: true, - json: async () => ({ - data: [ - { - b64_json: Buffer.from(params.imageData).toString("base64"), - ...(params.revisedPrompt ? { revised_prompt: params.revisedPrompt } : {}), - }, - ], - }), - } as Response, + response: response(), release: vi.fn(async () => {}), }); const postMultipartRequestSpy = vi.spyOn(providerHttp, "postMultipartRequest").mockResolvedValue({ finalUrl: params.finalUrl, - response: { - ok: true, - json: async () => ({ - data: [ - { - b64_json: Buffer.from(params.imageData).toString("base64"), - ...(params.revisedPrompt ? { revised_prompt: params.revisedPrompt } : {}), - }, - ], - }), - } as Response, + response: response(), release: vi.fn(async () => {}), }); vi.spyOn(providerHttp, "assertOkOrThrowHttpError").mockResolvedValue(undefined); diff --git a/extensions/openrouter/image-generation-provider.test.ts b/extensions/openrouter/image-generation-provider.test.ts index 2dc0aa022c8c..63a16b0e234c 100644 --- a/extensions/openrouter/image-generation-provider.test.ts +++ b/extensions/openrouter/image-generation-provider.test.ts @@ -31,6 +31,8 @@ vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({ vi.mock("openclaw/plugin-sdk/provider-http", () => ({ assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock, postJsonRequest: postJsonRequestMock, + // Pass-through: bounded-reader enforcement is tested via bounded-reader unit tests. + readProviderJsonResponse: async (response: { json(): Promise }) => response.json(), resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock, })); diff --git a/extensions/openrouter/image-generation-provider.ts b/extensions/openrouter/image-generation-provider.ts index a8c320b305c7..7a8e4909b5ab 100644 --- a/extensions/openrouter/image-generation-provider.ts +++ b/extensions/openrouter/image-generation-provider.ts @@ -7,13 +7,16 @@ import type { import { generatedImageAssetFromBase64, generatedImageAssetFromDataUrl, + resolveInlineImageJsonResponseMaxBytes, toImageDataUrl, } from "openclaw/plugin-sdk/image-generation"; +import { MAX_IMAGE_BYTES } from "openclaw/plugin-sdk/media-runtime"; import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth"; import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime"; import { assertOkOrThrowHttpError, postJsonRequest, + readProviderJsonResponse, resolveProviderHttpRequestConfig, } from "openclaw/plugin-sdk/provider-http"; import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -22,6 +25,7 @@ import { OPENROUTER_BASE_URL } from "./provider-catalog.js"; const DEFAULT_MODEL = "google/gemini-3.1-flash-image-preview"; const DEFAULT_TIMEOUT_MS = 180_000; const MAX_IMAGE_RESULTS = 4; +const MB = 1024 * 1024; const SUPPORTED_MODELS = [ DEFAULT_MODEL, "google/gemini-3-pro-image-preview", @@ -213,6 +217,16 @@ function resolveImageCount(count: number | undefined): number { return Math.max(1, Math.min(MAX_IMAGE_RESULTS, Math.trunc(count))); } +function resolveGeneratedImageMaxBytes(req: { + cfg: { agents?: { defaults?: { mediaMaxMb?: number } } }; +}): number { + const configured = req.cfg.agents?.defaults?.mediaMaxMb; + if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) { + return Math.floor(configured * MB); + } + return MAX_IMAGE_BYTES; +} + function isGeminiImageModel(model: string): boolean { return model.startsWith("google/gemini-"); } @@ -307,6 +321,7 @@ export function buildOpenRouterImageGenerationProvider(): ImageGenerationProvide transport: "http", }); + const count = resolveImageCount(req.count); const { response, release } = await postJsonRequest({ url: `${baseUrl}/chat/completions`, headers, @@ -314,7 +329,7 @@ export function buildOpenRouterImageGenerationProvider(): ImageGenerationProvide model, messages: [{ role: "user", content: buildMessageContent(req) }], modalities: ["image", "text"], - n: resolveImageCount(req.count), + n: count, ...(Object.keys(imageConfig).length > 0 ? { image_config: imageConfig } : {}), }, timeoutMs: req.timeoutMs ?? DEFAULT_TIMEOUT_MS, @@ -326,7 +341,12 @@ export function buildOpenRouterImageGenerationProvider(): ImageGenerationProvide try { await assertOkOrThrowHttpError(response, "OpenRouter image generation failed"); - const payload = await response.json(); + const payload = await readProviderJsonResponse(response, "openrouter.image-generation", { + maxBytes: resolveInlineImageJsonResponseMaxBytes( + count, + resolveGeneratedImageMaxBytes(req), + ), + }); const images = extractOpenRouterImagesFromResponse(payload, { malformedResponseError: OPENROUTER_IMAGE_MALFORMED_RESPONSE, }); diff --git a/extensions/openrouter/media-understanding-provider.test.ts b/extensions/openrouter/media-understanding-provider.test.ts index 1b49047b6ce3..25a926b60408 100644 --- a/extensions/openrouter/media-understanding-provider.test.ts +++ b/extensions/openrouter/media-understanding-provider.test.ts @@ -24,6 +24,8 @@ const { assertOkOrThrowHttpErrorMock, postJsonRequestMock, resolveProviderHttpRe vi.mock("openclaw/plugin-sdk/provider-http", () => ({ assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock, postJsonRequest: postJsonRequestMock, + // Pass-through: bounded-reader enforcement is tested via bounded-reader unit tests. + readProviderJsonResponse: async (response: { json(): Promise }) => response.json(), requireTranscriptionText: (value: string | undefined, message: string) => { const text = value?.trim(); if (!text) { diff --git a/extensions/openrouter/media-understanding-provider.ts b/extensions/openrouter/media-understanding-provider.ts index ffdabd9c96a0..c57cab6f8e48 100644 --- a/extensions/openrouter/media-understanding-provider.ts +++ b/extensions/openrouter/media-understanding-provider.ts @@ -10,6 +10,7 @@ import { import { assertOkOrThrowHttpError, postJsonRequest, + readProviderJsonResponse, requireTranscriptionText, resolveProviderHttpRequestConfig, } from "openclaw/plugin-sdk/provider-http"; @@ -148,7 +149,10 @@ export async function transcribeOpenRouterAudio( try { await assertOkOrThrowHttpError(response, "OpenRouter audio transcription failed"); - const payload = (await response.json()) as OpenRouterSttResponse; + const payload = await readProviderJsonResponse( + response, + "openrouter.stt", + ); return { text: requireTranscriptionText( payload.text, diff --git a/extensions/openrouter/video-generation-provider.test.ts b/extensions/openrouter/video-generation-provider.test.ts index 3a512e612dad..4ade52de4f7a 100644 --- a/extensions/openrouter/video-generation-provider.test.ts +++ b/extensions/openrouter/video-generation-provider.test.ts @@ -54,13 +54,34 @@ vi.mock("openclaw/plugin-sdk/provider-http", async () => { function releasedJson(value: unknown) { return { - response: { - json: async () => value, - }, + response: new Response(JSON.stringify(value), { + status: 200, + headers: { "content-type": "application/json" }, + }), release: vi.fn(async () => {}), }; } +function releasedOversizedJsonStream() { + let canceled = false; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(16 * 1024 * 1024 + 1)); + }, + cancel() { + canceled = true; + }, + }); + return { + response: new Response(stream, { + status: 200, + headers: { "content-type": "application/json" }, + }), + release: vi.fn(async () => {}), + wasCanceled: () => canceled, + }; +} + function releasedVideo(params: { contentType: string; bytes: string }) { return { response: new Response(Buffer.from(params.bytes), { @@ -292,6 +313,40 @@ describe("openrouter video generation provider", () => { }); }); + it("cancels oversized OpenRouter video catalog success bodies", async () => { + const oversized = releasedOversizedJsonStream(); + fetchWithTimeoutGuardedMock.mockResolvedValueOnce(oversized); + + await expect( + listOpenRouterVideoModelCatalog({ + config: { + models: { + providers: { + openrouter: { + baseUrl: "https://custom.openrouter.test/openrouter/api/v1", + }, + }, + }, + } as never, + env: {}, + resolveProviderApiKey: () => ({ + apiKey: "OPENROUTER_API_KEY", + discoveryApiKey: "resolved-openrouter-key", + }), + resolveProviderAuth: () => ({ + apiKey: "OPENROUTER_API_KEY", + discoveryApiKey: "resolved-openrouter-key", + mode: "api_key", + source: "env", + }), + }), + ).rejects.toThrow( + "OpenRouter video models request failed: JSON response exceeds 16777216 bytes", + ); + expect(oversized.wasCanceled()).toBe(true); + expect(oversized.release).toHaveBeenCalledOnce(); + }); + it("skips live OpenRouter video catalog discovery without an API key", async () => { await expect( listOpenRouterVideoModelCatalog({ diff --git a/extensions/openrouter/video-model-catalog.ts b/extensions/openrouter/video-model-catalog.ts index 0a8a14736204..74fe3172e505 100644 --- a/extensions/openrouter/video-model-catalog.ts +++ b/extensions/openrouter/video-model-catalog.ts @@ -7,6 +7,7 @@ import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runt import { getCachedLiveCatalogValue } from "openclaw/plugin-sdk/provider-catalog-shared"; import { assertOkOrThrowHttpError, + readProviderJsonResponse, resolveProviderHttpRequestConfig, } from "openclaw/plugin-sdk/provider-http"; import { @@ -234,7 +235,10 @@ async function fetchOpenRouterVideoModels(params: { }); try { await assertOkOrThrowHttpError(response, "OpenRouter video models request failed"); - return (await response.json()) as OpenRouterVideoModelsResponse; + return await readProviderJsonResponse( + response, + "OpenRouter video models request failed", + ); } finally { await release(); } diff --git a/extensions/openshell/src/backend.exec-workdir.test.ts b/extensions/openshell/src/backend.exec-workdir.test.ts new file mode 100644 index 000000000000..949fc6d6f01f --- /dev/null +++ b/extensions/openshell/src/backend.exec-workdir.test.ts @@ -0,0 +1,167 @@ +// Openshell tests cover backend-owned exec workdir validation behavior. +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { CreateSandboxBackendParams } from "openclaw/plugin-sdk/sandbox"; +import { + createSandboxBrowserConfig, + createSandboxPruneConfig, + createSandboxSshConfig, +} from "openclaw/plugin-sdk/test-fixtures"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createOpenShellSandboxBackendFactory } from "./backend.js"; +import { resolveOpenShellPluginConfig } from "./config.js"; + +const sdkMocks = vi.hoisted(() => ({ + runSshSandboxCommand: vi.fn(), + disposeSshSandboxSession: vi.fn(), +})); + +const cliMocks = vi.hoisted(() => ({ + runOpenShellCli: vi.fn(), + createOpenShellSshSession: vi.fn(), +})); + +vi.mock("openclaw/plugin-sdk/sandbox", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runSshSandboxCommand: sdkMocks.runSshSandboxCommand, + disposeSshSandboxSession: sdkMocks.disposeSshSandboxSession, + }; +}); + +vi.mock("./cli.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runOpenShellCli: cliMocks.runOpenShellCli, + createOpenShellSshSession: cliMocks.createOpenShellSshSession, + }; +}); + +const tempDirs: string[] = []; + +function createOpenShellBackendSandboxConfig(): CreateSandboxBackendParams["cfg"] { + return { + mode: "all", + backend: "openshell", + scope: "session", + workspaceAccess: "rw", + workspaceRoot: "/tmp/openclaw-sandboxes", + docker: { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + network: "none", + capDrop: [], + binds: [], + env: {}, + }, + ssh: createSandboxSshConfig("/tmp/openclaw-sandboxes"), + browser: createSandboxBrowserConfig(), + tools: { allow: ["*"], deny: [] }, + prune: createSandboxPruneConfig(), + }; +} + +async function makeTempDir(prefix: string) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +describe("openshell backend exec workdir validation", () => { + beforeEach(() => { + vi.clearAllMocks(); + cliMocks.createOpenShellSshSession.mockResolvedValue({ + command: "ssh", + configPath: "/tmp/openclaw-openshell-test-ssh-config", + host: "openshell-test", + }); + cliMocks.runOpenShellCli.mockResolvedValue({ + code: 0, + stdout: "", + stderr: "", + }); + sdkMocks.runSshSandboxCommand.mockImplementation(async ({ remoteCommand }) => ({ + stdout: String(remoteCommand).includes("openclaw-validate-workdir") + ? Buffer.from("/workspace\n") + : Buffer.alloc(0), + stderr: Buffer.alloc(0), + code: 0, + })); + }); + + afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); + }); + + it("reuses validation-time workspace preparation for the following exec", async () => { + const workspaceDir = await makeTempDir("openclaw-openshell-workspace-"); + await fs.writeFile(path.join(workspaceDir, "seed.txt"), "seed", "utf8"); + const backendFactory = createOpenShellSandboxBackendFactory({ + pluginConfig: resolveOpenShellPluginConfig({ + command: "openshell", + mode: "mirror", + }), + }); + const backend = await backendFactory({ + sessionKey: "agent:main:turn", + scopeKey: "agent:main", + workspaceDir, + agentWorkspaceDir: workspaceDir, + cfg: createOpenShellBackendSandboxConfig(), + }); + + await expect(backend.validateWorkdir?.("/workspace")).resolves.toBe("/workspace"); + const execSpec = await backend.buildExecSpec({ + command: "pwd", + workdir: "/workspace", + env: {}, + usePty: false, + }); + + const uploadCalls = cliMocks.runOpenShellCli.mock.calls.filter( + ([params]) => params.args[0] === "sandbox" && params.args[1] === "upload", + ); + expect(uploadCalls).toHaveLength(1); + expect(execSpec.argv).toContain("openshell-test"); + }); + + it("does not reuse validation-time workspace preparation after discard", async () => { + const workspaceDir = await makeTempDir("openclaw-openshell-workspace-"); + await fs.writeFile(path.join(workspaceDir, "seed.txt"), "seed", "utf8"); + const backendFactory = createOpenShellSandboxBackendFactory({ + pluginConfig: resolveOpenShellPluginConfig({ + command: "openshell", + mode: "mirror", + }), + }); + const backend = await backendFactory({ + sessionKey: "agent:main:turn", + scopeKey: "agent:main", + workspaceDir, + agentWorkspaceDir: workspaceDir, + cfg: createOpenShellBackendSandboxConfig(), + }); + + await expect(backend.validateWorkdir?.("/workspace")).resolves.toBe("/workspace"); + backend.discardPreparedWorkdir?.("/workspace"); + await backend.buildExecSpec({ + command: "pwd", + workdir: "/workspace", + env: {}, + usePty: false, + }); + + const uploadCalls = cliMocks.runOpenShellCli.mock.calls.filter( + ([params]) => params.args[0] === "sandbox" && params.args[1] === "upload", + ); + expect(uploadCalls).toHaveLength(2); + }); +}); diff --git a/extensions/openshell/src/backend.ts b/extensions/openshell/src/backend.ts index cb8bd1de7be8..6fed53738b48 100644 --- a/extensions/openshell/src/backend.ts +++ b/extensions/openshell/src/backend.ts @@ -22,6 +22,7 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coer import type { OpenShellSandboxBackend } from "./backend.types.js"; import { buildValidatedExecRemoteCommand, + buildRemoteWorkdirValidationCommand, buildRemoteCommand, createOpenShellSshSession, runOpenShellCli, @@ -280,6 +281,13 @@ async function createOpenShellSandboxBackend(params: { mode: params.pluginConfig.mode, configLabel: params.pluginConfig.from, configLabelKind: "Source", + workdirValidation: "backend", + validateWorkdir: async (workdir) => await impl.validateWorkdir(workdir), + discardPreparedWorkdir: (workdir) => impl.discardPreparedWorkdir(workdir), + workdirRoots: [ + params.pluginConfig.remoteWorkspaceDir, + params.pluginConfig.remoteAgentWorkspaceDir, + ], buildExecSpec: async ({ command, workdir, env, usePty }) => { const pending = await impl.prepareExec({ command, workdir, env, usePty }); return { @@ -318,6 +326,10 @@ async function createOpenShellSandboxBackend(params: { class OpenShellSandboxBackendImpl { private ensurePromise: Promise | null = null; + private preparedRemoteWorkspaceForNextExec: { + workdir: string; + promise: Promise; + } | null = null; private remoteSeedPending = false; constructor( @@ -339,6 +351,10 @@ class OpenShellSandboxBackendImpl { mode: this.params.execContext.config.mode, configLabel: this.params.execContext.config.from, configLabelKind: "Source", + workdirValidation: "backend", + validateWorkdir: async (workdir) => await this.validateWorkdir(workdir), + discardPreparedWorkdir: (workdir) => this.discardPreparedWorkdir(workdir), + workdirRoots: [this.params.remoteWorkspaceDir, this.params.remoteAgentWorkspaceDir], remoteWorkspaceDir: this.params.remoteWorkspaceDir, remoteAgentWorkspaceDir: this.params.remoteAgentWorkspaceDir, buildExecSpec: async ({ command, workdir, env, usePty }) => { @@ -382,20 +398,14 @@ class OpenShellSandboxBackendImpl { env: Record; usePty: boolean; }): Promise<{ argv: string[]; token: PendingExec }> { + const remoteWorkdir = params.workdir ?? this.params.remoteWorkspaceDir; + const preparedWorkspace = this.consumePreparedRemoteWorkspaceForNextExec(remoteWorkdir); const remoteCommand = buildValidatedExecRemoteCommand({ command: params.command, - workdir: params.workdir ?? this.params.remoteWorkspaceDir, + workdir: remoteWorkdir, env: params.env, }); - await this.ensureSandboxExists(); - if (this.params.execContext.config.mode === "mirror") { - await this.syncWorkspaceToRemote(); - } else { - const seeded = await this.maybeSeedRemoteWorkspace(); - if (!seeded) { - await this.syncSkillsWorkspaceToRemote(); - } - } + await (preparedWorkspace ?? this.prepareRemoteWorkspaceForExec()); const sshSession = await createOpenShellSshSession({ context: this.params.execContext, }); @@ -414,6 +424,85 @@ class OpenShellSandboxBackendImpl { }; } + async validateWorkdir(workdir: string): Promise { + const preparedWorkspace = this.prepareRemoteWorkspaceForExec(); + const reusablePreparation = { workdir, promise: preparedWorkspace }; + this.preparedRemoteWorkspaceForNextExec = reusablePreparation; + try { + await preparedWorkspace; + const sshSession = await createOpenShellSshSession({ + context: this.params.execContext, + }); + try { + const result = await runSshSandboxCommand({ + session: sshSession, + remoteCommand: buildRemoteWorkdirValidationCommand({ + workdir, + root: this.resolveWorkdirValidationRoot(workdir), + }), + allowFailure: true, + }); + const resolvedWorkdir = result.code === 0 ? result.stdout.toString("utf8").trim() : ""; + if (this.preparedRemoteWorkspaceForNextExec === reusablePreparation) { + this.preparedRemoteWorkspaceForNextExec = resolvedWorkdir + ? { workdir: resolvedWorkdir, promise: preparedWorkspace } + : null; + } + return resolvedWorkdir || null; + } finally { + await disposeSshSandboxSession(sshSession); + } + } catch (error) { + if (this.preparedRemoteWorkspaceForNextExec === reusablePreparation) { + this.preparedRemoteWorkspaceForNextExec = null; + } + throw error; + } + } + + private resolveWorkdirValidationRoot(workdir: string): string { + try { + const normalized = normalizeRemotePath(workdir); + const roots = [ + normalizeRemotePath(this.params.remoteAgentWorkspaceDir), + normalizeRemotePath(this.params.remoteWorkspaceDir), + ].toSorted((a, b) => b.length - a.length); + return ( + roots.find((root) => isRemotePathInside(root, normalized)) ?? this.params.remoteWorkspaceDir + ); + } catch { + return this.params.remoteWorkspaceDir; + } + } + + private consumePreparedRemoteWorkspaceForNextExec(workdir: string): Promise | null { + const preparedWorkspace = this.preparedRemoteWorkspaceForNextExec; + if (!preparedWorkspace || preparedWorkspace.workdir !== workdir) { + this.preparedRemoteWorkspaceForNextExec = null; + return null; + } + this.preparedRemoteWorkspaceForNextExec = null; + return preparedWorkspace.promise; + } + + discardPreparedWorkdir(workdir: string): void { + if (this.preparedRemoteWorkspaceForNextExec?.workdir === workdir) { + this.preparedRemoteWorkspaceForNextExec = null; + } + } + + private async prepareRemoteWorkspaceForExec(): Promise { + await this.ensureSandboxExists(); + if (this.params.execContext.config.mode === "mirror") { + await this.syncWorkspaceToRemote(); + return; + } + const seeded = await this.maybeSeedRemoteWorkspace(); + if (!seeded) { + await this.syncSkillsWorkspaceToRemote(); + } + } + async finalizeExec(token?: PendingExec): Promise { try { if (this.params.execContext.config.mode === "mirror") { diff --git a/extensions/openshell/src/cli.ts b/extensions/openshell/src/cli.ts index 4e3fe0a4f67c..da42b76d63bf 100644 --- a/extensions/openshell/src/cli.ts +++ b/extensions/openshell/src/cli.ts @@ -9,6 +9,7 @@ import type { ResolvedOpenShellPluginConfig } from "./config.js"; export { buildExecRemoteCommand, + buildRemoteWorkdirValidationCommand, buildValidatedExecRemoteCommand, shellEscape, } from "openclaw/plugin-sdk/sandbox"; diff --git a/extensions/openshell/src/fs-bridge.ts b/extensions/openshell/src/fs-bridge.ts index 5fbfa3d08897..bb00af6912c5 100644 --- a/extensions/openshell/src/fs-bridge.ts +++ b/extensions/openshell/src/fs-bridge.ts @@ -8,9 +8,8 @@ import type { SandboxResolvedPath, } from "openclaw/plugin-sdk/sandbox"; import { createWritableRenameTargetResolver } from "openclaw/plugin-sdk/sandbox"; -import { isPathInside } from "openclaw/plugin-sdk/security-runtime"; +import { FsSafeError, isPathInside } from "openclaw/plugin-sdk/security-runtime"; import type { OpenShellFsBridgeContext, OpenShellSandboxBackend } from "./backend.types.js"; -import { movePathWithCopyFallback } from "./mirror.js"; type ResolvedMountPath = SandboxResolvedPath & { mountHostRoot: string; @@ -18,6 +17,9 @@ type ResolvedMountPath = SandboxResolvedPath & { source: "workspace" | "agent" | "protectedSkill"; }; +type FsSafeRoot = Awaited>; +type FsSafeStat = Awaited>; + const MATERIALIZED_SKILLS_CONTAINER_PARTS = [".openclaw", "sandbox-skills", "skills"] as const; export function createOpenShellFsBridge(params: { @@ -117,7 +119,7 @@ class OpenShellFsBridge implements SandboxFsBridge { allowFinalSymlinkForUnlink: false, }); await this.backend.mkdirpRemotePath(target.containerPath, params.signal); - await fsPromises.mkdir(hostPath, { recursive: true }); + await mkdirLocalRootPath({ hostPath, target }); } async remove(params: { @@ -141,9 +143,11 @@ class OpenShellFsBridge implements SandboxFsBridge { signal: params.signal, ignoreMissing: params.force !== false, }); - await fsPromises.rm(hostPath, { - recursive: params.recursive ?? false, - force: params.force !== false, + await removeLocalRootPath({ + force: params.force, + hostPath, + recursive: params.recursive, + target, }); } @@ -168,9 +172,17 @@ class OpenShellFsBridge implements SandboxFsBridge { allowMissingLeaf: true, allowFinalSymlinkForUnlink: false, }); + await assertRenameSourceSupported(fromHostPath); + if (from.mountHostRoot !== to.mountHostRoot) { + throw new Error("OpenShell cross-root mirror renames require pinned fs-safe support"); + } + await assertSameDeviceRenameSupported({ + fromHostPath, + root: from.mountHostRoot, + toHostPath, + }); await this.backend.renameRemotePath(from.containerPath, to.containerPath, params.signal); - await fsPromises.mkdir(path.dirname(toHostPath), { recursive: true }); - await movePathWithCopyFallback({ from: fromHostPath, to: toHostPath }); + await moveLocalRootPath({ from, fromHostPath, to, toHostPath }); } async stat(params: { @@ -343,6 +355,162 @@ class OpenShellFsBridge implements SandboxFsBridge { } } +async function mkdirLocalRootPath(params: { + target: ResolvedMountPath; + hostPath: string; +}): Promise { + const relativePath = relativeToRoot(params.target, params.hostPath); + if (!relativePath) { + return; + } + const root = await fsRoot(params.target.mountHostRoot); + await root.mkdir(relativePath); +} + +async function removeLocalRootPath(params: { + target: ResolvedMountPath; + hostPath: string; + recursive?: boolean; + force?: boolean; +}): Promise { + const root = await fsRoot(params.target.mountHostRoot); + const relativePath = relativeToRoot(params.target, params.hostPath); + try { + if (params.force === false) { + await fsPromises.lstat(params.hostPath); + } + if (params.recursive) { + const stats = await fsPromises.lstat(params.hostPath).catch((err: unknown) => { + if (isNotFoundError(err)) { + return null; + } + throw err; + }); + if (stats?.isSymbolicLink()) { + await root.remove(relativePath); + return; + } + await removeRootTree(root, relativePath); + return; + } + await root.remove(relativePath); + } catch (err) { + if (params.force !== false && isNotFoundError(err)) { + return; + } + throw err; + } +} + +async function removeRootTree( + root: FsSafeRoot, + relativePath: string, + knownStats?: FsSafeStat, +): Promise { + const stats = knownStats ?? (await root.stat(relativePath)); + if (stats.isDirectory && !stats.isSymbolicLink) { + const entries = await root.list(relativePath, { withFileTypes: true }); + for (const entry of entries) { + await removeRootTree(root, path.join(relativePath, entry.name), entry); + } + if (!relativePath) { + return; + } + } + await root.remove(relativePath); +} + +async function moveLocalRootPath(params: { + from: ResolvedMountPath; + fromHostPath: string; + to: ResolvedMountPath; + toHostPath: string; +}): Promise { + const root = await fsRoot(params.from.mountHostRoot); + const fromRelativePath = relativeToRoot(params.from, params.fromHostPath); + const toRelativePath = relativeToRoot(params.to, params.toHostPath); + await mkdirParentPath(root, toRelativePath); + await root.move(fromRelativePath, toRelativePath, { overwrite: true }); +} + +async function mkdirParentPath(root: FsSafeRoot, relativePath: string): Promise { + const parentPath = path.dirname(relativePath); + if (parentPath === "." || parentPath === "") { + return; + } + await root.mkdir(parentPath); +} + +function relativeToRoot(target: ResolvedMountPath, hostPath: string): string { + const relativePath = path.relative(target.mountHostRoot, hostPath); + return relativePath === "." ? "" : relativePath; +} + +async function assertRenameSourceSupported(fromHostPath: string): Promise { + const stats = await fsPromises.lstat(fromHostPath); + if (stats.isSymbolicLink()) { + throw new Error("Sandbox symlink rename sources are not supported by the local mirror bridge"); + } + if (stats.isFile() && stats.nlink > 1) { + throw new Error( + "Sandbox hardlinked rename sources are not supported by the local mirror bridge", + ); + } +} + +async function assertSameDeviceRenameSupported(params: { + fromHostPath: string; + root: string; + toHostPath: string; +}): Promise { + const sourceStats = await fsPromises.lstat(params.fromHostPath); + const destinationParentStats = await nearestExistingDirectoryStats({ + root: params.root, + targetPath: path.dirname(params.toHostPath), + }); + if (sourceStats.dev !== destinationParentStats.dev) { + throw new Error("OpenShell cross-device mirror renames require pinned fs-safe support"); + } +} + +async function nearestExistingDirectoryStats(params: { + root: string; + targetPath: string; +}): Promise>> { + const rootPath = path.resolve(params.root); + let cursor = path.resolve(params.targetPath); + while (isPathInside(rootPath, cursor)) { + const stats = await fsPromises.lstat(cursor).catch((err: unknown) => { + if (isNotFoundError(err)) { + return null; + } + throw err; + }); + if (stats) { + if (!stats.isDirectory()) { + throw new Error(`Sandbox rename destination parent is not a directory: ${cursor}`); + } + return stats; + } + const next = path.dirname(cursor); + if (next === cursor) { + break; + } + cursor = next; + } + return await fsPromises.lstat(rootPath); +} + +function isNotFoundError(err: unknown): boolean { + return ( + (err instanceof FsSafeError && err.code === "not-found") || + (typeof err === "object" && + err !== null && + "code" in err && + (err as { code?: unknown }).code === "ENOENT") + ); +} + function resolveProtectedSkillTarget(params: { input: string; skillsRoot: string; @@ -421,7 +589,11 @@ async function assertLocalPathSafety(params: { const canonicalRoot = await fsPromises .realpath(params.root) .catch(() => path.resolve(params.root)); - const candidate = await resolveCanonicalCandidate(params.target.hostPath); + const targetStats = await fsPromises.lstat(params.target.hostPath).catch(() => null); + const candidate = + params.allowFinalSymlinkForUnlink && targetStats?.isSymbolicLink() + ? path.resolve(canonicalRoot, path.relative(params.root, params.target.hostPath)) + : await resolveCanonicalCandidate(params.target.hostPath); if (!isPathInside(canonicalRoot, candidate)) { throw new Error( `Sandbox path escapes allowed mounts; cannot access: ${params.target.containerPath}`, diff --git a/extensions/openshell/src/openshell-core.test.ts b/extensions/openshell/src/openshell-core.test.ts index 3e791af75a08..2c6faacf761d 100644 --- a/extensions/openshell/src/openshell-core.test.ts +++ b/extensions/openshell/src/openshell-core.test.ts @@ -733,6 +733,90 @@ describe("openshell fs bridges", () => { expect(backend["runRemoteShellScript"]).not.toHaveBeenCalled(); }); + it("rejects cross-root mirror renames before the remote backend commit", async () => { + const workspaceDir = await makeTempDir("openclaw-openshell-fs-"); + const agentWorkspaceDir = await makeTempDir("openclaw-openshell-agent-fs-"); + const sourcePath = path.join(workspaceDir, "source.txt"); + await fs.writeFile(sourcePath, "payload", "utf8"); + const backend = createMirrorBackendMock(); + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir, + containerWorkdir: "/sandbox", + }, + }); + + const { createOpenShellFsBridge } = await import("./fs-bridge.js"); + const bridge = createOpenShellFsBridge({ sandbox, backend }); + + await expect(bridge.rename({ from: "source.txt", to: "/agent/source.txt" })).rejects.toThrow( + "OpenShell cross-root mirror renames require pinned fs-safe support", + ); + expect(backend["renameRemotePath"]).not.toHaveBeenCalled(); + await expect(fs.readFile(sourcePath, "utf8")).resolves.toBe("payload"); + await expectPathMissing(path.join(agentWorkspaceDir, "source.txt")); + await expect(fs.readdir(agentWorkspaceDir)).resolves.toStrictEqual([]); + }); + + it.runIf(process.platform !== "win32")( + "rejects local mirror symlink rename sources before the remote backend commit", + async () => { + const workspaceDir = await makeTempDir("openclaw-openshell-fs-"); + await fs.writeFile(path.join(workspaceDir, "target.txt"), "payload", "utf8"); + await fs.symlink("target.txt", path.join(workspaceDir, "link.txt")); + const backend = createMirrorBackendMock(); + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir: workspaceDir, + containerWorkdir: "/sandbox", + }, + }); + + const { createOpenShellFsBridge } = await import("./fs-bridge.js"); + const bridge = createOpenShellFsBridge({ sandbox, backend }); + + await expect(bridge.rename({ from: "link.txt", to: "moved-link.txt" })).rejects.toThrow( + "Sandbox symlink rename sources are not supported", + ); + expect(backend["renameRemotePath"]).not.toHaveBeenCalled(); + await expect(fs.readlink(path.join(workspaceDir, "link.txt"))).resolves.toBe("target.txt"); + await expectPathMissing(path.join(workspaceDir, "moved-link.txt")); + }, + ); + + it.runIf(process.platform !== "win32")( + "rejects local mirror hardlinked rename sources before the remote backend commit", + async () => { + const workspaceDir = await makeTempDir("openclaw-openshell-fs-"); + const sourcePath = path.join(workspaceDir, "source.txt"); + await fs.writeFile(sourcePath, "payload", "utf8"); + await fs.link(sourcePath, path.join(workspaceDir, "other-link.txt")); + const backend = createMirrorBackendMock(); + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir: workspaceDir, + containerWorkdir: "/sandbox", + }, + }); + + const { createOpenShellFsBridge } = await import("./fs-bridge.js"); + const bridge = createOpenShellFsBridge({ sandbox, backend }); + + await expect(bridge.rename({ from: "source.txt", to: "moved.txt" })).rejects.toThrow( + "Sandbox hardlinked rename sources are not supported", + ); + expect(backend["renameRemotePath"]).not.toHaveBeenCalled(); + await expect(fs.readFile(sourcePath, "utf8")).resolves.toBe("payload"); + await expectPathMissing(path.join(workspaceDir, "moved.txt")); + }, + ); + it("removes remote mirror paths through the pinned backend operation", async () => { const workspaceDir = await makeTempDir("openclaw-openshell-fs-"); await fs.writeFile(path.join(workspaceDir, "target.txt"), "payload", "utf8"); @@ -759,6 +843,187 @@ describe("openshell fs bridges", () => { expect(backend["runRemoteShellScript"]).not.toHaveBeenCalled(); }); + it("removes recursive local mirror directories without raw path deletion", async () => { + const workspaceDir = await makeTempDir("openclaw-openshell-fs-"); + await fs.mkdir(path.join(workspaceDir, "nested", "child"), { recursive: true }); + await fs.writeFile(path.join(workspaceDir, "nested", "child", "target.txt"), "payload", "utf8"); + const backend = createMirrorBackendMock(); + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir: workspaceDir, + containerWorkdir: "/sandbox", + }, + }); + + const { createOpenShellFsBridge } = await import("./fs-bridge.js"); + const bridge = createOpenShellFsBridge({ sandbox, backend }); + await bridge.remove({ filePath: "nested", recursive: true, force: true }); + + await expectPathMissing(path.join(workspaceDir, "nested")); + expect(backend["removeRemotePath"]).toHaveBeenCalledWith("/sandbox/nested", { + recursive: true, + signal: undefined, + ignoreMissing: true, + }); + }); + + it.runIf(process.platform !== "win32")( + "removes recursive local mirror directories containing symlink leaves without following them", + async () => { + const workspaceDir = await makeTempDir("openclaw-openshell-fs-"); + const outsideDir = await makeTempDir("openclaw-openshell-outside-"); + const outsideTarget = path.join(outsideDir, "target.txt"); + await fs.mkdir(path.join(workspaceDir, "nested"), { recursive: true }); + await fs.writeFile(outsideTarget, "outside", "utf8"); + await fs.symlink(outsideTarget, path.join(workspaceDir, "nested", "link.txt")); + const backend = createMirrorBackendMock(); + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir: workspaceDir, + containerWorkdir: "/sandbox", + }, + }); + + const { createOpenShellFsBridge } = await import("./fs-bridge.js"); + const bridge = createOpenShellFsBridge({ sandbox, backend }); + await bridge.remove({ filePath: "nested", recursive: true, force: true }); + + await expectPathMissing(path.join(workspaceDir, "nested")); + await expect(fs.readFile(outsideTarget, "utf8")).resolves.toBe("outside"); + }, + ); + + it.runIf(process.platform !== "win32")( + "removes local mirror symlink leaves when force is false", + async () => { + const workspaceDir = await makeTempDir("openclaw-openshell-fs-"); + const outsideDir = await makeTempDir("openclaw-openshell-outside-"); + const outsideTarget = path.join(outsideDir, "target.txt"); + await fs.writeFile(outsideTarget, "outside", "utf8"); + await fs.symlink(outsideTarget, path.join(workspaceDir, "link.txt")); + const backend = createMirrorBackendMock(); + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir: workspaceDir, + containerWorkdir: "/sandbox", + }, + }); + + const { createOpenShellFsBridge } = await import("./fs-bridge.js"); + const bridge = createOpenShellFsBridge({ sandbox, backend }); + await bridge.remove({ filePath: "link.txt", force: false }); + + await expectPathMissing(path.join(workspaceDir, "link.txt")); + await expect(fs.readFile(outsideTarget, "utf8")).resolves.toBe("outside"); + expect(backend["removeRemotePath"]).toHaveBeenCalledWith("/sandbox/link.txt", { + recursive: false, + signal: undefined, + ignoreMissing: false, + }); + }, + ); + + it.runIf(process.platform !== "win32")( + "rejects local mirror mkdir when a validated parent is swapped to an outside symlink", + async () => { + const workspaceDir = await makeTempDir("openclaw-openshell-fs-"); + const outsideDir = await makeTempDir("openclaw-openshell-outside-"); + const slotPath = path.join(workspaceDir, "slot"); + await fs.mkdir(slotPath, { recursive: true }); + const backend = createMirrorBackendMock(); + backend["mkdirpRemotePath"] = vi.fn().mockImplementation(async () => { + await fs.rm(slotPath, { recursive: true, force: true }); + await fs.symlink(outsideDir, slotPath); + }); + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir: workspaceDir, + containerWorkdir: "/sandbox", + }, + }); + + const { createOpenShellFsBridge } = await import("./fs-bridge.js"); + const bridge = createOpenShellFsBridge({ sandbox, backend }); + + await expect(bridge.mkdirp({ filePath: "slot/escaped" })).rejects.toThrow(); + await expectPathMissing(path.join(outsideDir, "escaped")); + }, + ); + + it.runIf(process.platform !== "win32")( + "rejects local mirror remove when a validated parent is swapped to an outside symlink", + async () => { + const workspaceDir = await makeTempDir("openclaw-openshell-fs-"); + const outsideDir = await makeTempDir("openclaw-openshell-outside-"); + const slotPath = path.join(workspaceDir, "slot"); + const outsideTarget = path.join(outsideDir, "target.txt"); + await fs.mkdir(slotPath, { recursive: true }); + await fs.writeFile(path.join(slotPath, "target.txt"), "inside", "utf8"); + await fs.writeFile(outsideTarget, "outside", "utf8"); + const backend = createMirrorBackendMock(); + backend["removeRemotePath"] = vi.fn().mockImplementation(async () => { + await fs.rm(slotPath, { recursive: true, force: true }); + await fs.symlink(outsideDir, slotPath); + }); + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir: workspaceDir, + containerWorkdir: "/sandbox", + }, + }); + + const { createOpenShellFsBridge } = await import("./fs-bridge.js"); + const bridge = createOpenShellFsBridge({ sandbox, backend }); + + await expect(bridge.remove({ filePath: "slot/target.txt", force: true })).rejects.toThrow(); + await expect(fs.readFile(outsideTarget, "utf8")).resolves.toBe("outside"); + }, + ); + + it.runIf(process.platform !== "win32")( + "rejects local mirror rename when a validated destination parent is swapped to an outside symlink", + async () => { + const workspaceDir = await makeTempDir("openclaw-openshell-fs-"); + const outsideDir = await makeTempDir("openclaw-openshell-outside-"); + const slotPath = path.join(workspaceDir, "slot"); + const sourcePath = path.join(workspaceDir, "source.txt"); + await fs.mkdir(slotPath, { recursive: true }); + await fs.writeFile(sourcePath, "payload", "utf8"); + const backend = createMirrorBackendMock(); + backend["renameRemotePath"] = vi.fn().mockImplementation(async () => { + await fs.rm(slotPath, { recursive: true, force: true }); + await fs.symlink(outsideDir, slotPath); + }); + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir: workspaceDir, + containerWorkdir: "/sandbox", + }, + }); + + const { createOpenShellFsBridge } = await import("./fs-bridge.js"); + const bridge = createOpenShellFsBridge({ sandbox, backend }); + + await expect( + bridge.rename({ from: "source.txt", to: "slot/parent/moved.txt" }), + ).rejects.toThrow(); + await expect(fs.readFile(sourcePath, "utf8")).resolves.toBe("payload"); + await expectPathMissing(path.join(outsideDir, "parent", "moved.txt")); + }, + ); + it("keeps local mirror state unchanged when remote pinned mkdir is rejected", async () => { const workspaceDir = await makeTempDir("openclaw-openshell-fs-"); const backend = createMirrorBackendMock(); diff --git a/extensions/parallel/src/parallel-mcp-search.runtime.test.ts b/extensions/parallel/src/parallel-mcp-search.runtime.test.ts index e76e37ba8c78..597eb952f247 100644 --- a/extensions/parallel/src/parallel-mcp-search.runtime.test.ts +++ b/extensions/parallel/src/parallel-mcp-search.runtime.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createStreamingResponse } from "../../test-support/streaming-error-response.js"; type EndpointCall = { url: string; @@ -311,4 +312,27 @@ describe("runParallelMcpSearch", () => { expect(tracked.wasCanceled()).toBe(true); expect(textSpy).not.toHaveBeenCalled(); }); + + it("bounds successful MCP bodies without using response.text()", async () => { + const streamed = createStreamingResponse({ + chunkCount: 32, + chunkSize: 1024 * 1024, + text: "x", + headers: { "Content-Type": "application/json" }, + }); + const textSpy = vi.spyOn(streamed.response, "text").mockRejectedValue(new Error("unbounded")); + endpointMockState.responses.push(streamed.response); + + const error = await runParallelMcpSearch({ searchQueries: ["x"], maxResults: 5 }).catch( + (cause: unknown) => cause, + ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain( + "Parallel MCP: text response exceeds 16777216 bytes", + ); + expect(streamed.getReadCount()).toBeLessThan(32); + expect(streamed.wasCanceled()).toBe(true); + expect(textSpy).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/parallel/src/parallel-mcp-search.runtime.ts b/extensions/parallel/src/parallel-mcp-search.runtime.ts index 0b0031f3a09a..91153dd074a8 100644 --- a/extensions/parallel/src/parallel-mcp-search.runtime.ts +++ b/extensions/parallel/src/parallel-mcp-search.runtime.ts @@ -1,7 +1,10 @@ import { randomUUID } from "node:crypto"; import { createRequire } from "node:module"; import { readPluginPackageVersion } from "openclaw/plugin-sdk/extension-shared"; -import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; +import { + readProviderTextResponse, + readResponseTextLimited, +} from "openclaw/plugin-sdk/provider-http"; import { withTrustedWebSearchEndpoint } from "openclaw/plugin-sdk/provider-web-search"; // Free hosted Search MCP. This keyless transport is used only after the user @@ -218,7 +221,7 @@ async function postMcp(params: { status: response.status, statusText: response.statusText, text: response.ok - ? await response.text() + ? await readProviderTextResponse(response, "Parallel MCP") : await readResponseTextLimited(response, PARALLEL_MCP_ERROR_BODY_LIMIT_BYTES), sessionIdHeader: response.headers.get("mcp-session-id"), }), diff --git a/extensions/parallel/src/parallel-web-search-provider.runtime.ts b/extensions/parallel/src/parallel-web-search-provider.runtime.ts index b55f2bc334ca..b3122794c053 100644 --- a/extensions/parallel/src/parallel-web-search-provider.runtime.ts +++ b/extensions/parallel/src/parallel-web-search-provider.runtime.ts @@ -1,6 +1,9 @@ import { createRequire } from "node:module"; import { readPluginPackageVersion } from "openclaw/plugin-sdk/extension-shared"; -import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; +import { + readProviderJsonResponse, + readResponseTextLimited, +} from "openclaw/plugin-sdk/provider-http"; import { DEFAULT_SEARCH_COUNT, mergeScopedSearchConfig, @@ -36,6 +39,12 @@ import { const PARALLEL_BASE_URL = "https://api.parallel.ai"; const PARALLEL_SEARCH_PATHNAME = "/v1/search"; const PARALLEL_ERROR_BODY_LIMIT_BYTES = 8 * 1024; +// Parallel's /v1/search returns a bounded result set, but the body is external +// (web-search upstream) and untrusted. Cap the successful JSON read so a +// hostile or malfunctioning endpoint streaming an unbounded body cannot force +// the runtime to buffer the whole payload before parsing. 16 MiB matches the +// shared provider JSON cap (readProviderJsonResponse default). +const PARALLEL_SEARCH_RESPONSE_LIMIT_BYTES = 16 * 1024 * 1024; const require = createRequire(import.meta.url); const PLUGIN_VERSION = readPluginPackageVersion({ require }); @@ -151,11 +160,9 @@ async function runParallelSearch(params: { ); throw new Error(`Parallel API error (${res.status}): ${detail || res.statusText}`); } - try { - return (await res.json()) as ParallelSearchResponse; - } catch (cause) { - throw new Error("Parallel API returned malformed JSON", { cause }); - } + return await readProviderJsonResponse(res, "Parallel API", { + maxBytes: PARALLEL_SEARCH_RESPONSE_LIMIT_BYTES, + }); }, ); } @@ -282,6 +289,7 @@ export const testing = { resolveParallelSearchCount, resolveParallelSearchEndpoint, PARALLEL_ERROR_BODY_LIMIT_BYTES, + PARALLEL_SEARCH_RESPONSE_LIMIT_BYTES, USER_AGENT, } as const; diff --git a/extensions/parallel/src/parallel-web-search-provider.test.ts b/extensions/parallel/src/parallel-web-search-provider.test.ts index c9d7fbe00453..3f19400e1024 100644 --- a/extensions/parallel/src/parallel-web-search-provider.test.ts +++ b/extensions/parallel/src/parallel-web-search-provider.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createStreamingResponse } from "../../test-support/streaming-error-response.js"; type EndpointCall = { url: string; @@ -583,6 +584,70 @@ describe("parallel web search provider", () => { expect(textSpy).not.toHaveBeenCalled(); }); + it("bounds successful Parallel JSON bodies instead of buffering the whole response", async () => { + // 200-chunk x 1 MiB body (~200 MiB) caps at 16 MiB: the bounded reader must + // stop pulling chunks and cancel the stream well before draining it, then + // surface a bounded error rather than buffering the whole payload. + const streamed = createStreamingResponse({ + chunkCount: 200, + chunkSize: 1024 * 1024, + text: "a", + headers: { "Content-Type": "application/json" }, + }); + endpointMockState.responses.push(streamed.response); + const provider = createParallelWebSearchProvider(); + const tool = provider.createTool({ + config: {}, + searchConfig: { parallel: { apiKey: "par-secret" } }, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + + const error = await tool + .execute({ + objective: `parallel-success-body-${Date.now()}-${Math.random()}`, + search_queries: ["openclaw"], + }) + .catch((cause: unknown) => cause); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch( + new RegExp( + `Parallel API: JSON response exceeds ${testing.PARALLEL_SEARCH_RESPONSE_LIMIT_BYTES} bytes`, + ), + ); + // Stopped well before draining all 200 chunks, and cancelled the stream. + expect(streamed.getReadCount()).toBeLessThan(200); + expect(streamed.wasCanceled()).toBe(true); + }); + + it("parses a well-formed Parallel JSON body under the byte cap", async () => { + endpointMockState.responses.push( + new Response( + JSON.stringify({ + search_id: "ok", + session_id: "ok-session", + results: [{ url: "https://example.com/a", title: "A", excerpts: ["alpha"] }], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + const provider = createParallelWebSearchProvider(); + const tool = provider.createTool({ + config: {}, + searchConfig: { parallel: { apiKey: "par-secret" } }, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + const result = (await tool.execute({ + objective: `parallel-success-ok-${Date.now()}-${Math.random()}`, + search_queries: ["openclaw"], + })) as { provider?: string; searchId?: string; count?: number }; + expect(result).toMatchObject({ provider: "parallel", searchId: "ok", count: 1 }); + }); + it("does not surface a Parallel-generated sessionId on a cache hit", async () => { // Unique objective so this test does not collide with the SDK's // module-level web-search cache across other cases. diff --git a/extensions/perplexity/src/perplexity-web-search-provider.runtime.ts b/extensions/perplexity/src/perplexity-web-search-provider.runtime.ts index 2fe4de817fa0..0d7d056138b6 100644 --- a/extensions/perplexity/src/perplexity-web-search-provider.runtime.ts +++ b/extensions/perplexity/src/perplexity-web-search-provider.runtime.ts @@ -1,3 +1,4 @@ +import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; // Perplexity provider module implements model/runtime integration. import { readPositiveIntegerParam, @@ -142,11 +143,7 @@ function buildPerplexityRequestHeaders(apiKey: string, acceptJson = false): Reco } async function readPerplexityJsonResponse(response: Response, label: string): Promise { - try { - return (await response.json()) as T; - } catch (cause) { - throw new Error(`${label}: malformed JSON response`, { cause }); - } + return await readProviderJsonResponse(response, label); } function resolvePerplexityTransport(perplexity?: PerplexityConfig): { diff --git a/extensions/perplexity/src/perplexity-web-search-provider.test.ts b/extensions/perplexity/src/perplexity-web-search-provider.test.ts index 52ebd6bd85ca..8998f9f60b7f 100644 --- a/extensions/perplexity/src/perplexity-web-search-provider.test.ts +++ b/extensions/perplexity/src/perplexity-web-search-provider.test.ts @@ -1,6 +1,7 @@ // Perplexity tests cover perplexity web search provider plugin behavior. import { withEnv, withEnvAsync } from "openclaw/plugin-sdk/test-env"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { createStreamingResponse } from "../../test-support/streaming-error-response.js"; import { createPerplexityWebSearchProvider } from "./perplexity-web-search-provider.js"; import { testing } from "./perplexity-web-search-provider.runtime.js"; @@ -171,4 +172,22 @@ describe("perplexity web search provider", () => { testing.readPerplexityJsonResponse(new Response("{ nope"), "Perplexity"), ).rejects.toThrow("Perplexity: malformed JSON response"); }); + + it("bounds successful Perplexity JSON bodies before parsing", async () => { + const streamed = createStreamingResponse({ + chunkCount: 32, + chunkSize: 1024 * 1024, + text: "x", + headers: { "content-type": "application/json" }, + }); + const jsonSpy = vi.spyOn(streamed.response, "json").mockRejectedValue(new Error("unbounded")); + + await expect( + testing.readPerplexityJsonResponse(streamed.response, "Perplexity Search"), + ).rejects.toThrow("Perplexity Search: JSON response exceeds 16777216 bytes"); + + expect(streamed.getReadCount()).toBeLessThan(32); + expect(streamed.wasCanceled()).toBe(true); + expect(jsonSpy).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/qa-lab/src/cli.runtime.test.ts b/extensions/qa-lab/src/cli.runtime.test.ts index 9d31914843db..bf8698c2f0d3 100644 --- a/extensions/qa-lab/src/cli.runtime.test.ts +++ b/extensions/qa-lab/src/cli.runtime.test.ts @@ -462,10 +462,10 @@ describe("qa cli runtime", () => { profile?: unknown; scorecard?: { run?: { evidenceEntryCount?: unknown }; - features?: { fulfilled?: unknown }; + coverageIds?: { fulfilled?: unknown }; categoryReports?: Array<{ id?: unknown; - features?: { fulfilled?: unknown }; + coverageIds?: { fulfilled?: unknown }; missingCoverageIds?: unknown; }>; }; @@ -480,11 +480,11 @@ describe("qa cli runtime", () => { expect(evidence.scorecard).not.toHaveProperty("kind"); expect(evidence.scorecard).not.toHaveProperty("taxonomy"); expect(evidence.scorecard).not.toHaveProperty("profile"); - expect(evidence.scorecard?.features?.fulfilled).toBe(0); + expect(evidence.scorecard?.coverageIds?.fulfilled).toBe(1); expect(evidence.scorecard?.categoryReports?.[0]).toMatchObject({ id: "channel-framework.conversation-routing-and-delivery", - features: { - fulfilled: 0, + coverageIds: { + fulfilled: 1, }, }); expect(evidence.entries?.[0]).not.toHaveProperty("execution"); @@ -558,6 +558,8 @@ describe("qa cli runtime", () => { "qa-channel-reconnect-dedupe", "reaction-edit-delete", "thread-follow-up", + "claude-cli-provider-capabilities", + "claude-cli-provider-capabilities-subscription", "image-generation-roundtrip", "image-understanding-attachment", "native-image-generation", diff --git a/extensions/qa-lab/src/coverage-report.test.ts b/extensions/qa-lab/src/coverage-report.test.ts index 948b9ac1f275..bb6ae4e87ec7 100644 --- a/extensions/qa-lab/src/coverage-report.test.ts +++ b/extensions/qa-lab/src/coverage-report.test.ts @@ -182,9 +182,9 @@ describe("qa coverage report", () => { expect(inventory.scorecardTaxonomy.requiredCategoryCount).toBeLessThanOrEqual( inventory.scorecardTaxonomy.categoryCount, ); - expect(inventory.scorecardTaxonomy.requiredFeatureCount).toBeGreaterThan(0); - expect(inventory.scorecardTaxonomy.fulfilledFeatureCount).toBeGreaterThan(0); - expect(inventory.scorecardTaxonomy.taxonomyFulfillmentPercent).toBeGreaterThan(0); + expect(inventory.scorecardTaxonomy.requiredCoverageIdCount).toBeGreaterThan(0); + expect(inventory.scorecardTaxonomy.fulfilledCoverageIdCount).toBeGreaterThan(0); + expect(inventory.scorecardTaxonomy.coverageIdFulfillmentPercent).toBeGreaterThan(0); expect(inventory.scorecardTaxonomy.evidenceRefCount).toBeGreaterThan(0); expect(inventory.scorecardTaxonomy.scenarioCoverageIdCount).toBeGreaterThan(0); expect(inventory.scorecardTaxonomy.unknownCoverageIdCount).toBe(0); @@ -259,7 +259,7 @@ describe("qa coverage report", () => { expect(report).toContain("## Scorecard Taxonomy"); expect(report).toContain("- Taxonomy: taxonomy.yaml"); expect(report).toContain("- Fulfilled taxonomy categories:"); - expect(report).toContain("- Fulfilled taxonomy features:"); + expect(report).toContain("- Fulfilled taxonomy coverage IDs:"); expect(report).toContain("- Evidence refs:"); expect(report).toContain("- Scenario coverage IDs:"); expect(report).toContain( @@ -347,7 +347,7 @@ describe("qa coverage report", () => { ], }); - expect(report.fulfilledFeatureCount).toBe(0); + expect(report.fulfilledCoverageIdCount).toBe(0); expect(report.categories[0]?.coverageStatus).toBe("missing"); expect(report.validationIssues.map((issue) => issue.code)).toEqual([ "coverage-id-not-found", @@ -375,7 +375,7 @@ describe("qa coverage report", () => { expect(report.validationIssues).toStrictEqual([]); expect(report.fulfilledCategoryCount).toBe(1); - expect(report.fulfilledFeatureCount).toBe(1); + expect(report.fulfilledCoverageIdCount).toBe(1); expect(report.categories[0]?.coverageStatus).toBe("covered"); expect(report.categories[0]?.scenarioRefs).toStrictEqual([ "qa/scenarios/ui/control-ui-chat-flow-playwright.yaml", @@ -391,7 +391,7 @@ describe("qa coverage report", () => { ]); }); - it("requires every coverage ID on a taxonomy feature to have primary evidence", () => { + it("counts partial coverage IDs proportionately for taxonomy fulfillment", () => { const report = buildQaScorecardTaxonomyReport({ taxonomy: testMaturityTaxonomy({ featureCoverageIds: [[TEST_EXECUTABLE_COVERAGE_ID, TEST_WEBCHAT_COVERAGE_ID]], @@ -407,7 +407,9 @@ describe("qa coverage report", () => { }); expect(report.fulfilledCategoryCount).toBe(0); - expect(report.fulfilledFeatureCount).toBe(0); + expect(report.requiredCoverageIdCount).toBe(2); + expect(report.fulfilledCoverageIdCount).toBe(1); + expect(report.coverageIdFulfillmentPercent).toBe(50); expect(report.categories[0]?.coverageStatus).toBe("partial"); expect(report.categories[0]?.fulfilledCoverageIds).toStrictEqual([TEST_EXECUTABLE_COVERAGE_ID]); expect(report.validationIssues).toContainEqual( @@ -418,6 +420,75 @@ describe("qa coverage report", () => { ); }); + it("counts each required taxonomy coverage ID once across categories", () => { + const taxonomy: QaMaturityTaxonomy = { + ...testMaturityTaxonomy(), + profiles: [ + { + id: "release", + description: "Test release profile.", + includeAllCategories: false, + channelDriver: "qa-channel", + categoryIds: [ + "agent-runtime-and-provider-execution.agent-turn-execution", + "agent-runtime-and-provider-execution.tool-execution-controls", + ], + }, + ], + surfaces: [ + { + id: "agent-runtime-and-provider-execution", + name: "Agent Runtime", + family: "test", + level: "experimental", + categories: [ + { + id: "agent-turn-execution", + name: "Agent Turn Execution", + category_note: "agent-turn-execution.md", + docs: [], + search_anchors: [], + features: [ + { + name: "shared plus unique", + coverageIds: [TEST_EXECUTABLE_COVERAGE_ID, TEST_WEBCHAT_COVERAGE_ID], + }, + ], + }, + { + id: "tool-execution-controls", + name: "Tool Execution Controls", + category_note: "tool-execution-controls.md", + docs: [], + search_anchors: [], + features: [ + { + name: "shared", + coverageIds: [TEST_EXECUTABLE_COVERAGE_ID], + }, + ], + }, + ], + }, + ], + }; + const report = buildQaScorecardTaxonomyReport({ + taxonomy, + repoRoot: process.cwd(), + scenarios: [ + scenarioWithCoverage({ + primary: [TEST_EXECUTABLE_COVERAGE_ID], + secondary: [TEST_WEBCHAT_COVERAGE_ID], + sourcePath: "qa/scenarios/channels/dm-chat-baseline.yaml", + }), + ], + }); + + expect(report.requiredCoverageIdCount).toBe(2); + expect(report.fulfilledCoverageIdCount).toBe(1); + expect(report.coverageIdFulfillmentPercent).toBe(50); + }); + it("uses script producer evidence as coverage fulfillment", () => { const report = buildQaScorecardTaxonomyReport({ taxonomy: testMaturityTaxonomy({ @@ -437,7 +508,7 @@ describe("qa coverage report", () => { expect(report.validationIssues).toStrictEqual([]); expect(report.fulfilledCategoryCount).toBe(1); - expect(report.fulfilledFeatureCount).toBe(1); + expect(report.fulfilledCoverageIdCount).toBe(1); expect(report.categories[0]?.evidence).toStrictEqual([ { coverageId: TEST_BROWSER_COVERAGE_ID, @@ -555,7 +626,7 @@ describe("qa coverage report", () => { ], }); - expect(report.fulfilledFeatureCount).toBe(0); + expect(report.fulfilledCoverageIdCount).toBe(0); expect(report.categories[0]?.coverageStatus).toBe("partial"); expect(report.validationIssues.map((issue) => issue.code)).toEqual([ "coverage-id-not-found", diff --git a/extensions/qa-lab/src/coverage-report.ts b/extensions/qa-lab/src/coverage-report.ts index 03cdbf03d537..0f58157b5302 100644 --- a/extensions/qa-lab/src/coverage-report.ts +++ b/extensions/qa-lab/src/coverage-report.ts @@ -331,7 +331,7 @@ function pushScorecardTaxonomyLines(lines: string[], report: QaScorecardTaxonomy `- Fulfilled taxonomy categories: ${report.fulfilledCategoryCount}/${report.requiredCategoryCount} (${report.categoryFulfillmentPercent}%)`, ); lines.push( - `- Fulfilled taxonomy features: ${report.fulfilledFeatureCount}/${report.requiredFeatureCount} (${report.taxonomyFulfillmentPercent}%)`, + `- Fulfilled taxonomy coverage IDs: ${report.fulfilledCoverageIdCount}/${report.requiredCoverageIdCount} (${report.coverageIdFulfillmentPercent}%)`, ); lines.push(`- Evidence refs: ${report.evidenceRefCount}`); lines.push(`- Scenario coverage IDs: ${report.scenarioCoverageIdCount}`); diff --git a/extensions/qa-lab/src/evidence-summary.test.ts b/extensions/qa-lab/src/evidence-summary.test.ts index 21b9e98ef878..e55371abd39b 100644 --- a/extensions/qa-lab/src/evidence-summary.test.ts +++ b/extensions/qa-lab/src/evidence-summary.test.ts @@ -1,4 +1,5 @@ // Qa Lab tests cover QA evidence summary behavior. +import { execFileSync } from "node:child_process"; import { describe, expect, it } from "vitest"; import { QA_EVIDENCE_SUMMARY_KIND, @@ -123,6 +124,29 @@ describe("evidence summary", () => { }); }); + it("prefers the checked-out ref over an inherited GitHub event SHA", () => { + const repoRoot = process.cwd(); + const checkedOutRef = execFileSync("git", ["rev-parse", "--verify", "HEAD"], { + cwd: repoRoot, + encoding: "utf8", + }).trim(); + const evidence = buildQaSuiteEvidenceSummary({ + artifactPaths: [], + channelId: "qa-channel", + env: { + GITHUB_SHA: "bd479958c04a1eadbda8b6105e0722588d71e9ad", + } as NodeJS.ProcessEnv, + generatedAt: "2026-06-24T12:00:00.000Z", + primaryModel: "mock-openai/gpt-5.5", + providerMode: "mock-openai", + repoRoot, + scenarioDefinitions: [{ id: "ref-probe", title: "Ref probe" }], + scenarioResults: [{ name: "Ref probe", status: "pass" }], + }); + + expect(evidence.entries[0]?.execution?.environment.ref).toBe(checkedOutRef); + }); + it("builds Telegram live transport evidence entries", () => { const evidence = buildLiveTransportEvidenceSummary({ artifactPaths: [ diff --git a/extensions/qa-lab/src/evidence-summary.ts b/extensions/qa-lab/src/evidence-summary.ts index 9212af798577..35fa6b2ccf29 100644 --- a/extensions/qa-lab/src/evidence-summary.ts +++ b/extensions/qa-lab/src/evidence-summary.ts @@ -1,4 +1,5 @@ // Qa Lab plugin module implements QA evidence summary behavior. +import { execFileSync } from "node:child_process"; import { z } from "zod"; import { splitQaModelRef } from "./model-selection.js"; import { getQaProvider, type QaProviderMode } from "./providers/index.js"; @@ -116,15 +117,18 @@ const qaEvidenceScorecardCountSchema = z }) .strict(); +const qaEvidenceScorecardCoverageCountSchema = qaEvidenceScorecardCountSchema.extend({ + secondaryOnly: z.number().int().nonnegative(), +}); + const qaEvidenceScorecardCategorySchema = z .object({ id: nonEmptyStringSchema, surfaceId: nonEmptyStringSchema, name: nonEmptyStringSchema, status: z.enum(["fulfilled", "partial", "missing"]), - features: qaEvidenceScorecardCountSchema.extend({ - secondaryOnly: z.number().int().nonnegative(), - }), + features: qaEvidenceScorecardCountSchema, + coverageIds: qaEvidenceScorecardCoverageCountSchema, missingCoverageIds: z.array(nonEmptyStringSchema), }) .strict(); @@ -144,6 +148,7 @@ const qaEvidenceScorecardSchema = z .strict(), categories: qaEvidenceScorecardCountSchema, features: qaEvidenceScorecardCountSchema, + coverageIds: qaEvidenceScorecardCountSchema, categoryReports: z.array(qaEvidenceScorecardCategorySchema), }) .strict(); @@ -288,6 +293,7 @@ type QaEvidenceBuildBase = { channelDriver?: string; packageSource?: QaEvidencePackageSource; profile?: QaEvidenceProfile; + repoRoot?: string; runner?: string; }; @@ -388,9 +394,31 @@ function resolveQaEvidenceChannelDriver(params: { env?: NodeJS.ProcessEnv; fallb return id ? { id } : undefined; } -function resolveQaEvidenceEnvironment(env: NodeJS.ProcessEnv | undefined) { +function resolveQaEvidenceCheckoutRef(repoRoot?: string) { + try { + const ref = execFileSync("git", ["rev-parse", "--verify", "HEAD"], { + cwd: repoRoot ?? process.cwd(), + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return ref || undefined; + } catch { + return undefined; + } +} + +export function resolveQaEvidenceEnvironment(params: { + env?: NodeJS.ProcessEnv; + repoRoot?: string; +}) { return { - ref: env?.OPENCLAW_QA_REF?.trim() || env?.GITHUB_SHA?.trim() || null, + // GitHub's GITHUB_SHA describes the workflow event, not necessarily the + // checked-out ref selected by a manual or remote QA run. + ref: + params.env?.OPENCLAW_QA_REF?.trim() || + resolveQaEvidenceCheckoutRef(params.repoRoot) || + params.env?.GITHUB_SHA?.trim() || + null, os: process.platform, nodeVersion: process.version, }; @@ -550,7 +578,10 @@ export function buildQaSuiteEvidenceSummary( }, ): QaEvidenceSummaryJson { const provider = buildQaEvidenceProvider(params); - const environment = resolveQaEvidenceEnvironment(params.env); + const environment = resolveQaEvidenceEnvironment({ + env: params.env, + repoRoot: params.repoRoot, + }); const packageSource = resolveQaEvidenceBuildPackageSource(params); const runner = resolveQaEvidenceRunner({ env: params.env, fallback: params.runner }); const profile = resolveQaEvidenceProfile({ @@ -622,7 +653,10 @@ function buildTestRunnerEvidenceSummary( }, ): QaEvidenceSummaryJson { const provider = buildQaEvidenceProvider(params); - const environment = resolveQaEvidenceEnvironment(params.env); + const environment = resolveQaEvidenceEnvironment({ + env: params.env, + repoRoot: params.repoRoot, + }); const packageSource = resolveQaEvidenceBuildPackageSource(params); const runner = resolveQaEvidenceRunner({ env: params.env, @@ -726,7 +760,10 @@ export function buildLiveTransportEvidenceSummary( }, ): QaEvidenceSummaryJson { const provider = buildQaEvidenceProvider(params); - const environment = resolveQaEvidenceEnvironment(params.env); + const environment = resolveQaEvidenceEnvironment({ + env: params.env, + repoRoot: params.repoRoot, + }); const packageSource = resolveQaEvidenceBuildPackageSource(params); const runner = resolveQaEvidenceRunner({ env: params.env, fallback: params.runner }); const profile = resolveQaEvidenceProfile({ diff --git a/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts b/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts index ae374a4d8d85..b2db1f191ff2 100644 --- a/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts +++ b/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts @@ -1863,6 +1863,7 @@ export async function runDiscordQaLive(params: { generatedAt: finishedAt, primaryModel, providerMode, + repoRoot, transportId: "discord", }); await fs.writeFile( diff --git a/extensions/qa-lab/src/live-transports/slack/slack-live.runtime.ts b/extensions/qa-lab/src/live-transports/slack/slack-live.runtime.ts index 27562013a486..233b0da20bfe 100644 --- a/extensions/qa-lab/src/live-transports/slack/slack-live.runtime.ts +++ b/extensions/qa-lab/src/live-transports/slack/slack-live.runtime.ts @@ -2037,6 +2037,7 @@ export async function runSlackQaLive(params: { generatedAt: finishedAt, primaryModel, providerMode, + repoRoot, transportId: "slack", }); await fs.writeFile( diff --git a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts index 58f936f1164f..88f96bdea8a1 100644 --- a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts +++ b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts @@ -2188,6 +2188,7 @@ export async function runTelegramQaLive(params: { generatedAt: finishedAt, primaryModel, providerMode, + repoRoot, checks: scenarioResults, transportId: "telegram", }); diff --git a/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.ts b/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.ts index 1fdf0799116e..ce7c9ab2519d 100644 --- a/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.ts +++ b/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.ts @@ -3282,6 +3282,7 @@ export async function runWhatsAppQaLive(params: { generatedAt: finishedAt, primaryModel, providerMode, + repoRoot, transportId: "whatsapp", }); await fs.writeFile( diff --git a/extensions/qa-lab/src/providers/mock-openai/server.test.ts b/extensions/qa-lab/src/providers/mock-openai/server.test.ts index c47d683461e7..dd803396ce40 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.test.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.test.ts @@ -1846,6 +1846,52 @@ describe("qa mock openai server", () => { expect(memorySearch.status).toBe(200); expect(await memorySearch.text()).toContain('"name":"memory_search"'); + const memoryGetFromPathOnlySearchResult = await fetch(`${server.baseUrl}/v1/responses`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + stream: true, + input: [ + { + role: "user", + content: [ + { + type: "input_text", + text: "Memory tools check: what is the hidden project codename stored only in memory? Use memory tools first.", + }, + ], + }, + { + type: "function_call_output", + output: JSON.stringify({ + results: [ + { + path: "MEMORY.md", + snippet: "Hidden QA fact: the project codename is ORBIT-9.", + }, + ], + }), + }, + { + role: "user", + content: [ + { + type: "input_text", + text: "Protocol note: acknowledged. Continue with the QA scenario plan.", + }, + ], + }, + ], + }), + }); + expect(memoryGetFromPathOnlySearchResult.status).toBe(200); + const memoryGetText = await memoryGetFromPathOnlySearchResult.text(); + expect(memoryGetText).toContain('"name":"memory_get"'); + expect(memoryGetText).toContain('\\"path\\":\\"MEMORY.md\\"'); + expect(memoryGetText).toContain('\\"from\\":1'); + const image = await fetch(`${server.baseUrl}/v1/images/generations`, { method: "POST", headers: { diff --git a/extensions/qa-lab/src/providers/mock-openai/server.ts b/extensions/qa-lab/src/providers/mock-openai/server.ts index b77ea7e9779b..b4f902386918 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.ts @@ -2612,8 +2612,8 @@ async function buildResponsesPayload( }); } } - if (/memory tools check/i.test(prompt)) { - if (!toolOutput) { + if (/memory tools check/i.test(allInputText)) { + if (!scenarioToolOutput) { return buildToolCallEventsWithArgs("memory_search", { query: "project codename ORBIT-9", maxResults: 3, @@ -2623,10 +2623,7 @@ async function buildResponsesPayload( ? (toolJson.results as Array>) : []; const first = results[0]; - if ( - typeof first?.path === "string" && - (typeof first.startLine === "number" || typeof first.endLine === "number") - ) { + if (typeof first?.path === "string") { const from = typeof first.startLine === "number" ? Math.max(1, first.startLine) diff --git a/extensions/qa-lab/src/runtime-parity.test.ts b/extensions/qa-lab/src/runtime-parity.test.ts index c37703606053..a55eb98b0b01 100644 --- a/extensions/qa-lab/src/runtime-parity.test.ts +++ b/extensions/qa-lab/src/runtime-parity.test.ts @@ -168,23 +168,42 @@ describe("runtime parity", () => { const scoped = __testing.filterMockRequestsForParentPrompt( [ { + prompt: "Fanout worker alpha: inspect the QA workspace and finish with exactly ALPHA-OK.", + allInputText: + "Delegate one bounded QA task to a subagent. Fanout worker alpha: inspect the QA workspace and finish with exactly ALPHA-OK.", + plannedToolName: "read", + }, + { + prompt: "Delegate one bounded QA task to a subagent.", allInputText: "Delegate one bounded QA task to a subagent.", plannedToolName: "sessions_spawn", }, + { + prompt: "Continue the bounded QA task with the retained child result.", + allInputText: + "Delegate one bounded QA task to a subagent. Continue the bounded QA task with the retained child result.", + plannedToolName: "sessions_spawn", + }, { allInputText: "Inspect the QA workspace and return one concise protocol note.", plannedToolName: "read", }, { + prompt: "Delegate one bounded QA task to a subagent.", allInputText: "Delegate one bounded QA task to a subagent. Tool result: child accepted.", toolOutput: "child accepted", }, ], "Delegate one bounded QA task to a subagent.", + [ + "Delegate one bounded QA task to a subagent.", + "Continue the bounded QA task with the retained child result.", + ], ); - expect(scoped).toHaveLength(2); + expect(scoped).toHaveLength(3); expect(scoped.map((request) => request.plannedToolName ?? "result")).toEqual([ + "sessions_spawn", "sessions_spawn", "result", ]); diff --git a/extensions/qa-lab/src/runtime-parity.ts b/extensions/qa-lab/src/runtime-parity.ts index cbfeb22ea6f6..19101da4c038 100644 --- a/extensions/qa-lab/src/runtime-parity.ts +++ b/extensions/qa-lab/src/runtime-parity.ts @@ -120,6 +120,7 @@ type RuntimeParityTranscriptRecord = { }; type RuntimeParityMockRequestSnapshot = { + prompt?: string; allInputText?: string; plannedToolName?: string; plannedToolArgs?: unknown; @@ -759,14 +760,22 @@ function resolveRuntimeParityToolCalls(params: { function filterMockRequestsForParentPrompt( requests: RuntimeParityMockRequestSnapshot[], parentPrompt: string, + parentPrompts: readonly string[] = [parentPrompt], ) { - const normalizedParentPrompt = normalizeTextForParity(parentPrompt); - if (!normalizedParentPrompt) { + const normalizedParentPrompts = parentPrompts + .map(normalizeTextForParity) + .filter((prompt) => prompt.length > 0); + if (normalizedParentPrompts.length === 0) { return requests; } - const matching = requests.filter((request) => - normalizeTextForParity(request.allInputText ?? "").includes(normalizedParentPrompt), - ); + const matching = requests.filter((request) => { + const normalizedPrompt = normalizeTextForParity(request.prompt ?? ""); + if (normalizedPrompt) { + return normalizedParentPrompts.some((prompt) => normalizedPrompt.includes(prompt)); + } + const normalizedHistory = normalizeTextForParity(request.allInputText ?? ""); + return normalizedParentPrompts.some((prompt) => normalizedHistory.includes(prompt)); + }); return matching.length > 0 ? matching : requests; } @@ -966,6 +975,7 @@ async function loadRuntimeParityTranscripts(params: { async function loadRuntimeParityMockToolCalls( mockBaseUrl: string | undefined, parentPrompt: string, + parentPrompts: readonly string[] = [parentPrompt], ): Promise { const normalizedBaseUrl = mockBaseUrl?.trim().replace(/\/+$/u, ""); if (!normalizedBaseUrl) { @@ -991,6 +1001,7 @@ async function loadRuntimeParityMockToolCalls( } const requests = payload.filter(isMessageRecord).map( (entry): RuntimeParityMockRequestSnapshot => ({ + prompt: readNonEmptyString(entry.prompt), allInputText: readNonEmptyString(entry.allInputText), plannedToolName: readNonEmptyString(entry.plannedToolName), plannedToolArgs: entry.plannedToolArgs ?? null, @@ -998,7 +1009,7 @@ async function loadRuntimeParityMockToolCalls( }), ); return resolveToolCallOrderFromMockRequests( - filterMockRequestsForParentPrompt(requests, parentPrompt), + filterMockRequestsForParentPrompt(requests, parentPrompt, parentPrompts), ); } catch { return null; @@ -1015,12 +1026,16 @@ export async function captureRuntimeParityCell( }); const transcriptRecords = buildTranscriptRecords(transcriptBytes); const transcriptToolCalls = resolveToolCallOrder(transcriptRecords); - const parentPrompt = - transcriptRecords - .filter((record) => record.role === "user" && !isToolResultLikeMessage(record.message)) - .map((record) => extractAssistantText(record.message)) - .find(Boolean) ?? ""; - const mockToolCalls = await loadRuntimeParityMockToolCalls(params.mockBaseUrl, parentPrompt); + const parentPrompts = transcriptRecords + .filter((record) => record.role === "user") + .map((record) => extractAssistantText(record.message)) + .filter((prompt) => prompt.length > 0); + const parentPrompt = parentPrompts[0] ?? ""; + const mockToolCalls = await loadRuntimeParityMockToolCalls( + params.mockBaseUrl, + parentPrompt, + parentPrompts, + ); const gatewayLogs = params.gateway.logs?.(); const sentinelFindings = [ ...scanGatewayLogSentinels(gatewayLogs), diff --git a/extensions/qa-lab/src/scorecard-evidence.test.ts b/extensions/qa-lab/src/scorecard-evidence.test.ts new file mode 100644 index 000000000000..21ecf9ef51d8 --- /dev/null +++ b/extensions/qa-lab/src/scorecard-evidence.test.ts @@ -0,0 +1,153 @@ +// Qa Lab tests cover profile scorecard evidence math. +import { describe, expect, it } from "vitest"; +import type { QaEvidenceSummaryJson, QaEvidenceSummaryEntry } from "./evidence-summary.js"; +import { buildQaProfileScorecardEvidence } from "./scorecard-evidence.js"; +import type { QaScorecardCategoryCoverageReport } from "./scorecard-taxonomy.js"; + +function evidenceEntry(coverage: QaEvidenceSummaryEntry["coverage"]): QaEvidenceSummaryEntry { + return { + test: { + kind: "flow", + id: "partial-coverage", + title: "Partial coverage", + }, + coverage, + refs: [], + result: { + status: "pass", + }, + }; +} + +function evidenceSummary(entries: QaEvidenceSummaryEntry[]): QaEvidenceSummaryJson { + return { + kind: "openclaw.qa.evidence-summary", + schemaVersion: 2, + generatedAt: "2026-06-24T00:00:00.000Z", + evidenceMode: "full", + entries, + }; +} + +describe("profile scorecard evidence", () => { + it("scores partial multi-id feature coverage by covered coverage IDs", () => { + const category: QaScorecardCategoryCoverageReport = { + id: "surface.category", + taxonomySurfaceId: "surface", + taxonomyCategoryName: "Category", + coverageStatus: "partial", + profiles: ["release"], + features: [{ name: "Multi-id feature", coverageIds: ["coverage.one", "coverage.two"] }], + coverageIds: ["coverage.one", "coverage.two"], + fulfilledCoverageIds: ["coverage.one"], + evidence: [], + scenarioRefs: [], + missingCoverageIds: ["coverage.two"], + missingEvidenceRefs: [], + }; + + const scorecard = buildQaProfileScorecardEvidence({ + evidence: evidenceSummary([ + evidenceEntry([ + { + id: "coverage.one", + role: "primary", + }, + { + id: "coverage.two", + role: "secondary", + }, + ]), + ]), + filters: {}, + categories: [category], + }); + + expect(scorecard.categoryReports[0]?.status).toBe("partial"); + expect(scorecard.categoryReports[0]?.features).toMatchObject({ + total: 1, + fulfilled: 0, + partial: 1, + missing: 0, + fulfillmentPercent: 0, + }); + expect(scorecard.categoryReports[0]?.coverageIds).toMatchObject({ + total: 2, + fulfilled: 1, + secondaryOnly: 1, + missing: 1, + fulfillmentPercent: 50, + }); + expect(scorecard.coverageIds).toMatchObject({ + total: 2, + fulfilled: 1, + missing: 1, + fulfillmentPercent: 50, + }); + expect(scorecard.features).toMatchObject({ + total: 1, + fulfilled: 0, + partial: 1, + missing: 0, + fulfillmentPercent: 0, + }); + }); + + it("counts each profile coverage ID once in global totals", () => { + const firstCategory: QaScorecardCategoryCoverageReport = { + id: "surface.first", + taxonomySurfaceId: "surface", + taxonomyCategoryName: "First", + coverageStatus: "partial", + profiles: ["release"], + features: [ + { name: "Shared", coverageIds: ["coverage.shared"] }, + { name: "Unique", coverageIds: ["coverage.unique"] }, + ], + coverageIds: ["coverage.shared", "coverage.unique"], + fulfilledCoverageIds: ["coverage.shared"], + evidence: [], + scenarioRefs: [], + missingCoverageIds: ["coverage.unique"], + missingEvidenceRefs: [], + }; + const secondCategory: QaScorecardCategoryCoverageReport = { + ...firstCategory, + id: "surface.second", + taxonomyCategoryName: "Second", + features: [{ name: "Shared again", coverageIds: ["coverage.shared"] }], + coverageIds: ["coverage.shared"], + missingCoverageIds: [], + }; + + const scorecard = buildQaProfileScorecardEvidence({ + evidence: evidenceSummary([ + evidenceEntry([ + { + id: "coverage.shared", + role: "primary", + }, + ]), + ]), + filters: {}, + categories: [firstCategory, secondCategory], + }); + + expect(scorecard.categoryReports.map((category) => category.coverageIds.total)).toStrictEqual([ + 2, 1, + ]); + expect(scorecard.coverageIds).toMatchObject({ + total: 2, + fulfilled: 1, + missing: 1, + fulfillmentPercent: 50, + }); + expect(scorecard.features).toMatchObject({ + total: 3, + fulfilled: 2, + partial: 0, + missing: 1, + fulfillmentPercent: 66.7, + }); + }); +}); diff --git a/extensions/qa-lab/src/scorecard-evidence.ts b/extensions/qa-lab/src/scorecard-evidence.ts index 965ae9920823..edab3287f593 100644 --- a/extensions/qa-lab/src/scorecard-evidence.ts +++ b/extensions/qa-lab/src/scorecard-evidence.ts @@ -11,7 +11,6 @@ import type { QaScorecardCategoryCoverageReport, QaScorecardEvidenceMode, } from "./scorecard-taxonomy.js"; -import { readQaScorecardFeatureCoverageByCategory } from "./scorecard-taxonomy.js"; type QaProfileScorecardFilters = { surface?: string; @@ -46,85 +45,95 @@ function coverageIdsForRole( ); } -function statusForCategory(params: { featureCount: number; fulfilledFeatureCount: number }) { - if (params.fulfilledFeatureCount === 0) { +function statusForCategory(params: { coverageIdCount: number; fulfilledCoverageIdCount: number }) { + if (params.fulfilledCoverageIdCount === 0) { return "missing" as const; } - if (params.fulfilledFeatureCount === params.featureCount) { + if (params.fulfilledCoverageIdCount === params.coverageIdCount) { return "fulfilled" as const; } return "partial" as const; } -function categoryFeatureCoverageIds(params: { - category: QaScorecardCategoryCoverageReport; - featureCoverageByCategoryId?: ReadonlyMap; -}) { - const features = params.featureCoverageByCategoryId?.get(params.category.id); - return features && features.length > 0 - ? features - : params.category.coverageIds.map((coverageId) => [coverageId]); +function featureCounts( + features: readonly { coverageIds: readonly string[] }[], + primaryCoverageIds: ReadonlySet, +) { + let fulfilled = 0; + let partial = 0; + let missing = 0; + for (const feature of features) { + const coverageIds = uniqueSortedStrings(feature.coverageIds); + const fulfilledCoverageIds = coverageIds.filter((coverageId) => + primaryCoverageIds.has(coverageId), + ).length; + if (coverageIds.length > 0 && fulfilledCoverageIds === coverageIds.length) { + fulfilled += 1; + } else if (fulfilledCoverageIds > 0) { + partial += 1; + } else { + missing += 1; + } + } + return { + total: features.length, + fulfilled, + partial, + missing, + fulfillmentPercent: percent(fulfilled, features.length), + }; } export function buildQaProfileScorecardEvidence(params: { evidence: QaEvidenceSummaryJson; filters: QaProfileScorecardFilters; categories: readonly QaScorecardCategoryCoverageReport[]; - featureCoverageByCategoryId?: ReadonlyMap; }): QaEvidenceScorecardJson { const primaryCoverageIds = coverageIdsForRole(params.evidence.entries, "primary"); const secondaryCoverageIds = coverageIdsForRole(params.evidence.entries, "secondary"); - const categoryReports = params.categories.map((category) => { - const featureCoverageIds = categoryFeatureCoverageIds({ - category, - featureCoverageByCategoryId: params.featureCoverageByCategoryId, - }); - const fulfilledFeatureCount = featureCoverageIds.filter( - (coverageIds) => - coverageIds.length > 0 && - coverageIds.every((coverageId) => primaryCoverageIds.has(coverageId)), + const categoryInputs = params.categories.map((category) => ({ + category, + features: category.features, + coverageIds: uniqueSortedStrings(category.coverageIds), + })); + const categoryReports = categoryInputs.map(({ category, features, coverageIds }) => { + const fulfilledCoverageIdCount = coverageIds.filter((coverageId) => + primaryCoverageIds.has(coverageId), ).length; - const secondaryOnlyFeatureCount = featureCoverageIds.filter( - (coverageIds) => - coverageIds.some((coverageId) => !primaryCoverageIds.has(coverageId)) && - coverageIds.some( - (coverageId) => - !primaryCoverageIds.has(coverageId) && secondaryCoverageIds.has(coverageId), - ), + const secondaryOnlyCoverageIdCount = coverageIds.filter( + (coverageId) => !primaryCoverageIds.has(coverageId) && secondaryCoverageIds.has(coverageId), ).length; const missingCoverageIds = uniqueSortedStrings( - featureCoverageIds.flatMap((coverageIds) => - coverageIds.filter((coverageId) => !primaryCoverageIds.has(coverageId)), - ), + coverageIds.filter((coverageId) => !primaryCoverageIds.has(coverageId)), ); - const missingFeatureCount = featureCoverageIds.length - fulfilledFeatureCount; + const missingCoverageIdCount = coverageIds.length - fulfilledCoverageIdCount; return { id: category.id, surfaceId: category.taxonomySurfaceId, name: category.taxonomyCategoryName, status: statusForCategory({ - featureCount: featureCoverageIds.length, - fulfilledFeatureCount, + coverageIdCount: coverageIds.length, + fulfilledCoverageIdCount, }), - features: { - total: featureCoverageIds.length, - fulfilled: fulfilledFeatureCount, - secondaryOnly: secondaryOnlyFeatureCount, - missing: missingFeatureCount, - fulfillmentPercent: percent(fulfilledFeatureCount, featureCoverageIds.length), + features: featureCounts(features, primaryCoverageIds), + coverageIds: { + total: coverageIds.length, + fulfilled: fulfilledCoverageIdCount, + secondaryOnly: secondaryOnlyCoverageIdCount, + missing: missingCoverageIdCount, + fulfillmentPercent: percent(fulfilledCoverageIdCount, coverageIds.length), }, missingCoverageIds, }; }); - const featureCount = categoryReports.reduce((sum, category) => sum + category.features.total, 0); - const fulfilledFeatureCount = categoryReports.reduce( - (sum, category) => sum + category.features.fulfilled, - 0, - ); - const missingFeatureCount = categoryReports.reduce( - (sum, category) => sum + category.features.missing, - 0, + const profileCoverageIds = uniqueSortedStrings( + categoryInputs.flatMap((input) => input.coverageIds), ); + const coverageIdCount = profileCoverageIds.length; + const fulfilledCoverageIdCount = profileCoverageIds.filter((coverageId) => + primaryCoverageIds.has(coverageId), + ).length; + const missingCoverageIdCount = coverageIdCount - fulfilledCoverageIdCount; const fulfilledCategoryCount = categoryReports.filter( (category) => category.status === "fulfilled", ).length; @@ -134,6 +143,7 @@ export function buildQaProfileScorecardEvidence(params: { const missingCategoryCount = categoryReports.filter( (category) => category.status === "missing", ).length; + const profileFeatures = categoryInputs.flatMap((input) => input.features); return { filters: { surface: nullableFilter(params.filters.surface), @@ -149,11 +159,12 @@ export function buildQaProfileScorecardEvidence(params: { missing: missingCategoryCount, fulfillmentPercent: percent(fulfilledCategoryCount, categoryReports.length), }, - features: { - total: featureCount, - fulfilled: fulfilledFeatureCount, - missing: missingFeatureCount, - fulfillmentPercent: percent(fulfilledFeatureCount, featureCount), + features: featureCounts(profileFeatures, primaryCoverageIds), + coverageIds: { + total: coverageIdCount, + fulfilled: fulfilledCoverageIdCount, + missing: missingCoverageIdCount, + fulfillmentPercent: percent(fulfilledCoverageIdCount, coverageIdCount), }, categoryReports, }; @@ -173,7 +184,6 @@ export async function attachQaProfileScorecardEvidenceToFile(params: { evidence, filters: params.filters, categories: params.categories, - featureCoverageByCategoryId: readQaScorecardFeatureCoverageByCategory(), }); const nextEvidence = attachQaEvidenceScorecard({ summary: evidence, diff --git a/extensions/qa-lab/src/scorecard-taxonomy.ts b/extensions/qa-lab/src/scorecard-taxonomy.ts index 4c64f46cdf82..33cd60088960 100644 --- a/extensions/qa-lab/src/scorecard-taxonomy.ts +++ b/extensions/qa-lab/src/scorecard-taxonomy.ts @@ -376,6 +376,7 @@ export type QaScorecardCategoryCoverageReport = { taxonomyCategoryName: string; coverageStatus: "covered" | "partial" | "missing"; profiles: string[]; + features: QaScorecardCategoryFeatureCoverageReport[]; coverageIds: string[]; fulfilledCoverageIds: string[]; evidence: QaScorecardEvidenceReport[]; @@ -384,6 +385,11 @@ export type QaScorecardCategoryCoverageReport = { missingEvidenceRefs: string[]; }; +export type QaScorecardCategoryFeatureCoverageReport = { + name: string; + coverageIds: string[]; +}; + export type QaScorecardProfileReport = { id: string; evidenceMode: QaScorecardEvidenceMode; @@ -403,9 +409,9 @@ export type QaScorecardTaxonomyReport = { requiredCategoryCount: number; fulfilledCategoryCount: number; categoryFulfillmentPercent: number; - requiredFeatureCount: number; - fulfilledFeatureCount: number; - taxonomyFulfillmentPercent: number; + requiredCoverageIdCount: number; + fulfilledCoverageIdCount: number; + coverageIdFulfillmentPercent: number; evidenceRefCount: number; scenarioCoverageIdCount: number; unknownCoverageIdCount: number; @@ -831,16 +837,6 @@ function buildMaturityRefs(taxonomy: QaMaturityTaxonomy | null) { return { categories, coverageIds }; } -export function readQaScorecardFeatureCoverageByCategory(repoRoot?: string) { - const maturityRefs = buildMaturityRefs(readQaMaturityTaxonomy(repoRoot)); - return new Map( - [...maturityRefs.categories.entries()].map(([categoryId, category]) => [ - categoryId, - category.features.map((feature) => feature.coverageIds), - ]), - ); -} - export function readQaScorecardProfileOptions(profileId: string | undefined, repoRoot?: string) { const profile = profileId?.trim(); if (!profile) { @@ -1011,8 +1007,8 @@ export function buildQaScorecardTaxonomyReport(params: { ...categoryIdsWithEvidence, ]); - let requiredFeatureCount = 0; - let fulfilledFeatureCount = 0; + const requiredCoverageIds = new Set(); + const fulfilledRequiredCoverageIds = new Set(); for (const categoryId of relevantCategoryIds) { const category = maturityRefs.categories.get(categoryId); if (!category) { @@ -1078,21 +1074,23 @@ export function buildQaScorecardTaxonomyReport(params: { } } - const fulfilledFeatureCountForCategory = category.features.filter( - (feature) => - feature.coverageIds.length > 0 && - feature.coverageIds.every((coverageId) => fulfilledCoverageIds.has(coverageId)), + const fulfilledCoverageIdCountForCategory = category.coverageIds.filter((coverageId) => + fulfilledCoverageIds.has(coverageId), ).length; if (required) { - requiredFeatureCount += category.features.length; - fulfilledFeatureCount += fulfilledFeatureCountForCategory; + for (const coverageId of category.coverageIds) { + requiredCoverageIds.add(coverageId); + if (fulfilledCoverageIds.has(coverageId)) { + fulfilledRequiredCoverageIds.add(coverageId); + } + } pushMissingPrimaryIssues({ issues, category, coverageIdsWithPrimaryEvidence: fulfilledCoverageIds, coverageIdsWithSecondaryEvidence: secondaryOnlyCoverageIds, }); - if (fulfilledFeatureCountForCategory === 0) { + if (fulfilledCoverageIdCountForCategory === 0) { issues.push({ code: "profile-category-missing-evidence", severity: "warning", @@ -1107,8 +1105,8 @@ export function buildQaScorecardTaxonomyReport(params: { : []; const coverageStatus = required && - category.features.length > 0 && - fulfilledFeatureCountForCategory === category.features.length + category.coverageIds.length > 0 && + fulfilledCoverageIdCountForCategory === category.coverageIds.length ? "covered" : evidenceReports.length > 0 ? "partial" @@ -1120,6 +1118,7 @@ export function buildQaScorecardTaxonomyReport(params: { taxonomyCategoryName: category.categoryName, coverageStatus, profiles: profileIds, + features: category.features, coverageIds: category.coverageIds, fulfilledCoverageIds: uniqueSorted(fulfilledCoverageIds), evidence: evidenceReports.toSorted((left, right) => @@ -1156,9 +1155,12 @@ export function buildQaScorecardTaxonomyReport(params: { requiredCategoryCount: requiredCategories.length, fulfilledCategoryCount, categoryFulfillmentPercent: percent(fulfilledCategoryCount, requiredCategories.length), - requiredFeatureCount, - fulfilledFeatureCount, - taxonomyFulfillmentPercent: percent(fulfilledFeatureCount, requiredFeatureCount), + requiredCoverageIdCount: requiredCoverageIds.size, + fulfilledCoverageIdCount: fulfilledRequiredCoverageIds.size, + coverageIdFulfillmentPercent: percent( + fulfilledRequiredCoverageIds.size, + requiredCoverageIds.size, + ), evidenceRefCount: categories.reduce((count, category) => count + category.evidence.length, 0), scenarioCoverageIdCount: allScenarioCoverageIds.length, unknownCoverageIdCount: unknownCoverageIds.length, diff --git a/extensions/qa-lab/src/suite-launch.runtime.test.ts b/extensions/qa-lab/src/suite-launch.runtime.test.ts index 0cff5074dcfe..e7f6afdaace6 100644 --- a/extensions/qa-lab/src/suite-launch.runtime.test.ts +++ b/extensions/qa-lab/src/suite-launch.runtime.test.ts @@ -469,6 +469,94 @@ describe("qa suite runtime launcher", () => { expect(runQaTestFileScenarios).toHaveBeenCalledTimes(1); }); + it("starts native suite proof before isolated flow work fills the weighted queue", async () => { + const repoRoot = await makeTempRepo("qa-suite-native-before-isolated-"); + let releaseShared!: () => void; + let markSharedStarted!: () => void; + const sharedStarted = new Promise((resolve) => { + markSharedStarted = resolve; + }); + const sharedBlocked = new Promise((resolve) => { + releaseShared = resolve; + }); + let releaseTestFile!: () => void; + let markTestFileStarted!: () => void; + const testFileStarted = new Promise((resolve) => { + markTestFileStarted = resolve; + }); + const testFileBlocked = new Promise((resolve) => { + releaseTestFile = resolve; + }); + runQaFlowSuite.mockImplementationOnce( + async (params: { outputDir?: string; scenarioIds?: string[] } | undefined) => { + markSharedStarted(); + await sharedBlocked; + const outputDir = params?.outputDir ?? "/tmp/qa-flow"; + const evidencePath = path.join(outputDir, "qa-evidence.json"); + await writeEvidence(evidencePath); + const scenarioIds = params?.scenarioIds ?? ["channel-chat-baseline"]; + return { + outputDir, + evidencePath, + reportPath: path.join(outputDir, "qa-suite-report.md"), + summaryPath: path.join(outputDir, "qa-suite-summary.json"), + report: "# QA Suite Report\n", + scenarios: scenarioIds.map((scenarioId) => ({ + name: scenarioId, + status: "pass", + steps: [], + })), + watchUrl: "http://127.0.0.1:43124", + }; + }, + ); + runQaTestFileScenarios.mockImplementationOnce( + async (params: { + outputDir: string; + scenarios: Array<{ id: string; execution: { kind: "script" | "vitest" | "playwright" } }>; + }) => { + markTestFileStarted(); + await testFileBlocked; + const evidencePath = path.join(params.outputDir, "qa-evidence.json"); + await writeEvidence(evidencePath); + return { + outputDir: params.outputDir, + executionKind: params.scenarios[0]?.execution.kind ?? "playwright", + evidencePath, + results: params.scenarios.map((scenarioItem) => ({ + durationMs: 1, + logPath: path.join(params.outputDir, `${scenarioItem.id}.log`), + scenario: scenarioItem, + status: "pass", + })), + }; + }, + ); + + const runPromise = runQaSuite({ + repoRoot, + outputDir: ".artifacts/qa-e2e/native-before-isolated", + concurrency: 2, + scenarioIds: [ + "channel-chat-baseline", + "group-visible-reply-tool", + "control-ui-chat-flow-playwright", + ], + }); + await sharedStarted; + await testFileStarted; + await Promise.resolve(); + + expect(runQaFlowSuite).toHaveBeenCalledTimes(1); + expect(runQaTestFileScenarios).toHaveBeenCalledTimes(1); + + releaseTestFile(); + releaseShared(); + await runPromise; + + expect(runQaFlowSuite).toHaveBeenCalledTimes(2); + }); + it("waits for already-started partitions before rejecting a unified suite", async () => { const repoRoot = await makeTempRepo("qa-suite-reject-settle-"); let releaseTestFile!: () => void; diff --git a/extensions/qa-lab/src/suite-launch.runtime.ts b/extensions/qa-lab/src/suite-launch.runtime.ts index e759a0f62c3a..c17f5fe4eb83 100644 --- a/extensions/qa-lab/src/suite-launch.runtime.ts +++ b/extensions/qa-lab/src/suite-launch.runtime.ts @@ -448,7 +448,9 @@ async function runUnifiedQaSuite(params: { ); const evidenceSummaries: QaEvidenceSummaryJson[] = []; const scenarioResultsById = new Map(); - const partitionTasks: QaUnifiedPartitionTask[] = []; + const sharedFlowPartitionTasks: QaUnifiedPartitionTask[] = []; + const isolatedFlowPartitionTasks: QaUnifiedPartitionTask[] = []; + const testFilePartitionTasks: QaUnifiedPartitionTask[] = []; if (params.plan.flowScenarios.length > 0) { const sharedFlowScenarios = params.plan.flowScenarios.filter( (scenario) => !scenarioRequiresIsolatedQaSuiteWorker(scenario), @@ -488,7 +490,7 @@ async function runUnifiedQaSuite(params: { for (const partition of flowPartitions) { const isolatedPartition = partition.kind === "isolated" || partition.kind.startsWith("isolated-"); - partitionTasks.push({ + const task = { weight: partition.concurrency, run: async () => { const result = await runFlowSuite({ @@ -525,11 +527,16 @@ async function runUnifiedQaSuite(params: { scenarioResults, }; }, - }); + } satisfies QaUnifiedPartitionTask; + if (isolatedPartition) { + isolatedFlowPartitionTasks.push(task); + } else { + sharedFlowPartitionTasks.push(task); + } } } if (params.plan.testFileScenariosByKind.size > 0) { - partitionTasks.push({ + testFilePartitionTasks.push({ weight: 1, run: async () => { const testFileEvidenceSummaries: QaEvidenceSummaryJson[] = []; @@ -561,6 +568,11 @@ async function runUnifiedQaSuite(params: { }, }); } + const partitionTasks = [ + ...sharedFlowPartitionTasks, + ...testFilePartitionTasks, + ...isolatedFlowPartitionTasks, + ]; const partitionResults = await runWeightedUnifiedPartitionTasks(partitionTasks, concurrency); for (const partitionResult of partitionResults) { for (const scenarioResult of partitionResult.scenarioResults) { diff --git a/extensions/qa-lab/src/suite.ts b/extensions/qa-lab/src/suite.ts index d63046fdcd55..4527873651c7 100644 --- a/extensions/qa-lab/src/suite.ts +++ b/extensions/qa-lab/src/suite.ts @@ -848,6 +848,7 @@ async function runQaRuntimeParitySuite(params: { const finishedAt = new Date(); const { evidence, evidencePath, report, reportPath, summaryPath } = await writeQaSuiteArtifacts( { + repoRoot: params.repoRoot, outputDir: params.outputDir, startedAt: params.startedAt, finishedAt, @@ -900,6 +901,7 @@ async function runQaRuntimeParitySuite(params: { } async function writeQaSuiteArtifacts(params: { + repoRoot?: string; outputDir: string; startedAt: Date; finishedAt: Date; @@ -974,6 +976,7 @@ async function writeQaSuiteArtifacts(params: { generatedAt: params.finishedAt.toISOString(), primaryModel: params.primaryModel, providerMode: params.providerMode, + repoRoot: params.repoRoot, scenarioDefinitions: params.scenarioDefinitions, scenarioResults: params.scenarios, }) @@ -1296,6 +1299,7 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise { const partialFinishedAt = new Date(); const { report, reportPath } = await writeQaSuiteArtifacts({ + repoRoot, outputDir, startedAt, finishedAt: partialFinishedAt, @@ -1448,6 +1452,7 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise buildScenarioEvidenceTarget(result.scenario)), results: fallbackResults.map((result) => ({ id: result.scenario.id, @@ -616,6 +618,7 @@ function buildTestFileEvidence(params: { generatedAt: params.generatedAt, primaryModel: params.primaryModel, providerMode: params.providerMode, + repoRoot: params.repoRoot, targets: params.results.map((result) => buildScenarioEvidenceTarget(result.scenario)), results: params.results.map((result) => ({ id: result.scenario.id, @@ -802,6 +805,7 @@ export async function runQaTestFileScenarios( kind, primaryModel: params.primaryModel, providerMode: params.providerMode, + repoRoot: params.repoRoot, results, }); const paths = await writeTestFileEvidenceFile({ diff --git a/extensions/qqbot/src/engine/api/api-client.test.ts b/extensions/qqbot/src/engine/api/api-client.test.ts index 521e28936308..9beba470eb33 100644 --- a/extensions/qqbot/src/engine/api/api-client.test.ts +++ b/extensions/qqbot/src/engine/api/api-client.test.ts @@ -1,5 +1,6 @@ // Qqbot tests cover api-client plugin behavior. import { afterEach, describe, expect, it, vi } from "vitest"; +import { createStreamingResponse } from "../../../../test-support/streaming-error-response.js"; const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); @@ -88,4 +89,35 @@ describe("ApiClient", () => { }, }); }); + + it("bounds successful response bodies without using response.text()", async () => { + const release = vi.fn(async () => {}); + const streamed = createStreamingResponse({ + chunkCount: 32, + chunkSize: 1024 * 1024, + text: "x", + headers: { "content-type": "application/json" }, + }); + const textSpy = vi.spyOn(streamed.response, "text").mockRejectedValue(new Error("unbounded")); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: streamed.response, + release, + }); + + const client = new ApiClient({ baseUrl: "https://qqbot.test" }); + + let error: unknown; + try { + await client.request("token-1", "GET", "/v2/users/@me"); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(ApiError); + expect(String(error)).toContain("QQBot API response: text response exceeds 16777216 bytes"); + expect(streamed.getReadCount()).toBeLessThan(32); + expect(streamed.wasCanceled()).toBe(true); + expect(textSpy).not.toHaveBeenCalled(); + expect(release).toHaveBeenCalledTimes(1); + }); }); diff --git a/extensions/qqbot/src/engine/api/api-client.ts b/extensions/qqbot/src/engine/api/api-client.ts index 1036f08289fb..a8ce31309be4 100644 --- a/extensions/qqbot/src/engine/api/api-client.ts +++ b/extensions/qqbot/src/engine/api/api-client.ts @@ -9,7 +9,10 @@ * - `redactBodyKeys` replaces the hardcoded `file_data` redaction. */ -import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; +import { + readProviderTextResponse, + readResponseTextLimited, +} from "openclaw/plugin-sdk/provider-http"; import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; import { ApiError, type ApiClientConfig, type EngineLogger } from "../types.js"; import { formatErrorMessage } from "../utils/format.js"; @@ -162,7 +165,7 @@ export class ApiClient { const readBody = async (limitBytes?: number): Promise => { try { return limitBytes === undefined - ? await res.text() + ? await readProviderTextResponse(res, "QQBot API response") : await readResponseTextLimited(res, limitBytes); } catch (err) { throw new ApiError( diff --git a/extensions/qqbot/src/engine/tools/channel-api.test.ts b/extensions/qqbot/src/engine/tools/channel-api.test.ts index ea64e764e2c0..a8cabd040ccc 100644 --- a/extensions/qqbot/src/engine/tools/channel-api.test.ts +++ b/extensions/qqbot/src/engine/tools/channel-api.test.ts @@ -1,5 +1,6 @@ // Qqbot tests cover channel-api tool behavior. import { afterEach, describe, expect, it, vi } from "vitest"; +import { createStreamingResponse } from "../../../../test-support/streaming-error-response.js"; const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); @@ -109,4 +110,33 @@ describe("executeChannelApi", () => { expect(textSpy).not.toHaveBeenCalled(); expect(release).toHaveBeenCalledTimes(1); }); + + it("bounds successful response bodies without using response.text()", async () => { + const release = vi.fn(async () => {}); + const streamed = createStreamingResponse({ + chunkCount: 32, + chunkSize: 1024 * 1024, + text: "x", + headers: { "content-type": "application/json" }, + }); + const textSpy = vi.spyOn(streamed.response, "text").mockRejectedValue(new Error("unbounded")); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: streamed.response, + release, + }); + + const result = await executeChannelApi( + { method: "GET", path: "/guilds/123/channels" }, + { accessToken: "token-1" }, + ); + + expect(result.details).toMatchObject({ + error: "QQ channel API response: text response exceeds 16777216 bytes", + path: "/guilds/123/channels", + }); + expect(streamed.getReadCount()).toBeLessThan(32); + expect(streamed.wasCanceled()).toBe(true); + expect(textSpy).not.toHaveBeenCalled(); + expect(release).toHaveBeenCalledTimes(1); + }); }); diff --git a/extensions/qqbot/src/engine/tools/channel-api.ts b/extensions/qqbot/src/engine/tools/channel-api.ts index 4d0df88ed909..b9b8173eb016 100644 --- a/extensions/qqbot/src/engine/tools/channel-api.ts +++ b/extensions/qqbot/src/engine/tools/channel-api.ts @@ -8,7 +8,10 @@ * validation, fetch, and structured response formatting. */ -import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; +import { + readProviderTextResponse, + readResponseTextLimited, +} from "openclaw/plugin-sdk/provider-http"; import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; import { formatErrorMessage } from "../utils/format.js"; import { debugLog, debugError } from "../utils/log.js"; @@ -216,7 +219,7 @@ export async function executeChannelApi( debugLog(`[qqbot-channel-api] <<< Status: ${res.status} ${res.statusText}`); const rawBody = res.ok - ? await res.text() + ? await readProviderTextResponse(res, "QQ channel API response") : await readResponseTextLimited(res, CHANNEL_API_ERROR_BODY_LIMIT_BYTES); if (!rawBody || rawBody.trim() === "") { if (res.ok) { diff --git a/extensions/qwen/media-understanding-provider.test.ts b/extensions/qwen/media-understanding-provider.test.ts index 4bc4c5580792..acdee732f249 100644 --- a/extensions/qwen/media-understanding-provider.test.ts +++ b/extensions/qwen/media-understanding-provider.test.ts @@ -8,6 +8,39 @@ import { describeQwenVideo } from "./media-understanding-provider.js"; installPinnedHostnameTestHooks(); +function oversizedJsonResponse(params: { chunkCount: number; chunkSize: number }): { + response: Response; + getReadCount: () => number; + wasCanceled: () => boolean; +} { + const chunk = new Uint8Array(params.chunkSize); + let readCount = 0; + let canceled = false; + return { + response: new Response( + new ReadableStream({ + pull(controller) { + if (readCount >= params.chunkCount) { + controller.close(); + return; + } + readCount += 1; + controller.enqueue(chunk); + }, + cancel() { + canceled = true; + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + getReadCount: () => readCount, + wasCanceled: () => canceled, + }; +} + describe("describeQwenVideo", () => { it("builds the expected OpenAI-compatible video payload", async () => { const { fetchFn, getRequest } = createRequestCaptureJsonFetch({ @@ -74,4 +107,42 @@ describe("describeQwenVideo", () => { `data:video/mp4;base64,${Buffer.from("video-bytes").toString("base64")}`, ); }); + + it("bounds successful Qwen video JSON bodies instead of buffering the whole response", async () => { + const streamed = oversizedJsonResponse({ chunkCount: 64, chunkSize: 1024 * 1024 }); + + await expect( + describeQwenVideo({ + buffer: Buffer.from("video-bytes"), + fileName: "clip.mp4", + mime: "video/mp4", + apiKey: "test-key", + timeoutMs: 1500, + baseUrl: "https://example.com/v1", + fetchFn: async () => streamed.response, + }), + ).rejects.toThrow("Qwen video description failed: JSON response exceeds 16777216 bytes"); + + expect(streamed.getReadCount()).toBeLessThan(64); + expect(streamed.wasCanceled()).toBe(true); + }); + + it("reports malformed Qwen video JSON with a provider-owned error", async () => { + const response = new Response("not-json{", { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + + await expect( + describeQwenVideo({ + buffer: Buffer.from("video-bytes"), + fileName: "clip.mp4", + mime: "video/mp4", + apiKey: "test-key", + timeoutMs: 1500, + baseUrl: "https://example.com/v1", + fetchFn: async () => response, + }), + ).rejects.toThrow("Qwen video description failed: malformed JSON response"); + }); }); diff --git a/extensions/qwen/media-understanding-provider.ts b/extensions/qwen/media-understanding-provider.ts index 5ecf5729ba9d..b1e22a9174ec 100644 --- a/extensions/qwen/media-understanding-provider.ts +++ b/extensions/qwen/media-understanding-provider.ts @@ -13,6 +13,7 @@ import { import { assertOkOrThrowHttpError, postJsonRequest, + readProviderJsonResponse, resolveProviderHttpRequestConfig, } from "openclaw/plugin-sdk/provider-http"; import { QWEN_STANDARD_GLOBAL_BASE_URL } from "./models.js"; @@ -60,7 +61,14 @@ export async function describeQwenVideo( try { await assertOkOrThrowHttpError(res, "Qwen video description failed"); - const payload = (await res.json()) as OpenAiCompatibleVideoPayload; + // Read the success body through the shared byte-bounded JSON reader (16 MiB cap + + // stream cancel on overflow) so a hostile or buggy endpoint cannot force the runtime + // to buffer an unbounded body. Malformed JSON keeps the + // `Qwen video description failed: malformed JSON response` wrapping. + const payload = await readProviderJsonResponse( + res, + "Qwen video description failed", + ); const text = coerceOpenAiCompatibleVideoText(payload); if (!text) { throw new Error("Qwen video description response missing content"); diff --git a/extensions/raft/src/gateway.ts b/extensions/raft/src/gateway.ts index b561969225a6..9bb645926b01 100644 --- a/extensions/raft/src/gateway.ts +++ b/extensions/raft/src/gateway.ts @@ -2,23 +2,12 @@ import { spawn, type ChildProcess } from "node:child_process"; import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; import type { EventEmitter } from "node:events"; -import { - createServer, - type IncomingMessage, - type Server, - type ServerResponse, -} from "node:http"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import type { Socket } from "node:net"; -import { - keepHttpServerTaskAlive, - waitUntilAbort, -} from "openclaw/plugin-sdk/channel-outbound"; import type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract"; +import { keepHttpServerTaskAlive, waitUntilAbort } from "openclaw/plugin-sdk/channel-outbound"; import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; -import { - createClaimableDedupe, - type ClaimableDedupe, -} from "openclaw/plugin-sdk/persistent-dedupe"; +import { createClaimableDedupe, type ClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe"; import { RAFT_CHANNEL_ID, type ResolvedRaftAccount } from "./accounts.js"; import { dispatchRaftWake } from "./inbound.js"; @@ -54,11 +43,7 @@ type RaftBridgeProcess = Pick & Pick type RaftGatewayDeps = { createToken?: () => string; - spawnBridge?: (params: { - profile: string; - endpoint: string; - token: string; - }) => RaftBridgeProcess; + spawnBridge?: (params: { profile: string; endpoint: string; token: string }) => RaftBridgeProcess; wakeDedupe?: ClaimableDedupe; }; @@ -80,6 +65,8 @@ function spawnRaftBridge(params: { endpoint: string; token: string; }): RaftBridgeProcess { + // Raft owns the fixed bridge command. OpenClaw passes profile/loopback + // endpoint/token as separate argv/env fields; wake payloads never reach argv. return spawn( "raft", [ @@ -247,7 +234,7 @@ export async function startRaftGatewayAccount( onDiskError: (error) => { ctx.log?.warn?.(`Raft wake dedupe storage failed: ${String(error)}`); }, - }); + }); const token = (deps.createToken ?? createToken)(); const runtimeSession = randomUUID(); const sockets = new Set(); diff --git a/extensions/signal/src/approval-handler.runtime.ts b/extensions/signal/src/approval-handler.runtime.ts index b6d1258bf0d5..485f6cc8be44 100644 --- a/extensions/signal/src/approval-handler.runtime.ts +++ b/extensions/signal/src/approval-handler.runtime.ts @@ -197,6 +197,7 @@ export const signalApprovalNativeRuntime = createChannelApprovalNativeRuntimeAda conversationKey: entry.conversationKey, messageId: entry.messageId, approvalId: request.id, + approvalKind: view.approvalKind, allowedDecisions: pendingPayload.reactionPayload.allowedDecisions, targetAuthorKeys: entry.targetAuthorKeys, route: { diff --git a/extensions/signal/src/approval-reactions.test.ts b/extensions/signal/src/approval-reactions.test.ts index 7ef35df75aa2..c6e084de3933 100644 --- a/extensions/signal/src/approval-reactions.test.ts +++ b/extensions/signal/src/approval-reactions.test.ts @@ -1,12 +1,16 @@ +import { + buildExecApprovalPendingReplyPayload, + buildPluginApprovalPendingReplyPayload, +} from "openclaw/plugin-sdk/approval-reply-runtime"; // Signal tests cover approval reactions plugin behavior. import { beforeEach, describe, expect, it, vi } from "vitest"; import { addSignalApprovalReactionHintToText, - appendSignalApprovalReactionHintForOutboundMessage, + addSignalApprovalReactionHintToStructuredPayload, buildSignalApprovalReactionHint, clearSignalApprovalReactionTargetsForTest, maybeResolveSignalApprovalReaction, - registerSignalApprovalReactionTargetForOutboundMessage, + registerSignalApprovalReactionTargetForDeliveredPayload, registerSignalApprovalReactionTarget, resolveSignalApprovalReactionTargetWithPersistence, } from "./approval-reactions.js"; @@ -78,7 +82,220 @@ describe("Signal approval reactions", () => { ).toBe(prompt); }); - it("registers target-mode outbound approval prompts for reactions", async () => { + it("registers delivered structured approval payloads for reactions", async () => { + const cfg = { + channels: { + signal: { + allowFrom: ["+15551230000"], + }, + }, + approvals: { + exec: { + enabled: true, + mode: "targets" as const, + targets: [{ channel: "signal", to: "+15551230000" }], + }, + }, + }; + const payload = buildExecApprovalPendingReplyPayload({ + approvalId: "exec-structured-approval", + approvalSlug: "exec-str", + allowedDecisions: ["allow-once", "deny"], + command: "printf test", + host: "gateway", + agentId: "main", + sessionKey: "agent:main:signal:direct:+15551230000", + }); + const deliveredPayload = addSignalApprovalReactionHintToStructuredPayload({ + cfg, + accountId: "default", + to: "+15551230000", + payload, + targetAuthor: "+15550009999", + }); + + expect( + registerSignalApprovalReactionTargetForDeliveredPayload({ + cfg, + target: { + channel: "signal", + to: "+15551230000", + accountId: "default", + }, + payload: deliveredPayload!, + results: [ + { + channel: "signal", + messageId: "1700000000012", + toJid: "+15551230000", + }, + ], + targetAuthor: "+15550009999", + }), + ).toBe(true); + + await expect( + resolveSignalApprovalReactionTargetWithPersistence({ + accountId: "default", + conversationKey: "+15551230000", + messageId: "1700000000012", + reactionKey: "👍", + targetAuthor: "+15550009999", + }), + ).resolves.toEqual({ + approvalId: "exec-structured-approval", + approvalKind: "exec", + decision: "allow-once", + route: { + deliveryMode: "target", + to: "+15551230000", + accountId: "default", + agentId: "main", + sessionKey: "agent:main:signal:direct:+15551230000", + }, + }); + }); + + it("does not register metadata-only approval payloads without visible reaction hints", async () => { + const cfg = { + channels: { + signal: { + allowFrom: ["+15551230000"], + }, + }, + approvals: { + exec: { + enabled: true, + mode: "targets" as const, + targets: [{ channel: "signal", to: "+15551230000" }], + }, + }, + }; + const payload = buildExecApprovalPendingReplyPayload({ + approvalId: "exec-hidden-reaction", + approvalSlug: "exec-hid", + allowedDecisions: ["allow-once", "deny"], + command: "printf hidden", + host: "gateway", + agentId: "main", + sessionKey: "agent:main:signal:direct:+15551230000", + }); + + expect( + registerSignalApprovalReactionTargetForDeliveredPayload({ + cfg, + target: { + channel: "signal", + to: "+15551230000", + accountId: "default", + }, + payload, + results: [ + { + channel: "signal", + messageId: "1700000000015", + }, + ], + targetAuthor: "+15550009999", + }), + ).toBe(false); + + await expect( + resolveSignalApprovalReactionTargetWithPersistence({ + accountId: "default", + conversationKey: "+15551230000", + messageId: "1700000000015", + reactionKey: "👍", + targetAuthor: "+15550009999", + }), + ).resolves.toBeNull(); + }); + + it("registers only delivered chunks that contain visible reaction hints", async () => { + const cfg = { + channels: { + signal: { + allowFrom: ["+15551230000"], + }, + }, + approvals: { + exec: { + enabled: true, + mode: "targets" as const, + targets: [{ channel: "signal", to: "+15551230000" }], + }, + }, + }; + const payload = buildExecApprovalPendingReplyPayload({ + approvalId: "exec-chunked-reaction", + approvalSlug: "exec-ch", + allowedDecisions: ["allow-once", "deny"], + command: "printf chunked", + host: "gateway", + agentId: "main", + sessionKey: "agent:main:signal:direct:+15551230000", + }); + const deliveredPayload = addSignalApprovalReactionHintToStructuredPayload({ + cfg, + accountId: "default", + to: "+15551230000", + payload, + targetAuthor: "+15550009999", + }); + + expect( + registerSignalApprovalReactionTargetForDeliveredPayload({ + cfg, + target: { + channel: "signal", + to: "+15551230000", + accountId: "default", + }, + payload: deliveredPayload!, + results: [ + { + channel: "signal", + messageId: "1700000000016", + meta: { + signalVisibleText: "Exec approval required\n\nReact with:\n\n👍 Allow Once\n👎 Deny", + }, + }, + { + channel: "signal", + messageId: "1700000000017", + meta: { + signalVisibleText: "Continuation chunk without controls", + }, + }, + ], + targetAuthor: "+15550009999", + }), + ).toBe(true); + + await expect( + resolveSignalApprovalReactionTargetWithPersistence({ + accountId: "default", + conversationKey: "+15551230000", + messageId: "1700000000016", + reactionKey: "👍", + targetAuthor: "+15550009999", + }), + ).resolves.toMatchObject({ + approvalId: "exec-chunked-reaction", + decision: "allow-once", + }); + await expect( + resolveSignalApprovalReactionTargetWithPersistence({ + accountId: "default", + conversationKey: "+15551230000", + messageId: "1700000000017", + reactionKey: "👍", + targetAuthor: "+15550009999", + }), + ).resolves.toBeNull(); + }); + + it("registers delivered structured plugin approval payloads using metadata kind", async () => { const cfg = { channels: { signal: { @@ -93,70 +310,106 @@ describe("Signal approval reactions", () => { }, }, }; - const text = - "Plugin approval required\nID: plugin:abc\n\nReply with: /approve plugin:abc allow-once|deny"; - const textWithHint = appendSignalApprovalReactionHintForOutboundMessage({ + const payload = buildPluginApprovalPendingReplyPayload({ + request: { + id: "plugin-structured-approval", + request: { + title: "Sensitive plugin action", + description: "Needs approval", + allowedDecisions: ["allow-once", "deny"], + }, + createdAtMs: 1_000, + expiresAtMs: 61_000, + }, + nowMs: 1_000, + }); + const deliveredPayload = addSignalApprovalReactionHintToStructuredPayload({ cfg, accountId: "default", to: "+15551230000", - text, + payload, targetAuthor: "+15550009999", }); - expect(textWithHint).toContain("React with:\n\n👍 Allow Once\n👎 Deny"); expect( - registerSignalApprovalReactionTargetForOutboundMessage({ + registerSignalApprovalReactionTargetForDeliveredPayload({ cfg, - accountId: "default", - to: "+15551230000", - messageId: "1700000000009", - text: textWithHint, + target: { + channel: "signal", + to: "+15551230000", + accountId: "default", + }, + payload: deliveredPayload!, + results: [ + { + channel: "signal", + messageId: "1700000000013", + }, + ], targetAuthor: "+15550009999", }), ).toBe(true); - const handled = await maybeResolveSignalApprovalReaction({ - cfg, - accountId: "default", - conversationKey: "+15551230000", - messageId: "1700000000009", - reactionKey: "👍", - actorId: "+15551230000", - targetAuthor: "+15550009999", - }); - - expect(handled).toBe(true); - expect(resolverMocks.resolveSignalApproval).toHaveBeenCalledWith({ - cfg, - approvalId: "plugin:abc", + await expect( + resolveSignalApprovalReactionTargetWithPersistence({ + accountId: "default", + conversationKey: "+15551230000", + messageId: "1700000000013", + reactionKey: "👍", + targetAuthor: "+15550009999", + }), + ).resolves.toMatchObject({ + approvalId: "plugin-structured-approval", + approvalKind: "plugin", decision: "allow-once", - senderId: "+15551230000", - gatewayUrl: undefined, }); }); - it("keeps target-mode outbound prompts manual when the target route is disabled", () => { - const text = - "Plugin approval required\nID: plugin:abc\n\nReply with: /approve plugin:abc allow-once|deny"; + it("does not register delivered structured approval payloads without explicit approvers", () => { + const payload = buildExecApprovalPendingReplyPayload({ + approvalId: "exec-no-approvers", + approvalSlug: "exec-no", + allowedDecisions: ["allow-once", "deny"], + command: "printf test", + host: "gateway", + }); + const deliveredPayload = { + ...payload, + text: addSignalApprovalReactionHintToText({ + text: payload.text ?? "", + allowedDecisions: ["allow-once", "deny"], + }), + }; expect( - appendSignalApprovalReactionHintForOutboundMessage({ + registerSignalApprovalReactionTargetForDeliveredPayload({ cfg: { - channels: { signal: { allowFrom: ["+15551230000"] } }, + channels: { + signal: {}, + }, approvals: { - plugin: { - enabled: false, + exec: { + enabled: true, mode: "targets", targets: [{ channel: "signal", to: "+15551230000" }], }, }, }, - accountId: "default", - to: "+15551230000", - text, + target: { + channel: "signal", + to: "+15551230000", + accountId: "default", + }, + payload: deliveredPayload, + results: [ + { + channel: "signal", + messageId: "1700000000014", + }, + ], targetAuthor: "+15550009999", }), - ).toBe(text); + ).toBe(false); }); it("registers reaction state when only allow-always is available", async () => { diff --git a/extensions/signal/src/approval-reactions.ts b/extensions/signal/src/approval-reactions.ts index 0b8f8731ae76..92a6bc423ddc 100644 --- a/extensions/signal/src/approval-reactions.ts +++ b/extensions/signal/src/approval-reactions.ts @@ -8,8 +8,12 @@ import { type ApprovalReactionDecisionBinding, type ApprovalReactionTargetRecord, } from "openclaw/plugin-sdk/approval-reaction-runtime"; -import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime"; +import { + getExecApprovalReplyMetadata, + type ExecApprovalReplyDecision, +} from "openclaw/plugin-sdk/approval-reply-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { normalizeLowercaseStringOrEmpty, @@ -21,7 +25,7 @@ import { looksLikeUuid } from "./identity.js"; import { normalizeSignalMessagingTarget } from "./normalize.js"; import { getOptionalSignalRuntime } from "./runtime.js"; -const PERSISTENT_NAMESPACE = "signal.approval-reactions"; +const PERSISTENT_NAMESPACE = "signal.approval-reactions.v2"; const PERSISTENT_MAX_ENTRIES = 1000; const DEFAULT_REACTION_TARGET_TTL_MS = 24 * 60 * 60 * 1000; @@ -58,6 +62,19 @@ type SignalApprovalReactionTarget = ApprovalReactionTargetRecord; +}; + let resolverRuntimePromise: Promise | undefined; const signalApprovalReactionTargets = @@ -320,7 +337,7 @@ export function addSignalApprovalReactionHintToText(params: { text: string; allowedDecisions: readonly ExecApprovalReplyDecision[]; }): string { - if (/(^|\n)React with:\s*(\n|$)/i.test(params.text)) { + if (hasSignalApprovalReactionHintText(params.text)) { return params.text; } const hint = buildSignalApprovalReactionHint(params.allowedDecisions); @@ -329,40 +346,8 @@ export function addSignalApprovalReactionHintToText(params: { : params.text; } -function normalizeApprovalDecision(value: string): ExecApprovalReplyDecision | null { - const normalized = value.trim().toLowerCase(); - if (normalized === "always") { - return "allow-always"; - } - if (normalized === "allow-once" || normalized === "allow-always" || normalized === "deny") { - return normalized; - } - return null; -} - -export function extractSignalApprovalPromptBinding(text: string): { - approvalId: string; - allowedDecisions: ExecApprovalReplyDecision[]; -} | null { - const allowedDecisions: ExecApprovalReplyDecision[] = []; - let approvalId = ""; - for (const line of text.split(/\r?\n/)) { - const match = line.match(/\/approve(?:@[^\s]+)?\s+([A-Za-z0-9][A-Za-z0-9._:-]*)\s+(.+)$/i); - if (!match) { - continue; - } - if (approvalId && match[1] !== approvalId) { - continue; - } - approvalId ||= match[1]; - for (const decisionText of match[2].split(/[\s|,]+/)) { - const decision = normalizeApprovalDecision(decisionText); - if (decision && !allowedDecisions.includes(decision)) { - allowedDecisions.push(decision); - } - } - } - return approvalId && allowedDecisions.length > 0 ? { approvalId, allowedDecisions } : null; +function hasSignalApprovalReactionHintText(text?: string | null): boolean { + return /(^|\n)React with:\s*(\n|$)/i.test(text ?? ""); } function buildTargetRoute(params: { @@ -370,6 +355,7 @@ function buildTargetRoute(params: { accountId?: string | null; to: string; approvalId: string; + approvalKind?: ApprovalKind; agentId?: string | null; sessionKey?: string | null; }): Extract | null { @@ -393,7 +379,7 @@ function buildTargetRoute(params: { return isSignalApprovalReactionRouteStillEnabled({ cfg: params.cfg, target: { - approvalKind: resolveApprovalKindFromId(params.approvalId), + approvalKind: params.approvalKind ?? resolveApprovalKindFromId(params.approvalId), route, }, }) @@ -401,64 +387,6 @@ function buildTargetRoute(params: { : null; } -export function shouldAppendSignalApprovalReactionHintForOutboundMessage(params: { - cfg: OpenClawConfig; - accountId?: string | null; - to: string; - text: string; - targetAuthor?: string | null; - targetAuthorUuid?: string | null; - agentId?: string | null; - sessionKey?: string | null; -}): boolean { - const binding = extractSignalApprovalPromptBinding(params.text); - if (!binding) { - return false; - } - if (resolveSignalApprovalTargetAuthorKeys(params).length === 0) { - return false; - } - if (!hasSignalApprovalReactionApprovers({ cfg: params.cfg, accountId: params.accountId })) { - return false; - } - return Boolean( - buildTargetRoute({ - cfg: params.cfg, - accountId: params.accountId, - to: params.to, - approvalId: binding.approvalId, - agentId: params.agentId, - sessionKey: params.sessionKey, - }), - ); -} - -export function appendSignalApprovalReactionHintForOutboundMessage(params: { - cfg: OpenClawConfig; - accountId?: string | null; - to: string; - text: string; - targetAuthor?: string | null; - targetAuthorUuid?: string | null; - agentId?: string | null; - sessionKey?: string | null; -}): string { - const binding = extractSignalApprovalPromptBinding(params.text); - if ( - !binding || - !shouldAppendSignalApprovalReactionHintForOutboundMessage({ - ...params, - text: params.text, - }) - ) { - return params.text; - } - return addSignalApprovalReactionHintToText({ - text: params.text, - allowedDecisions: binding.allowedDecisions, - }); -} - export function hasSignalApprovalReactionApprovers(params: { cfg: OpenClawConfig; accountId?: string | null; @@ -471,6 +399,7 @@ export function registerSignalApprovalReactionTarget(params: { conversationKey: string; messageId: string; approvalId: string; + approvalKind?: ApprovalKind; allowedDecisions: readonly ExecApprovalReplyDecision[]; targetAuthorKeys: readonly string[]; route: SignalApprovalReactionRoute; @@ -521,7 +450,7 @@ export function registerSignalApprovalReactionTarget(params: { } satisfies SignalApprovalReactionRoute); const target: SignalApprovalReactionTarget = { approvalId, - approvalKind: resolveApprovalKindFromId(approvalId), + approvalKind: params.approvalKind ?? resolveApprovalKindFromId(approvalId), allowedDecisions, targetAuthorKeys, route, @@ -530,50 +459,142 @@ export function registerSignalApprovalReactionTarget(params: { return target; } -export function registerSignalApprovalReactionTargetForOutboundMessage(params: { +export function addSignalApprovalReactionHintToStructuredPayload(params: { cfg: OpenClawConfig; - accountId: string; + accountId?: string | null; to: string; - messageId: string; - text: string; + payload: ReplyPayload; targetAuthor?: string | null; targetAuthorUuid?: string | null; - agentId?: string | null; - sessionKey?: string | null; - ttlMs?: number; -}): boolean { - const binding = extractSignalApprovalPromptBinding(params.text); - if (!binding) { - return false; +}): ReplyPayload | null { + const metadata = getExecApprovalReplyMetadata(params.payload); + if (!metadata?.allowedDecisions || metadata.allowedDecisions.length === 0) { + return null; } - const conversationKey = resolveSignalApprovalConversationKey(params.to); - if (!conversationKey) { - return false; + if (resolveSignalApprovalTargetAuthorKeys(params).length === 0) { + return null; + } + if (!hasSignalApprovalReactionApprovers({ cfg: params.cfg, accountId: params.accountId })) { + return null; } const route = buildTargetRoute({ cfg: params.cfg, accountId: params.accountId, to: params.to, - approvalId: binding.approvalId, - agentId: params.agentId, - sessionKey: params.sessionKey, + approvalId: metadata.approvalId, + approvalKind: metadata.approvalKind, + agentId: metadata.agentId, + sessionKey: metadata.sessionKey, + }); + if (!route || !params.payload.text) { + return null; + } + return { + ...params.payload, + text: addSignalApprovalReactionHintToText({ + text: params.payload.text, + allowedDecisions: metadata.allowedDecisions, + }), + }; +} + +function readSignalDeliveryVisibleText(result: SignalApprovalDeliveryResult): string | null { + const meta = result.meta; + const visibleText = meta?.signalVisibleText ?? meta?.visibleText; + return typeof visibleText === "string" ? visibleText : null; +} + +function listDeliveredSignalMessageIdsWithVisibleHint(params: { + payload: ReplyPayload; + results: readonly SignalApprovalDeliveryResult[]; +}): string[] { + const signalResults = params.results.filter( + (result) => !result.channel || normalizeLowercaseStringOrEmpty(result.channel) === "signal", + ); + const resultsWithVisibleText = signalResults.filter( + (result) => readSignalDeliveryVisibleText(result) !== null, + ); + const candidates = resultsWithVisibleText.length > 0 ? resultsWithVisibleText : signalResults; + if (resultsWithVisibleText.length === 0 && candidates.length !== 1) { + return []; + } + const ids = candidates + .filter((result) => + resultsWithVisibleText.length > 0 + ? hasSignalApprovalReactionHintText(readSignalDeliveryVisibleText(result)) + : hasSignalApprovalReactionHintText(params.payload.text), + ) + .map((result) => normalizeOptionalString(result.messageId)) + .filter((messageId): messageId is string => Boolean(messageId && messageId !== "unknown")); + return Array.from(new Set(ids)); +} + +export function registerSignalApprovalReactionTargetForDeliveredPayload(params: { + cfg: OpenClawConfig; + target: SignalApprovalDeliveryTarget; + payload: ReplyPayload; + results: readonly SignalApprovalDeliveryResult[]; + targetAuthor?: string | null; + targetAuthorUuid?: string | null; + ttlMs?: number; +}): boolean { + if (normalizeLowercaseStringOrEmpty(params.target.channel) !== "signal") { + return false; + } + const metadata = getExecApprovalReplyMetadata(params.payload); + if (!metadata?.allowedDecisions || metadata.allowedDecisions.length === 0) { + return false; + } + if (!hasSignalApprovalReactionHintText(params.payload.text)) { + return false; + } + if ( + !hasSignalApprovalReactionApprovers({ cfg: params.cfg, accountId: params.target.accountId }) + ) { + return false; + } + const conversationKey = resolveSignalApprovalConversationKey(params.target.to); + if (!conversationKey) { + return false; + } + const route = buildTargetRoute({ + cfg: params.cfg, + accountId: params.target.accountId, + to: params.target.to, + approvalId: metadata.approvalId, + approvalKind: metadata.approvalKind, + agentId: metadata.agentId, + sessionKey: metadata.sessionKey, }); if (!route) { return false; } - return Boolean( - registerSignalApprovalReactionTarget({ - accountId: params.accountId, - conversationKey, - messageId: params.messageId, - approvalId: binding.approvalId, - allowedDecisions: binding.allowedDecisions, - targetAuthorKeys: resolveSignalApprovalTargetAuthorKeys(params), - route, - routeAllowed: true, - ttlMs: params.ttlMs, - }), - ); + const targetAuthorKeys = resolveSignalApprovalTargetAuthorKeys(params); + if (targetAuthorKeys.length === 0) { + return false; + } + let registered = false; + for (const messageId of listDeliveredSignalMessageIdsWithVisibleHint({ + payload: params.payload, + results: params.results, + })) { + registered = + Boolean( + registerSignalApprovalReactionTarget({ + accountId: normalizeAccountId(params.target.accountId ?? undefined), + conversationKey, + messageId, + approvalId: metadata.approvalId, + approvalKind: metadata.approvalKind, + allowedDecisions: metadata.allowedDecisions, + targetAuthorKeys, + route, + routeAllowed: true, + ttlMs: params.ttlMs, + }), + ) || registered; + } + return registered; } export function unregisterSignalApprovalReactionTarget(params: { diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 8ad5a639fb6c..a271935395ff 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -1,6 +1,7 @@ // Signal plugin module implements channel behavior. import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; +import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-contract"; import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; import { defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-outbound"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-outbound"; @@ -40,10 +41,12 @@ import { } from "./shared.js"; type SignalSendFn = typeof import("./send.runtime.js").sendMessageSignal; type SignalProbe = import("./probe.js").SignalProbe; +type SignalApprovalReactionsModule = typeof import("./approval-reactions.js"); let signalMonitorModulePromise: Promise | null = null; let signalProbeModulePromise: Promise | null = null; let signalSendRuntimePromise: Promise | null = null; +let signalApprovalReactionsModulePromise: Promise | null = null; async function loadSignalMonitorModule() { signalMonitorModulePromise ??= import("./monitor.js"); @@ -60,6 +63,11 @@ async function loadSignalSendRuntime() { return await signalSendRuntimePromise; } +async function loadSignalApprovalReactionsModule() { + signalApprovalReactionsModulePromise ??= import("./approval-reactions.js"); + return await signalApprovalReactionsModulePromise; +} + async function resolveSignalSendContext(params: { cfg: Parameters[0]["cfg"]; accountId?: string; @@ -102,6 +110,20 @@ type SignalMessageContextExtras = { deps?: { [channelId: string]: unknown }; }; +function attachSignalVisibleText(result: T, visibleText: string) { + const meta = + "meta" in result && result.meta && typeof result.meta === "object" + ? (result.meta as Record) + : {}; + return { + ...result, + meta: { + ...meta, + signalVisibleText: visibleText, + }, + }; +} + const signalMessageAdapter = defineChannelMessageAdapter({ id: "signal", durableFinal: { @@ -224,7 +246,7 @@ async function sendFormattedSignalText(ctx: { textMode: "plain", textStyles: chunk.styles, }); - results.push(result); + results.push(attachSignalVisibleText(result, chunk.text)); } return attachChannelToResults("signal", results); } @@ -267,7 +289,49 @@ async function sendFormattedSignalMedia(ctx: { textMode: "plain", textStyles: formatted.styles, }); - return attachChannelToResult("signal", result); + return attachChannelToResult("signal", attachSignalVisibleText(result, formatted.text)); +} + +async function registerDeliveredSignalApprovalPayloadForReactions( + params: Parameters>[0], +) { + const account = resolveSignalAccount({ + cfg: params.cfg, + accountId: params.target.accountId ?? undefined, + }); + if (!account.config.account) { + return; + } + const { registerSignalApprovalReactionTargetForDeliveredPayload } = + await loadSignalApprovalReactionsModule(); + registerSignalApprovalReactionTargetForDeliveredPayload({ + cfg: params.cfg, + target: params.target, + payload: params.payload, + results: params.results, + targetAuthor: account.config.account, + }); +} + +async function renderSignalApprovalPayloadForReactions( + params: Parameters>[0], +) { + const account = resolveSignalAccount({ + cfg: params.ctx.cfg, + accountId: params.ctx.accountId ?? undefined, + }); + if (!account.config.account) { + return null; + } + const { addSignalApprovalReactionHintToStructuredPayload } = + await loadSignalApprovalReactionsModule(); + return addSignalApprovalReactionHintToStructuredPayload({ + cfg: params.ctx.cfg, + accountId: params.ctx.accountId ?? undefined, + to: params.ctx.to, + payload: params.payload, + targetAuthor: account.config.account, + }); } export const signalPlugin: ChannelPlugin = @@ -404,6 +468,9 @@ export const signalPlugin: ChannelPlugin = payload, hint, }), + afterDeliverPayload: async (params) => + await registerDeliveredSignalApprovalPayloadForReactions(params), + renderPresentation: async (params) => await renderSignalApprovalPayloadForReactions(params), sendFormattedText: async ({ cfg, to, text, accountId, deps, abortSignal }) => await sendFormattedSignalText({ cfg, diff --git a/extensions/signal/src/core.test.ts b/extensions/signal/src/core.test.ts index aa4225486bba..45b9e03103ef 100644 --- a/extensions/signal/src/core.test.ts +++ b/extensions/signal/src/core.test.ts @@ -1,3 +1,4 @@ +import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/approval-reply-runtime"; // Signal tests cover core plugin behavior. import { createMessageReceiptFromOutboundResults, @@ -6,6 +7,10 @@ import { import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { createPluginSetupWizardStatus } from "openclaw/plugin-sdk/plugin-test-runtime"; import { describe, expect, it, vi } from "vitest"; +import { + clearSignalApprovalReactionTargetsForTest, + resolveSignalApprovalReactionTargetWithPersistence, +} from "./approval-reactions.js"; import { signalPlugin } from "./channel.js"; import * as clientModule from "./client-adapter.js"; import { classifySignalCliLogLine } from "./daemon.js"; @@ -18,6 +23,7 @@ import { import { probeSignal } from "./probe.js"; import { clearSignalRuntime } from "./runtime.js"; import { + createSignalCliPathTextInput, normalizeSignalAccountInput, parseSignalAllowFromEntries, signalDmPolicy, @@ -209,6 +215,13 @@ describe("probeSignal", () => { expect(status.configured).toBe(true); }); + + it("does not show a second missing-binary note before the cliPath prompt", () => { + const input = createSignalCliPathTextInput(async () => true); + + expect(input.helpLines).toBeUndefined(); + expect(input.helpTitle).toBeUndefined(); + }); }); describe("signal outbound", () => { @@ -264,6 +277,143 @@ describe("signal outbound", () => { ).toBe(true); }); + it("registers structured approval payloads for reactions after delivery", async () => { + clearSignalApprovalReactionTargetsForTest(); + const cfg = { + channels: { + signal: { + account: "+15550009999", + allowFrom: ["+15551230000"], + }, + }, + approvals: { + exec: { + enabled: true, + mode: "targets", + targets: [{ channel: "signal", to: "+15551230000" }], + }, + }, + } as OpenClawConfig; + const payload = buildExecApprovalPendingReplyPayload({ + approvalId: "exec-after-delivery", + approvalSlug: "exec-aft", + allowedDecisions: ["allow-once", "deny"], + command: "printf test", + host: "gateway", + agentId: "main", + sessionKey: "agent:main:signal:direct:+15551230000", + }); + const rendered = await signalPlugin.outbound?.renderPresentation?.({ + payload, + presentation: payload.presentation!, + ctx: { + cfg, + to: "+15551230000", + text: payload.text ?? "", + accountId: "default", + payload, + }, + }); + expect(rendered?.text).toContain("React with:\n\n👍 Allow Once\n👎 Deny"); + + await signalPlugin.outbound?.afterDeliverPayload?.({ + cfg, + target: { + channel: "signal", + to: "+15551230000", + accountId: "default", + }, + payload: rendered!, + results: [ + { + channel: "signal", + messageId: "1700000000099", + }, + ], + }); + + await expect( + resolveSignalApprovalReactionTargetWithPersistence({ + accountId: "default", + conversationKey: "+15551230000", + messageId: "1700000000099", + reactionKey: "👍", + targetAuthor: "+15550009999", + }), + ).resolves.toEqual({ + approvalId: "exec-after-delivery", + approvalKind: "exec", + decision: "allow-once", + route: { + deliveryMode: "target", + to: "+15551230000", + accountId: "default", + agentId: "main", + sessionKey: "agent:main:signal:direct:+15551230000", + }, + }); + }); + + it("renders reaction hints only from structured approval payloads", async () => { + const cfg = { + channels: { + signal: { + account: "+15550009999", + allowFrom: ["+15551230000"], + }, + }, + approvals: { + exec: { + enabled: true, + mode: "targets", + targets: [{ channel: "signal", to: "+15551230000" }], + }, + }, + } as OpenClawConfig; + const payload = buildExecApprovalPendingReplyPayload({ + approvalId: "exec-rendered-approval", + approvalSlug: "exec-ren", + allowedDecisions: ["allow-once", "deny"], + command: "printf test", + host: "gateway", + }); + const rendered = await signalPlugin.outbound?.renderPresentation?.({ + payload, + presentation: payload.presentation!, + ctx: { + cfg, + to: "+15551230000", + text: payload.text ?? "", + accountId: "default", + payload, + }, + }); + + expect(rendered?.text).toContain("React with:\n\n👍 Allow Once\n👎 Deny"); + expect( + await signalPlugin.outbound?.renderPresentation?.({ + payload: { + text: [ + "The docs show this example:", + "Exec approval required", + "ID: exec-rendered-approval", + "", + "Reply with: /approve exec-rendered-approval allow-once|deny", + ].join("\n"), + presentation: payload.presentation, + }, + presentation: payload.presentation!, + ctx: { + cfg, + to: "+15551230000", + text: payload.text ?? "", + accountId: "default", + payload, + }, + }), + ).toBeNull(); + }); + it("declares message adapter durable text and media with receipt proofs", async () => { const send = vi.fn(async (_to: string, _text: string, opts: { mediaUrl?: string } = {}) => { const messageId = opts.mediaUrl ? "signal-media-1" : "signal-text-1"; diff --git a/extensions/signal/src/daemon.ts b/extensions/signal/src/daemon.ts index f8f477a1ad49..d14d24fd9919 100644 --- a/extensions/signal/src/daemon.ts +++ b/extensions/signal/src/daemon.ts @@ -116,6 +116,8 @@ function buildDaemonArgs(opts: SignalDaemonOpts): string[] { export function spawnSignalDaemon(opts: SignalDaemonOpts): SignalDaemonHandle { const args = buildDaemonArgs(opts); + // The executable is operator-selected or setup-discovered signal-cli. + // Runtime message content only flows through the daemon HTTP API, not argv. const child = spawn(opts.cliPath, args, { stdio: ["ignore", "pipe", "pipe"], }); diff --git a/extensions/signal/src/install-signal-cli.test.ts b/extensions/signal/src/install-signal-cli.test.ts index 74c027030aa7..be6f33c15044 100644 --- a/extensions/signal/src/install-signal-cli.test.ts +++ b/extensions/signal/src/install-signal-cli.test.ts @@ -5,20 +5,36 @@ import path from "node:path"; import JSZip from "jszip"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import * as tar from "tar"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ReleaseAsset } from "./install-signal-cli.js"; -const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({ - fetchWithSsrFGuardMock: vi.fn(), -})); +const { fetchWithSsrFGuardMock, resolveBrewExecutableMock, runPluginCommandWithTimeoutMock } = + vi.hoisted(() => ({ + fetchWithSsrFGuardMock: vi.fn(), + resolveBrewExecutableMock: vi.fn(), + runPluginCommandWithTimeoutMock: vi.fn(), + })); vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ fetchWithSsrFGuard: fetchWithSsrFGuardMock, })); +vi.mock("openclaw/plugin-sdk/setup-tools", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveBrewExecutable: resolveBrewExecutableMock, + }; +}); + +vi.mock("openclaw/plugin-sdk/run-command", () => ({ + runPluginCommandWithTimeout: runPluginCommandWithTimeoutMock, +})); + const { downloadToFile, extractSignalCliArchive, + installSignalCli, installSignalCliFromRelease, looksLikeArchive, pickAsset, @@ -74,6 +90,8 @@ async function withTempFile(run: (filePath: string) => Promise) { beforeEach(() => { fetchWithSsrFGuardMock.mockReset(); + resolveBrewExecutableMock.mockReset(); + runPluginCommandWithTimeoutMock.mockReset(); }); function requireAsset(asset: ReleaseAsset | undefined, label: string): ReleaseAsset { @@ -143,6 +161,25 @@ describe("pickAsset", () => { const result = requireAsset(pickAsset(SAMPLE_ASSETS, "darwin", "x64"), "darwin x64"); expect(result.name).toContain("macOS-native"); }); + + it("does not fall back to Linux client archives when macOS assets are absent", () => { + const currentUpstreamAssets: ReleaseAsset[] = [ + { + name: "signal-cli-0.14.5-Linux-client.tar.gz", + browser_download_url: "https://example.com/linux-client.tar.gz", + }, + { + name: "signal-cli-0.14.5-Linux-native.tar.gz", + browser_download_url: "https://example.com/linux-native.tar.gz", + }, + { + name: "signal-cli-0.14.5.tar.gz", + browser_download_url: "https://example.com/jvm.tar.gz", + }, + ]; + + expect(pickAsset(currentUpstreamAssets, "darwin", "arm64")).toBeUndefined(); + }); }); describe("win32", () => { @@ -305,6 +342,46 @@ describe("installSignalCliFromRelease", () => { }); }); +describe("installSignalCli", () => { + const originalPlatform = process.platform; + const originalArch = process.arch; + + function setProcessPlatform(platform: NodeJS.Platform, arch: string) { + Object.defineProperty(process, "platform", { configurable: true, value: platform }); + Object.defineProperty(process, "arch", { configurable: true, value: arch }); + } + + afterEach(() => { + Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform }); + Object.defineProperty(process, "arch", { configurable: true, value: originalArch }); + }); + + it("uses Homebrew on macOS instead of downloading the first GitHub release archive", async () => { + setProcessPlatform("darwin", "arm64"); + const brewPrefix = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-brew-")); + await fs.mkdir(path.join(brewPrefix, "bin"), { recursive: true }); + await fs.writeFile(path.join(brewPrefix, "bin", "signal-cli"), ""); + resolveBrewExecutableMock.mockReturnValue("/opt/homebrew/bin/brew"); + runPluginCommandWithTimeoutMock + .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) + .mockResolvedValueOnce({ code: 0, stdout: `${brewPrefix}\n`, stderr: "" }) + .mockResolvedValueOnce({ code: 0, stdout: "signal-cli 0.14.5\n", stderr: "" }); + + try { + const result = await installSignalCli({ log: vi.fn() } as unknown as RuntimeEnv); + + expect(result).toEqual({ + ok: true, + cliPath: path.join(brewPrefix, "bin", "signal-cli"), + version: "0.14.5", + }); + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); + } finally { + await fs.rm(brewPrefix, { recursive: true, force: true }); + } + }); +}); + describe("extractSignalCliArchive", () => { async function withArchiveWorkspace(run: (workDir: string) => Promise) { const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-install-")); diff --git a/extensions/signal/src/install-signal-cli.ts b/extensions/signal/src/install-signal-cli.ts index 3b79e653bbd0..419ddd4393d0 100644 --- a/extensions/signal/src/install-signal-cli.ts +++ b/extensions/signal/src/install-signal-cli.ts @@ -105,7 +105,7 @@ export function pickAsset( } if (platform === "darwin") { - return byName(/macos|osx|darwin/) || archives[0]; + return byName(/macos|osx|darwin/); } if (platform === "win32") { @@ -228,7 +228,7 @@ async function installSignalCliViaBrew(runtime: RuntimeEnv): Promise ({ + sendMessageSignal: vi.fn(), +})); + +vi.mock("./send.js", async () => { + const actual = await vi.importActual("./send.js"); + return { + ...actual, + sendMessageSignal: sendMocks.sendMessageSignal, + }; +}); + +const { deliverReplies } = await import("./monitor.js"); + +const botAccount = "+15550009999"; +const approver = "+15551230000"; +const cfg = { + channels: { + signal: { + account: botAccount, + allowFrom: [approver], + }, + }, + approvals: { + exec: { + enabled: true, + mode: "targets", + targets: [{ channel: "signal", to: approver }], + }, + }, +} as OpenClawConfig; + +async function deliverReplyPayload(payload: ReplyPayload) { + await deliverReplies({ + cfg, + replies: [payload], + target: approver, + baseUrl: "http://127.0.0.1:8080", + account: botAccount, + accountId: "default", + runtime: { log: vi.fn() } as never, + maxBytes: 8 * 1024 * 1024, + textLimit: 4000, + chunkMode: "length", + }); +} + +describe("Signal monitor approval reply delivery", () => { + beforeEach(() => { + clearSignalApprovalReactionTargetsForTest(); + sendMocks.sendMessageSignal.mockReset().mockResolvedValue({ + messageId: "1700000000200", + }); + }); + + it("adds reaction hints and registers structured approval replies delivered by the monitor", async () => { + const payload = buildExecApprovalPendingReplyPayload({ + approvalId: "exec-monitor-structured", + approvalSlug: "exec-mon", + allowedDecisions: ["allow-once", "deny"], + command: "printf monitor", + host: "gateway", + agentId: "main", + sessionKey: "agent:main:signal:direct:+15551230000", + }); + + await deliverReplyPayload(payload); + + const sentText = String(sendMocks.sendMessageSignal.mock.calls[0]?.[1] ?? ""); + expect(sentText).toContain("React with:\n\n👍 Allow Once\n👎 Deny"); + await expect( + resolveSignalApprovalReactionTargetWithPersistence({ + accountId: "default", + conversationKey: approver, + messageId: "1700000000200", + reactionKey: "👍", + targetAuthor: botAccount, + }), + ).resolves.toEqual({ + approvalId: "exec-monitor-structured", + approvalKind: "exec", + decision: "allow-once", + route: { + deliveryMode: "target", + to: approver, + accountId: "default", + agentId: "main", + sessionKey: "agent:main:signal:direct:+15551230000", + }, + }); + }); + + it("does not bind ordinary monitor replies that quote approval commands", async () => { + const payload = { + text: [ + "The docs show this example:", + "Exec approval required", + "ID: exec-monitor-quoted", + "", + "Reply with: /approve exec-monitor-quoted allow-once|deny", + ].join("\n"), + }; + + await deliverReplyPayload(payload); + + const sentText = String(sendMocks.sendMessageSignal.mock.calls[0]?.[1] ?? ""); + expect(sentText).not.toContain("React with:"); + await expect( + resolveSignalApprovalReactionTargetWithPersistence({ + accountId: "default", + conversationKey: approver, + messageId: "1700000000200", + reactionKey: "👍", + targetAuthor: botAccount, + }), + ).resolves.toBeNull(); + }); +}); diff --git a/extensions/signal/src/monitor.ts b/extensions/signal/src/monitor.ts index bf5cc395a714..5f2c01580de7 100644 --- a/extensions/signal/src/monitor.ts +++ b/extensions/signal/src/monitor.ts @@ -39,6 +39,10 @@ import { normalizeE164 } from "openclaw/plugin-sdk/text-utility-runtime"; import { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runtime"; import { resolveSignalAccount } from "./accounts.js"; import { isSignalNativeApprovalHandlerConfigured } from "./approval-native.js"; +import { + addSignalApprovalReactionHintToStructuredPayload, + registerSignalApprovalReactionTargetForDeliveredPayload, +} from "./approval-reactions.js"; import { signalRpcRequest, signalCheck } from "./client-adapter.js"; import { formatSignalDaemonExit, spawnSignalDaemon, type SignalDaemonHandle } from "./daemon.js"; import { isSignalSenderAllowed, type resolveSignalSender } from "./identity.js"; @@ -354,7 +358,7 @@ async function fetchAttachment(params: { return { path: saved.path, contentType: saved.contentType }; } -async function deliverReplies(params: { +export async function deliverReplies(params: { cfg: OpenClawConfig; replies: ReplyPayload[]; target: string; @@ -369,32 +373,79 @@ async function deliverReplies(params: { const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit, chunkMode } = params; for (const payload of replies) { - const reply = resolveSendableOutboundReplyParts(payload); + const deliveryResults: Array<{ + channel: "signal"; + messageId: string; + meta: { signalVisibleText: string }; + }> = []; + const deliveredPayload = + addSignalApprovalReactionHintToStructuredPayload({ + cfg: params.cfg, + accountId, + to: target, + payload, + targetAuthor: account, + }) ?? payload; + const reply = resolveSendableOutboundReplyParts(deliveredPayload); + const recordDeliveryResult = ( + result: Awaited>, + visibleText: string, + ) => { + const messageId = + typeof result?.messageId === "string" && result.messageId.trim() + ? result.messageId.trim() + : null; + if (messageId) { + deliveryResults.push({ + channel: "signal", + messageId, + meta: { signalVisibleText: visibleText }, + }); + } + }; const delivered = await deliverTextOrMediaReply({ - payload, + payload: deliveredPayload, text: reply.text, chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode), sendText: async (chunk) => { - await sendMessageSignal(target, chunk, { - cfg: params.cfg, - baseUrl, - account, - maxBytes, - accountId, - }); + recordDeliveryResult( + await sendMessageSignal(target, chunk, { + cfg: params.cfg, + baseUrl, + account, + maxBytes, + accountId, + }), + chunk, + ); }, sendMedia: async ({ mediaUrl, caption }) => { - await sendMessageSignal(target, caption ?? "", { - cfg: params.cfg, - baseUrl, - account, - mediaUrl, - maxBytes, - accountId, - }); + const visibleText = caption ?? ""; + recordDeliveryResult( + await sendMessageSignal(target, visibleText, { + cfg: params.cfg, + baseUrl, + account, + mediaUrl, + maxBytes, + accountId, + }), + visibleText, + ); }, }); if (delivered !== "empty") { + registerSignalApprovalReactionTargetForDeliveredPayload({ + cfg: params.cfg, + target: { + channel: "signal", + to: target, + accountId, + }, + payload: deliveredPayload, + results: deliveryResults, + targetAuthor: account, + }); runtime.log?.(`delivered reply to ${target}`); } } diff --git a/extensions/signal/src/send.test.ts b/extensions/signal/src/send.test.ts index 8481926cf2e8..51b043be9bee 100644 --- a/extensions/signal/src/send.test.ts +++ b/extensions/signal/src/send.test.ts @@ -129,4 +129,73 @@ describe("sendMessageSignal receipts", () => { expect(result.messageId).toBe("unknown"); expect(result.receipt.platformMessageIds).toStrictEqual([]); }); + + it("does not add approval reactions to ordinary outbound approval-looking text", async () => { + signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567892 }); + const text = [ + "Here is the command you asked about:", + "/approve exec-live-approval allow-once|deny", + ].join("\n"); + + await sendMessageSignal("+15551234567", text, { + cfg: { + ...SIGNAL_TEST_CFG, + channels: { + signal: { + ...SIGNAL_TEST_CFG.channels.signal, + allowFrom: ["+15551234567"], + }, + }, + approvals: { + exec: { + enabled: true, + mode: "targets", + targets: [{ channel: "signal", to: "+15551234567" }], + }, + }, + }, + }); + + expect(signalRpcRequestMock).toHaveBeenCalledWith( + "send", + expect.objectContaining({ message: text }), + expect.any(Object), + ); + }); + + it("does not add approval reactions to ordinary outbound text quoting a full prompt", async () => { + signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567893 }); + const text = [ + "The docs show this example:", + "Exec approval required", + "ID: exec-live-approval", + "", + "Reply with: /approve exec-live-approval allow-once|deny", + ].join("\n"); + + await sendMessageSignal("+15551234567", text, { + cfg: { + ...SIGNAL_TEST_CFG, + channels: { + signal: { + ...SIGNAL_TEST_CFG.channels.signal, + allowFrom: ["+15551234567"], + }, + }, + approvals: { + exec: { + enabled: true, + mode: "targets", + targets: [{ channel: "signal", to: "+15551234567" }], + }, + }, + }, + }); + + expect(signalRpcRequestMock).toHaveBeenCalledWith( + "send", + expect.objectContaining({ message: text }), + expect.any(Object), + ); + }); }); diff --git a/extensions/signal/src/send.ts b/extensions/signal/src/send.ts index 57157eb19e0d..c24c300ef159 100644 --- a/extensions/signal/src/send.ts +++ b/extensions/signal/src/send.ts @@ -12,10 +12,6 @@ import { resolveOutboundAttachmentFromUrl } from "openclaw/plugin-sdk/media-runt import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveSignalAccount } from "./accounts.js"; -import { - appendSignalApprovalReactionHintForOutboundMessage, - registerSignalApprovalReactionTargetForOutboundMessage, -} from "./approval-reactions.js"; import { signalRpcRequest } from "./client-adapter.js"; import { markdownToSignalText, type SignalTextStyleRange } from "./format.js"; import { resolveSignalRpcContext } from "./rpc-context.js"; @@ -184,14 +180,7 @@ export async function sendMessageSignal( }); const { baseUrl, account } = resolveSignalRpcContext(opts, accountInfo); const target = parseTarget(to); - const outboundText = appendSignalApprovalReactionHintForOutboundMessage({ - cfg, - accountId: accountInfo.accountId, - to, - text: text ?? "", - targetAuthor: account, - }); - let message = outboundText; + let message = text ?? ""; let messageFromPlaceholder = false; let textStyles: SignalTextStyleRange[] = []; const textMode = opts.textMode ?? "markdown"; @@ -273,14 +262,6 @@ export async function sendMessageSignal( }); const timestamp = result?.timestamp; const messageId = timestamp ? String(timestamp) : "unknown"; - registerSignalApprovalReactionTargetForOutboundMessage({ - cfg, - accountId: accountInfo.accountId, - to, - messageId, - text: outboundText, - targetAuthor: account, - }); return { messageId, timestamp, diff --git a/extensions/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts index 9fc7785e24ac..4c6a2ead120f 100644 --- a/extensions/signal/src/setup-core.ts +++ b/extensions/signal/src/setup-core.ts @@ -193,10 +193,6 @@ export function createSignalCliPathTextInput( resolvePath: ({ cfg, accountId, credentialValues }) => resolveSignalCliPath({ cfg, accountId, credentialValues }), shouldPrompt, - helpTitle: "Signal", - helpLines: [ - "signal-cli not found. Install it, then rerun this step or set channels.signal.cliPath.", - ], }); } diff --git a/extensions/slack/src/monitor/slash.ts b/extensions/slack/src/monitor/slash.ts index 5b48f2b1aef0..6d3c957d5f44 100644 --- a/extensions/slack/src/monitor/slash.ts +++ b/extensions/slack/src/monitor/slash.ts @@ -20,7 +20,7 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; import { getRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-snapshot"; import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; -import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; +import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -112,14 +112,13 @@ function resolveSlackCommandMenuModelContext(params: { agentId: params.agentId, }); const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }); - const store = loadSessionStore(storePath); - const entry = store[params.sessionKey]; + const entry = getSessionEntry({ storePath, sessionKey: params.sessionKey }); if (entry?.modelOverrideSource === "auto" && normalizeOptionalString(entry.modelOverride)) { return { provider: defaultModel.provider, model: defaultModel.model }; } const override = resolveStoredModelOverride({ sessionEntry: entry, - sessionStore: store, + loadSessionEntry: (sessionKey) => getSessionEntry({ storePath, sessionKey }), sessionKey: params.sessionKey, defaultProvider: defaultModel.provider, }); diff --git a/extensions/tavily/src/tavily-client.test.ts b/extensions/tavily/src/tavily-client.test.ts index d5e6c3cd26ad..d0dce289e708 100644 --- a/extensions/tavily/src/tavily-client.test.ts +++ b/extensions/tavily/src/tavily-client.test.ts @@ -1,5 +1,6 @@ // Tavily tests cover tavily client plugin behavior. import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createStreamingResponse } from "../../test-support/streaming-error-response.js"; // Capture every call to postTrustedWebToolsJson so we can assert on extraHeaders. const postTrustedWebToolsJson = vi.fn(); @@ -61,6 +62,29 @@ describe("tavily client X-Client-Source header", () => { ); }); + it("bounds successful Tavily JSON bodies before parsing", async () => { + const streamed = createStreamingResponse({ + chunkCount: 32, + chunkSize: 1024 * 1024, + text: "x", + headers: { "content-type": "application/json" }, + }); + const jsonSpy = vi.spyOn(streamed.response, "json").mockRejectedValue(new Error("unbounded")); + + postTrustedWebToolsJson.mockImplementationOnce( + async (_params: unknown, parse: (r: Response) => Promise) => + parse(streamed.response), + ); + + await expect(runTavilySearch({ query: "test query" })).rejects.toThrow( + "Tavily Search: JSON response exceeds 16777216 bytes", + ); + + expect(streamed.getReadCount()).toBeLessThan(32); + expect(streamed.wasCanceled()).toBe(true); + expect(jsonSpy).not.toHaveBeenCalled(); + }); + it("runTavilyExtract sends X-Client-Source: openclaw", async () => { await runTavilyExtract({ urls: ["https://example.com"] }); diff --git a/extensions/tavily/src/tavily-client.ts b/extensions/tavily/src/tavily-client.ts index 63b1337cbcba..d3704b4fe0f6 100644 --- a/extensions/tavily/src/tavily-client.ts +++ b/extensions/tavily/src/tavily-client.ts @@ -1,5 +1,6 @@ // Tavily plugin module implements tavily client behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; import { DEFAULT_CACHE_TTL_MINUTES, normalizeCacheKey, @@ -26,6 +27,7 @@ const EXTRACT_CACHE = new Map< { value: Record; expiresAt: number; insertedAt: number } >(); const DEFAULT_SEARCH_COUNT = 5; +const TAVILY_EXTRACT_RESPONSE_MAX_BYTES = 64 * 1024 * 1024; export type TavilySearchParams = { cfg?: OpenClawConfig; @@ -73,6 +75,7 @@ async function postTavilyJson(params: { apiKey: string; body: Record; errorLabel: string; + responseMaxBytes?: number; }): Promise> { return postTrustedWebToolsJson( { @@ -83,19 +86,19 @@ async function postTavilyJson(params: { errorLabel: params.errorLabel, extraHeaders: { "X-Client-Source": "openclaw" }, }, - async (response) => readTavilyJsonResponse(response, params.errorLabel), + async (response) => + readTavilyJsonResponse(response, params.errorLabel, { + maxBytes: params.responseMaxBytes, + }), ); } async function readTavilyJsonResponse( response: Response, label: string, + opts?: { maxBytes?: number }, ): Promise> { - try { - return (await response.json()) as Record; - } catch (cause) { - throw new Error(`${label}: malformed JSON response`, { cause }); - } + return await readProviderJsonResponse>(response, label, opts); } export async function runTavilySearch( @@ -255,6 +258,8 @@ export async function runTavilyExtract( apiKey, body, errorLabel: "Tavily Extract", + // Extract can include raw page content and image lists, unlike search metadata. + responseMaxBytes: TAVILY_EXTRACT_RESPONSE_MAX_BYTES, }); const rawResults = Array.isArray(payload.results) ? payload.results : []; diff --git a/extensions/telegram/src/account-config.ts b/extensions/telegram/src/account-config.ts index d23a5e0087a3..921e762dabe9 100644 --- a/extensions/telegram/src/account-config.ts +++ b/extensions/telegram/src/account-config.ts @@ -90,6 +90,10 @@ export function mergeTelegramAccountConfig( baseAllowFrom: base.allowFrom, accountAllowFrom: account.allowFrom, }); + const capabilities = + Array.isArray(account.capabilities) && account.capabilities.length === 0 + ? base.capabilities + : (account.capabilities ?? base.capabilities); - return { ...base, ...account, allowFrom, groups }; + return { ...base, ...account, allowFrom, capabilities, groups }; } diff --git a/extensions/telegram/src/action-runtime.test.ts b/extensions/telegram/src/action-runtime.test.ts index a6189245f3ee..4d82b5e06413 100644 --- a/extensions/telegram/src/action-runtime.test.ts +++ b/extensions/telegram/src/action-runtime.test.ts @@ -1703,6 +1703,25 @@ describe("handleTelegramAction", () => { expect(sendMessageTelegram).toHaveBeenCalled(); }); + it("allows inline buttons when legacy capabilities are empty", async () => { + await handleTelegramAction( + { + action: "sendMessage", + to: "@testchannel", + content: "Choose", + presentation: { + blocks: [{ type: "buttons", buttons: [{ label: "Ok", value: "cmd:ok" }] }], + }, + }, + telegramConfig({ capabilities: [] }), + ); + const call = mockCall(sendMessageTelegram, 0, "empty legacy capabilities"); + expect(call[0]).toBe("@testchannel"); + expect(requireRecord(call[2], "empty legacy capabilities options").buttons).toEqual([ + [{ text: "Ok", callback_data: "cmd:ok" }], + ]); + }); + it("uses interactive button labels as fallback text when message text is omitted", async () => { await handleTelegramAction( { diff --git a/extensions/telegram/src/bot-core.ts b/extensions/telegram/src/bot-core.ts index d1c90f294c85..3d3d2de3006e 100644 --- a/extensions/telegram/src/bot-core.ts +++ b/extensions/telegram/src/bot-core.ts @@ -310,12 +310,11 @@ export function createTelegramBotCore( `agent:${agentId}:telegram:group:${buildTelegramGroupPeerId(params.chatId, params.messageThreadId)}`; const storePath = telegramDeps.resolveStorePath(cfg.session?.store, { agentId }); try { - const loadSessionStore = telegramDeps.loadSessionStore; - if (!loadSessionStore) { + const getSessionEntry = telegramDeps.getSessionEntry; + if (!getSessionEntry) { return undefined; } - const store = loadSessionStore(storePath); - const entry = store[sessionKey]; + const entry = getSessionEntry({ storePath, sessionKey }); if (entry?.groupActivation === "always") { return false; } diff --git a/extensions/telegram/src/bot-message-dispatch.runtime.ts b/extensions/telegram/src/bot-message-dispatch.runtime.ts index 33aaa8fcde18..cbb361dea65d 100644 --- a/extensions/telegram/src/bot-message-dispatch.runtime.ts +++ b/extensions/telegram/src/bot-message-dispatch.runtime.ts @@ -1,8 +1,8 @@ // Telegram plugin module implements bot message dispatch behavior. export { - loadSessionStore, - resolveSessionStoreEntry, + getSessionEntry, resolveStorePath, + type SessionEntry, } from "openclaw/plugin-sdk/session-store-runtime"; export { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; export { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 42f20b082ee9..ef550f07c6d6 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -83,6 +83,7 @@ const appendAssistantMirrorMessageByIdentity = vi.hoisted(() => messageId: "m1", })), ); +const getSessionEntry = vi.hoisted(() => vi.fn()); const loadSessionStore = vi.hoisted(() => vi.fn()); const readLatestAssistantTextByIdentity = vi.hoisted(() => vi.fn<() => Promise<{ text: string; timestamp?: number } | undefined>>(async () => undefined), @@ -102,11 +103,6 @@ const getAgentScopedMediaLocalRoots = vi.hoisted(() => ); const resolveChunkMode = vi.hoisted(() => vi.fn(() => undefined)); const resolveMarkdownTableMode = vi.hoisted(() => vi.fn(() => "preserve")); -const resolveSessionStoreEntry = vi.hoisted(() => - vi.fn(({ store, sessionKey }: { store: Record; sessionKey: string }) => ({ - existing: store[sessionKey], - })), -); vi.mock("./draft-stream.js", () => ({ createTelegramDraftStream, @@ -153,12 +149,11 @@ vi.mock("./send.js", () => ({ vi.mock("./bot-message-dispatch.runtime.js", () => ({ generateTopicLabel, + getSessionEntry, getAgentScopedMediaLocalRoots, - loadSessionStore, resolveAutoTopicLabelConfig: resolveAutoTopicLabelConfigRuntime, resolveChunkMode, resolveMarkdownTableMode, - resolveSessionStoreEntry, resolveStorePath, })); @@ -203,6 +198,7 @@ function installTelegramStateRuntimeForTest(): void { const telegramDepsForTest: TelegramBotDeps = { getRuntimeConfig: loadConfig as TelegramBotDeps["getRuntimeConfig"], resolveStorePath: resolveStorePath as TelegramBotDeps["resolveStorePath"], + getSessionEntry: getSessionEntry as TelegramBotDeps["getSessionEntry"], loadSessionStore: loadSessionStore as TelegramBotDeps["loadSessionStore"], readChannelAllowFromStore: readChannelAllowFromStore as TelegramBotDeps["readChannelAllowFromStore"], @@ -266,13 +262,13 @@ describe("dispatchTelegramMessage draft streaming", () => { wasSentByBot.mockReset(); appendAssistantMirrorMessageByIdentity.mockReset(); readLatestAssistantTextByIdentity.mockReset(); + getSessionEntry.mockReset(); loadSessionStore.mockReset(); resolveStorePath.mockReset(); generateTopicLabel.mockReset(); getAgentScopedMediaLocalRoots.mockClear(); resolveChunkMode.mockClear(); resolveMarkdownTableMode.mockClear(); - resolveSessionStoreEntry.mockClear(); describeStickerImage.mockReset(); loadModelCatalog.mockReset(); findModelInCatalog.mockReset(); @@ -325,6 +321,10 @@ describe("dispatchTelegramMessage draft streaming", () => { messageId: "m1", }); loadSessionStore.mockReturnValue({}); + getSessionEntry.mockImplementation( + ({ sessionKey }: { sessionKey: string }) => + (loadSessionStore() as Record)[sessionKey], + ); generateTopicLabel.mockResolvedValue("Topic label"); describeStickerImage.mockResolvedValue(null); loadModelCatalog.mockResolvedValue({}); @@ -712,7 +712,7 @@ describe("dispatchTelegramMessage draft streaming", () => { const preview = renderText?.("| A | B |\n| --- | --- |\n| 1 | 2 |"); expect(preview?.richMessage).toEqual( expect.objectContaining({ - html: expect.stringContaining(""), + html: expect.stringContaining("
"), }), ); }); diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index a4d64940af6a..d80c9005abde 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -72,11 +72,11 @@ import { deduplicateBlockSentMedia } from "./bot-message-dispatch.media-dedup.js import { generateTopicLabel, getAgentScopedMediaLocalRoots, - loadSessionStore, + getSessionEntry, resolveAutoTopicLabelConfig, resolveChunkMode, resolveMarkdownTableMode, - resolveSessionStoreEntry, + type SessionEntry, } from "./bot-message-dispatch.runtime.js"; import type { TelegramBotOptions } from "./bot.types.js"; import { deliverReplies, emitInternalMessageSentHook } from "./bot/delivery.js"; @@ -143,6 +143,7 @@ import { shouldSupersedeTelegramReplyFence, supersedeTelegramReplyFence, } from "./telegram-reply-fence.js"; +import { clipTelegramProgressText } from "./truncate.js"; export { resetTelegramReplyFenceForTests }; @@ -243,33 +244,37 @@ export type TelegramDispatchResult = type TelegramReasoningLevel = "off" | "on" | "stream"; type TelegramTranscriptMirrorPayload = { text?: string; mediaUrls?: string[] }; -type TelegramSessionStore = ReturnType; type TelegramScopedTranscriptSession = { sessionId: string; storePath: string }; -type FreshTelegramSessionStoreLoader = ((agentId: string) => { +type FreshTelegramSessionEntryLoader = (( + agentId: string, + sessionKey: string, +) => { storePath: string; - store: TelegramSessionStore; + entry?: SessionEntry; }) & { clear: () => void; }; -function createFreshTelegramSessionStoreLoader(params: { +function createFreshTelegramSessionEntryLoader(params: { cfg: OpenClawConfig; telegramDeps: TelegramBotDeps; -}): FreshTelegramSessionStoreLoader { - const storesByPath = new Map(); - const load = ((agentId: string) => { +}): FreshTelegramSessionEntryLoader { + const entriesByPathAndKey = new Map(); + const load = ((agentId: string, sessionKey: string) => { const storePath = params.telegramDeps.resolveStorePath(params.cfg.session?.store, { agentId }); - const cachedStore = storesByPath.get(storePath); - if (cachedStore) { - return { storePath, store: cachedStore }; + const cacheKey = `${storePath}\0${sessionKey}`; + if (entriesByPathAndKey.has(cacheKey)) { + return { storePath, entry: entriesByPathAndKey.get(cacheKey) }; } - const store = (params.telegramDeps.loadSessionStore ?? loadSessionStore)(storePath, { - skipCache: true, + const entry = (params.telegramDeps.getSessionEntry ?? getSessionEntry)({ + storePath, + sessionKey, + readConsistency: "latest", }); - storesByPath.set(storePath, store); - return { storePath, store }; - }) as FreshTelegramSessionStoreLoader; - load.clear = () => storesByPath.clear(); + entriesByPathAndKey.set(cacheKey, entry); + return { storePath, entry }; + }) as FreshTelegramSessionEntryLoader; + load.clear = () => entriesByPathAndKey.clear(); return load; } @@ -277,7 +282,7 @@ function resolveTelegramReasoningLevel(params: { cfg: OpenClawConfig; sessionKey?: string; agentId: string; - loadFreshSessionStore: FreshTelegramSessionStoreLoader; + loadFreshSessionEntry: FreshTelegramSessionEntryLoader; }): TelegramReasoningLevel { const { cfg, sessionKey, agentId } = params; const configDefault = resolveTelegramConfigReasoningDefault(cfg, agentId); @@ -285,8 +290,7 @@ function resolveTelegramReasoningLevel(params: { return configDefault; } try { - const { store } = params.loadFreshSessionStore(agentId); - const entry = resolveSessionStoreEntry({ store, sessionKey }).existing; + const { entry } = params.loadFreshSessionEntry(agentId, sessionKey); const level = entry?.reasoningLevel; if (level === "on" || level === "stream" || level === "off") { return level; @@ -317,11 +321,10 @@ function resolveTelegramMirroredTranscriptText( function resolveTelegramScopedTranscriptSession(params: { agentId: string; - loadFreshSessionStore: FreshTelegramSessionStoreLoader; + loadFreshSessionEntry: FreshTelegramSessionEntryLoader; sessionKey: string; }): TelegramScopedTranscriptSession | undefined { - const { store, storePath } = params.loadFreshSessionStore(params.agentId); - const entry = resolveSessionStoreEntry({ store, sessionKey: params.sessionKey }).existing; + const { entry, storePath } = params.loadFreshSessionEntry(params.agentId, params.sessionKey); const sessionId = entry?.sessionId?.trim(); return sessionId ? { sessionId, storePath } : undefined; } @@ -329,7 +332,7 @@ function resolveTelegramScopedTranscriptSession(params: { async function mirrorTelegramAssistantReplyToTranscript(params: { cfg: OpenClawConfig; idempotencyKey: string; - loadFreshSessionStore: FreshTelegramSessionStoreLoader; + loadFreshSessionEntry: FreshTelegramSessionEntryLoader; route: TelegramMessageContext["route"]; sessionKey: string; payload: TelegramTranscriptMirrorPayload; @@ -340,7 +343,7 @@ async function mirrorTelegramAssistantReplyToTranscript(params: { } const session = resolveTelegramScopedTranscriptSession({ agentId: params.route.agentId, - loadFreshSessionStore: params.loadFreshSessionStore, + loadFreshSessionEntry: params.loadFreshSessionEntry, sessionKey: params.sessionKey, }); if (!session) { @@ -364,22 +367,14 @@ async function mirrorTelegramAssistantReplyToTranscript(params: { } } -const MAX_PROGRESS_MARKDOWN_TEXT_CHARS = 300; const TELEGRAM_GENERAL_TOPIC_ID = 1; -function clipProgressMarkdownText(text: string): string { - if (text.length <= MAX_PROGRESS_MARKDOWN_TEXT_CHARS) { - return text; - } - return `${text.slice(0, MAX_PROGRESS_MARKDOWN_TEXT_CHARS - 1).trimEnd()}…`; -} - function sanitizeProgressMarkdownText(text: string): string { return text.replaceAll("`", "'"); } function formatProgressAsMarkdownCode(text: string): string { - const clipped = clipProgressMarkdownText(text); + const clipped = clipTelegramProgressText(text); return `\`${sanitizeProgressMarkdownText(clipped)}\``; } @@ -399,7 +394,7 @@ function escapeTelegramProgressHtml(text: string): string { } function renderTelegramProgressStringLine(text: string): string { - const clipped = clipProgressMarkdownText(text.trim()); + const clipped = clipTelegramProgressText(text.trim()); const italic = clipped.match(/^_(.*)_$/u); if (italic) { return `${escapeTelegramProgressHtml(italic[1] ?? "")}`; @@ -418,7 +413,7 @@ function renderTelegramProgressLine(line: ChannelProgressDraftCompositorLine): s const parts = [`${escapeTelegramProgressHtml(label)}`]; const detail = line.detail && line.detail !== line.label ? line.detail : undefined; if (detail) { - parts.push(`${escapeTelegramProgressHtml(clipProgressMarkdownText(detail))}`); + parts.push(`${escapeTelegramProgressHtml(clipTelegramProgressText(detail))}`); } else { const text = line.text.trim(); if (text && text !== label) { @@ -763,7 +758,7 @@ export const dispatchTelegramMessage = async ({ const dispatchContext = resolveDispatchTelegramContext({ cfg, context }); const telegramDeps = injectedTelegramDeps ?? (await import("./bot-deps.js")).defaultTelegramBotDeps; - const loadFreshSessionStore = createFreshTelegramSessionStoreLoader({ cfg, telegramDeps }); + const loadFreshSessionEntry = createFreshTelegramSessionEntryLoader({ cfg, telegramDeps }); const { ctxPayload, msg, @@ -899,7 +894,7 @@ export const dispatchTelegramMessage = async ({ cfg, sessionKey: ctxPayload.SessionKey, agentId: route.agentId, - loadFreshSessionStore, + loadFreshSessionEntry, }); const forceBlockStreamingForReasoning = resolvedReasoningLevel === "on"; const streamReasoningDraft = resolvedReasoningLevel === "stream"; @@ -1466,8 +1461,7 @@ export const dispatchTelegramMessage = async ({ return undefined; } try { - const { store, storePath } = loadFreshSessionStore(route.agentId); - const sessionEntry = resolveSessionStoreEntry({ store, sessionKey }).existing; + const { entry: sessionEntry, storePath } = loadFreshSessionEntry(route.agentId, sessionKey); if (!sessionEntry?.sessionId) { return undefined; } @@ -1515,7 +1509,7 @@ export const dispatchTelegramMessage = async ({ await mirrorTelegramAssistantReplyToTranscript({ cfg, idempotencyKey, - loadFreshSessionStore, + loadFreshSessionEntry, route, sessionKey, payload, @@ -1873,10 +1867,9 @@ export const dispatchTelegramMessage = async ({ if (isDmTopic) { try { - const { store } = loadFreshSessionStore(route.agentId); const sessionKeyLocal = ctxPayload.SessionKey; if (sessionKeyLocal) { - const entry = resolveSessionStoreEntry({ store, sessionKey: sessionKeyLocal }).existing; + const { entry } = loadFreshSessionEntry(route.agentId, sessionKeyLocal); isFirstTurnInSession = !entry?.systemSent; } else { logVerbose("auto-topic-label: SessionKey is absent, skipping first-turn detection"); @@ -1885,7 +1878,7 @@ export const dispatchTelegramMessage = async ({ logVerbose(`auto-topic-label: session store error: ${formatErrorMessage(err)}`); } } - loadFreshSessionStore.clear(); + loadFreshSessionEntry.clear(); if (statusReactionController && !isRoomEvent) { void statusReactionController.setThinking(); diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index fcae2b40591a..fcedfd1db579 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -572,6 +572,10 @@ describe("registerTelegramNativeCommands — session metadata", () => { ]); sessionMocks.getSessionEntry.mockClear().mockReturnValue(undefined); sessionMocks.loadSessionStore.mockClear().mockReturnValue({}); + sessionMocks.getSessionEntry.mockImplementation( + ({ storePath, sessionKey }: { storePath: string; sessionKey: string }) => + sessionMocks.loadSessionStore(storePath)[sessionKey], + ); sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined); sessionMocks.resolveSessionTranscriptLegacyFileTarget.mockClear().mockResolvedValue({ agentId: "main", @@ -651,7 +655,10 @@ describe("registerTelegramNativeCommands — session metadata", () => { { provider: "anthropic", model: "claude-opus-4-7" }, "thinking menu call", ); - expect(sessionMocks.loadSessionStore).toHaveBeenCalledWith("/tmp/openclaw-sessions.json"); + expect(sessionMocks.getSessionEntry).toHaveBeenCalledWith({ + storePath: "/tmp/openclaw-sessions.json", + sessionKey: "agent:main:main", + }); expectSendMessageCall({ sendMessage, chatId: 100, diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index e835ad2755a4..91ca05654471 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -40,8 +40,6 @@ import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { getSessionEntry, - loadSessionStore, - resolveSessionStoreEntry, resolveStorePath, type SessionEntry, } from "openclaw/plugin-sdk/session-store-runtime"; @@ -255,8 +253,7 @@ function resolveTelegramCommandMenuModelContext(params: { cfg: params.cfg, agentId: params.agentId, }); - const store = loadSessionStore(storePath); - const entry = resolveSessionStoreEntry({ store, sessionKey: params.sessionKey }).existing; + const entry = getSessionEntry({ storePath, sessionKey: params.sessionKey }); const thinkingLevel = normalizeOptionalString(entry?.thinkingLevel); const fastMode = entry?.fastMode; if (entry?.modelOverrideSource === "auto" && normalizeOptionalString(entry.modelOverride)) { @@ -269,7 +266,7 @@ function resolveTelegramCommandMenuModelContext(params: { } const override = resolveStoredModelOverride({ sessionEntry: entry, - sessionStore: store, + loadSessionEntry: (sessionKey) => getSessionEntry({ storePath, sessionKey }), sessionKey: params.sessionKey, defaultProvider: defaultModel.provider, }); @@ -318,14 +315,13 @@ function resolveTelegramFastCommandModelContext(params: { } try { const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }); - const store = loadSessionStore(storePath); - const entry = resolveSessionStoreEntry({ store, sessionKey: params.sessionKey }).existing; + const entry = getSessionEntry({ storePath, sessionKey: params.sessionKey }); if (entry?.modelOverrideSource === "auto" && normalizeOptionalString(entry.modelOverride)) { return fallback(); } const override = resolveStoredModelOverride({ sessionEntry: entry, - sessionStore: store, + loadSessionEntry: (sessionKey) => getSessionEntry({ storePath, sessionKey }), sessionKey: params.sessionKey, defaultProvider: defaultModel.provider, }); @@ -359,8 +355,7 @@ function resolveTelegramFastCommandState(params: { } try { const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }); - const store = loadSessionStore(storePath); - const entry = resolveSessionStoreEntry({ store, sessionKey: params.sessionKey }).existing; + const entry = getSessionEntry({ storePath, sessionKey: params.sessionKey }); const modelContext = resolveTelegramFastCommandModelContext(params); return resolveFastModeState({ cfg: params.cfg, diff --git a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts index 95eac33e6ba6..350df8eef0e4 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -12,6 +12,9 @@ type AnyMock = ReturnType; type AnyAsyncMock = ReturnType Promise>>; type GetRuntimeConfigFn = typeof import("openclaw/plugin-sdk/runtime-config-snapshot").getRuntimeConfig; +type GetSessionEntryFn = typeof import("openclaw/plugin-sdk/session-store-runtime").getSessionEntry; +type ListSessionEntriesFn = + typeof import("openclaw/plugin-sdk/session-store-runtime").listSessionEntries; type LoadSessionStoreFn = typeof import("openclaw/plugin-sdk/session-store-runtime").loadSessionStore; type ResolveStorePathFn = @@ -61,7 +64,9 @@ vi.mock("openclaw/plugin-sdk/web-media", () => ({ })); const { + getSessionEntryMock, getRuntimeConfig, + listSessionEntriesMock, loadSessionStoreMock, readSessionUpdatedAtMock, recordInboundSessionMock, @@ -69,7 +74,9 @@ const { sessionStoreEntries, } = vi.hoisted( (): { + getSessionEntryMock: MockFn; getRuntimeConfig: MockFn; + listSessionEntriesMock: MockFn; loadSessionStoreMock: MockFn; readSessionUpdatedAtMock: MockFn; recordInboundSessionMock: MockFn>; @@ -77,12 +84,23 @@ const { sessionStoreEntries: { value: SessionStore }; } => ({ getRuntimeConfig: vi.fn(() => ({})), - loadSessionStoreMock: vi.fn( - (_storePath, _opts) => sessionStoreEntries.value, - ), resolveStorePathMock: vi.fn( (storePath?: string) => storePath ?? sessionStorePath, ), + loadSessionStoreMock: vi.fn( + (_storePath, _opts) => sessionStoreEntries.value, + ), + getSessionEntryMock: vi.fn(({ storePath, sessionKey, agentId }) => { + const resolvedStorePath = storePath ?? resolveStorePathMock(undefined, { agentId }); + return loadSessionStoreMock(resolvedStorePath)[sessionKey]; + }), + listSessionEntriesMock: vi.fn(({ storePath, agentId } = {}) => { + const resolvedStorePath = storePath ?? resolveStorePathMock(undefined, { agentId }); + return Object.entries(loadSessionStoreMock(resolvedStorePath)).map(([sessionKey, entry]) => ({ + sessionKey, + entry, + })); + }), readSessionUpdatedAtMock: vi.fn(() => undefined), recordInboundSessionMock: vi.fn(async () => undefined), sessionStoreEntries: { value: {} as SessionStore }, @@ -444,6 +462,8 @@ export const telegramBotRuntimeForTest: TelegramBotRuntimeForTest = { }; export const telegramBotDepsForTest: TelegramBotDeps = { getRuntimeConfig, + getSessionEntry: getSessionEntryMock, + listSessionEntries: listSessionEntriesMock, loadSessionStore: loadSessionStoreMock as TelegramBotDeps["loadSessionStore"], resolveStorePath: resolveStorePathMock, readSessionUpdatedAt: readSessionUpdatedAtMock, @@ -564,6 +584,19 @@ beforeEach(() => { loadSessionStoreMock.mockImplementation(() => sessionStoreEntries.value); resolveStorePathMock.mockReset(); resolveStorePathMock.mockImplementation((storePath?: string) => storePath ?? sessionStorePath); + getSessionEntryMock.mockReset(); + getSessionEntryMock.mockImplementation(({ storePath, sessionKey, agentId }) => { + const resolvedStorePath = storePath ?? resolveStorePathMock(undefined, { agentId }); + return loadSessionStoreMock(resolvedStorePath)[sessionKey]; + }); + listSessionEntriesMock.mockReset(); + listSessionEntriesMock.mockImplementation(({ storePath, agentId } = {}) => { + const resolvedStorePath = storePath ?? resolveStorePathMock(undefined, { agentId }); + return Object.entries(loadSessionStoreMock(resolvedStorePath)).map(([sessionKey, entry]) => ({ + sessionKey, + entry, + })); + }); readSessionUpdatedAtMock.mockReset(); readSessionUpdatedAtMock.mockReturnValue(undefined); recordInboundSessionMock.mockReset(); diff --git a/extensions/telegram/src/bot.ts b/extensions/telegram/src/bot.ts index d2c570d69ba1..db50e00848b7 100644 --- a/extensions/telegram/src/bot.ts +++ b/extensions/telegram/src/bot.ts @@ -1,11 +1,10 @@ // Telegram plugin module implements bot behavior. -import { getSessionEntry, listSessionEntries } from "openclaw/plugin-sdk/session-store-runtime"; import { createTelegramBotCore, getTelegramSequentialKey, setTelegramBotRuntimeForTest, } from "./bot-core.js"; -import { defaultTelegramBotDeps, type TelegramBotDeps } from "./bot-deps.js"; +import { defaultTelegramBotDeps } from "./bot-deps.js"; import type { TelegramBotOptions } from "./bot.types.js"; export type { TelegramBotOptions } from "./bot.types.js"; @@ -17,39 +16,6 @@ export function createTelegramBot( ): ReturnType { return createTelegramBotCore({ ...opts, - telegramDeps: withTelegramSessionAccessorDeps(opts.telegramDeps ?? defaultTelegramBotDeps), + telegramDeps: opts.telegramDeps ?? defaultTelegramBotDeps, }); } - -function withTelegramSessionAccessorDeps(deps: TelegramBotDeps): TelegramBotDeps { - if (!deps.loadSessionStore) { - return { - ...deps, - getSessionEntry: deps.getSessionEntry ?? getSessionEntry, - listSessionEntries: deps.listSessionEntries ?? listSessionEntries, - }; - } - - const listInjectedEntries = ( - scope: Parameters>[0] = {}, - ) => { - const storePath = - scope.storePath ?? deps.resolveStorePath(undefined, { agentId: scope.agentId }); - return Object.entries(deps.loadSessionStore?.(storePath) ?? {}).map(([sessionKey, entry]) => ({ - sessionKey, - entry, - })); - }; - - return { - ...deps, - // Existing Telegram tests and custom deps inject loadSessionStore; expose - // the same data through the accessor seam consumed by migrated handlers. - getSessionEntry: - deps.getSessionEntry ?? - ((scope) => - listInjectedEntries(scope).find(({ sessionKey }) => sessionKey === scope.sessionKey) - ?.entry), - listSessionEntries: deps.listSessionEntries ?? listInjectedEntries, - }; -} diff --git a/extensions/telegram/src/bot/delivery.test.ts b/extensions/telegram/src/bot/delivery.test.ts index a8cd8cdc428a..816bdb071425 100644 --- a/extensions/telegram/src/bot/delivery.test.ts +++ b/extensions/telegram/src/bot/delivery.test.ts @@ -1239,6 +1239,33 @@ describe("deliverReplies", () => { expect(mockCallArg(sendRichMessage, 1, 0)).not.toHaveProperty("reply_to_message_id"); }); + it("skips rich entity detection for reply text with provider-prefixed email addresses", async () => { + const runtime = createRuntime(); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 11, + chat: { id: "123" }, + }); + const bot = createBot({ sendMessage }); + const oauthProfileText = + "OAuth profile: openai:keshavbotagent@gmail.com (keshavbotagent@gmail.com)"; + + await deliverWith({ + replies: [{ text: oauthProfileText }], + runtime, + bot, + richMessages: true, + }); + + const raw = bot.api.raw as unknown as { + sendRichMessage: ReturnType; + }; + const richMessage = raw.sendRichMessage.mock.calls[0]?.[0]?.rich_message; + expect(richMessage).toEqual({ + html: oauthProfileText, + skip_entity_detection: true, + }); + }); + it("uses legacy reply id when selected reply target differs from quote source", async () => { const runtime = createRuntime(); const sendMessage = vi.fn().mockResolvedValue({ diff --git a/extensions/telegram/src/channel-actions.contract.test.ts b/extensions/telegram/src/channel-actions.contract.test.ts index 67eea90a312a..f752f98af86f 100644 --- a/extensions/telegram/src/channel-actions.contract.test.ts +++ b/extensions/telegram/src/channel-actions.contract.test.ts @@ -43,6 +43,36 @@ describe("telegram actions contract", () => { expect(capabilities?.includes("richText")).toBe(expected); }); + it("advertises inline buttons when legacy Telegram capabilities are empty", () => { + const capabilities = telegramPlugin.agentPrompt?.messageToolCapabilities?.({ + cfg: { + channels: { + telegram: { + botToken: "123:telegram-test-token", + capabilities: [], + }, + }, + } as OpenClawConfig, + }); + + expect(capabilities).toContain("inlineButtons"); + }); + + it("does not advertise inline buttons for non-empty legacy Telegram capabilities without inlineButtons", () => { + const capabilities = telegramPlugin.agentPrompt?.messageToolCapabilities?.({ + cfg: { + channels: { + telegram: { + botToken: "123:telegram-test-token", + capabilities: ["vision"], + }, + }, + } as OpenClawConfig, + }); + + expect(capabilities).not.toContain("inlineButtons"); + }); + it("uses the selected Telegram account's rich text setting", () => { const capabilities = telegramPlugin.agentPrompt?.messageToolCapabilities?.({ cfg: { diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index c3c30531b02f..2550a0bdf62f 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -833,6 +833,7 @@ export const telegramPlugin = createChatChannelPlugin({ targetResolver: { looksLikeId: looksLikeTelegramTargetId, hint: "", + reservedLiterals: ["current", "self", "this", "me"], }, }, resolver: { diff --git a/extensions/telegram/src/draft-stream.test.ts b/extensions/telegram/src/draft-stream.test.ts index edb920b646d4..ca3f1ee8d4a7 100644 --- a/extensions/telegram/src/draft-stream.test.ts +++ b/extensions/telegram/src/draft-stream.test.ts @@ -690,6 +690,24 @@ describe("createTelegramDraftStream", () => { expect(api.editMessageText).not.toHaveBeenCalled(); }); + it("skips rich entity detection for draft text with provider-prefixed email addresses", async () => { + const api = createMockDraftApi(); + const stream = createDraftStream(api, { richMessages: true }); + const oauthProfileText = + "OAuth profile: openai:keshavbotagent@gmail.com (keshavbotagent@gmail.com)"; + + stream.update(oauthProfileText); + await stream.flush(); + + expect(api.raw.sendRichMessage).toHaveBeenCalledWith({ + chat_id: 123, + rich_message: { + html: oauthProfileText, + skip_entity_detection: true, + }, + }); + }); + it("keeps rich preview html out of plain preview gating", async () => { const api = createMockDraftApi(); const stream = createDraftStream(api, { richMessages: true, minInitialChars: 10 }); @@ -789,16 +807,16 @@ describe("createTelegramDraftStream", () => { expectNthPreviewSend(api, 2, "foo bar baz qux"); }); - it("clamps a first oversized non-final preview", async () => { + it("clamps a first oversized non-final preview on a UTF-16 boundary", async () => { const api = createMockDraftApi(); const stream = createDraftStream(api, { maxChars: 10 }); - stream.update("1234567890ABCDEFGHIJ"); + stream.update("123456789😀tail"); await stream.flush(); expect(api.sendMessage).toHaveBeenCalledTimes(1); - expectNthPreviewSend(api, 1, "1234567890"); - expect(stream.lastDeliveredText?.()).toBe("1234567890"); + expectNthPreviewSend(api, 1, "123456789"); + expect(stream.lastDeliveredText?.()).toBe("123456789"); }); it("finalizes overflow that was hidden by a clamped non-final preview", async () => { diff --git a/extensions/telegram/src/draft-stream.ts b/extensions/telegram/src/draft-stream.ts index 578e4171b708..ff16a4224c3f 100644 --- a/extensions/telegram/src/draft-stream.ts +++ b/extensions/telegram/src/draft-stream.ts @@ -5,6 +5,7 @@ import { takeMessageIdAfterStop, } from "openclaw/plugin-sdk/channel-outbound"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { buildTelegramThreadParams, type TelegramThreadSpec } from "./bot/helpers.js"; import { renderTelegramHtmlText, telegramHtmlToPlainTextFallback } from "./format.js"; import { @@ -169,7 +170,7 @@ function findTelegramDraftChunkLength( high = mid - 1; } } - return best; + return sliceUtf16Safe(text, 0, best).length; } export function createTelegramDraftStream(params: { diff --git a/extensions/telegram/src/format.test.ts b/extensions/telegram/src/format.test.ts index 73ed67865bfc..6126d7940f60 100644 --- a/extensions/telegram/src/format.test.ts +++ b/extensions/telegram/src/format.test.ts @@ -254,7 +254,7 @@ describe("markdownToTelegramHtml", () => { `| ${Array.from({ length: columns }, (_, index) => String(index + 1)).join(" | ")} |`, ].join("\n"); - expect(markdownToTelegramRichHtml(table(20))).toContain("
"); + expect(markdownToTelegramRichHtml(table(20))).toContain("
"); expect(markdownToTelegramRichHtml(table(21))).toContain("
");
     expect(markdownToTelegramRichHtml(table(2), { tableMode: "code" })).toContain("
");
     expect(markdownToTelegramRichHtml(table(2), { tableMode: "code" })).not.toContain("
"); @@ -295,6 +295,19 @@ describe("markdownToTelegramHtml", () => { expect(html).toContain(''); }); + it("preserves markdown table column alignment in rich tables", () => { + const html = markdownToTelegramRichHtml( + "| Feature | Status | Count |\n| :--- | :---: | ---: |\n| Rich tables | Fixed | 2 |", + ); + + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + }); + it("does not auto-linkify bare URLs when entity detection is skipped", () => { expect(markdownToTelegramRichHtml("https://example.com", { skipEntityDetection: true })).toBe( "https://example.com", diff --git a/extensions/telegram/src/format.ts b/extensions/telegram/src/format.ts index e6f4409e07c9..284b48e286db 100644 --- a/extensions/telegram/src/format.ts +++ b/extensions/telegram/src/format.ts @@ -346,6 +346,8 @@ type TelegramHtmlTagSupport = { attrPatterns: ReadonlyMap; }; +type TelegramTableAlignment = NonNullable[number]; + const TELEGRAM_LEGACY_HTML_TAG_SUPPORT: TelegramHtmlTagSupport = { simpleTags: TELEGRAM_SIMPLE_HTML_TAGS, attrPatterns: TELEGRAM_ATTR_HTML_TAG_PATTERNS, @@ -972,19 +974,25 @@ function renderTelegramRichHtmlTable(table: MarkdownTableMeta): string { } const renderCellValue = (cell: MarkdownTableCell | undefined) => cell ? renderTelegramHtml(cell) : ""; - const renderCell = (tag: "td" | "th", value: MarkdownTableCell | undefined) => - `<${tag}>${renderCellValue(value)}`; + const renderCell = ( + tag: "td" | "th", + value: MarkdownTableCell | undefined, + align: TelegramTableAlignment | undefined, + ) => { + const alignAttr = align ? ` align="${align}"` : ""; + return `<${tag}${alignAttr}>${renderCellValue(value)}`; + }; const head = table.headers.length - ? `${table.headerCells.map((cell) => renderCell("th", cell)).join("")}` + ? `${table.headerCells.map((cell, index) => renderCell("th", cell, table.aligns?.[index])).join("")}` : ""; const bodyRows = table.rowCells .map( (row) => - `${Array.from({ length: columnCount }, (_value, index) => renderCell("td", row[index])).join("")}`, + `${Array.from({ length: columnCount }, (_value, index) => renderCell("td", row[index], table.aligns?.[index])).join("")}`, ) .join(""); const body = bodyRows ? `${bodyRows}` : ""; - return `
docsFeatureStatusCountRich tablesFixed2
${head}${body}
\n\n`; + return `${head}${body}
\n\n`; } function renderTelegramRichHtmlDocument( diff --git a/extensions/telegram/src/inline-buttons.test.ts b/extensions/telegram/src/inline-buttons.test.ts index 74c6122e66de..0dc0a82baced 100644 --- a/extensions/telegram/src/inline-buttons.test.ts +++ b/extensions/telegram/src/inline-buttons.test.ts @@ -109,6 +109,39 @@ describe("resolveTelegramInlineButtonsScope (#75433 SecretRef tolerance)", () => expect(isTelegramInlineButtonsEnabled({ cfg })).toBe(true); }); + it("preserves the default inline-buttons scope when legacy capabilities are empty", () => { + const cfg = { + channels: { + telegram: { + botToken: { source: "exec", provider: "default", id: "telegram-token" }, + capabilities: [], + }, + }, + } as unknown as OpenClawConfig; + + expect(resolveTelegramInlineButtonsScope({ cfg })).toBe("allowlist"); + expect(isTelegramInlineButtonsEnabled({ cfg })).toBe(true); + }); + + it("inherits the channel scope when an account legacy capabilities array is empty", () => { + const cfg = { + channels: { + telegram: { + capabilities: { inlineButtons: "off" }, + accounts: { + ops: { + botToken: "123:telegram-ops-token", + capabilities: [], + }, + }, + }, + }, + } as unknown as OpenClawConfig; + + expect(resolveTelegramInlineButtonsScope({ cfg, accountId: "ops" })).toBe("off"); + expect(isTelegramInlineButtonsEnabled({ cfg, accountId: "ops" })).toBe(false); + }); + it('preserves configured "off" when botToken is an unresolved SecretRef', () => { const cfg = { channels: { diff --git a/extensions/telegram/src/inline-buttons.ts b/extensions/telegram/src/inline-buttons.ts index df74cef0d0c6..5179ff7faa19 100644 --- a/extensions/telegram/src/inline-buttons.ts +++ b/extensions/telegram/src/inline-buttons.ts @@ -47,6 +47,9 @@ export function resolveTelegramInlineButtonsScopeFromCapabilities( return DEFAULT_INLINE_BUTTONS_SCOPE; } if (Array.isArray(capabilities)) { + if (capabilities.length === 0) { + return DEFAULT_INLINE_BUTTONS_SCOPE; + } const enabled = capabilities.some( (entry) => normalizeLowercaseStringOrEmpty(String(entry)) === "inlinebuttons", ); diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index 481e5511dd71..51b6afe1d599 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -2,6 +2,7 @@ import type { OutboundDeliveryFormattingOptions } from "openclaw/plugin-sdk/channel-outbound"; import { resolveOutboundSendDep, + sanitizeForPlainText, type OutboundSendDeps, } from "openclaw/plugin-sdk/channel-outbound"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result"; @@ -19,6 +20,7 @@ import { sendPayloadMediaSequenceOrFallback, } from "openclaw/plugin-sdk/reply-payload"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking"; import type { TelegramInlineButtons } from "./button-types.js"; import { resolveTelegramInlineButtons } from "./button-types.js"; import { splitTelegramHtmlChunks } from "./format.js"; @@ -198,6 +200,7 @@ export function createTelegramOutboundAdapter( chunkerMode: "markdown", extractMarkdownImages: true, textChunkLimit: TELEGRAM_TEXT_CHUNK_LIMIT, + sanitizeText: ({ text }) => sanitizeForPlainText(sanitizeAssistantVisibleText(text)), shouldSuppressLocalPayloadPrompt: options.shouldSuppressLocalPayloadPrompt, beforeDeliverPayload: options.beforeDeliverPayload, shouldTreatDeliveredTextAsVisible: options.shouldTreatDeliveredTextAsVisible, diff --git a/extensions/telegram/src/polling-session.test.ts b/extensions/telegram/src/polling-session.test.ts index aef61fa9ca30..944768a2a186 100644 --- a/extensions/telegram/src/polling-session.test.ts +++ b/extensions/telegram/src/polling-session.test.ts @@ -418,7 +418,7 @@ type TestTelegramUpdate = { update_id: number; message: { text: string; - chat: { id: number; type: "supergroup" }; + chat: { id: number; type: "private" | "supergroup" }; message_thread_id?: number; is_topic_message?: boolean; }; @@ -436,6 +436,16 @@ function topicUpdate(updateId: number, threadId: number, text: string): TestTele }; } +function directUpdate(updateId: number, chatId: number, text: string): TestTelegramUpdate { + return { + update_id: updateId, + message: { + text, + chat: { id: chatId, type: "private" }, + }, + }; +} + async function waitForAbortSignal(signal: AbortSignal): Promise { if (signal.aborted) { return; @@ -476,6 +486,49 @@ async function pendingUpdateIds(spoolDir: string, limit: number | "all" = 100): return (await listTelegramSpooledUpdates({ spoolDir, limit })).map((update) => update.updateId); } +async function claimedAtForUpdate(spoolDir: string, updateId: number): Promise { + const claim = (await listTelegramSpooledUpdateClaims({ spoolDir })).find( + (entry) => entry.updateId === updateId, + ); + if (!claim?.claim) { + throw new Error(`Expected claimed spooled update ${updateId}`); + } + return claim.claim.claimedAt; +} + +function installSpooledClaimRefreshHarness(): { + restore: () => void; + triggerRefresh: () => void; +} { + let refresh: (() => void) | undefined; + const realSetInterval = globalThis.setInterval.bind(globalThis); + const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation((( + handler: Parameters[0], + timeout?: number, + ) => { + if (timeout === pollingSessionTesting.spooledClaimRefreshIntervalMs) { + refresh = () => { + if (typeof handler === "function") { + handler(); + } + }; + const timer = realSetInterval(() => undefined, 2_147_483_647); + timer.unref?.(); + return timer; + } + return realSetInterval(handler, timeout); + }) as typeof setInterval); + return { + restore: () => setIntervalSpy.mockRestore(), + triggerRefresh: () => { + if (!refresh) { + throw new Error("Expected spooled claim refresh interval to be registered"); + } + refresh(); + }, + }; +} + function normalizeTelegramTestAccountId(spoolDir: string): string { const trimmed = path.basename(spoolDir).trim(); return trimmed ? trimmed.replace(/[^a-z0-9._-]+/gi, "_") : "default"; @@ -1565,6 +1618,49 @@ describe("TelegramPollingSession", () => { }); }); + it("refreshes active spooled claims while the handler is still running", async () => { + const refreshHarness = installSpooledClaimRefreshHarness(); + await withTempSpool(async (tempDir) => { + const abort = new AbortController(); + const events: string[] = []; + let releaseHandler: (() => void) | undefined; + const handlerDone = new Promise((resolve) => { + releaseHandler = resolve; + }); + await writeSpooledTestUpdates(tempDir, [topicUpdate(42, 10, "long topic 10 turn")]); + + const { runPromise, stopWorker } = startIsolatedIngressSession({ + abort, + spoolDir: tempDir, + handleUpdate: async (update) => { + events.push(`topic10:${update.update_id}`); + await handlerDone; + }, + }); + + try { + await vi.waitFor(() => expect(events).toEqual(["topic10:42"])); + const before = await claimedAtForUpdate(tempDir, 42); + + refreshHarness.triggerRefresh(); + await vi.waitFor(async () => + expect(await claimedAtForUpdate(tempDir, 42)).toBeGreaterThan(before), + ); + + releaseHandler?.(); + await vi.waitFor(async () => + expect(await listTelegramSpooledUpdateClaims({ spoolDir: tempDir })).toEqual([]), + ); + } finally { + releaseHandler?.(); + abort.abort(); + stopWorker(); + refreshHarness.restore(); + await runPromise; + } + }); + }); + it("holds buffered spooled claims until deferred processing settles without blocking same-lane buffering", async () => { await withTempSpool(async (tempDir) => { const abort = new AbortController(); @@ -1615,6 +1711,50 @@ describe("TelegramPollingSession", () => { }); }); + it("refreshes deferred spooled claims after the active handler hands off", async () => { + const refreshHarness = installSpooledClaimRefreshHarness(); + await withTempSpool(async (tempDir) => { + const abort = new AbortController(); + const participants: TelegramSpooledReplayDeferredParticipant[] = []; + await writeSpooledTestUpdates(tempDir, [topicUpdate(42, 10, "buffered topic 10 turn")]); + + const { runPromise, stopWorker } = startIsolatedIngressSession({ + abort, + spoolDir: tempDir, + handleUpdate: async (update) => { + const participant = createTelegramSpooledReplayDeferredParticipant( + `test-buffer:${update.update_id}`, + ); + if (!participant) { + throw new Error("expected spooled replay participant"); + } + participants.push(participant); + }, + }); + + try { + await vi.waitFor(() => expect(participants).toHaveLength(1)); + const before = await claimedAtForUpdate(tempDir, 42); + + refreshHarness.triggerRefresh(); + await vi.waitFor(async () => + expect(await claimedAtForUpdate(tempDir, 42)).toBeGreaterThan(before), + ); + + participants[0]?.settle({ kind: "completed" }); + await vi.waitFor(async () => + expect(await listTelegramSpooledUpdateClaims({ spoolDir: tempDir })).toEqual([]), + ); + } finally { + participants[0]?.settle({ kind: "completed" }); + abort.abort(); + stopWorker(); + refreshHarness.restore(); + await runPromise; + } + }); + }); + it("releases buffered spooled claims for retry when deferred processing fails", async () => { await withTempSpool(async (tempDir) => { const abort = new AbortController(); @@ -1795,6 +1935,93 @@ describe("TelegramPollingSession", () => { }); }); + for (const scenario of [ + { + name: "topic", + conflict: topicUpdate(42, 10, "retryable session init conflict"), + blocked: topicUpdate(43, 10, "same topic must wait behind retry backoff"), + other: topicUpdate(44, 11, "other topic can continue"), + conflictEvent: "topic10:conflict", + blockedEvent: "topic10:overtook", + otherEvent: "topic11", + error: "reply session initialization conflicted for agent:main:telegram:group:-100:topic:10", + }, + { + name: "direct message", + conflict: directUpdate(42, 100, "retryable session init conflict"), + blocked: directUpdate(43, 100, "same DM must wait behind retry backoff"), + other: directUpdate(44, 101, "other DM can continue"), + conflictEvent: "dm100:conflict", + blockedEvent: "dm100:overtook", + otherEvent: "dm101", + error: "reply session initialization conflicted for agent:main:telegram:direct:100", + }, + ]) { + it(`backs off retryable reply session init conflicts for ${scenario.name} lanes`, async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + await withTempSpool(async (tempDir) => { + const abort = new AbortController(); + const log = vi.fn(); + let attempts = 0; + const events: string[] = []; + await writeSpooledTestUpdates(tempDir, [ + scenario.conflict, + scenario.blocked, + scenario.other, + ]); + + const { runPromise, stopWorker } = startIsolatedIngressSession({ + abort, + spoolDir: tempDir, + log, + drainIntervalMs: 100, + handleUpdate: async (update) => { + if (update.update_id === scenario.conflict.update_id) { + attempts += 1; + events.push(`${scenario.conflictEvent}:${attempts}`); + throw new Error(scenario.error); + } + if (update.update_id === scenario.blocked.update_id) { + events.push(scenario.blockedEvent); + return; + } + if (update.update_id === scenario.other.update_id) { + events.push(scenario.otherEvent); + } + }, + }); + + await vi.waitFor(() => expect(attempts).toBe(1)); + await vi.advanceTimersByTimeAsync(1_000); + expect(attempts).toBe(1); + await vi.waitFor(() => + expect(events).toEqual([`${scenario.conflictEvent}:1`, scenario.otherEvent]), + ); + expect(await pendingUpdateIds(tempDir, "all")).toEqual([ + scenario.conflict.update_id, + scenario.blocked.update_id, + ]); + expect(await failedUpdateIds(tempDir)).toEqual([]); + + await vi.advanceTimersByTimeAsync(4_500); + await vi.waitFor(() => expect(attempts).toBe(2)); + expect(events).not.toContain(scenario.blockedEvent); + expectLogIncludes( + log, + `spooled update ${scenario.conflict.update_id} failed; keeping for retry`, + ); + + abort.abort(); + stopWorker(); + await runPromise; + }); + } finally { + vi.useRealTimers(); + } + }); + } + it("dead-letters wrapped missing harness failures", async () => { await withTempSpool(async (tempDir) => { const abort = new AbortController(); @@ -3488,6 +3715,106 @@ describe("TelegramPollingSession", () => { } }); + it("marks isolated ingress unhealthy when a spooled backlog stalls before handler timeout", async () => { + vi.useFakeTimers({ now: 1_000, shouldAdvanceTime: true }); + const abort = new AbortController(); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-spool-")); + const setStatus = vi.fn(); + let releaseRegularTurn: (() => void) | undefined; + const regularTurnDone = new Promise((resolve) => { + releaseRegularTurn = resolve; + }); + const handleUpdate = vi.fn(async () => { + await regularTurnDone; + }); + createTelegramBotMock.mockReturnValueOnce({ + api: { + deleteWebhook: vi.fn(async () => true), + config: { use: vi.fn() }, + }, + init: vi.fn(async () => undefined), + handleUpdate, + stop: vi.fn(async () => undefined), + }); + await writeSpooledTestUpdates(tempDir, [ + topicUpdate(42, 10, "active topic 10 turn"), + topicUpdate(43, 10, "later topic 10 turn"), + ]); + + const workerListeners: WorkerMessageListener[] = []; + let stopWorker: (() => void) | undefined; + const workerDone = new Promise((resolve) => { + stopWorker = resolve; + }); + const createWorker = vi.fn(() => ({ + onMessage: vi.fn((listener: WorkerMessageListener) => { + workerListeners.push(listener); + return () => undefined; + }), + stop: vi.fn(async () => { + stopWorker?.(); + }), + task: vi.fn(async () => { + await workerDone; + }), + })); + + try { + const session = createPollingSession({ + abortSignal: abort.signal, + setStatus, + isolatedIngress: { + enabled: true, + spoolDir: tempDir, + createWorker, + drainIntervalMs: pollingSessionTesting.isolatedIngressBacklogStallMs * 2, + spooledUpdateHandlerTimeoutMs: pollingSessionTesting.isolatedIngressBacklogStallMs * 2, + }, + }); + + const runPromise = session.runUntilAbort(); + await vi.waitFor(() => expect(handleUpdate).toHaveBeenCalledTimes(1)); + workerListeners[0]?.({ + type: "poll-success", + offset: null, + count: 0, + finishedAt: Date.now(), + }); + expect(statusPatches(setStatus).some((patch) => patch.connected === true)).toBe(true); + + vi.setSystemTime(1_000 + pollingSessionTesting.isolatedIngressBacklogStallMs + 1); + workerListeners[0]?.({ type: "spooled", updateId: 43, queued: 1 }); + await vi.waitFor(() => + expect( + statusPatches(setStatus).some( + (patch) => + patch.connected === false && + String(patch.lastError).includes("isolated polling spool backlog stalled"), + ), + ).toBe(true), + ); + expect(await failedUpdateIds(tempDir)).toEqual([]); + expect(await pendingUpdateIds(tempDir, "all")).toEqual([43]); + expect( + (await listTelegramSpooledUpdateClaims({ spoolDir: tempDir })).map( + (claim) => claim.updateId, + ), + ).toEqual([42]); + + releaseRegularTurn?.(); + abort.abort(); + stopWorker?.(); + await vi.advanceTimersByTimeAsync(20_000); + await runPromise; + } finally { + releaseRegularTurn?.(); + abort.abort(); + stopWorker?.(); + vi.useRealTimers(); + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it("marks isolated ingress unhealthy when a spooled backlog handler times out", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); const abort = new AbortController(); diff --git a/extensions/telegram/src/polling-session.ts b/extensions/telegram/src/polling-session.ts index c7b7bcf50a8c..9c41a0f9ae50 100644 --- a/extensions/telegram/src/polling-session.ts +++ b/extensions/telegram/src/polling-session.ts @@ -41,6 +41,7 @@ import { listTelegramSpooledUpdateClaims, listTelegramSpooledUpdates, recoverStaleTelegramSpooledUpdateClaims, + refreshTelegramSpooledUpdateClaim, releaseTelegramSpooledUpdateClaim, resolveTelegramIngressSpoolDir, writeTelegramSpooledUpdate, @@ -131,11 +132,15 @@ const TELEGRAM_SPOOLED_HANDLER_ABORT_GRACE_MS = 5_000; const TELEGRAM_SPOOLED_HANDLER_TIMEOUT_ENV = "OPENCLAW_TELEGRAM_SPOOLED_HANDLER_TIMEOUT_MS"; const TELEGRAM_SPOOLED_DRAIN_START_LIMIT = 100; const TELEGRAM_SPOOLED_DRAIN_SCAN_LIMIT = TELEGRAM_SPOOLED_DRAIN_START_LIMIT * 10; +const TELEGRAM_SPOOLED_CLAIM_REFRESH_INTERVAL_MS = 5 * 60 * 1000; +const TELEGRAM_SPOOLED_SESSION_INIT_CONFLICT_RETRY_BASE_MS = 5_000; +const TELEGRAM_SPOOLED_SESSION_INIT_CONFLICT_RETRY_MAX_MS = 60_000; const TELEGRAM_POLLING_CLIENT_TIMEOUT_FLOOR_SECONDS = Math.ceil( TELEGRAM_GET_UPDATES_REQUEST_TIMEOUT_MS / 1000, ); const MISSING_AGENT_HARNESS_ERROR_NAME = "MissingAgentHarnessError"; const MISSING_AGENT_HARNESS_MESSAGE_RE = /Requested agent harness "[^"]+" is not registered\./u; +const REPLY_SESSION_INIT_CONFLICT_MESSAGE_RE = /reply session initialization conflicted for \S+/u; function normalizeTelegramAccountId(accountId?: string | null): string { return accountId?.trim() || "default"; @@ -169,6 +174,24 @@ function resolveNonRetryableSpooledUpdateFailure( return null; } +function resolveSpooledUpdateRetryDelayMs(update: TelegramSpooledUpdate, now = Date.now()): number { + const attempts = update.attempts ?? 0; + if ( + !update.lastError || + !REPLY_SESSION_INIT_CONFLICT_MESSAGE_RE.test(update.lastError) || + update.lastAttemptAt === undefined || + attempts <= 0 + ) { + return 0; + } + const exponent = Math.min(attempts - 1, 8); + const delayMs = Math.min( + TELEGRAM_SPOOLED_SESSION_INIT_CONFLICT_RETRY_MAX_MS, + TELEGRAM_SPOOLED_SESSION_INIT_CONFLICT_RETRY_BASE_MS * 2 ** exponent, + ); + return Math.max(0, update.lastAttemptAt + delayMs - now); +} + type TelegramBot = ReturnType; const waitForGracefulStop = async (stop: () => Promise) => { @@ -270,6 +293,8 @@ type SpooledUpdateHandlerState = { update: ClaimedTelegramSpooledUpdate; updateId: number; startedAt: number; + stopClaimRefresh: () => void; + backlogStatusMessage?: string; timedOutAt?: number; timeoutMessage?: string; }; @@ -282,6 +307,7 @@ type DeferredSpooledUpdateClaimState = { timedOutMessage?: string; update: ClaimedTelegramSpooledUpdate; updateId: number; + stopClaimRefresh: () => void; }; const deferredSpooledUpdateClaimsByKey = new Map(); @@ -551,8 +577,46 @@ export class TelegramPollingSession { } } + #startSpooledUpdateClaimRefresh(update: ClaimedTelegramSpooledUpdate): () => void { + // Refresh only while this process still owns useful work for this claim token. + // Stopping before release/fail/delete lets stale recovery take over if work stalls. + let stopped = false; + let refreshing = false; + const refresh = async (): Promise => { + if (stopped || refreshing) { + return; + } + refreshing = true; + try { + const refreshed = await refreshTelegramSpooledUpdateClaim(update); + if (!refreshed && !stopped) { + stopped = true; + clearInterval(timer); + } + } catch (err) { + this.opts.log( + `[telegram][diag] spooled update ${update.updateId} claim refresh failed: ${formatErrorMessage(err)}`, + ); + } finally { + refreshing = false; + } + }; + const timer = setInterval(() => { + void refresh(); + }, TELEGRAM_SPOOLED_CLAIM_REFRESH_INTERVAL_MS); + timer.unref?.(); + return () => { + if (stopped) { + return; + } + stopped = true; + clearInterval(timer); + }; + } + async #handleClaimedSpooledUpdate(params: { bot: TelegramBot; + stopClaimRefresh: () => void; update: ClaimedTelegramSpooledUpdate; }): Promise { let replay: { deferredWork?: TelegramSpooledReplayDeferredParticipant }; @@ -562,6 +626,7 @@ export class TelegramPollingSession { await params.bot.handleUpdate(update); }); } catch (err) { + params.stopClaimRefresh(); await this.#releaseFailedSpooledUpdate({ err, update: params.update, @@ -572,11 +637,13 @@ export class TelegramPollingSession { this.#registerDeferredSpooledUpdate({ deferredWork: replay.deferredWork, laneKey: this.#spooledUpdateLaneKey(params.update), + stopClaimRefresh: params.stopClaimRefresh, update: params.update, }); return true; } try { + params.stopClaimRefresh(); await deleteTelegramSpooledUpdate(params.update); return true; } catch (err) { @@ -590,6 +657,7 @@ export class TelegramPollingSession { #registerDeferredSpooledUpdate(params: { deferredWork: TelegramSpooledReplayDeferredParticipant; laneKey: string; + stopClaimRefresh: () => void; update: ClaimedTelegramSpooledUpdate; }): void { const claimKey = buildDeferredSpooledUpdateClaimKey(params.update); @@ -598,6 +666,7 @@ export class TelegramPollingSession { if (previous.timer) { clearTimeout(previous.timer); } + previous.stopClaimRefresh(); deferredSpooledUpdateClaimsByKey.delete(claimKey); } let settled = false; @@ -609,6 +678,7 @@ export class TelegramPollingSession { if (state.timer) { clearTimeout(state.timer); } + state.stopClaimRefresh(); if (deferredSpooledUpdateClaimsByKey.get(claimKey) === state) { deferredSpooledUpdateClaimsByKey.delete(claimKey); } @@ -640,10 +710,12 @@ export class TelegramPollingSession { }), update: params.update, updateId: params.update.updateId, + stopClaimRefresh: params.stopClaimRefresh, }; state.timer = setTimeout(() => { const age = formatDurationPrecise(this.#spooledUpdateHandlerTimeoutMs); state.timedOutMessage = `Telegram isolated polling spool buffered processing timed out behind update ${params.update.updateId} on lane ${params.laneKey} after ${age}; marking the update failed, aborting active reply work, and keeping the claim out of retry while the buffered task settles.`; + state.stopClaimRefresh(); params.deferredWork.settle({ kind: "failed-retryable", error: new Error(state.timedOutMessage), @@ -777,7 +849,9 @@ export class TelegramPollingSession { } } try { - await releaseTelegramSpooledUpdateClaim(params.update); + await releaseTelegramSpooledUpdateClaim(params.update, { + lastError: formatErrorMessage(params.err), + }); } catch (releaseErr) { this.opts.log( `[telegram][diag] spooled update ${params.update.updateId} failed and could not be requeued: ${formatErrorMessage(releaseErr)}`, @@ -865,6 +939,10 @@ export class TelegramPollingSession { if (this.opts.abortSignal?.aborted) { break; } + if (resolveSpooledUpdateRetryDelayMs(update) > 0) { + claimedLaneKeys.add(laneKey); + continue; + } const handlerKey = buildSpooledUpdateHandlerKey({ spoolDir: params.spoolDir, laneKey }); if (activeSpooledUpdateHandlersByLane.has(handlerKey)) { blockedByLane.add(handlerKey); @@ -878,8 +956,10 @@ export class TelegramPollingSession { claimedLaneKeys.add(laneKey); continue; } + const stopClaimRefresh = this.#startSpooledUpdateClaimRefresh(claimedUpdate); const handler = this.#handleClaimedSpooledUpdate({ bot: params.bot, + stopClaimRefresh, update: claimedUpdate, }); const state: SpooledUpdateHandlerState = { @@ -889,11 +969,17 @@ export class TelegramPollingSession { update: claimedUpdate, updateId: update.updateId, startedAt: Date.now(), + stopClaimRefresh, }; activeSpooledUpdateHandlersByLane.set(handlerKey, state); this.#spooledUpdateHandlerKeys.add(handlerKey); claimedLaneKeys.add(laneKey); void handler.finally(() => { + if ( + !deferredSpooledUpdateClaimsByKey.has(buildDeferredSpooledUpdateClaimKey(claimedUpdate)) + ) { + state.stopClaimRefresh(); + } if (activeSpooledUpdateHandlersByLane.get(handlerKey) === state) { activeSpooledUpdateHandlersByLane.delete(handlerKey); } @@ -942,6 +1028,7 @@ export class TelegramPollingSession { } const age = formatDurationPrecise(timedOutHandler.ageMs); activeHandler.timedOutAt = Date.now(); + activeHandler.stopClaimRefresh(); const message = `Telegram isolated polling spool handler timed out behind update ${handler.updateId} on lane ${handler.laneKey} after ${age}; marking the update failed, aborting active reply work, and restarting isolated ingress so later updates can drain.`; activeHandler.timeoutMessage = message; try { @@ -998,6 +1085,27 @@ export class TelegramPollingSession { return { handlerKey: handler.handlerKey, restart: true }; } + #noteSpooledBacklogStalls(blockedHandlerKeys: Set): Set { + const stalled = new Set(); + const now = Date.now(); + for (const handlerKey of blockedHandlerKeys) { + const handler = activeSpooledUpdateHandlersByLane.get(handlerKey); + if (!handler || handler.timedOutAt !== undefined) { + continue; + } + const ageMs = now - handler.startedAt; + if (ageMs < ISOLATED_INGRESS_BACKLOG_STALL_MS) { + continue; + } + stalled.add(handlerKey); + if (!handler.backlogStatusMessage) { + handler.backlogStatusMessage = `Telegram isolated polling spool backlog stalled behind update ${handler.updateId} on lane ${handler.laneKey} for ${formatDurationPrecise(ageMs)}; marking polling unhealthy until the backlog drains.`; + this.#status.notePollingError(handler.backlogStatusMessage); + } + } + return stalled; + } + async #runIsolatedIngressCycle(bot: TelegramBot): Promise<"continue" | "exit"> { const ingress = this.opts.isolatedIngress; if (!ingress?.enabled) { @@ -1195,6 +1303,9 @@ export class TelegramPollingSession { this.#status.notePollingError(handler.timeoutMessage); } } + for (const handlerKey of this.#noteSpooledBacklogStalls(drain.blockedByLane)) { + stalledBacklogKeys.add(handlerKey); + } // Active handlers can outlive their owning session after shutdown grace. // Recover every handler for this spool, including lone handlers with no backlog. const timeoutCandidateHandlerKeys = this.#activeSpooledUpdateHandlerKeysForSpool(spoolDir); @@ -1533,6 +1644,9 @@ export const testing = { createTelegramRestartBackoffState, resetTelegramRestartBackoffState, resolveTelegramRestartDelayMs, + resolveSpooledUpdateRetryDelayMs, + isolatedIngressBacklogStallMs: ISOLATED_INGRESS_BACKLOG_STALL_MS, + spooledClaimRefreshIntervalMs: TELEGRAM_SPOOLED_CLAIM_REFRESH_INTERVAL_MS, resolveSpooledUpdateHandlerAbortGraceMs: (valueMs: unknown): number => resolvePositiveTimerTimeoutMs(valueMs, TELEGRAM_SPOOLED_HANDLER_ABORT_GRACE_MS), }; diff --git a/extensions/telegram/src/rich-message.ts b/extensions/telegram/src/rich-message.ts index 4ec070fdf1b4..40df33249a10 100644 --- a/extensions/telegram/src/rich-message.ts +++ b/extensions/telegram/src/rich-message.ts @@ -96,6 +96,16 @@ type TelegramApiWithRichRaw = Bot["api"] & { raw?: TelegramRichRawApi; }; +const TELEGRAM_RICH_EMAIL_TOKEN_RE = + /[A-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?(?:\.[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?)+/iu; + +function shouldSkipTelegramRichEntityDetection( + text: string, + options?: Pick, +): boolean { + return options?.skipEntityDetection === true || TELEGRAM_RICH_EMAIL_TOKEN_RE.test(text); +} + export function getTelegramRichRawApi(api: Bot["api"]): TelegramRichRawApi { const raw = (api as TelegramApiWithRichRaw).raw; if (raw) { @@ -164,7 +174,11 @@ export function buildTelegramRichMarkdown( markdown: string, options?: TelegramRichMessageOptions, ): TelegramInputRichMessage { - return buildTelegramRichHtml(markdownToTelegramRichHtml(markdown, options), options); + const richOptions = { + ...options, + skipEntityDetection: shouldSkipTelegramRichEntityDetection(markdown, options), + }; + return buildTelegramRichHtml(markdownToTelegramRichHtml(markdown, richOptions), richOptions); } export function buildTelegramRichHtml( @@ -172,7 +186,7 @@ export function buildTelegramRichHtml( options?: TelegramRichMessageOptions, ): TelegramInputRichMessage { const safeHtml = prepareTelegramRichHtml(html); - return options?.skipEntityDetection === true + return shouldSkipTelegramRichEntityDetection(safeHtml, options) ? { html: safeHtml, skip_entity_detection: true } : { html: safeHtml }; } @@ -418,13 +432,14 @@ export function splitTelegramRichMessageTextChunks(params: { tableMode?: MarkdownTableMode; skipEntityDetection?: boolean; }): TelegramRichTextChunk[] { + const markdownOptions = { + tableMode: params.tableMode, + skipEntityDetection: shouldSkipTelegramRichEntityDetection(params.text, { + skipEntityDetection: params.skipEntityDetection, + }), + }; const renderMarkdownChunk = (chunk: string) => - prepareTelegramRichHtml( - markdownToTelegramRichHtml(chunk, { - tableMode: params.tableMode, - skipEntityDetection: params.skipEntityDetection, - }), - ); + prepareTelegramRichHtml(markdownToTelegramRichHtml(chunk, markdownOptions)); const htmlChunks = params.textMode === "html" ? splitPreparedTelegramRichHtml({ diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index 0f083cd0a836..aba8d7f5e814 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -953,7 +953,32 @@ describe("sendMessageTelegram", () => { expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1); const richMessage = botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message; - expect(richMessage?.html).toContain(""); + expect(richMessage?.html).toContain("
"); + }); + + it("skips rich entity detection for provider-prefixed email text", async () => { + botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } }); + const oauthProfileText = + "OAuth profile: openai:keshavbotagent@gmail.com (keshavbotagent@gmail.com)"; + + await sendMessageTelegram("123", oauthProfileText, { + cfg: { + channels: { + telegram: { + richMessages: true, + }, + }, + }, + token: "tok", + }); + + expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1); + const richMessage = botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message; + expect(richMessage).toEqual({ + html: oauthProfileText, + skip_entity_detection: true, + }); + expect(richMessage?.html).not.toContain("mailto:"); }); it.each([ diff --git a/extensions/telegram/src/telegram-ingress-spool.test.ts b/extensions/telegram/src/telegram-ingress-spool.test.ts index 5e9d8413d76a..ffe8eb442929 100644 --- a/extensions/telegram/src/telegram-ingress-spool.test.ts +++ b/extensions/telegram/src/telegram-ingress-spool.test.ts @@ -17,6 +17,7 @@ import { listTelegramSpooledUpdateClaims, listTelegramSpooledUpdates, recoverStaleTelegramSpooledUpdateClaims, + refreshTelegramSpooledUpdateClaim, releaseTelegramSpooledUpdateClaim, TELEGRAM_SPOOLED_UPDATE_PROCESSING_STALE_MS, writeTelegramSpooledUpdate, @@ -140,6 +141,32 @@ describe("Telegram ingress spool", () => { }); }); + it("refreshes active claim timestamps through the Telegram spool queue", async () => { + await withTempSpool(async (spoolDir) => { + await writeTelegramSpooledUpdate({ + spoolDir, + update: { update_id: 31, message: { text: "refresh me" } }, + }); + const update = (await listTelegramSpooledUpdates({ spoolDir }))[0]; + if (!update) { + throw new Error("Expected a spooled update"); + } + const claimed = await claimTelegramSpooledUpdate(update); + if (!claimed) { + throw new Error("Expected a claimed update"); + } + + await expect(refreshTelegramSpooledUpdateClaim(claimed, { refreshedAt: 123 })).resolves.toBe( + true, + ); + + const claims = await listTelegramSpooledUpdateClaims({ spoolDir }); + expect(claims).toHaveLength(1); + expect(claims[0]?.updateId).toBe(31); + expect(claims[0]?.claim?.claimedAt).toBe(123); + }); + }); + it("marks timed out claims failed without requeueing them", async () => { await withTempSpool(async (spoolDir) => { await writeTelegramSpooledUpdate({ diff --git a/extensions/telegram/src/telegram-ingress-spool.ts b/extensions/telegram/src/telegram-ingress-spool.ts index b61b1349c522..ed28ba746975 100644 --- a/extensions/telegram/src/telegram-ingress-spool.ts +++ b/extensions/telegram/src/telegram-ingress-spool.ts @@ -38,6 +38,9 @@ export type TelegramSpooledUpdate = { path: string; update: unknown; receivedAt: number; + attempts?: number; + lastAttemptAt?: number; + lastError?: string; claim?: TelegramSpooledUpdateClaimOwner; }; @@ -166,6 +169,9 @@ function parseQueueRecord( path: pendingPath(spoolDir, payload.updateId), update: payload.update, receivedAt: payload.receivedAt, + attempts: record.attempts, + ...(record.lastAttemptAt === undefined ? {} : { lastAttemptAt: record.lastAttemptAt }), + ...(record.lastError === undefined ? {} : { lastError: record.lastError }), }; } @@ -267,9 +273,28 @@ export async function claimTelegramSpooledUpdate( export async function releaseTelegramSpooledUpdateClaim( update: ClaimedTelegramSpooledUpdate, + options?: { lastError?: string; releasedAt?: number }, ): Promise { await createTelegramIngressQueue(path.dirname(update.pendingPath)).release( queueMutationTarget(update), + options, + ); +} + +export async function refreshTelegramSpooledUpdateClaim( + update: ClaimedTelegramSpooledUpdate, + options?: { refreshedAt?: number }, +): Promise { + const claimToken = update.claim?.claimToken; + if (!claimToken) { + return false; + } + const queue = createTelegramIngressQueue(path.dirname(update.pendingPath)); + return ( + (await queue.refreshClaim?.( + { id: queueEventId(update.updateId), claim: { token: claimToken } }, + options, + )) ?? false ); } diff --git a/extensions/telegram/src/telegram-outbound.test.ts b/extensions/telegram/src/telegram-outbound.test.ts index e002c0bbd6e5..dc726325e565 100644 --- a/extensions/telegram/src/telegram-outbound.test.ts +++ b/extensions/telegram/src/telegram-outbound.test.ts @@ -29,10 +29,23 @@ describe("telegramPlugin outbound", () => { expect(telegramOutbound.presentationCapabilities?.limits?.text?.markdownDialect).toBe( "markdown", ); - expect(telegramOutbound.sanitizeText).toBeUndefined(); expect(telegramOutbound.pollMaxOptions).toBe(10); }); + it("strips assistant-visible tool traces before outbound delivery", () => { + clearTelegramRuntime(); + const text = 'Done.\n⚠️ 🛠️ `search "Pipeline" in ~/.openclaw/workspace-* (agent)` failed'; + + expect(telegramOutbound.sanitizeText?.({ text, payload: { text } })).toBe("Done."); + }); + + it("preserves ordinary outbound text while sanitizing", () => { + clearTelegramRuntime(); + const text = "The pipeline has 3 deals."; + + expect(telegramOutbound.sanitizeText?.({ text, payload: { text } })).toBe(text); + }); + it("preserves explicit HTML parse mode before chunking", () => { clearTelegramRuntime(); const text = "hi"; diff --git a/extensions/telegram/src/truncate.test.ts b/extensions/telegram/src/truncate.test.ts new file mode 100644 index 000000000000..2eea1cdea960 --- /dev/null +++ b/extensions/telegram/src/truncate.test.ts @@ -0,0 +1,48 @@ +// Telegram tests cover progress text clipping behavior. +import { describe, expect, it } from "vitest"; +import { clipTelegramProgressText, TELEGRAM_PROGRESS_MAX_CHARS } from "./truncate.js"; + +describe("clipTelegramProgressText", () => { + it("drops a surrogate-pair emoji whole when it straddles the limit", () => { + // 😀 is U+1F600, encoded as two UTF-16 code units (high \uD83D + low \uDE00). + // Placing the emoji at positions [MAX-2, MAX-1] (0-indexed) puts its high + // surrogate right on the .slice(0, MAX-1) cut edge. A raw .slice keeps only + // \uD83D — an unpaired high surrogate — which is invalid in a Telegram payload. + const base = "a".repeat(TELEGRAM_PROGRESS_MAX_CHARS - 2); // 298 'a's + const out = clipTelegramProgressText(`${base}😀tail`); + expect(out).toBe(`${base}…`); + // No dangling high surrogate (high not followed by a low surrogate). + expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(out)).toBe(false); + }); + + it("keeps an emoji that fits entirely before the cut", () => { + // 296 'a's + '😀' (2 units) + 'xyz' (3 units) = 301 total > 300. + // The emoji sits at [296, 297] — entirely before the cut at 299 — so it stays. + const base = "a".repeat(TELEGRAM_PROGRESS_MAX_CHARS - 4); // 296 'a's + const out = clipTelegramProgressText(`${base}😀xyz`); + expect(out).toBe(`${base}😀x…`); + expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(out)).toBe(false); + }); + + it("returns text unchanged when it is within the limit", () => { + const short = "hello 😀 world"; + expect(clipTelegramProgressText(short)).toBe(short); + }); + + it("trims trailing whitespace before the ellipsis", () => { + // The sliced portion may end in spaces when trailing spaces straddle the cut. + const text = `${"a".repeat(TELEGRAM_PROGRESS_MAX_CHARS - 2)} rest`; + const out = clipTelegramProgressText(text); + expect(out).not.toContain(" …"); + expect(out.endsWith("…")).toBe(true); + }); + + it("handles plain ASCII that fills exactly to the limit", () => { + const exact = "x".repeat(TELEGRAM_PROGRESS_MAX_CHARS); + expect(clipTelegramProgressText(exact)).toBe(exact); + const oneOver = `${"x".repeat(TELEGRAM_PROGRESS_MAX_CHARS)}y`; + const out = clipTelegramProgressText(oneOver); + expect(out.length).toBeLessThanOrEqual(TELEGRAM_PROGRESS_MAX_CHARS); + expect(out.endsWith("…")).toBe(true); + }); +}); diff --git a/extensions/telegram/src/truncate.ts b/extensions/telegram/src/truncate.ts new file mode 100644 index 000000000000..18090e813e83 --- /dev/null +++ b/extensions/telegram/src/truncate.ts @@ -0,0 +1,20 @@ +// Telegram tests cover progress text clipping behavior. +import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; + +export const TELEGRAM_PROGRESS_MAX_CHARS = 300; + +/** + * Clips Telegram progress text to at most {@link TELEGRAM_PROGRESS_MAX_CHARS} UTF-16 code units, + * slicing on a code-point boundary so a surrogate pair straddling the limit is + * dropped whole rather than leaving a lone high surrogate in the payload. + */ +export function clipTelegramProgressText(text: string): string { + if (text.length <= TELEGRAM_PROGRESS_MAX_CHARS) { + return text; + } + // Slice on a code-point boundary so an emoji (or any astral character) that + // straddles the limit is dropped whole instead of leaving a lone \uD83D-style + // high surrogate before the ellipsis, which serializes to an invalid character + // in the Telegram Bot API payload. + return `${sliceUtf16Safe(text, 0, TELEGRAM_PROGRESS_MAX_CHARS - 1).trimEnd()}…`; +} diff --git a/extensions/test-support/streaming-error-response.ts b/extensions/test-support/streaming-error-response.ts index ea2777c42d3b..e7f2ad155965 100644 --- a/extensions/test-support/streaming-error-response.ts +++ b/extensions/test-support/streaming-error-response.ts @@ -1,11 +1,21 @@ -// Test Support plugin module implements streaming error response behavior. -export function createStreamingErrorResponse(params: { - status: number; +// Test Support plugin module implements streaming response fixtures. +export type StreamingResponseFixture = { + response: Response; + getReadCount: () => number; + wasCanceled: () => boolean; +}; + +export function createStreamingResponse(params: { + status?: number; chunkCount: number; chunkSize: number; - byte: number; -}): { response: Response; getReadCount: () => number } { + byte?: number; + text?: string; + headers?: HeadersInit; +}): StreamingResponseFixture { let reads = 0; + let canceled = false; + const encoder = new TextEncoder(); const stream = new ReadableStream({ pull(controller) { if (reads >= params.chunkCount) { @@ -13,11 +23,28 @@ export function createStreamingErrorResponse(params: { return; } reads += 1; - controller.enqueue(new Uint8Array(params.chunkSize).fill(params.byte)); + const chunk = + params.text !== undefined + ? encoder.encode(params.text.repeat(params.chunkSize)) + : new Uint8Array(params.chunkSize).fill(params.byte ?? 120); + controller.enqueue(chunk); + }, + cancel() { + canceled = true; }, }); return { - response: new Response(stream, { status: params.status }), + response: new Response(stream, { status: params.status ?? 200, headers: params.headers }), getReadCount: () => reads, + wasCanceled: () => canceled, }; } + +export function createStreamingErrorResponse(params: { + status: number; + chunkCount: number; + chunkSize: number; + byte: number; +}): StreamingResponseFixture { + return createStreamingResponse(params); +} diff --git a/extensions/voice-call/src/response-generator.test.ts b/extensions/voice-call/src/response-generator.test.ts index 56d79503f262..c4c49201583f 100644 --- a/extensions/voice-call/src/response-generator.test.ts +++ b/extensions/voice-call/src/response-generator.test.ts @@ -21,6 +21,12 @@ type EmbeddedAgentArgs = { provider?: string; model?: string; sessionKey?: string; + sessionTarget?: { + agentId?: string; + sessionId?: string; + sessionKey?: string; + storePath?: string; + }; sandboxSessionKey?: string; agentDir?: string; agentId?: string; @@ -313,7 +319,6 @@ describe("generateVoiceResponse", () => { resolveAgentWorkspaceDir, resolveAgentIdentity, resolveStorePath, - resolveSessionFilePath, sessionStore, } = createAgentRuntime([{ text: '{"spoken":"Default agent."}' }]); const coreConfig = {} as CoreConfig; @@ -336,19 +341,18 @@ describe("generateVoiceResponse", () => { if (!defaultSessionEntry) { throw new Error("Expected default voice session entry"); } - expect(resolveSessionFilePath).toHaveBeenCalledWith( - defaultSessionEntry.sessionId, - defaultSessionEntry, - { - agentId: "main", - }, - ); const args = requireEmbeddedAgentArgs(runEmbeddedAgent); expect(args.agentDir).toBe("/tmp/openclaw/agents/main"); expect(args.agentId).toBe("main"); + expect(args.sessionTarget).toStrictEqual({ + agentId: "main", + sessionId: defaultSessionEntry.sessionId, + sessionKey: "voice:15550001111", + storePath: "/tmp/openclaw/main/sessions.json", + }); expect(args.sandboxSessionKey).toBe("agent:main:voice:15550001111"); expect(args.workspaceDir).toBe("/tmp/openclaw/workspace/main"); - expect(args.sessionFile).toBe("/tmp/openclaw/main/sessions/session.jsonl"); + expect(args.sessionFile).toBeUndefined(); }); it("uses the configured voice response agent workspace", async () => { @@ -359,7 +363,6 @@ describe("generateVoiceResponse", () => { resolveAgentWorkspaceDir, resolveAgentIdentity, resolveStorePath, - resolveSessionFilePath, sessionStore, } = createAgentRuntime([{ text: '{"spoken":"Voice agent."}' }]); const coreConfig = {} as CoreConfig; @@ -386,19 +389,18 @@ describe("generateVoiceResponse", () => { if (!voiceSessionEntry) { throw new Error("Expected routed voice session entry"); } - expect(resolveSessionFilePath).toHaveBeenCalledWith( - voiceSessionEntry.sessionId, - voiceSessionEntry, - { - agentId: "voice", - }, - ); const args = requireEmbeddedAgentArgs(runEmbeddedAgent); expect(args.agentDir).toBe("/tmp/openclaw/agents/voice"); expect(args.agentId).toBe("voice"); + expect(args.sessionTarget).toStrictEqual({ + agentId: "voice", + sessionId: voiceSessionEntry.sessionId, + sessionKey: "voice:15550001111", + storePath: "/tmp/openclaw/voice/sessions.json", + }); expect(args.sandboxSessionKey).toBe("agent:voice:voice:15550001111"); expect(args.workspaceDir).toBe("/tmp/openclaw/workspace/voice"); - expect(args.sessionFile).toBe("/tmp/openclaw/voice/sessions/session.jsonl"); + expect(args.sessionFile).toBeUndefined(); }); it("passes the routed voice agent explicit tool allowlist to the embedded run", async () => { diff --git a/extensions/voice-call/src/response-generator.ts b/extensions/voice-call/src/response-generator.ts index f7ed6080d486..1e9428071e7f 100644 --- a/extensions/voice-call/src/response-generator.ts +++ b/extensions/voice-call/src/response-generator.ts @@ -291,10 +291,6 @@ export async function generateVoiceResponse( } const sessionId = sessionEntry.sessionId; - const sessionFile = agentRuntime.session.resolveSessionFilePath(sessionId, sessionEntry, { - agentId, - }); - // Resolve thinking level const thinkLevel = agentRuntime.resolveThinkingDefault({ cfg, provider, model }); @@ -324,10 +320,15 @@ export async function generateVoiceResponse( const result = await agentRuntime.runEmbeddedAgent({ sessionId, sessionKey: resolvedSessionKey, + sessionTarget: { + agentId, + sessionId, + sessionKey: resolvedSessionKey, + storePath, + }, sandboxSessionKey: resolveVoiceSandboxSessionKey(agentId, resolvedSessionKey), agentId, messageProvider: "voice", - sessionFile, workspaceDir, config: cfg, prompt: userMessage, diff --git a/extensions/voyage/embedding-batch.test.ts b/extensions/voyage/embedding-batch.test.ts new file mode 100644 index 000000000000..c9bbde704969 --- /dev/null +++ b/extensions/voyage/embedding-batch.test.ts @@ -0,0 +1,217 @@ +// Voyage batch tests cover bounded status/error response reads. +import { describe, expect, it } from "vitest"; +import type { VoyageEmbeddingClient } from "./embedding-provider.js"; +import { testing } from "./embedding-batch.js"; + +const { fetchVoyageBatchStatus, readVoyageBatchError, VOYAGE_BATCH_RESPONSE_MAX_BYTES } = testing; + +function buildClient(): VoyageEmbeddingClient { + return { + baseUrl: "https://api.voyageai.test/v1", + headers: { authorization: "Bearer test" }, + model: "voyage-3", + }; +} + +/** + * Build deps whose withRemoteHttpResponse drives the real onResponse against a + * caller-provided Response, so the bounded readers run exactly as in production. + */ +function buildDeps(response: Response): Parameters[0]["deps"] { + return { + now: () => 0, + sleep: async () => {}, + postJsonWithRetry: (async () => { + throw new Error("postJsonWithRetry should not be called in these tests"); + }) as never, + uploadBatchJsonlFile: (async () => { + throw new Error("uploadBatchJsonlFile should not be called in these tests"); + }) as never, + withRemoteHttpResponse: (async (params: { onResponse: (res: Response) => Promise }) => + await params.onResponse(response)) as never, + }; +} + +/** + * A streaming JSON-ish body that proves an oversized response stops being read + * before the whole advertised payload is buffered into memory. getReadCount + * reports how many chunks were pulled; cancel() flips wasCanceled. + */ +function streamingResponse(params: { chunkCount: number; chunkSize: number; status?: number }): { + response: Response; + getReadCount: () => number; + wasCanceled: () => boolean; +} { + let reads = 0; + let canceled = false; + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + pull(controller) { + if (reads >= params.chunkCount) { + controller.close(); + return; + } + reads += 1; + controller.enqueue(encoder.encode("a".repeat(params.chunkSize))); + }, + cancel() { + canceled = true; + }, + }); + return { + response: new Response(stream, { + status: params.status ?? 200, + headers: { "content-type": "application/json" }, + }), + getReadCount: () => reads, + wasCanceled: () => canceled, + }; +} + +describe("voyage batch bounded reads", () => { + it("uses a 16 MiB cap for batch status/error responses", () => { + expect(VOYAGE_BATCH_RESPONSE_MAX_BYTES).toBe(16 * 1024 * 1024); + }); + + it("parses a well-formed batch status response under the byte cap", async () => { + const response = new Response(JSON.stringify({ id: "batch_1", status: "completed" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + + const status = await fetchVoyageBatchStatus({ + client: buildClient(), + batchId: "batch_1", + deps: buildDeps(response), + }); + + expect(status).toEqual({ id: "batch_1", status: "completed" }); + }); + + it("caps an oversized batch status stream instead of buffering the whole body", async () => { + const streamed = streamingResponse({ chunkCount: 64, chunkSize: 1024 }); + + await expect( + fetchVoyageBatchStatus({ + client: buildClient(), + batchId: "batch_1", + deps: buildDeps(streamed.response), + maxResponseBytes: 4096, + }), + ).rejects.toThrow(/voyage-batch-status: JSON response exceeds 4096 bytes/); + + // Stream was cancelled mid-flight: fewer chunks read than the full payload. + expect(streamed.getReadCount()).toBeLessThan(64); + expect(streamed.wasCanceled()).toBe(true); + }); + + it("preserves the full NDJSON parse chain for an under-cap error file", async () => { + // Multi-line NDJSON with a blank line proves the bounded read does not + // disturb the original trim/split("\n")/JSON.parse/extractBatchErrorMessage + // pipeline: the first useful error message is still extracted byte-for-byte + // identically to the pre-change `await res.text()` path. + const body = [ + JSON.stringify({ custom_id: "req-0", response: { status_code: 200 } }), + "", + JSON.stringify({ custom_id: "req-1", error: { message: "voyage upstream rejected" } }), + JSON.stringify({ custom_id: "req-2", error: { message: "second error ignored" } }), + "", + ].join("\n"); + const response = new Response(body, { + status: 200, + headers: { "content-type": "application/x-ndjson" }, + }); + + const message = await readVoyageBatchError({ + client: buildClient(), + errorFileId: "file_1", + deps: buildDeps(response), + }); + + // extractBatchErrorMessage returns the first line carrying a message, so the + // success line is skipped and the second error is not surfaced. + expect(message).toBe("voyage upstream rejected"); + }); + + it("returns undefined for an empty error file via the original empty-body branch", async () => { + // Whitespace-only body must still hit the `!text.trim()` short-circuit after + // decoding the bounded buffer, returning undefined exactly as before. + const response = new Response(" \n", { + status: 200, + headers: { "content-type": "application/x-ndjson" }, + }); + + const message = await readVoyageBatchError({ + client: buildClient(), + errorFileId: "file_1", + deps: buildDeps(response), + }); + + expect(message).toBeUndefined(); + }); + + it("fail-softs an oversized error file into formatUnavailableBatchError by design", async () => { + const streamed = streamingResponse({ chunkCount: 64, chunkSize: 1024 }); + + // Intended behavior: an over-cap error file must NOT throw out of + // readVoyageBatchError. An unbounded error body would otherwise OOM the + // worker, so the bounded overflow error is caught and degraded into a + // diagnostic string via formatUnavailableBatchError. We accept the lost + // detail; the overflow message names the cap so the truncation is visible. + const readError = async () => + await readVoyageBatchError({ + client: buildClient(), + errorFileId: "file_1", + deps: buildDeps(streamed.response), + maxResponseBytes: 4096, + }); + + await expect(readError()).resolves.toMatch( + /error file unavailable: voyage batch error file content exceeds 4096 bytes/, + ); + + // The bounded reader still cancels the stream mid-flight rather than + // buffering the whole advertised payload before failing soft. + expect(streamed.getReadCount()).toBeLessThan(64); + expect(streamed.wasCanceled()).toBe(true); + }); + + it("caps an oversized non-OK (error) diagnostic body instead of buffering it whole", async () => { + // Regression for the non-OK gap: `assertVoyageResponseOk` previously read the + // 4xx/5xx diagnostic body with an unbounded `await res.text()`. A hostile + // endpoint can return a 500 with a never-ending body, so that read must be + // bounded too. Drive a streaming 500 through the real status path and assert + // the bounded overflow error fires and the stream is cancelled mid-flight. + const streamed = streamingResponse({ chunkCount: 64, chunkSize: 1024, status: 500 }); + + await expect( + fetchVoyageBatchStatus({ + client: buildClient(), + batchId: "batch_1", + deps: buildDeps(streamed.response), + maxResponseBytes: 4096, + }), + ).rejects.toThrow(/voyage batch status failed: 500 \(error body exceeds 4096 bytes\)/); + + // Stream was cancelled mid-flight rather than draining the whole body. + expect(streamed.getReadCount()).toBeLessThan(64); + expect(streamed.wasCanceled()).toBe(true); + }); + + it("preserves the diagnostic shape for a small non-OK (error) body", async () => { + // Under-cap non-OK body must still surface the original + // `${context}: ${status} ${text}` diagnostic byte-for-byte. + const response = new Response("voyage upstream is down", { + status: 503, + headers: { "content-type": "text/plain" }, + }); + + await expect( + fetchVoyageBatchStatus({ + client: buildClient(), + batchId: "batch_1", + deps: buildDeps(response), + }), + ).rejects.toThrow(/voyage batch status failed: 503 voyage upstream is down/); + }); +}); diff --git a/extensions/voyage/embedding-batch.ts b/extensions/voyage/embedding-batch.ts index d65d8b2d6a07..31a42b2a47c5 100644 --- a/extensions/voyage/embedding-batch.ts +++ b/extensions/voyage/embedding-batch.ts @@ -21,6 +21,8 @@ import { uploadBatchJsonlFile, withRemoteHttpResponse, } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; +import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; +import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { VoyageEmbeddingClient } from "./embedding-provider.js"; @@ -41,6 +43,10 @@ type VoyageBatchOutputLine = ProviderBatchOutputLine; const VOYAGE_BATCH_ENDPOINT = EMBEDDING_BATCH_ENDPOINT; const VOYAGE_BATCH_COMPLETION_WINDOW = "12h"; const VOYAGE_BATCH_MAX_REQUESTS = 50000; +// Voyage batch status/error responses are untrusted external bodies. Cap them +// the same way other bundled providers do (16 MiB) so a misbehaving or hostile +// endpoint cannot stream an unbounded body into memory before we parse it. +const VOYAGE_BATCH_RESPONSE_MAX_BYTES = 16 * 1024 * 1024; type VoyageBatchDeps = { now: () => number; @@ -65,9 +71,23 @@ function resolveVoyageBatchDeps(overrides: Partial | undefined) }; } -async function assertVoyageResponseOk(res: Response, context: string): Promise { +async function assertVoyageResponseOk( + res: Response, + context: string, + maxBytes: number = VOYAGE_BATCH_RESPONSE_MAX_BYTES, +): Promise { if (!res.ok) { - const text = await res.text(); + // The non-OK diagnostic body is just as untrusted as the success body: a + // misbehaving or hostile endpoint can return a 4xx/5xx with an unbounded + // body, and the old `await res.text()` buffered it whole before we threw. + // Read it through the same bounded reader (16 MiB cap, stream cancelled on + // overflow) while preserving the original `${context}: ${status} ${text}` + // diagnostic shape for backward compatibility. + const bytes = await readResponseWithLimit(res, maxBytes, { + onOverflow: ({ maxBytes: maxBytesLocal }) => + new Error(`${context}: ${res.status} (error body exceeds ${maxBytesLocal} bytes)`), + }); + const text = new TextDecoder().decode(bytes); throw new Error(`${context}: ${res.status} ${text}`); } } @@ -127,14 +147,18 @@ async function fetchVoyageBatchStatus(params: { client: VoyageEmbeddingClient; batchId: string; deps: VoyageBatchDeps; + maxResponseBytes?: number; }): Promise { + const maxBytes = params.maxResponseBytes ?? VOYAGE_BATCH_RESPONSE_MAX_BYTES; return await params.deps.withRemoteHttpResponse( buildVoyageBatchRequest({ client: params.client, path: `batches/${params.batchId}`, onResponse: async (res) => { - await assertVoyageResponseOk(res, "voyage batch status failed"); - return (await res.json()) as VoyageBatchStatus; + await assertVoyageResponseOk(res, "voyage batch status failed", maxBytes); + return await readProviderJsonResponse(res, "voyage-batch-status", { + maxBytes, + }); }, }), ); @@ -144,15 +168,21 @@ async function readVoyageBatchError(params: { client: VoyageEmbeddingClient; errorFileId: string; deps: VoyageBatchDeps; + maxResponseBytes?: number; }): Promise { + const maxBytes = params.maxResponseBytes ?? VOYAGE_BATCH_RESPONSE_MAX_BYTES; try { return await params.deps.withRemoteHttpResponse( buildVoyageBatchRequest({ client: params.client, path: `files/${params.errorFileId}/content`, onResponse: async (res) => { - await assertVoyageResponseOk(res, "voyage batch error file content failed"); - const text = await res.text(); + await assertVoyageResponseOk(res, "voyage batch error file content failed", maxBytes); + const bytes = await readResponseWithLimit(res, maxBytes, { + onOverflow: ({ maxBytes: maxBytesLocal }) => + new Error(`voyage batch error file content exceeds ${maxBytesLocal} bytes`), + }); + const text = new TextDecoder().decode(bytes); if (!text.trim()) { return undefined; } @@ -280,10 +310,9 @@ export async function runVoyageEmbeddingBatches( headers: buildBatchHeaders(params.client, { json: true }), }, onResponse: async (contentRes) => { - if (!contentRes.ok) { - const text = await contentRes.text(); - throw new Error(`voyage batch file content failed: ${contentRes.status} ${text}`); - } + // Same bounded non-OK diagnostic read as the status/error-file paths: + // the failure body is untrusted, so cap it instead of `await text()`. + await assertVoyageResponseOk(contentRes, "voyage batch file content failed"); if (!contentRes.body) { return; @@ -316,3 +345,9 @@ export async function runVoyageEmbeddingBatches( }, }); } + +export const testing = { + fetchVoyageBatchStatus, + readVoyageBatchError, + VOYAGE_BATCH_RESPONSE_MAX_BYTES, +} as const; diff --git a/extensions/vydra/image-generation-provider.ts b/extensions/vydra/image-generation-provider.ts index e56e74002bad..f583bcb3dc29 100644 --- a/extensions/vydra/image-generation-provider.ts +++ b/extensions/vydra/image-generation-provider.ts @@ -1,7 +1,11 @@ // Vydra provider module implements model/runtime integration. import type { ImageGenerationProvider } from "openclaw/plugin-sdk/image-generation"; import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth"; -import { assertOkOrThrowHttpError, postJsonRequest } from "openclaw/plugin-sdk/provider-http"; +import { + assertOkOrThrowHttpError, + postJsonRequest, + readProviderJsonResponse, +} from "openclaw/plugin-sdk/provider-http"; import { DEFAULT_VYDRA_IMAGE_MODEL, downloadVydraAsset, @@ -75,7 +79,7 @@ export function buildVydraImageGenerationProvider(): ImageGenerationProvider { try { await assertOkOrThrowHttpError(response, "Vydra image generation failed"); - const submitted = await response.json(); + const submitted = await readProviderJsonResponse(response, "vydra.image-generation"); const completedPayload = await resolveCompletedVydraPayload({ submitted, baseUrl, diff --git a/extensions/whatsapp/src/auto-reply/monitor/group-activation.ts b/extensions/whatsapp/src/auto-reply/monitor/group-activation.ts index 87d887717ebe..8651e500f991 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/group-activation.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/group-activation.ts @@ -1,10 +1,14 @@ // Whatsapp plugin module implements group activation behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; -import { updateSessionStore } from "openclaw/plugin-sdk/session-store-runtime"; +import { + getSessionEntry, + patchSessionEntry, + resolveStorePath, + type SessionEntry, +} from "openclaw/plugin-sdk/session-store-runtime"; import { resolveWhatsAppLegacyGroupSessionKey } from "../../group-session-key.js"; import { resolveWhatsAppInboundPolicy } from "../../inbound-policy.js"; -import { loadSessionStore, resolveStorePath } from "../config.runtime.js"; import { normalizeGroupActivation } from "./group-activation.runtime.js"; function hasNamedWhatsAppAccounts(cfg: OpenClawConfig) { @@ -28,6 +32,7 @@ function isActivationOnlyEntry( ); } +/** Resolves group activation for a WhatsApp conversation and backfills scoped session metadata. */ export async function resolveGroupActivationFor(params: { cfg: OpenClawConfig; accountId?: string | null; @@ -38,13 +43,15 @@ export async function resolveGroupActivationFor(params: { const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId, }); - const store = loadSessionStore(storePath); + const sessionScope = { storePath, agentId: params.agentId }; const legacySessionKey = resolveWhatsAppLegacyGroupSessionKey({ sessionKey: params.sessionKey, accountId: params.accountId, }); - const legacyEntry = legacySessionKey ? store[legacySessionKey] : undefined; - const scopedEntry = store[params.sessionKey]; + const legacyEntry = legacySessionKey + ? getSessionEntry({ ...sessionScope, sessionKey: legacySessionKey }) + : undefined; + const scopedEntry = getSessionEntry({ ...sessionScope, sessionKey: params.sessionKey }); const normalizedAccountId = normalizeAccountId(params.accountId); const ignoreScopedActivation = normalizedAccountId === DEFAULT_ACCOUNT_ID && @@ -54,15 +61,22 @@ export async function resolveGroupActivationFor(params: { (ignoreScopedActivation ? undefined : scopedEntry?.groupActivation) ?? legacyEntry?.groupActivation; if (activation !== undefined && scopedEntry?.groupActivation === undefined) { - await updateSessionStore(storePath, (nextStore) => { - const nextScopedEntry = nextStore[params.sessionKey]; - if (nextScopedEntry?.groupActivation !== undefined) { - return; - } - nextStore[params.sessionKey] = { - ...nextScopedEntry, - groupActivation: activation, - }; + // Activation-only backfills must not synthesize session ids or activity. + // replaceEntry preserves existing scoped metadata while keeping fallback writes sparse. + await patchSessionEntry({ + ...sessionScope, + sessionKey: params.sessionKey, + fallbackEntry: {} as SessionEntry, + replaceEntry: true, + update: (entry) => { + if (entry.groupActivation !== undefined) { + return null; + } + return { + ...entry, + groupActivation: activation, + }; + }, }); } const requireMention = resolveWhatsAppInboundPolicy({ diff --git a/extensions/xai/image-generation-provider.test.ts b/extensions/xai/image-generation-provider.test.ts index a979af52e887..fad3f0ef51a2 100644 --- a/extensions/xai/image-generation-provider.test.ts +++ b/extensions/xai/image-generation-provider.test.ts @@ -52,15 +52,21 @@ vi.mock("openclaw/plugin-sdk/provider-auth", () => ({ isProviderApiKeyConfigured: isProviderApiKeyConfiguredMock, })); -vi.mock("openclaw/plugin-sdk/provider-http", () => ({ - assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock, - createProviderOperationDeadline: createProviderOperationDeadlineMock, - postJsonRequest: postJsonRequestMock, - postMultipartRequest: postMultipartRequestMock, - resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock, - resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock, - sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock, -})); +vi.mock("openclaw/plugin-sdk/provider-http", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/provider-http", + ); + return { + assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock, + createProviderOperationDeadline: createProviderOperationDeadlineMock, + postJsonRequest: postJsonRequestMock, + postMultipartRequest: postMultipartRequestMock, + readProviderJsonResponse: actual.readProviderJsonResponse, + resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock, + resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock, + sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock, + }; +}); vi.mock("openclaw/plugin-sdk/string-coerce-runtime", () => ({ normalizeOptionalString: (v: unknown) => (typeof v === "string" ? v.trim() : undefined), @@ -69,6 +75,13 @@ vi.mock("openclaw/plugin-sdk/string-coerce-runtime", () => ({ readStringValue: (v: unknown) => (typeof v === "string" ? v.trim() : undefined), })); +function jsonResponse(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + function requirePostJsonCall(index = 0): { url?: string; timeoutMs?: number; @@ -133,11 +146,9 @@ describe("xai image generation provider", () => { it("uses main provider URL and resolves auth for generation", async () => { postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => ({ - data: [{ b64_json: Buffer.from("testpng").toString("base64") }], - }), - }, + response: jsonResponse({ + data: [{ b64_json: Buffer.from("testpng").toString("base64") }], + }), release: vi.fn(async () => {}), }); @@ -190,17 +201,15 @@ describe("xai image generation provider", () => { it("supports edit with exact user-provided payload format including image object with type image_url", async () => { postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => ({ - data: [ - { - b64_json: - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYGD4z0ABAAEfAG0B0xMAAAAASUVORK5CYII=", - mime_type: "image/png", - }, - ], - }), - }, + response: jsonResponse({ + data: [ + { + b64_json: + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYGD4z0ABAAEfAG0B0xMAAAAASUVORK5CYII=", + mime_type: "image/png", + }, + ], + }), release: vi.fn(async () => {}), }); @@ -232,11 +241,9 @@ describe("xai image generation provider", () => { it("forwards xAI attribution User-Agent through the SDK image request", async () => { vi.stubEnv("OPENCLAW_VERSION", "2026.3.22"); postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => ({ - data: [{ b64_json: Buffer.from("ua-png").toString("base64") }], - }), - }, + response: jsonResponse({ + data: [{ b64_json: Buffer.from("ua-png").toString("base64") }], + }), release: vi.fn(async () => {}), }); @@ -257,16 +264,14 @@ describe("xai image generation provider", () => { it("uses the plural xAI images payload for multiple edit inputs", async () => { postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => ({ - data: [ - { - b64_json: Buffer.from("edited").toString("base64"), - mime_type: "image/png", - }, - ], - }), - }, + response: jsonResponse({ + data: [ + { + b64_json: Buffer.from("edited").toString("base64"), + mime_type: "image/png", + }, + ], + }), release: vi.fn(async () => {}), }); diff --git a/extensions/xai/stt.ts b/extensions/xai/stt.ts index eeeb4a5f2611..6dd8043ff821 100644 --- a/extensions/xai/stt.ts +++ b/extensions/xai/stt.ts @@ -8,8 +8,9 @@ import { assertOkOrThrowHttpError, buildAudioTranscriptionFormData, postTranscriptionRequest, - resolveProviderHttpRequestConfig, + readProviderJsonResponse, requireTranscriptionText, + resolveProviderHttpRequestConfig, } from "openclaw/plugin-sdk/provider-http"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { XAI_BASE_URL } from "./model-definitions.js"; @@ -68,7 +69,7 @@ export async function transcribeXaiAudio( try { await assertOkOrThrowHttpError(response, "xAI audio transcription failed"); - const payload = (await response.json()) as XaiSttResponse; + const payload = await readProviderJsonResponse(response, "xai.stt"); return { text: requireTranscriptionText(payload.text, "xAI transcription response missing text"), ...(model ? { model } : {}), diff --git a/extensions/xai/xai-oauth.test.ts b/extensions/xai/xai-oauth.test.ts index b0a55f2c12b3..d69779e4472a 100644 --- a/extensions/xai/xai-oauth.test.ts +++ b/extensions/xai/xai-oauth.test.ts @@ -211,6 +211,67 @@ describe("xAI OAuth", () => { expect(refreshed.expires).toBe(121_000); }); + it("rediscovers the current token endpoint for stale xAI OAuth credentials", async () => { + const fetchImpl = vi.fn(async (url, init) => { + if (requestUrl(url) === XAI_OAUTH_DISCOVERY_URL) { + expect(init?.method).toBeUndefined(); + return jsonResponse({ + authorization_endpoint: "https://auth.x.ai/oauth2/authorize", + token_endpoint: "https://auth.x.ai/oauth2/token", + }); + } + expect(requestUrl(url)).toBe("https://auth.x.ai/oauth2/token"); + expect(init?.method).toBe("POST"); + expect(requireStringBody(init)).toContain("refresh_token=refresh-1"); + return jsonResponse({ + access_token: "access-2", + refresh_token: "refresh-2", + expires_in: 120, + }); + }); + const credential = { + type: "oauth", + provider: "xai", + access: "access-1", + refresh: "refresh-1", + expires: 100, + tokenEndpoint: "https://auth.x.ai/oauth/token", + } satisfies OAuthCredential & { tokenEndpoint: string }; + + const refreshed = await refreshXaiOAuthCredential(credential, { fetchImpl, now: () => 1_000 }); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(fetchImpl.mock.calls.map(([url]) => requestUrl(url))).toEqual([ + XAI_OAUTH_DISCOVERY_URL, + "https://auth.x.ai/oauth2/token", + ]); + expect(refreshed).toMatchObject({ + access: "access-2", + refresh: "refresh-2", + tokenEndpoint: "https://auth.x.ai/oauth2/token", + }); + }); + + it("does not reuse the stale xAI OAuth token endpoint when discovery fails", async () => { + const fetchImpl = vi.fn(async (url) => { + expect(requestUrl(url)).toBe(XAI_OAUTH_DISCOVERY_URL); + throw new Error("discovery unavailable"); + }); + const credential = { + type: "oauth", + provider: "xai", + access: "access-1", + refresh: "refresh-1", + expires: 100, + tokenEndpoint: "https://auth.x.ai/oauth/token", + } satisfies OAuthCredential & { tokenEndpoint: string }; + + await expect(refreshXaiOAuthCredential(credential, { fetchImpl })).rejects.toThrow( + "discovery unavailable", + ); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + it("does not coerce partial xAI expires_in values", async () => { const fetchImpl = vi.fn(async () => jsonResponse({ diff --git a/extensions/xai/xai-oauth.ts b/extensions/xai/xai-oauth.ts index 19f64d9c228f..7a1c4674b6a6 100644 --- a/extensions/xai/xai-oauth.ts +++ b/extensions/xai/xai-oauth.ts @@ -27,6 +27,7 @@ export const XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"; export const XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:access"; export const XAI_OAUTH_ISSUER = "https://auth.x.ai"; export const XAI_OAUTH_DISCOVERY_URL = `${XAI_OAUTH_ISSUER}/.well-known/openid-configuration`; +const XAI_LEGACY_OAUTH_TOKEN_ENDPOINT = `${XAI_OAUTH_ISSUER}/oauth/token`; export const XAI_OAUTH_CALLBACK_HOST = "127.0.0.1"; export const XAI_OAUTH_CALLBACK_PORT = 56121; export const XAI_OAUTH_CALLBACK_PATH = "/callback"; @@ -492,6 +493,28 @@ function readCredentialString( return typeof value === "string" && value.trim().length > 0 ? value : undefined; } +async function resolveXaiOAuthRefreshTokenEndpoint( + credential: OAuthCredential, + options: XaiOAuthFetchOptions, +): Promise { + const cachedEndpoint = readCredentialString(credential, "tokenEndpoint"); + if (!cachedEndpoint) { + return (await fetchXaiOAuthDiscovery(options)).tokenEndpoint; + } + let endpoint: URL; + try { + endpoint = new URL(cachedEndpoint); + } catch { + return cachedEndpoint; + } + if (`${endpoint.origin}${endpoint.pathname}` !== XAI_LEGACY_OAUTH_TOKEN_ENDPOINT) { + return cachedEndpoint; + } + // Older persisted xAI OAuth credentials can point at the retired endpoint; + // rediscover once so refresh writes back the current OAuth token endpoint. + return (await fetchXaiOAuthDiscovery(options)).tokenEndpoint; +} + async function noteXaiOAuthUrl(ctx: ProviderAuthContext, authorizeUrl: string): Promise { const lines = ["Open this xAI OAuth URL in your browser:"]; if (ctx.isRemote) { @@ -665,9 +688,7 @@ export async function refreshXaiOAuthCredential( if (!refreshToken) { throw new Error("xAI OAuth credential is missing refresh token"); } - const tokenEndpoint = - readCredentialString(credential, "tokenEndpoint") ?? - (await fetchXaiOAuthDiscovery(options)).tokenEndpoint; + const tokenEndpoint = await resolveXaiOAuthRefreshTokenEndpoint(credential, options); const tokens = await exchangeXaiOAuthToken({ ...options, tokenEndpoint, diff --git a/git-hooks/pre-commit b/git-hooks/pre-commit index 88c73412bac3..76fee645db11 100755 --- a/git-hooks/pre-commit +++ b/git-hooks/pre-commit @@ -16,6 +16,18 @@ if [[ ! -f "$FILTER_FILES" ]]; then exit 1 fi +GIT_DIR="$(git rev-parse --git-dir 2>/dev/null || true)" +if [[ -n "$GIT_DIR" ]] && \ + { [[ -f "$GIT_DIR/MERGE_HEAD" ]] || \ + [[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ]] || \ + [[ -f "$GIT_DIR/REVERT_HEAD" ]] || \ + [[ -f "$GIT_DIR/REBASE_HEAD" ]] || \ + [[ -d "$GIT_DIR/rebase-merge" ]] || \ + [[ -d "$GIT_DIR/rebase-apply" ]]; }; then + # Sequencer commits stage the operation result, not just the user's local edits. + exit 0 +fi + # Security: avoid option-injection from malicious file names (e.g. "--all", "--force"). # Robustness: NUL-delimited file list handles spaces/newlines safely. # Compatibility: use read loops instead of `mapfile` so this runs on macOS Bash 3.x. diff --git a/packages/acp-core/src/session.test.ts b/packages/acp-core/src/session.test.ts index 5dbf0d474d54..ca683badb938 100644 --- a/packages/acp-core/src/session.test.ts +++ b/packages/acp-core/src/session.test.ts @@ -34,6 +34,19 @@ describe("acp session manager", () => { expect(store.getSessionByRunId("run-1")).toBeUndefined(); }); + it("removes stale run lookup entries when rebinding an active run", () => { + const session = store.createSession({ + sessionKey: "acp:rebind", + cwd: "/tmp", + }); + + store.setActiveRun(session.sessionId, "run-old", new AbortController()); + store.setActiveRun(session.sessionId, "run-new", new AbortController()); + + expect(store.getSessionByRunId("run-old")).toBeUndefined(); + expect(store.getSessionByRunId("run-new")?.sessionId).toBe(session.sessionId); + }); + it("deletes sessions and aborts active runs on close", () => { const session = store.createSession({ sessionId: "close-me", diff --git a/packages/acp-core/src/session.ts b/packages/acp-core/src/session.ts index bb2313e2895a..dedcd0f8c74f 100644 --- a/packages/acp-core/src/session.ts +++ b/packages/acp-core/src/session.ts @@ -150,6 +150,9 @@ export function createInMemorySessionStore(options: AcpSessionStoreOptions = {}) if (!session) { return; } + if (session.activeRunId && session.activeRunId !== runId) { + runIdToSessionId.delete(session.activeRunId); + } session.activeRunId = runId; session.abortController = abortController; runIdToSessionId.set(runId, sessionId); diff --git a/packages/agent-core/src/harness/prompt-template-arguments.test.ts b/packages/agent-core/src/harness/prompt-template-arguments.test.ts new file mode 100644 index 000000000000..16a72553641e --- /dev/null +++ b/packages/agent-core/src/harness/prompt-template-arguments.test.ts @@ -0,0 +1,11 @@ +// Agent Core tests cover prompt template argument parsing behavior. +import { describe, expect, it } from "vitest"; +import { parseCommandArgs, substituteArgs } from "./prompt-template-arguments.js"; + +describe("prompt template arguments", () => { + it("preserves quoted empty arguments so positional placeholders stay aligned", () => { + expect(parseCommandArgs('first "" third')).toEqual(["first", "", "third"]); + expect(parseCommandArgs("first '' third")).toEqual(["first", "", "third"]); + expect(substituteArgs("$1|$2|$3", parseCommandArgs('first "" third'))).toBe("first||third"); + }); +}); diff --git a/packages/agent-core/src/harness/prompt-template-arguments.ts b/packages/agent-core/src/harness/prompt-template-arguments.ts index efe9e3d9a65d..76ed8baf34fd 100644 --- a/packages/agent-core/src/harness/prompt-template-arguments.ts +++ b/packages/agent-core/src/harness/prompt-template-arguments.ts @@ -5,26 +5,31 @@ export function parseCommandArgs(argsString: string): string[] { const args: string[] = []; let current = ""; let inQuote: string | null = null; + let hasToken = false; for (const char of argsString) { if (inQuote) { if (char === inQuote) { inQuote = null; } else { + hasToken = true; current += char; } } else if (char === '"' || char === "'") { + hasToken = true; inQuote = char; } else if (/\s/.test(char)) { - if (current) { + if (hasToken) { args.push(current); current = ""; + hasToken = false; } } else { + hasToken = true; current += char; } } - if (current) { + if (hasToken) { args.push(current); } return args; diff --git a/packages/gateway-protocol/src/clawhub-trust-error-details.ts b/packages/gateway-protocol/src/clawhub-trust-error-details.ts new file mode 100644 index 000000000000..353ac71210c4 --- /dev/null +++ b/packages/gateway-protocol/src/clawhub-trust-error-details.ts @@ -0,0 +1,66 @@ +/** Structured ClawHub trust details carried in gateway error payloads. */ +export const ClawHubTrustErrorCodes = { + SECURITY_UNAVAILABLE: "clawhub_security_unavailable", + RISK_ACKNOWLEDGEMENT_REQUIRED: "clawhub_risk_acknowledgement_required", + DOWNLOAD_BLOCKED: "clawhub_download_blocked", +} as const; + +export type ClawHubTrustErrorCode = + (typeof ClawHubTrustErrorCodes)[keyof typeof ClawHubTrustErrorCodes]; + +export type ClawHubTrustErrorDetails = { + clawhubTrustCode?: ClawHubTrustErrorCode; + version?: string; + warning?: string; +}; + +function normalizeNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +export function isClawHubTrustErrorCode(value: unknown): value is ClawHubTrustErrorCode { + return ( + value === ClawHubTrustErrorCodes.SECURITY_UNAVAILABLE || + value === ClawHubTrustErrorCodes.RISK_ACKNOWLEDGEMENT_REQUIRED || + value === ClawHubTrustErrorCodes.DOWNLOAD_BLOCKED + ); +} + +export function buildClawHubTrustErrorDetails(params: { + code?: ClawHubTrustErrorCode; + version?: string; + warning?: string; +}): ClawHubTrustErrorDetails | undefined { + if (!params.code && !params.version && !params.warning) { + return undefined; + } + return { + ...(params.code ? { clawhubTrustCode: params.code } : {}), + ...(params.version ? { version: params.version } : {}), + ...(params.warning ? { warning: params.warning } : {}), + }; +} + +export function readClawHubTrustErrorDetails( + details: unknown, +): ClawHubTrustErrorDetails | undefined { + if (!details || typeof details !== "object" || Array.isArray(details)) { + return undefined; + } + const raw = details as { + clawhubTrustCode?: unknown; + version?: unknown; + warning?: unknown; + }; + const code = isClawHubTrustErrorCode(raw.clawhubTrustCode) ? raw.clawhubTrustCode : undefined; + const version = normalizeNonEmptyString(raw.version); + const warning = normalizeNonEmptyString(raw.warning); + if (!code && !version && !warning) { + return undefined; + } + return { + ...(code ? { clawhubTrustCode: code } : {}), + ...(version ? { version } : {}), + ...(warning ? { warning } : {}), + }; +} diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 2e98ebac84cf..540fbae9949c 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -1,5 +1,13 @@ // Public gateway protocol entrypoint. Keep this barrel aligned with schema.ts // so clients can import wire types, JSON schemas, and validators from one place. +export { + buildClawHubTrustErrorDetails, + ClawHubTrustErrorCodes, + isClawHubTrustErrorCode, + readClawHubTrustErrorDetails, + type ClawHubTrustErrorCode, + type ClawHubTrustErrorDetails, +} from "./clawhub-trust-error-details.js"; import { Compile, type Validator as TypeBoxValidator } from "typebox/compile"; import { type AgentEvent, diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.test.ts b/packages/gateway-protocol/src/schema/agents-models-skills.test.ts index e783efb82812..0b5e2b726887 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.test.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.test.ts @@ -3,6 +3,7 @@ import { Value } from "typebox/value"; import { describe, expect, it } from "vitest"; import { AgentsListResultSchema, + SkillsDetailResultSchema, SkillsProposalInspectResultSchema, SkillsProposalRequestRevisionResultSchema, ToolsEffectiveResultSchema, @@ -173,3 +174,34 @@ describe("SkillsProposalRequestRevisionResultSchema", () => { ).toBe(false); }); }); + +describe("SkillsDetailResultSchema", () => { + it("accepts official ClawHub skill publisher metadata", () => { + const result = { + skill: { + slug: "tao-setup-nvidia-gpu-host", + displayName: "TAO Setup NVIDIA GPU Host", + summary: "Prepare an NVIDIA GPU host for TAO workflows.", + tags: { gpu: "GPU" }, + channel: "official", + isOfficial: true, + createdAt: 1_700_000_000, + updatedAt: 1_700_010_000, + }, + latestVersion: { + version: "1.0.0", + createdAt: 1_700_010_000, + }, + owner: { + handle: "nvidia", + displayName: "NVIDIA", + image: "https://example.test/nvidia.png", + official: true, + channel: "official", + isOfficial: true, + }, + }; + + expect(Value.Check(SkillsDetailResultSchema, result)).toBe(true); + }); +}); diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.ts b/packages/gateway-protocol/src/schema/agents-models-skills.ts index 8132bf6d575f..83d77949e4c7 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.ts @@ -344,6 +344,7 @@ export const SkillsInstallParamsSchema = Type.Union([ slug: NonEmptyString, version: Type.Optional(NonEmptyString), force: Type.Optional(Type.Boolean()), + acknowledgeClawHubRisk: Type.Optional(Type.Boolean()), timeoutMs: Type.Optional(Type.Integer({ minimum: 1000 })), }, { additionalProperties: false }, @@ -379,6 +380,7 @@ export const SkillsUpdateParamsSchema = Type.Union([ source: Type.Literal("clawhub"), slug: Type.Optional(NonEmptyString), all: Type.Optional(Type.Boolean()), + acknowledgeClawHubRisk: Type.Optional(Type.Boolean()), }, { additionalProperties: false }, ), @@ -439,6 +441,8 @@ export const SkillsDetailResultSchema = Type.Object( displayName: NonEmptyString, summary: Type.Optional(Type.String()), tags: Type.Optional(Type.Record(NonEmptyString, Type.String())), + channel: Type.Optional(Type.Union([Type.String(), Type.Null()])), + isOfficial: Type.Optional(Type.Union([Type.Boolean(), Type.Null()])), createdAt: Type.Integer(), updatedAt: Type.Integer(), }, @@ -478,6 +482,9 @@ export const SkillsDetailResultSchema = Type.Object( handle: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), displayName: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), image: Type.Optional(Type.Union([Type.String(), Type.Null()])), + official: Type.Optional(Type.Union([Type.Boolean(), Type.Null()])), + channel: Type.Optional(Type.Union([Type.String(), Type.Null()])), + isOfficial: Type.Optional(Type.Union([Type.Boolean(), Type.Null()])), }, { additionalProperties: false }, ), diff --git a/packages/gateway-protocol/src/schema/cron.ts b/packages/gateway-protocol/src/schema/cron.ts index f56638591c8e..9296d1860f3f 100644 --- a/packages/gateway-protocol/src/schema/cron.ts +++ b/packages/gateway-protocol/src/schema/cron.ts @@ -27,6 +27,9 @@ function cronAgentTurnPayloadSchema(params: { allowUnsafeExternalContent: Type.Optional(Type.Boolean()), lightContext: Type.Optional(Type.Boolean()), toolsAllow: Type.Optional(params.toolsAllow), + // Server-managed marker for auto-stamped defaults; persisted so CLI cron + // runs can drop only the cap that was never user-explicit. + toolsAllowIsDefault: Type.Optional(Type.Boolean()), }, { additionalProperties: false }, ); diff --git a/packages/markdown-core/src/fences.test.ts b/packages/markdown-core/src/fences.test.ts new file mode 100644 index 000000000000..e063d6bb7d50 --- /dev/null +++ b/packages/markdown-core/src/fences.test.ts @@ -0,0 +1,45 @@ +// Tests fenced-code-block span scanning used to keep chunk breaks out of code blocks. +import { describe, expect, it } from "vitest"; +import { isSafeFenceBreak, parseFenceSpans } from "./fences.js"; + +describe("parseFenceSpans closing-fence rules", () => { + it("treats a marker line with trailing text as code content, not a closing fence", () => { + // CommonMark: a closing fence may be followed only by whitespace, so "``` not a close" is code + // content and the block stays open until the real closing fence. Reporting an interior offset + // as a safe break would let a chunker split inside the code block. + const text = "```\ncode\n``` not a close\nmore code\n```\n"; + const spans = parseFenceSpans(text); + + expect(spans).toHaveLength(1); + expect(isSafeFenceBreak(spans, text.indexOf("more code") + 1)).toBe(false); + }); + + it("does not close on non-space/tab whitespace", () => { + for (const suffix of ["\u00a0", "\v", "\f"]) { + const text = `\`\`\`\ncode\n\`\`\`${suffix}\nmore code\n`; + const spans = parseFenceSpans(text); + + expect(spans).toHaveLength(1); + expect(spans[0]?.end).toBe(text.length); + expect(isSafeFenceBreak(spans, text.indexOf("more code") + 1)).toBe(false); + } + }); + + it("closes fences with CRLF line endings", () => { + const text = "```\r\ncode\r\n```\r\nafter\r\n"; + const spans = parseFenceSpans(text); + + expect(spans).toHaveLength(1); + expect(isSafeFenceBreak(spans, text.indexOf("after") + 1)).toBe(true); + }); + + it("still closes on a bare fence, a longer same-marker fence, and keeps an opener info string", () => { + expect(parseFenceSpans("```\ncode\n```\nafter\n")).toHaveLength(1); + expect(parseFenceSpans("```\ncode\n````` \nafter\n")).toHaveLength(1); + expect(parseFenceSpans("```python\nx = 1\n```\n")).toHaveLength(1); + + const closed = "```\ncode\n```\nafter\n"; + const spans = parseFenceSpans(closed); + expect(isSafeFenceBreak(spans, closed.indexOf("after") + 1)).toBe(true); + }); +}); diff --git a/packages/markdown-core/src/fences.ts b/packages/markdown-core/src/fences.ts index bdb63e7da18b..74859d6ef673 100644 --- a/packages/markdown-core/src/fences.ts +++ b/packages/markdown-core/src/fences.ts @@ -41,7 +41,7 @@ export function scanFenceSpans( while (offset <= buffer.length) { const nextNewline = buffer.indexOf("\n", offset); const lineEnd = nextNewline === -1 ? buffer.length : nextNewline; - const line = buffer.slice(offset, lineEnd); + const line = buffer.slice(offset, lineEnd).replace(/\r$/, ""); const match = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/); if (match && (offset > 0 || startsAtLineStart)) { @@ -58,9 +58,13 @@ export function scanFenceSpans( marker, indent, }; - } else if (open.markerChar === markerChar && markerLen >= open.markerLen) { - // CommonMark allows a closing fence to be longer than the opener, but - // it must use the same marker character to avoid crossing fence kinds. + } else if ( + open.markerChar === markerChar && + markerLen >= open.markerLen && + /^[ \t]*$/.test(match[3]) + ) { + // CommonMark permits only spaces or tabs after a closing fence. A marker line carrying + // other trailing text is code content, not a close, so it must not end the block. const end = lineEnd; spans.push({ start: open.start, diff --git a/packages/markdown-core/src/ir.ts b/packages/markdown-core/src/ir.ts index 604bd7ceaf36..552e5bb92070 100644 --- a/packages/markdown-core/src/ir.ts +++ b/packages/markdown-core/src/ir.ts @@ -78,9 +78,12 @@ function createStyleSpan(params: MarkdownStyleSpan): MarkdownStyleSpan { return span; } +type MarkdownTableAlignment = "left" | "center" | "right"; + export type MarkdownTableData = { headers: string[]; rows: string[][]; + aligns?: (MarkdownTableAlignment | undefined)[]; }; export type MarkdownTableCell = { @@ -113,6 +116,7 @@ type TableCell = MarkdownTableCell; type TableState = { headers: TableCell[]; rows: TableCell[][]; + aligns: (MarkdownTableAlignment | undefined)[]; currentRow: TableCell[]; currentCell: RenderTarget | null; inHeader: boolean; @@ -172,6 +176,20 @@ function getAttr(token: MarkdownToken, name: string): string | null { return null; } +function markdownTableAlignmentFromToken(token: MarkdownToken): MarkdownTableAlignment | undefined { + const value = getAttr(token, "style") ?? ""; + if (/text-align\s*:\s*left/i.test(value)) { + return "left"; + } + if (/text-align\s*:\s*center/i.test(value)) { + return "center"; + } + if (/text-align\s*:\s*right/i.test(value)) { + return "right"; + } + return undefined; +} + function createTextToken(base: MarkdownToken, content: string): MarkdownToken { return { ...base, type: "text", content, children: undefined }; } @@ -432,6 +450,7 @@ function initTableState(): TableState { return { headers: [], rows: [], + aligns: [], currentRow: [], currentCell: null, inHeader: false, @@ -517,13 +536,15 @@ function collectTableBlock(state: RenderState) { } const headerCells = state.table.headers.map(trimCell); const rowCells = state.table.rows.map((row) => row.map(trimCell)); - state.collectedTables.push({ + const table = { headers: headerCells.map((cell) => cell.text), rows: rowCells.map((row) => row.map((cell) => cell.text)), headerCells, rowCells, placeholderOffset: state.text.length, - }); + ...(state.table.aligns.some(Boolean) ? { aligns: [...state.table.aligns] } : {}), + }; + state.collectedTables.push(table); } function appendTableBulletValue( @@ -874,6 +895,10 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void { case "td_open": if (state.table) { state.table.currentCell = initRenderTarget(); + if (token.type === "th_open" && state.table.inHeader) { + state.table.aligns[state.table.currentRow.length] = + markdownTableAlignmentFromToken(token); + } } break; case "th_close": diff --git a/packages/media-core/src/base64.test.ts b/packages/media-core/src/base64.test.ts index a501ec145095..a26a5d39b42c 100644 --- a/packages/media-core/src/base64.test.ts +++ b/packages/media-core/src/base64.test.ts @@ -13,6 +13,16 @@ describe("base64 helpers", () => { actual: canonicalizeBase64(" SGV s bG8= \n"), expected: "SGVsbG8=", }, + { + name: "canonicalizeBase64 pads valid unpadded base64", + actual: canonicalizeBase64("SGVsbG8"), + expected: "SGVsbG8=", + }, + { + name: "canonicalizeBase64 rejects impossible unpadded length", + actual: canonicalizeBase64("S"), + expected: undefined, + }, { name: "canonicalizeBase64 rejects invalid base64 characters", actual: canonicalizeBase64('SGVsbG8=" onerror="alert(1)'), diff --git a/packages/media-core/src/base64.ts b/packages/media-core/src/base64.ts index 2c8ee622637f..511ca221fb21 100644 --- a/packages/media-core/src/base64.ts +++ b/packages/media-core/src/base64.ts @@ -74,8 +74,15 @@ export function canonicalizeBase64(base64: string): string | undefined { } cleaned += base64[i]; } - if (!cleaned || cleaned.length % 4 !== 0) { + if (!cleaned) { return undefined; } + const remainder = cleaned.length % 4; + if (remainder !== 0) { + if (sawPadding || remainder === 1) { + return undefined; + } + cleaned += "=".repeat(4 - remainder); + } return cleaned; } diff --git a/packages/media-core/src/inline-image-data-url.test.ts b/packages/media-core/src/inline-image-data-url.test.ts index fcbbc8a10475..3258d9709e96 100644 --- a/packages/media-core/src/inline-image-data-url.test.ts +++ b/packages/media-core/src/inline-image-data-url.test.ts @@ -39,6 +39,13 @@ describe("inline image data URL sanitizer", () => { ); }); + it("canonicalizes valid unpadded image data URLs", () => { + const unpaddedPng = PNG_1X1.replace(/=+$/u, ""); + expect(sanitizeInlineImageDataUrl(`data:image/png;base64,${unpaddedPng}`)).toBe( + `data:image/png;base64,${PNG_1X1}`, + ); + }); + it("rejects image data URLs for formats that require conversion before provider transport", () => { expect(sanitizeInlineImageDataUrl(`data:image/bmp;base64,${BMP_HEADER}`)).toBeUndefined(); expect(sanitizeInlineImageDataUrl(`data:image/heic;base64,${HEIC_HEADER}`)).toBeUndefined(); diff --git a/packages/media-core/src/mime.test.ts b/packages/media-core/src/mime.test.ts index bb4d85087a8a..6de70cb99fd1 100644 --- a/packages/media-core/src/mime.test.ts +++ b/packages/media-core/src/mime.test.ts @@ -8,6 +8,7 @@ import { FILE_TYPE_SNIFF_MAX_BYTES, imageMimeFromFormat, isAudioFileName, + isGifMedia, kindFromMime, mimeTypeFromFilePath, normalizeMimeType, @@ -271,6 +272,29 @@ describe("isAudioFileName", () => { }); }); +describe("isGifMedia", () => { + it.each([ + { + opts: { contentType: "image/gif; charset=binary" }, + expected: true, + }, + { + opts: { contentType: " IMAGE/GIF " }, + expected: true, + }, + { + opts: { contentType: "image/png" }, + expected: false, + }, + { + opts: { fileName: "animation.GIF" }, + expected: true, + }, + ] as const)("detects GIF media from normalized metadata %#", ({ opts, expected }) => { + expect(isGifMedia(opts)).toBe(expected); + }); +}); + describe("normalizeMimeType", () => { function expectNormalizedMimeCase( input: Parameters[0], diff --git a/packages/media-core/src/mime.ts b/packages/media-core/src/mime.ts index 3a80e47d85f1..8542acba3047 100644 --- a/packages/media-core/src/mime.ts +++ b/packages/media-core/src/mime.ts @@ -252,7 +252,7 @@ export function isGifMedia(opts: { contentType?: string | null; fileName?: string | null; }): boolean { - if (opts.contentType?.toLowerCase() === "image/gif") { + if (normalizeMimeType(opts.contentType) === "image/gif") { return true; } const ext = getFileExtension(opts.fileName); diff --git a/packages/media-generation-core/src/catalog.test.ts b/packages/media-generation-core/src/catalog.test.ts index 0dd47e0645ad..f42b58a2f55c 100644 --- a/packages/media-generation-core/src/catalog.test.ts +++ b/packages/media-generation-core/src/catalog.test.ts @@ -55,4 +55,27 @@ describe("media-generation catalog", () => { }), ).toEqual(["video-default", "video-pro"]); }); + + it("marks a trimmed default model as the catalog default", () => { + expect( + synthesizeMediaGenerationCatalogEntries({ + kind: "video_generation", + provider: { + id: "example", + defaultModel: " video-default ", + models: ["video-default"], + capabilities: {}, + }, + }), + ).toEqual([ + { + kind: "video_generation", + provider: "example", + model: "video-default", + source: "static", + default: true, + capabilities: {}, + }, + ]); + }); }); diff --git a/packages/media-generation-core/src/catalog.ts b/packages/media-generation-core/src/catalog.ts index 15415570c78c..d87c8e946b28 100644 --- a/packages/media-generation-core/src/catalog.ts +++ b/packages/media-generation-core/src/catalog.ts @@ -51,6 +51,7 @@ export function synthesizeMediaGenerationCatalogEntries(params: { provider: MediaGenerationCatalogProvider; modes?: readonly string[]; }): Array> { + const defaultModel = uniqueTrimmedStrings([params.provider.defaultModel])[0]; return uniqueModels(params.provider).map((model) => { const entry: MediaGenerationCatalogEntry = { kind: params.kind, @@ -62,7 +63,7 @@ export function synthesizeMediaGenerationCatalogEntries(params: { if (params.provider.label) { entry.label = params.provider.label; } - if (model === params.provider.defaultModel) { + if (model === defaultModel) { entry.default = true; } if (params.modes) { diff --git a/packages/media-understanding-common/src/format.test.ts b/packages/media-understanding-common/src/format.test.ts index bb30b4843ea0..136d70523585 100644 --- a/packages/media-understanding-common/src/format.test.ts +++ b/packages/media-understanding-common/src/format.test.ts @@ -48,6 +48,36 @@ describe("formatMediaUnderstandingBody", () => { expect(body).toBe("[Audio]\nUser text:\ncaption here\nTranscript:\ntranscribed"); }); + it("strips repeated leading media placeholders from user text", () => { + const body = formatMediaUnderstandingBody({ + body: " caption here", + outputs: [ + { + kind: "audio.transcription", + attachmentIndex: 0, + text: "transcribed", + provider: "groq", + }, + ], + }); + expect(body).toBe("[Audio]\nUser text:\ncaption here\nTranscript:\ntranscribed"); + }); + + it("treats repeated media placeholders without captions as synthetic text", () => { + const body = formatMediaUnderstandingBody({ + body: " ", + outputs: [ + { + kind: "image.description", + attachmentIndex: 0, + text: "a chart", + provider: "openai", + }, + ], + }); + expect(body).toBe("[Image]\nDescription:\na chart"); + }); + it("keeps user text once when multiple outputs exist", () => { const body = formatMediaUnderstandingBody({ body: "caption here", diff --git a/packages/media-understanding-common/src/format.ts b/packages/media-understanding-common/src/format.ts index 6ce11a88df2d..9d7a337f08e5 100644 --- a/packages/media-understanding-common/src/format.ts +++ b/packages/media-understanding-common/src/format.ts @@ -1,8 +1,9 @@ // Media Understanding Common helper module supports format behavior. import type { MediaUnderstandingOutput } from "./types.js"; -const MEDIA_PLACEHOLDER_RE = /^]+>(\s*\([^)]*\))?$/i; -const MEDIA_PLACEHOLDER_TOKEN_RE = /^]+>(\s*\([^)]*\))?\s*/i; +const MEDIA_PLACEHOLDER_TOKEN = String.raw`]+>(?:\s*\([^)]*\))?`; +const MEDIA_PLACEHOLDER_RE = new RegExp(String.raw`^(?:${MEDIA_PLACEHOLDER_TOKEN}\s*)+$`, "i"); +const MEDIA_PLACEHOLDER_TOKEN_RE = new RegExp(String.raw`^(?:${MEDIA_PLACEHOLDER_TOKEN}\s*)+`, "i"); /** Extracts user-authored text while ignoring synthetic media placeholder tokens. */ export function extractMediaUserText(body?: string): string | undefined { diff --git a/packages/media-understanding-common/src/output-extract.test.ts b/packages/media-understanding-common/src/output-extract.test.ts new file mode 100644 index 000000000000..336055d87794 --- /dev/null +++ b/packages/media-understanding-common/src/output-extract.test.ts @@ -0,0 +1,100 @@ +// Media Understanding Common tests cover provider output extraction behavior. +import { describe, expect, it } from "vitest"; +import { extractGeminiResponse } from "./output-extract.js"; + +describe("extractGeminiResponse", () => { + it("extracts the response from noisy output with nested JSON objects", () => { + expect( + extractGeminiResponse( + [ + "debug: invoking gemini", + JSON.stringify({ + response: "a useful description", + usage: { + inputTokens: 12, + outputTokens: 4, + }, + }), + ].join("\n"), + ), + ).toBe("a useful description"); + }); + + it("returns null for an incomplete JSON object", () => { + expect(extractGeminiResponse("{")).toBeNull(); + }); + + it("ignores unmatched quotes in noisy output before the JSON object", () => { + expect(extractGeminiResponse('debug: model said "hello\n{"response":"ok"}')).toBe("ok"); + }); + + it("ignores braces inside quoted noisy output", () => { + expect(extractGeminiResponse('debug: "hello { world" {"response":"ok"}')).toBe("ok"); + }); + + it("ignores shell-quoted JSON-like noisy output", () => { + expect(extractGeminiResponse('debug: \'{"response":"fake"}\'')).toBeNull(); + }); + + it("does not treat apostrophes inside noisy words as quote delimiters", () => { + expect(extractGeminiResponse('debug: it\'s done {"response":"ok"}')).toBe("ok"); + }); + + it("resynchronizes after an unmatched brace in noisy output", () => { + expect(extractGeminiResponse('debug: generated {\n{"response":"ok"}')).toBe("ok"); + }); + + it("preserves brace-heavy response text", () => { + const response = "{".repeat(33); + expect(extractGeminiResponse(JSON.stringify({ response }))).toBe(response); + }); + + it("extracts pretty-printed JSON output", () => { + expect( + extractGeminiResponse( + JSON.stringify( + { + response: "pretty response", + usage: { inputTokens: 12 }, + }, + null, + 2, + ), + ), + ).toBe("pretty response"); + }); + + it("preserves pretty-printed object elements inside arrays", () => { + expect( + extractGeminiResponse( + JSON.stringify( + { + response: "array response", + items: [{ id: 1 }, { id: 2 }], + }, + null, + 2, + ), + ), + ).toBe("array response"); + }); + + it("does not accept an inner response from a malformed trailing object", () => { + expect(extractGeminiResponse('{"response":"good"} {"meta":{"response":"bad"} broken}')).toBe( + "good", + ); + expect(extractGeminiResponse('{"response":"good"} {"meta":{"response":"bad"}')).toBe("good"); + }); + + it("ignores a nested response inside an unfinished outer object", () => { + expect(extractGeminiResponse('noise {"meta":{"response":"bad"}')).toBeNull(); + }); + + it("does not promote a child from a malformed outer object", () => { + expect(extractGeminiResponse('{"response":"good"} {"meta" {"response":"bad"}}')).toBe("good"); + expect(extractGeminiResponse('noise {broken {"response":"bad"}}')).toBeNull(); + expect(extractGeminiResponse('{"response":"good"}\nnoise {broken\n{"response":"bad"}}')).toBe( + "good", + ); + }); +}); diff --git a/packages/media-understanding-common/src/output-extract.ts b/packages/media-understanding-common/src/output-extract.ts index 9b7cffea0e14..53cecd82e7aa 100644 --- a/packages/media-understanding-common/src/output-extract.ts +++ b/packages/media-understanding-common/src/output-extract.ts @@ -3,16 +3,119 @@ /** Parse the last JSON object in a noisy provider output string. */ function extractLastJsonObject(raw: string): unknown { const trimmed = raw.trim(); - const start = trimmed.lastIndexOf("{"); - if (start === -1) { - return null; + const ranges: Array<{ end: number; start: number }> = []; + const starts: number[] = []; + let inString = false; + let escaped = false; + let preambleQuote: string | undefined; + let preambleEscaped = false; + let previousSignificant: string | undefined; + let lineHasNonWhitespace = false; + let arrayDepth = 0; + let candidateHasContent = false; + + for (let index = 0; index < trimmed.length; index += 1) { + const character = trimmed[index]; + if (inString) { + if (character === "\n" || character === "\r") { + starts.length = 0; + inString = false; + escaped = false; + } else if (escaped) { + escaped = false; + } else if (character === "\\") { + escaped = true; + } else if (character === '"') { + inString = false; + } + continue; + } + + if (starts.length === 0) { + if (preambleQuote !== undefined) { + if (character === "\n" || character === "\r") { + preambleQuote = undefined; + preambleEscaped = false; + } else if (preambleEscaped) { + preambleEscaped = false; + } else if (character === "\\") { + preambleEscaped = true; + } else if (character === preambleQuote) { + preambleQuote = undefined; + } + continue; + } + if (character === '"' || character === "'" || character === "`") { + const previous = trimmed[index - 1]; + if (previous === undefined || /[\s:([{]/.test(previous)) { + preambleQuote = character; + preambleEscaped = false; + continue; + } + } + if (character === "{") { + arrayDepth = 0; + candidateHasContent = false; + starts.push(index); + } + if (!/\s/.test(character)) { + previousSignificant = character; + lineHasNonWhitespace = true; + } else if (character === "\n" || character === "\r") { + lineHasNonWhitespace = false; + } + continue; + } + + const hadCandidateContent = candidateHasContent; + if (character === '"') { + inString = true; + } else if (character === "{") { + if ( + previousSignificant === ":" || + previousSignificant === "[" || + previousSignificant === '"' || + (previousSignificant === "," && (lineHasNonWhitespace || arrayDepth > 0)) + ) { + starts.push(index); + } else if (!lineHasNonWhitespace && !hadCandidateContent) { + // Only resync at a clean record boundary; otherwise keep malformed + // outer objects from promoting diagnostic payloads as valid results. + starts.length = 1; + starts[0] = index; + arrayDepth = 0; + candidateHasContent = false; + } + } else if (character === "}" && starts.length > 0) { + const start = starts.pop(); + if (start !== undefined && starts.length === 0) { + ranges.push({ start, end: index }); + } + } else if (character === "[") { + arrayDepth += 1; + } else if (character === "]" && arrayDepth > 0) { + arrayDepth -= 1; + } + + if (!/\s/.test(character)) { + candidateHasContent = true; + previousSignificant = character; + lineHasNonWhitespace = true; + } else if (character === "\n" || character === "\r") { + lineHasNonWhitespace = false; + } } - const slice = trimmed.slice(start); - try { - return JSON.parse(slice); - } catch { - return null; + + for (let index = ranges.length - 1; index >= 0; index -= 1) { + const range = ranges[index]; + try { + return JSON.parse(trimmed.slice(range.start, range.end + 1)); + } catch { + // Ignore malformed objects and try the previous completed range. + } } + + return null; } /** Extract Gemini CLI-style response text from the last JSON object in output. */ diff --git a/packages/media-understanding-common/src/video.test.ts b/packages/media-understanding-common/src/video.test.ts new file mode 100644 index 000000000000..f775ae79caa1 --- /dev/null +++ b/packages/media-understanding-common/src/video.test.ts @@ -0,0 +1,28 @@ +// Media Understanding Common tests cover video payload sizing behavior. +import { describe, expect, it } from "vitest"; +import { DEFAULT_VIDEO_MAX_BASE64_BYTES } from "./defaults.js"; +import { estimateBase64Size, resolveVideoMaxBase64Bytes } from "./video.js"; + +describe("estimateBase64Size", () => { + it("rounds byte counts to base64 quanta", () => { + expect(estimateBase64Size(1)).toBe(4); + expect(estimateBase64Size(2)).toBe(4); + expect(estimateBase64Size(3)).toBe(4); + expect(estimateBase64Size(4)).toBe(8); + }); +}); + +describe("resolveVideoMaxBase64Bytes", () => { + it("allows raw byte limits that expand to valid base64 boundaries", () => { + expect(resolveVideoMaxBase64Bytes(1)).toBe(4); + expect(resolveVideoMaxBase64Bytes(2)).toBe(4); + expect(resolveVideoMaxBase64Bytes(3)).toBe(4); + expect(resolveVideoMaxBase64Bytes(4)).toBe(8); + }); + + it("keeps the shared maximum base64 payload cap", () => { + expect(resolveVideoMaxBase64Bytes(DEFAULT_VIDEO_MAX_BASE64_BYTES)).toBe( + DEFAULT_VIDEO_MAX_BASE64_BYTES, + ); + }); +}); diff --git a/packages/media-understanding-common/src/video.ts b/packages/media-understanding-common/src/video.ts index 501a9fc9536c..515aa86b21b9 100644 --- a/packages/media-understanding-common/src/video.ts +++ b/packages/media-understanding-common/src/video.ts @@ -10,6 +10,6 @@ export function estimateBase64Size(bytes: number): number { /** Resolve video base64 byte limit from raw byte limit and global cap. */ export function resolveVideoMaxBase64Bytes(maxBytes: number): number { - const expanded = Math.floor(maxBytes * (4 / 3)); + const expanded = estimateBase64Size(maxBytes); return Math.min(expanded, DEFAULT_VIDEO_MAX_BASE64_BYTES); } diff --git a/packages/memory-host-sdk/src/host/session-files.test.ts b/packages/memory-host-sdk/src/host/session-files.test.ts index f3fb7cf0eba4..552cb932c3db 100644 --- a/packages/memory-host-sdk/src/host/session-files.test.ts +++ b/packages/memory-host-sdk/src/host/session-files.test.ts @@ -158,6 +158,91 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => { }); }); + it("classifies active entries through cron parentage chains", async () => { + const sessionsDir = path.join(tmpDir, "agents", "main", "sessions"); + fsSync.mkdirSync(sessionsDir, { recursive: true }); + const cronPath = path.join(sessionsDir, "cron-run.jsonl"); + const spawnedChildPath = path.join(sessionsDir, "spawned-child.jsonl"); + const keyedChildPath = path.join(sessionsDir, "keyed-child.jsonl"); + const orphanChildPath = path.join(sessionsDir, "orphan-child.jsonl"); + const normalPath = path.join(sessionsDir, "normal-child.jsonl"); + for (const filePath of [ + cronPath, + spawnedChildPath, + keyedChildPath, + orphanChildPath, + normalPath, + ]) { + fsSync.writeFileSync(filePath, ""); + } + fsSync.writeFileSync( + path.join(sessionsDir, "sessions.json"), + JSON.stringify({ + "agent:main:cron:job-1:run:run-1": { + sessionFile: "cron-run.jsonl", + sessionId: "cron-run", + }, + "agent:main:subagent:spawned-child": { + sessionFile: "spawned-child.jsonl", + sessionId: "spawned-child", + spawnedBy: "agent:main:cron:job-1:run:run-1", + }, + "agent:main:subagent:keyed-child": { + parentSessionKey: "agent:main:subagent:spawned-child", + sessionFile: "keyed-child.jsonl", + sessionId: "keyed-child", + }, + "agent:main:subagent:orphan-child": { + sessionFile: "orphan-child.jsonl", + sessionId: "orphan-child", + spawnedBy: "agent:main:cron:job-1:run:missing", + }, + "agent:main:subagent:normal-child": { + sessionFile: "normal-child.jsonl", + sessionId: "normal-child", + spawnedBy: "agent:main:chat:manual", + }, + }), + ); + + const classification = loadSessionTranscriptClassificationForAgent("main"); + + expect(classification.cronRunTranscriptPaths).toEqual( + new Set( + [cronPath, spawnedChildPath, keyedChildPath, orphanChildPath].map((filePath) => + path.resolve(filePath), + ), + ), + ); + await expect(listSessionTranscriptCorpusEntriesForAgent("main")).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + generatedByCronRun: true, + sessionFile: spawnedChildPath, + sessionKey: "agent:main:subagent:spawned-child", + }), + expect.objectContaining({ + generatedByCronRun: true, + sessionFile: keyedChildPath, + sessionKey: "agent:main:subagent:keyed-child", + }), + expect.objectContaining({ + generatedByCronRun: true, + sessionFile: orphanChildPath, + sessionKey: "agent:main:subagent:orphan-child", + }), + expect.objectContaining({ + sessionFile: normalPath, + sessionKey: "agent:main:subagent:normal-child", + }), + ]), + ); + const entries = await listSessionTranscriptCorpusEntriesForAgent("main"); + expect(entries.find((entry) => entry.sessionFile === normalPath)?.generatedByCronRun).toBe( + undefined, + ); + }); + it("keeps archive classification when the active transcript is missing", async () => { const sessionsDir = path.join(tmpDir, "agents", "main", "sessions"); fsSync.mkdirSync(sessionsDir, { recursive: true }); diff --git a/packages/memory-host-sdk/src/host/session-transcript-corpus.ts b/packages/memory-host-sdk/src/host/session-transcript-corpus.ts index 279c23fb03c7..3234b2131f4a 100644 --- a/packages/memory-host-sdk/src/host/session-transcript-corpus.ts +++ b/packages/memory-host-sdk/src/host/session-transcript-corpus.ts @@ -58,10 +58,6 @@ function isDreamingNarrativeSessionKeyLike(value: unknown): boolean { return typeof value === "string" && isDreamingNarrativeSessionStoreKey(value); } -function hasCronRunSessionKey(value: unknown): boolean { - return typeof value === "string" && isCronRunSessionKey(value); -} - function normalizeComparablePath(pathname: string): string { const resolved = path.resolve(pathname); return process.platform === "win32" ? resolved.toLowerCase() : resolved; @@ -163,6 +159,7 @@ function resolveSessionStoreTranscriptCorpusPath( function classifySessionEntry( sessionKey: string, entry: SessionEntry, + cronGeneratedSessionKeys: ReadonlySet, ): { generatedByDreamingNarrative: boolean; generatedByCronRun: boolean; @@ -171,10 +168,69 @@ function classifySessionEntry( generatedByDreamingNarrative: isDreamingNarrativeSessionStoreKey(sessionKey) || isDreamingNarrativeSessionKeyLike(entry.spawnedBy), - generatedByCronRun: isCronRunSessionKey(sessionKey) || hasCronRunSessionKey(entry.spawnedBy), + generatedByCronRun: cronGeneratedSessionKeys.has(sessionKey), }; } +function readParentSessionKeys(entry: SessionEntry | undefined): string[] { + const keys = new Set(); + for (const value of [entry?.parentSessionKey, entry?.spawnedBy]) { + if (typeof value !== "string") { + continue; + } + const trimmed = value.trim(); + if (trimmed) { + keys.add(trimmed); + } + } + return [...keys]; +} + +function collectCronGeneratedSessionKeys( + summaries: readonly SessionEntrySummary[], +): ReadonlySet { + // Build the cron-generated closure once so active entries and archive + // artifacts share the same lineage classification. + const entriesByKey = new Map(summaries.map((summary) => [summary.sessionKey, summary.entry])); + const cronGeneratedKeys = new Set(); + const cache = new Map(); + const resolving = new Set(); + + const isCronGenerated = (sessionKey: string, entry: SessionEntry | undefined): boolean => { + if (isCronRunSessionKey(sessionKey)) { + cache.set(sessionKey, true); + cronGeneratedKeys.add(sessionKey); + return true; + } + const cached = cache.get(sessionKey); + if (cached !== undefined) { + return cached; + } + if (resolving.has(sessionKey)) { + return false; + } + + resolving.add(sessionKey); + const generated = readParentSessionKeys(entry).some( + (parentKey) => + // Parent rows can be pruned before child rows; a cron-shaped parent key + // still carries cron lineage without requiring a store entry. + isCronRunSessionKey(parentKey) || isCronGenerated(parentKey, entriesByKey.get(parentKey)), + ); + resolving.delete(sessionKey); + cache.set(sessionKey, generated); + if (generated) { + cronGeneratedKeys.add(sessionKey); + } + return generated; + }; + + for (const summary of summaries) { + isCronGenerated(summary.sessionKey, summary.entry); + } + return cronGeneratedKeys; +} + function isRegularSessionTranscriptFile(absPath: string): boolean { try { return fsSync.lstatSync(absPath).isFile(); @@ -187,6 +243,7 @@ function toSessionStoreCorpusEntry( agentId: string, sessionsDir: string, summary: SessionEntrySummary, + cronGeneratedSessionKeys: ReadonlySet, ): SessionTranscriptCorpusEntry | null { const sessionFile = resolveSessionStoreTranscriptCorpusPath(agentId, sessionsDir, summary.entry); if (!sessionFile || !isUsageCountedSessionTranscriptFileName(path.basename(sessionFile))) { @@ -200,7 +257,11 @@ function toSessionStoreCorpusEntry( return null; } const sessionKey = summary.sessionKey.trim(); - const classification = classifySessionEntry(summary.sessionKey, summary.entry); + const classification = classifySessionEntry( + summary.sessionKey, + summary.entry, + cronGeneratedSessionKeys, + ); return { agentId, artifactKind: "active-session", @@ -299,11 +360,13 @@ export function listSessionTranscriptCorpusEntriesForAgentSync( const activeEntryOwnersByPath = new Map(); const artifactDirsByPath = new Map(); rememberArtifactDir(artifactDirsByPath, sessionsDir); - for (const summary of listSessionEntries({ + const sessionEntries = listSessionEntries({ agentId: normalizedAgentId, hydrateSkillPromptRefs: false, storePath, - })) { + }); + const cronGeneratedSessionKeys = collectCronGeneratedSessionKeys(sessionEntries); + for (const summary of sessionEntries) { const sessionKey = isSharedFixedStore ? summary.sessionKey : canonicalizeMainSessionAlias({ @@ -316,7 +379,12 @@ export function listSessionTranscriptCorpusEntriesForAgentSync( sessionKey, ...(isSharedFixedStore ? {} : { fallbackAgentId: normalizedAgentId }), }); - const entry = toSessionStoreCorpusEntry(ownerAgentId, sessionsDir, summary); + const entry = toSessionStoreCorpusEntry( + ownerAgentId, + sessionsDir, + summary, + cronGeneratedSessionKeys, + ); if (!entry) { continue; } diff --git a/packages/memory-host-sdk/src/host/types.ts b/packages/memory-host-sdk/src/host/types.ts index 0b6a7aae040e..10d3761a5da5 100644 --- a/packages/memory-host-sdk/src/host/types.ts +++ b/packages/memory-host-sdk/src/host/types.ts @@ -55,11 +55,39 @@ export type MemorySyncParams = { }; /** Runtime backend/mode diagnostics for memory search. */ +export type MemorySearchRuntimeQmdCollectionValidationDebug = { + cacheState?: "hit" | "miss" | "write" | "bypass-force" | "error"; + elapsedMs: number; + collectionCount: number; + listCalls?: number; + showCalls?: number; +}; + +export type MemorySearchRuntimeQmdMultiCollectionProbeDebug = { + cacheState?: "hit" | "miss" | "write" | "error"; + elapsedMs: number; + supported: boolean; +}; + +export type MemorySearchRuntimeQmdSearchPlanDebug = { + command?: "query" | "search" | "vsearch"; + collectionCount?: number; + groupCount?: number; + sources?: MemorySource[]; +}; + +export type MemorySearchRuntimeQmdDebug = { + collectionValidation?: MemorySearchRuntimeQmdCollectionValidationDebug; + multiCollectionProbe?: MemorySearchRuntimeQmdMultiCollectionProbeDebug; + searchPlan?: MemorySearchRuntimeQmdSearchPlanDebug; +}; + export type MemorySearchRuntimeDebug = { backend: "builtin" | "qmd"; configuredMode?: string; effectiveMode?: string; fallback?: string; + qmd?: MemorySearchRuntimeQmdDebug; }; /** Result of reading a memory file, optionally paginated/truncated. */ diff --git a/qa/maturity-coverage-investigation.md b/qa/maturity-coverage-investigation.md new file mode 100644 index 000000000000..a5ed182e6a20 --- /dev/null +++ b/qa/maturity-coverage-investigation.md @@ -0,0 +1,584 @@ +# QA maturity coverage investigation + +Snapshot: current worktree, 2026-06-24. + +## Summary + +- Taxonomy coverage IDs: 1665 +- Primary-fulfilled coverage IDs today: 105 (6.3%) +- QA-linked coverage IDs today, including secondary metadata: 171 (10.3%) +- Unlinked coverage IDs with direct e2e/live/script candidates: 31 +- Coverage IDs with no direct repo e2e candidate in this scan: 1463 +- Scenario files: 129 total; 118 flow scenarios; 11 native scenario links. +- Existing unlinked e2e/live/script proof files scanned: 459. + +This is intentionally conservative: a coverage ID counts as an existing-test candidate only when an unlinked e2e/live/proof script has matching owner/path plus coverage-ID or feature-name terms. Broad unit tests and vague category words do not count. + +Coverage score math uses distinct primary-fulfilled coverage IDs over distinct required coverage IDs, so partial coverage of a multi-ID feature counts proportionately. Any-linked counts still include secondary metadata and are useful for inventory discovery, but they are not the release coverage score. + +## Current Coverage By Profile + +| Profile | Categories | Coverage IDs | Primary linked | Any linked | Candidate links | No direct e2e candidate | Primary % | Any-linked % | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| smoke-ci | 34 | 290 | 94 | 149 | 3 | 138 | 32.4% | 51.4% | +| release | 167 | 1101 | 105 | 170 | 20 | 911 | 9.5% | 15.4% | +| all | 281 | 1665 | 105 | 171 | 31 | 1463 | 6.3% | 10.3% | + +## Current Coverage By Surface + +| Surface | Coverage IDs | Primary linked | Any linked | Candidate links | No direct e2e candidate | Primary % | Any-linked % | After candidate % | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| agent-runtime-and-provider-execution
Agent Runtime | 94 | 43 | 76 | 0 | 18 | 45.7% | 80.9% | 80.9% | +| android-app
Android app | 10 | 0 | 0 | 0 | 10 | 0% | 0% | 0% | +| anthropic-provider-path
Anthropic provider path | 40 | 3 | 5 | 1 | 34 | 7.5% | 12.5% | 15% | +| automation-cron-hooks-tasks-polling
Automation: cron, hooks, tasks, polling | 68 | 5 | 9 | 2 | 57 | 7.4% | 13.2% | 16.2% | +| browser-automation-and-exec-sandbox-tools
Browser automation, exec, and sandbox tools | 22 | 5 | 6 | 0 | 16 | 22.7% | 27.3% | 27.3% | +| browser-control-ui-and-webchat
Gateway Web App | 56 | 5 | 10 | 2 | 44 | 8.9% | 17.9% | 21.4% | +| channel-framework
Channel framework | 65 | 25 | 36 | 1 | 28 | 38.5% | 55.4% | 56.9% | +| clawhub-and-external-plugin-distribution
ClawHub | 50 | 0 | 0 | 3 | 47 | 0% | 0% | 6% | +| cli-install-update-onboard-doctor
CLI | 43 | 1 | 7 | 1 | 35 | 2.3% | 16.3% | 18.6% | +| discord
Discord | 39 | 0 | 0 | 0 | 39 | 0% | 0% | 0% | +| docker-podman-hosting
Docker and Podman hosting | 27 | 2 | 4 | 7 | 16 | 7.4% | 14.8% | 40.7% | +| feishu-qq-bot-wechat-yuanbao-zalo-zalo-personal-regional-channels
Feishu, QQ Bot, WeChat, Yuanbao, Zalo, Zalo Personal, regional channels | 9 | 0 | 0 | 0 | 9 | 0% | 0% | 0% | +| gateway-runtime
Gateway runtime | 105 | 8 | 15 | 0 | 90 | 7.6% | 14.3% | 14.3% | +| google-chat
Google Chat | 45 | 0 | 0 | 0 | 45 | 0% | 0% | 0% | +| google-provider-path
Google provider path | 44 | 0 | 0 | 0 | 44 | 0% | 0% | 0% | +| image-video-music-generation-tools
Image, video, and music generation tools | 42 | 0 | 0 | 0 | 42 | 0% | 0% | 0% | +| imessage-bluebubbles
iMessage and BlueBubbles | 31 | 0 | 0 | 0 | 31 | 0% | 0% | 0% | +| ios-app
iOS app | 15 | 0 | 0 | 0 | 15 | 0% | 0% | 0% | +| kubernetes-hosting
Kubernetes hosting | 20 | 0 | 0 | 0 | 20 | 0% | 0% | 0% | +| linux-companion-app
Linux companion app | 26 | 0 | 0 | 0 | 26 | 0% | 0% | 0% | +| linux-gateway-host
Linux Gateway host | 23 | 0 | 0 | 0 | 23 | 0% | 0% | 0% | +| local-model-providers-ollama-vllm-sglang-lm-studio
Local model providers: Ollama, vLLM, SGLang, LM Studio | 37 | 0 | 0 | 1 | 36 | 0% | 0% | 2.7% | +| long-tail-hosted-providers
Long-tail hosted providers | 32 | 0 | 0 | 2 | 30 | 0% | 0% | 6.3% | +| macos-companion-app
macOS companion app | 35 | 0 | 0 | 0 | 35 | 0% | 0% | 0% | +| macos-gateway-host
macOS Gateway host | 41 | 0 | 0 | 0 | 41 | 0% | 0% | 0% | +| matrix
Matrix | 23 | 0 | 0 | 0 | 23 | 0% | 0% | 0% | +| mattermost-line-irc-nextcloud-talk-nostr-twitch-tlon-synology-chat
Mattermost, LINE, IRC, Nextcloud Talk, Nostr, Twitch, Tlon, Synology Chat | 4 | 0 | 0 | 0 | 4 | 0% | 0% | 0% | +| media-understanding-and-media-generation
Media understanding and media generation | 48 | 6 | 8 | 2 | 38 | 12.5% | 16.7% | 20.8% | +| microsoft-teams
Microsoft Teams | 33 | 0 | 0 | 0 | 33 | 0% | 0% | 0% | +| native-windows-cli-and-gateway
Native Windows | 28 | 0 | 0 | 0 | 28 | 0% | 0% | 0% | +| native-windows-companion-app
Native Windows companion app | 24 | 0 | 0 | 1 | 23 | 0% | 0% | 4.2% | +| nix-install-path
Nix install path | 30 | 0 | 0 | 0 | 30 | 0% | 0% | 0% | +| openai-codex-provider-path
OpenAI and Codex provider path | 26 | 10 | 17 | 0 | 9 | 38.5% | 65.4% | 65.4% | +| openclaw-app-sdk
OpenClaw App SDK | 31 | 1 | 2 | 0 | 29 | 3.2% | 6.5% | 6.5% | +| openrouter-provider-path
OpenRouter provider path | 41 | 0 | 0 | 0 | 41 | 0% | 0% | 0% | +| plugin-sdk-and-bundled-plugin-architecture
Plugins | 69 | 11 | 25 | 1 | 43 | 15.9% | 36.2% | 37.7% | +| raspberry-pi-small-linux-devices
Raspberry Pi and small Linux devices | 36 | 0 | 0 | 1 | 35 | 0% | 0% | 2.8% | +| security-auth-pairing-and-secrets
Security, auth, pairing, and secrets | 41 | 8 | 12 | 0 | 29 | 19.5% | 29.3% | 29.3% | +| session-memory-and-context-engine
Session, memory, and context engine | 57 | 32 | 48 | 0 | 9 | 56.1% | 84.2% | 84.2% | +| signal
Signal | 24 | 0 | 0 | 0 | 24 | 0% | 0% | 0% | +| slack
Slack | 25 | 0 | 0 | 0 | 25 | 0% | 0% | 0% | +| telegram
Telegram | 31 | 1 | 1 | 1 | 29 | 3.2% | 3.2% | 6.5% | +| telemetry-diagnostics-and-observability
Observability | 58 | 15 | 24 | 0 | 34 | 25.9% | 41.4% | 41.4% | +| tui-and-terminal-ux
TUI | 33 | 0 | 0 | 0 | 33 | 0% | 0% | 0% | +| voice-and-realtime-talk
Voice and realtime talk | 36 | 0 | 0 | 1 | 35 | 0% | 0% | 2.8% | +| voice-call-channel
Voice Call channel | 8 | 0 | 0 | 1 | 7 | 0% | 0% | 12.5% | +| watchos-companion-surfaces
watchOS companion surfaces | 26 | 0 | 0 | 2 | 24 | 0% | 0% | 7.7% | +| web-search-tools
Web search tools | 44 | 5 | 7 | 0 | 37 | 11.4% | 15.9% | 15.9% | +| whatsapp
WhatsApp | 20 | 0 | 0 | 0 | 20 | 0% | 0% | 0% | +| windows-via-wsl2
Windows via WSL2 | 49 | 3 | 3 | 1 | 45 | 6.1% | 6.1% | 8.2% | + +## Existing Native QA Links + +| Scenario | Kind | Path | +| --- | --- | --- | +| `qa/scenarios/channels/channel-message-flows.yaml` | vitest | `extensions/telegram/src/channel-message-flows.qa.e2e.test.ts` | +| `qa/scenarios/plugins/plugin-lifecycle-probe.yaml` | vitest | `test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts` | +| `qa/scenarios/runtime/gateway-smoke.yaml` | vitest | `test/e2e/qa-lab/runtime/gateway-smoke.e2e.test.ts` | +| `qa/scenarios/runtime/openai-compatible-chat-tools.yaml` | vitest | `test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts` | +| `qa/scenarios/runtime/openai-web-search-minimal.yaml` | vitest | `test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts` | +| `qa/scenarios/runtime/openai-web-search-native-assertions.yaml` | vitest | `test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts` | +| `qa/scenarios/runtime/openwebui-openai-compatible.yaml` | vitest | `test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts` | +| `qa/scenarios/runtime/package-openclaw-for-docker.yaml` | vitest | `test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts` | +| `qa/scenarios/runtime/qa-otel-smoke.yaml` | vitest | `test/e2e/qa-lab/runtime/qa-otel-smoke.e2e.test.ts` | +| `qa/scenarios/ui/control-ui-chat-flow-playwright.yaml` | playwright | `ui/src/ui/e2e/chat-flow.e2e.test.ts` | +| `qa/scenarios/ui/ux-matrix-evidence-dashboard.yaml` | script | `scripts/qa/ux-matrix-evidence-producer.ts` | + +## Existing E2E Tests To Migrate Or Link + +Add small native scenario YAML wrappers for these rather than duplicating the tests. Use `scenario.execution.kind: vitest` for `*.test.ts` files and `scenario.execution.kind: script` for shell/Node proof scripts. + +| Coverage ID | Surface | Category | Existing test/proof path | +| --- | --- | --- | --- | +| `anthropic.auth-profile-health` | anthropic-provider-path | Provider Auth and Recovery | `src/agents/embedded-agent-runner.run-embedded-agent.auth-profile-rotation.e2e.test.ts` | +| `automation.active-hours` | automation-cron-hooks-tasks-polling | Heartbeat | `src/infra/heartbeat-runner.active-hours-schedule.e2e.test.ts` | +| `automation.heartbeat-scheduling` | automation-cron-hooks-tasks-polling | Heartbeat | `src/infra/heartbeat-runner.active-hours-schedule.e2e.test.ts` | +| `ui.assistant-media-tickets` | browser-control-ui-and-webchat | WebChat Conversations | `src/gateway/control-ui-assistant-media.e2e.test.ts` | +| `ui.browser-talk-start-stop` | browser-control-ui-and-webchat | Browser Realtime Talk | `ui/src/ui/realtime-talk-google-live.test.ts` | +| `channels.native-command-session-target` | channel-framework | Channel Actions Commands and Approvals | `src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts` | +| `clawhub.marketplace-list` | clawhub-and-external-plugin-distribution | Plugin Lifecycle and Health | `scripts/e2e/lib/plugins/marketplace.sh`
`scripts/e2e/lib/release-plugin-marketplace/scenario.sh` | +| `clawhub.npm-pack-local-release-candidate-installs` | clawhub-and-external-plugin-distribution | Plugin Lifecycle and Health | `scripts/release-candidate-checklist.mjs`
`test/scripts/release-candidate-checklist.test.ts` | +| `clawhub.skill-installs` | clawhub-and-external-plugin-distribution | Plugin Lifecycle and Health | `src/cli/skills-cli.clawhub-install.e2e.test.ts` | +| `cli.channel-picker` | cli-install-update-onboard-doctor | Plugin and Channel Setup | `src/commands/onboard-channels.e2e.test.ts` | +| `docker.backed-agent-sandbox-support` | docker-podman-hosting | Agent Sandbox and Tooling | `scripts/e2e/agent-bundle-mcp-tools-docker-client.ts`
`scripts/e2e/agent-bundle-mcp-tools-docker.sh`
`scripts/e2e/agents-delete-shared-workspace-docker.sh`
`scripts/e2e/npm-onboard-channel-agent-docker.sh` | +| `docker.compose` | docker-podman-hosting | Container Operations | `src/docker-setup.e2e.test.ts` | +| `docker.compose-network-access` | docker-podman-hosting | Container Operations | `scripts/e2e/gateway-network-docker.sh` | +| `docker.first-run-onboarding` | docker-podman-hosting | Container Setup | `scripts/e2e/crestodian-first-run-docker-client.ts`
`scripts/e2e/crestodian-first-run-docker.sh` | +| `docker.local-image-setup-script` | docker-podman-hosting | Container Setup | `scripts/e2e/build-image.sh`
`scripts/e2e/openai-image-auth-docker-client.ts`
`scripts/e2e/openai-image-auth-docker.sh` | +| `docker.only-first-run-notes` | docker-podman-hosting | Container Setup | `scripts/e2e/crestodian-first-run-docker.sh`
`scripts/e2e/crestodian-first-run-docker-client.ts`
`scripts/docker-e2e-rerun.mjs`
`scripts/docker/install-sh-e2e/run.sh` | +| `docker.release-workflow` | docker-podman-hosting | Image Release and Validation | `scripts/e2e/release-media-memory-docker.sh`
`scripts/e2e/release-plugin-marketplace-docker.sh`
`scripts/e2e/release-typed-onboarding-docker.sh`
`scripts/e2e/release-upgrade-user-journey-docker.sh` | +| `local-models.openai-compatible-chat-and-tool-semantics` | local-model-providers-ollama-vllm-sglang-lm-studio | OpenAI-Compatible Runtime Compatibility | `scripts/e2e/openai-chat-tools-docker.sh` | +| `hosted-providers.image-generation-providers` | long-tail-hosted-providers | Hosted Media Providers | `test/image-generation.infer-cli.live.test.ts`
`test/image-generation.runtime.live.test.ts` | +| `hosted-providers.video-generation-providers` | long-tail-hosted-providers | Hosted Media Providers | `extensions/video-generation-providers.live.test.ts` | +| `media.reference-image-video-and-audio-inputs` | media-understanding-and-media-generation | Media Generation | `extensions/video-generation-providers.live.test.ts` | +| `media.video-generation-tool-invocation` | media-understanding-and-media-generation | Media Generation | `extensions/video-generation-providers.live.test.ts` | +| `windows.native-windows-chat-window` | native-windows-companion-app | Chat Sessions | `scripts/e2e/parallels/windows-smoke.ts`
`scripts/e2e/parallels-windows-smoke.sh`
`scripts/e2e/parallels/windows-git.ts` | +| `plugins.packaged-bundled-plugins` | plugin-sdk-and-bundled-plugin-architecture | Bundled plugins | `scripts/e2e/lib/bundled-plugin-install-uninstall/probe.mjs` | +| `raspberry-pi.first-run-verification` | raspberry-pi-small-linux-devices | Setup and Compatibility | `scripts/e2e/crestodian-first-run-docker-client.ts`
`scripts/e2e/crestodian-first-run-docker.sh` | +| `telegram.bot-token` | telegram | Channel Setup and Operations | `extensions/telegram/src/bot.media.e2e-harness.ts`
`extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts`
`extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts` | +| `voice.active-talk-agent-run-status` | voice-and-realtime-talk | Realtime Talk Sessions | `src/agents/embedded-agent-runner.run-embedded-agent.auth-profile-rotation.e2e.test.ts`
`src/agents/test-helpers/embedded-agent-runner-e2e-fixtures.ts`
`src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts`
`src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.e2e.test.ts` | +| `voice-call.cli-rpc-agent-tool` | voice-call-channel | Channel Setup and Operations | `src/agents/agent-tools.before-tool-call.e2e.test.ts` | +| `watchos.gateway-side-ios-exec-approval` | watchos-companion-surfaces | Delivery and Recovery | `src/agents/bash-tools.exec-gateway-approval.e2e.test.ts` | +| `watchos.watch-exec-approval-prompt` | watchos-companion-surfaces | Exec Approvals | `src/agents/bash-tools.exec-gateway-approval.e2e.test.ts` | +| `wsl2.npm-pnpm-git-package-root` | windows-via-wsl2 | CLI | `scripts/e2e/parallels/npm-update-smoke.ts` | + +## New Exploration Groups + +These are the largest no-direct-e2e-candidate groups. Start here after linking the existing candidates. + +| Group | No direct e2e candidate | Action | +| --- | --- | --- | +| gateway-runtime
Gateway runtime | 90 | Add focused core QA Lab scenarios before broad live lanes. | +| automation-cron-hooks-tasks-polling
Automation: cron, hooks, tasks, polling | 57 | Add focused core QA Lab scenarios before broad live lanes. | +| clawhub-and-external-plugin-distribution
ClawHub | 47 | Add owner-level release/profile exploration; no direct scenario wrapper candidate found. | +| google-chat
Google Chat | 45 | Add or promote channel live transport scenario pack coverage. | +| windows-via-wsl2
Windows via WSL2 | 45 | Add platform install/update/gateway smoke exploration in Crabbox/Testbox. | +| browser-control-ui-and-webchat
Gateway Web App | 44 | Add owner-level release/profile exploration; no direct scenario wrapper candidate found. | +| google-provider-path
Google provider path | 44 | Add provider live smoke plus contract-normalization scenario wrappers. | +| plugin-sdk-and-bundled-plugin-architecture
Plugins | 43 | Add focused core QA Lab scenarios before broad live lanes. | +| image-video-music-generation-tools
Image, video, and music generation tools | 42 | Add owner-level release/profile exploration; no direct scenario wrapper candidate found. | +| macos-gateway-host
macOS Gateway host | 41 | Add platform install/update/gateway smoke exploration in Crabbox/Testbox. | +| openrouter-provider-path
OpenRouter provider path | 41 | Add provider live smoke plus contract-normalization scenario wrappers. | +| discord
Discord | 39 | Add or promote channel live transport scenario pack coverage. | +| media-understanding-and-media-generation
Media understanding and media generation | 38 | Add owner-level release/profile exploration; no direct scenario wrapper candidate found. | +| web-search-tools
Web search tools | 37 | Add focused core QA Lab scenarios before broad live lanes. | +| local-model-providers-ollama-vllm-sglang-lm-studio
Local model providers: Ollama, vLLM, SGLang, LM Studio | 36 | Add provider live smoke plus contract-normalization scenario wrappers. | +| cli-install-update-onboard-doctor
CLI | 35 | Add focused core QA Lab scenarios before broad live lanes. | +| macos-companion-app
macOS companion app | 35 | Add platform install/update/gateway smoke exploration in Crabbox/Testbox. | +| raspberry-pi-small-linux-devices
Raspberry Pi and small Linux devices | 35 | Add platform install/update/gateway smoke exploration in Crabbox/Testbox. | + +## Full No-Direct-E2E-Candidate Coverage ID Appendix + +### gateway-runtime (90) + +- Approvals and Remote Execution (6): `gateway.approval-mutation-safety`, `gateway.approved-node-execution`, `gateway.delivery-fallback-behavior`, `gateway.exec-approvals`, `gateway.node-exec-approvals`, `gateway.plugin-approvals` +- HTTP APIs (3): `gateway.admin-api-access`, `gateway.hook-ingress`, `gateway.tool-invocation-api` +- Hosted Web Surface (3): `gateway.canvas-and-a2ui-routes`, `gateway.plugin-web-routes`, `gateway.webchat-hosting` +- Gateway RPC APIs and Events (18): `gateway.accepted-then-final-results`, `gateway.agent-and-artifact-apis`, `gateway.channel-apis`, `gateway.chat-apis`, `gateway.config-and-secrets-apis`, `gateway.event-discovery`, `gateway.event-ordering`, `gateway.idempotent-side-effects`, `gateway.identity-and-presence-apis`, `gateway.method-discovery`, `gateway.model-apis`, `gateway.request-and-event-envelopes`, `gateway.state-refresh-after-gaps`, `gateway.task-and-automation-apis`, `gateway.tool-and-skill-apis`, `gateway.update-and-setup-apis`, `gateway.usage-and-memory-apis`, `gateway.web-login-and-wake-apis` +- Device Auth and Pairing (10): `gateway.auth-mismatch-recovery`, `gateway.client-pairing`, `gateway.device-auth-migration`, `gateway.device-challenge-signing`, `gateway.device-tokens`, `gateway.private-ingress-mode`, `gateway.setup-code-bootstrap`, `gateway.shared-secret-login`, `security.node-pairing`, `security.trusted-proxy-auth` +- Network Access and Discovery (6): `gateway.endpoint-discovery`, `gateway.loopback-and-lan-access`, `gateway.saved-endpoints`, `gateway.ssh-tunnels`, `gateway.tailnet-access`, `gateway.tls-pinning` +- Nodes and Remote Capabilities (8): `gateway.node-actions`, `gateway.node-capabilities`, `gateway.node-events`, `gateway.node-inventory`, `gateway.node-presence`, `gateway.pending-work-delivery`, `gateway.remote-device-capabilities`, `gateway.remote-host-commands` +- Health, Diagnostics, and Repair (7): `gateway.channel-readiness`, `gateway.diagnostics-exports`, `gateway.log-tailing`, `gateway.payload-diagnostics`, `gateway.stability-diagnostics`, `telemetry.doctor-checks`, `telemetry.health-snapshots` +- Protocol Compatibility (7): `gateway.backward-compatible-evolution`, `gateway.client-transport-defaults`, `gateway.json-schema-export`, `gateway.published-protocol-schema`, `gateway.runtime-request-validation`, `gateway.swift-client-models`, `gateway.version-negotiation` +- Roles and Permissions (5): `gateway.approval-gated-actions`, `gateway.event-scoping`, `gateway.operator-permissions`, `gateway.role-negotiation`, `gateway.untrusted-node-declarations` +- Gateway Lifecycle (5): `gateway.bind-and-port-settings`, `gateway.foreground-startup`, `gateway.multi-gateway-isolation`, `gateway.service-installation`, `gateway.service-status` +- Security Controls (6): `gateway.fail-closed-protocol-handling`, `gateway.gateway-and-node-trust-boundaries`, `gateway.non-loopback-auth`, `gateway.remote-execution-safeguards`, `gateway.trusted-cidr-auto-approval`, `gateway.trusted-proxy-exceptions` +- WebSocket Connection (6): `gateway.connect-challenge`, `gateway.connect-request`, `gateway.plugin-surface-urls`, `gateway.protocol-version-negotiation`, `gateway.session-limits`, `gateway.startup-retry` + +### cli-install-update-onboard-doctor (35) + +- CLI Setup (4): `cli.installer-scripts`, `cli.local-prefix-install`, `cli.source-checkout-install`, `cli.supported-node-runtime` +- Onboarding and Auth Setup (5): `cli.auth-choices`, `cli.gateway-auth-storage`, `cli.guided-onboarding`, `cli.remote-onboarding`, `cli.targeted-reconfiguration` +- Plugin and Channel Setup (4): `cli.channel-account-setup`, `cli.plugin-install-sources`, `cli.post-setup-probes`, `cli.remote-gateway-caveat` +- Gateway Service Management (4): `cli.drift-and-reinstall-recovery`, `cli.foreground-gateway-runs`, `cli.service-health-checks`, `cli.service-install-and-control` +- CLI Observability (4): `cli.diagnostics-export`, `cli.remote-log-tailing`, `cli.support-safe-redaction`, `telemetry.health-snapshots` +- Doctor (9): `cli.auth-and-secretref-checks`, `cli.config-migration`, `cli.extra-gateway-discovery`, `cli.interactive-repair`, `cli.lint-and-json-findings`, `cli.port-and-startup-diagnosis`, `cli.restart-guidance`, `cli.runtime-path-checks`, `cli.supervisor-drift-repair` +- Updates and Upgrades (5): `cli.install-kind-switching`, `cli.managed-gateway-restart`, `cli.plugin-convergence`, `cli.update-channels`, `cli.update-status-and-rpc` + +### plugin-sdk-and-bundled-plugin-architecture (43) + +- Authoring and Packaging plugins (8): `plugins.entrypoint-discovery`, `plugins.focused-sdk-imports`, `plugins.manifest`, `plugins.migration-shims`, `plugins.package-metadata`, `plugins.root-sdk-entrypoint`, `plugins.runtime-compatibility`, `plugins.validation-feedback` +- Bundled plugins (4): `plugins.bundled-channel-ids`, `plugins.bundled-plugin-listing`, `plugins.bundled-source-overlays`, `plugins.generated-plugin-inventory` +- Canvas plugin (6): `plugins.a2ui-transport-and-snapshots`, `plugins.agent-canvas-tool`, `plugins.canvas-documents`, `plugins.control-ui-embeds`, `plugins.hosted-canvas-and-a2ui-surfaces`, `plugins.node-canvas-commands` +- Installing and running plugins (1): `plugins.dependency-repair` +- Channel plugins (5): `plugins.destination-resolution`, `plugins.inbound-event-handling`, `plugins.ingress-authorization`, `plugins.native-approval-prompts`, `plugins.outbound-delivery` +- Provider and tool plugins (3): `plugins.model-catalogs`, `plugins.provider-auth`, `plugins.provider-plugins` +- Plugin approvals (6): `plugins.approval-replay-protection`, `plugins.approval-requests`, `plugins.exec-and-plugin-separation`, `plugins.native-approval-delivery`, `plugins.same-chat-fallbacks`, `plugins.security-helpers` +- Publishing plugins (6): `plugins.clawhub-publishing`, `plugins.compatibility-signaling`, `plugins.install-sources`, `plugins.npm-publishing`, `plugins.third-party-publication-rules`, `plugins.update-and-rollback-expectations` +- Testing plugins (4): `plugins.docker-lifecycle-suites`, `plugins.local-test-environment`, `plugins.test-fixtures`, `plugins.unit-and-integration-scaffolds` + +### agent-runtime-and-provider-execution (18) + +- External Runtimes and Subagents (2): `runtime.cli-runtime-aliases`, `runtime.recovery` +- Hosted Provider Execution (1): `runtime.hosted-streaming-and-replies` +- Local and Self-hosted Providers (5): `runtime.local-failure-handling`, `runtime.local-provider-profiles`, `runtime.local-smoke-checks`, `runtime.timeouts-and-context-windows`, `runtime.tool-capability-flags` +- Model and Runtime Selection (1): `runtime.invalid-route-recovery` +- Provider Auth (6): `runtime.auth-failover`, `runtime.missing-key-and-oauth-guidance`, `runtime.rate-limit-and-capacity-recovery`, `runtime.restart-and-stale-route-recovery`, `runtime.structured-provider-diagnostics`, `runtime.subagent-credential-propagation` +- Tool Execution Controls (3): `runtime.delegated-tool-access`, `runtime.elevated-execution`, `runtime.sandboxed-exec-behavior` + +### session-memory-and-context-engine (9) + +- CLI Session and Transcript Management (2): `session.cli-session`, `session.transcript-management` +- Token Management (1): `session.pruning` +- Diagnostics, Maintenance, and Recovery (2): `session.diagnostic-reports`, `session.maintenance-warnings` +- Memory (1): `session.memory-backend-storage` +- Session Routing (1): `memory.session-routing` +- Transcript Persistence (2): `session.durability`, `session.transcript-persistence` + +### channel-framework (28) + +- Channel Actions Commands and Approvals (3): `channels.message-tool-api-discovery`, `channels.native-approval-prompts`, `channels.native-commands` +- Channel Setup (4): `channels.install-on-demand`, `channels.setup-wizard-metadata`, `channels.status-taxonomy-in-channels-list`, `channels.supported-channel-catalog` +- Group Thread and Ambient Room Behavior (2): `channels.bot-loop-protection`, `channels.broadcast-groups` +- Inbound Access and Identity Gates (5): `channels.access-group-expansion`, `channels.group-channel-allowlists`, `channels.mention-gating`, `channels.sanitized-inbound-identity-route-projections`, `security.dm-pairing` +- Media Attachments and Rich Channel Data (4): `channels.inbound-media-normalization`, `channels.media-roots`, `channels.outbound-direct-text-media-sends`, `channels.provider-specific-channeldata` +- Conversation Routing and Delivery (7): `channels.account-startup`, `channels.agent-selection-precedence`, `channels.auto-restart`, `channels.config-secrets-reload-interactions`, `channels.runtime-conversation-routing`, `channels.whole-channel-lifecycle-controls`, `memory.session-key-construction` +- Status Health and Operator Controls (3): `channels.operator-cli-controls`, `channels.status`, `channels.status-read-model` + +### security-auth-pairing-and-secrets (30) + +- Approval Policy and Tool Safeguards (1): `security.dangerous-tool-safeguards` +- Gateway Auth and Remote Access (9): `raspberry-pi.tailscale-serve-funnel`, `security.bind-and-origin-restrictions`, `security.browser-control-ui`, `security.gateway-auth-mode`, `security.operator-facing-docs`, `security.remote-client-trust`, `security.shared-gateway-token-password-auth`, `security.trusted-proxy-identity`, `security.websocket-handshake-auth` +- Channel Access Control (3): `security.allowlists`, `security.channel-identity`, `security.sender-pairing` +- Device and Node Pairing (11): `security.auth-migration`, `security.capability-trust`, `security.device-identity-creation`, `security.device-pairing-approvals-for-operator`, `security.device-token-issuance`, `security.local-control-ui`, `security.node-pairing`, `security.operator-facing-docs`, `security.operator-scopes-that-gate-pairing`, `security.remote-exec-approvals`, `security.setup-codes` +- Plugin Trust (2): `security.boundaries`, `security.plugin-installation-trust` +- Credential and Secret Hygiene (4): `security.api-key-health`, `security.configuration-hygiene`, `security.provider-auth-profiles`, `security.secrets-storage` + +### telemetry-diagnostics-and-observability (34) + +- Health and Repair (10): `telemetry.background-health-monitor-loop`, `telemetry.core-doctor-checks`, `telemetry.gateway-rpc-health`, `telemetry.openclaw-health`, `telemetry.per-account-enable-disable-settings`, `telemetry.plugin-sdk-doctor-health-contracts`, `telemetry.restart-logging`, `telemetry.startup-grace`, `telemetry.structured-health-checks`, `windows.openclaw-status` +- Logging (5): `telemetry.gateway-rpc-logs-tail`, `telemetry.openclaw-logs`, `telemetry.redaction-patterns-and-sinks`, `telemetry.rolling-gateway-jsonl-file-logs`, `telemetry.trace-correlation-fields` +- Diagnostic Collection (7): `telemetry.bounded-in-process-stability-recorder`, `telemetry.chat-diagnostics`, `telemetry.critical-memory-pressure-snapshot-option`, `telemetry.memory-pressure-events`, `telemetry.openclaw-gateway-diagnostics-export`, `telemetry.openclaw-gateway-stability`, `telemetry.openclaw-gateway-stability-bundle` +- Telemetry Export (8): `automation.async-dispatch`, `telemetry.diagnostic-event-types`, `telemetry.diagnostics-otel-plugin-install`, `telemetry.diagnostics-prometheus-plugin-install`, `telemetry.model-call-diagnostic-events`, `telemetry.trusted-diagnostic-event-subscription`, `telemetry.trusted-trace-context`, `telemetry.w3c-trace-context-creation` +- Session Diagnostics (4): `telemetry.diagnostic-session-activity-snapshots`, `telemetry.export-of-session-signals-to-stability`, `telemetry.model-usage`, `telemetry.session-state` + +### automation-cron-hooks-tasks-polling (57) + +- Cron Jobs (9): `automation.create-edit-remove-jobs`, `automation.delivery-previews`, `automation.failure-destinations`, `automation.model-provider-preflight`, `automation.schedule-types`, `automation.skipped-run-alerts`, `automation.timeout-and-denial-diagnostics`, `automation.timezone-and-stagger`, `automation.webhook-delivery` +- Event Ingress (15): `automation.async-dispatch`, `automation.gmail-event-routing`, `automation.gmail-setup-wizard`, `automation.hook-auth-policy`, `automation.imessage-watch-fallback`, `automation.mapped-hooks`, `automation.polling-stall-diagnostics`, `automation.post-hooks-agent`, `automation.post-hooks-wake`, `automation.push-token-validation`, `automation.tailscale-public-routing`, `automation.telegram-long-polling`, `automation.telegram-webhook-mode`, `automation.watcher-start-serve`, `automation.zalo-polling-webhook-mode` +- Automation Hooks (11): `automation.api-on-registration`, `automation.cron-changed`, `automation.hook-cli-management`, `automation.hook-discovery`, `automation.hook-md-authoring`, `automation.hook-packs`, `automation.lifecycle-event-dispatch`, `automation.message-hooks`, `automation.plugin-approval-requests`, `automation.session-lifecycle-hooks`, `automation.tool-call-policy-hooks` +- Background Tasks and Flows (10): `automation.chat-task-board`, `automation.flow-audit-and-maintenance`, `automation.managed-flows`, `automation.mirrored-flows`, `automation.openclaw-tasks-flow`, `automation.plugin-managedflows`, `automation.task-audit-and-maintenance`, `automation.task-list-show-cancel`, `automation.task-notifications`, `automation.task-pressure-status` +- Heartbeat (2): `automation.due-only-heartbeat-tasks`, `automation.wake-and-cooldown-handling` +- Polling Controls (10): `automation.background-process-status`, `automation.channel-capability-gates`, `automation.no-progress-loop-detection`, `automation.openclaw-message-poll`, `automation.poll-flags`, `automation.process-input-controls`, `automation.process-log`, `automation.process-poll`, `automation.teams-polls`, `automation.telegram-polls` + +### media-understanding-and-media-generation (38) + +- Media Intake and Access (8): `media.inbound-media-store`, `media.local-and-remote-media-references`, `media.local-root-policy`, `media.mime-and-type-detection`, `media.pdf-document-extraction-dispatch`, `media.qr-and-media-helper-classification`, `media.safe-remote-fetch`, `media.size-caps-and-bounded-reads` +- Channel Media Handling (5): `media.duplicate-delivery-suppression`, `media.inbound-attachment-staging`, `media.message-tool-attachment-delivery`, `media.reply-media-templating`, `media.sandbox-media-rewrites` +- Media Configuration (1): `media.capability-configuration` +- Text-to-Speech Delivery (2): `media.outbound-voice-audio-delivery`, `media.tts` +- Media Understanding (11): `media.active-vision-model-bypass`, `media.audio-attachment-selection`, `media.audio-proxy-and-limit-handling`, `media.batch-stt-provider-and-cli-fallback`, `media.direct-video-analysis`, `media.image-and-pdf-input-routing`, `media.text-only-model-media-offload`, `media.transcript-insertion-and-echo`, `media.video-understanding`, `media.vision-provider-fallback`, `media.voice-note-mention-preflight` +- Media Generation (11): `media.generated-image-task-lifecycle`, `media.generated-video-persistence-and-delivery`, `media.lyrics-instrumental-duration-and-format-controls`, `media.mode-and-provider-capability-selection`, `media.music-generation-provider-controls`, `media.music-generation-tool-invocation`, `media.music-task-lifecycle-and-duplicate-status`, `media.provider-option-validation`, `media.reference-image-editing`, `media.reference-inputs-where-supported`, `media.video-task-lifecycle-and-status` + +### voice-and-realtime-talk (35) + +- Talk Providers (7): `models.diagnostics`, `voice.google-gemini-live-backend-bridge`, `voice.openai-realtime-voice-backend-bridge`, `voice.realtime-voice-provider-sdk-contracts`, `voice.shared-native-config-parsing`, `voice.talk-catalog`, `voice.talk-provider-config` +- Realtime Talk Sessions (10): `voice.agent-consult-handoff`, `voice.audio-frame-limits`, `voice.browser-relay-mode`, `voice.browser-talk-start-stop-ui`, `voice.browser-tool-call-forwarding`, `voice.browser-webrtc-sessions`, `voice.forced-consult-scheduling`, `voice.gateway-relay-sessions`, `voice.realtime-session-controls`, `voice.talkback-runtime-behavior` +- Speech and Transcription (5): `models.realtime-transcription-providers`, `voice.directives`, `voice.native-directive-parsing`, `voice.talk-speech-playback`, `voice.transcription-relay-sessions` +- Native App Talk (4): `voice.android-talk-mode`, `voice.ios-talk-mode`, `voice.macos-native-talk-mode`, `voice.shared-talk-config` +- Voice Wake and Routing (4): `voice.macos-voice-wake-runtime`, `voice.mobile-wake-preferences`, `voice.wake-routing`, `voice.wake-word-settings` +- Talk Observability (5): `voice.live-smoke-output`, `voice.operator-visibility-into-setup`, `voice.prometheus-diagnostic-counters`, `voice.session-log-health`, `voice.talk-event-logging` + +### browser-control-ui-and-webchat (44) + +- Browser Realtime Talk (4): `ui.gateway-relay-audio`, `ui.provider-session-selection`, `ui.steer-and-cancel`, `ui.tool-call-consults` +- Browser Access and Trust (5): `security.trusted-proxy-auth`, `ui.allowed-origins-gatewayurl`, `ui.device-pairing`, `ui.tailscale-serve-auth`, `ui.token-password-auth` +- Configuration (5): `ui.apply-and-restart`, `ui.base-hash-guarded-writes`, `ui.config-snapshots`, `ui.raw-json-editing`, `ui.schema-form-editing` +- Browser UI (8): `ui.base-path-routing`, `ui.dev-gatewayurl-target`, `ui.pwa-install-metadata`, `ui.service-worker-updates`, `ui.static-asset-recovery`, `ui.subscribe-unsubscribe`, `ui.test-notifications`, `ui.vapid-keys` +- WebChat Conversations (13): `ui.abort-partial-retention`, `ui.attachments`, `ui.authenticated-avatars`, `ui.chat-history-projection`, `ui.csp-image-policy`, `ui.external-embed-gating`, `ui.hosted-embeds`, `ui.injected-assistant-notes`, `ui.markdown-tool-media-rendering`, `ui.model-thinking-controls`, `ui.reconnect-continuity`, `ui.send-and-abort`, `ui.session-and-agent-picker` +- Operator Console (9): `ui.activity-summaries`, `ui.channels-login`, `ui.cron`, `ui.exec-approvals-agents`, `ui.health-status-models`, `ui.live-log-tail`, `ui.rpc-timing-telemetry`, `ui.session-manager-and-history`, `ui.skills-nodes` + +### tui-and-terminal-ux (33) + +- Runtime Modes (14): `tui.config-repair-loop`, `tui.embedded-local-chat`, `tui.gateway-authentication`, `tui.gateway-command-rpcs`, `tui.gateway-connection`, `tui.gateway-free-recovery`, `tui.gateway-tui-launch`, `tui.history-load-on-attach`, `tui.initial-message-launch`, `tui.launch-option-validation`, `tui.local-auth-flow`, `tui.local-chat-launch`, `tui.reconnect-visibility`, `tui.terminal-alias-launch` +- Input and Commands (8): `slack.slash-commands`, `tui.ime-and-altgr-handling`, `tui.input-history`, `tui.keyboard-shortcuts`, `tui.message-composition`, `tui.paste-and-busy-submit-handling`, `tui.pickers`, `tui.settings` +- Session Management (3): `tui.history`, `tui.resume`, `tui.session-lifecycle` +- Local Shell Execution (4): `tui.approval-prompt`, `tui.bang-command-routing`, `tui.command-output-display`, `tui.execution-environment-marker` +- Rendering and Output Safety (4): `tui.output-safety`, `tui.streaming-message-rendering`, `tui.terminal-rendering-primitives`, `tui.tool-cards` + +### clawhub-and-external-plugin-distribution (47) + +- Publishing (7): `clawhub.external-code-plugin-package-contract-required`, `clawhub.npm-trusted-publishing-provenance`, `clawhub.openclaw-owned-package-release-validation-for-clawhub`, `clawhub.package-publishing-owner`, `clawhub.skill-package-metadata`, `clawhub.skill-publishing-flow`, `clawhub.version-bump-gates` +- Catalog Discovery (5): `clawhub.catalog-lookup-failure`, `clawhub.distinction-between-plugin-search`, `clawhub.openclaw-plugins-search-as-the-clawhub`, `clawhub.search-result-metadata`, `clawhub.skill-catalog-search` +- Compatibility and Trust (12): `clawhub.archive`, `clawhub.built-in-dangerous-code-scanner`, `clawhub.compatibility-docs`, `clawhub.npm-compatibility-fallback-to-the-newest`, `clawhub.npm-integrity-drift`, `clawhub.official-external-plugin-catalog-behavior`, `clawhub.openclaw-compat-pluginapi`, `clawhub.operator-trust-model-for-installing`, `clawhub.package-compatibility-validation`, `clawhub.publishing-review-hidden-release-behavior-as-upstream`, `clawhub.skill-archive-safety`, `clawhub.skill-audit-signals` +- Plugin Lifecycle and Health (23): `clawhub.bare-package-behavior-during-the-launch`, `clawhub.codex`, `clawhub.dependency-ownership-between-plugin-packages`, `clawhub.downgrade`, `clawhub.explicit-pinned-versions`, `clawhub.gateway-restart-reload-requirements-after`, `clawhub.legacy-dependency-root-cleanup`, `clawhub.local`, `clawhub.local-plugin-index`, `clawhub.managed-install-records-that-preserve-source`, `clawhub.peer-dependency-relinking`, `clawhub.per-plugin-managed-npm-project`, `clawhub.plugins-list`, `clawhub.reinstall-vs-update-semantics`, `clawhub.remote-marketplace-path-safety`, `clawhub.runtime-verification-after-gateway`, `clawhub.skill-dependency-installers`, `clawhub.skill-upload-install-path`, `clawhub.source-prefixes`, `clawhub.supported-mapped-features`, `clawhub.troubleshooting-stale-config`, `clawhub.uninstall-config-index-policy-file-cleanup`, `clawhub.update-by-plugin-id` + +### openclaw-app-sdk (29) + +- Client API (4): `app-sdk.app-plugin-boundary`, `app-sdk.namespace-layout`, `app-sdk.package-split`, `app-sdk.sdk-entrypoints` +- Gateway Access (5): `app-sdk.auto-gateway`, `app-sdk.custom-transport`, `app-sdk.gateway-connect`, `app-sdk.scopes-and-redaction`, `app-sdk.url-and-token-config` +- Agent Conversations (6): `app-sdk.agent-handles`, `app-sdk.agent-runs`, `app-sdk.run-results`, `app-sdk.session-controls`, `app-sdk.session-creation`, `app-sdk.session-send` +- Events and Approvals (5): `app-sdk.approval-callbacks`, `app-sdk.event-envelope`, `app-sdk.event-stream`, `app-sdk.questions`, `app-sdk.replay-cursors` +- Resource Helpers (4): `app-sdk.environments`, `app-sdk.models`, `app-sdk.tasks`, `app-sdk.toolspace` +- Compatibility (5): `app-sdk.ergonomic-wrappers`, `app-sdk.generated-client`, `app-sdk.public-package-contract`, `app-sdk.schema-alignment`, `app-sdk.unsupported-calls` + +### macos-gateway-host (41) + +- CLI Setup (4): `macos.app-triggered-cli-install`, `macos.hosted-installer`, `macos.node-24-recommendation`, `macos.shell-path-and-version-manager-drift` +- Local Gateway Integration (9): `macos.app-local-remote-connection-mode`, `macos.app-managed-gateway-launchagent-install-restart-uninstall`, `macos.attach-to-existing-local-gateway-compatibility`, `macos.bonjour-discovery`, `macos.cli-install-detection`, `macos.gateway-endpoint`, `macos.gateway-mode-local-configuration`, `macos.local-app-endpoint-resolution`, `macos.loopback-bind` +- Remote Gateway Mode (5): `macos.app-remote-over-ssh`, `macos.local-node-host-startup`, `macos.remote-endpoint-token-password-tls-fingerprint`, `macos.ssh-tunnel-setup`, `macos.tailscale-magicdns` +- Gateway Service Lifecycle (10): `macos.app-managed-launchagent-handoff`, `macos.gateway-token-env-handling`, `macos.launchagent-labels`, `macos.launchctl-bootstrap`, `macos.managed-service-refresh`, `macos.openclaw-uninstall`, `macos.openclaw-update-package-git-handoff`, `macos.per-user-gateway-launchagent-install`, `macos.stale-updater-launchd-job-detection`, `macos.stranded-service-recovery` +- Diagnostics and Observability (4): `macos.gateway-silently-stops-responding`, `macos.launchagent-log-paths`, `macos.openclaw-gateway-status-deep`, `macos.stale-updater-jobs` +- Permissions and Native Capabilities (4): `macos.native-node-capability-exposure`, `macos.permission-driven-support`, `macos.system-run-policy`, `macos.tcc-permission-prompts-status` +- Profiles and Isolation (5): `macos.derived-ports`, `macos.extra-gateway-process-detection`, `macos.profile-specific-launchagent-labels`, `macos.profile-specific-state-config-workspace-roots`, `macos.rescue-bot-setup` + +### macos-companion-app (35) + +- Canvas (4): `macos.a2ui-host-auto-navigation`, `macos.canvas-enable-disable-setting`, `macos.canvas-panel-open-hide-navigate-eval-snapshot`, `macos.local-custom-url-scheme` +- Local Setup (7): `macos.cli-discovery`, `macos.existing-listener-detection`, `macos.launchagent-install-update-restart-uninstall`, `macos.local-mode-gateway-attach-start-stop`, `macos.local-workspace-selection`, `macos.native-first-run-onboarding-flow`, `macos.onboarding-webchat-session-separation` +- Status and Settings (5): `macos.activity-state-ingestion`, `macos.channels-settings`, `macos.health-polling`, `macos.menu-bar-status`, `macos.settings-navigation` +- Native Capabilities (5): `macos.exec-approval-policy`, `macos.mac-node-session-connection`, `macos.permission-requests`, `macos.system-run`, `macos.tcc-persistence` +- Remote Connections (3): `gateway.discovery`, `macos.remote-connection-mode-selection`, `macos.ssh-tunnel` +- Voice and Talk (3): `macos.push-to-talk`, `macos.talk-provider-playback-plan`, `macos.voice-wake-runtime` +- WebChat (3): `gateway.chat-transport`, `macos.local-and-remote-data-plane-reuse`, `macos.native-swiftui-webchat-window` +- Remote WebChat (5): `macos.direct-ws-wss-remote-mode`, `macos.remote-troubleshooting`, `macos.ssh-tunnel-data-plane`, `macos.webchat-transport`, `memory.session-continuity` + +### linux-gateway-host (23) + +- Host Setup and Updates (4): `linux.cli-install`, `linux.node-runtime-prerequisites`, `linux.package-manager-policy`, `linux.update-path` +- Gateway Runtime and Service Control (6): `linux.foreground-gateway-runtime`, `linux.process-control`, `linux.systemd-user-service-lifecycle-operation`, `linux.systemd-user-service-lifecycle-recovery`, `linux.systemd-user-service-lifecycle-setup`, `linux.systemd-user-service-lifecycle-status` +- Remote Access and Security (6): `linux.gateway-authentication-modes`, `linux.gateway-exposure-safeguards`, `linux.remote-network-exposure`, `linux.secret-handling`, `linux.tailscale`, `linux.tls` +- Diagnostics and Repair (4): `linux.gateway-diagnostic-reports`, `linux.gateway-log-tailing`, `linux.operator-repair-guidance`, `telemetry.doctor-checks` +- Deployment Targets (3): `linux.cloud-deployment-guidance`, `linux.container`, `linux.vps` + +### linux-companion-app (26) + +- App Distribution (3): `linux.distro-package-targets`, `linux.native-app-package`, `linux.official-release-metadata` +- Gateway Connectivity (4): `linux.gateway-pairing-and-auth`, `linux.local-and-remote-resource-boundaries`, `linux.local-gateway-attach-and-status`, `linux.remote-mode` +- Chat and Sessions (3): `gateway.chat-transport`, `linux.native-linux-chat-window`, `linux.transcript` +- Desktop Capabilities (9): `linux.desktop-permissions`, `linux.desktop-tools`, `linux.microphone-capture`, `linux.native-media-permissions`, `linux.native-node-identity`, `linux.native-talk`, `linux.sandbox-package-posture`, `linux.secret-storage`, `tools.host-command-execution` +- Status and Diagnostics (7): `linux.desktop-environment-integration`, `linux.doctor-repair-affordances`, `linux.gateway-health-status-display`, `linux.log-transcript-opening`, `linux.native-linux-app-readiness`, `linux.runtime-status-row`, `linux.tray-status-item` + +### windows-via-wsl2 (45) + +- WSL Setup (6): `wsl2.linux-install-flow-inside-wsl2`, `wsl2.network-family-requirements`, `wsl2.node-runtime`, `wsl2.runtime-boundary`, `wsl2.source-install-and-build-inside-wsl2`, `wsl2.ubuntu-installation` +- CLI (7): `windows.openclaw-onboard`, `wsl2.cli-entrypoints`, `wsl2.managed-systemd-gateway-restart`, `wsl2.openclaw-doctor-status-and-logs`, `wsl2.openclaw-update`, `wsl2.package-manager-caveats`, `wsl2.service-metadata-refresh` +- Gateway Service Lifecycle (10): `wsl2.clear-expectations-around-pc-power`, `wsl2.doctor-service-repair`, `wsl2.gateway-service-install`, `wsl2.onboarded-systemd-install`, `wsl2.systemd-availability-after-windows-boot`, `wsl2.systemd-user-unit-rendering`, `wsl2.verification-before-windows-sign-in`, `wsl2.windows-startup-task-for-wsl`, `wsl2.wsl-aware-systemd-unavailable-hints`, `wsl2.wsl-user-service-linger` +- Gateway Access and Exposure (11): `security.provider-credentials`, `wsl2.gateway-auth-secretrefs`, `wsl2.gateway-token-password-auth`, `wsl2.ipv4-networking`, `wsl2.loopback-and-lan-exposure`, `wsl2.reachable-gateway-urls`, `wsl2.remote-url-credential-precedence`, `wsl2.tailscale-remote-access`, `wsl2.windows-firewall-rules`, `wsl2.windows-portproxy-setup`, `wsl2.wsl-virtual-network` +- Diagnostics and Repair (5): `telemetry.openclaw-logs`, `windows.openclaw-status`, `wsl2.operator-repair-guidance-after-wsl2-service`, `wsl2.secretref`, `wsl2.wsl-systemd-unavailable-hints` +- Browser and Control UI (6): `wsl2.browser-profile-cdpurl`, `wsl2.gateway-with-windows-browser`, `wsl2.host-local-chrome-mcp`, `wsl2.layered-diagnostics`, `wsl2.raw-remote-cdp-to-windows-chrome`, `wsl2.windows-control-ui-url` + +### native-windows-cli-and-gateway (28) + +- CLI (9): `windows.command-shims`, `windows.daemon-install-flags`, `windows.local-gateway-config`, `windows.native-vs-wsl-setup-boundary`, `windows.node-and-package-manager-bootstrap`, `windows.npm-global-install`, `windows.openclaw-onboard`, `windows.packaged-cli-launcher`, `windows.powershell-installer` +- Gateway Management (11): `windows.foreground-runtime-health-readiness`, `windows.gateway-launcher-files`, `windows.openclaw-gateway`, `windows.openclaw-gateway-install`, `windows.openclaw-status`, `windows.post-install-diagnostics`, `windows.scheduled-task-runtime-status`, `windows.service-inspection`, `windows.specific-restart-signal`, `windows.startup-folder-fallback`, `windows.unmanaged-foreground-mode` +- Networking (4): `windows.gateway-status-and-probe-output`, `windows.loopback-lan-and-wsl-boundary`, `windows.native-windows-host-networking`, `windows.netsh-interface-portproxy` +- Updates (4): `windows.detached-update-handoff`, `windows.managed-gateway-stop-restart`, `windows.openclaw-update-on-native-windows-package`, `windows.package-locks` + +### native-windows-companion-app (23) + +- Installation and Updates (4): `windows.app-release-channel`, `windows.architecture-handling-for-x64`, `windows.msi-msix-app-installer-winget-style-packaging`, `windows.official-app-download` +- Gateway Connection (3): `windows.app-managed-local-gateway-attach-start`, `windows.device-node-pairing`, `windows.remote-gateway-connection-modes` +- Chat Sessions (1): `gateway.chat-transport` +- Status and Repair (5): `windows.app-health-states`, `windows.app-specific-notification-permission`, `windows.app-specific-repair`, `windows.status-indicators`, `windows.system-tray-app` +- Desktop Tools and Permissions (10): `tools.host-command-execution`, `windows.acl`, `windows.app-approval-prompts`, `windows.app-secrets`, `windows.canvas-host-behavior`, `windows.command-approval`, `windows.desktop-command-policy`, `windows.node-identity`, `windows.screen-and-media-capture`, `windows.shell-integrations` + +### android-app (10) + +- Media Capture (1): `android.camera-and-media-capture` +- Mobile Chat (1): `android.chat-tab` +- Connection Setup (1): `gateway.discovery` +- Distribution (3): `android.manual-install-path`, `android.public-google-play-install-path`, `android.release-smoke-and-startup-performance` +- Settings (1): `android.settings-sheet` +- Voice (1): `android.voice-tab` +- Device Runtime (2): `android.background-reconnect-and-presence`, `android.device-command-availability` + +### ios-app (15) + +- Media and Sharing (1): `ios.camera-list-snap-clip` +- Canvas and Screen (1): `ios.canvas-present-hide-navigate-eval-snapshot` +- Chat and Sessions (1): `ios.chat-sessions-and-operator-controls` +- Gateway Setup and Diagnostics (7): `ios.bonjour-local`, `ios.gateway-connect-configuration-persistence`, `ios.manual-host-port`, `ios.pairing-approval`, `ios.pairing-auth-diagnostics-for-users`, `ios.settings-tab`, `ios.tls-fingerprint-trust-prompt` +- Distribution (1): `ios.internal-preview-status` +- Device Commands (2): `ios.device-command-handling`, `ios.location-modes` +- Notifications and Background (1): `ios.apns-registration-and-relay-delivery` +- Voice (1): `ios.voice-wake` + +### watchos-companion-surfaces (24) + +- Delivery and Recovery (6): `watchos.apns-relay-direct-registration-as-it-affects`, `watchos.delivery-fallback-among-reachable-messages`, `watchos.iphone-side-watchconnectivity-transport`, `watchos.pending-approval-recovery-ids`, `watchos.silent-push`, `watchos.watch-side-receiver-activation` +- Exec Approvals (2): `watchos.iphone-side-prompt-caching`, `watchos.watch-approval-list-detail-ui` +- Distribution and Support (6): `watchos.changelog`, `watchos.historical-bug-regression-themes-relevant-to-scoring`, `watchos.public-support-status`, `watchos.release-metadata`, `watchos.signing-profile-variables`, `watchos.watch-app` +- Notifications and Replies (7): `watchos.iphone-side-dedupe`, `watchos.mirrored-ios-notification-action`, `watchos.mirrored-ios-notification-fallback-when-watch`, `watchos.payload-normalization`, `watchos.watch-action-buttons-from-generic-prompt`, `watchos.watch-status`, `watchos.watch-to-iphone-reply-payloads` +- Watch App UI (3): `watchos.generic-inbox`, `watchos.persistent-watch-inbox-state`, `watchos.watch-app-entry-point` + +### raspberry-pi-small-linux-devices (35) + +- Setup and Compatibility (11): `raspberry-pi.64-bit-arm-boundary`, `raspberry-pi.fallback-build-guidance`, `raspberry-pi.hardware-and-64-bit-os-requirements`, `raspberry-pi.installer-architecture-detection`, `raspberry-pi.node-runtime-setup`, `raspberry-pi.npm-pnpm-bun-install-modes`, `raspberry-pi.openclaw-install-and-onboarding`, `raspberry-pi.optional-arm-binary-checks`, `raspberry-pi.slow-device-caveats`, `raspberry-pi.supported-pi-model-selection`, `raspberry-pi.unsupported-device-guidance` +- Remote Access and Auth (9): `raspberry-pi.authenticated-control-ui-access`, `raspberry-pi.device-pairing-approvals`, `raspberry-pi.gateway-shared-secret-auth`, `raspberry-pi.headless-api-key-auth`, `raspberry-pi.loopback-non-loopback-exposure-controls`, `raspberry-pi.secretref-handling`, `raspberry-pi.ssh-tunnel-dashboard-access`, `raspberry-pi.tailscale-serve-funnel`, `raspberry-pi.token-drift-recovery` +- Gateway Runtime (10): `raspberry-pi.always-on-gateway-process`, `raspberry-pi.backup-restore`, `raspberry-pi.channel-startup`, `raspberry-pi.cloud-model-configuration`, `raspberry-pi.gateway-health-status`, `raspberry-pi.linger-boot-persistence`, `raspberry-pi.restart-tuning`, `raspberry-pi.service-drop-ins`, `raspberry-pi.status-log-inspection`, `raspberry-pi.user-service-install` +- Performance and Diagnostics (5): `raspberry-pi.compile-cache-no-respawn-settings`, `raspberry-pi.diagnostics-bundles`, `raspberry-pi.oom-performance-troubleshooting`, `raspberry-pi.swap-and-low-ram-tuning`, `raspberry-pi.usb-ssd-guidance` + +### docker-podman-hosting (16) + +- Container Setup (3): `docker.compose-gateway`, `docker.rootless-podman-image-setup`, `docker.setup-scripts-and-quadlet-template` +- Container Operations (9): `docker.container-health-endpoints`, `docker.container-targeting`, `docker.container-update-rebuild-restart-guidance-for-docker`, `docker.gateway-token-generation`, `docker.host-cli-routing-into-running-docker-podman`, `docker.operator-facing-update`, `docker.ownership`, `docker.provider-vps-docker-hosting-docs`, `docker.vm-persistence-update-guidance` +- Image Release and Validation (2): `docker.release-path-install`, `docker.root-dockerfile-build-stages` +- Agent Sandbox and Tooling (2): `docker.container-image-dependency-baking`, `docker.gateway-setup` + +### kubernetes-hosting (20) + +- Deployment Setup (5): `kubernetes.cluster-prerequisites`, `kubernetes.kind-validation`, `kubernetes.kustomize-packaging`, `kubernetes.manifest-apply`, `kubernetes.quick-deploy` +- Configuration and Secrets (5): `kubernetes.agent-instructions`, `kubernetes.gateway-config`, `kubernetes.image-and-namespace`, `kubernetes.provider-secrets`, `kubernetes.secret-rotation` +- Access and Exposure (5): `kubernetes.auth-and-tls`, `kubernetes.ingress-exposure`, `kubernetes.localhost-posture`, `kubernetes.port-forward-access`, `kubernetes.service-endpoint` +- Cluster Lifecycle (5): `kubernetes.redeploy`, `kubernetes.resource-layout`, `kubernetes.security-context`, `kubernetes.state-persistence`, `kubernetes.teardown` + +### nix-install-path (30) + +- Install Handoff (4): `nix.install-discoverability`, `nix.install-overview`, `nix.openclaw-source-of-truth`, `nix.verification-handoff` +- Plugin Lifecycle (4): `nix.declarative-plugin-selection`, `nix.hardlink-safety`, `nix.lifecycle-command-refusal`, `nix.store-plugin-loading` +- Activation and App UX (7): `nix.environment-activation`, `nix.macos-defaults-activation`, `nix.managed-by-nix-banner`, `nix.onboarding-skip`, `nix.read-only-config-controls`, `nix.runtime-nix-mode-detection`, `nix.stable-nix-defaults` +- Config and State (7): `nix.agent-first-nix-edits`, `nix.config-writer-refusal`, `nix.explicit-config-path`, `nix.immutable-config-guard`, `nix.immutable-store-config-support`, `nix.state-integrity-checks`, `nix.writable-state-directory` +- Service Runtime and Guards (8): `nix.doctor-repair-refusal`, `nix.profile-path-discovery`, `nix.profile-precedence`, `nix.service-lifecycle-handoff`, `nix.service-path-fallback`, `nix.setup-write-refusal`, `nix.trusted-binary-boundaries`, `nix.update-handoff` + +### discord (39) + +- Channel Setup and Operations (10): `discord.account-monitor-startup`, `discord.application-and-bot-setup`, `discord.gateway-websocket-lifecycle`, `discord.multi-account-bot-configuration`, `discord.rate-limits-and-gateway-metadata`, `discord.reconnect-and-heartbeat-handling`, `discord.setup-wizard-and-account-inspection`, `discord.status-doctor-and-intent-checks`, `discord.status-probe-and-health-monitor-recovery`, `discord.token-and-application-id-configuration` +- Access and Identity (6): `discord.access-group-authorization`, `discord.allowlist-inheritance`, `discord.dm-policy-modes`, `discord.group-dm-authorization`, `security.pairing-code-approval`, `security.sender-authorization` +- Conversation Routing and Delivery (12): `channels.mention-gating`, `discord.acp-agent-routing`, `discord.configured-and-runtime-routing`, `discord.forum-and-media-channel-thread-posts`, `discord.guild-and-channel-admission`, `discord.inbound-context-visibility`, `discord.routing-lifecycle`, `discord.session-key-isolation`, `discord.target-parsing`, `discord.thread-actions`, `discord.thread-bound-session-routing`, `discord.thread-context-resolution` +- Media and Rich Content (1): `channels.media-rich-content` +- Native Controls and Approvals (5): `discord.callback-ttl`, `discord.components-v2-messages`, `discord.model-picker-commands`, `discord.native-slash-command-execution`, `discord.native-slash-command-registration` +- Realtime Voice and Calls (5): `discord.auto-join-and-follow-users`, `discord.realtime-voice-modes`, `discord.voice-channel-lifecycle`, `discord.voice-codec-and-dave-recovery`, `discord.wake-barge-in-and-echo-handling` + +### telegram (29) + +- Channel Setup and Operations (9): `telegram.account-scoped-outbound`, `telegram.botfather-token-creation`, `telegram.channel-status`, `telegram.cli-message-tool-targets`, `telegram.directory-adapters`, `telegram.doctor-status-surfacing`, `telegram.named-account-configuration`, `telegram.setup-wizard-credential-capture`, `telegram.startup-getme` +- Access and Identity (10): `memory.session-key-construction`, `security.group-allowlists`, `security.pairing-code-approval`, `telegram.acp-topic-routing`, `telegram.allowfrom`, `telegram.dmpolicy-modes`, `telegram.forum-topic-session-keys`, `telegram.numeric-telegram-user-id-normalization-with-telegram`, `telegram.supergroup-negative-chat-ids`, `telegram.unauthorized-dm` +- Conversation Routing and Delivery (1): `channels.conversation-routing-delivery` +- Media and Rich Content (1): `channels.media-rich-content` +- Native Controls and Approvals (8): `telegram.action-capability-discovery`, `telegram.built-in-commands`, `telegram.command-authorization-in-dms`, `telegram.command-name-description-normalization`, `telegram.exec-approvals-in-dms`, `telegram.inline-keyboard-rendering`, `telegram.model-buttons`, `telegram.native-setmycommands-startup-sync` + +### whatsapp (20) + +- Channel Setup and Operations (5): `whatsapp.baileys-socket-lifecycle`, `whatsapp.channel-config-schema`, `whatsapp.official-openclaw-whatsapp-plugin-metadata`, `whatsapp.openclaw-plugin-install-whatsapp`, `whatsapp.operator-troubleshooting` +- Access and Identity (7): `whatsapp.baileys-multi-file-auth-persistence`, `whatsapp.direct-message-dmpolicy`, `whatsapp.dm-pairing-challenge`, `whatsapp.multi-account-default-account-resolution`, `whatsapp.privacy-controls-for-plugin-hooks`, `whatsapp.qr-login`, `whatsapp.sender-identity-extraction` +- Conversation Routing and Delivery (4): `security.group-allowlists`, `whatsapp.group-session-keys`, `whatsapp.outbound-text-sends`, `whatsapp.provider-accepted-receipts` +- Media and Rich Content (2): `whatsapp.inbound-media-download`, `whatsapp.outbound-image` +- Native Controls and Approvals (2): `whatsapp.approver-target-resolution`, `whatsapp.native-exec` + +### slack (25) + +- Channel Setup and Operations (10): `codex.operator-repair`, `slack.account-status`, `slack.app-credentials`, `slack.app-install`, `slack.channel-status-diagnostics`, `slack.http-transport`, `slack.manifest`, `slack.runtime-lifecycle`, `slack.scopes`, `slack.socket` +- Access and Identity (1): `channels.access-and-identity` +- Conversation Routing and Delivery (5): `security.dm-pairing`, `security.sender-authorization`, `slack.channel-allowlists`, `slack.session-isolation`, `slack.thread-routing` +- Media and Rich Content (1): `channels.media-rich-content` +- Native Controls and Approvals (8): `security.native-approvals`, `slack.actions`, `slack.app-home`, `slack.assistant-events`, `slack.interactive-replies`, `slack.native-command-routing`, `slack.security-sensitive-ops`, `slack.slash-commands` + +### imessage-bluebubbles (31) + +- Channel Setup and Operations (11): `imessage.account-config`, `imessage.account-setup-prompts`, `imessage.account-status-checks`, `imessage.cut-over-safely`, `imessage.doctor-repair-checks`, `imessage.grant-macos-permissions`, `imessage.handle-migration-caveats`, `imessage.probe-runtime-health`, `imessage.run-local-imsg`, `imessage.run-through-ssh-wrapper`, `imessage.translate-legacy-config` +- Access and Identity (6): `imessage.authorize-direct-senders`, `imessage.bind-acp-sessions`, `imessage.group-policy`, `imessage.mentions`, `imessage.route-direct-conversations`, `imessage.system-prompts` +- Conversation Routing and Delivery (4): `imessage.coalesce-split-send-dms`, `imessage.replay-missed-messages`, `imessage.seed-conversation-history`, `imessage.watch-live-messages` +- Media and Rich Content (7): `imessage.chunking`, `imessage.media`, `imessage.message-tool`, `imessage.native-actions`, `imessage.private-api`, `imessage.remote-fetch`, `ui.attachments` +- Native Controls and Approvals (3): `imessage.operator-control`, `imessage.reactions`, `security.native-approvals` + +### signal (24) + +- Channel Setup and Operations (7): `signal.account-safety-guardrails`, `signal.container-account-provisioning`, `signal.installer-and-binary-setup`, `signal.qr-link-setup`, `signal.setup-diagnostics`, `signal.sms-registration`, `signal.status-probes` +- Access and Identity (6): `matrix.mention-gates`, `security.dm-pairing`, `security.group-allowlists`, `signal.dm-allowlists`, `signal.pending-group-history`, `signal.sender-identity-normalization` +- Conversation Routing and Delivery (1): `channels.conversation-routing-delivery` +- Media and Rich Content (7): `signal.add-remove-reactions`, `signal.group-reaction-targeting`, `signal.media-delivery-and-limits`, `signal.reaction-action-discovery`, `signal.styled-chunked-output`, `signal.text-delivery-targets`, `signal.typing-and-read-receipts` +- Native Controls and Approvals (3): `signal.approver-targeting`, `signal.native-approval-routing`, `signal.reaction-approval-responses` + +### google-chat (45) + +- Channel Setup and Operations (16): `google-chat.account-resolution`, `google-chat.channel-aliases-and-labels`, `google-chat.channel-status-and-probes`, `google-chat.chat-app-configuration`, `google-chat.directory-and-mutable-id-diagnostics`, `google-chat.env-file-and-inline-credentials`, `google-chat.google-cloud-project-setup`, `google-chat.guided-channel-setup`, `google-chat.install-update-metadata`, `google-chat.npm-and-clawhub-install`, `google-chat.operator-status-ui`, `google-chat.plugin-docs-and-catalog-routing`, `google-chat.service-account-secretrefs`, `google-chat.service-account-setup`, `google-chat.webhook-audience-and-path`, `google-chat.workspace-visibility-and-app-status` +- Access and Identity (11): `channels.bot-loop-protection`, `channels.mention-gating`, `google-chat.direct-session-routing`, `google-chat.dm-pairing-approval`, `google-chat.group-session-isolation`, `google-chat.identity-matching`, `google-chat.pairing-diagnostics`, `google-chat.sender-access-groups`, `google-chat.sender-allowlists`, `google-chat.space-allowlists`, `google-chat.space-diagnostics` +- Conversation Routing and Delivery (1): `channels.conversation-routing-delivery` +- Media and Rich Content (1): `channels.media-rich-content` +- Native Controls and Approvals (16): `google-chat.action-capability-gates`, `google-chat.approval-sender-matching`, `google-chat.inbound-attachments`, `google-chat.markdown-text-rendering`, `google-chat.media-receipts-and-thread-placement`, `google-chat.media-source-and-size-controls`, `google-chat.message-tool-current-source-replies`, `google-chat.message-upload-action`, `google-chat.no-reply-cleanup`, `google-chat.outbound-media-replies`, `google-chat.reaction-actions`, `google-chat.streaming-and-chunked-replies`, `google-chat.text-send-action`, `google-chat.thread-aware-replies`, `google-chat.typing-placeholder-lifecycle`, `google-chat.upload-file-action` + +### matrix (23) + +- Channel Setup and Operations (5): `matrix.account-discovery`, `matrix.doctor-warnings`, `matrix.plugin-identity`, `matrix.probe-status`, `matrix.setup-wizard` +- Access and Identity (7): `matrix.acp-subagent-spawn-hooks`, `matrix.direct-room-classification`, `matrix.dm-policy`, `matrix.inbound-route-selection-across-sender-bound-dms`, `matrix.mention-gates`, `matrix.persisted-matrix-thread-routing-managers`, `matrix.thread-reply-routing` +- Conversation Routing and Delivery (1): `channels.conversation-routing-delivery` +- Media and Rich Content (1): `channels.media-rich-content` +- Native Controls and Approvals (6): `matrix.channel-action-discovery`, `matrix.inbound-media-failure-handling`, `matrix.message-presentation-metadata`, `matrix.message-send-read-edit-delete`, `matrix.outbound-matrix-text`, `matrix.profile-media-loading` +- Encryption and Verification (3): `matrix.encrypted-media-upload-download`, `matrix.encryption-setup`, `matrix.legacy-state` + +### microsoft-teams (33) + +- Channel Setup and Operations (9): `microsoft-teams.bot-registration-and-manifest-upload`, `microsoft-teams.credential-configuration`, `microsoft-teams.operator-repair-paths`, `microsoft-teams.probe-and-scope-reporting`, `microsoft-teams.setup-status`, `microsoft-teams.teams-app-doctor`, `microsoft-teams.teams-app-install-verification`, `microsoft-teams.teams-cli-app-creation`, `microsoft-teams.webhook-and-health-diagnostics` +- Access and Identity (9): `microsoft-teams.allowlists-and-access-groups`, `microsoft-teams.bot-framework-sso-invokes`, `microsoft-teams.delegated-token-storage`, `microsoft-teams.graph-directory-lookup`, `microsoft-teams.invoke-and-command-authorization`, `microsoft-teams.member-profile-lookup`, `microsoft-teams.stable-sender-identity`, `microsoft-teams.teams-originated-config-writes`, `security.dm-pairing` +- Conversation Routing and Delivery (5): `memory.session-routing`, `microsoft-teams.deterministic-channel-replies`, `microsoft-teams.mention-gated-group-access`, `microsoft-teams.reply-and-thread-context`, `microsoft-teams.team-and-channel-allowlists` +- Media and Rich Content (5): `google-chat.inbound-attachments`, `microsoft-teams.file-consent`, `microsoft-teams.graph-hosted-media`, `microsoft-teams.media-fetch-safety`, `microsoft-teams.sharepoint-and-onedrive-sharing` +- Native Controls and Approvals (5): `microsoft-teams.feedback-and-group-actions`, `microsoft-teams.message-action-discovery`, `microsoft-teams.native-approval-cards`, `microsoft-teams.polls-and-reactions`, `microsoft-teams.read-edit-delete-and-pin` + +### mattermost-line-irc-nextcloud-talk-nostr-twitch-tlon-synology-chat (4) + +- Channel Setup and Operations (1): `channels.setup-operations` +- Access and Identity (1): `channels.access-and-identity` +- Conversation Routing and Delivery (1): `channels.conversation-routing-delivery` +- Media and Rich Content (1): `channels.media-rich-content` + +### feishu-qq-bot-wechat-yuanbao-zalo-zalo-personal-regional-channels (9) + +- Channel Setup and Operations (6): `regional-channels.channel-setup-wizard`, `regional-channels.core-channel-plugin-catalog`, `regional-channels.cross-channel-ingress-access-refactor-concerns`, `regional-channels.docs-channel-index`, `regional-channels.missing-plugin`, `regional-channels.official-external-channel-catalog-entries` +- Access and Identity (1): `channels.access-and-identity` +- Conversation Routing and Delivery (1): `channels.conversation-routing-delivery` +- Media and Rich Content (1): `channels.media-rich-content` + +### voice-call-channel (7) + +- Channel Setup and Operations (1): `voice-call.setup-smoke` +- Access and Identity (1): `voice-call.webhook-security` +- Conversation Routing and Delivery (1): `voice-call.inbound-routing` +- Media and Rich Content (2): `voice-call.provider-transports`, `voice-call.telephony-audio` +- Realtime Voice and Calls (2): `voice-call.realtime-consult`, `voice-call.streaming-transcription` + +### openai-codex-provider-path (9) + +- Model and Auth (3): `codex.catalog`, `codex.operator-repair`, `codex.subscription-usage` +- Responses and Tool Compatibility (2): `codex.capability-compatibility`, `codex.responses-transport` +- Image and Multimodal Input (2): `codex.image-generation-editing`, `codex.multimodal-input` +- Voice and Realtime Audio (2): `codex.realtime-voice-transcription`, `codex.speech` + +### anthropic-provider-path (34) + +- Provider Auth and Recovery (8): `anthropic.claude-cli-credential-reuse`, `anthropic.cooldown-profile-reporting`, `anthropic.fallback-guidance`, `anthropic.long-context-recovery`, `anthropic.model-status`, `anthropic.setup-token-auth`, `anthropic.usage-windows`, `gateway.api-key-onboarding` +- Model and Runtime Selection (8): `anthropic.bundled-claude-catalog`, `anthropic.capability-metadata`, `anthropic.fallback-prelude`, `anthropic.mcp-tool-bridge`, `anthropic.permission-mode-mapping`, `anthropic.runtime-selection`, `memory.session-continuity`, `models.picker-availability` +- Request Transport and Turn Semantics (9): `anthropic.abort-error-handling`, `anthropic.api-key-oauth-transport`, `anthropic.messages-payloads`, `anthropic.native-thinking`, `anthropic.partial-json-recovery`, `anthropic.streaming-decode`, `anthropic.tool-result-replay`, `anthropic.tool-use-blocks`, `anthropic.usage-and-stop-reasons` +- Prompt Cache and Context (5): `anthropic.1m-context`, `anthropic.cache-diagnostics`, `anthropic.cache-retention`, `anthropic.fast-mode-service-tier`, `anthropic.system-prompt-cache-boundary` +- Media Inputs (4): `anthropic.image-input`, `anthropic.image-tool-results`, `anthropic.media-model-fallback`, `anthropic.pdf-document-input` + +### google-provider-path (44) + +- Provider Setup and Credentials (10): `gateway.api-key-onboarding`, `google.auth-choice-metadata`, `google.canonical-google-model-refs`, `google.cli-runtime-selection`, `google.cli-usage-normalization`, `google.daemon-and-fallback-credentials`, `google.gemini-cli-oauth-setup`, `google.oauth-diagnostics`, `google.oauth-login-and-refresh`, `google.vertex-adc-setup` +- Model Routing and Endpoints (10): `google.adc-service-account-auth`, `google.catalog-rows-and-aliases`, `google.compatibility-boundaries`, `google.custom-base-url-policy`, `google.dynamic-model-resolution`, `google.native-config-normalization`, `google.project-location-endpoints`, `google.provider-routing`, `google.vertex-provider-selection`, `models.picker-availability` +- Direct Gemini Runtime (9): `anthropic.usage-and-stop-reasons`, `google.direct-gemini-chat`, `google.direct-gemini-transport-payloads`, `google.incomplete-turn-recovery`, `google.multimodal-inputs`, `google.thinking-level-mapping`, `google.thought-signature-replay`, `google.tool-call-streaming`, `google.tool-turn-ordering` +- Media, Search, and Realtime (10): `google.audio-and-transcript-events`, `google.bundled-plugin-distribution`, `google.constrained-browser-tokens`, `google.image-and-media-adapters`, `google.live-tool-calls`, `google.provider-auto-enable-metadata`, `google.realtime-voice-sessions`, `google.search-and-generation-tools`, `google.session-reconnects`, `google.speech-and-realtime-adapters` +- Prompt Caching (5): `google.cache-diagnostics-and-live-proof`, `google.cache-retention-config`, `google.cache-usage-accounting`, `google.managed-cachedcontents`, `google.manual-cachedcontent-handles` + +### openrouter-provider-path (41) + +- Provider Setup and Auth (14): `openrouter.api-key`, `openrouter.auth-profiles-and-auth-order`, `openrouter.auto-and-nested-refs`, `openrouter.default-model-selection`, `openrouter.dynamic-models-discovery`, `openrouter.first-run-setup`, `openrouter.free-model-scan-probe`, `openrouter.gateway-env-inheritance`, `openrouter.model-list-picker-cache`, `openrouter.model-ref-examples`, `openrouter.provider-entry-secretref-api-key-resolution`, `openrouter.provider-plugin-registration`, `openrouter.static-catalog-rows`, `openrouter.status-probe-and-removal` +- Chat Runtime and Normalization (15): `openrouter.anthropic-cache-control-markers`, `openrouter.anthropic-gemini-deepseek-variants`, `openrouter.attribution-headers`, `openrouter.cache-usage-mapping`, `openrouter.chat-completions-route`, `openrouter.custom-proxy-exclusions`, `openrouter.family-specific-replay-policy`, `openrouter.per-model-route-overrides`, `openrouter.provider-routing-params`, `openrouter.reasoning-details-visible-output`, `openrouter.reasoning-payload-policy`, `openrouter.response-cache-headers-ttl-clear`, `openrouter.response-model-and-usage-normalization`, `openrouter.streamed-content-parsing`, `openrouter.tool-call-delta-preservation` +- Provider Recovery and Diagnostics (5): `openrouter.auth-billing-key-limit-classification`, `openrouter.context-overflow`, `openrouter.guarded-fetch-pricing-warnings`, `openrouter.model-fallback-notices`, `openrouter.timeout-retry-classification` +- Media Generation and Speech (7): `openrouter.generated-artifact-delivery`, `openrouter.image-generate-openrouter-route`, `openrouter.inbound-media-understanding`, `openrouter.music-generate-audio-route`, `openrouter.speech-to-text-transcription`, `openrouter.text-to-speech`, `openrouter.video-generate-async-jobs-polling-download` + +### local-model-providers-ollama-vllm-sglang-lm-studio (36) + +- Provider Setup, Lifecycle, and Diagnostics (12): `local-models.backend-reachability-probes`, `local-models.health-checks-and-restart`, `local-models.local-provider-status`, `local-models.localservice-configuration`, `local-models.memory-readiness-diagnostics`, `local-models.model-availability-errors`, `local-models.onboarding`, `local-models.process-startup-and-readiness`, `local-models.provider-recipes`, `local-models.provider-selection`, `local-models.provider-troubleshooting-docs`, `local-models.request-leases-and-idle-shutdown` +- Native Provider Plugins (10): `local-models.lm-studio-embeddings`, `local-models.lm-studio-setup`, `local-models.model-discovery`, `local-models.model-discovery-and-auth`, `local-models.model-preload-and-jit-loading`, `local-models.ollama-embeddings`, `local-models.ollama-setup-and-model-pulling`, `local-models.streaming-and-vision`, `local-models.streaming-compatibility`, `local-models.web-search-support` +- OpenAI-Compatible Runtime Compatibility (7): `local-models.bundled-provider-setup`, `local-models.model-discovery-endpoint`, `local-models.non-interactive-configuration`, `local-models.request-stream-compatibility`, `local-models.sglang-compatibility-guidance`, `local-models.tool-calling`, `local-models.vllm-thinking-controls` +- Local Memory and Embeddings (5): `local-models.embedding-provider-selection`, `local-models.fallback-lexical-search`, `local-models.memory-search-readiness`, `local-models.memoryflush-model-override`, `local-models.provider-mismatch-guidance` +- Network Safety and Prompt Controls (2): `local-models.prompt-pressure-controls`, `local-models.safety-network` + +### long-tail-hosted-providers (30) + +- Hosted LLM Providers (12): `hosted-providers.account-prerequisite-diagnostics`, `hosted-providers.bedrock-setup`, `hosted-providers.copilot-opencode-hosted-access`, `hosted-providers.gateway-proxy-routing`, `hosted-providers.hosted-text-completion`, `hosted-providers.model-catalog-resolution`, `hosted-providers.provider-specific-request-shaping`, `hosted-providers.proxy-capability-diagnostics`, `hosted-providers.region-and-plan-routing`, `hosted-providers.regional-live-smoke`, `hosted-providers.regional-provider-setup`, `hosted-providers.tool-call-and-streaming-compatibility` +- Hosted Media Providers (6): `hosted-providers.audio-format-diagnostics`, `hosted-providers.media-mode-coverage`, `hosted-providers.music-generation-providers`, `hosted-providers.speech-to-text-providers`, `hosted-providers.text-to-speech-providers`, `models.realtime-transcription-providers` +- Provider Operations (12): `hosted-providers.auth-profiles-and-aliases`, `hosted-providers.catalog-parity-checks`, `hosted-providers.credential-health-probes`, `hosted-providers.direct-provider-smoke`, `hosted-providers.fallback-trace-and-repair`, `hosted-providers.gateway-live-smoke`, `hosted-providers.key-rotation-and-recovery`, `hosted-providers.model-catalog-metadata`, `hosted-providers.models-status-probes`, `hosted-providers.provider-directory`, `hosted-providers.provider-install-catalog`, `hosted-providers.provider-setup-descriptors` + +### web-search-tools (37) + +- Search Providers (16): `web-search.codex-native-web-search`, `web-search.contract-tests`, `web-search.gemini-grounding`, `web-search.grok-web-grounding`, `web-search.keyless-and-self-hosted-providers`, `web-search.kimi-web-search`, `web-search.provider-comparison-and-auto-detection`, `web-search.provider-native-citations`, `web-search.provider-specific-filters-and-extraction`, `web-search.public-artifact-loading`, `web-search.registerwebfetchprovider`, `web-search.registerwebsearchprovider`, `web-search.result-normalization`, `web-search.runtime-resolution`, `web-search.webfetchproviders`, `web-search.websearchproviders` +- Setup and Diagnostics (9): `codex.operator-repair`, `models.diagnostics`, `security.provider-credentials`, `web-search.cache-controls`, `web-search.credential-repair`, `web-search.default-provider-selection`, `web-search.quota-errors`, `web-search.retry-and-fallback`, `web-search.status-checks` +- Network Safety (4): `browser-tools.ssrf`, `web-search.network-safety`, `web-search.redirects`, `web-search.untrusted-content` +- Tool Availability and Fetch (8): `web-search.content-citation-handoff`, `web-search.disabled-state-diagnostics`, `web-search.group-web-policy`, `web-search.pdf-text-extraction`, `web-search.provider-model-gating`, `web-search.safe-truncation`, `web-search.url-fetch`, `web-search.x-search-exposure` + +### browser-automation-and-exec-sandbox-tools (16) + +- Browser Automation (7): `browser-tools.browser-actions`, `browser-tools.browser-plugin-service`, `browser-tools.browser-security`, `browser-tools.profiles`, `browser-tools.remote-control`, `browser-tools.snapshots`, `browser-tools.ssrf` +- Tool Invocation and Execution (3): `browser-tools.elevated-mode`, `browser-tools.host-exec-approvals`, `browser-tools.node-system-run` +- Sandbox and Tool Policy (6): `browser-tools.codex-dynamic-tools`, `browser-tools.sandbox-backends`, `browser-tools.sandbox-tool-gates`, `browser-tools.sandboxed-browser`, `browser-tools.tool-policy`, `browser-tools.workspace-isolation` + +### image-video-music-generation-tools (42) + +- Media Routing and Discovery (4): `media-tools.action-list-provider-inspection`, `media-tools.auth-backed-tool-discovery`, `media-tools.default-media-model-config`, `media-tools.per-call-model-refs-and-fallbacks` +- Task Lifecycle and Delivery (12): `media-tools.background-task-creation`, `media-tools.channel-attachment-proof`, `media-tools.completion-failure-wake`, `media-tools.duplicate-guards`, `media-tools.hosted-url-fallback`, `media-tools.idempotent-missing-media-fallback`, `media-tools.local-media-persistence`, `media-tools.message-tool-handoff`, `media-tools.mime-filename-inference`, `media-tools.no-session-inline-fallback`, `media-tools.progress-keepalive`, `media-tools.task-status-list-show-cancel` +- Image Generation (9): `media-tools.action-status`, `media-tools.api-key-openai`, `media-tools.openai-codex-oauth`, `media-tools.openrouter-xai-fal-litellm-deepinfra-google-minimax-comfyui-auth`, `media-tools.output-hints`, `media-tools.provider-attempt-metadata`, `media-tools.provider-error-diagnostics`, `media-tools.text-to-image`, `media.reference-image-editing` +- Video Generation (11): `media-tools.audio-refs`, `media-tools.hosted-url-download`, `media-tools.image-to-video`, `media-tools.polling-timeout-handling`, `media-tools.provider-skip-explanations`, `media-tools.queue-backed-jobs`, `media-tools.reference-role-validation`, `media-tools.returned-asset-metadata`, `media-tools.text-to-video`, `media-tools.typed-provideroptions`, `media-tools.video-to-video` +- Music Generation (6): `media-tools.duration-format-controls`, `media-tools.generated-audio-outputs`, `media-tools.image-reference-edit-lanes`, `media-tools.instrumental-mode`, `media-tools.prompt-and-lyrics-input`, `media-tools.provider-fallback` diff --git a/qa/scenarios/agents/subagent-fanout-synthesis.yaml b/qa/scenarios/agents/subagent-fanout-synthesis.yaml index 1d37c19c575c..bcf953326af4 100644 --- a/qa/scenarios/agents/subagent-fanout-synthesis.yaml +++ b/qa/scenarios/agents/subagent-fanout-synthesis.yaml @@ -108,7 +108,7 @@ flow: - lambda: params: [text] expr: "config.expectedReplyGroups.every((group) => group.some((needle) => normalizeLowercaseStringOrEmpty(text).includes(needle)))" - - expr: "env.providerMode === 'mock-openai' ? 10000 : 30000" + - expr: "30000" - expr: "env.providerMode === 'mock-openai' ? 100 : 250" - if: expr: "Boolean(env.mock)" @@ -240,7 +240,11 @@ flow: message: expr: "lastError instanceof Error ? formatErrorMessage(lastError) : String(lastError ?? 'fanout retry exhausted')" - if: - expr: "Boolean(env.mock)" + # Codex completes child sessions through its app-server path but + # does not relay the child marker back onto the parent QA channel. + # The shared assertions above already prove both child tool calls + # and child session rows; keep this transport-only proof OpenClaw-specific. + expr: "Boolean(env.mock) && env.gateway.runtimeEnv.OPENCLAW_QA_FORCE_RUNTIME !== 'codex'" then: - forEach: items: @@ -253,5 +257,5 @@ flow: - lambda: params: [candidate] expr: "String(candidate.text ?? '').trim() === childCompletionMarker" - - 10000 + - 30000 detailsExpr: "details" diff --git a/qa/scenarios/memory/memory-tools-channel-context.yaml b/qa/scenarios/memory/memory-tools-channel-context.yaml index b3d10555694d..28514ab7f098 100644 --- a/qa/scenarios/memory/memory-tools-channel-context.yaml +++ b/qa/scenarios/memory/memory-tools-channel-context.yaml @@ -8,10 +8,9 @@ scenario: - memory.tools secondary: - channels.group-messages - objective: Verify the agent uses memory_search and memory_get in a shared channel when the answer lives only in memory files, not the live transcript. + objective: Verify the agent uses memory tools in a shared channel when the answer lives only in memory files, not the live transcript. successCriteria: - Agent uses memory_search before answering. - - Agent narrows with memory_get before answering. - Final reply returns the memory-only fact correctly in-channel. docsRefs: - docs/concepts/memory.md @@ -21,7 +20,7 @@ scenario: - extensions/qa-lab/src/suite.ts execution: kind: flow - summary: Verify the agent uses memory_search and memory_get in a shared channel when the answer lives only in memory files, not the live transcript. + summary: Verify the agent uses memory tools in a shared channel when the answer lives only in memory files, not the live transcript. config: channelId: qa-memory-room channelTitle: QA Memory Room @@ -33,7 +32,7 @@ scenario: flow: steps: - - name: uses memory_search plus memory_get before answering in-channel + - name: uses memory_search before answering in-channel actions: - call: reset - call: fs.writeFile @@ -80,7 +79,4 @@ flow: - assert: expr: "!env.mock || (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).filter((request) => String(request.allInputText ?? '').includes(config.promptSnippet)).some((request) => request.plannedToolName === 'memory_search')" message: expected memory_search in mock request plan - - assert: - expr: "!env.mock || (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).some((request) => request.plannedToolName === 'memory_get')" - message: expected memory_get in mock request plan detailsExpr: outbound.text diff --git a/qa/scenarios/models/claude-cli-provider-capabilities-subscription.yaml b/qa/scenarios/models/claude-cli-provider-capabilities-subscription.yaml index e5399150d705..87615722ae0c 100644 --- a/qa/scenarios/models/claude-cli-provider-capabilities-subscription.yaml +++ b/qa/scenarios/models/claude-cli-provider-capabilities-subscription.yaml @@ -31,6 +31,7 @@ scenario: summary: Run with `pnpm openclaw qa suite --provider-mode live-frontier --cli-auth-mode subscription --model claude-cli/claude-sonnet-4-6 --alt-model claude-cli/claude-sonnet-4-6 --scenario claude-cli-provider-capabilities-subscription`. config: authMode: subscription + requiredProviderMode: live-frontier requiredProvider: claude-cli chatPrompt: "Claude CLI provider marker check. Reply exactly: CLAUDE-CLI-CHAT-OK" chatExpected: CLAUDE-CLI-CHAT-OK diff --git a/qa/scenarios/models/claude-cli-provider-capabilities.yaml b/qa/scenarios/models/claude-cli-provider-capabilities.yaml index f04ab0c53a0e..c65da98d7647 100644 --- a/qa/scenarios/models/claude-cli-provider-capabilities.yaml +++ b/qa/scenarios/models/claude-cli-provider-capabilities.yaml @@ -31,6 +31,7 @@ scenario: summary: Run with `pnpm openclaw qa suite --provider-mode live-frontier --cli-auth-mode api-key --model claude-cli/claude-sonnet-4-6 --alt-model claude-cli/claude-sonnet-4-6 --scenario claude-cli-provider-capabilities`. config: authMode: api-key + requiredProviderMode: live-frontier requiredProvider: claude-cli chatPrompt: "Claude CLI provider marker check. Reply exactly: CLAUDE-CLI-CHAT-OK" chatExpected: CLAUDE-CLI-CHAT-OK diff --git a/qa/scenarios/plugins/mcp-plugin-tools-call.yaml b/qa/scenarios/plugins/mcp-plugin-tools-call.yaml index cf8c6ec9b3eb..8f0d4210a48a 100644 --- a/qa/scenarios/plugins/mcp-plugin-tools-call.yaml +++ b/qa/scenarios/plugins/mcp-plugin-tools-call.yaml @@ -18,47 +18,8 @@ scenario: - docs/gateway/protocol.md codeRefs: - src/mcp/plugin-tools-serve.ts - - extensions/qa-lab/src/suite.ts + - src/mcp/plugin-tools-mcp-client.test.ts execution: - kind: flow + kind: vitest + path: src/mcp/plugin-tools-mcp-client.test.ts summary: Verify OpenClaw can expose plugin tools over MCP and a real MCP client can call one successfully. - config: - memoryFact: "MCP fact: the codename is ORBIT-9." - query: "ORBIT-9 codename" - expectedNeedle: "ORBIT-9" - -flow: - steps: - - name: serves and calls memory_search over MCP - actions: - - call: fs.writeFile - args: - - expr: "path.join(env.gateway.workspaceDir, 'MEMORY.md')" - - expr: "`${config.memoryFact}\\n`" - - utf8 - - call: forceMemoryIndex - args: - - env: - ref: env - query: - expr: config.query - expectedNeedle: - expr: config.expectedNeedle - - call: callPluginToolsMcp - saveAs: result - args: - - env: - ref: env - toolName: memory_search - args: - query: - expr: config.query - maxResults: 3 - - set: text - value: - expr: "JSON.stringify(result.content ?? [])" - - assert: - expr: "text.includes(config.expectedNeedle)" - message: - expr: "`MCP memory_search missed expected fact: ${text}`" - detailsExpr: text diff --git a/qa/scenarios/runtime/long-context-cache-stability.yaml b/qa/scenarios/runtime/long-context-cache-stability.yaml index 67c8314a54a8..b0040da71250 100644 --- a/qa/scenarios/runtime/long-context-cache-stability.yaml +++ b/qa/scenarios/runtime/long-context-cache-stability.yaml @@ -26,7 +26,9 @@ scenario: config: sessionKey: agent:qa:long-context-cache-stability fixtureFile: large-cache-fixture.txt - cacheEvidenceNeedle: CACHE-FIXTURE-0550 + cacheEvidenceNeedle: CACHE-FIXTURE-0050 + cacheEvidenceLine: "CACHE-FIXTURE-0050: stable tool-result evidence for prompt-cache reuse across long sessions." + followupPromptNeedle: Using the already-read warmupMarker: QA-LARGE-CACHE-WARMUP-OK hitMarker: QA-LARGE-CACHE-HIT-OK @@ -84,8 +86,17 @@ flow: - set: debugRequests value: expr: "env.mock ? [...(await fetchJson(`${env.mock.baseUrl}/debug/requests`))] : []" + - set: cappedReadOutputIndex + value: + expr: "debugRequests.reduce((found, planned, index) => { if (found >= 0 || !planned.plannedToolCallId || planned.plannedToolName !== 'read' || planned.plannedToolArgs?.path !== config.fixtureFile) return found; const outputOffset = debugRequests.slice(index + 1).findIndex((candidate) => Boolean(candidate.toolOutputCallId) && candidate.toolOutputCallId === planned.plannedToolCallId); if (outputOffset < 0) return found; const output = debugRequests[index + 1 + outputOffset]; const evidence = [planned.allInputText, output.allInputText, output.toolOutput].filter((value) => typeof value === 'string').join('\\n'); const hasCodexFormattedTruncation = evidence.includes('Warning: truncated output') && (evidence.includes('chars truncated') || evidence.includes('tokens truncated')); return evidence.includes(config.cacheEvidenceLine) && (evidence.includes('[Read output capped at 50KB') || evidence.includes('...(OpenClaw truncated dynamic tool result') || evidence.includes('...(truncated)...') || hasCodexFormattedTruncation) ? index + 1 + outputOffset : found; }, -1)" + - set: hasCappedReadEvidence + value: + expr: "cappedReadOutputIndex >= 0" + - set: hasFollowupCacheEvidence + value: + expr: "cappedReadOutputIndex >= 0 && debugRequests.some((request, index) => index > cappedReadOutputIndex && String(request.prompt ?? '').includes(config.followupPromptNeedle) && String(request.allInputText ?? '').includes(config.cacheEvidenceLine))" - assert: - expr: "!env.mock || debugRequests.some((request, index) => request.plannedToolName === 'read' && request.plannedToolArgs?.path === config.fixtureFile && typeof request.plannedToolCallId === 'string' && debugRequests.slice(index + 1).some((result, resultOffset) => result.toolOutputCallId === request.plannedToolCallId && String(result.toolOutput ?? '').includes(config.cacheEvidenceNeedle) && (String(result.toolOutput ?? '').includes('[Read output capped at 50KB') || (String(result.toolOutput ?? '').includes('...(truncated)...') && String(result.toolOutput ?? '').length <= 13000)) && debugRequests.slice(index + resultOffset + 2).some((followup) => followup.plannedToolName === 'read' && followup.plannedToolArgs?.path === config.fixtureFile && String(followup.allInputText ?? '').includes(config.cacheEvidenceNeedle) && (String(followup.allInputText ?? '').includes('[Read output capped at 50KB') || String(followup.allInputText ?? '').includes('...(truncated)...')))))" + expr: "!env.mock || (hasCappedReadEvidence && hasFollowupCacheEvidence)" message: - expr: "`large capped read tool result was not observed: ${JSON.stringify(debugRequests.slice(-8).map((request) => ({ plannedToolName: request.plannedToolName ?? null, plannedToolArgs: request.plannedToolArgs ?? null, plannedToolCallId: request.plannedToolCallId ?? null, toolOutputCallId: request.toolOutputCallId ?? null, toolOutputLength: String(request.toolOutput ?? '').length, toolOutputHasNeedle: String(request.toolOutput ?? '').includes(config.cacheEvidenceNeedle), toolOutputHasReadCap: String(request.toolOutput ?? '').includes('[Read output capped at 50KB'), toolOutputHasCodexTruncation: String(request.toolOutput ?? '').includes('...(truncated)...'), inputHasNeedle: String(request.allInputText ?? '').includes(config.cacheEvidenceNeedle), inputHasReadCap: String(request.allInputText ?? '').includes('[Read output capped at 50KB'), inputHasCodexTruncation: String(request.allInputText ?? '').includes('...(truncated)...') })))}`" + expr: "`large capped read cache evidence was not observed: ${JSON.stringify({ hasCappedReadEvidence, hasFollowupCacheEvidence, requests: debugRequests.slice(-8).map((request) => ({ prompt: request.prompt ?? null, plannedToolName: request.plannedToolName ?? null, plannedToolArgs: request.plannedToolArgs ?? null, plannedToolCallId: request.plannedToolCallId ?? null, toolOutputCallId: request.toolOutputCallId ?? null, toolOutputLength: String(request.toolOutput ?? '').length, outputHasReadCap: String(request.toolOutput ?? '').includes('[Read output capped at 50KB'), outputHasCodexTruncation: String(request.toolOutput ?? '').includes('...(truncated)...'), inputHasEvidenceLine: String(request.allInputText ?? '').includes(config.cacheEvidenceLine) })) })}`" detailsExpr: "outbound?.text ?? config.hitMarker" diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 4df5e7890aad..1b7e256e5131 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -8,7 +8,8 @@ This directory owns local tooling, script wrappers, and generated-artifact helpe - For tests, prefer `scripts/run-vitest.mjs` or the root `pnpm test ...` entrypoints over raw `vitest run` calls. - Never use bare `vitest ...` in automation; it starts local watch mode unless `run` or `--run` is explicit. - For lint/typecheck flows, prefer `scripts/run-oxlint.mjs` and `scripts/run-tsgo.mjs` when adding or editing package scripts or CI steps that should honor repo-local runtime behavior. -- For changed-file verification, prefer `scripts/check-changed.mjs` and keep lane classification in `scripts/changed-lanes.mjs`. Do not copy path-scope rules into new hooks or ad hoc CI snippets. +- For changed-file verification, prefer `scripts/check-changed.mjs` and keep lane classification in `scripts/changed-lanes.mjs`. Use `node scripts/check-changed.mjs --dry-run [--staged|-- ]` to inspect the plan before running anything expensive. Do not copy path-scope rules into new hooks or ad hoc CI snippets. +- For one/few lint files, prefer direct `node scripts/run-oxlint.mjs --tsconfig ` over sharded `pnpm lint`; `check-changed.mjs` owns this targeting for core, extension, and script diffs. ## Local Heavy-Check Lock diff --git a/scripts/check-changed.mjs b/scripts/check-changed.mjs index 6a353cea03e7..cb66e4f3ffe0 100644 --- a/scripts/check-changed.mjs +++ b/scripts/check-changed.mjs @@ -49,10 +49,19 @@ const RUNTIME_SIDECAR_BASELINE_PATH_RE = const CANVAS_A2UI_NATIVE_RESOURCE_PATH_RE = /^(?:pnpm-lock\.yaml$|apps\/shared\/OpenClawKit\/Sources\/OpenClawKit\/Resources\/CanvasA2UI\/|extensions\/canvas\/(?:package\.json$|scripts\/bundle-a2ui\.mjs$|src\/host\/a2ui(?:\/(?:index\.html|a2ui\.bundle\.js|\.bundle\.hash)$|-app\/))|scripts\/(?:bundle-a2ui|sync-native-a2ui)\.mjs$)/u; const CORE_OXLINT_TS_CONFIG = "config/tsconfig/oxlint.core.json"; -const TARGETED_CORE_LINT_PATH_LIMIT = 8; +const EXTENSIONS_OXLINT_TS_CONFIG = "config/tsconfig/oxlint.extensions.json"; +const SCRIPTS_OXLINT_TS_CONFIG = "config/tsconfig/oxlint.scripts.json"; +const TARGETED_LINT_PATH_LIMIT = 8; const LINTABLE_CORE_PATH_RE = /^(?:src|ui|packages)\/.+\.[cm]?[jt]sx?$/u; +const LINTABLE_EXTENSION_PATH_RE = /^extensions\/[^/]+\/.+\.[cm]?[jt]sx?$/u; +const LINTABLE_SCRIPT_PATH_RE = /^scripts\/.+\.[cm]?[jt]sx?$/u; +const MARKDOWN_LINT_OPTIMIZATION_NEUTRAL_PATH_RE = /^(?:docs\/|README\.md$|.*\.mdx?$)/u; const CORE_LINT_OPTIMIZATION_NEUTRAL_PATH_RE = /^(?:scripts|test\/scripts)\/|^\.github\/workflows\/ci\.yml$/u; +const EXTENSION_LINT_OPTIMIZATION_NEUTRAL_PATH_RE = + /^(?:test\/scripts\/|\.github\/workflows\/ci\.yml$)/u; +const SCRIPT_LINT_OPTIMIZATION_NEUTRAL_PATH_RE = + /^(?:test\/scripts\/|\.github\/workflows\/ci\.yml$)/u; const ANDROID_VERSION_SYNC_PATHS = new Set([ "apps/android/CHANGELOG.md", "apps/android/Config/Version.properties", @@ -413,10 +422,32 @@ export function createChangedCheckPlan(result, options = {}) { addLint("lint core", ["lint:core"]); } if (lanes.extensions || lanes.extensionTests) { - addLint("lint extensions", ["lint:extensions"]); + const extensionLintCommand = createTargetedExtensionLintCommand(result.paths, baseEnv); + if (extensionLintCommand) { + addCommand( + extensionLintCommand.name, + extensionLintCommand.bin, + extensionLintCommand.args, + extensionLintCommand.env, + ); + } else { + addLint("lint extensions", ["lint:extensions"]); + } } if (lanes.tooling || lanes.liveDockerTooling) { - addLint("lint scripts", ["lint:scripts"]); + const scriptLintCommand = createTargetedScriptLintCommand(result.paths, baseEnv); + if (scriptLintCommand) { + addLint("lint docker-e2e", ["lint:docker-e2e"]); + addLint("raw HTTP/2 import guard", ["lint:tmp:no-raw-http2-imports"]); + addCommand( + scriptLintCommand.name, + scriptLintCommand.bin, + scriptLintCommand.args, + scriptLintCommand.env, + ); + } else { + addLint("lint scripts", ["lint:scripts"]); + } } if (lanes.apps && shouldSkipAppLintForMissingSwiftlint({ ...options, env: baseEnv })) { addCommand( @@ -466,29 +497,73 @@ export function createChangedCheckPlan(result, options = {}) { } export function createTargetedCoreLintCommand(paths, env = process.env, options = {}) { + return createTargetedOxlintCommand({ + env, + label: "core", + lintablePathRe: LINTABLE_CORE_PATH_RE, + neutralPathRe: CORE_LINT_OPTIMIZATION_NEUTRAL_PATH_RE, + paths, + tsconfig: CORE_OXLINT_TS_CONFIG, + ...options, + }); +} + +export function createTargetedExtensionLintCommand(paths, env = process.env, options = {}) { + return createTargetedOxlintCommand({ + env, + label: "extension", + lintablePathRe: LINTABLE_EXTENSION_PATH_RE, + neutralPathRe: EXTENSION_LINT_OPTIMIZATION_NEUTRAL_PATH_RE, + paths, + tsconfig: EXTENSIONS_OXLINT_TS_CONFIG, + ...options, + }); +} + +export function createTargetedScriptLintCommand(paths, env = process.env, options = {}) { + return createTargetedOxlintCommand({ + env, + label: "script", + lintablePathRe: LINTABLE_SCRIPT_PATH_RE, + neutralPathRe: SCRIPT_LINT_OPTIMIZATION_NEUTRAL_PATH_RE, + paths, + tsconfig: SCRIPTS_OXLINT_TS_CONFIG, + ...options, + }); +} + +function createTargetedOxlintCommand({ + env = process.env, + fileExists = existsSync, + label, + lintablePathRe, + neutralPathRe, + paths, + tsconfig, +}) { if ( paths.some( (changedPath) => - !LINTABLE_CORE_PATH_RE.test(changedPath) && - !CORE_LINT_OPTIMIZATION_NEUTRAL_PATH_RE.test(changedPath), + !lintablePathRe.test(changedPath) && + !neutralPathRe.test(changedPath) && + !MARKDOWN_LINT_OPTIMIZATION_NEUTRAL_PATH_RE.test(changedPath), ) ) { return null; } const targets = paths - .filter((changedPath) => LINTABLE_CORE_PATH_RE.test(changedPath)) + .filter((changedPath) => lintablePathRe.test(changedPath)) .toSorted((left, right) => left.localeCompare(right)); - if (targets.length === 0 || targets.length > TARGETED_CORE_LINT_PATH_LIMIT) { + if (targets.length === 0 || targets.length > TARGETED_LINT_PATH_LIMIT) { return null; } - const fileExists = options.fileExists ?? existsSync; if (!targets.every((target) => fileExists(target))) { return null; } return { - name: targets.length === 1 ? "lint core changed file" : "lint core changed files", + name: targets.length === 1 ? `lint ${label} changed file` : `lint ${label} changed files`, bin: "node", - args: ["scripts/run-oxlint.mjs", "--tsconfig", CORE_OXLINT_TS_CONFIG, ...targets], + args: ["scripts/run-oxlint.mjs", "--tsconfig", tsconfig, ...targets], env, }; } @@ -544,6 +619,11 @@ function printPlan(result, plan, options) { for (const reason of result.reasons) { console.error(`${prefix} ${reason}`); } + if (options.dryRun) { + for (const command of plan.commands) { + console.error(`${prefix} would run: ${formatPlanCommand(command)}`); + } + } } async function runPnpm(command, timings) { @@ -557,6 +637,15 @@ async function runPlanCommand(command, timings) { return await runPnpm(command, timings); } +function formatPlanCommand(command) { + const argv = command.bin ? [command.bin, ...command.args] : ["pnpm", ...command.args]; + return argv.map(formatShellToken).join(" "); +} + +function formatShellToken(token) { + return /^[A-Za-z0-9_./:@=-]+$/u.test(token) ? token : `'${token.replaceAll("'", "'\\''")}'`; +} + export function createPnpmManagedCommand(command, env = process.env) { const commandEnv = command.env ?? resolveLocalHeavyCheckEnv(env); if (isTruthyEnvFlag(commandEnv.CI) || isTruthyEnvFlag(commandEnv.GITHUB_ACTIONS)) { diff --git a/scripts/check-session-accessor-boundary.mjs b/scripts/check-session-accessor-boundary.mjs index 0bc11c4d24ac..b20959f5d5b5 100644 --- a/scripts/check-session-accessor-boundary.mjs +++ b/scripts/check-session-accessor-boundary.mjs @@ -61,6 +61,7 @@ const sessionStoreRuntimeFileBackedCompatNames = new Set([ "saveSessionStore", "updateSessionStore", ]); +const embeddedAgentSessionFileRuntimeNames = new Set(["resolveSessionFilePath"]); export const allowedSessionStoreRuntimeFileBackedCompatExports = new Set([ "loadSessionStore", @@ -107,6 +108,7 @@ export const migratedSessionAccessorFiles = new Set([ "src/gateway/sessions-history-http.ts", "src/gateway/session-utils.ts", "src/gateway/managed-image-attachments.ts", + "src/gateway/boot.ts", "src/gateway/server-methods/artifacts.ts", "src/gateway/server-methods/chat.ts", "src/gateway/sessions-resolve.ts", @@ -116,6 +118,7 @@ export const migratedSessionAccessorFiles = new Set([ "src/gateway/session-reset-service.ts", "src/infra/outbound/message-action-tts.ts", "src/agents/tools/embedded-gateway-stub.ts", + "src/agents/tools/session-status-tool.ts", "src/agents/tools/sessions-list-tool.ts", "src/plugins/host-hook-state.ts", "src/status/status-message.ts", @@ -123,10 +126,28 @@ export const migratedSessionAccessorFiles = new Set([ ]); export const migratedBundledPluginSessionAccessorFiles = new Set([ + "extensions/codex/src/conversation-binding.ts", + "extensions/discord/src/monitor/native-command-model-picker-ui.ts", "extensions/discord/src/monitor/native-command-model-picker-apply.ts", "extensions/discord/src/monitor/thread-session-close.ts", + "extensions/feishu/src/reasoning-preview.ts", + "extensions/memory-core/src/dreaming-phases.ts", "extensions/memory-core/src/dreaming-narrative.ts", + "extensions/mattermost/src/mattermost/model-picker.ts", + "extensions/matrix/src/matrix/monitor/handler.ts", + "extensions/matrix/src/session-route.ts", + "extensions/slack/src/monitor/slash.ts", + "extensions/telegram/src/bot-core.ts", "extensions/telegram/src/bot-handlers.runtime.ts", + "extensions/telegram/src/bot.ts", + "extensions/telegram/src/bot-message-dispatch.ts", + "extensions/telegram/src/bot-native-commands.ts", + "extensions/voice-call/src/response-generator.ts", + "extensions/whatsapp/src/auto-reply/monitor/group-activation.ts", +]); + +export const migratedEmbeddedAgentSessionTargetFiles = new Set([ + "extensions/voice-call/src/response-generator.ts", ]); export const migratedSessionAccessorWriteFiles = new Set([ @@ -141,6 +162,7 @@ export const migratedSessionAccessorWriteFiles = new Set([ "src/auto-reply/reply/abort.ts", "src/agents/subagent-control.ts", "src/agents/subagent-registry-helpers.ts", + "src/agents/tools/session-status-tool.ts", "src/auto-reply/reply/abort-cutoff.runtime.ts", "src/auto-reply/reply/agent-runner-cli-dispatch.ts", "src/auto-reply/reply/agent-runner-execution.ts", @@ -163,7 +185,9 @@ export const migratedSessionAccessorWriteFiles = new Set([ "src/auto-reply/reply/session-usage.ts", "src/commands/tasks.ts", "src/config/sessions/cleanup-service.ts", + "src/gateway/boot.ts", "src/gateway/server-node-events.ts", + "src/gateway/session-compaction-checkpoints.ts", "src/plugins/host-hook-cleanup.ts", "src/plugins/host-hook-state.ts", "src/tui/embedded-backend.ts", @@ -235,6 +259,13 @@ function propertyAccessName(expression) { return null; } +function propertyNameText(name) { + if (ts.isIdentifier(name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name)) { + return name.text; + } + return null; +} + function bindingName(node) { if (node.propertyName && ts.isIdentifier(node.propertyName)) { return node.propertyName.text; @@ -397,6 +428,51 @@ export function findSessionAccessorBoundaryViolations(content, fileName = "sourc return findNamedSessionStoreViolations(content, fileName, legacyNames, legacyKind); } +export function findEmbeddedAgentSessionTargetViolations(content, fileName = "source.ts") { + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); + const violations = findNamedBoundaryViolations( + content, + fileName, + embeddedAgentSessionFileRuntimeNames, + "legacy embedded-agent session file resolver", + ); + + const recordDeprecatedSessionFile = (name) => { + violations.push({ + line: toLine(sourceFile, name), + reason: + 'passes deprecated embedded-agent runtime identity field "sessionFile"; use sessionTarget', + }); + }; + + const visitRunOptions = (options) => { + for (const property of options.properties) { + if (ts.isPropertyAssignment(property) && propertyNameText(property.name) === "sessionFile") { + recordDeprecatedSessionFile(property.name); + } else if ( + ts.isShorthandPropertyAssignment(property) && + property.name.text === "sessionFile" + ) { + recordDeprecatedSessionFile(property.name); + } + } + }; + + const visit = (node) => { + if (ts.isCallExpression(node) && propertyAccessName(node.expression) === "runEmbeddedAgent") { + const options = unwrapExpression(node.arguments[0]); + if (options && ts.isObjectLiteralExpression(options)) { + visitRunOptions(options); + } + return; + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return violations; +} + export function findSessionAccessorWriteBoundaryViolations(content, fileName = "source.ts") { return findNamedSessionStoreViolations(content, fileName, legacyWriterNames, "writer"); } @@ -548,6 +624,7 @@ export async function main() { "extensions/discord/src/monitor", "extensions/memory-core/src", "extensions/telegram/src", + "extensions/voice-call/src", "src/acp", "src/agents", "src/auto-reply", @@ -639,6 +716,15 @@ export async function main() { ), findViolations: findMemoryHostSessionCorpusBoundaryViolations, }); + const embeddedAgentSessionTargetViolations = await collectFileViolations({ + repoRoot, + sourceRoots: resolveSourceRoots(repoRoot, ["extensions/voice-call/src"]), + skipFile: (filePath) => + !migratedEmbeddedAgentSessionTargetFiles.has( + normalizeRelativePath(path.relative(repoRoot, filePath)), + ), + findViolations: findEmbeddedAgentSessionTargetViolations, + }); const sessionStoreRuntimePath = path.join(repoRoot, "src/plugin-sdk/session-store-runtime.ts"); const sessionStoreRuntimeCompatViolations = findSessionStoreRuntimeFileBackedCompatExportViolations( @@ -655,6 +741,7 @@ export async function main() { ...manualCompactTrimViolations, ...lifecycleCleanupViolations, ...memoryHostSessionCorpusViolations, + ...embeddedAgentSessionTargetViolations, ...sessionStoreRuntimeCompatViolations, ]; diff --git a/scripts/docker/sandbox/Dockerfile.common b/scripts/docker/sandbox/Dockerfile.common index 39eaa3692b4a..789b8abc3e6b 100644 --- a/scripts/docker/sandbox/Dockerfile.common +++ b/scripts/docker/sandbox/Dockerfile.common @@ -7,7 +7,9 @@ USER root ENV DEBIAN_FRONTEND=noninteractive -ARG PACKAGES="curl wget jq coreutils grep nodejs npm python3 git ca-certificates golang-go rustc cargo unzip pkg-config libasound2-dev build-essential file" +ARG PACKAGES="curl wget jq coreutils grep python3 git ca-certificates golang-go rustc cargo unzip pkg-config libasound2-dev build-essential file" +ARG INSTALL_NODE=1 +ARG NODE_MAJOR=24 ARG INSTALL_PNPM=1 ARG INSTALL_BUN=1 ARG BUN_INSTALL_DIR=/opt/bun @@ -26,7 +28,18 @@ RUN --mount=type=cache,id=openclaw-sandbox-common-apt-cache,target=/var/cache/ap apt-get update \ && apt-get install -y --no-install-recommends ${PACKAGES} -RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi +RUN --mount=type=cache,id=openclaw-sandbox-common-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=openclaw-sandbox-common-apt-lists,target=/var/lib/apt,sharing=locked \ + if [ "${INSTALL_NODE}" = "1" ]; then \ + apt-get update; \ + apt-get install -y --no-install-recommends ca-certificates curl; \ + curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash -; \ + apt-get install -y --no-install-recommends nodejs; \ + node --version; \ + npm --version; \ + fi + +RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm && pnpm --version; fi RUN if [ "${INSTALL_BUN}" = "1" ]; then \ curl -fsSL https://bun.sh/install | bash; \ diff --git a/scripts/e2e/codex-on-demand-docker.sh b/scripts/e2e/codex-on-demand-docker.sh index ba69b3784980..6745cd02d289 100755 --- a/scripts/e2e/codex-on-demand-docker.sh +++ b/scripts/e2e/codex-on-demand-docker.sh @@ -13,6 +13,11 @@ HOST_BUILD="${OPENCLAW_CODEX_ON_DEMAND_HOST_BUILD:-1}" PACKAGE_TGZ="${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" run_log="" +# This lane installs the package and then exercises a managed npm install of Codex. +# Keep the package install budget above the shared default so slow npm hosts reach +# the Codex assertions instead of failing as a silent package-install timeout. +export OPENCLAW_E2E_NPM_INSTALL_TIMEOUT="${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-1200s}" + cleanup() { if [ -n "${PACKAGE_TGZ:-}" ]; then docker_e2e_cleanup_package_tgz "$PACKAGE_TGZ" diff --git a/scripts/e2e/commitments-safety-docker-client.ts b/scripts/e2e/commitments-safety-docker-client.ts index 7fb55a79ac11..d59543745c7e 100644 --- a/scripts/e2e/commitments-safety-docker-client.ts +++ b/scripts/e2e/commitments-safety-docker-client.ts @@ -24,18 +24,26 @@ function assert(condition: unknown, message: string): asserts condition { } } +function setEnvValue(key: string, value: string): void { + Reflect.set(process.env, key, value); +} + +function deleteEnvValue(key: string): void { + Reflect.deleteProperty(process.env, key); +} + async function withStateDir(name: string, fn: (stateDir: string) => Promise): Promise { const root = await fs.mkdtemp(path.join(os.tmpdir(), `openclaw-${name}-`)); const previousStateDir = process.env.OPENCLAW_STATE_DIR; try { - process.env.OPENCLAW_STATE_DIR = root; + setEnvValue("OPENCLAW_STATE_DIR", root); return await fn(root); } finally { resetCommitmentExtractionRuntimeForTests(); if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; + deleteEnvValue("OPENCLAW_STATE_DIR"); } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; + setEnvValue("OPENCLAW_STATE_DIR", previousStateDir); } await fs.rm(root, { recursive: true, force: true }); } diff --git a/scripts/e2e/crestodian-first-run-docker-client.ts b/scripts/e2e/crestodian-first-run-docker-client.ts index ba6d5007d875..05afaecd35e9 100644 --- a/scripts/e2e/crestodian-first-run-docker-client.ts +++ b/scripts/e2e/crestodian-first-run-docker-client.ts @@ -38,6 +38,10 @@ function assert(condition: unknown, message: string): asserts condition { } } +function setEnvValue(key: string, value: string): void { + Reflect.set(process.env, key, value); +} + function createRuntime(): { runtime: RuntimeEnv; lines: string[] } { const lines: string[] = []; return { @@ -71,8 +75,8 @@ async function main() { tempState.registerExitCleanup(); const stateDir = tempState.stateDir; const configPath = process.env.OPENCLAW_CONFIG_PATH ?? path.join(stateDir, "openclaw.json"); - process.env.OPENCLAW_STATE_DIR = stateDir; - process.env.OPENCLAW_CONFIG_PATH = configPath; + setEnvValue("OPENCLAW_STATE_DIR", stateDir); + setEnvValue("OPENCLAW_CONFIG_PATH", configPath); await fs.rm(stateDir, { recursive: true, force: true }); await fs.mkdir(stateDir, { recursive: true }); clearConfigCache(); @@ -104,7 +108,7 @@ async function main() { "fresh overview did not include setup recommendation", ); - process.env[spec.discordEnv] = spec.discordToken; + setEnvValue(spec.discordEnv, spec.discordToken); const commandVars = { defaultWorkspace: spec.dockerDefaultWorkspace, diff --git a/scripts/e2e/lib/clawhub-fixture-server.cjs b/scripts/e2e/lib/clawhub-fixture-server.cjs index 8b4adb359c60..bb2d08791660 100644 --- a/scripts/e2e/lib/clawhub-fixture-server.cjs +++ b/scripts/e2e/lib/clawhub-fixture-server.cjs @@ -403,6 +403,20 @@ async function main() { npmShasum: clawpack.npmShasum, }, }; + const securityDetail = { + package: artifactResolverDetail.package, + release: { + version: fixture.version, + }, + trust: { + scanStatus: "clean", + moderationState: null, + blockedFromDownload: false, + reasons: [], + pending: false, + stale: false, + }, + }; const server = http.createServer((request, response) => { const url = new URL(request.url, "http://127.0.0.1"); @@ -429,6 +443,13 @@ async function main() { json(response, artifactResolverDetail); return; } + if ( + url.pathname === + `/api/v1/packages/${encodeURIComponent(packageName)}/versions/${fixture.version}/security` + ) { + json(response, securityDetail); + return; + } if ( betaStatus !== undefined && url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/versions/beta` diff --git a/scripts/e2e/session-runtime-context-docker-client.ts b/scripts/e2e/session-runtime-context-docker-client.ts index 8de460bf6ec7..561c46625ee3 100644 --- a/scripts/e2e/session-runtime-context-docker-client.ts +++ b/scripts/e2e/session-runtime-context-docker-client.ts @@ -28,6 +28,10 @@ function assert(condition: unknown, message: string): asserts condition { } } +function setEnvValue(key: string, value: string): void { + Reflect.set(process.env, key, value); +} + async function readJsonl(filePath: string): Promise { const raw = await fs.readFile(filePath, "utf-8"); return raw @@ -235,9 +239,10 @@ async function verifyDoctorRepair(root: string) { async function main() { const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-runtime-context-")); - process.env.HOME = root; - process.env.OPENCLAW_STATE_DIR = path.join(root, ".openclaw"); - process.env.OPENCLAW_CONFIG_PATH = path.join(process.env.OPENCLAW_STATE_DIR, "openclaw.json"); + const stateDir = path.join(root, ".openclaw"); + setEnvValue("HOME", root); + setEnvValue("OPENCLAW_STATE_DIR", stateDir); + setEnvValue("OPENCLAW_CONFIG_PATH", path.join(stateDir, "openclaw.json")); try { await verifyRuntimeContextTranscriptShape(root); await verifyDoctorRepair(root); diff --git a/scripts/e2e/upgrade-survivor-docker.sh b/scripts/e2e/upgrade-survivor-docker.sh index a19090bb7794..7bd57f5c980b 100755 --- a/scripts/e2e/upgrade-survivor-docker.sh +++ b/scripts/e2e/upgrade-survivor-docker.sh @@ -25,10 +25,35 @@ PROBE_ATTEMPT_TIMEOUT_MS="$( PROBE_MAX_BODY_BYTES="$( openclaw_e2e_read_positive_int_env OPENCLAW_UPGRADE_SURVIVOR_PROBE_MAX_BODY_BYTES 1048576 )" -LANE_ARTIFACT_SUFFIX="${OPENCLAW_DOCKER_ALL_LANE_NAME:-default}" +ROOT_MANAGED_VPS="${OPENCLAW_UPGRADE_SURVIVOR_ROOT_MANAGED_VPS:-0}" + +resolve_lane_artifact_suffix() { + if [ -n "${OPENCLAW_DOCKER_ALL_LANE_NAME:-}" ]; then + printf "%s" "$OPENCLAW_DOCKER_ALL_LANE_NAME" + return + fi + + if [ "$ROOT_MANAGED_VPS" = "1" ]; then + printf "root-managed-vps-upgrade" + elif [ "$UPDATE_RESTART_MODE" = "auto-auth" ]; then + printf "update-restart-auth" + elif [ "${OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE:-0}" = "1" ]; then + printf "published-upgrade-survivor" + else + printf "upgrade-survivor" + fi + + if [ -n "${BASELINE_SPEC// }" ]; then + printf -- "-%s" "$BASELINE_SPEC" + fi + if [ "$SCENARIO" != "base" ]; then + printf -- "-%s" "$SCENARIO" + fi +} + +LANE_ARTIFACT_SUFFIX="$(resolve_lane_artifact_suffix)" LANE_ARTIFACT_SUFFIX="${LANE_ARTIFACT_SUFFIX//[^A-Za-z0-9_.-]/_}" ARTIFACT_DIR="${OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_DIR:-$ROOT_DIR/.artifacts/upgrade-survivor/$LANE_ARTIFACT_SUFFIX}" -ROOT_MANAGED_VPS="${OPENCLAW_UPGRADE_SURVIVOR_ROOT_MANAGED_VPS:-0}" DOCKER_RUN_USER_ARGS=() PROBE_ENV_ARGS=( -e OPENCLAW_UPGRADE_SURVIVOR_PROBE_TIMEOUT_MS="$PROBE_TIMEOUT_MS" diff --git a/scripts/lib/plain-gh.mjs b/scripts/lib/plain-gh.mjs index 23e65db9e462..0adc4503a934 100644 --- a/scripts/lib/plain-gh.mjs +++ b/scripts/lib/plain-gh.mjs @@ -3,6 +3,14 @@ import fs from "node:fs"; import path from "node:path"; export const PLAIN_GH_MAX_BUFFER_BYTES = 32 * 1024 * 1024; +export const PLAIN_GH_SYSTEM_CANDIDATES = [ + // Prefer package-manager opt paths: bin/gh may intentionally be an Octopool shim. + "/opt/homebrew/opt/gh/bin/gh", + "/usr/local/opt/gh/bin/gh", + "/home/linuxbrew/.linuxbrew/opt/gh/bin/gh", + "/opt/homebrew/bin/gh", + "/usr/local/bin/gh", +]; function isExecutable(filePath) { try { @@ -32,7 +40,10 @@ export function plainGhEnv(env = process.env) { return next; } -export function resolvePlainGhBin(env = process.env) { +export function resolvePlainGhBin( + env = process.env, + systemCandidates = PLAIN_GH_SYSTEM_CANDIDATES, +) { if (env.OPENCLAW_GH_BIN) { if (isExecutable(env.OPENCLAW_GH_BIN)) { return env.OPENCLAW_GH_BIN; @@ -40,7 +51,7 @@ export function resolvePlainGhBin(env = process.env) { throw new Error(`OPENCLAW_GH_BIN is not executable: ${env.OPENCLAW_GH_BIN}`); } - for (const candidate of ["/opt/homebrew/bin/gh", "/usr/local/bin/gh"]) { + for (const candidate of systemCandidates) { if (isExecutable(candidate)) { return candidate; } diff --git a/scripts/lib/plain-gh.sh b/scripts/lib/plain-gh.sh index 232ad760757e..f18742eb784f 100644 --- a/scripts/lib/plain-gh.sh +++ b/scripts/lib/plain-gh.sh @@ -24,12 +24,12 @@ resolve_plain_gh_bin() { fi local candidate - for candidate in /opt/homebrew/bin/gh /usr/local/bin/gh; do + while IFS= read -r candidate; do if [ -x "$candidate" ]; then printf '%s\n' "$candidate" return 0 fi - done + done < <(plain_gh_system_candidates) if candidate=$(PATH="$(plain_gh_search_path)" type -P gh 2>/dev/null); then printf '%s\n' "$candidate" @@ -39,6 +39,16 @@ resolve_plain_gh_bin() { type -P gh 2>/dev/null } +plain_gh_system_candidates() { + # bin/gh may intentionally be an Octopool shim; prefer package-manager opt paths. + printf '%s\n' \ + /opt/homebrew/opt/gh/bin/gh \ + /usr/local/opt/gh/bin/gh \ + /home/linuxbrew/.linuxbrew/opt/gh/bin/gh \ + /opt/homebrew/bin/gh \ + /usr/local/bin/gh +} + plain_gh_search_path() { local path_value="${PATH:-}" local home_bin="${HOME:-}/bin" diff --git a/scripts/lib/plugin-sdk-doc-metadata.ts b/scripts/lib/plugin-sdk-doc-metadata.ts index 49ab01c681cf..dace93beddd2 100644 --- a/scripts/lib/plugin-sdk-doc-metadata.ts +++ b/scripts/lib/plugin-sdk-doc-metadata.ts @@ -21,6 +21,9 @@ export const pluginSdkDocMetadata = { health: { category: "core", }, + sandbox: { + category: "runtime", + }, "approval-runtime": { category: "runtime", }, diff --git a/scripts/mcp-code-mode-gateway-e2e.ts b/scripts/mcp-code-mode-gateway-e2e.ts index 9d1f4039c9c7..167266f83604 100644 --- a/scripts/mcp-code-mode-gateway-e2e.ts +++ b/scripts/mcp-code-mode-gateway-e2e.ts @@ -11,6 +11,7 @@ import { stageQaMockAuthProfiles } from "../extensions/qa-lab/src/providers/shar import { buildQaGatewayConfig } from "../extensions/qa-lab/src/qa-gateway-config.js"; import { resetConfigRuntimeState } from "../src/config/config.js"; import { startGatewayServer } from "../src/gateway/server.js"; +import { deleteTestEnvValue, setTestEnvValue } from "../src/test-utils/env.js"; import { writeProbeMcpServer } from "./e2e/lib/mcp-code-mode-probe-server.ts"; import { type McpCodeModeMentions, @@ -87,9 +88,9 @@ async function readSessionLogMentions(stateDir: string): Promise', ` ${maturityLabelPill(value?.label ?? "Unscored")}`, ` ${markdownEscape(description)}`, + ...details.map((detail) => ` ${markdownEscape(detail)}`), " ", "", ]; @@ -546,11 +548,10 @@ function renderScoreBands(): string[] { "## Score bands", "", '
', - ...QA_MATURITY_SCORE_LABEL_BANDS.toReversed() - .map( - ([label, low, high]) => - `
${maturityLabelPill(label)}${low}-${high}%
`, - ), + ...QA_MATURITY_SCORE_LABEL_BANDS.toReversed().map( + ([label, low, high]) => + `
${maturityLabelPill(label)}${low}-${high}%
`, + ), "
", "", ]; @@ -645,6 +646,12 @@ function checkSetTitle(profile: string): string { function resultCountsText(statuses: StatusCounts): string { const parts = [`${statuses.pass} passed`]; + if (statuses.fail > 0) { + parts.push(`${statuses.fail} failed`); + } + if (statuses.blocked > 0) { + parts.push(`${statuses.blocked} blocked`); + } if (statuses.skipped > 0) { parts.push(`${statuses.skipped} skipped`); } @@ -747,7 +754,7 @@ function deriveCoverageScores( for (const report of coverageSummary.scorecard?.categoryReports ?? []) { categories.set( qaMaturityCoverageCategoryKey(report.surfaceId, report.name), - qaMaturityScoreObjectForScore(Math.round(report.features.fulfillmentPercent)), + qaMaturityScoreObjectForScore(Math.round(report.coverageIds.fulfillmentPercent)), ); } @@ -882,7 +889,7 @@ function renderEvidenceSection( ` ${markdownEscape(checkSetTitle(item.profile))}`, ` ${markdownEscape(item.generatedAt)}`, ` ${item.entryCount} checks - ${markdownEscape(resultCountsText(item.statuses))}`, - ` ${markdownEscape(countText(scorecard?.categories))} areas - ${markdownEscape(countText(scorecard?.features))} capabilities`, + ` ${markdownEscape(countText(scorecard?.categories))} areas - ${markdownEscape(countText(scorecard?.features))} features - ${markdownEscape(countText(scorecard?.coverageIds))} coverage IDs`, " ", ); } @@ -907,14 +914,11 @@ function renderEvidenceSection( ); for (const [surfaceId, rows] of grouped) { const surfaceName = surfaceNames.get(surfaceId) ?? familyTitle(surfaceId); - const statusCounts = rows.reduce>( - (counts, row) => { - counts[readinessStatusText(row.category.status)] = - (counts[readinessStatusText(row.category.status)] ?? 0) + 1; - return counts; - }, - {}, - ); + const statusCounts = rows.reduce>((counts, row) => { + counts[readinessStatusText(row.category.status)] = + (counts[readinessStatusText(row.category.status)] ?? 0) + 1; + return counts; + }, {}); const summary = Object.entries(statusCounts) .map(([status, count]) => `${count} ${status.toLowerCase()}`) .join(" / "); @@ -922,7 +926,7 @@ function renderEvidenceSection( ` `, `

${markdownEscape(summary)}

`, '
', - '
AreaCapabilitiesFollow-up
', + '
AreaFeatures / coverage IDsFollow-up
', ); for (const { item, category } of rows) { const status = readinessStatusText(category.status); @@ -932,7 +936,7 @@ function renderEvidenceSection( ` ${markdownEscape(category.name)}`, ` ${markdownEscape(status)} - ${markdownEscape(checkSetTitle(item.profile))}`, "
", - ` ${markdownEscape(countText(category.features))}`, + ` ${markdownEscape(countText(category.features))} / ${markdownEscape(countText(category.coverageIds))}`, ` ${markdownEscape(followUpText(category.missingCoverageIds))}`, " ", ); @@ -962,6 +966,7 @@ function renderMaturityScorecard({ const surfaceAverage = coverage.rollups.surface_average; const qualityAverage = scores.rollups.surface_average.quality; const completenessAverage = scores.rollups.surface_average.completeness; + const maturityAverage = averageScores([qualityAverage, completenessAverage]); const lines = [ ...frontmatter( "Maturity scorecard", @@ -983,18 +988,17 @@ function renderMaturityScorecard({ "## At a glance", "", '
', - ...indentMarkdown(scoreSummary("Coverage", surfaceAverage, "QA profile evidence"), 2), ...indentMarkdown( - scoreSummary("Quality", qualityAverage, "Reliability and operator confidence"), - 2, - ), - ...indentMarkdown( - scoreSummary("Completeness", completenessAverage, "Expected workflow coverage"), + scoreSummary("Maturity score", maturityAverage, "Quality + completeness", [ + `Coverage ${scoreLabel(surfaceAverage)}`, + `Quality ${scoreLabel(qualityAverage)}`, + `Completeness ${scoreLabel(completenessAverage)}`, + ]), 2, ), "
", "", - 'Coverage is deliberately evidence-led: an area does not become "ready" just because the implementation exists.', + 'Coverage is deliberately evidence-led: an area does not become "ready" just because the implementation exists. It is not an input to the maturity score, but OpenClaw aims to keep end-to-end coverage above 90% for mature Stable-or-better features over time.', "", ...renderScoreBands(), ]; diff --git a/scripts/qa/ux-matrix-evidence-producer.ts b/scripts/qa/ux-matrix-evidence-producer.ts index f71e3879e14c..4730f77e6889 100644 --- a/scripts/qa/ux-matrix-evidence-producer.ts +++ b/scripts/qa/ux-matrix-evidence-producer.ts @@ -10,6 +10,7 @@ import { QA_EVIDENCE_FILENAME, QA_EVIDENCE_SUMMARY_KIND, QA_EVIDENCE_SUMMARY_SCHEMA_VERSION, + resolveQaEvidenceEnvironment, validateQaEvidenceSummaryJson, type QaEvidenceStatus, type QaEvidenceSummaryEntry, @@ -174,15 +175,15 @@ function sanitizeArtifactText( function buildExecution(params: { artifacts: MatrixCell["artifacts"]; + repoRoot: string; source: string; }): QaEvidenceSummaryEntry["execution"] { return { runner: "ux-matrix-script-producer", - environment: { - ref: process.env.OPENCLAW_QA_REF?.trim() || process.env.GITHUB_SHA?.trim() || null, - os: process.platform, - nodeVersion: process.version, - }, + environment: resolveQaEvidenceEnvironment({ + env: process.env, + repoRoot: params.repoRoot, + }), provider: { id: "ux-matrix", live: false, @@ -202,7 +203,7 @@ function buildExecution(params: { }; } -function buildEvidenceEntry(cell: MatrixCell): QaEvidenceSummaryEntry { +function buildEvidenceEntry(cell: MatrixCell, repoRoot: string): QaEvidenceSummaryEntry { const source = `ux-matrix:${cell.surface}:${cell.stage}`; return { test: { @@ -221,6 +222,7 @@ function buildEvidenceEntry(cell: MatrixCell): QaEvidenceSummaryEntry { ], execution: buildExecution({ artifacts: cell.artifacts, + repoRoot, source, }), result: { @@ -243,13 +245,14 @@ function buildEvidenceEntry(cell: MatrixCell): QaEvidenceSummaryEntry { function buildEvidenceSummary(params: { cells: readonly MatrixCell[]; generatedAt: string; + repoRoot: string; }): QaEvidenceSummaryJson { return validateQaEvidenceSummaryJson({ kind: QA_EVIDENCE_SUMMARY_KIND, schemaVersion: QA_EVIDENCE_SUMMARY_SCHEMA_VERSION, generatedAt: params.generatedAt, evidenceMode: "full", - entries: params.cells.map(buildEvidenceEntry), + entries: params.cells.map((cell) => buildEvidenceEntry(cell, params.repoRoot)), }); } @@ -693,6 +696,7 @@ export async function runUxMatrixEvidenceProducer(options: ProducerOptions) { const previewEvidence = buildEvidenceSummary({ cells: initialCells, generatedAt: new Date().toISOString(), + repoRoot: options.repoRoot, }); const screenshotLog = await fs.readFile(path.join(screenshotCellDir, "logs.txt"), "utf8"); await writeProducerArtifactFixtureHtml({ @@ -753,7 +757,11 @@ export async function runUxMatrixEvidenceProducer(options: ProducerOptions) { ...initialCells, ]; - const evidence = buildEvidenceSummary({ cells, generatedAt: new Date().toISOString() }); + const evidence = buildEvidenceSummary({ + cells, + generatedAt: new Date().toISOString(), + repoRoot: options.repoRoot, + }); await writeProducerArtifactFixtureHtml({ artifactBase: options.artifactBase, evidence, diff --git a/scripts/sandbox-common-setup.sh b/scripts/sandbox-common-setup.sh index 7d0655ba6809..be653c3606fc 100755 --- a/scripts/sandbox-common-setup.sh +++ b/scripts/sandbox-common-setup.sh @@ -6,7 +6,9 @@ source "$ROOT_DIR/scripts/lib/docker-build.sh" BASE_IMAGE="${BASE_IMAGE:-openclaw-sandbox:bookworm-slim}" TARGET_IMAGE="${TARGET_IMAGE:-openclaw-sandbox-common:bookworm-slim}" -PACKAGES="${PACKAGES:-curl wget jq coreutils grep nodejs npm python3 git ca-certificates golang-go rustc cargo unzip pkg-config libasound2-dev build-essential file}" +PACKAGES="${PACKAGES:-curl wget jq coreutils grep python3 git ca-certificates golang-go rustc cargo unzip pkg-config libasound2-dev build-essential file}" +INSTALL_NODE="${INSTALL_NODE:-1}" +NODE_MAJOR="${NODE_MAJOR:-24}" INSTALL_PNPM="${INSTALL_PNPM:-1}" INSTALL_BUN="${INSTALL_BUN:-1}" BUN_INSTALL_DIR="${BUN_INSTALL_DIR:-/opt/bun}" @@ -30,6 +32,8 @@ docker_build_exec \ -f "$ROOT_DIR/scripts/docker/sandbox/Dockerfile.common" \ --build-arg BASE_IMAGE="${BASE_IMAGE}" \ --build-arg PACKAGES="${PACKAGES}" \ + --build-arg INSTALL_NODE="${INSTALL_NODE}" \ + --build-arg NODE_MAJOR="${NODE_MAJOR}" \ --build-arg INSTALL_PNPM="${INSTALL_PNPM}" \ --build-arg INSTALL_BUN="${INSTALL_BUN}" \ --build-arg BUN_INSTALL_DIR="${BUN_INSTALL_DIR}" \ diff --git a/src/acp/commands.ts b/src/acp/commands.ts index 497f1139922d..774b936db459 100644 --- a/src/acp/commands.ts +++ b/src/acp/commands.ts @@ -16,7 +16,7 @@ const BASE_AVAILABLE_COMMANDS: AvailableCommand[] = [ { name: "subagents", description: "List or manage sub-agents." }, { name: "config", description: "Read or write config (owner-only)." }, { name: "debug", description: "Set runtime-only overrides (owner-only)." }, - { name: "usage", description: "Toggle usage footer (off|tokens|full)." }, + { name: "usage", description: "Toggle usage footer (off|tokens|full|reset). 'reset'/'inherit'/'clear'/'default' clears the session override to re-inherit the configured default." }, { name: "stop", description: "Stop the current run." }, { name: "restart", description: "Restart the gateway (if enabled)." }, { name: "activation", description: "Set group activation (mention|always)." }, diff --git a/src/acp/translator.presentation.ts b/src/acp/translator.presentation.ts index 212fb823eb7d..f946a1ce34fd 100644 --- a/src/acp/translator.presentation.ts +++ b/src/acp/translator.presentation.ts @@ -221,9 +221,9 @@ export function buildSessionPresentation(params: { id: ACP_RESPONSE_USAGE_CONFIG_ID, name: "Usage detail", description: - "Controls how much usage information OpenClaw attaches to responses for the session.", - currentValue: normalizeOptionalString(row.responseUsage) || "off", - values: ["off", "tokens", "full"], + "Controls how much usage information OpenClaw attaches to responses for the session. 'inherit' follows the configured default; 'off' explicitly disables it for this session.", + currentValue: normalizeOptionalString(row.responseUsage) || "inherit", + values: ["inherit", "off", "tokens", "full"], }), buildSelectConfigOption({ id: ACP_ELEVATED_LEVEL_CONFIG_ID, diff --git a/src/acp/translator.session-config.test.ts b/src/acp/translator.session-config.test.ts index 907d3efc36df..0092b4edc3eb 100644 --- a/src/acp/translator.session-config.test.ts +++ b/src/acp/translator.session-config.test.ts @@ -358,4 +358,106 @@ describe("acp setSessionConfigOption bridge behavior", () => { sessionStore.clearAllSessionsForTest(); }); + + it('maps response_usage "inherit" selection to sessions.patch with responseUsage: null', async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const request = vi.fn(async (method: string, _params?: unknown) => { + if (method === "sessions.list") { + return { + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: { modelProvider: null, model: null, contextTokens: null }, + sessions: [ + { + key: "usage-inherit-session", + kind: "direct", + updatedAt: Date.now(), + thinkingLevel: "minimal", + modelProvider: "openai", + model: "gpt-5.4", + responseUsage: "tokens", + }, + ], + }; + } + if (method === "sessions.patch") { + expect(requireRecord(_params, "sessions.patch params")).toMatchObject({ + key: "usage-inherit-session", + responseUsage: null, + }); + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + await agent.loadSession(createLoadSessionRequest("usage-inherit-session")); + + const result = await agent.setSessionConfigOption( + createSetSessionConfigOptionRequest("usage-inherit-session", "response_usage", "inherit"), + ); + + // After selecting "inherit", the ACP config option should report "inherit" (unset). + expectConfigOption(result.configOptions, "response_usage", { currentValue: "inherit" }); + expect( + (request as unknown as MockCallSource).mock.calls.some( + ([method]) => method === "sessions.patch", + ), + ).toBe(true); + + sessionStore.clearAllSessionsForTest(); + }); + + it('maps response_usage "off" selection to sessions.patch with responseUsage: "off"', async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const request = vi.fn(async (method: string, _params?: unknown) => { + if (method === "sessions.list") { + return { + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: { modelProvider: null, model: null, contextTokens: null }, + sessions: [ + { + key: "usage-off-session", + kind: "direct", + updatedAt: Date.now(), + thinkingLevel: "minimal", + modelProvider: "openai", + model: "gpt-5.4", + }, + ], + }; + } + if (method === "sessions.patch") { + expect(requireRecord(_params, "sessions.patch params")).toMatchObject({ + key: "usage-off-session", + responseUsage: "off", + }); + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + await agent.loadSession(createLoadSessionRequest("usage-off-session")); + + const result = await agent.setSessionConfigOption( + createSetSessionConfigOptionRequest("usage-off-session", "response_usage", "off"), + ); + + expectConfigOption(result.configOptions, "response_usage", { currentValue: "off" }); + expect( + (request as unknown as MockCallSource).mock.calls.some( + ([method]) => method === "sessions.patch", + ), + ).toBe(true); + + sessionStore.clearAllSessionsForTest(); + }); }); diff --git a/src/acp/translator.session-setup.test.ts b/src/acp/translator.session-setup.test.ts index 1eb62a3265e2..76d2321c3937 100644 --- a/src/acp/translator.session-setup.test.ts +++ b/src/acp/translator.session-setup.test.ts @@ -98,7 +98,8 @@ describe("acp session UX bridge behavior", () => { }); expectConfigOption(result.configOptions, "verbose_level", { currentValue: "off" }); expectConfigOption(result.configOptions, "reasoning_level", { currentValue: "off" }); - expectConfigOption(result.configOptions, "response_usage", { currentValue: "off" }); + // Unset session inherits the configured default → control reads "inherit", not "off". + expectConfigOption(result.configOptions, "response_usage", { currentValue: "inherit" }); expectConfigOption(result.configOptions, "elevated_level", { currentValue: "off" }); sessionStore.clearAllSessionsForTest(); diff --git a/src/acp/translator.ts b/src/acp/translator.ts index 1a8de32d1dd7..76c948b41818 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -801,8 +801,7 @@ export class AcpGatewayAgent implements Agent { const promptKey = this.pendingPromptKey(params.sessionId, runId); if ( isGatewayCloseError(err) && - (this.getPendingPrompt(params.sessionId, runId) || - this.settlingPromptKeys.has(promptKey)) + (this.getPendingPrompt(params.sessionId, runId) || this.settlingPromptKeys.has(promptKey)) ) { return; } @@ -1592,7 +1591,7 @@ export class AcpGatewayAgent implements Agent { value: string | boolean, ): { overrides: Partial; - patch?: Record; + patch?: Record; } { if (typeof value !== "string") { throw new Error( @@ -1630,11 +1629,13 @@ export class AcpGatewayAgent implements Agent { patch: { reasoningLevel: value }, overrides: { reasoningLevel: value }, }; - case ACP_RESPONSE_USAGE_CONFIG_ID: + case ACP_RESPONSE_USAGE_CONFIG_ID: { + const next = value === "inherit" ? null : value; return { - patch: { responseUsage: value }, - overrides: { responseUsage: value as GatewaySessionPresentationRow["responseUsage"] }, + patch: { responseUsage: next }, + overrides: { responseUsage: next as GatewaySessionPresentationRow["responseUsage"] }, }; + } case ACP_ELEVATED_LEVEL_CONFIG_ID: return { patch: { elevatedLevel: value }, diff --git a/src/agents/agent-command.ingress-diagnostics.test.ts b/src/agents/agent-command.ingress-diagnostics.test.ts new file mode 100644 index 000000000000..558efee933a8 --- /dev/null +++ b/src/agents/agent-command.ingress-diagnostics.test.ts @@ -0,0 +1,301 @@ +/** + * Tests for ingress model.usage diagnostic emission in agentCommandFromIngress. + * + * Covers: + * - ingressDiagnosticChannel channel label resolution + * - emitIngressModelUsageDiagnostic with diagnostics enabled + valid usage + * - emitIngressModelUsageDiagnostic with diagnostics disabled + * - emitIngressModelUsageDiagnostic with null/missing usage + */ + +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + emitTrustedDiagnosticEvent: vi.fn(), + isDiagnosticsEnabled: vi.fn(), + getRuntimeConfig: vi.fn(), + hasNonzeroUsage: vi.fn(), + resolveModelCostConfig: vi.fn(), + estimateUsageCost: vi.fn(), +})); + +vi.mock("../infra/diagnostic-events.js", async () => { + const actual = await vi.importActual( + "../infra/diagnostic-events.js", + ); + return { + ...actual, + emitTrustedDiagnosticEvent: mocks.emitTrustedDiagnosticEvent, + isDiagnosticsEnabled: mocks.isDiagnosticsEnabled, + }; +}); + +vi.mock("../utils/usage-format.js", () => ({ + resolveModelCostConfig: (...args: Array) => mocks.resolveModelCostConfig(...args), + estimateUsageCost: (...args: Array) => mocks.estimateUsageCost(...args), +})); + +vi.mock("./usage.js", () => ({ + hasNonzeroUsage: (usage: unknown) => mocks.hasNonzeroUsage(usage), +})); + +vi.mock("../config/io.js", () => ({ + getRuntimeConfig: () => mocks.getRuntimeConfig(), +})); + +let testing: typeof import("./agent-command.js").testing; + +beforeAll(async () => { + const mod = await import("./agent-command.js"); + testing = mod.testing; +}); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.isDiagnosticsEnabled.mockReturnValue(true); + mocks.hasNonzeroUsage.mockReturnValue(true); + mocks.getRuntimeConfig.mockReturnValue({}); + mocks.resolveModelCostConfig.mockReturnValue({}); + mocks.estimateUsageCost.mockReturnValue(0.001); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +function makeResult(overrides?: Record) { + return { + payloads: [{ text: "hello", mediaUrl: "" }], + meta: { + durationMs: 1234, + aborted: false, + stopReason: "end_turn", + agentMeta: { + provider: "openai", + model: "gpt-5.5", + sessionId: "sess-abc", + usage: { + input: 500, + output: 200, + cacheRead: 50, + cacheWrite: 25, + total: 775, + }, + contextTokens: 128000, + promptTokens: 1200, + lastCallUsage: { input: 500, output: 200 }, + ...(overrides?.agentMeta as Record | undefined), + }, + ...(overrides?.meta as Record | undefined), + }, + ...overrides, + }; +} + +function makeOpts(overrides?: Record) { + return { + message: "hello", + sessionKey: "agent:main:main", + agentId: "main", + allowModelOverride: false, + messageChannel: "api", + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// ingressDiagnosticChannel +// --------------------------------------------------------------------------- +describe("ingressDiagnosticChannel", () => { + it("returns runContext.messageChannel when set", () => { + const channel = testing.ingressDiagnosticChannel({ + message: "hi", + allowModelOverride: false, + runContext: { messageChannel: "discord" }, + messageChannel: "api", + channel: "http", + }); + expect(channel).toBe("discord"); + }); + + it("falls back to opts.messageChannel", () => { + const channel = testing.ingressDiagnosticChannel({ + message: "hi", + allowModelOverride: false, + messageChannel: "api", + channel: "http", + }); + expect(channel).toBe("api"); + }); + + it("falls back to opts.channel", () => { + const channel = testing.ingressDiagnosticChannel({ + message: "hi", + allowModelOverride: false, + channel: "webchat", + }); + expect(channel).toBe("webchat"); + }); + + it('defaults to "http" when no channel info is present', () => { + const channel = testing.ingressDiagnosticChannel({ + message: "hi", + allowModelOverride: false, + }); + expect(channel).toBe("http"); + }); +}); + +// --------------------------------------------------------------------------- +// emitIngressModelUsageDiagnostic +// --------------------------------------------------------------------------- +describe("emitIngressModelUsageDiagnostic", () => { + it("emits model.usage when diagnostics are enabled and result has usage", () => { + const result = makeResult(); + const opts = makeOpts(); + + testing.emitIngressModelUsageDiagnostic(result, opts); + + expect(mocks.emitTrustedDiagnosticEvent).toHaveBeenCalledTimes(1); + const event = mocks.emitTrustedDiagnosticEvent.mock.calls[0]?.[0]; + expect(event).toMatchObject({ + type: "model.usage", + sessionKey: "agent:main:main", + sessionId: "sess-abc", + channel: "api", + agentId: "main", + provider: "openai", + model: "gpt-5.5", + usage: { + input: 500, + output: 200, + cacheRead: 50, + cacheWrite: 25, + promptTokens: 575, + total: 775, + }, + durationMs: 1234, + }); + }); + + it("does not emit when diagnostics are disabled", () => { + mocks.isDiagnosticsEnabled.mockReturnValue(false); + const result = makeResult(); + const opts = makeOpts(); + + testing.emitIngressModelUsageDiagnostic(result, opts); + + expect(mocks.emitTrustedDiagnosticEvent).not.toHaveBeenCalled(); + }); + + it("does not emit when agentMeta is missing", () => { + const result = makeResult({ + meta: { durationMs: 100, aborted: false, stopReason: "end_turn" }, + }); + // result.meta.agentMeta is undefined + (result as Record).meta = { durationMs: 100 }; + + const opts = makeOpts(); + + testing.emitIngressModelUsageDiagnostic(result, opts); + + expect(mocks.emitTrustedDiagnosticEvent).not.toHaveBeenCalled(); + }); + + it("does not emit when usage is zero", () => { + mocks.hasNonzeroUsage.mockReturnValue(false); + const result = makeResult(); + const opts = makeOpts(); + + testing.emitIngressModelUsageDiagnostic(result, opts); + + expect(mocks.emitTrustedDiagnosticEvent).not.toHaveBeenCalled(); + }); + + it("resolves channel from runContext when available", () => { + const result = makeResult(); + const opts = makeOpts({ + runContext: { messageChannel: "discord" }, + messageChannel: "api", + }); + + testing.emitIngressModelUsageDiagnostic(result, opts); + + expect(mocks.emitTrustedDiagnosticEvent).toHaveBeenCalledTimes(1); + const event = mocks.emitTrustedDiagnosticEvent.mock.calls[0]?.[0]; + expect(event.channel).toBe("discord"); + }); + + it('defaults channel to "http" when no channel info is present', () => { + const result = makeResult(); + const opts = { message: "hi", allowModelOverride: false }; + + testing.emitIngressModelUsageDiagnostic(result, opts); + + expect(mocks.emitTrustedDiagnosticEvent).toHaveBeenCalledTimes(1); + const event = mocks.emitTrustedDiagnosticEvent.mock.calls[0]?.[0]; + expect(event.channel).toBe("http"); + }); + + it("computes cost when billable usage buckets are present", () => { + const result = makeResult(); + const opts = makeOpts(); + + testing.emitIngressModelUsageDiagnostic(result, opts); + + expect(mocks.resolveModelCostConfig).toHaveBeenCalledWith({ + provider: "openai", + model: "gpt-5.5", + config: expect.any(Object) as unknown, + }); + expect(mocks.estimateUsageCost).toHaveBeenCalled(); + expect(mocks.emitTrustedDiagnosticEvent).toHaveBeenCalledTimes(1); + const event = mocks.emitTrustedDiagnosticEvent.mock.calls[0]?.[0]; + expect(event.costUsd).toBe(0.001); + }); + + it("handles missing optional usage fields gracefully", () => { + const result = makeResult({ + agentMeta: { + provider: "openai", + model: "gpt-5.5", + sessionId: "sess-min", + usage: { input: 100, output: 50 }, + }, + }); + const opts = makeOpts(); + + testing.emitIngressModelUsageDiagnostic(result, opts); + + expect(mocks.emitTrustedDiagnosticEvent).toHaveBeenCalledTimes(1); + const event = mocks.emitTrustedDiagnosticEvent.mock.calls[0]?.[0]; + expect(event.usage).toMatchObject({ + input: 100, + output: 50, + cacheRead: 0, + cacheWrite: 0, + promptTokens: 100, + total: 150, + }); + }); + + it("omits context.used when promptTokens is undefined", () => { + const result = makeResult({ + agentMeta: { + promptTokens: undefined, + provider: "openai", + model: "gpt-5.5", + sessionId: "sess-no-prompt", + usage: { input: 10, output: 5 }, + contextTokens: 128000, + }, + }); + const opts = makeOpts(); + + testing.emitIngressModelUsageDiagnostic(result, opts); + + expect(mocks.emitTrustedDiagnosticEvent).toHaveBeenCalledTimes(1); + const event = mocks.emitTrustedDiagnosticEvent.mock.calls[0]?.[0]; + expect(event.context).toEqual({ limit: 128000 }); + }); +}); diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index c2ddd5e3e063..a8e46c14f8d8 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -26,6 +26,7 @@ import { registerAgentRunContext, withAgentRunLifecycleGeneration, } from "../infra/agent-events.js"; +import { isDiagnosticsEnabled, emitTrustedDiagnosticEvent } from "../infra/diagnostic-events.js"; import { formatErrorMessage } from "../infra/errors.js"; import { resolveAgentDeliveryPlan, @@ -67,6 +68,7 @@ import { isDeliverableMessageChannel, resolveMessageChannel, } from "../utils/message-channel.js"; +import { estimateUsageCost, resolveModelCostConfig } from "../utils/usage-format.js"; import { resolveAgentRuntimeConfig } from "./agent-runtime-config.js"; import { clearAutoFallbackPrimaryProbeSelection, @@ -137,6 +139,7 @@ import { } from "./run-termination.js"; import { normalizeSpawnedRunMetadata } from "./spawned-context.js"; import { resolveAgentTimeoutMs } from "./timeout.js"; +import { hasNonzeroUsage } from "./usage.js"; import { ensureAgentWorkspace } from "./workspace.js"; const log = createSubsystemLogger("agents/agent-command"); @@ -2442,6 +2445,83 @@ export async function agentCommand( ); } +/** Resolve the channel label for model.usage diagnostics from ingress run options. */ +function ingressDiagnosticChannel(opts: AgentCommandIngressOpts): string { + return opts.runContext?.messageChannel ?? opts.messageChannel ?? opts.channel ?? "http"; +} + +/** + * Emit a model.usage diagnostic event after an ingress agent run completes. + * + * Unlike channel/cron paths which emit model.usage in runReplyAgent / + * finalizeCronRun, the ingress path has no such existing emission — without + * this every diagnostics consumer (Langfuse bridge, @openclaw/diagnostics-otel, + * diagnostics-prometheus) sees usage/cost only for webchat/cli/cron turns + * and is blind to HTTP API traffic (POST /v1/responses, POST /v1/chat/completions, + * and node-event dispatch). + */ +function emitIngressModelUsageDiagnostic( + result: NonNullable>>, + opts: AgentCommandIngressOpts, +): void { + const cfg = getRuntimeConfig(); + if (!isDiagnosticsEnabled(cfg)) { + return; + } + const agentMeta = result.meta?.agentMeta; + const usage = agentMeta?.usage; + if (!agentMeta || !hasNonzeroUsage(usage)) { + return; + } + + const providerUsed = agentMeta.provider ?? ""; + const modelUsed = agentMeta.model ?? ""; + const input = usage.input ?? 0; + const output = usage.output ?? 0; + const cacheRead = usage.cacheRead ?? 0; + const cacheWrite = usage.cacheWrite ?? 0; + const usagePromptTokens = input + cacheRead + cacheWrite; + const totalTokens = usage.total ?? usagePromptTokens + output; + const hasBillableUsageBuckets = + usage.input !== undefined || + usage.output !== undefined || + usage.cacheRead !== undefined || + usage.cacheWrite !== undefined; + const costConfig = resolveModelCostConfig({ + provider: providerUsed, + model: modelUsed, + config: cfg, + }); + const costUsd = hasBillableUsageBuckets + ? estimateUsageCost({ usage, cost: costConfig }) + : undefined; + + emitTrustedDiagnosticEvent({ + type: "model.usage", + sessionKey: opts.sessionKey, + sessionId: agentMeta.sessionId, + channel: ingressDiagnosticChannel(opts), + agentId: opts.agentId, + provider: providerUsed, + model: modelUsed, + usage: { + input, + output, + cacheRead, + cacheWrite, + promptTokens: usagePromptTokens, + total: totalTokens, + }, + lastCallUsage: agentMeta.lastCallUsage, + context: { + limit: agentMeta.contextTokens, + ...(agentMeta.promptTokens !== undefined ? { used: agentMeta.promptTokens } : {}), + }, + costUsd, + durationMs: result.meta?.durationMs, + }); +} + /** Runs an agent turn from an inbound channel/gateway ingress context. */ export async function agentCommandFromIngress( opts: AgentCommandIngressOpts, @@ -2453,8 +2533,8 @@ export async function agentCommandFromIngress( } const lifecycleGeneration = opts.lifecycleGeneration ?? captureAgentRunLifecycleGeneration(opts.runId ?? ""); - return await withAgentRunLifecycleGeneration(lifecycleGeneration, () => - agentCommandInternal( + return await withAgentRunLifecycleGeneration(lifecycleGeneration, async () => { + const result = await agentCommandInternal( { ...opts, lifecycleGeneration, @@ -2462,14 +2542,22 @@ export async function agentCommandFromIngress( }, runtime, deps, - ), - ); + ); + + if (result) { + emitIngressModelUsageDiagnostic(result, opts); + } + + return result; + }); } export const testing = { resolveAgentRuntimeConfig, prepareAgentCommandExecution, resolveExplicitAgentCommandSessionKey, + ingressDiagnosticChannel, + emitIngressModelUsageDiagnostic, }; /** @deprecated Use `testing`. */ diff --git a/src/agents/agent-tool-definition-adapter.test.ts b/src/agents/agent-tool-definition-adapter.test.ts index f51b73a77acf..3aa0b3bda156 100644 --- a/src/agents/agent-tool-definition-adapter.test.ts +++ b/src/agents/agent-tool-definition-adapter.test.ts @@ -3,6 +3,8 @@ * Exercises result coercion, error wrapping, client delegation, and conflict * detection at the ToolDefinition boundary. */ +import os from "node:os"; +import path from "node:path"; import type { AgentTool } from "openclaw/plugin-sdk/agent-core"; import { Type } from "typebox"; import { describe, expect, it, vi } from "vitest"; @@ -14,6 +16,7 @@ import { toToolDefinitions, } from "./agent-tool-definition-adapter.js"; import { wrapToolWithBeforeToolCallHook } from "./agent-tools.before-tool-call.js"; +import { createExecTool } from "./bash-tools.exec.js"; import type { ClientToolDefinition } from "./embedded-agent-runner/run/params.js"; type ToolExecute = ReturnType[number]["execute"]; @@ -93,6 +96,186 @@ describe("agent tool definition adapter", () => { expect(details?.error).toBe("nope"); }); + it("preserves exec deny before prepared workdir failures", async () => { + const tool = createExecTool({ + security: "deny", + ask: "off", + }); + const [definition] = toToolDefinitions([tool]); + const missingWorkdir = path.join(os.tmpdir(), `openclaw-missing-denied-cwd-${Date.now()}`); + + const existing = await definition.execute( + "call-denied-existing-cwd", + { + command: "echo denied", + workdir: process.cwd(), + }, + undefined, + undefined, + extensionContext, + ); + const missing = await definition.execute( + "call-denied-missing-cwd", + { + command: "echo denied", + workdir: missingWorkdir, + }, + undefined, + undefined, + extensionContext, + ); + + const expected = { + status: "error", + error: "exec denied: host=gateway security=deny", + }; + expect(existing.details).toMatchObject(expected); + expect(missing.details).toMatchObject(expected); + expect(JSON.stringify(missing)).not.toContain("unavailable or not a directory"); + }); + + it("does not validate backend sandbox workdirs before exec deny", async () => { + const validateWorkdir = vi.fn(async (workdir: string) => workdir); + const tool = createExecTool({ + host: "sandbox", + security: "deny", + ask: "off", + sandbox: { + containerName: "remote-sandbox-workdir-test", + workspaceDir: process.cwd(), + containerWorkdir: "/remote/workspace", + workdirValidation: "backend", + validateWorkdir, + }, + }); + const [definition] = toToolDefinitions([tool]); + + const result = await definition.execute( + "call-denied-backend-cwd", + { + command: "echo denied", + workdir: "/remote/workspace/generated", + }, + undefined, + undefined, + extensionContext, + ); + + expect(result.details).toMatchObject({ + status: "error", + error: "exec denied: host=sandbox security=deny", + }); + expect(validateWorkdir).not.toHaveBeenCalled(); + }); + + it("does not throw WeakMap errors when preparing malformed exec params", async () => { + const tool = createExecTool({ + security: "full", + ask: "off", + }); + const [definition] = toToolDefinitions([tool]); + + const result = await definition.execute( + "call-malformed-exec-params", + "not-an-object", + undefined, + undefined, + extensionContext, + ); + + expect(result.details).toMatchObject({ + status: "error", + error: "Provide a command to start.", + }); + }); + + it("does not throw WeakMap errors when preparing malformed backend sandbox exec params", async () => { + const validateWorkdir = vi.fn(async (workdir: string) => workdir); + const tool = createExecTool({ + host: "sandbox", + security: "full", + ask: "off", + sandbox: { + containerName: "remote-sandbox-workdir-test", + workspaceDir: process.cwd(), + containerWorkdir: "/remote/workspace", + workdirValidation: "backend", + validateWorkdir, + }, + }); + const [definition] = toToolDefinitions([tool]); + + const result = await definition.execute( + "call-malformed-backend-sandbox-exec-params", + "not-an-object", + undefined, + undefined, + extensionContext, + ); + + expect(result.details).toMatchObject({ + status: "error", + error: "Provide a command to start.", + }); + expect(JSON.stringify(result)).not.toContain("WeakMap"); + expect(validateWorkdir).not.toHaveBeenCalled(); + }); + + it("reports malformed exec params when elevated logging is enabled", async () => { + const tool = createExecTool({ + security: "full", + ask: "off", + elevated: { enabled: true, allowed: true, defaultLevel: "on" }, + }); + const [definition] = toToolDefinitions([tool]); + + const result = await definition.execute( + "call-malformed-elevated-exec-params", + {}, + undefined, + undefined, + extensionContext, + ); + + expect(result.details).toMatchObject({ + status: "error", + error: "Provide a command to start.", + }); + }); + + it("does not validate backend sandbox workdirs before malformed exec params fail", async () => { + const validateWorkdir = vi.fn(async (workdir: string) => workdir); + const tool = createExecTool({ + host: "sandbox", + security: "full", + ask: "off", + sandbox: { + containerName: "remote-sandbox-workdir-test", + workspaceDir: process.cwd(), + containerWorkdir: "/remote/workspace", + workdirValidation: "backend", + validateWorkdir, + }, + }); + const [definition] = toToolDefinitions([tool]); + + const result = await definition.execute( + "call-malformed-backend-sandbox-exec-params", + { + workdir: "/remote/workspace/generated", + }, + undefined, + undefined, + extensionContext, + ); + + expect(result.details).toMatchObject({ + status: "error", + error: "Provide a command to start.", + }); + expect(validateWorkdir).not.toHaveBeenCalled(); + }); + it("coerces details-only tool results to include content", async () => { const tool = { name: "memory_query", diff --git a/src/agents/agent-tools-agent-config.exec.test.ts b/src/agents/agent-tools-agent-config.exec.test.ts index e45858525d39..d9502e15fd5e 100644 --- a/src/agents/agent-tools-agent-config.exec.test.ts +++ b/src/agents/agent-tools-agent-config.exec.test.ts @@ -2,9 +2,12 @@ * Tests agent-specific exec defaults in assembled coding tools. * Verifies per-agent exec host policy affects lazy exec/process behavior. */ -import { beforeEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import "./test-helpers/fast-coding-tools.js"; import "./test-helpers/fast-openclaw-tools.js"; +import { createTempDirTracker } from "../../test/helpers/temp-dir.js"; import type { OpenClawConfig } from "../config/config.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createSessionConversationTestRegistry } from "../test-utils/session-conversation-registry.js"; @@ -46,11 +49,26 @@ function requireExecTool(tools: ReturnType) { return execTool; } +const tempDirs = createTempDirTracker(); + +function createTempAgentDirs(prefix: string) { + const root = tempDirs.make(`${prefix}-`); + const workspaceDir = path.join(root, "workspace"); + const agentDir = path.join(root, "agent"); + fs.mkdirSync(workspaceDir, { recursive: true }); + fs.mkdirSync(agentDir, { recursive: true }); + return { workspaceDir, agentDir }; +} + describe("Agent-specific exec tool defaults", () => { beforeEach(() => { setActivePluginRegistry(createSessionConversationTestRegistry()); }); + afterEach(() => { + tempDirs.cleanup(); + }); + it("should run exec synchronously when process is denied", async () => { const cfg: OpenClawConfig = { tools: { @@ -66,8 +84,7 @@ describe("Agent-specific exec tool defaults", () => { const tools = createOpenClawCodingTools({ config: cfg, sessionKey: "agent:main:main", - workspaceDir: "/tmp/test-main", - agentDir: "/tmp/agent-main", + ...createTempAgentDirs("test-main"), }); const execTool = requireExecTool(tools); @@ -91,8 +108,7 @@ describe("Agent-specific exec tool defaults", () => { }, }, sessionKey: "agent:main:main", - workspaceDir: "/tmp/test-main-implicit-gateway", - agentDir: "/tmp/agent-main-implicit-gateway", + ...createTempAgentDirs("test-main-implicit-gateway"), }); const execTool = requireExecTool(tools); @@ -113,8 +129,7 @@ describe("Agent-specific exec tool defaults", () => { }, }, sessionKey: "agent:main:main", - workspaceDir: "/tmp/test-main-mode-deny", - agentDir: "/tmp/agent-main-mode-deny", + ...createTempAgentDirs("test-main-mode-deny"), }); const execTool = requireExecTool(tools); @@ -135,8 +150,7 @@ describe("Agent-specific exec tool defaults", () => { }, }, sessionKey: "agent:main:main", - workspaceDir: "/tmp/test-main-mode-call-security", - agentDir: "/tmp/agent-main-mode-call-security", + ...createTempAgentDirs("test-main-mode-call-security"), }); const execTool = requireExecTool(tools); @@ -171,8 +185,7 @@ describe("Agent-specific exec tool defaults", () => { }, }, sessionKey: "agent:main:main", - workspaceDir: "/tmp/test-main-mode-partial-agent", - agentDir: "/tmp/agent-main-mode-partial-agent", + ...createTempAgentDirs("test-main-mode-partial-agent"), }); const execTool = requireExecTool(tools); @@ -197,8 +210,7 @@ describe("Agent-specific exec tool defaults", () => { security: "deny", }, sessionKey: "agent:main:main", - workspaceDir: "/tmp/test-main-session-legacy-override", - agentDir: "/tmp/agent-main-session-legacy-override", + ...createTempAgentDirs("test-main-session-legacy-override"), }); const execTool = requireExecTool(tools); @@ -213,8 +225,7 @@ describe("Agent-specific exec tool defaults", () => { const tools = createOpenClawCodingTools({ config: {}, sessionKey: "agent:main:main", - workspaceDir: "/tmp/test-main-fail-closed", - agentDir: "/tmp/agent-main-fail-closed", + ...createTempAgentDirs("test-main-fail-closed"), }); const execTool = requireExecTool(tools); await expect( @@ -234,8 +245,7 @@ describe("Agent-specific exec tool defaults", () => { const mainTools = createOpenClawCodingTools({ config: cfg, sessionKey: "agent:main:main", - workspaceDir: "/tmp/test-main-exec-defaults", - agentDir: "/tmp/agent-main-exec-defaults", + ...createTempAgentDirs("test-main-exec-defaults"), }); const mainExecTool = requireExecTool(mainTools); const mainResult = await mainExecTool.execute("call-main-default", { @@ -254,8 +264,7 @@ describe("Agent-specific exec tool defaults", () => { const helperTools = createOpenClawCodingTools({ config: cfg, sessionKey: "agent:helper:main", - workspaceDir: "/tmp/test-helper-exec-defaults", - agentDir: "/tmp/agent-helper-exec-defaults", + ...createTempAgentDirs("test-helper-exec-defaults"), }); const helperExecTool = requireExecTool(helperTools); const helperResult = await helperExecTool.execute("call-helper-default", { @@ -280,8 +289,7 @@ describe("Agent-specific exec tool defaults", () => { config: cfg, agentId: "main", sessionKey: "run-opaque-123", - workspaceDir: "/tmp/test-main-opaque-session", - agentDir: "/tmp/agent-main-opaque-session", + ...createTempAgentDirs("test-main-opaque-session"), }); const execTool = requireExecTool(tools); const result = await execTool.execute("call-main-opaque-session", { diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index ea94f258a742..7c95dd4015fe 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -854,6 +854,12 @@ export function createOpenClawCodingTools(options?: { containerName: sandbox.containerName, workspaceDir: sandbox.workspaceDir, containerWorkdir: sandbox.containerWorkdir, + workdirValidation: sandbox.backend?.workdirValidation, + validateWorkdir: sandbox.backend?.validateWorkdir?.bind(sandbox.backend), + discardPreparedWorkdir: sandbox.backend?.discardPreparedWorkdir?.bind( + sandbox.backend, + ), + workdirRoots: sandbox.backend?.workdirRoots, env: sandbox.backend?.env ?? sandbox.docker.env, buildExecSpec: sandbox.backend?.buildExecSpec.bind(sandbox.backend), finalizeExec: sandbox.backend?.finalizeExec?.bind(sandbox.backend), diff --git a/src/agents/anthropic-payload-log.test.ts b/src/agents/anthropic-payload-log.test.ts index 64ed762b6825..36e234b721fa 100644 --- a/src/agents/anthropic-payload-log.test.ts +++ b/src/agents/anthropic-payload-log.test.ts @@ -9,6 +9,10 @@ import { describe, expect, it } from "vitest"; import { createAnthropicPayloadLogger } from "./anthropic-payload-log.js"; describe("createAnthropicPayloadLogger", () => { + const bareAnthropicKey = "sk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWx"; // pragma: allowlist secret + const bareGithubKey = "ghp_AbCdEfGhIjKlMnOpQrStUvWxYz1234567890"; // pragma: allowlist secret + const bareGoogleKey = "AIzaSyA1bC2dE3fG4hI5jK6lM7nO8pQrStUvW"; // pragma: allowlist secret + it("sanitizes credential fields and image base64 payload data before writing logs", async () => { const lines: string[] = []; const logger = createAnthropicPayloadLogger({ @@ -26,6 +30,7 @@ describe("createAnthropicPayloadLogger", () => { { role: "user", authorization: "Bearer sk-secret", // pragma: allowlist secret + diagnosticText: bareAnthropicKey, content: [ { type: "image", @@ -38,6 +43,7 @@ describe("createAnthropicPayloadLogger", () => { api_key: "sk-test", // pragma: allowlist secret nestedToken: "shh", // pragma: allowlist secret tokenBudget: 1024, + diagnosticText: bareGithubKey, }, }; const streamFn: StreamFn = ((model, __, options) => { @@ -61,13 +67,22 @@ describe("createAnthropicPayloadLogger", () => { ?.source ?? {}) as Record; const metadata = (sanitizedPayload.metadata ?? {}) as Record; expect(message[0]).not.toHaveProperty("authorization"); + expect(message[0]?.diagnosticText).toBeTypeOf("string"); + expect(message[0]?.diagnosticText).not.toBe(bareAnthropicKey); + expect(message[0]?.diagnosticText).not.toContain(bareAnthropicKey); expect(metadata).not.toHaveProperty("api_key"); expect(metadata).not.toHaveProperty("nestedToken"); expect(metadata.tokenBudget).toBe(1024); + expect(metadata.diagnosticText).toBeTypeOf("string"); + expect(metadata.diagnosticText).not.toBe(bareGithubKey); + expect(metadata.diagnosticText).not.toContain(bareGithubKey); expect(source.data).toBe(""); expect(source.bytes).toBe(4); expect(source.sha256).toBe(crypto.createHash("sha256").update("QUJDRA==").digest("hex")); expect(event.payloadDigest).toMatch(/^[a-f0-9]{64}$/u); + const serialized = JSON.stringify(event); + expect(serialized).not.toContain(bareAnthropicKey); + expect(serialized).not.toContain(bareGithubKey); }); it("sanitizes usage and error fields before writing logs", () => { @@ -89,14 +104,24 @@ describe("createAnthropicPayloadLogger", () => { usage: { input: 1, authorization: "Bearer sk-secret", // pragma: allowlist secret + diagnosticText: bareGithubKey, }, } as never, ], - new Error("failed with Bearer sk-secret"), // pragma: allowlist secret + new Error(`failed with Bearer sk-secret and ${bareGoogleKey}`), // pragma: allowlist secret ); const event = JSON.parse(lines[0]?.trim() ?? "{}") as Record; - expect(event.error).toBe("failed with Bearer "); - expect(event.usage).toEqual({ input: 1 }); + expect(event.error).toBeTypeOf("string"); + expect(event.error).toContain("failed with Bearer and "); + expect(event.error).not.toContain(bareGoogleKey); + expect(event.usage).toEqual({ input: 1, diagnosticText: expect.any(String) }); + expect((event.usage as { diagnosticText?: string }).diagnosticText).not.toBe(bareGithubKey); + expect((event.usage as { diagnosticText?: string }).diagnosticText).not.toContain( + bareGithubKey, + ); + const serialized = JSON.stringify(event); + expect(serialized).not.toContain(bareGithubKey); + expect(serialized).not.toContain(bareGoogleKey); }); }); diff --git a/src/agents/anthropic-payload-log.ts b/src/agents/anthropic-payload-log.ts index 36e4338419c4..0af661e1a83d 100644 --- a/src/agents/anthropic-payload-log.ts +++ b/src/agents/anthropic-payload-log.ts @@ -11,7 +11,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveUserPath } from "../utils.js"; import { parseBooleanValue } from "../utils/boolean.js"; import { safeJsonStringify } from "../utils/safe-json.js"; -import { sanitizeDiagnosticPayload } from "./payload-redaction.js"; +import { redactAgentDiagnosticPayload } from "./diagnostic-redaction.js"; import { getQueuedFileWriter, type QueuedFileWriter } from "./queued-file-writer.js"; import type { AgentMessage, StreamFn } from "./runtime/index.js"; @@ -58,18 +58,18 @@ function getWriter(filePath: string): PayloadLogWriter { function formatError(error: unknown): string | undefined { if (error instanceof Error) { - const redacted = sanitizeDiagnosticPayload(error.message); + const redacted = redactAgentDiagnosticPayload(error.message); return typeof redacted === "string" ? redacted : error.message; } if (typeof error === "string") { - const redacted = sanitizeDiagnosticPayload(error); + const redacted = redactAgentDiagnosticPayload(error); return typeof redacted === "string" ? redacted : error; } if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") { return String(error); } if (error && typeof error === "object") { - return safeJsonStringify(sanitizeDiagnosticPayload(error)) ?? "unknown error"; + return safeJsonStringify(redactAgentDiagnosticPayload(error)) ?? "unknown error"; } return undefined; } @@ -151,7 +151,7 @@ export function createAnthropicPayloadLogger(params: { const nextOnPayload = (payload: unknown) => { // Forward the original payload to the provider hook, but persist only // the redacted diagnostic copy. - const redactedPayload = sanitizeDiagnosticPayload(payload); + const redactedPayload = redactAgentDiagnosticPayload(payload); record({ ...base, ts: new Date().toISOString(), @@ -187,7 +187,7 @@ export function createAnthropicPayloadLogger(params: { ...base, ts: new Date().toISOString(), stage: "usage", - usage: sanitizeDiagnosticPayload(usage) as Record, + usage: redactAgentDiagnosticPayload(usage), error: errorMessage, }); log.info("anthropic usage", { diff --git a/src/agents/bash-tools.exec-foreground-failures.test.ts b/src/agents/bash-tools.exec-foreground-failures.test.ts index 939fb3ceeef2..593906e6a6d2 100644 --- a/src/agents/bash-tools.exec-foreground-failures.test.ts +++ b/src/agents/bash-tools.exec-foreground-failures.test.ts @@ -3,18 +3,23 @@ * Verifies failed process outcomes surface useful text/details for shell * errors, timeouts, signals, and runtime failures. */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { SpawnInput } from "../process/supervisor/index.js"; +import { createTempDirTracker } from "../../test/helpers/temp-dir.js"; +import type { ProcessSupervisor, SpawnInput } from "../process/supervisor/index.js"; import { captureEnv } from "../test-utils/env.js"; import { resetProcessRegistryForTests } from "./bash-process-registry.js"; import { createExecTool } from "./bash-tools.exec.js"; +import type { BashSandboxConfig } from "./bash-tools.shared.js"; import { resolveShellFromPath } from "./shell-utils.js"; const supervisorMock = vi.hoisted(() => ({ - spawn: vi.fn(), - cancel: vi.fn(), - cancelScope: vi.fn(), - getRecord: vi.fn(), + spawn: vi.fn(), + cancel: vi.fn(), + cancelScope: vi.fn(), + getRecord: vi.fn(), })); vi.mock("../process/supervisor/index.js", () => ({ @@ -25,6 +30,7 @@ const isWin = process.platform === "win32"; const defaultShell = isWin ? undefined : process.env.OPENCLAW_TEST_SHELL || resolveShellFromPath("bash") || process.env.SHELL || "sh"; +const tempDirs = createTempDirTracker(); function requireTextContent( result: Awaited["execute"]>>, @@ -47,6 +53,66 @@ function requireFailedDetails( return details; } +function mockSuccessfulSpawn(stdout = "ok\n") { + supervisorMock.spawn.mockImplementationOnce(async (input: SpawnInput) => ({ + runId: input.runId ?? "call-success", + pid: 1234, + startedAtMs: Date.now(), + stdin: { + write: vi.fn(), + end: vi.fn(), + destroy: vi.fn(), + }, + wait: vi.fn(async () => ({ + reason: "exit" as const, + exitCode: 0, + exitSignal: null, + durationMs: 1, + stdout, + stderr: "", + timedOut: false, + noOutputTimedOut: false, + })), + cancel: vi.fn(), + })); +} + +async function expectUnavailableWorkdir(params: { + workdir: string; + toolDefaults?: Parameters[0]; + executeArgs?: Partial["execute"]>[1]>; + cleanup?: () => void; +}) { + const tool = createExecTool({ + security: "full", + ask: "off", + allowBackground: false, + ...params.toolDefaults, + }); + + try { + const executeArgs = params.executeArgs ?? { workdir: params.workdir }; + const result = await tool.execute("call-unavailable-workdir", { + command: "echo should-not-run", + ...executeArgs, + }); + + const text = requireTextContent(result); + expect(text).toContain(`workdir "${params.workdir}" is unavailable or not a directory`); + expect(text).toContain("command was not executed"); + expect(text).toContain("workdir is treated as a literal path"); + expect(text).toContain('shell expansions such as "~" are not applied'); + const details = requireFailedDetails(result.details); + expect(details.exitCode).toBeNull(); + expect(details.timedOut).toBe(false); + expect(details.aggregated).toBe(""); + expect(details.cwd).toBe(params.workdir); + expect(supervisorMock.spawn).not.toHaveBeenCalled(); + } finally { + params.cleanup?.(); + } +} + describe("exec foreground failures", () => { let envSnapshot: ReturnType | undefined; @@ -67,6 +133,7 @@ describe("exec foreground failures", () => { vi.useRealTimers(); envSnapshot?.restore(); envSnapshot = undefined; + tempDirs.cleanup(); }); it("returns a failed text result when the default timeout is exceeded", async () => { @@ -144,4 +211,430 @@ describe("exec foreground failures", () => { ); } }); + + it("returns a failed result for unavailable explicit host workdirs before launching", async () => { + const missingWorkdir = path.join( + os.tmpdir(), + `openclaw-missing-workdir-${process.pid}-${Date.now()}`, + ); + fs.rmSync(missingWorkdir, { recursive: true, force: true }); + + const fileWorkdir = path.join( + os.tmpdir(), + `openclaw-file-workdir-${process.pid}-${Date.now()}`, + ); + fs.writeFileSync(fileWorkdir, "not a directory"); + + try { + for (const workdir of [missingWorkdir, " ", fileWorkdir]) { + await expectUnavailableWorkdir({ workdir }); + supervisorMock.spawn.mockClear(); + } + } finally { + fs.rmSync(fileWorkdir, { force: true }); + } + }); + + it("returns a failed result for unavailable configured host workdirs before launching", async () => { + const missingDefaultWorkdir = path.join( + os.tmpdir(), + `openclaw-missing-default-workdir-${process.pid}-${Date.now()}`, + ); + fs.rmSync(missingDefaultWorkdir, { recursive: true, force: true }); + + await expectUnavailableWorkdir({ + workdir: missingDefaultWorkdir, + toolDefaults: { cwd: missingDefaultWorkdir }, + executeArgs: {}, + }); + }); + + it("returns a failed result when the current gateway cwd is unavailable", async () => { + const cwdSpy = vi.spyOn(process, "cwd").mockImplementation(() => { + throw new Error("current cwd unavailable"); + }); + try { + await expectUnavailableWorkdir({ + workdir: "current working directory", + executeArgs: {}, + }); + } finally { + cwdSpy.mockRestore(); + } + }); + + it("returns a failed result for unavailable configured sandbox workdirs before launching", async () => { + const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-"); + try { + await expectUnavailableWorkdir({ + workdir: "/workspace/missing", + toolDefaults: { + cwd: "/workspace/missing", + host: "sandbox", + sandbox: { + containerName: "sandbox-workdir-test", + workspaceDir, + containerWorkdir: "/workspace", + }, + }, + executeArgs: {}, + }); + } finally { + fs.rmSync(workspaceDir, { recursive: true, force: true }); + } + }); + + it("defaults omitted sandbox workdirs to the sandbox workspace", async () => { + const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-"); + mockSuccessfulSpawn(); + + const tool = createExecTool({ + host: "sandbox", + security: "full", + ask: "off", + allowBackground: false, + sandbox: { + containerName: "sandbox-workdir-test", + workspaceDir, + containerWorkdir: "/workspace", + }, + }); + + try { + const result = await tool.execute("call-sandbox-default-workdir", { + command: "echo ok", + }); + + expect(result.details.status).toBe("completed"); + expect(result.details.cwd).toBe(workspaceDir); + expect(supervisorMock.spawn).toHaveBeenCalledOnce(); + const input = supervisorMock.spawn.mock.calls[0]?.[0]; + expect(input?.cwd).toBe(workspaceDir); + expect(input?.mode).toBe("child"); + if (input?.mode === "child") { + expect(input.argv).toContain("-w"); + expect(input.argv).toContain("/workspace"); + } + } finally { + fs.rmSync(workspaceDir, { recursive: true, force: true }); + } + }); + + it("lets backend-validated sandbox workdirs reach the backend without host stat fallback", async () => { + const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-"); + const buildExecSpec = vi.fn>( + async (params) => ({ + argv: ["remote-shell", params.command], + env: {}, + stdinMode: "pipe-open" as const, + }), + ); + const validateWorkdir = vi.fn>( + async (workdir) => workdir, + ); + mockSuccessfulSpawn(); + + const tool = createExecTool({ + host: "sandbox", + security: "full", + ask: "off", + allowBackground: false, + sandbox: { + containerName: "remote-sandbox-workdir-test", + workspaceDir, + containerWorkdir: "/remote/workspace", + workdirValidation: "backend", + validateWorkdir, + buildExecSpec, + }, + }); + + try { + const result = await tool.execute("call-remote-sandbox-workdir", { + command: "echo ok", + workdir: "/remote/workspace/generated", + }); + + expect(result.details.status).toBe("completed"); + expect(result.details.cwd).toBe(workspaceDir); + expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/generated"); + expect(buildExecSpec).toHaveBeenCalledOnce(); + expect(buildExecSpec.mock.calls[0]?.[0]?.workdir).toBe("/remote/workspace/generated"); + expect(supervisorMock.spawn).toHaveBeenCalledOnce(); + expect(supervisorMock.spawn.mock.calls[0]?.[0]?.cwd).toBe(workspaceDir); + } finally { + fs.rmSync(workspaceDir, { recursive: true, force: true }); + } + }); + + it("finalizes backend sandbox exec tokens when process spawn fails", async () => { + const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-"); + const finalizeToken = { session: "remote-session" }; + const buildExecSpec = vi.fn>( + async (params) => ({ + argv: ["remote-shell", params.command], + env: {}, + stdinMode: "pipe-open" as const, + finalizeToken, + }), + ); + const finalizeExec = vi.fn>(async () => {}); + const validateWorkdir = vi.fn>( + async (workdir) => workdir, + ); + supervisorMock.spawn.mockRejectedValueOnce(new Error("spawn failed")); + + const tool = createExecTool({ + host: "sandbox", + security: "full", + ask: "off", + allowBackground: false, + sandbox: { + containerName: "remote-sandbox-workdir-test", + workspaceDir, + containerWorkdir: "/remote/workspace", + workdirValidation: "backend", + validateWorkdir, + buildExecSpec, + finalizeExec, + }, + }); + + try { + await expect( + tool.execute("call-remote-sandbox-spawn-failure", { + command: "echo ok", + workdir: "/remote/workspace/generated", + }), + ).rejects.toThrow("spawn failed"); + + expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/generated"); + expect(buildExecSpec).toHaveBeenCalledOnce(); + expect(supervisorMock.spawn).toHaveBeenCalledOnce(); + expect(finalizeExec).toHaveBeenCalledOnce(); + expect(finalizeExec).toHaveBeenCalledWith({ + status: "failed", + exitCode: null, + timedOut: false, + token: finalizeToken, + }); + } finally { + fs.rmSync(workspaceDir, { recursive: true, force: true }); + } + }); + + it("rejects unsafe commands before backend workdir validation", async () => { + const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-"); + const buildExecSpec = vi.fn>( + async (params) => ({ + argv: ["remote-shell", params.command], + env: {}, + stdinMode: "pipe-open" as const, + }), + ); + const validateWorkdir = vi.fn>( + async (workdir) => workdir, + ); + const discardPreparedWorkdir = + vi.fn>(); + + const tool = createExecTool({ + host: "sandbox", + security: "full", + ask: "off", + allowBackground: false, + sandbox: { + containerName: "remote-sandbox-workdir-test", + workspaceDir, + containerWorkdir: "/remote/workspace", + workdirValidation: "backend", + validateWorkdir, + discardPreparedWorkdir, + buildExecSpec, + }, + }); + + try { + await expect( + tool.execute("call-remote-sandbox-rejected-command", { + command: "/approve approval-1 deny", + workdir: "/remote/workspace/generated", + }), + ).rejects.toThrow("exec cannot run /approve commands"); + + expect(validateWorkdir).not.toHaveBeenCalled(); + expect(discardPreparedWorkdir).not.toHaveBeenCalled(); + expect(buildExecSpec).not.toHaveBeenCalled(); + expect(supervisorMock.spawn).not.toHaveBeenCalled(); + } finally { + fs.rmSync(workspaceDir, { recursive: true, force: true }); + } + }); + + it("does not preflight remote-only backend workdirs from the local workspace root", async () => { + const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-"); + fs.writeFileSync(path.join(workspaceDir, "script.py"), "print($TOKEN)\n"); + const buildExecSpec = vi.fn>( + async (params) => ({ + argv: ["remote-shell", params.command], + env: {}, + stdinMode: "pipe-open" as const, + }), + ); + const validateWorkdir = vi.fn>( + async (workdir) => workdir, + ); + mockSuccessfulSpawn(); + + const tool = createExecTool({ + host: "sandbox", + security: "full", + ask: "off", + allowBackground: false, + sandbox: { + containerName: "remote-sandbox-workdir-test", + workspaceDir, + containerWorkdir: "/remote/workspace", + workdirValidation: "backend", + validateWorkdir, + buildExecSpec, + }, + }); + + try { + const result = await tool.execute("call-remote-only-script", { + command: "python script.py", + workdir: "/remote/workspace/generated", + }); + + expect(result.details.status).toBe("completed"); + expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/generated"); + expect(buildExecSpec).toHaveBeenCalledOnce(); + expect(buildExecSpec.mock.calls[0]?.[0]?.workdir).toBe("/remote/workspace/generated"); + expect(supervisorMock.spawn).toHaveBeenCalledOnce(); + } finally { + fs.rmSync(workspaceDir, { recursive: true, force: true }); + } + }); + + it("uses the mapped host cwd for existing relative backend-validated sandbox workdirs", async () => { + const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-"); + const srcDir = path.join(workspaceDir, "src"); + fs.mkdirSync(srcDir); + const buildExecSpec = vi.fn>( + async (params) => ({ + argv: ["remote-shell", params.command], + env: {}, + stdinMode: "pipe-open" as const, + }), + ); + const validateWorkdir = vi.fn>( + async (workdir) => workdir, + ); + mockSuccessfulSpawn(); + + const tool = createExecTool({ + host: "sandbox", + security: "full", + ask: "off", + allowBackground: false, + sandbox: { + containerName: "remote-sandbox-workdir-test", + workspaceDir, + containerWorkdir: "/remote/workspace", + workdirValidation: "backend", + validateWorkdir, + buildExecSpec, + }, + }); + + try { + const result = await tool.execute("call-relative-remote-sandbox-workdir", { + command: "echo ok", + workdir: "src", + }); + + expect(result.details.status).toBe("completed"); + expect(result.details.cwd).toBe(srcDir); + expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/src"); + expect(buildExecSpec).toHaveBeenCalledOnce(); + expect(buildExecSpec.mock.calls[0]?.[0]?.workdir).toBe("/remote/workspace/src"); + expect(supervisorMock.spawn).toHaveBeenCalledOnce(); + expect(supervisorMock.spawn.mock.calls[0]?.[0]?.cwd).toBe(srcDir); + } finally { + fs.rmSync(workspaceDir, { recursive: true, force: true }); + } + }); + + it("fails backend-validated sandbox workdirs before launch when backend validation rejects", async () => { + const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-"); + const validateWorkdir = vi.fn>( + async () => null, + ); + const buildExecSpec = vi.fn>( + async (params) => ({ + argv: ["remote-shell", params.command], + env: {}, + stdinMode: "pipe-open" as const, + }), + ); + + const tool = createExecTool({ + host: "sandbox", + security: "full", + ask: "off", + allowBackground: false, + sandbox: { + containerName: "remote-sandbox-workdir-test", + workspaceDir, + containerWorkdir: "/remote/workspace", + workdirValidation: "backend", + validateWorkdir, + buildExecSpec, + }, + }); + + try { + const result = await tool.execute("call-remote-sandbox-workdir", { + command: "echo ok", + workdir: "/remote/workspace/generated", + }); + + expect(result.details).toMatchObject({ + status: "failed", + cwd: "/remote/workspace/generated", + }); + expect(JSON.stringify(result)).toContain("unavailable or not a directory"); + expect(validateWorkdir).toHaveBeenCalledOnce(); + expect(buildExecSpec).not.toHaveBeenCalled(); + expect(supervisorMock.spawn).not.toHaveBeenCalled(); + } finally { + fs.rmSync(workspaceDir, { recursive: true, force: true }); + } + }); + + it("returns a failed result for unavailable explicit sandbox workdirs before launching a command", async () => { + const workspaceDir = tempDirs.make("openclaw-sandbox-workdir-"); + const outsideDir = tempDirs.make("openclaw-outside-workdir-"); + fs.writeFileSync(path.join(workspaceDir, "not-dir"), "not a directory"); + try { + for (const workdir of ["/workspace/missing", " ", "/workspace/not-dir", outsideDir]) { + await expectUnavailableWorkdir({ + workdir, + toolDefaults: { + host: "sandbox", + sandbox: { + containerName: "sandbox-workdir-test", + workspaceDir, + containerWorkdir: "/workspace", + }, + }, + }); + supervisorMock.spawn.mockClear(); + } + } finally { + fs.rmSync(workspaceDir, { recursive: true, force: true }); + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/agents/bash-tools.exec-host-node.test.ts b/src/agents/bash-tools.exec-host-node.test.ts index 946d9cca8b8d..a5cc39d677fa 100644 --- a/src/agents/bash-tools.exec-host-node.test.ts +++ b/src/agents/bash-tools.exec-host-node.test.ts @@ -2716,7 +2716,10 @@ describe("executeNodeHostCommand", () => { expect(requireGatewayCommand("system.run.prepare").params?.params?.env).toEqual({ FOO: "bar", }); - expect(requireRunParams(requireGatewayCommand("system.run")).env).toEqual({ FOO: "bar" }); + expect(requireGatewayCommand("system.run.prepare").params?.params?.cwd).toBe("/tmp/work"); + const runParams = requireRunParams(requireGatewayCommand("system.run")); + expect(runParams.env).toEqual({ FOO: "bar" }); + expect(runParams.cwd).toBe("/tmp/work"); const evalEnvs = evaluateShellAllowlistMock.mock.calls.map( ([raw]) => (raw as ShellAllowlistMockParams).env, ); @@ -2745,12 +2748,31 @@ describe("executeNodeHostCommand", () => { const runParams = requireRunParams(call); expect(runParams.command).toEqual(["/bin/sh", "-lc", "bun ./script.ts"]); expect(runParams.rawCommand).toBe("bun ./script.ts"); + expect(runParams.cwd).toBe("/tmp/work"); expect(typeof runParams.runId).toBe("string"); expect(runParams.suppressNotifyOnExit).toBe(true); expect(runParams.timeoutMs).toBe(30_000); expect(Object.hasOwn(runParams, "systemRunPlan")).toBe(false); }); + it("omits cwd from direct node system.run when workdir is undefined", async () => { + await executeNodeHostCommand({ + command: "bun ./script.ts", + workdir: undefined, + env: {}, + security: "full", + ask: "off", + defaultTimeoutSec: 30, + approvalRunningNoticeMs: 0, + warnings: [], + agentId: "requested-agent", + sessionKey: "requested-session", + }); + + const runParams = requireRunParams(requireGatewayCall(0)); + expect(Object.hasOwn(runParams, "cwd")).toBe(false); + }); + it("rejects disconnected node targets before invoking system.run", async () => { listNodesMock.mockResolvedValueOnce([ { diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 8280aa6457c7..5470875b7f18 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -715,6 +715,21 @@ export async function runExecProcess(opts: { const timeoutMs = resolveExecTimeoutMs(opts.timeoutSec); let sandboxFinalizeToken: unknown; + let sandboxFinalized = false; + const finalizeSandboxExec = async (params: { + status: "completed" | "failed"; + exitCode: number | null; + timedOut: boolean; + }) => { + if (sandboxFinalized || !opts.sandbox?.finalizeExec) { + return; + } + sandboxFinalized = true; + await opts.sandbox.finalizeExec({ + ...params, + token: sandboxFinalizeToken, + }); + }; const spawnSpec: | { @@ -861,6 +876,13 @@ export async function runExecProcess(opts: { } catch (retryErr) { markExited(session, null, null, "failed"); maybeNotifyOnExit(session, "failed"); + await finalizeSandboxExec({ + status: "failed", + exitCode: null, + timedOut: false, + }).catch((finalizeErr: unknown) => { + logWarn(`exec: sandbox finalize after spawn failure failed (${String(finalizeErr)}).`); + }); emitExecProcessCompleted({ command: opts.command, mode: "child", @@ -877,6 +899,13 @@ export async function runExecProcess(opts: { } else { markExited(session, null, null, "failed"); maybeNotifyOnExit(session, "failed"); + await finalizeSandboxExec({ + status: "failed", + exitCode: null, + timedOut: false, + }).catch((finalizeErr: unknown) => { + logWarn(`exec: sandbox finalize after spawn failure failed (${String(finalizeErr)}).`); + }); emitExecProcessCompleted({ command: opts.command, mode: spawnSpec.mode, @@ -915,14 +944,11 @@ export async function runExecProcess(opts: { if (!session.child && session.stdin) { session.stdin.destroyed = true; } - if (opts.sandbox?.finalizeExec) { - await opts.sandbox.finalizeExec({ - status: outcome.status, - exitCode: exit.exitCode ?? null, - timedOut: exit.timedOut, - token: sandboxFinalizeToken, - }); - } + await finalizeSandboxExec({ + status: outcome.status, + exitCode: exit.exitCode ?? null, + timedOut: exit.timedOut, + }); emitExecProcessCompleted({ command: opts.command, mode: usingPty ? "pty" : "child", diff --git a/src/agents/bash-tools.exec-workdir.test.ts b/src/agents/bash-tools.exec-workdir.test.ts new file mode 100644 index 000000000000..066b4e1b5867 --- /dev/null +++ b/src/agents/bash-tools.exec-workdir.test.ts @@ -0,0 +1,616 @@ +/** + * Exec workdir resolver tests. + * Verifies cwd selection and validation before exec launches or remote node + * forwarding. + */ +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveExecWorkdir } from "./bash-tools.exec-workdir.js"; +import type { BashSandboxConfig } from "./bash-tools.shared.js"; + +async function withTempDir(run: (dir: string) => Promise) { + const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-exec-workdir-")); + try { + await run(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +function sandboxConfig(workspaceDir: string): BashSandboxConfig { + return { + containerName: "sandbox-workdir-test", + workspaceDir, + containerWorkdir: "/workspace", + }; +} + +function backendSandboxConfig( + workspaceDir: string, + params?: { + containerWorkdir?: string; + workdirRoots?: readonly string[]; + validateWorkdir?: BashSandboxConfig["validateWorkdir"]; + }, +): BashSandboxConfig { + return { + ...sandboxConfig(workspaceDir), + containerWorkdir: params?.containerWorkdir ?? "/remote/workspace", + workdirValidation: "backend", + workdirRoots: params?.workdirRoots, + validateWorkdir: params?.validateWorkdir ?? (async (workdir) => workdir), + }; +} + +describe("resolveExecWorkdir", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("rejects blank explicit local workdirs", async () => { + await expect( + resolveExecWorkdir({ + host: "gateway", + workdir: " ", + }), + ).resolves.toEqual({ kind: "unavailable", requestedCwd: " " }); + }); + + it("rejects missing explicit local workdirs without fallback", async () => { + await withTempDir(async (workspaceDir) => { + const missing = path.join(workspaceDir, "missing"); + await expect( + resolveExecWorkdir({ + host: "gateway", + workdir: missing, + }), + ).resolves.toEqual({ kind: "unavailable", requestedCwd: missing }); + }); + }); + + it("rejects file explicit local workdirs", async () => { + await withTempDir(async (workspaceDir) => { + const fileWorkdir = path.join(workspaceDir, "not-dir"); + await writeFile(fileWorkdir, "not a directory"); + + await expect( + resolveExecWorkdir({ + host: "gateway", + workdir: fileWorkdir, + }), + ).resolves.toEqual({ kind: "unavailable", requestedCwd: fileWorkdir }); + }); + }); + + it("resolves valid explicit local workdirs", async () => { + await withTempDir(async (workspaceDir) => { + await expect( + resolveExecWorkdir({ + host: "gateway", + workdir: ` ${workspaceDir} `, + }), + ).resolves.toEqual({ kind: "local", hostCwd: workspaceDir }); + }); + }); + + it("uses configured local cwd when workdir is omitted", async () => { + await withTempDir(async (workspaceDir) => { + await expect( + resolveExecWorkdir({ + host: "gateway", + defaultCwd: workspaceDir, + }), + ).resolves.toEqual({ kind: "local", hostCwd: workspaceDir }); + }); + }); + + it("uses current cwd for omitted local workdir only when no default exists", async () => { + await withTempDir(async (workspaceDir) => { + vi.spyOn(process, "cwd").mockReturnValue(workspaceDir); + + await expect( + resolveExecWorkdir({ + host: "gateway", + }), + ).resolves.toEqual({ kind: "local", hostCwd: workspaceDir }); + }); + }); + + it("fails omitted local workdir when current cwd is unavailable", async () => { + vi.spyOn(process, "cwd").mockImplementation(() => { + throw new Error("cwd unavailable"); + }); + + await expect( + resolveExecWorkdir({ + host: "gateway", + }), + ).resolves.toEqual({ kind: "unavailable", requestedCwd: "current working directory" }); + }); + + it("rejects missing configured local cwd without falling back to current cwd", async () => { + await withTempDir(async (workspaceDir) => { + const missingDefault = path.join(workspaceDir, "missing-default"); + vi.spyOn(process, "cwd").mockReturnValue(workspaceDir); + + await expect( + resolveExecWorkdir({ + host: "gateway", + defaultCwd: missingDefault, + }), + ).resolves.toEqual({ kind: "unavailable", requestedCwd: missingDefault }); + }); + }); + + it("uses the sandbox workspace when sandbox workdir is omitted", async () => { + await withTempDir(async (workspaceDir) => { + await expect( + resolveExecWorkdir({ + host: "sandbox", + sandbox: sandboxConfig(workspaceDir), + }), + ).resolves.toEqual({ + kind: "sandbox", + hostCwd: workspaceDir, + containerCwd: "/workspace", + scriptPreflightCwd: workspaceDir, + }); + }); + }); + + it("rejects missing explicit sandbox workdirs", async () => { + await withTempDir(async (workspaceDir) => { + await expect( + resolveExecWorkdir({ + host: "sandbox", + workdir: "/workspace/missing", + sandbox: sandboxConfig(workspaceDir), + }), + ).resolves.toEqual({ kind: "unavailable", requestedCwd: "/workspace/missing" }); + }); + }); + + it("rejects missing configured sandbox workdirs", async () => { + await withTempDir(async (workspaceDir) => { + await expect( + resolveExecWorkdir({ + host: "sandbox", + defaultCwd: "/workspace/missing", + sandbox: sandboxConfig(workspaceDir), + }), + ).resolves.toEqual({ kind: "unavailable", requestedCwd: "/workspace/missing" }); + }); + }); + + it("rejects file sandbox workdirs", async () => { + await withTempDir(async (workspaceDir) => { + await writeFile(path.join(workspaceDir, "not-dir"), "not a directory"); + + await expect( + resolveExecWorkdir({ + host: "sandbox", + workdir: "/workspace/not-dir", + sandbox: sandboxConfig(workspaceDir), + }), + ).resolves.toEqual({ kind: "unavailable", requestedCwd: "/workspace/not-dir" }); + }); + }); + + it("rejects sandbox workdirs that escape the workspace", async () => { + await withTempDir(async (workspaceDir) => { + await withTempDir(async (outsideDir) => { + await expect( + resolveExecWorkdir({ + host: "sandbox", + workdir: outsideDir, + sandbox: sandboxConfig(workspaceDir), + }), + ).resolves.toEqual({ kind: "unavailable", requestedCwd: outsideDir }); + }); + }); + }); + + it("rejects sandbox workdirs with parent-directory segments", async () => { + await withTempDir(async (workspaceDir) => { + await expect( + resolveExecWorkdir({ + host: "sandbox", + workdir: "missing/..", + sandbox: sandboxConfig(workspaceDir), + }), + ).resolves.toEqual({ kind: "unavailable", requestedCwd: "missing/.." }); + + await expect( + resolveExecWorkdir({ + host: "sandbox", + workdir: "/workspace/missing/..", + sandbox: sandboxConfig(workspaceDir), + }), + ).resolves.toEqual({ kind: "unavailable", requestedCwd: "/workspace/missing/.." }); + }); + }); + + it("rejects sandbox workdir symlinks that escape the workspace", async () => { + await withTempDir(async (workspaceDir) => { + await withTempDir(async (outsideDir) => { + await symlink(outsideDir, path.join(workspaceDir, "escape"), "dir"); + + await expect( + resolveExecWorkdir({ + host: "sandbox", + workdir: "/workspace/escape", + sandbox: sandboxConfig(workspaceDir), + }), + ).resolves.toEqual({ kind: "unavailable", requestedCwd: "/workspace/escape" }); + }); + }); + }); + + it("resolves relative sandbox workdirs under the workspace", async () => { + await withTempDir(async (workspaceDir) => { + const srcDir = path.join(workspaceDir, "src"); + await mkdir(srcDir); + + await expect( + resolveExecWorkdir({ + host: "sandbox", + workdir: "src", + sandbox: sandboxConfig(workspaceDir), + }), + ).resolves.toEqual({ + kind: "sandbox", + hostCwd: srcDir, + containerCwd: "/workspace/src", + scriptPreflightCwd: srcDir, + }); + }); + }); + + it("supports custom sandbox container workdir prefixes", async () => { + await withTempDir(async (workspaceDir) => { + const projectDir = path.join(workspaceDir, "project"); + await mkdir(projectDir); + + await expect( + resolveExecWorkdir({ + host: "sandbox", + workdir: "/sandbox-root/project", + sandbox: { + ...sandboxConfig(workspaceDir), + containerWorkdir: "/sandbox-root", + }, + }), + ).resolves.toEqual({ + kind: "sandbox", + hostCwd: projectDir, + containerCwd: "/sandbox-root/project", + scriptPreflightCwd: projectDir, + }); + }); + }); + + it("lets backend-validated sandboxes use remote-only container workdirs", async () => { + await withTempDir(async (workspaceDir) => { + await expect( + resolveExecWorkdir({ + host: "sandbox", + workdir: "/remote/workspace/generated", + sandbox: backendSandboxConfig(workspaceDir), + }), + ).resolves.toEqual({ + kind: "sandbox", + hostCwd: workspaceDir, + containerCwd: "/remote/workspace/generated", + scriptPreflightCwd: null, + }); + }); + }); + + it("normalizes backend-validated sandbox workdir roots with trailing slashes", async () => { + await withTempDir(async (workspaceDir) => { + await expect( + resolveExecWorkdir({ + host: "sandbox", + workdir: "/remote/workspace/generated", + sandbox: backendSandboxConfig(workspaceDir, { + containerWorkdir: "/remote/workspace/", + }), + }), + ).resolves.toEqual({ + kind: "sandbox", + hostCwd: workspaceDir, + containerCwd: "/remote/workspace/generated", + scriptPreflightCwd: null, + }); + }); + }); + + it("lets backend-validated sandboxes use declared alternate remote roots", async () => { + await withTempDir(async (workspaceDir) => { + const validateWorkdir = vi.fn(async (workdir: string) => workdir); + + await expect( + resolveExecWorkdir({ + host: "sandbox", + workdir: "/agent/project", + sandbox: backendSandboxConfig(workspaceDir, { + workdirRoots: ["/agent"], + validateWorkdir, + }), + }), + ).resolves.toEqual({ + kind: "sandbox", + hostCwd: workspaceDir, + containerCwd: "/agent/project", + scriptPreflightCwd: null, + }); + expect(validateWorkdir).toHaveBeenCalledWith("/agent/project"); + }); + }); + + it("resolves relative backend-validated sandbox workdirs under the remote workspace", async () => { + await withTempDir(async (workspaceDir) => { + await expect( + resolveExecWorkdir({ + host: "sandbox", + workdir: "remote-only", + sandbox: backendSandboxConfig(workspaceDir), + }), + ).resolves.toEqual({ + kind: "sandbox", + hostCwd: workspaceDir, + containerCwd: "/remote/workspace/remote-only", + scriptPreflightCwd: null, + }); + }); + }); + + it("keeps existing relative backend-validated sandbox workdirs aligned with the local mirror", async () => { + await withTempDir(async (workspaceDir) => { + const localDir = path.join(workspaceDir, "src"); + await mkdir(localDir); + const validateWorkdir = vi.fn(async (workdir: string) => workdir); + + await expect( + resolveExecWorkdir({ + host: "sandbox", + workdir: "src", + sandbox: backendSandboxConfig(workspaceDir, { validateWorkdir }), + }), + ).resolves.toEqual({ + kind: "sandbox", + hostCwd: localDir, + containerCwd: "/remote/workspace/src", + scriptPreflightCwd: localDir, + }); + expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/src"); + }); + }); + + it("defers stale relative backend-validated sandbox workdirs to the backend", async () => { + await withTempDir(async (workspaceDir) => { + const localFile = path.join(workspaceDir, "build"); + await writeFile(localFile, "stale local mirror file"); + const validateWorkdir = vi.fn(async (workdir: string) => workdir); + + await expect( + resolveExecWorkdir({ + host: "sandbox", + workdir: "build", + sandbox: backendSandboxConfig(workspaceDir, { validateWorkdir }), + }), + ).resolves.toEqual({ + kind: "sandbox", + hostCwd: workspaceDir, + containerCwd: "/remote/workspace/build", + scriptPreflightCwd: null, + }); + expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/build"); + }); + }); + + it("accepts backend-validated absolute workdirs when the remote workspace root is slash", async () => { + await withTempDir(async (workspaceDir) => { + await expect( + resolveExecWorkdir({ + host: "sandbox", + workdir: "/generated", + sandbox: backendSandboxConfig(workspaceDir, { + containerWorkdir: "/", + }), + }), + ).resolves.toEqual({ + kind: "sandbox", + hostCwd: workspaceDir, + containerCwd: "/generated", + scriptPreflightCwd: null, + }); + }); + }); + + it("maps host workspace paths for backend-validated sandboxes when they exist locally", async () => { + await withTempDir(async (workspaceDir) => { + const localDir = path.join(workspaceDir, "src"); + await mkdir(localDir); + + await expect( + resolveExecWorkdir({ + host: "sandbox", + workdir: localDir, + sandbox: backendSandboxConfig(workspaceDir), + }), + ).resolves.toEqual({ + kind: "sandbox", + hostCwd: localDir, + containerCwd: "/remote/workspace/src", + scriptPreflightCwd: localDir, + }); + }); + }); + + it("defers missing absolute backend workdirs to remote validation when roots overlap", async () => { + await withTempDir(async (workspaceDir) => { + const missingRemoteDir = path.join(workspaceDir, "generated"); + const validateWorkdir = vi.fn(async (workdir: string) => workdir); + + await expect( + resolveExecWorkdir({ + host: "sandbox", + workdir: missingRemoteDir, + sandbox: backendSandboxConfig(workspaceDir, { + containerWorkdir: workspaceDir, + validateWorkdir, + }), + }), + ).resolves.toEqual({ + kind: "sandbox", + hostCwd: workspaceDir, + containerCwd: missingRemoteDir, + scriptPreflightCwd: null, + }); + expect(validateWorkdir).toHaveBeenCalledWith(missingRemoteDir); + }); + }); + + it("maps missing absolute host workspace paths before backend validation", async () => { + await withTempDir(async (workspaceDir) => { + const missingRemoteDir = path.join(workspaceDir, "generated"); + const validateWorkdir = vi.fn(async (workdir: string) => workdir); + + await expect( + resolveExecWorkdir({ + host: "sandbox", + workdir: missingRemoteDir, + sandbox: backendSandboxConfig(workspaceDir, { + validateWorkdir, + }), + }), + ).resolves.toEqual({ + kind: "sandbox", + hostCwd: workspaceDir, + containerCwd: "/remote/workspace/generated", + scriptPreflightCwd: null, + }); + expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/generated"); + }); + }); + + it("rejects backend-validated sandbox host paths that symlink outside the workspace", async () => { + await withTempDir(async (workspaceDir) => { + await withTempDir(async (outsideDir) => { + const escape = path.join(workspaceDir, "escape"); + await symlink(outsideDir, escape, "dir"); + + await expect( + resolveExecWorkdir({ + host: "sandbox", + workdir: escape, + sandbox: backendSandboxConfig(workspaceDir), + }), + ).resolves.toEqual({ + kind: "unavailable", + requestedCwd: escape, + }); + }); + }); + }); + + it("prefers existing host workspace paths over matching backend container prefixes", async () => { + await withTempDir(async (workspaceDir) => { + const localDir = path.join(workspaceDir, "src"); + await mkdir(localDir); + + await expect( + resolveExecWorkdir({ + host: "sandbox", + workdir: localDir, + sandbox: backendSandboxConfig(workspaceDir, { + containerWorkdir: workspaceDir, + }), + }), + ).resolves.toEqual({ + kind: "sandbox", + hostCwd: localDir, + containerCwd: `${workspaceDir}/src`, + scriptPreflightCwd: localDir, + }); + }); + }); + + it("rejects backend-validated sandbox workdirs outside local and remote workspace roots", async () => { + await withTempDir(async (workspaceDir) => { + await expect( + resolveExecWorkdir({ + host: "sandbox", + workdir: "/other/remote/workspace", + sandbox: backendSandboxConfig(workspaceDir), + }), + ).resolves.toEqual({ + kind: "unavailable", + requestedCwd: "/other/remote/workspace", + }); + }); + }); + + it("rejects backend-validated sandbox workdirs with parent-directory segments", async () => { + await withTempDir(async (workspaceDir) => { + await expect( + resolveExecWorkdir({ + host: "sandbox", + workdir: "/remote/workspace/missing/..", + sandbox: backendSandboxConfig(workspaceDir), + }), + ).resolves.toEqual({ + kind: "unavailable", + requestedCwd: "/remote/workspace/missing/..", + }); + }); + }); + + it("rejects backend-validated sandbox workdirs when the backend validator fails", async () => { + await withTempDir(async (workspaceDir) => { + await expect( + resolveExecWorkdir({ + host: "sandbox", + workdir: "/remote/workspace/missing", + sandbox: backendSandboxConfig(workspaceDir, { + validateWorkdir: async () => null, + }), + }), + ).resolves.toEqual({ + kind: "unavailable", + requestedCwd: "/remote/workspace/missing", + }); + }); + }); + + it("omits node cwd when node workdir is omitted", async () => { + await expect( + resolveExecWorkdir({ + host: "node", + defaultCwd: "/gateway/default", + }), + ).resolves.toEqual({ kind: "node" }); + }); + + it("forwards explicit node cwd without local validation", async () => { + await expect( + resolveExecWorkdir({ + host: "node", + workdir: "/remote/node/workspace", + defaultCwd: "/gateway/default", + }), + ).resolves.toEqual({ kind: "node", remoteCwd: "/remote/node/workspace" }); + }); + + it("rejects blank explicit node workdirs", async () => { + await expect( + resolveExecWorkdir({ + host: "node", + workdir: " ", + }), + ).resolves.toEqual({ kind: "unavailable", requestedCwd: " " }); + }); +}); diff --git a/src/agents/bash-tools.exec-workdir.ts b/src/agents/bash-tools.exec-workdir.ts new file mode 100644 index 000000000000..fd4907e1c6f2 --- /dev/null +++ b/src/agents/bash-tools.exec-workdir.ts @@ -0,0 +1,381 @@ +/** + * Internal exec workdir resolver. + * Owns cwd selection and validation before exec approval, hooks, preflight, or + * process launch can observe an invalid selected working directory. + */ +import fs from "node:fs/promises"; +import path from "node:path"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import type { ExecHost } from "../infra/exec-approvals.js"; +import { safeStatSync } from "../infra/path-guards.js"; +import type { BashSandboxConfig } from "./bash-tools.shared.js"; +import { assertSandboxPath } from "./sandbox-paths.js"; + +export type ExecWorkdirResolution = + | { kind: "local"; hostCwd: string } + | { kind: "sandbox"; hostCwd: string; containerCwd: string; scriptPreflightCwd: string | null } + | { kind: "node"; remoteCwd?: string } + | { kind: "unavailable"; requestedCwd: string }; + +type NormalizedWorkdirInput = + | { kind: "omitted" } + | { kind: "blank"; raw: string } + | { kind: "specified"; value: string }; + +type SandboxWorkdir = { + hostCwd: string; + containerCwd: string; + scriptPreflightCwd: string | null; +}; + +type BackendHostWorkdirCandidate = { + hostPath: string; + failIfInvalid: boolean; +}; + +type ExistingHostWorkspacePathResult = + | { kind: "available"; workdir: SandboxWorkdir } + | { kind: "missing"; relative: string } + | { kind: "invalid" }; + +function normalizeExplicitWorkdirInput(workdir: string | undefined): NormalizedWorkdirInput { + if (workdir === undefined) { + return { kind: "omitted" }; + } + const value = normalizeOptionalString(workdir); + return value ? { kind: "specified", value } : { kind: "blank", raw: workdir }; +} + +function unavailable(requestedCwd: string): ExecWorkdirResolution { + return { kind: "unavailable", requestedCwd }; +} + +function resolveExistingHostWorkdir(workdir: string): string | null { + const stats = safeStatSync(workdir); + return stats?.isDirectory() ? workdir : null; +} + +function isHostPathInsideRoot(params: { root: string; candidate: string }): boolean { + const root = path.resolve(params.root); + const candidate = path.resolve(params.candidate); + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function safeCurrentCwd(): string | null { + try { + return process.cwd(); + } catch { + return null; + } +} + +function mapContainerWorkdirToHost(params: { + workdir: string; + sandbox: BashSandboxConfig; +}): string | undefined { + const workdir = normalizeContainerPath(params.workdir); + const containerRoot = normalizeContainerPath(params.sandbox.containerWorkdir); + if (containerRoot === ".") { + return undefined; + } + if (workdir === containerRoot) { + return path.resolve(params.sandbox.workspaceDir); + } + if (!workdir.startsWith(`${containerRoot}/`)) { + return undefined; + } + const rel = workdir + .slice(containerRoot.length + 1) + .split("/") + .filter(Boolean); + return path.resolve(params.sandbox.workspaceDir, ...rel); +} + +function normalizeContainerPath(input: string): string { + const normalized = input.trim().replace(/\\/g, "/"); + if (!normalized) { + return "."; + } + const posixPath = path.posix.normalize(normalized); + return posixPath === "/" ? posixPath : posixPath.replace(/\/+$/g, ""); +} + +function joinContainerWorkdir(containerWorkdir: string, relative: string): string { + return relative ? path.posix.join(containerWorkdir, relative) : containerWorkdir; +} + +function hasParentPathSegment(input: string): boolean { + return input + .replace(/\\/g, "/") + .split("/") + .some((segment) => segment === ".."); +} + +function isContainerWorkdirInsideRoot(params: { root: string; workdir: string }): boolean { + const root = normalizeContainerPath(params.root); + const workdir = normalizeContainerPath(params.workdir); + if (root === "/") { + return path.posix.isAbsolute(workdir); + } + return workdir === root || workdir.startsWith(`${root}/`); +} + +function resolveBackendWorkdirRoots(sandbox: BashSandboxConfig): string[] { + const roots: string[] = []; + const addRoot = (root: string | undefined) => { + const normalized = normalizeContainerPath(root ?? ""); + if (normalized === "." || !path.posix.isAbsolute(normalized) || roots.includes(normalized)) { + return; + } + roots.push(normalized); + }; + addRoot(sandbox.containerWorkdir); + for (const root of sandbox.workdirRoots ?? []) { + addRoot(root); + } + return roots; +} + +function resolveBackendContainerWorkdir(params: { + workdir: string; + sandbox: BashSandboxConfig; +}): string | null { + const containerRoot = normalizeContainerPath(params.sandbox.containerWorkdir); + const backendRoots = resolveBackendWorkdirRoots(params.sandbox); + const requested = normalizeContainerPath(params.workdir); + if (path.posix.isAbsolute(requested)) { + return backendRoots.some((root) => isContainerWorkdirInsideRoot({ root, workdir: requested })) + ? requested + : null; + } + if (requested === ".." || requested.startsWith("../")) { + return null; + } + return joinContainerWorkdir(containerRoot, requested === "." ? "" : requested); +} + +async function mapExistingHostWorkspacePath(params: { + hostPath: string; + sandbox: BashSandboxConfig; +}): Promise { + let resolved: Awaited>; + try { + resolved = await assertSandboxPath({ + filePath: params.hostPath, + cwd: params.sandbox.workspaceDir, + root: params.sandbox.workspaceDir, + }); + } catch { + return { kind: "invalid" }; + } + const stats = safeStatSync(resolved.resolved); + if (!stats) { + return { + kind: "missing", + relative: resolved.relative ? resolved.relative.split(path.sep).join(path.posix.sep) : "", + }; + } + if (!stats.isDirectory()) { + return { kind: "invalid" }; + } + const relative = resolved.relative ? resolved.relative.split(path.sep).join(path.posix.sep) : ""; + return { + kind: "available", + workdir: { + hostCwd: resolved.resolved, + containerCwd: joinContainerWorkdir(params.sandbox.containerWorkdir, relative), + scriptPreflightCwd: resolved.resolved, + }, + }; +} + +async function validateBackendWorkdir(params: { + workdir: SandboxWorkdir; + sandbox: BashSandboxConfig; +}): Promise { + const containerCwd = await params.sandbox.validateWorkdir?.(params.workdir.containerCwd); + return containerCwd + ? { + hostCwd: params.workdir.hostCwd, + containerCwd, + scriptPreflightCwd: params.workdir.scriptPreflightCwd, + } + : null; +} + +function resolveBackendHostWorkdirCandidate(params: { + workdir: string; + sandbox: BashSandboxConfig; +}): BackendHostWorkdirCandidate | null { + if (!path.isAbsolute(params.workdir)) { + return { + hostPath: path.resolve(params.sandbox.workspaceDir, params.workdir), + failIfInvalid: false, + }; + } + const hostPath = path.resolve(params.workdir); + if ( + isHostPathInsideRoot({ + root: params.sandbox.workspaceDir, + candidate: hostPath, + }) + ) { + return { hostPath, failIfInvalid: true }; + } + const containerMappedHostPath = mapContainerWorkdirToHost({ + workdir: params.workdir, + sandbox: params.sandbox, + }); + return containerMappedHostPath + ? { hostPath: containerMappedHostPath, failIfInvalid: false } + : null; +} + +async function resolveBackendValidatedSandboxWorkdir(params: { + workdir: string; + sandbox: BashSandboxConfig; +}): Promise { + const workspaceHostCwd = resolveExistingHostWorkdir(params.sandbox.workspaceDir); + if (!workspaceHostCwd) { + return null; + } + const hostCandidate = resolveBackendHostWorkdirCandidate(params); + if (hostCandidate) { + const mappedWorkdir = await mapExistingHostWorkspacePath({ + hostPath: hostCandidate.hostPath, + sandbox: params.sandbox, + }); + if (mappedWorkdir.kind === "available") { + return await validateBackendWorkdir({ + workdir: mappedWorkdir.workdir, + sandbox: params.sandbox, + }); + } + if (mappedWorkdir.kind === "missing") { + return await validateBackendWorkdir({ + workdir: { + hostCwd: workspaceHostCwd, + containerCwd: joinContainerWorkdir( + params.sandbox.containerWorkdir, + mappedWorkdir.relative, + ), + scriptPreflightCwd: null, + }, + sandbox: params.sandbox, + }); + } + if (hostCandidate.failIfInvalid && mappedWorkdir.kind === "invalid") { + return null; + } + } + const containerCwd = resolveBackendContainerWorkdir(params); + if (containerCwd) { + return await validateBackendWorkdir({ + workdir: { + hostCwd: workspaceHostCwd, + containerCwd, + scriptPreflightCwd: null, + }, + sandbox: params.sandbox, + }); + } + return null; +} + +async function resolveHostValidatedSandboxWorkdir(params: { + workdir: string; + sandbox: BashSandboxConfig; +}): Promise { + const mappedHostWorkdir = mapContainerWorkdirToHost({ + workdir: params.workdir, + sandbox: params.sandbox, + }); + const candidateWorkdir = mappedHostWorkdir ?? params.workdir; + try { + const resolved = await assertSandboxPath({ + filePath: candidateWorkdir, + cwd: params.sandbox.workspaceDir, + root: params.sandbox.workspaceDir, + }); + const stats = await fs.stat(resolved.resolved); + if (!stats.isDirectory()) { + return null; + } + const relative = resolved.relative + ? resolved.relative.split(path.sep).join(path.posix.sep) + : ""; + const containerCwd = joinContainerWorkdir(params.sandbox.containerWorkdir, relative); + return { hostCwd: resolved.resolved, containerCwd, scriptPreflightCwd: resolved.resolved }; + } catch { + return null; + } +} + +async function resolveSandboxWorkdir(params: { + workdir: string; + sandbox: BashSandboxConfig; +}): Promise { + if (hasParentPathSegment(params.workdir)) { + return null; + } + if (params.sandbox.workdirValidation === "backend") { + return await resolveBackendValidatedSandboxWorkdir(params); + } + return await resolveHostValidatedSandboxWorkdir(params); +} + +export function formatUnavailableWorkdirFailure(workdir: string): string { + return [ + `workdir "${workdir}" is unavailable or not a directory: command was not executed.`, + 'workdir is treated as a literal path; shell expansions such as "~" are not applied.', + "Use an existing directory, omit an explicit workdir to use the default cwd, or update the configured default cwd.", + ].join(" "); +} + +export async function resolveExecWorkdir(params: { + host: ExecHost; + workdir?: string; + defaultCwd?: string; + sandbox?: BashSandboxConfig; +}): Promise { + const explicitWorkdir = normalizeExplicitWorkdirInput(params.workdir); + if (explicitWorkdir.kind === "blank") { + return unavailable(explicitWorkdir.raw); + } + + if (params.host === "node") { + return explicitWorkdir.kind === "specified" + ? { kind: "node", remoteCwd: explicitWorkdir.value } + : { kind: "node" }; + } + + const defaultCwd = normalizeOptionalString(params.defaultCwd); + if (params.host === "sandbox") { + const sandbox = params.sandbox; + if (!sandbox) { + throw new Error("exec internal error: sandbox workdir resolution requires sandbox config"); + } + const requestedCwd = + explicitWorkdir.kind === "specified" + ? explicitWorkdir.value + : (defaultCwd ?? sandbox.containerWorkdir); + const resolved = await resolveSandboxWorkdir({ workdir: requestedCwd, sandbox }); + return resolved + ? { + kind: "sandbox", + hostCwd: resolved.hostCwd, + containerCwd: resolved.containerCwd, + scriptPreflightCwd: resolved.scriptPreflightCwd, + } + : unavailable(requestedCwd); + } + + const requestedCwd = + explicitWorkdir.kind === "specified" ? explicitWorkdir.value : (defaultCwd ?? safeCurrentCwd()); + if (!requestedCwd) { + return unavailable("current working directory"); + } + const resolved = resolveExistingHostWorkdir(requestedCwd); + return resolved ? { kind: "local", hostCwd: resolved } : unavailable(requestedCwd); +} diff --git a/src/agents/bash-tools.exec.path.test.ts b/src/agents/bash-tools.exec.path.test.ts index 28d5c2f44166..409d79353638 100644 --- a/src/agents/bash-tools.exec.path.test.ts +++ b/src/agents/bash-tools.exec.path.test.ts @@ -216,6 +216,29 @@ describe("exec PATH login shell merge", () => { } }); + it("fails without running when an explicit workdir is unavailable", async () => { + const missingWorkdir = path.join( + os.tmpdir(), + `openclaw-missing-workdir-${process.pid}-${Date.now()}`, + ); + fs.rmSync(missingWorkdir, { recursive: true, force: true }); + + const tool = createExecTool({ host: "gateway", security: "full", ask: "off" }); + const result = await tool.execute("call-missing-workdir", { + command: "echo ok", + workdir: missingWorkdir, + yieldMs: FOREGROUND_TEST_YIELD_MS, + }); + const value = normalizeText(result.content.find((c) => c.type === "text")?.text); + + expect(result.details?.status).toBe("failed"); + expect(value).toContain(`workdir "${missingWorkdir}" is unavailable or not a directory`); + expect(value).toContain("command was not executed"); + expect(value).toContain("workdir is treated as a literal path"); + expect(value).toContain('shell expansions such as "~" are not applied'); + expect(value).not.toMatch(/^ok/); + }); + it("merges login-shell PATH for host=gateway", async () => { if (isWin) { return; diff --git a/src/agents/bash-tools.exec.resolve-env-hook.test.ts b/src/agents/bash-tools.exec.resolve-env-hook.test.ts index c94beadedee0..141773e47bda 100644 --- a/src/agents/bash-tools.exec.resolve-env-hook.test.ts +++ b/src/agents/bash-tools.exec.resolve-env-hook.test.ts @@ -5,6 +5,8 @@ */ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { OPENCLAW_CLI_ENV_VALUE } from "../infra/openclaw-exec-env.js"; +import type { ExecuteNodeHostCommandParams } from "./bash-tools.exec-host-node.types.js"; +import type { BashSandboxConfig } from "./bash-tools.shared.js"; import type { ExtensionContext } from "./sessions/index.js"; declare module "../plugins/hook-types.js" { @@ -14,6 +16,10 @@ declare module "../plugins/hook-types.js" { } const CHANNEL_CONTEXT_ENV_KEY = "OPENCLAW_CHANNEL_CONTEXT"; +type CapturedNodeHostParams = Pick< + ExecuteNodeHostCommandParams, + "env" | "requestedEnv" | "workdir" +>; const mocks = vi.hoisted(() => ({ hookRunner: undefined as @@ -28,10 +34,7 @@ const mocks = vi.hoisted(() => ({ env: Record; requestedEnv?: Record; }>, - nodeHostParams: [] as Array<{ - env: Record; - requestedEnv?: Record; - }>, + nodeHostParams: [] as CapturedNodeHostParams[], spawnInputs: [] as Array<{ env?: Record; }>, @@ -64,10 +67,11 @@ vi.mock("./bash-tools.exec-host-gateway.js", () => ({ vi.mock("./bash-tools.exec-host-node.js", () => ({ executeNodeHostCommand: vi.fn( - async (params: { env: Record; requestedEnv?: Record }) => { + async (params: Pick) => { mocks.nodeHostParams.push({ env: { ...params.env }, requestedEnv: params.requestedEnv ? { ...params.requestedEnv } : undefined, + workdir: params.workdir, }); return { content: [{ type: "text", text: "node ok" }], @@ -272,6 +276,370 @@ describe("exec resolve_exec_env hook wiring", () => { expect(mocks.nodeHostParams[0]?.env).not.toHaveProperty("LD_PRELOAD"); }); + it("does not forward configured gateway cwd defaults to node host requests", async () => { + const tool = createExecTool({ + cwd: "/gateway/default/that/node/cannot/use", + host: "node", + security: "full", + ask: "off", + }); + + await tool.execute("call-node-default-cwd", { + command: "echo ok", + }); + + expect(mocks.nodeHostParams[0]?.workdir).toBeUndefined(); + }); + + it("fails blank explicit node host workdirs before node invocation", async () => { + const tool = createExecTool({ + host: "node", + security: "full", + ask: "off", + }); + + const result = await tool.execute("call-node-blank-cwd", { + command: "echo ok", + workdir: " ", + }); + const text = result.content.find((entry) => entry.type === "text")?.text ?? ""; + + expect((result.details as { status?: unknown } | undefined)?.status).toBe("failed"); + expect(text).toContain('workdir " " is unavailable or not a directory'); + expect(text).toContain("command was not executed"); + expect(mocks.nodeHostParams).toHaveLength(0); + }); + + it("prevalidates node workdirs before resolving exec env when a backend sandbox exists", async () => { + installResolveExecEnvHook({ PLUGIN_SAFE: "yes" }); + const validateWorkdir = vi.fn(async (workdir: string) => workdir); + const tool = createExecTool({ + host: "node", + security: "full", + ask: "off", + sandbox: { + containerName: "remote-sandbox-workdir-test", + workspaceDir: process.cwd(), + containerWorkdir: "/remote/workspace", + workdirValidation: "backend", + validateWorkdir, + }, + }); + + const result = await tool.execute("call-node-invalid-cwd-with-backend-sandbox", { + command: "echo ok", + workdir: " ", + }); + + expect((result.details as { status?: unknown } | undefined)?.status).toBe("failed"); + expect(mocks.hookRunner?.runResolveExecEnv).not.toHaveBeenCalled(); + expect(validateWorkdir).not.toHaveBeenCalled(); + expect(mocks.nodeHostParams).toHaveLength(0); + }); + + it("fails invalid workdirs before resolving exec env", async () => { + installResolveExecEnvHook({ PLUGIN_SAFE: "yes" }); + const tool = createExecTool({ + host: "gateway", + security: "full", + ask: "off", + }); + + const result = await tool.execute("call-invalid-cwd-before-env", { + command: "echo ok", + workdir: " ", + }); + + expect((result.details as { status?: unknown } | undefined)?.status).toBe("failed"); + expect(mocks.hookRunner?.runResolveExecEnv).not.toHaveBeenCalled(); + expect(mocks.gatewayParams).toHaveLength(0); + expect(mocks.spawnInputs).toHaveLength(0); + }); + + it("prevalidates gateway workdirs before resolving exec env when a backend sandbox exists", async () => { + installResolveExecEnvHook({ PLUGIN_SAFE: "yes" }); + const validateWorkdir = vi.fn(async (workdir: string) => workdir); + const tool = createExecTool({ + host: "gateway", + security: "full", + ask: "off", + sandbox: { + containerName: "remote-sandbox-workdir-test", + workspaceDir: process.cwd(), + containerWorkdir: "/remote/workspace", + workdirValidation: "backend", + validateWorkdir, + }, + }); + + const result = await tool.execute("call-gateway-invalid-cwd-with-backend-sandbox", { + command: "echo ok", + workdir: " ", + }); + + expect((result.details as { status?: unknown } | undefined)?.status).toBe("failed"); + expect(mocks.hookRunner?.runResolveExecEnv).not.toHaveBeenCalled(); + expect(validateWorkdir).not.toHaveBeenCalled(); + expect(mocks.gatewayParams).toHaveLength(0); + expect(mocks.spawnInputs).toHaveLength(0); + }); + + it("lets before_tool_call see invalid wrapped workdirs before failing unchanged params", async () => { + mocks.hookRunner = { + hasHooks: vi.fn( + (hookName: string) => hookName === "resolve_exec_env" || hookName === "before_tool_call", + ), + runResolveExecEnv: vi.fn(async () => ({ PLUGIN_SAFE: "yes" })), + runBeforeToolCall: vi.fn(async () => undefined), + }; + const tool = createExecTool({ + host: "gateway", + security: "full", + ask: "off", + sessionKey: "agent:main:telegram:chat-1", + }); + const [definition] = toToolDefinitions([tool], { + agentId: "main", + sessionKey: "agent:main:telegram:chat-1", + }); + + const result = await definition.execute( + "call-invalid-wrapped-cwd-before-hooks", + { + command: "echo ok", + workdir: " ", + }, + undefined, + undefined, + testExtensionContext, + ); + const text = result.content.find((entry) => entry.type === "text")?.text ?? ""; + + expect((result.details as { status?: unknown } | undefined)?.status).toBe("failed"); + expect(text).toContain('workdir " " is unavailable or not a directory'); + expect(mocks.hookRunner.runBeforeToolCall!).toHaveBeenCalledTimes(1); + expect(mocks.hookRunner.runResolveExecEnv!).not.toHaveBeenCalled(); + expect(mocks.gatewayParams).toHaveLength(0); + expect(mocks.spawnInputs).toHaveLength(0); + }); + + it("does not validate backend sandbox workdirs before before_tool_call veto", async () => { + const validateWorkdir = vi.fn(async (workdir: string) => workdir); + mocks.hookRunner = { + hasHooks: vi.fn((hookName: string) => hookName === "before_tool_call"), + runBeforeToolCall: vi.fn(async () => ({ + block: true, + blockReason: "blocked by test hook", + })), + }; + const tool = createExecTool({ + host: "sandbox", + security: "full", + ask: "off", + sandbox: { + containerName: "remote-sandbox-workdir-test", + workspaceDir: process.cwd(), + containerWorkdir: "/remote/workspace", + workdirValidation: "backend", + validateWorkdir, + }, + }); + const [definition] = toToolDefinitions([tool], { + agentId: "main", + sessionKey: "agent:main:telegram:chat-1", + }); + + const result = await definition.execute( + "call-backend-cwd-vetoed-before-validation", + { + command: "echo ok", + workdir: "/remote/workspace/generated", + }, + undefined, + undefined, + testExtensionContext, + ); + + expect( + result.details as { status?: unknown; deniedReason?: unknown } | undefined, + ).toMatchObject({ + status: "blocked", + deniedReason: "plugin-before-tool-call", + }); + expect(mocks.hookRunner.runBeforeToolCall!).toHaveBeenCalledOnce(); + expect(validateWorkdir).not.toHaveBeenCalled(); + expect(mocks.gatewayParams).toHaveLength(0); + expect(mocks.spawnInputs).toHaveLength(0); + }); + + it("defers resolve_exec_env for backend sandboxes until workdir validation succeeds", async () => { + const validateWorkdir = vi.fn(async () => null); + mocks.hookRunner = { + hasHooks: vi.fn( + (hookName: string) => hookName === "resolve_exec_env" || hookName === "before_tool_call", + ), + runResolveExecEnv: vi.fn(async () => ({ PLUGIN_SAFE: "yes" })), + runBeforeToolCall: vi.fn(async () => undefined), + }; + const tool = createExecTool({ + host: "sandbox", + security: "full", + ask: "off", + sandbox: { + containerName: "remote-sandbox-workdir-test", + workspaceDir: process.cwd(), + containerWorkdir: "/remote/workspace", + workdirValidation: "backend", + validateWorkdir, + }, + }); + const [definition] = toToolDefinitions([tool], { + agentId: "main", + sessionKey: "agent:main:telegram:chat-1", + }); + + const result = await definition.execute( + "call-backend-invalid-cwd-before-env", + { + command: "echo ok", + workdir: "/remote/workspace/missing", + }, + undefined, + undefined, + testExtensionContext, + ); + + expect((result.details as { status?: unknown } | undefined)?.status).toBe("failed"); + expect(mocks.hookRunner.runBeforeToolCall!).toHaveBeenCalledOnce(); + expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/missing"); + expect(mocks.hookRunner.runResolveExecEnv!).not.toHaveBeenCalled(); + expect(mocks.gatewayParams).toHaveLength(0); + expect(mocks.spawnInputs).toHaveLength(0); + }); + + it("preserves hook context when backend sandbox env resolution is deferred", async () => { + const validateWorkdir = vi.fn(async (workdir: string) => workdir); + const buildExecSpec = vi.fn>( + async (params) => ({ + argv: ["remote-shell", params.command], + env: {}, + stdinMode: "pipe-open" as const, + }), + ); + mocks.hookRunner = { + hasHooks: vi.fn( + (hookName: string) => hookName === "resolve_exec_env" || hookName === "before_tool_call", + ), + runResolveExecEnv: vi.fn(async () => ({ PLUGIN_SAFE: "yes" })), + runBeforeToolCall: vi.fn(async () => undefined), + }; + const tool = createExecTool({ + host: "sandbox", + security: "full", + ask: "off", + sandbox: { + containerName: "remote-sandbox-workdir-test", + workspaceDir: process.cwd(), + containerWorkdir: "/remote/workspace", + workdirValidation: "backend", + validateWorkdir, + buildExecSpec, + }, + }); + const [definition] = toToolDefinitions([tool], { + agentId: "ctx-agent", + sessionKey: "agent:ctx-agent:telegram:chat-2", + channelId: "ctx-channel", + }); + + const result = await definition.execute( + "call-backend-deferred-env-context", + { + command: "echo ok", + workdir: "/remote/workspace/generated", + }, + undefined, + undefined, + testExtensionContext, + ); + + expect((result.details as { status?: unknown } | undefined)?.status).toBe("completed"); + expect(validateWorkdir).toHaveBeenCalledWith("/remote/workspace/generated"); + expect(mocks.hookRunner.runBeforeToolCall!).toHaveBeenCalledOnce(); + expect(mocks.hookRunner.runResolveExecEnv!).toHaveBeenCalledOnce(); + expect(mocks.hookRunner.runResolveExecEnv!.mock.calls[0]?.[0]).toMatchObject({ + sessionKey: "agent:ctx-agent:telegram:chat-2", + toolName: "exec", + host: "sandbox", + }); + expect(mocks.hookRunner.runResolveExecEnv!.mock.calls[0]?.[1]).toMatchObject({ + agentId: "ctx-agent", + sessionKey: "agent:ctx-agent:telegram:chat-2", + channelId: "ctx-channel", + }); + expect(buildExecSpec.mock.calls[0]?.[0]?.env).toMatchObject({ + PLUGIN_SAFE: "yes", + }); + }); + + it("lets lazy before_tool_call see invalid workdirs before failing unchanged params", async () => { + mocks.hookRunner = { + hasHooks: vi.fn( + (hookName: string) => hookName === "resolve_exec_env" || hookName === "before_tool_call", + ), + runResolveExecEnv: vi.fn(async () => ({ LAZY_PLUGIN_SAFE: "yes" })), + runBeforeToolCall: vi.fn(async () => undefined), + }; + + const exec = createOpenClawCodingTools({ + agentId: "main", + sessionKey: "agent:main:telegram:chat-1", + cwd: process.cwd(), + exec: { host: "gateway", security: "full", ask: "off" }, + }).find((tool) => tool.name === "exec"); + expect(exec).toBeDefined(); + const [definition] = toToolDefinitions([exec!], { + agentId: "main", + sessionKey: "agent:main:telegram:chat-1", + channelId: "chat-1", + }); + + const result = await definition.execute( + "call-invalid-lazy-cwd-before-hooks", + { + command: "echo ok", + workdir: " ", + }, + undefined, + undefined, + testExtensionContext, + ); + const text = result.content.find((entry) => entry.type === "text")?.text ?? ""; + + expect((result.details as { status?: unknown } | undefined)?.status).toBe("failed"); + expect(text).toContain('workdir " " is unavailable or not a directory'); + expect(mocks.hookRunner.runBeforeToolCall!).toHaveBeenCalledTimes(1); + expect(mocks.hookRunner.runResolveExecEnv!).not.toHaveBeenCalled(); + expect(mocks.gatewayParams).toHaveLength(0); + expect(mocks.spawnInputs).toHaveLength(0); + }); + + it("forwards explicit node host workdirs without local gateway validation", async () => { + const remoteWorkdir = "/remote/node/workspace"; + const tool = createExecTool({ + host: "node", + security: "full", + ask: "off", + }); + + await tool.execute("call-node-explicit-cwd", { + command: "echo ok", + workdir: remoteWorkdir, + }); + + expect(mocks.nodeHostParams[0]?.workdir).toBe(remoteWorkdir); + }); + it("keeps plugin env out of before_tool_call params before execution", async () => { mocks.hookRunner = { hasHooks: vi.fn( @@ -422,6 +790,57 @@ describe("exec resolve_exec_env hook wiring", () => { expect(mocks.nodeHostParams[0]?.requestedEnv).not.toHaveProperty("GATEWAY_PLUGIN_SAFE"); }); + it("lets before_tool_call reroute gateway-invalid workdirs to node host execution", async () => { + mocks.hookRunner = { + hasHooks: vi.fn( + (hookName: string) => hookName === "resolve_exec_env" || hookName === "before_tool_call", + ), + runResolveExecEnv: vi.fn(async (event: { host: "gateway" | "sandbox" | "node" }) => + event.host === "node" ? { NODE_PLUGIN_SAFE: "node" } : { GATEWAY_PLUGIN_SAFE: "gateway" }, + ), + runBeforeToolCall: vi.fn(async (event: { params: Record }) => ({ + params: { ...event.params, host: "node" }, + })), + }; + + const tool = createExecTool({ + host: "auto", + security: "full", + ask: "off", + sessionKey: "agent:main:telegram:chat-1", + }); + const [definition] = toToolDefinitions([tool], { + agentId: "main", + sessionKey: "agent:main:telegram:chat-1", + }); + + await definition.execute( + "call-host-rewrite-with-remote-cwd", + { + command: "echo ok", + env: { REQUEST_SAFE: "request" }, + workdir: "/remote/node/workspace", + }, + undefined, + undefined, + testExtensionContext, + ); + + expect(mocks.hookRunner.runBeforeToolCall!).toHaveBeenCalledOnce(); + expect(mocks.hookRunner.runResolveExecEnv!).toHaveBeenCalledOnce(); + expect(mocks.hookRunner.runResolveExecEnv!).toHaveBeenCalledWith( + expect.objectContaining({ host: "node" }), + expect.anything(), + ); + expect(mocks.nodeHostParams[0]?.requestedEnv).toEqual({ + NODE_PLUGIN_SAFE: "node", + REQUEST_SAFE: "request", + }); + expect(mocks.nodeHostParams[0]?.workdir).toBe("/remote/node/workspace"); + expect(mocks.gatewayParams).toHaveLength(0); + expect(mocks.spawnInputs).toHaveLength(0); + }); + it("lets before_tool_call rewrite host when no resolve_exec_env hook is registered", async () => { mocks.hookRunner = { hasHooks: vi.fn((hookName: string) => hookName === "before_tool_call"), diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 664c59327dd9..c61564c83629 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -63,23 +63,28 @@ import { DEFAULT_MAX_OUTPUT, DEFAULT_PATH, DEFAULT_PENDING_MAX_OUTPUT, + type ExecProcessHandle, type ExecProcessOutcome, applyPathPrepend, applyShellPath, normalizePathPrepend, resolveExecTarget, resolveApprovalRunningNoticeMs, + buildExecRuntimeErrorOutcome, runExecProcess, execSchema, } from "./bash-tools.exec-runtime.js"; import type { ExecToolDefaults, ExecToolDetails } from "./bash-tools.exec-types.js"; +import { + type ExecWorkdirResolution, + formatUnavailableWorkdirFailure, + resolveExecWorkdir, +} from "./bash-tools.exec-workdir.js"; import { buildSandboxEnv, clampWithDefault, coerceEnv, readEnvInt, - resolveSandboxWorkdir, - resolveWorkdir, truncateMiddle, } from "./bash-tools.shared.js"; import { createModelExecAutoReviewer } from "./exec-auto-reviewer.js"; @@ -138,6 +143,22 @@ type ResolvedExecEnvPreparedState = { pluginEnv?: Record; }; const resolvedExecEnvPreparedStates = new WeakMap(); +type DeferredResolveExecEnvPreparedState = { + hookContext?: HookContext; +}; +const deferredResolveExecEnvPreparedStates = new WeakMap< + ExecToolArgs, + DeferredResolveExecEnvPreparedState +>(); +type ResolvedExecWorkdirPreparedState = { + host: ExecHost; + inputWorkdir?: string; + resolution: ExecWorkdirResolution; +}; +const resolvedExecWorkdirPreparedStates = new WeakMap< + ExecToolArgs, + ResolvedExecWorkdirPreparedState +>(); const XML_ARG_VALUE_EXEC_PARAM_KEYS = [ "command", @@ -148,6 +169,10 @@ const XML_ARG_VALUE_EXEC_PARAM_KEYS = [ "node", ] as const; +function isExecToolArgsObject(value: unknown): value is ExecToolArgs { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + function filterPluginExecEnv(rawEnv: Record): Record | undefined { const env: Record = {}; for (const [rawKey, value] of Object.entries(rawEnv)) { @@ -187,6 +212,34 @@ function isResolveExecEnvPrepared(params: ExecToolArgs): boolean { return Boolean(getResolvedExecEnvPreparedState(params)); } +function markDeferredResolveExecEnvPrepared( + params: T, + state: DeferredResolveExecEnvPreparedState, +): T { + deferredResolveExecEnvPreparedStates.set(params, state); + return params; +} + +function getDeferredResolveExecEnvPreparedState( + params: ExecToolArgs, +): DeferredResolveExecEnvPreparedState | undefined { + return deferredResolveExecEnvPreparedStates.get(params); +} + +function markResolvedExecWorkdirPrepared( + params: T, + state: ResolvedExecWorkdirPreparedState, +): T { + resolvedExecWorkdirPreparedStates.set(params, state); + return params; +} + +function getResolvedExecWorkdirPreparedState( + params: ExecToolArgs, +): ResolvedExecWorkdirPreparedState | undefined { + return resolvedExecWorkdirPreparedStates.get(params); +} + function buildExecForegroundResult(params: { outcome: ExecProcessOutcome; cwd?: string; @@ -1325,6 +1378,62 @@ export function createExecTool( sandboxAvailable: Boolean(defaults?.sandbox), }).effectiveHost; }; + const buildUnavailableWorkdirResult = (params: { + cwd: string; + startedAt?: number; + warningText?: string; + }) => + buildExecForegroundResult({ + outcome: buildExecRuntimeErrorOutcome({ + error: formatUnavailableWorkdirFailure(params.cwd), + aggregated: "", + durationMs: params.startedAt ? Date.now() - params.startedAt : 0, + }), + cwd: params.cwd, + warningText: params.warningText, + }); + const prepareParamsWithResolvedExecWorkdir = async (rawArgs: unknown): Promise => { + if (typeof rawArgs !== "object" || rawArgs === null || Array.isArray(rawArgs)) { + return rawArgs as ExecToolArgs; + } + const params = stripMalformedXmlArgValueSuffixFromKeys( + rawArgs as ExecToolArgs, + XML_ARG_VALUE_EXEC_PARAM_KEYS, + ); + let host: ExecHost; + try { + host = resolveHostForParams(params); + } catch { + return params; + } + if (host === "sandbox" && !defaults?.sandbox) { + return params; + } + if (host === "sandbox" && defaults?.sandbox?.workdirValidation === "backend") { + return params; + } + const resolution = await resolveExecWorkdir({ + host, + workdir: params.workdir, + defaultCwd: defaults?.cwd, + sandbox: defaults?.sandbox, + }); + return markResolvedExecWorkdirPrepared(params, { + host, + inputWorkdir: params.workdir, + resolution, + }); + }; + const shouldDeferResolveExecEnvUntilWorkdirValidated = (params: ExecToolArgs): boolean => { + try { + return ( + resolveHostForParams(params) === "sandbox" && + defaults?.sandbox?.workdirValidation === "backend" + ); + } catch { + return false; + } + }; const prepareParamsWithResolvedExecEnv = async ( rawArgs: unknown, context?: { hookContext?: HookContext }, @@ -1385,41 +1494,85 @@ export function createExecTool( return describeExecTool({ agentId, hasCronTool: defaults?.hasCronTool === true }); }, parameters: execSchema, - prepareBeforeToolCallParams: async (args, context) => - prepareParamsWithResolvedExecEnv(args, { + prepareBeforeToolCallParams: async (args, context) => { + const params = await prepareParamsWithResolvedExecWorkdir(args); + const workdirState = getResolvedExecWorkdirPreparedState(params); + if (workdirState?.resolution.kind === "unavailable") { + return params; + } + if (!isExecToolArgsObject(params)) { + return params; + } + if (shouldDeferResolveExecEnvUntilWorkdirValidated(params)) { + return markDeferredResolveExecEnvPrepared(params, { + hookContext: context.hookContext as HookContext | undefined, + }); + } + return prepareParamsWithResolvedExecEnv(params, { hookContext: context.hookContext as HookContext | undefined, - }), - finalizeBeforeToolCallParams: (params, preparedParams) => - (() => { - const state = getResolvedExecEnvPreparedState(preparedParams as ExecToolArgs); - if (!state) { - return params; - } - const execParams = params as ExecToolArgs; - if (state.host && execParams.command && resolveHostForParams(execParams) !== state.host) { + }); + }, + finalizeBeforeToolCallParams: (params, preparedParams) => { + const envState = getResolvedExecEnvPreparedState(preparedParams as ExecToolArgs); + const deferredEnvState = getDeferredResolveExecEnvPreparedState( + preparedParams as ExecToolArgs, + ); + const workdirState = getResolvedExecWorkdirPreparedState(preparedParams as ExecToolArgs); + if (!envState && !deferredEnvState && !workdirState) { + return params; + } + if (!isExecToolArgsObject(params)) { + return params; + } + const execParams = params; + let host: ExecHost | undefined; + const resolveFinalHost = () => { + host ??= resolveHostForParams(execParams); + return host; + }; + try { + if (envState?.host && execParams.command && resolveFinalHost() !== envState.host) { return { ...execParams }; } - return markResolveExecEnvPrepared(execParams, state); - })(), - execute: async (_toolCallId, args, signal, onUpdate) => { - const params = isResolveExecEnvPrepared(args as ExecToolArgs) - ? stripMalformedXmlArgValueSuffixFromKeys( - args as ExecToolArgs, - XML_ARG_VALUE_EXEC_PARAM_KEYS, - ) - : await prepareParamsWithResolvedExecEnv(args); - - if (!params.command) { - throw new Error("Provide a command to start."); + if ( + workdirState && + (resolveFinalHost() !== workdirState.host || + execParams.workdir !== workdirState.inputWorkdir) + ) { + return { ...execParams }; + } + } catch { + return { ...execParams }; } + if (envState) { + markResolveExecEnvPrepared(execParams, envState); + } + if (deferredEnvState) { + markDeferredResolveExecEnvPrepared(execParams, deferredEnvState); + } + if (workdirState) { + markResolvedExecWorkdirPrepared(execParams, workdirState); + } + return execParams; + }, + execute: async (_toolCallId, args, signal, onUpdate) => { + let params = stripMalformedXmlArgValueSuffixFromKeys( + args as ExecToolArgs, + XML_ARG_VALUE_EXEC_PARAM_KEYS, + ); + const resolveExecEnvPrepared = isResolveExecEnvPrepared(args as ExecToolArgs); + const deferredResolveExecEnvState = getDeferredResolveExecEnvPreparedState(params); + const preparedWorkdirState = getResolvedExecWorkdirPreparedState(params); const maxOutput = DEFAULT_MAX_OUTPUT; const pendingMaxOutput = DEFAULT_PENDING_MAX_OUTPUT; const warnings: string[] = []; + const getWarningText = () => (warnings.length ? `${warnings.join("\n")}\n\n` : ""); const approvalWarningText = normalizeOptionalString(defaults?.approvalWarningText); if (approvalWarningText) { warnings.push(approvalWarningText); } + const startedAt = Date.now(); let execCommandOverride: string | undefined; const backgroundRequested = params.background === true; const yieldRequested = typeof params.yieldMs === "number"; @@ -1492,9 +1645,6 @@ export function createExecTool( ); } } - if (elevatedRequested) { - logInfo(`exec: elevated command ${truncateMiddle(params.command, 120)}`); - } const requestedTarget = requireValidExecTarget(params.host); const target = resolveExecTarget({ configuredTarget: defaults?.host, @@ -1567,242 +1717,271 @@ export function createExecTool( ].join("\n"), ); } - const explicitWorkdir = normalizeOptionalString(params.workdir); - const defaultWorkdir = normalizeOptionalString(defaults?.cwd); - let workdir: string | undefined; - let containerWorkdir = sandbox?.containerWorkdir; - if (sandbox) { - const sandboxWorkdir = explicitWorkdir ?? defaultWorkdir ?? process.cwd(); - const resolved = await resolveSandboxWorkdir({ - workdir: sandboxWorkdir, - sandbox, - warnings, - }); - workdir = resolved.hostWorkdir; - containerWorkdir = resolved.containerWorkdir; - } else if (host === "node") { - // For remote node execution, only forward a cwd that was explicitly - // requested on the tool call. The gateway's workspace root is wired in as a - // local default, but it is not meaningful on the remote node and would - // recreate the cross-platform approval failure this path is fixing. - // When no explicit cwd was given, the gateway's own - // process.cwd() is meaningless on the remote node (especially cross-platform, - // e.g. Linux gateway + Windows node) and would cause - // "SYSTEM_RUN_DENIED: approval requires an existing canonical cwd". - // Passing undefined lets the node use its own default working directory. - workdir = explicitWorkdir; - } else { - const rawWorkdir = explicitWorkdir ?? defaultWorkdir ?? process.cwd(); - workdir = resolveWorkdir(rawWorkdir, warnings); + if (!params.command) { + throw new Error("Provide a command to start."); } await rejectUnsafeExecControlShellCommand(params.command); - - const inheritedBaseEnv = coerceEnv(process.env); - const resolvedExecEnvState = getResolvedExecEnvPreparedState(params); - const channelContextEnv = buildChannelContextEnv(defaults?.channelContext); - const requestedEnv: Record | undefined = - params.env !== undefined || - resolvedExecEnvState?.pluginEnv !== undefined || - channelContextEnv !== undefined - ? { ...params.env, ...resolvedExecEnvState?.pluginEnv, ...channelContextEnv } - : undefined; - const hostEnvResult = - host === "sandbox" - ? null - : sanitizeHostExecEnvWithDiagnostics({ - baseEnv: inheritedBaseEnv, - overrides: requestedEnv, - blockPathOverrides: true, + let workdir: string | undefined; + let scriptPreflightCwd: string | null = null; + let containerWorkdir = sandbox?.containerWorkdir; + let discardPreparedSandboxWorkdir: (() => void) | null = null; + const workdirResolution = + preparedWorkdirState?.host === host + ? preparedWorkdirState.resolution + : await resolveExecWorkdir({ + host, + workdir: params.workdir, + defaultCwd: defaults?.cwd, + sandbox, }); - if ( - hostEnvResult && - requestedEnv && - (hostEnvResult.rejectedOverrideBlockedKeys.length > 0 || - hostEnvResult.rejectedOverrideInvalidKeys.length > 0) - ) { - const blockedKeys = hostEnvResult.rejectedOverrideBlockedKeys; - const invalidKeys = hostEnvResult.rejectedOverrideInvalidKeys; - const pathBlocked = blockedKeys.includes("PATH"); - if (pathBlocked && blockedKeys.length === 1 && invalidKeys.length === 0) { - throw new Error( - "Security Violation: Custom 'PATH' variable is forbidden during host execution.", - ); - } - if (blockedKeys.length === 1 && invalidKeys.length === 0) { - throw new Error( - `Security Violation: Environment variable '${blockedKeys[0]}' is forbidden during host execution.`, - ); - } - const details: string[] = []; - if (blockedKeys.length > 0) { - details.push(`blocked override keys: ${blockedKeys.join(", ")}`); - } - if (invalidKeys.length > 0) { - details.push(`invalid non-portable override keys: ${invalidKeys.join(", ")}`); - } - const suffix = details.join("; "); - if (pathBlocked) { - throw new Error( - `Security Violation: Custom 'PATH' variable is forbidden during host execution (${suffix}).`, - ); - } - throw new Error(`Security Violation: ${suffix}.`); - } - - const env = - sandbox && host === "sandbox" - ? buildSandboxEnv({ - defaultPath: DEFAULT_PATH, - paramsEnv: requestedEnv, - sandboxEnv: sandbox.env, - containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir, - }) - : (hostEnvResult?.env ?? inheritedBaseEnv); - - if (!sandbox && host === "gateway" && !requestedEnv?.PATH) { - const shellPath = getShellPathFromLoginShell({ - env: process.env, - timeoutMs: resolveShellEnvFallbackTimeoutMs(process.env), + if (workdirResolution.kind === "unavailable") { + return buildUnavailableWorkdirResult({ + cwd: workdirResolution.requestedCwd, + startedAt, + warningText: warnings.join("\n"), }); - applyShellPath(env, shellPath); } - - // `tools.exec.pathPrepend` is only meaningful when exec runs locally (gateway) or in the sandbox. - // Node hosts intentionally ignore request-scoped PATH overrides, so don't pretend this applies. - if (host === "node" && defaultPathPrepend.length > 0) { - warnings.push( - "Warning: tools.exec.pathPrepend is ignored for host=node. Configure PATH on the node host/service instead.", - ); + if (workdirResolution.kind === "sandbox") { + workdir = workdirResolution.hostCwd; + containerWorkdir = workdirResolution.containerCwd; + scriptPreflightCwd = workdirResolution.scriptPreflightCwd; + if (sandbox?.discardPreparedWorkdir && sandbox.workdirValidation === "backend") { + const preparedContainerWorkdir = containerWorkdir; + discardPreparedSandboxWorkdir = () => { + sandbox.discardPreparedWorkdir?.(preparedContainerWorkdir); + }; + } + } else if (workdirResolution.kind === "local") { + workdir = workdirResolution.hostCwd; + scriptPreflightCwd = workdirResolution.hostCwd; } else { - applyPathPrepend(env, defaultPathPrepend); + workdir = workdirResolution.remoteCwd; } + let run: ExecProcessHandle; + let effectiveTimeout: number; + try { + if (elevatedRequested) { + logInfo(`exec: elevated command ${truncateMiddle(params.command, 120)}`); + } + if (!resolveExecEnvPrepared) { + params = await prepareParamsWithResolvedExecEnv(params, { + hookContext: deferredResolveExecEnvState?.hookContext, + }); + } - if (host === "node") { - return executeNodeHostCommand({ - command: params.command, - workdir, - env, - requestedEnv, - requestedNode: params.node?.trim(), - boundNode: defaults?.node?.trim(), - sessionKey: defaults?.sessionKey, - sessionId: defaults?.sessionId, - sessionStore: defaults?.sessionStore, - bashElevated: elevatedDefaults, - approvalReviewerDeviceId: defaults?.approvalReviewerDeviceId, - turnSourceChannel: defaults?.messageProvider, - turnSourceTo: defaults?.currentChannelId, - turnSourceAccountId: defaults?.accountId, - turnSourceThreadId: defaults?.currentThreadTs, - agentId, - security, - ask, - autoReview, - autoReviewer, - strictInlineEval: defaults?.strictInlineEval, - commandHighlighting: defaults?.commandHighlighting, - trigger: defaults?.trigger, - timeoutSec: params.timeout, - defaultTimeoutSec, - approvalRunningNoticeMs, - warnings, - notifySessionKey, - notifyOnExit, - trustedSafeBinDirs, - }); - } - - if (!workdir) { - throw new Error("exec internal error: local execution requires a resolved workdir"); - } - - if (host === "gateway" && !bypassApprovals) { - const gatewayResult = await processGatewayAllowlist({ + const inheritedBaseEnv = coerceEnv(process.env); + const resolvedExecEnvState = getResolvedExecEnvPreparedState(params); + const channelContextEnv = buildChannelContextEnv(defaults?.channelContext); + const requestedEnv: Record | undefined = + params.env !== undefined || + resolvedExecEnvState?.pluginEnv !== undefined || + channelContextEnv !== undefined + ? { ...params.env, ...resolvedExecEnvState?.pluginEnv, ...channelContextEnv } + : undefined; + const hostEnvResult = + host === "sandbox" + ? null + : sanitizeHostExecEnvWithDiagnostics({ + baseEnv: inheritedBaseEnv, + overrides: requestedEnv, + blockPathOverrides: true, + }); + if ( + hostEnvResult && + requestedEnv && + (hostEnvResult.rejectedOverrideBlockedKeys.length > 0 || + hostEnvResult.rejectedOverrideInvalidKeys.length > 0) + ) { + const blockedKeys = hostEnvResult.rejectedOverrideBlockedKeys; + const invalidKeys = hostEnvResult.rejectedOverrideInvalidKeys; + const pathBlocked = blockedKeys.includes("PATH"); + if (pathBlocked && blockedKeys.length === 1 && invalidKeys.length === 0) { + throw new Error( + "Security Violation: Custom 'PATH' variable is forbidden during host execution.", + ); + } + if (blockedKeys.length === 1 && invalidKeys.length === 0) { + throw new Error( + `Security Violation: Environment variable '${blockedKeys[0]}' is forbidden during host execution.`, + ); + } + const details: string[] = []; + if (blockedKeys.length > 0) { + details.push(`blocked override keys: ${blockedKeys.join(", ")}`); + } + if (invalidKeys.length > 0) { + details.push(`invalid non-portable override keys: ${invalidKeys.join(", ")}`); + } + const suffix = details.join("; "); + if (pathBlocked) { + throw new Error( + `Security Violation: Custom 'PATH' variable is forbidden during host execution (${suffix}).`, + ); + } + throw new Error(`Security Violation: ${suffix}.`); + } + + const env = + sandbox && host === "sandbox" + ? buildSandboxEnv({ + defaultPath: DEFAULT_PATH, + paramsEnv: requestedEnv, + sandboxEnv: sandbox.env, + containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir, + }) + : (hostEnvResult?.env ?? inheritedBaseEnv); + + if (!sandbox && host === "gateway" && !requestedEnv?.PATH) { + const shellPath = getShellPathFromLoginShell({ + env: process.env, + timeoutMs: resolveShellEnvFallbackTimeoutMs(process.env), + }); + applyShellPath(env, shellPath); + } + + // `tools.exec.pathPrepend` is only meaningful when exec runs locally (gateway) or in the sandbox. + // Node hosts intentionally ignore request-scoped PATH overrides, so don't pretend this applies. + if (host === "node" && defaultPathPrepend.length > 0) { + warnings.push( + "Warning: tools.exec.pathPrepend is ignored for host=node. Configure PATH on the node host/service instead.", + ); + } else { + applyPathPrepend(env, defaultPathPrepend); + } + + if (host === "node") { + return executeNodeHostCommand({ + command: params.command, + workdir, + env, + requestedEnv, + requestedNode: params.node?.trim(), + boundNode: defaults?.node?.trim(), + sessionKey: defaults?.sessionKey, + sessionId: defaults?.sessionId, + sessionStore: defaults?.sessionStore, + bashElevated: elevatedDefaults, + approvalReviewerDeviceId: defaults?.approvalReviewerDeviceId, + turnSourceChannel: defaults?.messageProvider, + turnSourceTo: defaults?.currentChannelId, + turnSourceAccountId: defaults?.accountId, + turnSourceThreadId: defaults?.currentThreadTs, + agentId, + security, + ask, + autoReview, + autoReviewer, + strictInlineEval: defaults?.strictInlineEval, + commandHighlighting: defaults?.commandHighlighting, + trigger: defaults?.trigger, + timeoutSec: params.timeout, + defaultTimeoutSec, + approvalRunningNoticeMs, + warnings, + notifySessionKey, + notifyOnExit, + trustedSafeBinDirs, + }); + } + + if (!workdir) { + throw new Error("exec internal error: local execution requires a resolved workdir"); + } + + if (host === "gateway" && !bypassApprovals) { + const gatewayResult = await processGatewayAllowlist({ + command: params.command, + workdir, + env, + pathPrepend: defaultPathPrepend, + requestedEnv, + pty: params.pty === true && !sandbox, + timeoutSec: params.timeout, + defaultTimeoutSec, + security, + ask, + autoReview, + autoReviewer, + safeBins, + safeBinProfiles, + strictInlineEval: defaults?.strictInlineEval, + commandHighlighting: defaults?.commandHighlighting, + trigger: defaults?.trigger, + agentId, + sessionKey: defaults?.sessionKey, + sessionId: defaults?.sessionId, + sessionStore: defaults?.sessionStore, + bashElevated: elevatedDefaults, + approvalReviewerDeviceId: defaults?.approvalReviewerDeviceId, + turnSourceChannel: defaults?.messageProvider, + turnSourceTo: defaults?.currentChannelId, + turnSourceAccountId: defaults?.accountId, + turnSourceThreadId: defaults?.currentThreadTs, + scopeKey: defaults?.scopeKey, + approvalFollowupText: defaults?.approvalFollowupText, + approvalFollowup: defaults?.approvalFollowup, + approvalFollowupMode: defaults?.approvalFollowupMode, + warnings, + notifySessionKey, + approvalRunningNoticeMs, + maxOutput, + pendingMaxOutput, + trustedSafeBinDirs, + }); + if (gatewayResult.pendingResult) { + return gatewayResult.pendingResult; + } + if (gatewayResult.deniedResult) { + return gatewayResult.deniedResult; + } + execCommandOverride = gatewayResult.execCommandOverride; + if (gatewayResult.allowWithoutEnforcedCommand) { + execCommandOverride = undefined; + } + } + + const explicitTimeoutSec = typeof params.timeout === "number" ? params.timeout : null; + effectiveTimeout = explicitTimeoutSec ?? defaultTimeoutSec; + const usePty = params.pty === true && !sandbox; + + // Preflight: catch a common model failure mode (shell syntax leaking into Python/JS sources) + // before we execute and burn tokens in cron loops. + if (scriptPreflightCwd && !shouldSkipExecScriptPreflight({ host, security, ask })) { + await validateScriptFileForShellBleed({ + command: params.command, + workdir: scriptPreflightCwd, + }); + } + + run = await runExecProcess({ command: params.command, + execCommand: execCommandOverride, workdir, env, pathPrepend: defaultPathPrepend, - requestedEnv, - pty: params.pty === true && !sandbox, - timeoutSec: params.timeout, - defaultTimeoutSec, - security, - ask, - autoReview, - autoReviewer, - safeBins, - safeBinProfiles, - strictInlineEval: defaults?.strictInlineEval, - commandHighlighting: defaults?.commandHighlighting, - trigger: defaults?.trigger, - agentId, - sessionKey: defaults?.sessionKey, - sessionId: defaults?.sessionId, - sessionStore: defaults?.sessionStore, - bashElevated: elevatedDefaults, - approvalReviewerDeviceId: defaults?.approvalReviewerDeviceId, - turnSourceChannel: defaults?.messageProvider, - turnSourceTo: defaults?.currentChannelId, - turnSourceAccountId: defaults?.accountId, - turnSourceThreadId: defaults?.currentThreadTs, - scopeKey: defaults?.scopeKey, - approvalFollowupText: defaults?.approvalFollowupText, - approvalFollowup: defaults?.approvalFollowup, - approvalFollowupMode: defaults?.approvalFollowupMode, + sandbox, + containerWorkdir, + usePty, warnings, - notifySessionKey, - approvalRunningNoticeMs, maxOutput, pendingMaxOutput, - trustedSafeBinDirs, + notifyOnExit, + notifyOnExitEmptySuccess, + scopeKey: defaults?.scopeKey, + sessionKey: notifySessionKey, + mainKey: defaults?.mainKey, + sessionScope: defaults?.sessionScope, + eventRouting: defaults?.eventRouting, + notifyDeliveryContext, + timeoutSec: effectiveTimeout, + onUpdate, }); - if (gatewayResult.pendingResult) { - return gatewayResult.pendingResult; - } - if (gatewayResult.deniedResult) { - return gatewayResult.deniedResult; - } - execCommandOverride = gatewayResult.execCommandOverride; - if (gatewayResult.allowWithoutEnforcedCommand) { - execCommandOverride = undefined; - } + discardPreparedSandboxWorkdir = null; + } catch (error) { + discardPreparedSandboxWorkdir?.(); + throw error; } - const explicitTimeoutSec = typeof params.timeout === "number" ? params.timeout : null; - const effectiveTimeout = explicitTimeoutSec ?? defaultTimeoutSec; - const getWarningText = () => (warnings.length ? `${warnings.join("\n")}\n\n` : ""); - const usePty = params.pty === true && !sandbox; - - // Preflight: catch a common model failure mode (shell syntax leaking into Python/JS sources) - // before we execute and burn tokens in cron loops. - if (!shouldSkipExecScriptPreflight({ host, security, ask })) { - await validateScriptFileForShellBleed({ command: params.command, workdir }); - } - - const run = await runExecProcess({ - command: params.command, - execCommand: execCommandOverride, - workdir, - env, - pathPrepend: defaultPathPrepend, - sandbox, - containerWorkdir, - usePty, - warnings, - maxOutput, - pendingMaxOutput, - notifyOnExit, - notifyOnExitEmptySuccess, - scopeKey: defaults?.scopeKey, - sessionKey: notifySessionKey, - mainKey: defaults?.mainKey, - sessionScope: defaults?.sessionScope, - eventRouting: defaults?.eventRouting, - notifyDeliveryContext, - timeoutSec: effectiveTimeout, - onUpdate, - }); - let yielded = false; let yieldTimer: NodeJS.Timeout | null = null; let registeredAbortSignal: AbortSignal | null = null; diff --git a/src/agents/bash-tools.schemas.ts b/src/agents/bash-tools.schemas.ts index cc1a5c9d50bb..8ed0d3d300a3 100644 --- a/src/agents/bash-tools.schemas.ts +++ b/src/agents/bash-tools.schemas.ts @@ -12,7 +12,12 @@ const EXEC_TOOL_HOST_VALUES = ["auto", "sandbox", "gateway", "node"] as const; /** Parameters accepted by the exec tool. */ export const execSchema = Type.Object({ command: Type.String({ description: "Shell command to execute" }), - workdir: Type.Optional(Type.String({ description: "Working directory (defaults to cwd)" })), + workdir: Type.Optional( + Type.String({ + description: + "Working directory. Blank/whitespace values are invalid; omit to use the default cwd.", + }), + ), env: Type.Optional(Type.Record(Type.String(), Type.String())), yieldMs: Type.Optional( Type.Number({ diff --git a/src/agents/bash-tools.shared.test.ts b/src/agents/bash-tools.shared.test.ts index b07e72784a34..890b61ba8d44 100644 --- a/src/agents/bash-tools.shared.test.ts +++ b/src/agents/bash-tools.shared.test.ts @@ -1,24 +1,11 @@ /** * Shared bash-tool helper tests. - * Covers strict env parsing and sandbox workdir mapping between container and - * host workspace paths. + * Covers strict env parsing and compact session labels. */ -import { mkdir, mkdtemp, rm } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { deriveSessionName, readEnvInt, resolveSandboxWorkdir } from "./bash-tools.shared.js"; +import { deriveSessionName, readEnvInt } from "./bash-tools.shared.js"; -async function withTempDir(run: (dir: string) => Promise) { - const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-bash-workdir-")); - try { - await run(dir); - } finally { - await rm(dir, { recursive: true, force: true }); - } -} - -describe("resolveSandboxWorkdir", () => { +describe("readEnvInt", () => { afterEach(() => { vi.unstubAllEnvs(); }); @@ -56,67 +43,6 @@ describe("resolveSandboxWorkdir", () => { expect(readEnvInt("OPENCLAW_BASH_YIELD_MS", "PI_BASH_YIELD_MS")).toBeUndefined(); }); - - it("maps container root workdir to host workspace", async () => { - await withTempDir(async (workspaceDir) => { - const warnings: string[] = []; - const resolved = await resolveSandboxWorkdir({ - workdir: "/workspace", - sandbox: { - containerName: "sandbox-1", - workspaceDir, - containerWorkdir: "/workspace", - }, - warnings, - }); - - expect(resolved.hostWorkdir).toBe(workspaceDir); - expect(resolved.containerWorkdir).toBe("/workspace"); - expect(warnings).toStrictEqual([]); - }); - }); - - it("maps nested container workdir under the container workspace", async () => { - await withTempDir(async (workspaceDir) => { - const nested = path.join(workspaceDir, "scripts", "runner"); - await mkdir(nested, { recursive: true }); - const warnings: string[] = []; - const resolved = await resolveSandboxWorkdir({ - workdir: "/workspace/scripts/runner", - sandbox: { - containerName: "sandbox-2", - workspaceDir, - containerWorkdir: "/workspace", - }, - warnings, - }); - - expect(resolved.hostWorkdir).toBe(nested); - expect(resolved.containerWorkdir).toBe("/workspace/scripts/runner"); - expect(warnings).toStrictEqual([]); - }); - }); - - it("supports custom container workdir prefixes", async () => { - await withTempDir(async (workspaceDir) => { - const nested = path.join(workspaceDir, "project"); - await mkdir(nested, { recursive: true }); - const warnings: string[] = []; - const resolved = await resolveSandboxWorkdir({ - workdir: "/sandbox-root/project", - sandbox: { - containerName: "sandbox-3", - workspaceDir, - containerWorkdir: "/sandbox-root", - }, - warnings, - }); - - expect(resolved.hostWorkdir).toBe(nested); - expect(resolved.containerWorkdir).toBe("/sandbox-root/project"); - expect(warnings).toStrictEqual([]); - }); - }); }); describe("deriveSessionName", () => { diff --git a/src/agents/bash-tools.shared.ts b/src/agents/bash-tools.shared.ts index ae08fd93e45f..a48f1a055947 100644 --- a/src/agents/bash-tools.shared.ts +++ b/src/agents/bash-tools.shared.ts @@ -1,16 +1,15 @@ /** * Shared helpers for bash exec/process tools. - * Owns sandbox workdir mapping, Docker exec argument construction, output - * slicing, environment coercion, and compact session labels. + * Owns Docker exec argument construction, output slicing, environment + * coercion, and compact session labels. */ -import { existsSync, statSync } from "node:fs"; -import fs from "node:fs/promises"; -import { homedir } from "node:os"; -import path from "node:path"; import { parseStrictInteger } from "@openclaw/normalization-core/number-coercion"; import { sliceUtf16Safe } from "../utils.js"; -import { assertSandboxPath } from "./sandbox-paths.js"; -import type { SandboxBackendExecSpec } from "./sandbox/backend-handle.types.js"; +import type { + SandboxBackendExecSpec, + SandboxBackendWorkdirValidation, + SandboxBackendWorkdirValidator, +} from "./sandbox/backend-handle.types.js"; const CHUNK_LIMIT = 8 * 1024; @@ -19,6 +18,10 @@ export type BashSandboxConfig = { containerName: string; workspaceDir: string; containerWorkdir: string; + workdirValidation?: SandboxBackendWorkdirValidation; + validateWorkdir?: SandboxBackendWorkdirValidator; + discardPreparedWorkdir?: (workdir: string) => void; + workdirRoots?: readonly string[]; env?: Record; buildExecSpec?: (params: { command: string; @@ -109,101 +112,6 @@ export function buildDockerExecArgs(params: { return args; } -/** Resolves a requested workdir to both host and container paths for a sandbox. */ -export async function resolveSandboxWorkdir(params: { - workdir: string; - sandbox: BashSandboxConfig; - warnings: string[]; -}) { - const fallback = params.sandbox.workspaceDir; - const mappedHostWorkdir = mapContainerWorkdirToHost({ - workdir: params.workdir, - sandbox: params.sandbox, - }); - const candidateWorkdir = mappedHostWorkdir ?? params.workdir; - try { - const resolved = await assertSandboxPath({ - filePath: candidateWorkdir, - cwd: process.cwd(), - root: params.sandbox.workspaceDir, - }); - const stats = await fs.stat(resolved.resolved); - if (!stats.isDirectory()) { - throw new Error("workdir is not a directory"); - } - const relative = resolved.relative - ? resolved.relative.split(path.sep).join(path.posix.sep) - : ""; - const containerWorkdir = relative - ? path.posix.join(params.sandbox.containerWorkdir, relative) - : params.sandbox.containerWorkdir; - return { hostWorkdir: resolved.resolved, containerWorkdir }; - } catch { - params.warnings.push( - `Warning: workdir "${params.workdir}" is unavailable; using "${fallback}".`, - ); - return { - hostWorkdir: fallback, - containerWorkdir: params.sandbox.containerWorkdir, - }; - } -} - -function mapContainerWorkdirToHost(params: { - workdir: string; - sandbox: BashSandboxConfig; -}): string | undefined { - const workdir = normalizeContainerPath(params.workdir); - const containerRoot = normalizeContainerPath(params.sandbox.containerWorkdir); - if (containerRoot === ".") { - return undefined; - } - if (workdir === containerRoot) { - return path.resolve(params.sandbox.workspaceDir); - } - if (!workdir.startsWith(`${containerRoot}/`)) { - return undefined; - } - const rel = workdir - .slice(containerRoot.length + 1) - .split("/") - .filter(Boolean); - return path.resolve(params.sandbox.workspaceDir, ...rel); -} - -function normalizeContainerPath(input: string): string { - const normalized = input.trim().replace(/\\/g, "/"); - if (!normalized) { - return "."; - } - return path.posix.normalize(normalized); -} - -/** Resolves a host workdir, falling back to a safe cwd/home path with a warning. */ -export function resolveWorkdir(workdir: string, warnings: string[]) { - const current = safeCwd(); - const fallback = current ?? homedir(); - try { - const stats = statSync(workdir); - if (stats.isDirectory()) { - return workdir; - } - } catch { - // ignore, fallback below - } - warnings.push(`Warning: workdir "${workdir}" is unavailable; using "${fallback}".`); - return fallback; -} - -function safeCwd() { - try { - const cwd = process.cwd(); - return existsSync(cwd) ? cwd : null; - } catch { - return null; - } -} - /** * Clamp a number within min/max bounds, using defaultValue if undefined or NaN. */ diff --git a/src/agents/cache-trace.test.ts b/src/agents/cache-trace.test.ts index e02cbe9867bd..0f62fb7b0b9a 100644 --- a/src/agents/cache-trace.test.ts +++ b/src/agents/cache-trace.test.ts @@ -6,6 +6,12 @@ import { resolveUserPath } from "../utils.js"; import { createCacheTrace } from "./cache-trace.js"; describe("createCacheTrace", () => { + const bareAnthropicKey = "sk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWx"; // pragma: allowlist secret + const bareAwsKey = "AKIAIOSFODNN7EXAMPLE"; // pragma: allowlist secret + const bareGithubKey = "ghp_AbCdEfGhIjKlMnOpQrStUvWxYz1234567890"; // pragma: allowlist secret + const bareGoogleKey = "AIzaSyA1bC2dE3fG4hI5jK6lM7nO8pQrStUvW"; // pragma: allowlist secret + const barePerplexityKey = "pplx-AbCdEfGhIjKlMnOpQrStUvWx"; // pragma: allowlist secret + function createMemoryTraceForTest() { const lines: string[] = []; // In-memory writer keeps cache trace assertions deterministic without @@ -179,15 +185,21 @@ describe("createCacheTrace", () => { trace?.recordStage("stream:context", { system: { - provider: { apiKey: "sk-system-secret", baseUrl: "https://api.example.com" }, + provider: { + apiKey: "sk-system-secret", + baseUrl: "https://api.example.com", + diagnosticText: bareAwsKey, + }, }, model: { id: "test-model", apiKey: "sk-model-secret", tokenCount: 8192, + diagnosticText: bareGoogleKey, }, options: { apiKey: "sk-options-secret", + diagnosticText: bareGithubKey, nested: { password: "super-secret-password", safe: "keep-me", @@ -204,6 +216,10 @@ describe("createCacheTrace", () => { label: "preserve-me", }, content: [ + { + type: "text", + text: barePerplexityKey, + }, { type: "image", source: { type: "base64", media_type: "image/jpeg", data: "U0VDUkVU" }, @@ -214,16 +230,25 @@ describe("createCacheTrace", () => { }); const event = JSON.parse(lines[0]?.trim() ?? "{}") as Record; - expect(event.system).toEqual({ - provider: { - baseUrl: "https://api.example.com", - }, + const systemProvider = + (event.system as { provider?: Record } | undefined)?.provider ?? {}; + expect(systemProvider).toMatchObject({ + baseUrl: "https://api.example.com", }); + expect(systemProvider.diagnosticText).toBeTypeOf("string"); + expect(systemProvider.diagnosticText).not.toBe(bareAwsKey); + expect(systemProvider.diagnosticText).not.toContain(bareAwsKey); expect(event.model).toEqual({ id: "test-model", tokenCount: 8192, + diagnosticText: expect.any(String), }); + expect((event.model as { diagnosticText?: string }).diagnosticText).not.toBe(bareGoogleKey); + expect((event.model as { diagnosticText?: string }).diagnosticText).not.toContain( + bareGoogleKey, + ); expect(event.options).toEqual({ + diagnosticText: expect.any(String), nested: { safe: "keep-me", tokenCount: 42, @@ -238,6 +263,10 @@ describe("createCacheTrace", () => { }, ], }); + expect((event.options as { diagnosticText?: string }).diagnosticText).not.toBe(bareGithubKey); + expect((event.options as { diagnosticText?: string }).diagnosticText).not.toContain( + bareGithubKey, + ); const optionsImages = ( ((event.options as { images?: unknown[] } | undefined)?.images ?? []) as Array< @@ -257,11 +286,44 @@ describe("createCacheTrace", () => { expect(firstMessage?.metadata).toEqual({ label: "preserve-me", }); - const source = (((firstMessage?.content as Array> | undefined) ?? [])[0] - ?.source ?? {}) as Record; + const content = (firstMessage?.content as Array> | undefined) ?? []; + expect(content[0]).toEqual({ + type: "text", + text: expect.any(String), + }); + expect(content[0]?.text).not.toBe(barePerplexityKey); + expect(content[0]?.text).not.toContain(barePerplexityKey); + const source = (content[1]?.source ?? {}) as Record; expect(source.data).toBe(""); expect(source.bytes).toBe(6); expect(source.sha256).toBe(crypto.createHash("sha256").update("U0VDUkVU").digest("hex")); + const serialized = JSON.stringify(event); + expect(serialized).not.toContain(bareAwsKey); + expect(serialized).not.toContain(bareGoogleKey); + expect(serialized).not.toContain(bareGithubKey); + expect(serialized).not.toContain(barePerplexityKey); + }); + + it("redacts bare vendor keys from cache-trace prompt, note, and error fields", () => { + const { lines, trace } = createMemoryTraceForTest(); + + trace?.recordStage("prompt:before", { + prompt: `prompt ${bareAnthropicKey}`, + note: `note ${bareGithubKey}`, + error: `error ${bareGoogleKey}`, + }); + + const event = JSON.parse(lines[0]?.trim() ?? "{}") as Record; + expect(event.prompt).toBeTypeOf("string"); + expect(event.note).toBeTypeOf("string"); + expect(event.error).toBeTypeOf("string"); + expect(event.prompt).not.toBe(`prompt ${bareAnthropicKey}`); + expect(event.note).not.toBe(`note ${bareGithubKey}`); + expect(event.error).not.toBe(`error ${bareGoogleKey}`); + const serialized = JSON.stringify(event); + expect(serialized).not.toContain(bareAnthropicKey); + expect(serialized).not.toContain(bareGithubKey); + expect(serialized).not.toContain(bareGoogleKey); }); it("handles circular references in messages without stack overflow", () => { diff --git a/src/agents/cache-trace.ts b/src/agents/cache-trace.ts index 4dfd6b52cb44..4f1e8054df07 100644 --- a/src/agents/cache-trace.ts +++ b/src/agents/cache-trace.ts @@ -8,7 +8,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveUserPath } from "../utils.js"; import { parseBooleanValue } from "../utils/boolean.js"; import { safeJsonStringify } from "../utils/safe-json.js"; -import { sanitizeDiagnosticPayload } from "./payload-redaction.js"; +import { redactAgentDiagnosticPayload } from "./diagnostic-redaction.js"; import { getQueuedFileWriter, type QueuedFileWriter } from "./queued-file-writer.js"; import type { AgentMessage, StreamFn } from "./runtime/index.js"; import { stableStringify } from "./stable-stringify.js"; @@ -156,17 +156,17 @@ export function createCacheTrace(params: CacheTraceInit): CacheTrace | null { }; if (payload.prompt !== undefined && cfg.includePrompt) { - event.prompt = payload.prompt; + event.prompt = redactAgentDiagnosticPayload(payload.prompt); } if (payload.system !== undefined && cfg.includeSystem) { - event.system = sanitizeDiagnosticPayload(payload.system); + event.system = redactAgentDiagnosticPayload(payload.system); event.systemDigest = digest(payload.system); } if (payload.options) { - event.options = sanitizeDiagnosticPayload(payload.options) as Record; + event.options = redactAgentDiagnosticPayload(payload.options); } if (payload.model) { - event.model = sanitizeDiagnosticPayload(payload.model) as Record; + event.model = redactAgentDiagnosticPayload(payload.model); } const messages = payload.messages; @@ -179,15 +179,15 @@ export function createCacheTrace(params: CacheTraceInit): CacheTrace | null { if (cfg.includeMessages) { // Full messages are optional; summaries/digests are always recorded when // message payloads are supplied. - event.messages = sanitizeDiagnosticPayload(messages) as AgentMessage[]; + event.messages = redactAgentDiagnosticPayload(messages); } } if (payload.note) { - event.note = payload.note; + event.note = redactAgentDiagnosticPayload(payload.note); } if (payload.error) { - event.error = payload.error; + event.error = redactAgentDiagnosticPayload(payload.error); } const line = safeJsonStringify(event); diff --git a/src/agents/diagnostic-redaction.ts b/src/agents/diagnostic-redaction.ts new file mode 100644 index 000000000000..7d4622a2a7e2 --- /dev/null +++ b/src/agents/diagnostic-redaction.ts @@ -0,0 +1,6 @@ +import { redactSecrets } from "../logging/redact.js"; +import { sanitizeDiagnosticPayload } from "./payload-redaction.js"; + +export function redactAgentDiagnosticPayload(value: T): T { + return redactSecrets(sanitizeDiagnosticPayload(value)) as T; +} diff --git a/src/agents/embedded-agent-helpers/errors.ts b/src/agents/embedded-agent-helpers/errors.ts index ebd8e1279544..fdca555af622 100644 --- a/src/agents/embedded-agent-helpers/errors.ts +++ b/src/agents/embedded-agent-helpers/errors.ts @@ -75,6 +75,10 @@ export { const log = createSubsystemLogger("errors"); const sandboxToolPolicyAuditMessages = new WeakSet(); export const GENERIC_ASSISTANT_ERROR_TEXT = "LLM request failed."; +export const AUTH_INVALID_TOKEN_USER_TEXT = + "Authentication failed (provider returned HTTP 401). " + + "Your provider token may have expired — try the request again in a moment. " + + "If the failure persists, re-authenticate this provider."; const PROVIDER_SCHEMA_REJECTION_USER_TEXT = "LLM request failed: provider rejected the request schema or tool payload."; const MODEL_NOT_FOUND_USER_TEXT = @@ -1419,11 +1423,7 @@ export function formatAssistantErrorText( } if (providerRuntimeFailureKind === "auth_invalid_token") { - return ( - "Authentication failed (provider returned HTTP 401). " + - "Your provider token may have expired — try the request again in a moment. " + - "If the failure persists, re-authenticate this provider." - ); + return AUTH_INVALID_TOKEN_USER_TEXT; } if (providerRuntimeFailureKind === "upstream_html") { diff --git a/src/agents/embedded-agent-runner/run.prompt-timeout-fallback.test.ts b/src/agents/embedded-agent-runner/run.prompt-timeout-fallback.test.ts new file mode 100644 index 000000000000..812a83fdd86c --- /dev/null +++ b/src/agents/embedded-agent-runner/run.prompt-timeout-fallback.test.ts @@ -0,0 +1,99 @@ +// Coverage for handing replay-safe plugin-harness prompt timeouts to model fallback. +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { makeModelFallbackCfg } from "../test-helpers/model-fallback-config-fixture.js"; +import { makeAttemptResult } from "./run.overflow-compaction.fixture.js"; +import { + loadRunOverflowCompactionHarness, + MockedFailoverError, + mockedClassifyFailoverReason, + mockedRunEmbeddedAttempt, + overflowBaseRunParams, + resetRunOverflowCompactionHarnessMocks, +} from "./run.overflow-compaction.harness.js"; + +let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; + +describe("runEmbeddedAgent prompt timeout fallback handoff", () => { + beforeAll(async () => { + ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); + }); + + beforeEach(() => { + resetRunOverflowCompactionHarnessMocks(); + }); + + it("throws FailoverError for replay-safe harness-owned prompt timeouts when model fallbacks are configured", async () => { + mockedClassifyFailoverReason.mockReturnValue("timeout"); + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + assistantTexts: [], + promptError: new Error("LLM request timed out."), + promptErrorSource: "prompt", + }), + ); + + const promise = runEmbeddedAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.4", + runId: "run-prompt-timeout-fallback", + config: makeModelFallbackCfg({ + agents: { + defaults: { + model: { + primary: "openai/gpt-5.4", + fallbacks: ["anthropic/claude-opus-4-6"], + }, + }, + }, + }), + }); + + await expect(promise).rejects.toBeInstanceOf(MockedFailoverError); + await expect(promise).rejects.toThrow("LLM request timed out."); + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); + }); + + it("surfaces replay-invalid prompt timeouts instead of handing them to model fallback", async () => { + mockedClassifyFailoverReason.mockReturnValue("timeout"); + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + assistantTexts: [], + promptError: new Error("LLM request timed out."), + promptErrorSource: "prompt", + promptTimeoutOutcome: { + message: "Harness abandoned the timed-out turn after provider activity.", + replayInvalid: true, + livenessState: "abandoned", + }, + }), + ); + + let thrown: unknown; + try { + await runEmbeddedAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.4", + runId: "run-prompt-timeout-replay-invalid", + config: makeModelFallbackCfg({ + agents: { + defaults: { + model: { + primary: "openai/gpt-5.4", + fallbacks: ["anthropic/claude-opus-4-6"], + }, + }, + }, + }), + }); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(Error); + expect(thrown).not.toBeInstanceOf(MockedFailoverError); + expect(String((thrown as Error | undefined)?.message)).toContain("LLM request timed out."); + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/agents/embedded-agent-runner/run.ts b/src/agents/embedded-agent-runner/run.ts index 1f78ebf7a5df..9e7e95905ff1 100644 --- a/src/agents/embedded-agent-runner/run.ts +++ b/src/agents/embedded-agent-runner/run.ts @@ -641,7 +641,7 @@ async function runEmbeddedAgentInternal( ...paramsBase, agentId: paramsBase.agentId ?? runSessionTarget.agentId, sessionId: runSessionTarget.sessionId, - sessionKey: effectiveSessionKey ?? runSessionTarget.sessionKey, + sessionKey: normalizeOptionalString(effectiveSessionKey ?? runSessionTarget.sessionKey), sessionFile: runSessionTarget.sessionFile, }; const sessionLane = resolveSessionLane(params.sessionKey?.trim() || params.sessionId); @@ -3116,6 +3116,12 @@ async function runEmbeddedAgentInternal( ); const promptFailoverFailure = promptFailoverReason !== null || isFailoverErrorMessage(errorText, { provider }); + const promptTimeoutFallbackSafe = + promptErrorSource === "prompt" && + promptFailoverReason === "timeout" && + !attempt.codexAppServerFailure && + attempt.promptTimeoutOutcome?.replayInvalid !== true && + attempt.replayMetadata.replaySafe; // Capture the failing profile before auth-profile rotation mutates `lastProfileId`. const failedPromptProfileId = lastProfileId; const logPromptFailoverDecision = createFailoverDecisionLogger({ @@ -3147,6 +3153,7 @@ async function runEmbeddedAgentInternal( failoverFailure: promptFailoverFailure, failoverReason: promptFailoverReason, harnessOwnsTransport: pluginHarnessOwnsTransport, + promptTimeoutFallbackSafe, profileRotated: false, }); if ( @@ -3186,6 +3193,7 @@ async function runEmbeddedAgentInternal( failoverFailure: promptFailoverFailure, failoverReason: promptFailoverReason, harnessOwnsTransport: pluginHarnessOwnsTransport, + promptTimeoutFallbackSafe, profileRotated: true, }); } diff --git a/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts b/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts index 642d72406372..e95c58280e0b 100644 --- a/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts @@ -3992,6 +3992,103 @@ describe("embedded attempt session lock lifecycle", () => { expect(acquireSessionWriteLockLocal).toHaveBeenCalledTimes(2); }); + it("releaseHeldLockWithFence sets deferred flag when bailed out during active scope; re-attempted after scope deactivation (#95915)", async () => { + const events: string[] = []; + const releasePrep = vi.fn(async () => events.push("prep-release")); + const releaseRetained = vi.fn(async () => events.push("retained-release")); + const acquireSessionWriteLockLocal = vi + .fn() + .mockResolvedValueOnce({ release: releasePrep }) + .mockResolvedValueOnce({ release: releaseRetained }); + + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: acquireSessionWriteLockLocal, + lockOptions, + }); + + await controller.releaseForPrompt(); + await controller.reacquireAfterPrompt(); + + await controller.withSessionWriteLock(async () => { + events.push("write-start"); + await controller.releaseHeldLockForAbort(); + events.push("write-end"); + }); + + expect(events).toEqual(["prep-release", "write-start", "write-end", "retained-release"]); + expect(acquireSessionWriteLockLocal).toHaveBeenCalledTimes(2); + }); + + it("controls the held lock lifecycle across deferred abort release, reacquisition, and prompt release", async () => { + const events: string[] = []; + const acquireSessionWriteLockLocal = vi + .fn() + .mockResolvedValueOnce({ release: vi.fn(async () => events.push("init-release")) }) + .mockResolvedValueOnce({ release: vi.fn(async () => events.push("held-release")) }) + .mockResolvedValueOnce({ release: vi.fn(async () => events.push("reacquire-release")) }); + + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: acquireSessionWriteLockLocal, + lockOptions, + }); + + await controller.releaseForPrompt(); + await controller.reacquireAfterPrompt(); + + await controller.withSessionWriteLock(async () => { + events.push("write"); + await controller.releaseHeldLockForAbort(); + }); + + expect(events).toEqual(["init-release", "write", "held-release"]); + + await controller.reacquireAfterPrompt(); + await controller.releaseForPrompt(); + + expect(events).toEqual(["init-release", "write", "held-release", "reacquire-release"]); + expect(acquireSessionWriteLockLocal).toHaveBeenCalledTimes(3); + }); + + it("takeHeldLockAfterRetainedIdle does not self-deadlock when called from inside active write scope (#95915)", async () => { + const events: string[] = []; + const acquireSessionWriteLockLocal = vi + .fn() + .mockResolvedValueOnce({ release: vi.fn(async () => events.push("init-release")) }) + .mockResolvedValueOnce({ release: vi.fn(async () => events.push("held-release")) }) + .mockRejectedValueOnce( + new SessionWriteLockTimeoutError({ + timeoutMs: lockOptions.timeoutMs, + owner: "pid=test", + lockPath: `${lockOptions.sessionFile}.lock`, + }), + ); + + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: acquireSessionWriteLockLocal, + lockOptions, + }); + + await controller.releaseForPrompt(); + await controller.reacquireAfterPrompt(); + + const takeoverError = await controller + .withSessionWriteLock(async () => { + events.push("write-start"); + const cleanupLock = await controller.acquireForCleanup(); + await cleanupLock.release(); + events.push("cleanup-inside-done"); + }) + .catch((error: unknown) => error); + + expect(takeoverError).toBeInstanceOf(EmbeddedAttemptSessionTakeoverError); + + const cleanupLock = await controller.acquireForCleanup(); + await cleanupLock.release(); + + expect(events).toEqual(["init-release", "write-start", "cleanup-inside-done", "held-release"]); + expect(acquireSessionWriteLockLocal).toHaveBeenCalledTimes(3); + }); + it("returns a no-op cleanup lock after prompt lock reacquisition times out", async () => { const releases: string[] = []; const acquireSessionWriteLockResult = vi diff --git a/src/agents/embedded-agent-runner/run/attempt.session-lock.ts b/src/agents/embedded-agent-runner/run/attempt.session-lock.ts index 449373f23dbe..d35b409d136f 100644 --- a/src/agents/embedded-agent-runner/run/attempt.session-lock.ts +++ b/src/agents/embedded-agent-runner/run/attempt.session-lock.ts @@ -1171,6 +1171,9 @@ export async function createEmbeddedAttemptSessionLockController(params: { let fenceGeneration = 0; let fenceActive = false; let takeoverDetected = false; + // Set when an active retained write prevents immediate held-lock release. + // The scope completion path retries release after the retained use unwinds. + let releaseHeldLockDeferred = false; let retainedLockUseCount = 0; const retainedLockIdleWaiters = new Set<() => void>(); let heldLockDraining = false; @@ -1603,6 +1606,7 @@ export async function createEmbeddedAttemptSessionLockController(params: { const drainOwner = await beginHeldLockDrain(); try { if (!(await waitForRetainedLockIdle())) { + releaseHeldLockDeferred = true; return; } if (!heldLock) { @@ -1639,6 +1643,8 @@ export async function createEmbeddedAttemptSessionLockController(params: { const drainOwner = await beginHeldLockDrain(); try { if (!(await waitForRetainedLockIdle())) { + // Do not wait for retained idle from inside the active scope; that + // scope must unwind before the retained-use waiter can resolve. return undefined; } if (!heldLock) { @@ -1660,6 +1666,7 @@ export async function createEmbeddedAttemptSessionLockController(params: { const drainOwner = await beginHeldLockDrain(); try { if (!(await waitForRetainedLockIdle())) { + // Same active-scope self-deadlock guard as takeHeldLockAfterRetainedIdle. return; } if (!heldLock) { @@ -1721,6 +1728,12 @@ export async function createEmbeddedAttemptSessionLockController(params: { } } await releaseHeldLockAfterTakeover(); + // Retained use has been released and the active scope is no longer live, + // so a prior active-scope release bailout can drain the held file lock now. + if (releaseHeldLockDeferred) { + releaseHeldLockDeferred = false; + await releaseHeldLockWithFence(); + } if (!outcome.ok) { throw outcome.error; } diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index c818c1225cd3..9dd0ec3e2368 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -3129,7 +3129,11 @@ export async function runEmbeddedAttempt( trigger: params.trigger, runTimeoutMs: resolvedRunTimeoutMs, modelRequestTimeoutMs: (params.model as { requestTimeoutMs?: number }).requestTimeoutMs, - model: params.model as { baseUrl?: string }, + model: { + baseUrl: params.model.baseUrl, + id: params.modelId, + provider: params.provider, + }, }); if (idleTimeoutMs > 0) { activeSession.agent.streamFn = streamWithIdleTimeout( diff --git a/src/agents/embedded-agent-runner/run/failover-policy.test.ts b/src/agents/embedded-agent-runner/run/failover-policy.test.ts index fe2f8caac089..7f773f7701b0 100644 --- a/src/agents/embedded-agent-runner/run/failover-policy.test.ts +++ b/src/agents/embedded-agent-runner/run/failover-policy.test.ts @@ -581,6 +581,44 @@ describe("resolveRunFailoverDecision", () => { }); }); + it("falls back on fallback-safe harness-owned prompt timeouts", () => { + expect( + resolveRunFailoverDecision({ + stage: "prompt", + aborted: false, + externalAbort: false, + fallbackConfigured: true, + failoverFailure: true, + failoverReason: "timeout", + harnessOwnsTransport: true, + promptTimeoutFallbackSafe: true, + profileRotated: true, + }), + ).toEqual({ + action: "fallback_model", + reason: "timeout", + }); + }); + + it("surfaces fallback-safe harness-owned prompt timeouts when no fallback is configured", () => { + expect( + resolveRunFailoverDecision({ + stage: "prompt", + aborted: false, + externalAbort: false, + fallbackConfigured: false, + failoverFailure: true, + failoverReason: "timeout", + harnessOwnsTransport: true, + promptTimeoutFallbackSafe: true, + profileRotated: true, + }), + ).toEqual({ + action: "surface_error", + reason: "timeout", + }); + }); + it("surfaces error on LLM idle timeout when no fallback is configured and rotation is exhausted", () => { expect( resolveRunFailoverDecision({ diff --git a/src/agents/embedded-agent-runner/run/failover-policy.ts b/src/agents/embedded-agent-runner/run/failover-policy.ts index c1a1a23c2df2..f91ee89f81f7 100644 --- a/src/agents/embedded-agent-runner/run/failover-policy.ts +++ b/src/agents/embedded-agent-runner/run/failover-policy.ts @@ -50,6 +50,7 @@ type PromptDecisionParams = { failoverFailure: boolean; failoverReason: FailoverReason | null; harnessOwnsTransport?: boolean; + promptTimeoutFallbackSafe?: boolean; profileRotated: boolean; }; @@ -179,6 +180,14 @@ export function resolveRunFailoverDecision(params: RunFailoverDecisionParams): R }; } if (params.harnessOwnsTransport && params.failoverReason === "timeout") { + // Plugin harness lifecycle timeouts must stay inside the harness boundary; + // only prompt request timeouts proven replay-safe may enter model fallback. + if (params.promptTimeoutFallbackSafe === true && params.fallbackConfigured) { + return { + action: "fallback_model", + reason: "timeout", + }; + } return { action: "surface_error", reason: params.failoverReason, diff --git a/src/agents/embedded-agent-runner/run/images.test.ts b/src/agents/embedded-agent-runner/run/images.test.ts index 12e2b67caa5b..2a1845dbeff7 100644 --- a/src/agents/embedded-agent-runner/run/images.test.ts +++ b/src/agents/embedded-agent-runner/run/images.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { describe, expect, it, vi } from "vitest"; import { resolvePreferredOpenClawTmpDir } from "../../../infra/tmp-openclaw-dir.js"; +import { captureEnv, setTestEnvValue } from "../../../test-utils/env.js"; import { createHostSandboxFsBridge } from "../../test-helpers/host-sandbox-fs-bridge.js"; import { createUnsafeMountedSandbox } from "../../test-helpers/unsafe-mounted-sandbox.js"; import { @@ -420,7 +421,8 @@ describe("loadImageFromRef", () => { await fs.mkdir(workspaceDir, { recursive: true }); await fs.mkdir(inboundDir, { recursive: true }); await fs.writeFile(path.join(inboundDir, mediaId), Buffer.from(TINY_PNG_BASE64, "base64")); - vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); try { const image = await loadImageFromRef( @@ -437,7 +439,7 @@ describe("loadImageFromRef", () => { expect(image?.mimeType).toBe("image/png"); expect(image?.data).toBe(TINY_PNG_BASE64); } finally { - vi.unstubAllEnvs(); + envSnapshot.restore(); await fs.rm(stateDir, { recursive: true, force: true }); } }); @@ -670,7 +672,8 @@ describe("detectAndLoadPromptImages", () => { const imagePath = path.join(inboundDir, "signal-replay.png"); const pngB64 = TINY_PNG_BASE64; await fs.writeFile(imagePath, Buffer.from(pngB64, "base64")); - vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); try { const result = await detectAndLoadPromptImages({ @@ -685,7 +688,7 @@ describe("detectAndLoadPromptImages", () => { expect(result.skippedCount).toBe(0); expect(result.images).toHaveLength(1); } finally { - vi.unstubAllEnvs(); + envSnapshot.restore(); await fs.rm(stateDir, { recursive: true, force: true }); } }); diff --git a/src/agents/embedded-agent-runner/run/llm-idle-timeout.test.ts b/src/agents/embedded-agent-runner/run/llm-idle-timeout.test.ts index 6459a6365a98..65a6dd1849ce 100644 --- a/src/agents/embedded-agent-runner/run/llm-idle-timeout.test.ts +++ b/src/agents/embedded-agent-runner/run/llm-idle-timeout.test.ts @@ -12,6 +12,7 @@ import type { StreamFn } from "../../runtime/index.js"; import { resolveLlmIdleTimeoutMs, streamWithIdleTimeout } from "./llm-idle-timeout.js"; const DEFAULT_LLM_IDLE_TIMEOUT_MS = 120_000; +const CRON_LLM_IDLE_TIMEOUT_MS = 60_000; describe("resolveLlmIdleTimeoutMs", () => { it("returns default when config is undefined", () => { @@ -41,8 +42,153 @@ describe("resolveLlmIdleTimeoutMs", () => { expect(resolveLlmIdleTimeoutMs({ runTimeoutMs: 30_000 })).toBe(30_000); }); - it("honors explicit cron run timeouts as the idle watchdog ceiling", () => { - expect(resolveLlmIdleTimeoutMs({ trigger: "cron", runTimeoutMs: 600_000 })).toBe(600_000); + it("caps explicit cron run timeouts so stream stalls can reach model fallbacks", () => { + expect(resolveLlmIdleTimeoutMs({ trigger: "cron", runTimeoutMs: 600_000 })).toBe( + CRON_LLM_IDLE_TIMEOUT_MS, + ); + }); + + it("uses shorter explicit cron run timeouts as the idle watchdog ceiling", () => { + expect(resolveLlmIdleTimeoutMs({ trigger: "cron", runTimeoutMs: 30_000 })).toBe(30_000); + }); + + it("honors explicit cron run timeouts for local provider model calls", () => { + expect( + resolveLlmIdleTimeoutMs({ + trigger: "cron", + runTimeoutMs: 600_000, + model: { baseUrl: "http://127.0.0.1:11434" }, + }), + ).toBe(600_000); + }); + + it.each([ + ["ollama", "http://ollama-host:11434"], + ["ollama-beelink", "http://ollama-host:11434"], + ["lmstudio", "http://lmstudio-box:1234/v1"], + ["lmstudio-mac", "http://lmstudio-box:1234/v1"], + ["vllm", "http://vllm-rig:8000/v1"], + ["sglang", "http://sglang-rig:30000/v1"], + ])( + "honors explicit cron run timeouts for self-hosted provider %s hostname %s", + (provider, baseUrl) => { + expect( + resolveLlmIdleTimeoutMs({ + trigger: "cron", + runTimeoutMs: 600_000, + model: { provider, baseUrl }, + }), + ).toBe(600_000); + }, + ); + + it("honors explicit cron run timeouts for explicit local host aliases", () => { + expect( + resolveLlmIdleTimeoutMs({ + trigger: "cron", + runTimeoutMs: 600_000, + model: { baseUrl: "http://host.docker.internal:11434" }, + }), + ).toBe(600_000); + }); + + it("honors explicit cron run timeouts for custom local provider markers on bare hostnames", () => { + const cfg = { + models: { + providers: { + gpu: { + baseUrl: "http://gpu-box:8000/v1", + api: "openai-completions", + apiKey: "custom-local", + models: [], + }, + "local-ollama": { + baseUrl: "http://ollama-box:11434", + api: "ollama", + apiKey: "ollama-local", + models: [], + }, + }, + }, + } as unknown as OpenClawConfig; + + expect( + resolveLlmIdleTimeoutMs({ + cfg, + trigger: "cron", + runTimeoutMs: 600_000, + model: { provider: "gpu", baseUrl: "http://gpu-box:8000/v1" }, + }), + ).toBe(600_000); + expect( + resolveLlmIdleTimeoutMs({ + cfg, + trigger: "cron", + runTimeoutMs: 600_000, + model: { provider: "local-ollama", baseUrl: "http://ollama-box:11434" }, + }), + ).toBe(600_000); + }); + + it("honors explicit cron run timeouts for provider-owned local services on bare hostnames", () => { + const cfg = { + models: { + providers: { + ds4: { + baseUrl: "http://ds4-box:8000/v1", + api: "openai-completions", + localService: { + command: "/opt/ds4/ds4-server", + healthUrl: "http://ds4-box:8000/v1/models", + }, + models: [], + }, + }, + }, + } as unknown as OpenClawConfig; + + expect( + resolveLlmIdleTimeoutMs({ + cfg, + trigger: "cron", + runTimeoutMs: 600_000, + model: { provider: "ds4", baseUrl: "http://ds4-box:8000/v1" }, + }), + ).toBe(600_000); + }); + + it.each([ + ["openai", "openai/gpt-5.5", "http://api:8080/v1"], + ["custom-proxy", "custom-proxy/gpt-5.5", "http://gateway:4000/v1"], + ["ollama-cloud", "ollama-cloud/kimi-k2.6", "http://ollama-host:11434"], + ])( + "keeps the cron stall cap for cloud provider %s routed through single-label host %s", + (provider, id, baseUrl) => { + expect( + resolveLlmIdleTimeoutMs({ + trigger: "cron", + runTimeoutMs: 600_000, + model: { provider, id, baseUrl }, + }), + ).toBe(CRON_LLM_IDLE_TIMEOUT_MS); + }, + ); + + it("keeps the cron stall cap for remote or cloud hostnames", () => { + expect( + resolveLlmIdleTimeoutMs({ + trigger: "cron", + runTimeoutMs: 600_000, + model: { provider: "openai", id: "openai/gpt-5.5", baseUrl: "https://api.openai.com/v1" }, + }), + ).toBe(CRON_LLM_IDLE_TIMEOUT_MS); + expect( + resolveLlmIdleTimeoutMs({ + trigger: "cron", + runTimeoutMs: 600_000, + model: { provider: "ollama", id: "ollama/gpt-oss:cloud", baseUrl: "http://ollama-host" }, + }), + ).toBe(CRON_LLM_IDLE_TIMEOUT_MS); }); it("disables the idle watchdog when an explicit run timeout disables timeouts", () => { diff --git a/src/agents/embedded-agent-runner/run/llm-idle-timeout.ts b/src/agents/embedded-agent-runner/run/llm-idle-timeout.ts index 212eae429d21..af5a50c1e923 100644 --- a/src/agents/embedded-agent-runner/run/llm-idle-timeout.ts +++ b/src/agents/embedded-agent-runner/run/llm-idle-timeout.ts @@ -18,6 +18,16 @@ import type { EmbeddedRunTrigger } from "./params.js"; * Default idle timeout for LLM streaming responses in milliseconds. */ const DEFAULT_LLM_IDLE_TIMEOUT_MS = 120_000; +// Cron has its own outer watchdog; stream stalls must fail early enough for +// the existing model fallback chain to try the next configured candidate. +const CRON_LLM_IDLE_TIMEOUT_MS = 60_000; +const LOCAL_PROVIDER_AUTH_MARKERS = new Set(["custom-local", "ollama-local"]); +const SELF_HOSTED_PROVIDER_ID_PREFIXES = ["ollama", "lmstudio", "vllm", "sglang", "llama-cpp"]; + +type IdleTimeoutProviderConfig = { + apiKey?: unknown; + localService?: unknown; +}; /** * Detects loopback / private-network / `.local` base URLs. Local providers @@ -37,11 +47,9 @@ const DEFAULT_LLM_IDLE_TIMEOUT_MS = 120_000; * matched, mirroring the SSRF-policy helper in * `src/cron/isolated-agent/model-preflight.runtime.ts`. * - DNS-resolved local aliases (e.g. an `/etc/hosts` entry mapping a custom - * hostname to a private IP) are not detected: classification keys on - * `URL.hostname` so resolution would have to happen here, and adding - * sync/async DNS to the watchdog hot path is disproportionate. Affected - * users can use the IP directly or set - * `models.providers..timeoutSeconds` explicitly. + * hostname to a private IP) are not detected for the implicit watchdog opt-out: + * classification keys on `URL.hostname` so resolution would have to happen + * here, and adding sync/async DNS to the watchdog hot path is disproportionate. */ function isLocalProviderBaseUrl(baseUrl: string): boolean { let host: string; @@ -95,6 +103,82 @@ function isLocalProviderBaseUrl(baseUrl: string): boolean { ); } +function isExplicitLocalHostnameBaseUrl(baseUrl: string): boolean { + let host: string; + try { + host = new URL(baseUrl).hostname.toLowerCase(); + } catch { + return false; + } + + if ( + host === "docker.orb.internal" || + host === "host.docker.internal" || + host === "host.orb.internal" + ) { + return true; + } + return false; +} + +function isBareProviderHostnameBaseUrl(baseUrl: string): boolean { + let host: string; + try { + host = new URL(baseUrl).hostname.toLowerCase(); + } catch { + return false; + } + + if (host.includes(".") || host.includes(":")) { + return false; + } + return /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(host); +} + +function isSelfHostedProviderId(provider: string | undefined): boolean { + const normalized = provider?.trim().toLowerCase(); + if (!normalized || normalized === "ollama-cloud") { + return false; + } + return SELF_HOSTED_PROVIDER_ID_PREFIXES.some( + (prefix) => normalized === prefix || normalized.startsWith(`${prefix}-`), + ); +} + +function findConfiguredProviderConfig( + cfg: OpenClawConfig | undefined, + provider: string | undefined, +): IdleTimeoutProviderConfig | undefined { + const normalizedProvider = provider?.trim().toLowerCase(); + if (!normalizedProvider) { + return undefined; + } + const providers = cfg?.models?.providers as + | Record + | undefined; + const exact = providers?.[normalizedProvider]; + if (exact) { + return exact; + } + return Object.entries(providers ?? {}).find( + ([key]) => key.trim().toLowerCase() === normalizedProvider, + )?.[1]; +} + +function hasLocalProviderAuthMarker(apiKey: unknown): boolean { + return typeof apiKey === "string" && LOCAL_PROVIDER_AUTH_MARKERS.has(apiKey.trim().toLowerCase()); +} + +function hasConfiguredLocalProviderSignal(params: { + cfg: OpenClawConfig | undefined; + provider: string | undefined; +}): boolean { + const providerConfig = findConfiguredProviderConfig(params.cfg, params.provider); + return Boolean( + providerConfig?.localService || hasLocalProviderAuthMarker(providerConfig?.apiKey), + ); +} + function isOllamaCloudModel(model: { id?: string; provider?: string } | undefined): boolean { const rawModelId = model?.id; if (typeof rawModelId !== "string") { @@ -134,6 +218,22 @@ export function resolveLlmIdleTimeoutMs(params?: { const hasExplicitRunTimeout = typeof runTimeoutMs === "number" && Number.isFinite(runTimeoutMs) && runTimeoutMs > 0; const runTimeoutIsNoTimeout = hasExplicitRunTimeout && runTimeoutMs >= MAX_TIMER_TIMEOUT_MS; + const baseUrl = params?.model?.baseUrl; + const isLocalProvider = + typeof baseUrl === "string" && baseUrl.length > 0 && isLocalProviderBaseUrl(baseUrl); + const isLocalRuntimeModel = isLocalProvider && !isOllamaCloudModel(params?.model); + const isExplicitLocalHostnameRuntimeModel = + typeof baseUrl === "string" && + baseUrl.length > 0 && + isExplicitLocalHostnameBaseUrl(baseUrl) && + !isOllamaCloudModel(params?.model); + const isSelfHostedHostnameRuntimeModel = + typeof baseUrl === "string" && + baseUrl.length > 0 && + isBareProviderHostnameBaseUrl(baseUrl) && + (isSelfHostedProviderId(params?.model?.provider) || + hasConfiguredLocalProviderSignal({ cfg: params?.cfg, provider: params?.model?.provider })) && + !isOllamaCloudModel(params?.model); const timeoutBounds = [ runTimeoutIsNoTimeout ? undefined : runTimeoutMs, hasExplicitRunTimeout ? undefined : agentTimeoutMs, @@ -174,7 +274,14 @@ export function resolveLlmIdleTimeoutMs(params?: { return 0; } if (params?.trigger === "cron") { - return clampTimeoutMs(runTimeoutMs); + if ( + isLocalRuntimeModel || + isExplicitLocalHostnameRuntimeModel || + isSelfHostedHostnameRuntimeModel + ) { + return clampTimeoutMs(runTimeoutMs); + } + return clampTimeoutMs(Math.min(runTimeoutMs, CRON_LLM_IDLE_TIMEOUT_MS)); } return clampImplicitTimeoutMs(runTimeoutMs); } @@ -190,10 +297,7 @@ export function resolveLlmIdleTimeoutMs(params?: { // baseUrl pointing at loopback / private-network / `.local`. Ollama cloud // models are still hosted remotely even when proxied through local Ollama, so // keep the cloud watchdog for `*:cloud` model ids. - const baseUrl = params?.model?.baseUrl; - const isLocalProvider = - typeof baseUrl === "string" && baseUrl.length > 0 && isLocalProviderBaseUrl(baseUrl); - if (isLocalProvider && !isOllamaCloudModel(params?.model)) { + if (isLocalRuntimeModel) { return 0; } diff --git a/src/agents/main-session-restart-recovery.test.ts b/src/agents/main-session-restart-recovery.test.ts index 9d72418655ca..ba9f919eec32 100644 --- a/src/agents/main-session-restart-recovery.test.ts +++ b/src/agents/main-session-restart-recovery.test.ts @@ -73,6 +73,7 @@ function cleanedLockForPath(lockPath: string): SessionLockInspection { ageMs: 1_000, stale: true, staleReasons: ["dead-pid"], + removable: true, removed: true, }; } diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index 3f203392f0a3..f2d5c2cfedf0 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -99,9 +99,77 @@ function installScopedSessionStores(syncUpdates = false) { async function createSessionsModuleMock() { const actual = await vi.importActual("../config/sessions.js"); + const resolveMockStorePath = (_store: string | undefined, opts?: { agentId?: string }) => + opts?.agentId === "support" ? "/tmp/support/sessions.json" : "/tmp/main/sessions.json"; + const cloneEntry = (entry: SessionEntry): SessionEntry => structuredClone(entry); return { ...actual, loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath), + patchSessionEntryWithKey: async ( + scope: { agentId?: string; sessionKey: string; storePath?: string }, + update: ( + entry: SessionEntry, + context: { existingEntry?: SessionEntry }, + ) => Promise | null> | Partial | null, + options?: { fallbackEntry?: SessionEntry; replaceEntry?: boolean }, + ) => { + const storePath = + scope.storePath ?? resolveMockStorePath(undefined, { agentId: scope.agentId }); + const store = loadSessionStoreMock(storePath) as Record; + const resolved = actual.resolveSessionStoreEntry({ store, sessionKey: scope.sessionKey }); + const existing = resolved.existing ?? options?.fallbackEntry; + if (!existing) { + return null; + } + const patch = await update(cloneEntry(existing), { + existingEntry: resolved.existing ? cloneEntry(resolved.existing) : undefined, + }); + if (!patch) { + return { sessionKey: resolved.normalizedKey, entry: cloneEntry(existing) }; + } + const next = options?.replaceEntry + ? cloneEntry(patch as SessionEntry) + : actual.mergeSessionEntry(existing, patch); + store[resolved.normalizedKey] = next; + updateSessionStoreMock(storePath, store); + return { sessionKey: resolved.normalizedKey, entry: cloneEntry(next) }; + }, + resolveSessionEntryCandidateTarget: (scope: { + agentId: string; + candidateKeys: readonly string[]; + cfg: { session?: { store?: string } }; + fallback?: { sessionKey: string; entry: SessionEntry }; + }) => { + const storePath = resolveMockStorePath(scope.cfg.session?.store, { agentId: scope.agentId }); + const store = loadSessionStoreMock(storePath) as Record; + const candidates = [...new Set(scope.candidateKeys.map((key) => key.trim()))]; + for (const candidateKey of candidates) { + if (!candidateKey) { + continue; + } + const resolved = actual.resolveSessionStoreEntry({ store, sessionKey: candidateKey }); + if (!resolved.existing) { + continue; + } + return { + agentId: scope.agentId, + candidateKey, + entry: cloneEntry(resolved.existing), + persisted: true, + sessionKey: resolved.normalizedKey, + }; + } + const fallbackKey = scope.fallback?.sessionKey.trim(); + return fallbackKey && scope.fallback + ? { + agentId: scope.agentId, + candidateKey: fallbackKey, + entry: cloneEntry(scope.fallback.entry), + persisted: false, + sessionKey: fallbackKey, + } + : null; + }, updateSessionStore: async ( storePath: string, mutator: (store: Record) => Promise | void, @@ -111,8 +179,7 @@ async function createSessionsModuleMock() { updateSessionStoreMock(storePath, store); return store; }, - resolveStorePath: (_store: string | undefined, opts?: { agentId?: string }) => - opts?.agentId === "support" ? "/tmp/support/sessions.json" : "/tmp/main/sessions.json", + resolveStorePath: resolveMockStorePath, }; } @@ -1191,6 +1258,41 @@ describe("session_status tool", () => { expect(saved.sessionId).toMatch(UUID_RE); }); + it("preserves an existing legacy main row when implicit fallback mutates model state", async () => { + resetSessionStore({ + main: { + sessionId: "legacy-main-session", + updatedAt: 10, + label: "Legacy Main", + lastChannel: "telegram", + }, + }); + + const tool = getSessionStatusTool("agent:main:main"); + + const result = await tool.execute("call-legacy-main-fallback-model", { + model: "anthropic/claude-sonnet-4-6", + }); + const details = result.details as { + ok?: boolean; + sessionKey?: string; + modelOverride?: string | null; + }; + expect(details.ok).toBe(true); + expect(details.sessionKey).toBe("main"); + expect(details.modelOverride).toBe("anthropic/claude-sonnet-4-6"); + expect(updateSessionStoreMock).toHaveBeenCalledTimes(1); + const savedStore = latestMockCallArg(updateSessionStoreMock, 1) as Record; + expect(savedStore.main).toMatchObject({ + sessionId: "legacy-main-session", + label: "Legacy Main", + lastChannel: "telegram", + providerOverride: "anthropic", + modelOverride: "claude-sonnet-4-6", + liveModelSwitchPending: true, + }); + }); + it("fires session:patch when session_status changes the persisted session model", async () => { const events: InternalHookEvent[] = []; registerInternalHook("session:patch", async (event) => { diff --git a/src/agents/provider-http-errors.test.ts b/src/agents/provider-http-errors.test.ts index 6a843fd827f3..824e4c23de14 100644 --- a/src/agents/provider-http-errors.test.ts +++ b/src/agents/provider-http-errors.test.ts @@ -9,6 +9,7 @@ import { ProviderHttpError, readProviderBinaryResponse, readProviderJsonResponse, + readProviderTextResponse, readResponseTextLimited, } from "./provider-http-errors.js"; @@ -64,6 +65,31 @@ function createStreamingJsonResponse(params: { chunkCount: number; chunkSize: nu }; } +function createStreamingTextResponse(params: { chunkCount: number; chunkSize: number }): { + response: Response; + getReadCount: () => number; +} { + let reads = 0; + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + pull(controller) { + if (reads >= params.chunkCount) { + controller.close(); + return; + } + reads += 1; + controller.enqueue(encoder.encode("x".repeat(params.chunkSize))); + }, + }); + return { + response: new Response(stream, { + status: 200, + headers: { "Content-Type": "text/plain" }, + }), + getReadCount: () => reads, + }; +} + describe("provider error utils", () => { it("formats nested provider error details with request ids", async () => { const response = new Response( @@ -263,6 +289,21 @@ describe("provider error utils", () => { expect(streamed.getReadCount()).toBeLessThan(20); }); + it("caps successful text responses instead of buffering oversized bodies", async () => { + const streamed = createStreamingTextResponse({ + chunkCount: 20, + chunkSize: 1024, + }); + + await expect( + readProviderTextResponse(streamed.response, "Provider text failed", { + maxBytes: 2048, + }), + ).rejects.toThrow("Provider text failed: text response exceeds 2048 bytes"); + + expect(streamed.getReadCount()).toBeLessThan(20); + }); + it("caps successful binary responses instead of buffering oversized bodies", async () => { const streamed = createStreamingBinaryResponse({ chunkCount: 20, diff --git a/src/agents/provider-http-errors.ts b/src/agents/provider-http-errors.ts index dbfe7c06ed3d..cef0190c0319 100644 --- a/src/agents/provider-http-errors.ts +++ b/src/agents/provider-http-errors.ts @@ -14,6 +14,7 @@ export { normalizeOptionalString as trimToUndefined } from "../../packages/norma const ERROR_BODY_METADATA_LIMIT = 500; const PROVIDER_BINARY_RESPONSE_MAX_BYTES = 16 * 1024 * 1024; const PROVIDER_JSON_RESPONSE_MAX_BYTES = 16 * 1024 * 1024; +const PROVIDER_TEXT_RESPONSE_MAX_BYTES = 16 * 1024 * 1024; /** Returns a plain object view for provider JSON payloads when one exists. */ export function asObject(value: unknown): Record | undefined { @@ -86,6 +87,20 @@ export async function readResponseTextLimited( return text; } +/** Reads a successful provider text response under a byte cap. */ +export async function readProviderTextResponse( + response: Response, + label: string, + opts?: { maxBytes?: number }, +): Promise { + const maxBytes = opts?.maxBytes ?? PROVIDER_TEXT_RESPONSE_MAX_BYTES; + const bytes = await readResponseWithLimit(response, maxBytes, { + onOverflow: ({ maxBytes: maxBytesLocal }) => + new Error(`${label}: text response exceeds ${maxBytesLocal} bytes`), + }); + return new TextDecoder().decode(bytes); +} + /** Formats common provider JSON error payload shapes into one readable detail string. */ export function formatProviderErrorPayload(payload: unknown): string | undefined { const root = asObject(payload); diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 53799d0e8f09..afe9e2cf4343 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -43,6 +43,7 @@ export { isToolAllowed, resolveSandboxToolPolicyForAgent } from "./sandbox/tool- export type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath } from "./sandbox/fs-bridge.js"; export { buildExecRemoteCommand, + buildRemoteWorkdirValidationCommand, buildRemoteCommand, buildSshSandboxArgv, buildValidatedExecRemoteCommand, @@ -68,9 +69,12 @@ export type { SandboxBackendHandle, SandboxBackendId, SandboxBackendManager, + SandboxBackendPreparedWorkdirDiscarder, SandboxBackendRegistration, SandboxBackendRuntimeInfo, + SandboxBackendWorkdirValidation, SandboxBackendWorkdirResolver, + SandboxBackendWorkdirValidator, } from "./sandbox/backend.js"; export type { RemoteShellSandboxHandle } from "./sandbox/remote-fs-bridge.js"; export type { diff --git a/src/agents/sandbox/backend-handle.types.ts b/src/agents/sandbox/backend-handle.types.ts index c59600fb40f5..d18537762bef 100644 --- a/src/agents/sandbox/backend-handle.types.ts +++ b/src/agents/sandbox/backend-handle.types.ts @@ -18,6 +18,11 @@ export type SandboxBackendExecSpec = { finalizeToken?: unknown; }; +export type SandboxBackendWorkdirValidation = "host" | "backend"; + +export type SandboxBackendWorkdirValidator = (workdir: string) => Promise; +export type SandboxBackendPreparedWorkdirDiscarder = (workdir: string) => void; + /** Parameters for backend-managed shell commands used by fs bridges and probes. */ export type SandboxBackendCommandParams = { script: string; @@ -59,6 +64,18 @@ export type SandboxBackendHandle = { env?: Record; configLabel?: string; configLabelKind?: string; + /** + * Remote backends own cwd existence checks because valid runtime paths may + * not exist in the local workspace mirror. Backend validation must be paired + * with validateWorkdir so cwd is proved after before_tool_call adjustments + * and before env resolution, approval, preflight, and launch. + */ + workdirValidation?: SandboxBackendWorkdirValidation; + validateWorkdir?: SandboxBackendWorkdirValidator; + /** Discard one-shot state created while validating a backend-owned cwd. */ + discardPreparedWorkdir?: SandboxBackendPreparedWorkdirDiscarder; + /** Remote cwd roots managed by backend validation. Defaults to workdir. */ + workdirRoots?: readonly string[]; capabilities?: { browser?: boolean; }; diff --git a/src/agents/sandbox/backend.ts b/src/agents/sandbox/backend.ts index c1742d08388e..9636167d4aeb 100644 --- a/src/agents/sandbox/backend.ts +++ b/src/agents/sandbox/backend.ts @@ -20,6 +20,7 @@ export type { SandboxBackendManager, SandboxBackendRegistration, SandboxBackendRuntimeInfo, + SandboxBackendWorkdirValidation, SandboxBackendWorkdirResolver, } from "./backend.types.js"; export type { @@ -27,6 +28,8 @@ export type { SandboxBackendCommandResult, SandboxBackendExecSpec, SandboxBackendHandle, + SandboxBackendPreparedWorkdirDiscarder, + SandboxBackendWorkdirValidator, } from "./backend-handle.types.js"; const SANDBOX_BACKEND_FACTORIES_STATE_KEY = Symbol.for("openclaw.sandboxBackendFactories"); diff --git a/src/agents/sandbox/backend.types.ts b/src/agents/sandbox/backend.types.ts index e7f9acde67b1..0d961e92c3d2 100644 --- a/src/agents/sandbox/backend.types.ts +++ b/src/agents/sandbox/backend.types.ts @@ -68,5 +68,8 @@ export type { SandboxBackendCommandParams, SandboxBackendCommandResult, SandboxBackendExecSpec, + SandboxBackendPreparedWorkdirDiscarder, + SandboxBackendWorkdirValidation, + SandboxBackendWorkdirValidator, SandboxFsBridgeContext, } from "./backend-handle.types.js"; diff --git a/src/agents/sandbox/ssh-backend.test.ts b/src/agents/sandbox/ssh-backend.test.ts index c6d71ffec5ce..8691af0c5a81 100644 --- a/src/agents/sandbox/ssh-backend.test.ts +++ b/src/agents/sandbox/ssh-backend.test.ts @@ -33,7 +33,8 @@ vi.mock("./ssh.js", async () => { }; }); -const { createSshSandboxBackend, sshSandboxBackendManager } = await import("./ssh-backend.js"); +const { createSshSandboxBackend, resolveSshRuntimePaths, sshSandboxBackendManager } = + await import("./ssh-backend.js"); const tempDirs: string[] = []; async function createTempDir(prefix: string): Promise { @@ -341,6 +342,173 @@ describe("ssh sandbox backend", () => { expect(sshMocks.disposeSshSandboxSession).toHaveBeenCalledTimes(2); }); + it("validates remote workdirs before exec accepts backend-owned cwd", async () => { + sshMocks.runSshSandboxCommand + .mockResolvedValueOnce({ + stdout: Buffer.from("1\n"), + stderr: Buffer.alloc(0), + code: 0, + }) + .mockResolvedValueOnce({ + stdout: Buffer.from("/remote/openclaw/openclaw-ssh-agent-worker-abcd1234/workspace/src\n"), + stderr: Buffer.alloc(0), + code: 0, + }) + .mockResolvedValueOnce({ + stdout: Buffer.alloc(0), + stderr: Buffer.from("remote directory not found\n"), + code: 1, + }) + .mockResolvedValueOnce({ + stdout: Buffer.from("/remote/openclaw/openclaw-ssh-agent-worker-abcd1234/agent/src\n"), + stderr: Buffer.alloc(0), + code: 0, + }); + + const backend = await createSshSandboxBackend({ + sessionKey: "agent:worker:task", + scopeKey: "agent:worker", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + cfg: createBackendSandboxConfig({ + target: "peter@example.com:2222", + }), + }); + + await expect( + backend.validateWorkdir?.( + "/remote/openclaw/openclaw-ssh-agent-worker-abcd1234/workspace/src", + ), + ).resolves.toBe("/remote/openclaw/openclaw-ssh-agent-worker-abcd1234/workspace/src"); + await expect( + backend.validateWorkdir?.( + "/remote/openclaw/openclaw-ssh-agent-worker-abcd1234/workspace/missing", + ), + ).resolves.toBeNull(); + await expect( + backend.validateWorkdir?.("/remote/openclaw/openclaw-ssh-agent-worker-abcd1234/agent/src"), + ).resolves.toBe("/remote/openclaw/openclaw-ssh-agent-worker-abcd1234/agent/src"); + + const validationCommand = String(requireSshRunCommandParams(1).remoteCommand); + expect(validationCommand).toContain("openclaw-validate-workdir"); + expect(validationCommand).toContain("remote directory must stay under root"); + const agentValidationCommand = String(requireSshRunCommandParams(3).remoteCommand); + expect(agentValidationCommand).toContain( + "/remote/openclaw/openclaw-ssh-agent-worker-abcd1234/agent", + ); + }); + + it("refreshes materialized skills before validating a skills workdir", async () => { + const skillsWorkspaceDir = await createTempDir("openclaw-ssh-skills-"); + await fs.mkdir(path.join(skillsWorkspaceDir, "skills", "demo"), { recursive: true }); + const runtimePaths = resolveSshRuntimePaths("/remote/openclaw", "agent:worker"); + const skillsWorkdir = path.posix.join(runtimePaths.remoteSkillsWorkspaceDir, "skills", "demo"); + sshMocks.runSshSandboxCommand + .mockResolvedValueOnce({ + stdout: Buffer.from("1\n"), + stderr: Buffer.alloc(0), + code: 0, + }) + .mockResolvedValueOnce({ + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + code: 0, + }) + .mockResolvedValueOnce({ + stdout: Buffer.from(`${skillsWorkdir}\n`), + stderr: Buffer.alloc(0), + code: 0, + }); + + const backend = await createSshSandboxBackend({ + sessionKey: "agent:worker:task", + scopeKey: "agent:worker", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + skillsWorkspaceDir, + cfg: createBackendSandboxConfig({ + target: "peter@example.com:2222", + }), + }); + + await expect(backend.validateWorkdir?.(skillsWorkdir)).resolves.toBe(skillsWorkdir); + const execSpec = await backend.buildExecSpec({ + command: "pwd", + workdir: skillsWorkdir, + env: {}, + usePty: false, + }); + + expect(sshMocks.uploadDirectoryToSshTarget).toHaveBeenCalledOnce(); + const skillsUploadParams = requireSshUploadParams(0, "skills upload params"); + expect(skillsUploadParams.localDir).toBe(skillsWorkspaceDir); + expect(skillsUploadParams.remoteDir).toBe(runtimePaths.remoteSkillsWorkspaceDir); + expect(execSpec.argv.at(-1)).toContain(skillsWorkdir); + await backend.finalizeExec?.({ + status: "completed", + exitCode: 0, + timedOut: false, + token: execSpec.finalizeToken, + }); + }); + + it("discards validated materialized skills refreshes that do not launch", async () => { + const skillsWorkspaceDir = await createTempDir("openclaw-ssh-skills-"); + await fs.mkdir(path.join(skillsWorkspaceDir, "skills", "demo"), { recursive: true }); + const runtimePaths = resolveSshRuntimePaths("/remote/openclaw", "agent:worker"); + const skillsWorkdir = path.posix.join(runtimePaths.remoteSkillsWorkspaceDir, "skills", "demo"); + sshMocks.runSshSandboxCommand + .mockResolvedValueOnce({ + stdout: Buffer.from("1\n"), + stderr: Buffer.alloc(0), + code: 0, + }) + .mockResolvedValueOnce({ + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + code: 0, + }) + .mockResolvedValueOnce({ + stdout: Buffer.from(`${skillsWorkdir}\n`), + stderr: Buffer.alloc(0), + code: 0, + }) + .mockResolvedValueOnce({ + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + code: 0, + }); + + const backend = await createSshSandboxBackend({ + sessionKey: "agent:worker:task", + scopeKey: "agent:worker", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + skillsWorkspaceDir, + cfg: createBackendSandboxConfig({ + target: "peter@example.com:2222", + }), + }); + + await expect(backend.validateWorkdir?.(skillsWorkdir)).resolves.toBe(skillsWorkdir); + backend.discardPreparedWorkdir?.(skillsWorkdir); + + const execSpec = await backend.buildExecSpec({ + command: "pwd", + workdir: skillsWorkdir, + env: {}, + usePty: false, + }); + + expect(sshMocks.uploadDirectoryToSshTarget).toHaveBeenCalledTimes(2); + await backend.finalizeExec?.({ + status: "completed", + exitCode: 0, + timedOut: false, + token: execSpec.finalizeToken, + }); + }); + it("refreshes materialized skills before each exec and remote fs command", async () => { const skillsWorkspaceDir = await createTempDir("openclaw-ssh-skills-"); await fs.mkdir(path.join(skillsWorkspaceDir, "skills"), { recursive: true }); diff --git a/src/agents/sandbox/ssh-backend.ts b/src/agents/sandbox/ssh-backend.ts index 2656440a9506..ffbfc7c2c56f 100644 --- a/src/agents/sandbox/ssh-backend.ts +++ b/src/agents/sandbox/ssh-backend.ts @@ -23,6 +23,7 @@ import { import { sanitizeEnvVars } from "./sanitize-env-vars.js"; import { buildRemoteCommand, + buildRemoteWorkdirValidationCommand, buildSshSandboxArgv, buildValidatedExecRemoteCommand, createSshSandboxSessionFromSettings, @@ -132,6 +133,7 @@ export async function createSshSandboxBackend( class SshSandboxBackendImpl { private ensurePromise: Promise | null = null; + private refreshedSkillsForNextExecWorkdir: string | null = null; constructor( private readonly params: { @@ -150,18 +152,28 @@ class SshSandboxBackendImpl { env: this.params.createParams.cfg.docker.env, configLabel: this.params.target, configLabelKind: "Target", + workdirValidation: "backend", + validateWorkdir: async (workdir) => await this.validateWorkdir(workdir), + discardPreparedWorkdir: (workdir) => this.discardPreparedWorkdir(workdir), + workdirRoots: [ + this.params.runtimePaths.remoteWorkspaceDir, + this.params.runtimePaths.remoteAgentWorkspaceDir, + ], remoteWorkspaceDir: this.params.runtimePaths.remoteWorkspaceDir, remoteAgentWorkspaceDir: this.params.runtimePaths.remoteAgentWorkspaceDir, buildExecSpec: async ({ command, workdir, env, usePty }) => { + const remoteWorkdir = workdir ?? this.params.runtimePaths.remoteWorkspaceDir; const remoteCommand = buildValidatedExecRemoteCommand({ command, - workdir: workdir ?? this.params.runtimePaths.remoteWorkspaceDir, + workdir: remoteWorkdir, env, }); await this.ensureRuntime(); const sshSession = await this.createSession(); try { - await this.refreshRemoteSkillsWorkspace(sshSession); + if (!this.consumeRefreshedSkillsForNextExec(remoteWorkdir)) { + await this.refreshRemoteSkillsWorkspace(sshSession); + } return { argv: buildSshSandboxArgv({ session: sshSession, @@ -252,6 +264,68 @@ class SshSandboxBackendImpl { } } + private async validateWorkdir(workdir: string): Promise { + await this.ensureRuntime(); + const session = await this.createSession(); + let refreshedSkillsForWorkdir: string | null = null; + try { + if (isRemotePathInsideRoot(this.params.runtimePaths.remoteSkillsWorkspaceDir, workdir)) { + await this.refreshRemoteSkillsWorkspace(session); + refreshedSkillsForWorkdir = workdir; + this.refreshedSkillsForNextExecWorkdir = workdir; + } + const result = await runSshSandboxCommand({ + session, + remoteCommand: buildRemoteWorkdirValidationCommand({ + workdir, + root: this.resolveWorkdirValidationRoot(workdir), + }), + allowFailure: true, + }); + const resolvedWorkdir = result.code === 0 ? result.stdout.toString("utf8").trim() : ""; + if (refreshedSkillsForWorkdir) { + this.refreshedSkillsForNextExecWorkdir = resolvedWorkdir || null; + } + return resolvedWorkdir || null; + } catch (error) { + if ( + refreshedSkillsForWorkdir && + this.refreshedSkillsForNextExecWorkdir === refreshedSkillsForWorkdir + ) { + this.refreshedSkillsForNextExecWorkdir = null; + } + throw error; + } finally { + await disposeSshSandboxSession(session); + } + } + + private discardPreparedWorkdir(workdir: string): void { + if (this.refreshedSkillsForNextExecWorkdir === workdir) { + this.refreshedSkillsForNextExecWorkdir = null; + } + } + + private consumeRefreshedSkillsForNextExec(workdir: string): boolean { + if (this.refreshedSkillsForNextExecWorkdir !== workdir) { + this.refreshedSkillsForNextExecWorkdir = null; + return false; + } + this.refreshedSkillsForNextExecWorkdir = null; + return true; + } + + private resolveWorkdirValidationRoot(workdir: string): string { + const roots = [ + this.params.runtimePaths.remoteAgentWorkspaceDir, + this.params.runtimePaths.remoteWorkspaceDir, + ]; + return ( + roots.find((root) => isRemotePathInsideRoot(root, workdir)) ?? + this.params.runtimePaths.remoteWorkspaceDir + ); + } + private async refreshRemoteSkillsWorkspace(session: SshSandboxSession): Promise { if ( this.params.createParams.cfg.workspaceAccess !== "rw" || @@ -333,6 +407,22 @@ async function isExistingDirectory(dir: string): Promise { } } +function normalizeRemotePath(input: string): string { + const normalized = path.posix.normalize(input.replace(/\\/g, "/")); + return normalized === "/" ? normalized : normalized.replace(/\/+$/g, ""); +} + +function isRemotePathInsideRoot(root: string, candidate: string): boolean { + const normalizedRoot = normalizeRemotePath(root); + const normalizedCandidate = normalizeRemotePath(candidate); + return ( + normalizedCandidate === normalizedRoot || + (normalizedRoot === "/" + ? normalizedCandidate.startsWith("/") + : normalizedCandidate.startsWith(`${normalizedRoot}/`)) + ); +} + export function resolveSshRuntimePaths( workspaceRoot: string, scopeKey: string, diff --git a/src/agents/sandbox/ssh.test.ts b/src/agents/sandbox/ssh.test.ts index 2e9dae6739d5..b388637523d3 100644 --- a/src/agents/sandbox/ssh.test.ts +++ b/src/agents/sandbox/ssh.test.ts @@ -9,12 +9,14 @@ import { afterEach, describe, expect, it } from "vitest"; import { makeTempDir } from "../../../test/helpers/temp-dir.js"; import { buildExecRemoteCommand, + buildRemoteWorkdirValidationCommand, buildValidatedExecRemoteCommand, createSshSandboxSessionFromSettings, disposeSshSandboxSession, ENSURE_REMOTE_REAL_DIRECTORY_SCRIPT, type SshSandboxSession, uploadDirectoryToSshTarget, + VALIDATE_REMOTE_WORKDIR_SCRIPT, } from "./ssh.js"; const sessions: SshSandboxSession[] = []; @@ -237,6 +239,87 @@ describe("sandbox ssh helpers", () => { }, ); + it.runIf(process.platform !== "win32")( + "validates exec workdirs without creating missing directories", + async () => { + const root = makeTempDir(tempDirs, "openclaw-ssh-workdir-"); + const project = path.join(root, "workspace", "project"); + await fs.mkdir(project, { recursive: true }); + const canonicalProject = await fs.realpath(project); + + const { stdout } = await execFileAsync("/bin/sh", [ + "-c", + VALIDATE_REMOTE_WORKDIR_SCRIPT, + "openclaw-validate-workdir", + project, + path.join(root, "workspace"), + ]); + + expect(stdout.trim()).toBe(canonicalProject); + await expect( + execFileAsync("/bin/sh", [ + "-c", + VALIDATE_REMOTE_WORKDIR_SCRIPT, + "openclaw-validate-workdir", + path.join(root, "workspace", "missing"), + path.join(root, "workspace"), + ]), + ).rejects.toThrow(/remote directory not found/); + await expect(fs.stat(path.join(root, "workspace", "missing"))).rejects.toThrow(); + }, + ); + + it.runIf(process.platform !== "win32")( + "rejects symlinked exec workdirs inside the trusted remote root", + async () => { + const root = makeTempDir(tempDirs, "openclaw-ssh-workdir-"); + const workspace = path.join(root, "workspace"); + await fs.mkdir(workspace, { recursive: true }); + await fs.symlink(root, path.join(workspace, "escape")); + + await expect( + execFileAsync("/bin/sh", [ + "-c", + VALIDATE_REMOTE_WORKDIR_SCRIPT, + "openclaw-validate-workdir", + path.join(workspace, "escape"), + workspace, + ]), + ).rejects.toThrow(/unsafe remote directory symlink/); + }, + ); + + it.runIf(process.platform !== "win32")( + "validates exec workdirs when the trusted remote root is slash", + async () => { + const root = makeTempDir(tempDirs, "openclaw-ssh-root-"); + const project = path.join(root, "project"); + await fs.mkdir(project, { recursive: true }); + const canonicalProject = await fs.realpath(project); + + const { stdout } = await execFileAsync("/bin/sh", [ + "-c", + VALIDATE_REMOTE_WORKDIR_SCRIPT, + "openclaw-validate-workdir", + canonicalProject, + "/", + ]); + + expect(stdout.trim()).toBe(canonicalProject); + }, + ); + + it("builds remote workdir validation commands with quoted literal paths", () => { + const command = buildRemoteWorkdirValidationCommand({ + workdir: "/remote/workspace/project one", + root: "/remote/workspace", + }); + + expect(command).toContain("openclaw-validate-workdir"); + expect(command).toContain("project one"); + expect(command).toContain("remote directory must be absolute"); + }); + it.runIf(process.platform !== "win32")( "rejects symlinked directories inside the trusted remote root", async () => { diff --git a/src/agents/sandbox/ssh.ts b/src/agents/sandbox/ssh.ts index 324866b8b7af..c8869110f257 100644 --- a/src/agents/sandbox/ssh.ts +++ b/src/agents/sandbox/ssh.ts @@ -308,6 +308,60 @@ export function buildValidatedExecRemoteCommand(params: { return buildExecRemoteCommand(params); } +export const VALIDATE_REMOTE_WORKDIR_SCRIPT = [ + "set -e", + 'target="$1"', + 'root="$2"', + 'case "$target" in /*) ;; *) echo "remote directory must be absolute: $target" >&2; exit 1 ;; esac', + 'case "$root" in /*) ;; *) echo "remote root must be absolute: $root" >&2; exit 1 ;; esac', + 'target="${target%/}"', + 'root="${root%/}"', + '[ -n "$target" ] || target="/"', + '[ -n "$root" ] || root="/"', + 'if [ "$root" != "/" ]; then', + ' case "$target/" in "$root"/*|"$root/") ;; *) echo "remote directory must stay under root: $target" >&2; exit 1 ;; esac', + "fi", + 'for path_to_check in "$target" "$root"; do', + ' relative="${path_to_check#/}"', + ' while [ -n "$relative" ]; do', + ' part="${relative%%/*}"', + ' if [ "$part" = "$relative" ]; then relative=""; else relative="${relative#*/}"; fi', + ' [ -n "$part" ] || continue', + ' case "$part" in "."|"..") echo "unsafe remote directory component: $part" >&2; exit 1 ;; esac', + " done", + "done", + 'if [ -L "$root" ]; then echo "unsafe remote root symlink: $root" >&2; exit 1; fi', + 'if [ ! -d "$root" ]; then echo "remote root not found: $root" >&2; exit 1; fi', + 'canonical_root="$(cd "$root" && pwd -P)"', + 'relative="${target#"$root"}"', + 'relative="${relative#/}"', + 'current="$canonical_root"', + 'while [ -n "$relative" ]; do', + ' part="${relative%%/*}"', + ' if [ "$part" = "$relative" ]; then relative=""; else relative="${relative#*/}"; fi', + ' [ -n "$part" ] || continue', + ' if [ "$current" = "/" ]; then next="/$part"; else next="$current/$part"; fi', + ' if [ -L "$next" ]; then echo "unsafe remote directory symlink: $next" >&2; exit 1; fi', + ' if [ ! -d "$next" ]; then echo "remote directory not found: $next" >&2; exit 1; fi', + ' current="$next"', + "done", + 'printf "%s\\n" "$current"', +].join("\n"); + +export function buildRemoteWorkdirValidationCommand(params: { + workdir: string; + root: string; +}): string { + return buildRemoteCommand([ + "/bin/sh", + "-c", + VALIDATE_REMOTE_WORKDIR_SCRIPT, + "openclaw-validate-workdir", + params.workdir, + params.root, + ]); +} + function createExecCommandFrame(kind: ExecCommandFrame["kind"], parenDepth = 0): ExecCommandFrame { return { kind, quote: "plain", escaping: false, parenDepth }; } diff --git a/src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts b/src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts index 4db630f8533d..e12ebdcadfe5 100644 --- a/src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts +++ b/src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts @@ -10,6 +10,7 @@ import { resetGlobalHookRunner, } from "../plugins/hook-runner-global.js"; import { loadOpenClawPlugins } from "../plugins/loader.js"; +import { deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js"; import { guardSessionManager } from "./session-tool-result-guard-wrapper.js"; const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} }; @@ -117,9 +118,9 @@ afterEach(() => { process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledPluginsDir; } if (originalConfigPath === undefined) { - delete process.env.OPENCLAW_CONFIG_PATH; + deleteTestEnvValue("OPENCLAW_CONFIG_PATH"); } else { - process.env.OPENCLAW_CONFIG_PATH = originalConfigPath; + setTestEnvValue("OPENCLAW_CONFIG_PATH", originalConfigPath); } for (const dir of tempDirs) { fs.rmSync(dir, { force: true, recursive: true }); @@ -259,9 +260,10 @@ describe("tool_result_persist hook", () => { it("keeps sensitive parent keys when custom value patterns match the key probe", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-redact-config-")); tempDirs.push(tempDir); - process.env.OPENCLAW_CONFIG_PATH = path.join(tempDir, "openclaw.json"); + const configPath = path.join(tempDir, "openclaw.json"); + setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath); fs.writeFileSync( - process.env.OPENCLAW_CONFIG_PATH, + configPath, JSON.stringify({ logging: { redactPatterns: ["/[a-z0-9]{30,}/g"] } }), "utf-8", ); diff --git a/src/agents/session-write-lock.ts b/src/agents/session-write-lock.ts index 4e672c703102..b4fd492cf494 100644 --- a/src/agents/session-write-lock.ts +++ b/src/agents/session-write-lock.ts @@ -36,6 +36,7 @@ export type SessionLockInspection = { ageMs: number | null; stale: boolean; staleReasons: string[]; + removable: boolean; removed: boolean; }; @@ -858,13 +859,15 @@ export async function cleanStaleLockFiles(params: { reclaimLockWithoutStarttime: false, readOwnerProcessArgs: ownerProcessArgsReader, }); + const removable = await shouldRemoveLockDuringCleanup(lockPath, inspected, staleMs, nowMs); const lockInfo: SessionLockInspection = { lockPath, ...inspected, + removable, removed: false, }; - if (removeStale && (await shouldRemoveLockDuringCleanup(lockPath, lockInfo, staleMs, nowMs))) { + if (removeStale && removable) { await fs.rm(lockPath, { force: true }); lockInfo.removed = true; cleaned.push(lockInfo); diff --git a/src/agents/shell-snapshot.test.ts b/src/agents/shell-snapshot.test.ts index 15ce2060654c..d2d68388329c 100644 --- a/src/agents/shell-snapshot.test.ts +++ b/src/agents/shell-snapshot.test.ts @@ -4,7 +4,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { captureEnv } from "../test-utils/env.js"; +import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; import { maybeWrapCommandWithShellSnapshot, resetShellSnapshotCacheForTests, @@ -40,9 +40,9 @@ function setSnapshotStateForTest( options: { home?: string; zdotdir?: string } = {}, ): void { // Snapshot tests mutate trusted process env, not per-command untrusted env. - process.env.OPENCLAW_STATE_DIR = stateDir; + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); if (options.home) { - process.env.HOME = options.home; + setTestEnvValue("HOME", options.home); } if (options.zdotdir) { process.env.ZDOTDIR = options.zdotdir; @@ -91,7 +91,7 @@ describe("exec shell snapshots", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-disabled-home-")); tempDirs.push(stateDir, home); setSnapshotStateForTest(stateDir, { home }); - process.env[EXEC_SHELL_SNAPSHOT_ENV] = "0"; + setTestEnvValue(EXEC_SHELL_SNAPSHOT_ENV, "0"); const command = "echo unchanged"; const wrapped = await maybeWrapCommandWithShellSnapshot({ command, diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index ce1ff3bacb80..e2c3c724ffdc 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -2167,6 +2167,7 @@ describe("cron tool", () => { expect(params?.patch?.payload).toEqual({ kind: "agentTurn", toolsAllow: ["read", "cron"], + toolsAllowIsDefault: true, }); }); @@ -2202,6 +2203,7 @@ describe("cron tool", () => { payload: { kind: "agentTurn", toolsAllow: ["read", "cron"], + toolsAllowIsDefault: true, }, }, }, @@ -2278,6 +2280,51 @@ describe("cron tool", () => { }); }); + it("preserves the default toolsAllow flag across an update that omits toolsAllow", async () => { + // Regression guard: a routine update (here, toggling enabled) of an + // agentTurn job whose cap was an auto-stamped default must keep + // toolsAllowIsDefault set. Otherwise the run-time CLI drop (which keys off + // the flag) stops applying and the job fails closed again after a restart — + // re-breaking the exact #91499 regression this change fixes. + callGatewayMock + .mockResolvedValueOnce({ + id: "job-13", + payload: { + kind: "agentTurn", + message: "hi", + toolsAllow: ["read", "cron"], + toolsAllowIsDefault: true, + }, + }) + .mockResolvedValueOnce({ ok: true }); + + const tool = createTestCronTool({ + agentSessionKey: "agent:main:telegram:group:restricted-room", + creatorToolAllowlist: ["read", "cron"], + }); + await tool.execute("call-update-preserve-default-flag", { + action: "update", + id: "job-13", + patch: { enabled: false }, + }); + + expect(callGatewayMock).toHaveBeenCalledTimes(2); + expect(readGatewayCall(1)).toEqual({ + method: "cron.update", + params: { + id: "job-13", + patch: { + enabled: false, + payload: { + kind: "agentTurn", + toolsAllow: ["read", "cron"], + toolsAllowIsDefault: true, + }, + }, + }, + }); + }); + it("adds the creator tool surface when converting an existing job to agentTurn", async () => { callGatewayMock .mockResolvedValueOnce({ @@ -2310,6 +2357,7 @@ describe("cron tool", () => { kind: "agentTurn", message: "run later", toolsAllow: ["read", "cron"], + toolsAllowIsDefault: true, }, }, }, diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 8e58465bc44a..aaaeff315912 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -466,6 +466,7 @@ function capCronAgentTurnToolsAllow(params: { : params.defaultToolsAllow; if (!Array.isArray(requestedRaw)) { params.payload.toolsAllow = creatorToolNames; + params.payload.toolsAllowIsDefault = true; return; } const requestedToolsAllow = normalizeCronToolsAllow( @@ -473,10 +474,12 @@ function capCronAgentTurnToolsAllow(params: { ); if (requestedToolsAllow.length === 0) { params.payload.toolsAllow = []; + delete params.payload.toolsAllowIsDefault; return; } if (requestedToolsAllow.includes("*")) { params.payload.toolsAllow = creatorToolNames; + params.payload.toolsAllowIsDefault = true; return; } const pluginGroups = buildPluginToolGroups({ @@ -490,6 +493,7 @@ function capCronAgentTurnToolsAllow(params: { params.payload.toolsAllow = creatorToolNames.filter((toolName) => isToolAllowedByPolicyName(toolName, requestedPolicy), ); + delete params.payload.toolsAllowIsDefault; } function capCronAgentTurnJobToolsAllow( @@ -549,8 +553,12 @@ async function capCronAgentTurnUpdatePatchToolsAllow(params: { capCronAgentTurnToolsAllow({ payload: nextPayload, creatorToolAllowlist: params.creatorToolAllowlist, + // Flagged defaults are re-derived so normal updates do not turn them into + // explicit restrictions or lose the marker needed after restart. defaultToolsAllow: - existingPayloadKind === "agentTurn" && isRecord(existingPayload) + existingPayloadKind === "agentTurn" && + isRecord(existingPayload) && + existingPayload.toolsAllowIsDefault !== true ? existingPayload.toolsAllow : undefined, }); diff --git a/src/agents/tools/session-status-session-resolve.ts b/src/agents/tools/session-status-session-resolve.ts new file mode 100644 index 000000000000..8a47a250092c --- /dev/null +++ b/src/agents/tools/session-status-session-resolve.ts @@ -0,0 +1,154 @@ +// Status-tool session resolution helpers keep storage lookup out of the tool body. +import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; +import { resolveSessionEntryCandidateTarget, type SessionEntry } from "../../config/sessions.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + buildAgentMainSessionKey, + DEFAULT_AGENT_ID, + parseAgentSessionKey, +} from "../../routing/session-key.js"; +import { resolveInternalSessionKey } from "./sessions-helpers.js"; + +export type ResolvedStatusSessionEntry = { + entry: SessionEntry; + key: string; + persisted: boolean; +}; + +/** Resolves one status lookup against ordered tool-local session key candidates. */ +export function resolveSessionStatusEntry(params: { + agentId: string; + alias: string; + cfg: OpenClawConfig; + includeAliasFallback?: boolean; + keyRaw: string; + mainKey: string; + requesterInternalKey?: string; +}): ResolvedStatusSessionEntry | null { + const keyRaw = params.keyRaw.trim(); + if (!keyRaw) { + return null; + } + const includeAliasFallback = params.includeAliasFallback ?? true; + const internal = resolveInternalSessionKey({ + key: keyRaw, + alias: params.alias, + mainKey: params.mainKey, + requesterInternalKey: params.requesterInternalKey, + }); + + const candidates: string[] = [keyRaw]; + if (!keyRaw.startsWith("agent:")) { + candidates.push(`agent:${DEFAULT_AGENT_ID}:${keyRaw}`); + } + if (includeAliasFallback && internal !== keyRaw) { + candidates.push(internal); + } + if (includeAliasFallback && !keyRaw.startsWith("agent:")) { + const agentInternal = `agent:${DEFAULT_AGENT_ID}:${internal}`; + const agentRaw = `agent:${DEFAULT_AGENT_ID}:${keyRaw}`; + if (agentInternal !== agentRaw) { + candidates.push(agentInternal); + } + } + if (includeAliasFallback && (keyRaw === "main" || keyRaw === "current")) { + const defaultMainKey = buildAgentMainSessionKey({ + agentId: DEFAULT_AGENT_ID, + mainKey: params.mainKey, + }); + if (!candidates.includes(defaultMainKey)) { + candidates.push(defaultMainKey); + } + } + + const resolved = resolveSessionEntryCandidateTarget({ + agentId: params.agentId, + candidateKeys: candidates, + cfg: params.cfg, + }); + return resolved + ? { + entry: resolved.entry, + key: resolved.sessionKey, + persisted: resolved.persisted, + } + : null; +} + +/** Maps requester keys into the currently selected agent store's legacy main key shape. */ +export function resolveStoreScopedRequesterKey(params: { + agentId: string; + mainKey: string; + requesterKey: string; +}) { + const parsed = parseAgentSessionKey(params.requesterKey); + if (!parsed || parsed.agentId !== params.agentId) { + return params.requesterKey; + } + return parsed.rest === params.mainKey ? params.mainKey : params.requesterKey; +} + +function synthesizeImplicitCurrentSessionEntry(): SessionEntry { + return { + sessionId: "", + updatedAt: Date.now(), + }; +} + +/** Returns a synthesized current-session entry without writing it to storage. */ +export function resolveImplicitCurrentSessionFallback(params: { + agentId: string; + allowFallback: boolean; + cfg: OpenClawConfig; + fallbackKey: string; +}): ResolvedStatusSessionEntry | null { + const fallbackKey = params.fallbackKey.trim(); + if (!params.allowFallback || !fallbackKey) { + return null; + } + const resolved = resolveSessionEntryCandidateTarget({ + agentId: params.agentId, + candidateKeys: [], + cfg: params.cfg, + fallback: { + sessionKey: fallbackKey, + entry: synthesizeImplicitCurrentSessionEntry(), + }, + }); + return resolved + ? { + entry: resolved.entry, + key: resolved.sessionKey, + persisted: resolved.persisted, + } + : null; +} + +/** Lists policy-key fallbacks for implicit default-account direct status lookups. */ +export function listImplicitDefaultDirectFallbackKeys(params: { + keyRaw: string; + mainKey: string; +}): string[] { + const parsed = parseAgentSessionKey(params.keyRaw.trim()); + if (!parsed) { + return []; + } + const parts = parsed.rest.split(":"); + if (parts.length < 4 || parts[1] !== "default" || parts[2] !== "direct") { + return []; + } + const channel = parts[0]; + const peerParts = parts.slice(3); + if (!channel || peerParts.length === 0) { + return []; + } + const candidates = [ + `agent:${parsed.agentId}:${channel}:direct:${peerParts.join(":")}`, + buildAgentMainSessionKey({ + agentId: parsed.agentId, + mainKey: params.mainKey, + }), + params.mainKey, + ]; + return uniqueStrings(candidates); +} diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index 2a46c9f0bcd7..7a1183bbaa8b 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -3,8 +3,8 @@ * * Reports and updates session runtime state, model overrides, visibility, task status, and delivery context. */ +import { randomUUID } from "node:crypto"; import { readStringValue } from "@openclaw/normalization-core/string-coerce"; -import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import { Type } from "typebox"; import type { ElevatedLevel, @@ -14,11 +14,9 @@ import type { } from "../../auto-reply/thinking.js"; import { getRuntimeConfig } from "../../config/config.js"; import { - loadSessionStore, - mergeSessionEntry, + patchSessionEntryWithKey, resolveStorePath, type SessionEntry, - updateSessionStore, } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { triggerSessionPatchHook } from "../../gateway/session-patch-hooks.js"; @@ -26,7 +24,6 @@ import { resolveSessionModelIdentityRef } from "../../gateway/session-utils.js"; import { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js"; import { buildAgentMainSessionKey, - DEFAULT_AGENT_ID, parseAgentSessionKey, resolveAgentIdFromSessionKey, } from "../../routing/session-key.js"; @@ -59,12 +56,17 @@ import { } from "../tool-description-presets.js"; import type { AnyAgentTool } from "./common.js"; import { normalizeToolModelOverride, readStringParam } from "./common.js"; +import { + listImplicitDefaultDirectFallbackKeys, + resolveImplicitCurrentSessionFallback, + resolveSessionStatusEntry, + resolveStoreScopedRequesterKey, +} from "./session-status-session-resolve.js"; import { createAgentToAgentPolicy, createSessionVisibilityGuard, resolveCurrentSessionClientAlias, resolveEffectiveSessionToolsVisibility, - resolveInternalSessionKey, resolveSandboxedSessionToolContext, resolveSessionReference, resolveVisibleSessionReference, @@ -88,121 +90,6 @@ function loadCommandsStatusRuntime(): Promise { return commandsStatusRuntimeLoader.load(); } -function resolveSessionEntry(params: { - store: Record; - keyRaw: string; - alias: string; - mainKey: string; - requesterInternalKey?: string; - includeAliasFallback?: boolean; -}): { key: string; entry: SessionEntry } | null { - const keyRaw = params.keyRaw.trim(); - if (!keyRaw) { - return null; - } - const includeAliasFallback = params.includeAliasFallback ?? true; - const internal = resolveInternalSessionKey({ - key: keyRaw, - alias: params.alias, - mainKey: params.mainKey, - requesterInternalKey: params.requesterInternalKey, - }); - - const candidates: string[] = [keyRaw]; - if (!keyRaw.startsWith("agent:")) { - candidates.push(`agent:${DEFAULT_AGENT_ID}:${keyRaw}`); - } - if (includeAliasFallback && internal !== keyRaw) { - candidates.push(internal); - } - if (includeAliasFallback && !keyRaw.startsWith("agent:")) { - const agentInternal = `agent:${DEFAULT_AGENT_ID}:${internal}`; - const agentRaw = `agent:${DEFAULT_AGENT_ID}:${keyRaw}`; - if (agentInternal !== agentRaw) { - candidates.push(agentInternal); - } - } - if (includeAliasFallback && (keyRaw === "main" || keyRaw === "current")) { - const defaultMainKey = buildAgentMainSessionKey({ - agentId: DEFAULT_AGENT_ID, - mainKey: params.mainKey, - }); - if (!candidates.includes(defaultMainKey)) { - candidates.push(defaultMainKey); - } - } - - for (const key of candidates) { - const entry = params.store[key]; - if (entry) { - return { key, entry }; - } - } - - return null; -} - -function resolveStoreScopedRequesterKey(params: { - requesterKey: string; - agentId: string; - mainKey: string; -}) { - const parsed = parseAgentSessionKey(params.requesterKey); - if (!parsed || parsed.agentId !== params.agentId) { - return params.requesterKey; - } - return parsed.rest === params.mainKey ? params.mainKey : params.requesterKey; -} - -function synthesizeImplicitCurrentSessionEntry(): SessionEntry { - return { - sessionId: "", - updatedAt: Date.now(), - }; -} - -function resolveImplicitCurrentSessionFallback(params: { - allowFallback: boolean; - fallbackKey: string; -}): { key: string; entry: SessionEntry } | null { - const fallbackKey = params.fallbackKey.trim(); - if (!params.allowFallback || !fallbackKey) { - return null; - } - return { - key: fallbackKey, - entry: synthesizeImplicitCurrentSessionEntry(), - }; -} - -function listImplicitDefaultDirectFallbackKeys(params: { - keyRaw: string; - mainKey: string; -}): string[] { - const parsed = parseAgentSessionKey(params.keyRaw.trim()); - if (!parsed) { - return []; - } - const parts = parsed.rest.split(":"); - if (parts.length < 4 || parts[1] !== "default" || parts[2] !== "direct") { - return []; - } - const channel = parts[0]; - const peerParts = parts.slice(3); - if (!channel || peerParts.length === 0) { - return []; - } - const candidates = [ - `agent:${parsed.agentId}:${channel}:direct:${peerParts.join(":")}`, - buildAgentMainSessionKey({ - agentId: parsed.agentId, - mainKey: params.mainKey, - }), - params.mainKey, - ]; - return uniqueStrings(candidates); -} - type ActiveStatusModelIdentity = { provider?: string; model: string }; type SessionStatusOriginDetails = { @@ -642,7 +529,6 @@ export function createSessionStatusTool(opts?: { ? resolveAgentIdFromSessionKey(requestedKeyInput) : requesterAgentId; let storePath = resolveStorePath(cfg.session?.store, { agentId }); - let store = loadSessionStore(storePath); let storeScopedRequesterKey = resolveStoreScopedRequesterKey({ requesterKey: effectiveRequesterKey, agentId, @@ -650,8 +536,9 @@ export function createSessionStatusTool(opts?: { }); // Resolve against the requester-scoped store first to avoid leaking default agent data. - let resolved = resolveSessionEntry({ - store, + let resolved = resolveSessionStatusEntry({ + cfg, + agentId, keyRaw: requestedKeyRaw, alias, mainKey, @@ -687,14 +574,14 @@ export function createSessionStatusTool(opts?: { requestedKeyInput = requestedKeyRaw.trim(); agentId = resolveAgentIdFromSessionKey(visibleSession.key); storePath = resolveStorePath(cfg.session?.store, { agentId }); - store = loadSessionStore(storePath); storeScopedRequesterKey = resolveStoreScopedRequesterKey({ requesterKey: effectiveRequesterKey, agentId, mainKey, }); - resolved = resolveSessionEntry({ - store, + resolved = resolveSessionStatusEntry({ + cfg, + agentId, keyRaw: requestedKeyRaw, alias, mainKey, @@ -706,8 +593,9 @@ export function createSessionStatusTool(opts?: { } if (!resolved && requestedKeyInput === "current" && effectiveRequesterLookupKey) { - resolved = resolveSessionEntry({ - store, + resolved = resolveSessionStatusEntry({ + cfg, + agentId, keyRaw: effectiveRequesterLookupKey, alias, mainKey, @@ -717,8 +605,9 @@ export function createSessionStatusTool(opts?: { } if (!resolved && requestedKeyInput === "current") { - resolved = resolveSessionEntry({ - store, + resolved = resolveSessionStatusEntry({ + cfg, + agentId, keyRaw: requestedKeyRaw, alias, mainKey, @@ -732,8 +621,9 @@ export function createSessionStatusTool(opts?: { keyRaw: requestedKeyRaw, mainKey, })) { - resolved = resolveSessionEntry({ - store, + resolved = resolveSessionStatusEntry({ + cfg, + agentId, keyRaw: fallbackKey, alias, mainKey, @@ -750,7 +640,9 @@ export function createSessionStatusTool(opts?: { if (!resolved) { const runSessionFallbackKey = opts?.runSessionKey?.trim(); const fallback = resolveImplicitCurrentSessionFallback({ + agentId, allowFallback: isSemanticCurrentRequest || requestedKeyParam === undefined, + cfg, fallbackKey: (isSemanticCurrentRequest || isImplicitRunSessionStatus) && runSessionFallbackKey ? runSessionFallbackKey @@ -793,46 +685,66 @@ export function createSessionStatusTool(opts?: { sessionEntry: resolved.entry, agentId, }); + const modelSelection = + selection.kind === "reset" + ? { + provider: configured.provider, + model: configured.model, + isDefault: true, + } + : { + provider: selection.provider, + model: selection.model, + isDefault: selection.isDefault, + }; const nextEntry: SessionEntry = { ...resolved.entry }; const applied = applyModelOverrideToSessionEntry({ entry: nextEntry, - selection: - selection.kind === "reset" - ? { - provider: configured.provider, - model: configured.model, - isDefault: true, - } - : { - provider: selection.provider, - model: selection.model, - isDefault: selection.isDefault, - }, + selection: modelSelection, markLiveSwitchPending: true, }); if (applied.updated) { - const persistedEntry = nextEntry.sessionId.trim() - ? nextEntry - : (() => { - const persistedEntryPatch: Partial = { ...nextEntry }; - delete persistedEntryPatch.sessionId; - const existingEntry = store[resolved.key]; - const existingWithValidSessionId = existingEntry?.sessionId?.trim() - ? existingEntry - : undefined; - return mergeSessionEntry(existingWithValidSessionId, persistedEntryPatch); - })(); - store[resolved.key] = persistedEntry; - await updateSessionStore(storePath, (nextStore) => { - nextStore[resolved.key] = persistedEntry; - }); - resolved.entry = persistedEntry; + const patchResult = await patchSessionEntryWithKey( + { + agentId, + sessionKey: resolved.key, + storePath, + }, + (entry, context) => { + const persistedEntryPatch: SessionEntry = { ...entry }; + applyModelOverrideToSessionEntry({ + entry: persistedEntryPatch, + selection: modelSelection, + markLiveSwitchPending: true, + }); + if ( + !persistedEntryPatch.sessionId.trim() && + !context.existingEntry?.sessionId?.trim() + ) { + persistedEntryPatch.sessionId = randomUUID(); + } + return persistedEntryPatch; + }, + { + fallbackEntry: resolved.persisted ? undefined : resolved.entry, + replaceEntry: true, + }, + ); + if (!patchResult) { + throw new Error(`Unknown sessionKey: ${resolved.key}`); + } + const persistedEntry = patchResult.entry; + resolved = { + entry: persistedEntry, + key: patchResult.sessionKey, + persisted: true, + }; triggerSessionPatchHook({ cfg, sessionEntry: persistedEntry, - sessionKey: resolved.key, + sessionKey: patchResult.sessionKey, patch: { - key: resolved.key, + key: patchResult.sessionKey, model: selection.kind === "reset" ? null : `${selection.provider}/${selection.model}`, }, }); diff --git a/src/agents/tools/sessions-history-tool.test.ts b/src/agents/tools/sessions-history-tool.test.ts index e0601aaa865f..4ea8fb42f40c 100644 --- a/src/agents/tools/sessions-history-tool.test.ts +++ b/src/agents/tools/sessions-history-tool.test.ts @@ -5,6 +5,7 @@ import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { callGateway as gatewayCall } from "../../gateway/call.js"; +import { deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js"; type CallGatewayRequest = Parameters[0]; @@ -18,7 +19,7 @@ function useLoggingConfig(name: string, logging: Record): void } const configPath = path.join(tempDir, name); fs.writeFileSync(configPath, `${JSON.stringify({ logging })}\n`, "utf8"); - process.env.OPENCLAW_CONFIG_PATH = configPath; + setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath); } function createHistoryToolWithMessage(content: string) { @@ -50,9 +51,9 @@ describe("sessions_history redaction", () => { afterAll(() => { if (previousConfigPath === undefined) { - delete process.env.OPENCLAW_CONFIG_PATH; + deleteTestEnvValue("OPENCLAW_CONFIG_PATH"); } else { - process.env.OPENCLAW_CONFIG_PATH = previousConfigPath; + setTestEnvValue("OPENCLAW_CONFIG_PATH", previousConfigPath); } if (tempDir) { fs.rmSync(tempDir, { recursive: true, force: true }); diff --git a/src/agents/utils/tools-manager.test.ts b/src/agents/utils/tools-manager.test.ts index 00fd5f66e2a3..c1414574c64e 100644 --- a/src/agents/utils/tools-manager.test.ts +++ b/src/agents/utils/tools-manager.test.ts @@ -2,6 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js"; const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); const spawnSyncMock = vi.hoisted(() => vi.fn()); @@ -21,7 +22,7 @@ let tempAgentDir: string | undefined; beforeEach(() => { originalAgentDir = process.env.OPENCLAW_AGENT_DIR; tempAgentDir = mkdtempSync(join(tmpdir(), "openclaw-tools-manager-")); - process.env.OPENCLAW_AGENT_DIR = tempAgentDir; + setTestEnvValue("OPENCLAW_AGENT_DIR", tempAgentDir); fetchWithSsrFGuardMock.mockReset(); spawnSyncMock.mockReturnValue({ error: new Error("ENOENT"), @@ -35,9 +36,9 @@ afterEach(() => { vi.clearAllMocks(); vi.resetModules(); if (originalAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; + deleteTestEnvValue("OPENCLAW_AGENT_DIR"); } else { - process.env.OPENCLAW_AGENT_DIR = originalAgentDir; + setTestEnvValue("OPENCLAW_AGENT_DIR", originalAgentDir); } if (tempAgentDir) { rmSync(tempAgentDir, { recursive: true, force: true }); diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index 0f3ddf0ae86b..849de0db2f45 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -513,6 +513,19 @@ describe("chunkByNewline", () => { it.each(["", " \n\n "] as const)("returns empty array for input %j", (text) => { expect(chunkByNewline(text, 100)).toStrictEqual([]); }); + + it("does not split surrogate pairs when hard-splitting an over-long line", () => { + // An emoji-dense line with no break point forces the raw head cut at an odd code-unit offset; + // it must back off to a code-point boundary so no chunk ends in a high (or starts with a low) + // surrogate — the same contract the recursive chunkText path already honors. + const text = "😀".repeat(30); + const chunks = chunkByNewline(text, 11); + + expect(chunks.join("")).toBe(text); + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.every((chunk) => !/[\uD800-\uDBFF]$/u.test(chunk))).toBe(true); + expect(chunks.every((chunk) => !/^[\uDC00-\uDFFF]/u.test(chunk))).toBe(true); + }); }); describe("chunkTextWithMode", () => { diff --git a/src/auto-reply/chunk.ts b/src/auto-reply/chunk.ts index e4222928c1e2..356202b26d8a 100644 --- a/src/auto-reply/chunk.ts +++ b/src/auto-reply/chunk.ts @@ -163,7 +163,10 @@ export function chunkByNewline( continue; } - const firstLimit = Math.max(1, maxLineLength - prefix.length); + // Back the head cut off to a code-point boundary so an over-long line never splits a surrogate + // pair; the recursive chunkText below is already surrogate-safe, only this first cut was raw. + const rawLimit = Math.max(1, maxLineLength - prefix.length); + const firstLimit = avoidTrailingHighSurrogateBreak(lineValue, 0, rawLimit); const first = lineValue.slice(0, firstLimit); chunks.push(prefix + first); const remaining = lineValue.slice(firstLimit); diff --git a/src/auto-reply/get-reply-options.types.ts b/src/auto-reply/get-reply-options.types.ts index d0a86ee4f654..8064ceebb015 100644 --- a/src/auto-reply/get-reply-options.types.ts +++ b/src/auto-reply/get-reply-options.types.ts @@ -1,8 +1,8 @@ +import type { FastMode } from "@openclaw/normalization-core/string-coerce"; /** Public option types for reply generation callbacks, streaming, and delivery policy. */ import type { ImageContent } from "../llm/types.js"; import type { PromptImageOrderEntry } from "../media/prompt-image-order.js"; import type { UserTurnTranscriptRecorder } from "../sessions/user-turn-transcript.types.js"; -import type { FastMode } from "@openclaw/normalization-core/string-coerce"; import type { ReplyPayload } from "./reply-payload.js"; import type { TypingController } from "./reply/typing.js"; @@ -73,6 +73,8 @@ export type GetReplyOptions = { /** Called when the typing controller cleans up (e.g., run ended with NO_REPLY). */ onTypingCleanup?: () => void; onTypingController?: (typing: TypingController) => void; + /** If false, send only the initial typing signal without periodic keepalive refreshes. */ + typingKeepalive?: boolean; isHeartbeat?: boolean; /** Policy-level typing control for run classes (user/system/internal/heartbeat). */ typingPolicy?: TypingPolicy; diff --git a/src/auto-reply/model-runtime.ts b/src/auto-reply/model-runtime.ts index cdbd6cd7d40a..e156d7e24e92 100644 --- a/src/auto-reply/model-runtime.ts +++ b/src/auto-reply/model-runtime.ts @@ -78,12 +78,17 @@ export function resolveSelectedAndActiveModel(params: { selectedProvider: string; selectedModel: string; sessionEntry?: Pick; + parseSelectedProvider?: boolean; }): { selected: ModelRef; active: ModelRef; activeDiffers: boolean; } { - const selected = normalizeModelRef(params.selectedModel, params.selectedProvider); + const selected = normalizeModelRef( + params.selectedModel, + params.selectedProvider, + params.parseSelectedProvider, + ); const runtimeModel = normalizeOptionalString(params.sessionEntry?.model); const runtimeProvider = normalizeOptionalString(params.sessionEntry?.modelProvider); diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index 3e37a086d938..b1d8d21fafaf 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -28,6 +28,7 @@ import { } from "./agent-runner-execution.js"; import { HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT } from "./agent-runner-failure-copy.js"; import { + PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE, PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE, PROVIDER_INTERNAL_ERROR_USER_MESSAGE, PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE, @@ -327,6 +328,31 @@ type FallbackRunnerParams = { type EmbeddedAgentParams = { lifecycleGeneration?: string; onExecutionStarted?: (info?: { lifecycleGeneration?: string }) => void; + onExecutionPhase?: (info: { + phase: + | "runner_entered" + | "workspace" + | "runtime_plugins" + | "before_agent_reply" + | "model_resolution" + | "auth" + | "context_engine" + | "attempt_dispatch" + | "context_assembled" + | "turn_accepted" + | "process_spawned" + | "tool_execution_started" + | "assistant_output_started" + | "model_call_started"; + provider?: string; + model?: string; + backend?: string; + source?: string; + tool?: string; + toolCallId?: string; + itemId?: string; + firstModelCallStarted?: boolean; + }) => void; onBlockReply?: (payload: { text?: string; mediaUrls?: string[] }) => Promise | void; onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => Promise | void; onItemEvent?: (payload: { @@ -361,6 +387,7 @@ function createMockTypingSignaler(): TypingSignaler { signalTextDelta: vi.fn(async () => {}), signalReasoningDelta: vi.fn(async () => {}), signalToolStart: vi.fn(async () => {}), + signalExecutionActivity: vi.fn(async () => {}), }; } @@ -515,6 +542,7 @@ function createMinimalRunAgentTurnParams(overrides?: { opts?: GetReplyOptions; replyOperation?: ReplyOperation; sessionCtx?: TemplateContext; + typingSignals?: TypingSignaler; }) { return { commandBody: "fix it", @@ -527,7 +555,7 @@ function createMinimalRunAgentTurnParams(overrides?: { } as unknown as TemplateContext), opts: overrides?.opts ?? ({} satisfies GetReplyOptions), replyOperation: overrides?.replyOperation, - typingSignals: createMockTypingSignaler(), + typingSignals: overrides?.typingSignals ?? createMockTypingSignaler(), blockReplyPipeline: null, blockStreamingEnabled: false, resolvedBlockStreamingBreak: "message_end" as const, @@ -1327,6 +1355,73 @@ describe("runAgentTurnWithFallback", () => { }); }); + it("signals typing from embedded harness execution phases before assistant text", async () => { + const typingSignals = createMockTypingSignaler(); + const onAgentRunStart = vi.fn(); + state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + params.onExecutionPhase?.({ + phase: "model_call_started", + provider: "openai", + model: "gpt-5.4", + firstModelCallStarted: true, + }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const result = await runAgentTurnWithFallback({ + ...createMinimalRunAgentTurnParams({ + opts: { + onAgentRunStart, + } satisfies GetReplyOptions, + }), + typingSignals, + }); + + expect(result.kind).toBe("success"); + expect(typingSignals.signalExecutionActivity).toHaveBeenCalledOnce(); + expect(typingSignals.signalRunStart).not.toHaveBeenCalled(); + expect(onAgentRunStart).toHaveBeenCalledOnce(); + }); + + it("forwards CLI harness execution phases into typing signals", async () => { + state.isCliProviderMock.mockReturnValue(true); + state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({ + result: await params.run("codex-cli", "gpt-5.4"), + provider: "codex-cli", + model: "gpt-5.4", + attempts: [], + })); + state.runCliAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { + params.onExecutionPhase?.({ + phase: "process_spawned", + provider: "codex-cli", + model: "gpt-5.4", + backend: "codex", + }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + const followupRun = createFollowupRun(); + followupRun.run.provider = "codex-cli"; + followupRun.run.model = "gpt-5.4"; + const typingSignals = createMockTypingSignaler(); + + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const result = await runAgentTurnWithFallback( + createMinimalRunAgentTurnParams({ + followupRun, + typingSignals, + }), + ); + + expect(result.kind).toBe("success"); + expect(typingSignals.signalExecutionActivity).toHaveBeenCalledOnce(); + expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", { + provider: "codex-cli", + model: "gpt-5.4", + }); + }); + it("registers run ownership before asynchronous image preflight", async () => { const agentEvents = await import("../../infra/agent-events.js"); const registerAgentRunContext = vi.mocked(agentEvents.registerAgentRunContext); @@ -6440,6 +6535,38 @@ describe("runAgentTurnWithFallback", () => { }, ); + it.each(NON_DIRECT_FAILURE_SURFACE_CASES)( + "surfaces provider authentication failures in $label chats", + async (testCase) => { + const rawError = + "unexpected status 401 Unauthorized: Missing bearer or basic authentication in header, url: https://api.openai.com/v1/responses"; + state.runEmbeddedAgentMock.mockRejectedValueOnce( + new FailoverError("LLM request unauthorized.", { + reason: "auth", + provider: "openai", + model: "gpt-5.5", + status: 401, + rawError, + }), + ); + + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const result = await runAgentTurnWithFallback( + createMinimalRunAgentTurnParams({ + sessionCtx: createNonDirectFailureSessionCtx(testCase), + }), + ); + + expect(result.kind).toBe("final"); + if (result.kind === "final") { + expect(result.payload.isError).toBe(true); + expect(result.payload.text).toBe(PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE); + expect(result.payload.text).not.toBe(SILENT_REPLY_TOKEN); + expect(result.payload.text).not.toContain(rawError); + } + }, + ); + it.each(NON_DIRECT_FAILURE_SURFACE_CASES)( "surfaces rate-limit fallback copy in $label chats", async (testCase) => { diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 42f2089d2080..473c0dd2c0f4 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -42,6 +42,7 @@ import { import { sanitizeUserFacingText } from "../../agents/embedded-agent-helpers/sanitize-user-facing-text.js"; import { isMessagingToolSendAction } from "../../agents/embedded-agent-messaging.js"; import { mergeEmbeddedAgentRunResultForModelFallbackExhaustion } from "../../agents/embedded-agent-runner/result-fallback-classifier.js"; +import type { RunEmbeddedAgentParams } from "../../agents/embedded-agent-runner/run/params.js"; import { runEmbeddedAgent } from "../../agents/embedded-agent.js"; import { isFailoverError } from "../../agents/failover-error.js"; import type { FastModeAutoProgressState } from "../../agents/fast-mode.js"; @@ -1731,6 +1732,25 @@ export async function runAgentTurnWithFallback(params: { didNotifyAgentRunStart = true; params.opts?.onAgentRunStart?.(runId); }; + const signalExecutionPhaseForTyping = ( + info: Parameters>[0], + ) => { + const isUserVisibleExecutionActivity = + info.phase === "turn_accepted" || + info.phase === "process_spawned" || + info.phase === "model_call_started" || + info.phase === "tool_execution_started" || + info.phase === "assistant_output_started"; + if (!isUserVisibleExecutionActivity) { + return; + } + notifyAgentRunStart(); + void ( + params.typingSignals.signalExecutionActivity?.() ?? params.typingSignals.signalRunStart() + ).catch((err: unknown) => { + logVerbose(`execution phase typing signal failed: ${String(err)}`); + }); + }; const currentMessageId = params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid; const notifyUserAboutCompaction = shouldNotifyUserAboutCompaction(runtimeConfig); const deliverCompactionNoticePayload = async (noticePayload: ReplyPayload, label: string) => { @@ -2424,6 +2444,7 @@ export async function runAgentTurnWithFallback(params: { toolsAllow: params.opts?.toolsAllow, disableTools: params.opts?.disableTools, abortSignal: runAbortSignal, + onExecutionPhase: signalExecutionPhaseForTyping, replyOperation: params.replyOperation, }, }), @@ -2571,6 +2592,7 @@ export async function runAgentTurnWithFallback(params: { lifecycleGeneration = info.lifecycleGeneration; } }, + onExecutionPhase: signalExecutionPhaseForTyping, blockReplyBreak: params.resolvedBlockStreamingBreak, blockReplyChunking: params.blockReplyChunking, onPartialReply: async (payload) => { diff --git a/src/auto-reply/reply/agent-runner-usage-line.ts b/src/auto-reply/reply/agent-runner-usage-line.ts index ce9b97638e41..044755375fa1 100644 --- a/src/auto-reply/reply/agent-runner-usage-line.ts +++ b/src/auto-reply/reply/agent-runner-usage-line.ts @@ -1,14 +1,20 @@ -/** Formats and appends token/cost usage lines to reply payloads. */ +import { hasNonzeroUsage, type NormalizedUsage } from "../../agents/usage.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { PluginHookReplyUsageState } from "../../plugins/hook-types.js"; import { estimateUsageCost, formatTokenCount, formatUsd, type ModelCostConfig, + resolveModelCostConfig, } from "../../utils/usage-format.js"; import { getReplyPayloadMetadata, setReplyPayloadMetadata } from "../reply-payload.js"; +import { resolveEffectiveResponseUsage } from "../thinking.js"; import type { ReplyPayload } from "../types.js"; +import { buildUsageContract } from "../usage-bar/contract.js"; +import { loadUsageBarTemplate } from "../usage-bar/template.js"; +import { renderUsageBar } from "../usage-bar/translator.js"; -/** Formats the optional usage/cost summary appended to agent replies. */ export const formatResponseUsageLine = (params: { usage?: { input?: number; @@ -54,7 +60,56 @@ export const formatResponseUsageLine = (params: { return `Usage: ${inputLabel} in / ${outputLabel} out${cacheSuffix}${suffix}`; }; -/** Appends a usage line to the last text payload while preserving payload metadata. */ +export const resolveResponseUsageLine = (params: { + config: OpenClawConfig; + sessionRaw?: string | null; + channel?: string; + usage?: NormalizedUsage; + provider?: string; + model?: string; + preserveUserFacingSessionState?: boolean; + replyUsageState?: PluginHookReplyUsageState; +}): string | undefined => { + const responseUsageMode = resolveEffectiveResponseUsage( + params.sessionRaw, + params.config.messages?.responseUsage, + params.channel, + ); + if ( + responseUsageMode === "off" || + !hasNonzeroUsage(params.usage) || + params.preserveUserFacingSessionState === true + ) { + return undefined; + } + + const costConfig = resolveModelCostConfig({ + provider: params.provider, + model: params.model, + config: params.config, + allowPluginNormalization: false, + }); + const showCost = responseUsageMode === "full" && costConfig !== undefined; + const formatted = formatResponseUsageLine({ + usage: params.usage, + showCost, + costConfig, + }); + const usageTemplate = + responseUsageMode === "full" && params.replyUsageState + ? loadUsageBarTemplate(params.config.messages?.usageTemplate) + : undefined; + const rendered = + usageTemplate && params.replyUsageState + ? renderUsageBar(usageTemplate, buildUsageContract(params.replyUsageState, params.channel)) + : undefined; + + if (rendered) { + return rendered; + } + return formatted ?? undefined; +}; + export const appendUsageLine = (payloads: ReplyPayload[], line: string): ReplyPayload[] => { let index = -1; for (let i = payloads.length - 1; i >= 0; i -= 1) { diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 43825221ff8c..dd9157140f2d 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -16,7 +16,6 @@ import { queueEmbeddedAgentMessageWithOutcomeAsync, } from "../../agents/embedded-agent-runner/runs.js"; import { resolveFastModeState } from "../../agents/fast-mode.js"; -import { resolveAgentIdentity } from "../../agents/identity.js"; import { resolveModelAuthMode } from "../../agents/model-auth.js"; import { isCliProvider } from "../../agents/model-selection.js"; import { deriveContextPromptTokens, hasNonzeroUsage, normalizeUsage } from "../../agents/usage.js"; @@ -40,7 +39,6 @@ import { } from "../../infra/diagnostic-trace-context.js"; import { measureDiagnosticsTimelineSpan } from "../../infra/diagnostics-timeline.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; -import type { PluginHookReplyUsageState } from "../../plugins/hook-types.js"; import { CommandLaneClearedError, GatewayDrainingError } from "../../process/command-queue.js"; import { shouldPreserveUserFacingSessionStateForInputProvenance } from "../../sessions/input-provenance.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; @@ -65,12 +63,9 @@ import { setReplyPayloadMetadata, } from "../reply-payload.js"; import type { OriginatingChannelType, TemplateContext } from "../templating.js"; -import { resolveResponseUsageMode, type VerboseLevel } from "../thinking.js"; +import type { VerboseLevel } from "../thinking.js"; import { SILENT_REPLY_TOKEN } from "../tokens.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; -import { buildUsageContract } from "../usage-bar/contract.js"; -import { loadUsageBarTemplate } from "../usage-bar/template.js"; -import { renderUsageBar } from "../usage-bar/translator.js"; import { buildKnownAgentRunFailureReplyPayload, runAgentTurnWithFallback, @@ -89,7 +84,7 @@ import { hasUnbackedReminderCommitment, } from "./agent-runner-reminder-guard.js"; import { resetReplyRunSession } from "./agent-runner-session-reset.js"; -import { appendUsageLine, formatResponseUsageLine } from "./agent-runner-usage-line.js"; +import { appendUsageLine, resolveResponseUsageLine } from "./agent-runner-usage-line.js"; import { resolveQueuedReplyExecutionConfig } from "./agent-runner-utils.js"; import { createAudioAsVoiceBuffer, createBlockReplyPipeline } from "./block-reply-pipeline.js"; import { resolveEffectiveBlockStreamingConfig } from "./block-streaming.js"; @@ -126,7 +121,7 @@ import { } from "./reply-run-registry.js"; import { createReplyToModeFilterForChannel, resolveReplyToMode } from "./reply-threading.js"; import { admitReplyTurn, resolveReplyTurnKind } from "./reply-turn-admission.js"; -import { recordReplyUsageState } from "./reply-usage-state.js"; +import { buildReplyUsageState, recordReplyUsageState } from "./reply-usage-state.js"; import { resolveRoutedDeliveryThreadId } from "./routed-delivery-thread.js"; import { incrementRunCompactionCount, persistRunSessionUsage } from "./session-run-accounting.js"; import { resolveSourceReplyVisibilityPolicy } from "./source-reply-delivery-mode.js"; @@ -1683,7 +1678,6 @@ export async function runReplyAgent(params: { toolProgressDetail, }); - let responseUsageLine: string | undefined; type SessionResetOptions = { failureLabel: string; buildLogMessage: (nextSessionId: string) => string; @@ -1829,80 +1823,52 @@ export async function runReplyAgent(params: { const providerUsed = runResult.meta?.agentMeta?.provider ?? fallbackProvider ?? followupRun.run.provider; - let replyUsageState: PluginHookReplyUsageState | undefined; - { - const winnerProvider = fallbackExhausted - ? undefined - : (runResult.meta?.executionTrace?.winnerProvider ?? providerUsed); - const winnerModel = fallbackExhausted - ? undefined - : (runResult.meta?.executionTrace?.winnerModel ?? modelUsed); - const ctxTokens = runResult.meta?.agentMeta?.contextTokens; - const compactions = runResult.meta?.agentMeta?.compactionCount; - const lastCallUsage = runResult.meta?.agentMeta?.lastCallUsage; - replyUsageState = { - provider: providerUsed, - model: modelUsed, - resolvedRef: winnerProvider && winnerModel ? `${winnerProvider}/${winnerModel}` : undefined, - reasoningEffort: - typeof followupRun.run.thinkLevel === "string" ? followupRun.run.thinkLevel : undefined, - fastMode: resolveFastModeState({ - cfg, - provider: providerUsed ?? "", - model: modelUsed ?? "", - agentId: followupRun.run.agentId, - sessionEntry: activeSessionEntry, - }).enabled, - fallbackUsed: runResult.meta?.executionTrace?.fallbackUsed === true, + const winnerProvider = fallbackExhausted + ? undefined + : (runResult.meta?.executionTrace?.winnerProvider ?? providerUsed); + const winnerModel = fallbackExhausted + ? undefined + : (runResult.meta?.executionTrace?.winnerModel ?? modelUsed); + const ctxTokens = runResult.meta?.agentMeta?.contextTokens; + const compactions = runResult.meta?.agentMeta?.compactionCount; + const lastCallUsage = runResult.meta?.agentMeta?.lastCallUsage; + const replyUsageState = buildReplyUsageState({ + config: cfg, + provider: providerUsed, + model: modelUsed, + fallbackExhausted, + winnerProvider, + winnerModel, + reasoningEffort: + typeof followupRun.run.thinkLevel === "string" ? followupRun.run.thinkLevel : undefined, + fastMode: resolveFastModeState({ + cfg, + provider: providerUsed ?? "", + model: modelUsed ?? "", agentId: followupRun.run.agentId, - sessionId: followupRun.run.sessionId, - chatType: typeof sessionCtx.ChatType === "string" ? sessionCtx.ChatType : undefined, - authMode: runResult.meta?.requestShaping?.authMode ?? undefined, - overrideSource: activeSessionEntry?.modelOverrideSource ?? undefined, - requested: - followupRun.run.provider && followupRun.run.model - ? `${followupRun.run.provider}/${followupRun.run.model}` - : undefined, - turnUsd: hasBillableUsageBuckets - ? estimateUsageCost({ - usage, - cost: resolveModelCostConfig({ - provider: providerUsed, - model: modelUsed, - config: cfg, - }), - }) + sessionEntry: activeSessionEntry, + }).enabled, + fallbackUsed: runResult.meta?.executionTrace?.fallbackUsed === true, + agentId: followupRun.run.agentId, + sessionId: followupRun.run.sessionId, + chatType: typeof sessionCtx.ChatType === "string" ? sessionCtx.ChatType : undefined, + authMode: runResult.meta?.requestShaping?.authMode ?? undefined, + overrideSource: activeSessionEntry?.modelOverrideSource ?? undefined, + requestedProvider: followupRun.run.provider, + requestedModel: followupRun.run.model, + durationMs: Date.now() - runStartedAt, + compactionCount: typeof compactions === "number" ? compactions : undefined, + contextTokenBudget: + typeof ctxTokens === "number" && Number.isFinite(ctxTokens) ? ctxTokens : undefined, + contextUsedTokens: + typeof promptTokens === "number" && Number.isFinite(promptTokens) + ? promptTokens : undefined, - durationMs: Date.now() - runStartedAt, - identity: resolveAgentIdentity(cfg, followupRun.run.agentId), - compactionCount: typeof compactions === "number" ? compactions : undefined, - contextTokenBudget: - typeof ctxTokens === "number" && Number.isFinite(ctxTokens) ? ctxTokens : undefined, - contextUsedTokens: - typeof promptTokens === "number" && Number.isFinite(promptTokens) - ? promptTokens - : undefined, - usage: usage - ? { - input: usage.input, - output: usage.output, - cacheRead: usage.cacheRead, - cacheWrite: usage.cacheWrite, - total: usage.total, - } - : undefined, - lastUsage: lastCallUsage - ? { - input: lastCallUsage.input, - output: lastCallUsage.output, - cacheRead: lastCallUsage.cacheRead, - cacheWrite: lastCallUsage.cacheWrite, - total: lastCallUsage.total, - } - : undefined, - }; - recordReplyUsageState(runId, replyUsageState); - } + promptTokens, + usage, + lastCallUsage, + }); + recordReplyUsageState(runId, replyUsageState); const verboseEnabled = resolvedVerboseLevel !== "off"; const preserveUserFacingSessionState = shouldPreserveUserFacingSessionStateForInputProvenance( followupRun.run.inputProvenance, @@ -2270,39 +2236,19 @@ export async function runReplyAgent(params: { }); } - const responseUsageRaw = + const responseUsageSessionRaw = activeSessionEntry?.responseUsage ?? (sessionKey ? activeSessionStore?.[sessionKey]?.responseUsage : undefined); - const responseUsageMode = resolveResponseUsageMode(responseUsageRaw); - if (responseUsageMode !== "off" && hasNonzeroUsage(usage) && !preserveUserFacingSessionState) { - const costConfig = resolveModelCostConfig({ - provider: providerUsed, - model: modelUsed, - config: cfg, - allowPluginNormalization: false, - }); - const showCost = responseUsageMode === "full" && costConfig !== undefined; - let formatted = formatResponseUsageLine({ - usage, - showCost, - costConfig, - }); - const usageTemplate = - responseUsageMode === "full" && replyUsageState - ? loadUsageBarTemplate(cfg.messages?.usageTemplate) - : undefined; - const renderedUsageLine = usageTemplate - ? renderUsageBar(usageTemplate, buildUsageContract(replyUsageState, replyToChannel)) - : undefined; - if (renderedUsageLine) { - formatted = renderedUsageLine; - } else if (formatted && responseUsageMode === "full" && sessionKey) { - formatted = `${formatted} · session \`${sessionKey}\``; - } - if (formatted) { - responseUsageLine = formatted; - } - } + const responseUsageLine = resolveResponseUsageLine({ + config: cfg, + sessionRaw: responseUsageSessionRaw, + channel: replyToChannel, + usage, + provider: providerUsed, + model: modelUsed, + preserveUserFacingSessionState, + replyUsageState, + }); if (verboseEnabled) { activeSessionEntry = refreshSessionEntryFromStore({ diff --git a/src/auto-reply/reply/commands-allowlist.test.ts b/src/auto-reply/reply/commands-allowlist.test.ts index 66bcf2902fba..a221b548fa5d 100644 --- a/src/auto-reply/reply/commands-allowlist.test.ts +++ b/src/auto-reply/reply/commands-allowlist.test.ts @@ -17,6 +17,7 @@ import { createChannelTestPluginBase, createTestRegistry, } from "../../test-utils/channel-plugins.js"; +import { deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js"; import { handleAllowlistCommand } from "./commands-allowlist.js"; import type { HandleCommandsParams } from "./commands-types.js"; import type { ConfigSnapshotMock } from "./commands.test-harness.js"; @@ -256,15 +257,15 @@ async function withTempConfigPath( const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-allowlist-config-")); const configPath = path.join(dir, "openclaw.json"); const previous = process.env.OPENCLAW_CONFIG_PATH; - process.env.OPENCLAW_CONFIG_PATH = configPath; + setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath); await fs.writeFile(configPath, JSON.stringify(initialConfig, null, 2), "utf-8"); try { return await run(configPath); } finally { if (previous === undefined) { - delete process.env.OPENCLAW_CONFIG_PATH; + deleteTestEnvValue("OPENCLAW_CONFIG_PATH"); } else { - process.env.OPENCLAW_CONFIG_PATH = previous; + setTestEnvValue("OPENCLAW_CONFIG_PATH", previous); } await fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); } diff --git a/src/auto-reply/reply/commands-diagnostics.test.ts b/src/auto-reply/reply/commands-diagnostics.test.ts index 7a5b5e5aecd3..4eb68c526bad 100644 --- a/src/auto-reply/reply/commands-diagnostics.test.ts +++ b/src/auto-reply/reply/commands-diagnostics.test.ts @@ -574,7 +574,7 @@ describe("diagnostics command", () => { }); it("requires an owner for diagnostics", async () => { - const { handleDiagnosticsCommand } = createDiagnosticsHandlerForTest(); + const { execCalls, handleDiagnosticsCommand } = createDiagnosticsHandlerForTest(); const result = await handleDiagnosticsCommand( buildDiagnosticsParams("/diagnostics", { command: { @@ -586,6 +586,7 @@ describe("diagnostics command", () => { ); expect(result).toEqual({ shouldContinue: false }); + expect(execCalls).toHaveLength(0); }); it("routes confirmations back to the Codex diagnostics handler without repeating the preamble", async () => { diff --git a/src/auto-reply/reply/commands-diagnostics.ts b/src/auto-reply/reply/commands-diagnostics.ts index 59288496b618..650252257160 100644 --- a/src/auto-reply/reply/commands-diagnostics.ts +++ b/src/auto-reply/reply/commands-diagnostics.ts @@ -11,6 +11,7 @@ import type { InteractiveReply, MessagePresentationAction } from "../../interact import { executePluginCommand, matchPluginCommand } from "../../plugins/commands.js"; import type { PluginCommandDiagnosticsSession, PluginCommandResult } from "../../plugins/types.js"; import type { ReplyPayload } from "../types.js"; +import { rejectNonOwnerCommand } from "./command-gates.js"; import { buildCurrentOpenClawCliCommand, buildCurrentOpenClawCliExecEnv, @@ -95,6 +96,10 @@ async function handleDiagnosticsCommandWithDeps( ); return { shouldContinue: false }; } + const nonOwner = rejectNonOwnerCommand(params, DIAGNOSTICS_COMMAND); + if (nonOwner) { + return nonOwner; + } if (isCodexDiagnosticsConfirmationAction(args)) { const codexResult = await executeCodexDiagnosticsAddon(params, args); const reply = codexResult diff --git a/src/auto-reply/reply/commands-mcp.test.ts b/src/auto-reply/reply/commands-mcp.test.ts index 3e876c4357b1..24a97f051e5f 100644 --- a/src/auto-reply/reply/commands-mcp.test.ts +++ b/src/auto-reply/reply/commands-mcp.test.ts @@ -84,6 +84,49 @@ describe("handleCommands /mcp", () => { }); }); + it("blocks authorized non-owner senders from writing MCP config", async () => { + await withTempHome("openclaw-command-mcp-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + mcpServers.set("existing", { command: "uvx", args: ["existing-mcp"] }); + const setParams = buildCommandTestParams( + '/mcp set evil={"command":"/bin/sh","args":["-c","id > /tmp/pwned"]}', + buildCfg(), + undefined, + { workspaceDir }, + ); + setParams.command.senderIsOwner = false; + + const setResult = expectMcpResult(await handleMcpCommand(setParams, true)); + expect(setResult).toEqual({ shouldContinue: false }); + expect(mcpServers.has("evil")).toBe(false); + + const unsetParams = buildCommandTestParams("/mcp unset existing", buildCfg(), undefined, { + workspaceDir, + }); + unsetParams.command.senderIsOwner = false; + const unsetResult = expectMcpResult(await handleMcpCommand(unsetParams, true)); + expect(unsetResult).toEqual({ shouldContinue: false }); + expect(mcpServers.has("existing")).toBe(true); + }); + }); + + it("blocks authorized non-owner senders from reading MCP config", async () => { + await withTempHome("openclaw-command-mcp-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + mcpServers.set("context7", { command: "uvx", args: ["context7-mcp"] }); + const showParams = buildCommandTestParams("/mcp show context7", buildCfg(), undefined, { + workspaceDir, + }); + showParams.command.senderIsOwner = false; + + const showResult = expectMcpResult(await handleMcpCommand(showParams, true)); + expect(showResult).toEqual({ shouldContinue: false }); + const replyText = showResult.reply?.text ?? ""; + expect(replyText).not.toContain('MCP server "context7"'); + expect(replyText).not.toContain('"command": "uvx"'); + }); + }); + it("rejects internal writes without operator.admin", async () => { await withTempHome("openclaw-command-mcp-home-", async () => { const workspaceDir = await workspaceHarness.createWorkspace(); diff --git a/src/auto-reply/reply/commands-mcp.ts b/src/auto-reply/reply/commands-mcp.ts index 2e7025a29658..bea9d307a77d 100644 --- a/src/auto-reply/reply/commands-mcp.ts +++ b/src/auto-reply/reply/commands-mcp.ts @@ -5,6 +5,7 @@ import { unsetConfiguredMcpServer, } from "../../config/mcp-config.js"; import { + rejectNonOwnerCommand, rejectUnauthorizedCommand, requireCommandFlagEnabled, requireGatewayClientScope, @@ -29,6 +30,10 @@ export const handleMcpCommand: CommandHandler = async (params, allowTextCommands if (unauthorized) { return unauthorized; } + const nonOwner = rejectNonOwnerCommand(params, "/mcp"); + if (nonOwner) { + return nonOwner; + } const disabled = requireCommandFlagEnabled(params.cfg, { label: "/mcp", configKey: "mcp", diff --git a/src/auto-reply/reply/commands-plugins.install.test.ts b/src/auto-reply/reply/commands-plugins.install.test.ts index 0e3445117810..f712b990c72e 100644 --- a/src/auto-reply/reply/commands-plugins.install.test.ts +++ b/src/auto-reply/reply/commands-plugins.install.test.ts @@ -290,6 +290,16 @@ describe("handleCommands /plugins install", () => { version: "1.2.3", integrity: "sha512-demo", resolvedAt: "2026-03-22T12:00:00.000Z", + artifactKind: "npm-pack", + artifactFormat: "tgz", + npmIntegrity: "sha512-npm-pack", + npmShasum: "a".repeat(40), + npmTarballName: "clawhub-demo-1.2.3.tgz", + clawhubTrustDisposition: "review-recommended", + clawhubTrustScanStatus: "pending", + clawhubTrustReasons: ["scan:pending"], + clawhubTrustPending: true, + clawhubTrustCheckedAt: "2026-03-22T11:59:59.000Z", }, }); persistPluginInstallMock.mockResolvedValue({}); @@ -316,10 +326,147 @@ describe("handleCommands /plugins install", () => { integrity: "sha512-demo", clawhubPackage: "@openclaw/clawhub-demo", clawhubChannel: "official", + artifactKind: "npm-pack", + artifactFormat: "tgz", + npmIntegrity: "sha512-npm-pack", + npmShasum: "a".repeat(40), + npmTarballName: "clawhub-demo-1.2.3.tgz", + clawhubTrustDisposition: "review-recommended", + clawhubTrustScanStatus: "pending", + clawhubTrustReasons: ["scan:pending"], + clawhubTrustPending: true, + clawhubTrustCheckedAt: "2026-03-22T11:59:59.000Z", }); }); }); + it("includes non-blocking ClawHub warnings in successful chat install replies", async () => { + const warning = + 'ClawHub trust warning for "@openclaw/clawhub-demo@1.2.3": scan=pending; reasons=pending.'; + const richWarning = `\u001b[33m${warning}\u001b[39m`; + installPluginFromClawHubMock.mockImplementation(async (params: unknown) => { + if (!params || typeof params !== "object" || !("logger" in params)) { + throw new Error("expected ClawHub install logger"); + } + const logger = params.logger; + if ( + !logger || + typeof logger !== "object" || + !("warn" in logger) || + typeof logger.warn !== "function" + ) { + throw new Error("expected ClawHub install warn logger"); + } + logger.warn(richWarning); + return { + ok: true, + pluginId: "clawhub-demo", + targetDir: "/tmp/clawhub-demo", + version: "1.2.3", + extensions: ["index.js"], + packageName: "@openclaw/clawhub-demo", + clawhub: { + source: "clawhub", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "@openclaw/clawhub-demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + version: "1.2.3", + integrity: "sha512-demo", + resolvedAt: "2026-03-22T12:00:00.000Z", + }, + }; + }); + persistPluginInstallMock.mockResolvedValue({}); + + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const params = buildPluginsParams( + "/plugins install clawhub:@openclaw/clawhub-demo@1.2.3", + workspaceDir, + ); + const result = await handlePluginsCommand(params, true); + if (result === null) { + throw new Error("expected plugin install result"); + } + expect(result.reply?.text).toContain('Installed plugin "clawhub-demo"'); + expect(result.reply?.text).toContain(warning); + expect(result.reply?.text).not.toContain("\u001b"); + expect(mockFirstObjectArg(installPluginFromClawHubMock).logger).toEqual( + expect.objectContaining({ terminalLinks: false }), + ); + expectPersistedInstall("clawhub-demo", { + source: "clawhub", + spec: "clawhub:@openclaw/clawhub-demo@1.2.3", + installPath: "/tmp/clawhub-demo", + }); + }); + }); + + it("reports risky ClawHub install failures without persisting install metadata", async () => { + const warning = + 'ClawHub trust warning for "@openclaw/risky-demo@1.2.3": scan=suspicious; moderation=none; blockedFromDownload=false; pending=false; stale=false; reasons=payload_string. Risk signals: scan status suspicious, payload_string.'; + installPluginFromClawHubMock.mockResolvedValue({ + ok: false, + code: "clawhub_risk_acknowledgement_required", + error: + 'ClawHub release "@openclaw/risky-demo@1.2.3" has trust warnings. Review the package and rerun with --acknowledge-clawhub-risk to continue.', + warning, + }); + + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const params = buildPluginsParams( + "/plugins install clawhub:@openclaw/risky-demo@1.2.3", + workspaceDir, + ); + const result = await handlePluginsCommand(params, true); + if (result === null) { + throw new Error("expected plugin install result"); + } + + expect(result.reply?.text).toContain("has trust warnings"); + expect(result.reply?.text).toContain("scan=suspicious"); + expect(result.reply?.text).toContain("payload_string"); + expect(result.reply?.text).toContain("--acknowledge-clawhub-risk"); + expect(result.reply?.text).toContain("local openclaw plugins install command"); + expect(result.reply?.text).toContain("trusted shell"); + expect(mockFirstObjectArg(installPluginFromClawHubMock).spec).toBe( + "clawhub:@openclaw/risky-demo@1.2.3", + ); + expect(persistPluginInstallMock).not.toHaveBeenCalled(); + }); + }); + + it("includes ClawHub trust details for blocked chat install failures", async () => { + const warning = + 'ClawHub trust warning for "@openclaw/blocked-demo@1.2.3": scan=suspicious; moderation=blocked; blockedFromDownload=true; pending=false; stale=false; reasons=payload_string. Risk signals: blocked from download, scan status suspicious, moderation state blocked, payload_string.'; + installPluginFromClawHubMock.mockResolvedValue({ + ok: false, + code: "clawhub_download_blocked", + error: 'ClawHub release "@openclaw/blocked-demo@1.2.3" is blocked from download by ClawHub.', + warning, + }); + + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const params = buildPluginsParams( + "/plugins install clawhub:@openclaw/blocked-demo@1.2.3", + workspaceDir, + ); + const result = await handlePluginsCommand(params, true); + if (result === null) { + throw new Error("expected plugin install result"); + } + + expect(result.reply?.text).toContain("blocked from download"); + expect(result.reply?.text).toContain("scan=suspicious"); + expect(result.reply?.text).toContain("moderation=blocked"); + expect(result.reply?.text).toContain("payload_string"); + expect(persistPluginInstallMock).not.toHaveBeenCalled(); + }); + }); + it("refuses plugin installs in Nix mode before package installer side effects", async () => { const previousNixMode = process.env.OPENCLAW_NIX_MODE; process.env.OPENCLAW_NIX_MODE = "1"; diff --git a/src/auto-reply/reply/commands-plugins.test.ts b/src/auto-reply/reply/commands-plugins.test.ts index 57579f104a6a..b93c20c2dac0 100644 --- a/src/auto-reply/reply/commands-plugins.test.ts +++ b/src/auto-reply/reply/commands-plugins.test.ts @@ -79,6 +79,9 @@ vi.mock("../../infra/clawhub.js", () => ({ })); vi.mock("../../plugins/clawhub.js", () => ({ + CLAWHUB_INSTALL_ERROR_CODE: { + CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED: "clawhub_risk_acknowledgement_required", + }, installPluginFromClawHub: vi.fn(), })); diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index 3db55b533f78..c569d1cb6fd1 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -1,6 +1,7 @@ // Implements plugin command listing, install, and configuration helpers. import fs from "node:fs"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { stripAnsi } from "../../../packages/terminal-core/src/ansi.js"; import { buildNpmInstallRecordFields } from "../../cli/npm-resolution.js"; import { resolveOfficialExternalNpmPackageTrust } from "../../cli/plugin-install-plan.js"; import { @@ -21,7 +22,8 @@ import type { PluginInstallRecord } from "../../config/types.plugins.js"; import { resolveArchiveKind } from "../../infra/archive.js"; import { parseClawHubPluginSpec } from "../../infra/clawhub.js"; import { formatErrorMessage } from "../../infra/errors.js"; -import { installPluginFromClawHub } from "../../plugins/clawhub.js"; +import { buildClawHubPluginInstallRecordFields } from "../../plugins/clawhub-install-records.js"; +import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "../../plugins/clawhub.js"; import { installPluginFromGitSpec, parseGitPluginSpec } from "../../plugins/git-install.js"; import { installPluginFromNpmSpec, installPluginFromPath } from "../../plugins/install.js"; import { loadInstalledPluginIndexInstallRecords } from "../../plugins/installed-plugin-index-records.js"; @@ -217,7 +219,9 @@ async function installPluginFromPluginsCommand(params: { raw: string; config: OpenClawConfig; snapshot: ConfigSnapshotForInstallPersist; -}): Promise<{ ok: true; pluginId: string } | { ok: false; error: string }> { +}): Promise< + { ok: true; pluginId: string; warnings?: readonly string[] } | { ok: false; error: string } +> { const fileSpec = resolveFileNpmSpecToLocalPath(params.raw); if (fileSpec && !fileSpec.ok) { return { ok: false, error: fileSpec.error }; @@ -285,31 +289,42 @@ async function installPluginFromPluginsCommand(params: { const clawhubSpec = parseClawHubPluginSpec(params.raw); if (clawhubSpec) { + const warnings: string[] = []; + const logger = createPluginInstallLogger(); const result = await installPluginFromClawHub({ spec: params.raw, config: params.config, - logger: createPluginInstallLogger(), + logger: { + info: logger.info, + warn: (message) => { + warnings.push(stripAnsi(message)); + logger.warn(message); + }, + terminalLinks: false, + }, }); if (!result.ok) { - return { ok: false, error: result.error }; + const warning = "warning" in result ? result.warning : warnings.join("\n"); + const warningPrefix = warning ? `${warning} ` : ""; + if (result.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED) { + return { + ok: false, + error: `${warningPrefix}${result.error} The /plugins chat command cannot acknowledge ClawHub risk; run the local openclaw plugins install command with --acknowledge-clawhub-risk from a trusted shell after reviewing the warning.`, + }; + } + return { ok: false, error: `${warningPrefix}${result.error}` }; } await persistPluginInstall({ snapshot: params.snapshot, pluginId: result.pluginId, install: { - source: "clawhub", + ...buildClawHubPluginInstallRecordFields(result.clawhub), spec: params.raw, installPath: result.targetDir, version: result.version, - integrity: result.clawhub.integrity, - resolvedAt: result.clawhub.resolvedAt, - clawhubUrl: result.clawhub.clawhubUrl, - clawhubPackage: result.clawhub.clawhubPackage, - clawhubFamily: result.clawhub.clawhubFamily, - clawhubChannel: result.clawhub.clawhubChannel, }, }); - return { ok: true, pluginId: result.pluginId }; + return { ok: true, pluginId: result.pluginId, warnings }; } const officialNpmTrust = resolveOfficialExternalNpmPackageTrust({ @@ -486,7 +501,10 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm return { shouldContinue: false, reply: { - text: `🔌 Installed plugin "${installed.pluginId}". Gateway restart will load the new plugin source.`, + text: [ + `🔌 Installed plugin "${installed.pluginId}". Gateway restart will load the new plugin source.`, + ...(installed.warnings ?? []).map((warning) => `⚠️ ${warning}`), + ].join("\n"), }, }; } diff --git a/src/auto-reply/reply/commands-session-usage.test.ts b/src/auto-reply/reply/commands-session-usage.test.ts index d709f2d0ad50..720487af7bac 100644 --- a/src/auto-reply/reply/commands-session-usage.test.ts +++ b/src/auto-reply/reply/commands-session-usage.test.ts @@ -238,20 +238,108 @@ describe("handleUsageCommand", () => { expect(params.sessionEntry.responseUsage).toBe("tokens"); }); - it("clears usage footer mode on off updates", async () => { + it("persists an explicit /usage off so a configured default cannot re-enable it", async () => { const params = buildUsageParams(); params.command.commandBodyNormalized = "/usage off"; - params.sessionEntry = { - sessionId: "target-session", - updatedAt: Date.now(), - responseUsage: "full", + params.sessionStore = { + [params.sessionKey]: { + sessionId: "target-session", + updatedAt: Date.now(), + responseUsage: "tokens", + }, }; - params.sessionStore = { [params.sessionKey]: params.sessionEntry }; const result = await handleUsageCommand(params, true); + expect(result?.shouldContinue).toBe(false); expect(result?.reply?.text).toBe("⚙️ Usage footer: off."); - expect(params.sessionEntry.responseUsage).toBeUndefined(); + expect(params.sessionStore[params.sessionKey]?.responseUsage).toBe("off"); + }); + + it("no-arg toggle uses the effective mode (config default) when session is unset", async () => { + // When session has no override, the effective mode is the config default. + // The toggle should cycle from that effective value, not from "off". + const params = buildUsageParams(); + params.command.commandBodyNormalized = "/usage"; + params.cfg = { + ...params.cfg, + messages: { responseUsage: "tokens" }, + } as OpenClawConfig; + params.sessionStore = { + [params.sessionKey]: { + sessionId: "target-session", + updatedAt: Date.now(), + // responseUsage is absent — session inherits config default "tokens" + }, + }; + + const result = await handleUsageCommand(params, true); + + expect(result?.shouldContinue).toBe(false); + // Effective current = "tokens" (from config), so cycle → "full" + expect(result?.reply?.text).toBe("⚙️ Usage footer: full."); + expect(params.sessionStore[params.sessionKey]?.responseUsage).toBe("full"); + }); + + it("/usage reset clears the session override so the config default takes over", async () => { + const params = buildUsageParams(); + params.command.commandBodyNormalized = "/usage reset"; + params.sessionStore = { + [params.sessionKey]: { + sessionId: "target-session", + updatedAt: Date.now(), + responseUsage: "off", + }, + }; + + const result = await handleUsageCommand(params, true); + + expect(result?.shouldContinue).toBe(false); + expect(result?.reply?.text).toBe("⚙️ Usage footer: reset to default."); + // responseUsage is deleted (undefined) — session now inherits the config default + expect(params.sessionStore[params.sessionKey]?.responseUsage).toBeUndefined(); + }); + + it("/usage inherit (alias) clears the session override", async () => { + const params = buildUsageParams(); + params.command.commandBodyNormalized = "/usage inherit"; + params.sessionStore = { + [params.sessionKey]: { + sessionId: "target-session", + updatedAt: Date.now(), + responseUsage: "full", + }, + }; + + const result = await handleUsageCommand(params, true); + + expect(result?.shouldContinue).toBe(false); + expect(result?.reply?.text).toBe("⚙️ Usage footer: reset to default."); + expect(params.sessionStore[params.sessionKey]?.responseUsage).toBeUndefined(); + }); + + it("explicit off is stored and not treated as unset — config default cannot override it", async () => { + // This verifies the three-state distinction: "off" vs undefined. + // When session has explicit "off", the effective value is "off" regardless of config. + const params = buildUsageParams(); + params.command.commandBodyNormalized = "/usage"; + params.cfg = { + ...params.cfg, + messages: { responseUsage: "tokens" }, + } as OpenClawConfig; + params.sessionStore = { + [params.sessionKey]: { + sessionId: "target-session", + updatedAt: Date.now(), + responseUsage: "off", // explicit off — stays off despite config default "tokens" + }, + }; + + const result = await handleUsageCommand(params, true); + + expect(result?.shouldContinue).toBe(false); + // Effective current = "off" (explicit, not inherited), so cycle → "tokens" + expect(result?.reply?.text).toBe("⚙️ Usage footer: tokens."); }); }); diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index 9de360613c56..369307c43796 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -39,7 +39,7 @@ import { isSessionDefaultDirectiveValue, normalizeFastMode, normalizeUsageDisplay, - resolveResponseUsageMode, + resolveEffectiveResponseUsage, } from "../thinking.js"; import { resolveCommandSurfaceChannel } from "./channel-context.js"; import { rejectNonOwnerCommand, rejectUnauthorizedCommand } from "./command-gates.js"; @@ -352,24 +352,40 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman }; } - if (rawArgs && !requested) { + const isReset = rawArgs ? isSessionDefaultDirectiveValue(rawArgs) : false; + + if (rawArgs && !requested && !isReset) { return { shouldContinue: false, - reply: { text: "⚙️ Usage: /usage off|tokens|full|cost" }, + reply: { text: "⚙️ Usage: /usage off|tokens|full|reset|cost" }, }; } const targetSessionEntry = params.sessionStore?.[params.sessionKey] ?? params.sessionEntry; + + if (isReset) { + if (targetSessionEntry && params.sessionStore && params.sessionKey) { + delete targetSessionEntry.responseUsage; + params.sessionStore[params.sessionKey] = targetSessionEntry; + await persistSessionEntry({ ...params, sessionEntry: targetSessionEntry }); + } + return { + shouldContinue: false, + reply: { text: "⚙️ Usage footer: reset to default." }, + }; + } + + const replyChannel = params.command.channel; const currentRaw = targetSessionEntry?.responseUsage; - const current = resolveResponseUsageMode(currentRaw); + const current = resolveEffectiveResponseUsage( + currentRaw, + params.cfg.messages?.responseUsage, + replyChannel, + ); const next = requested ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off"); if (targetSessionEntry && params.sessionStore && params.sessionKey) { - if (next === "off") { - delete targetSessionEntry.responseUsage; - } else { - targetSessionEntry.responseUsage = next; - } + targetSessionEntry.responseUsage = next; params.sessionStore[params.sessionKey] = targetSessionEntry; await persistSessionEntry({ ...params, sessionEntry: targetSessionEntry }); } diff --git a/src/auto-reply/reply/commands-status.test.ts b/src/auto-reply/reply/commands-status.test.ts index fde30392e046..12be69723757 100644 --- a/src/auto-reply/reply/commands-status.test.ts +++ b/src/auto-reply/reply/commands-status.test.ts @@ -957,6 +957,8 @@ describe("buildStatusReply subagent summary", () => { OPENAI_API_KEY: undefined, OPENAI_OAUTH_TOKEN: undefined, }, + skipSessionCleanup: true, + skipHomeCleanup: true, }, ); }); @@ -1042,6 +1044,8 @@ describe("buildStatusReply subagent summary", () => { OPENAI_API_KEY: undefined, OPENAI_OAUTH_TOKEN: undefined, }, + skipSessionCleanup: true, + skipHomeCleanup: true, }, ); }); @@ -1066,66 +1070,69 @@ describe("buildStatusReply subagent summary", () => { ], }); - await withTempHome(async (dir) => { - saveStatusTestAuthProfile({ dir, profileId: "work", provider: "openai" }); + await withTempHome( + async (dir) => { + saveStatusTestAuthProfile({ dir, profileId: "work", provider: "openai" }); - const text = await buildStatusText({ - cfg: { - ...baseCfg, - agents: { - defaults: { - agentRuntime: { id: "codex" }, + const text = await buildStatusText({ + cfg: { + ...baseCfg, + agents: { + defaults: { + agentRuntime: { id: "codex" }, + }, }, }, - }, - sessionEntry: { - sessionId: "sess-status-codex-synthetic-usage", - updatedAt: 0, - authProfileOverride: "work", - }, - sessionKey: "agent:main:main", - parentSessionKey: "agent:main:main", - sessionScope: "per-sender", - statusChannel: "mobilechat", - provider: "openai", - model: "gpt-5.5", - contextTokens: 32_000, - resolvedFastMode: false, - resolvedVerboseLevel: "off", - resolvedReasoningLevel: "off", - resolveDefaultThinkingLevel: async () => undefined, - isGroup: false, - defaultGroupActivation: () => "mention", - modelAuthOverride: "oauth", - activeModelAuthOverride: "oauth", - }); - - const normalized = normalizeTestText(text); - expect(normalized).toContain("Model: openai/gpt-5.5"); - expect(normalized).toContain("Runtime: OpenAI Codex"); - expect(normalized).toContain("Usage: 5h 91% left"); - const providerUsageCall = providerUsageMock.loadProviderUsageSummary.mock.calls.find( - ([params]) => params?.providers?.includes("openai"), - ); - if (!providerUsageCall) { - throw new Error("expected provider usage summary call for synthetic Codex auth"); - } - expect(providerUsageCall[0]).toMatchObject({ - timeoutMs: 8000, - providers: ["openai"], - auth: [ - { - ...expectedCodexRuntimeUsageAuth[0], - authProfileId: "work", + sessionEntry: { + sessionId: "sess-status-codex-synthetic-usage", + updatedAt: 0, + authProfileOverride: "work", }, - ], - config: expect.objectContaining({ - agents: expect.objectContaining({ - defaults: expect.objectContaining({ agentRuntime: { id: "codex" } }), + sessionKey: "agent:main:main", + parentSessionKey: "agent:main:main", + sessionScope: "per-sender", + statusChannel: "mobilechat", + provider: "openai", + model: "gpt-5.5", + contextTokens: 32_000, + resolvedFastMode: false, + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolveDefaultThinkingLevel: async () => undefined, + isGroup: false, + defaultGroupActivation: () => "mention", + modelAuthOverride: "oauth", + activeModelAuthOverride: "oauth", + }); + + const normalized = normalizeTestText(text); + expect(normalized).toContain("Model: openai/gpt-5.5"); + expect(normalized).toContain("Runtime: OpenAI Codex"); + expect(normalized).toContain("Usage: 5h 91% left"); + const providerUsageCall = providerUsageMock.loadProviderUsageSummary.mock.calls.find( + ([params]) => params?.providers?.includes("openai"), + ); + if (!providerUsageCall) { + throw new Error("expected provider usage summary call for synthetic Codex auth"); + } + expect(providerUsageCall[0]).toMatchObject({ + timeoutMs: 8000, + providers: ["openai"], + auth: [ + { + ...expectedCodexRuntimeUsageAuth[0], + authProfileId: "work", + }, + ], + config: expect.objectContaining({ + agents: expect.objectContaining({ + defaults: expect.objectContaining({ agentRuntime: { id: "codex" } }), + }), }), - }), - }); - }); + }); + }, + { skipSessionCleanup: true, skipHomeCleanup: true }, + ); }); it("forwards legacy Codex profile providers to Codex synthetic usage", async () => { @@ -1141,14 +1148,74 @@ describe("buildStatusReply subagent summary", () => { ], }); - await withTempHome(async (dir) => { - saveStatusTestAuthProfile({ - dir, - profileId: "openai-codex:legacy", - provider: "openai-codex", - }); + await withTempHome( + async (dir) => { + saveStatusTestAuthProfile({ + dir, + profileId: "openai-codex:legacy", + provider: "openai-codex", + }); - await buildStatusText({ + await buildStatusText({ + cfg: { + ...baseCfg, + agents: { + defaults: { + agentRuntime: { id: "codex" }, + }, + }, + }, + sessionEntry: { + sessionId: "sess-status-codex-legacy-profile", + updatedAt: 0, + authProfileOverride: "openai-codex:legacy", + }, + sessionKey: "agent:main:main", + parentSessionKey: "agent:main:main", + sessionScope: "per-sender", + statusChannel: "mobilechat", + provider: "openai", + model: "gpt-5.5", + contextTokens: 32_000, + resolvedFastMode: false, + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolveDefaultThinkingLevel: async () => undefined, + isGroup: false, + defaultGroupActivation: () => "mention", + modelAuthOverride: "oauth", + activeModelAuthOverride: "oauth", + }); + + const providerUsageCall = providerUsageMock.loadProviderUsageSummary.mock.calls.find( + ([params]) => params?.providers?.includes("openai"), + ); + expect(providerUsageCall?.[0]?.auth).toEqual([ + { + ...expectedCodexRuntimeUsageAuth[0], + authProfileId: "openai-codex:legacy", + }, + ]); + }, + { skipSessionCleanup: true, skipHomeCleanup: true }, + ); + }); + + it("loads Codex synthetic usage when no local OpenAI profile label exists", async () => { + registerStatusCodexHarness(); + providerUsageMock.loadProviderUsageSummary.mockResolvedValue({ + updatedAt: Date.now(), + providers: [ + { + provider: "openai", + displayName: "OpenAI", + windows: [{ label: "5h", usedPercent: 16 }], + }, + ], + }); + + await withTempHome(async () => { + const text = await buildStatusText({ cfg: { ...baseCfg, agents: { @@ -1158,9 +1225,8 @@ describe("buildStatusReply subagent summary", () => { }, }, sessionEntry: { - sessionId: "sess-status-codex-legacy-profile", + sessionId: "sess-status-codex-no-profile", updatedAt: 0, - authProfileOverride: "openai-codex:legacy", }, sessionKey: "agent:main:main", parentSessionKey: "agent:main:main", @@ -1175,19 +1241,13 @@ describe("buildStatusReply subagent summary", () => { resolveDefaultThinkingLevel: async () => undefined, isGroup: false, defaultGroupActivation: () => "mention", - modelAuthOverride: "oauth", - activeModelAuthOverride: "oauth", }); + expect(normalizeTestText(text)).toContain("Usage: 5h 84% left"); const providerUsageCall = providerUsageMock.loadProviderUsageSummary.mock.calls.find( ([params]) => params?.providers?.includes("openai"), ); - expect(providerUsageCall?.[0]?.auth).toEqual([ - { - ...expectedCodexRuntimeUsageAuth[0], - authProfileId: "openai-codex:legacy", - }, - ]); + expect(providerUsageCall?.[0]?.auth).toEqual(expectedCodexRuntimeUsageAuth); }); }); @@ -1204,49 +1264,201 @@ describe("buildStatusReply subagent summary", () => { ], }); - await withTempHome(async (dir) => { - saveStatusTestAuthProfiles({ - dir, - profiles: [ - { profileId: "openai:status", provider: "openai" }, - { profileId: "anthropic:work", provider: "anthropic" }, - ], - }); + await withTempHome( + async (dir) => { + saveStatusTestAuthProfiles({ + dir, + profiles: [ + { profileId: "openai:status", provider: "openai" }, + { profileId: "anthropic:work", provider: "anthropic" }, + ], + }); - await buildStatusText({ - cfg: { - ...baseCfg, - agents: { - defaults: { - agentRuntime: { id: "codex" }, + await buildStatusText({ + cfg: { + ...baseCfg, + agents: { + defaults: { + agentRuntime: { id: "codex" }, + }, + }, + }, + sessionEntry: { + sessionId: "sess-status-codex-stale-profile", + updatedAt: 0, + authProfileOverride: "anthropic:work", + }, + sessionKey: "agent:main:main", + parentSessionKey: "agent:main:main", + sessionScope: "per-sender", + statusChannel: "mobilechat", + provider: "openai", + model: "gpt-5.5", + contextTokens: 32_000, + resolvedFastMode: false, + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolveDefaultThinkingLevel: async () => undefined, + isGroup: false, + defaultGroupActivation: () => "mention", + }); + + const providerUsageCall = providerUsageMock.loadProviderUsageSummary.mock.calls.find( + ([params]) => params?.providers?.includes("openai"), + ); + expect(providerUsageCall?.[0]?.auth).toEqual(expectedCodexRuntimeUsageAuth); + }, + { skipSessionCleanup: true, skipHomeCleanup: true }, + ); + }); + + it("uses active fallback provider usage for legacy fallback notices", async () => { + const fallbackModel: ModelDefinitionConfig = { + id: "MiniMax-M2.7", + name: "MiniMax M2.7", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 32_000, + }; + const selectedModel: ModelDefinitionConfig = { + id: "mimo-v2-flash", + name: "MiMo V2 Flash", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_048_576, + maxTokens: 32_000, + }; + providerUsageMock.loadProviderUsageSummary.mockImplementation(async (options) => ({ + updatedAt: Date.now(), + providers: + options?.providers?.includes("minimax") === true + ? [ + { + provider: "minimax", + displayName: "MiniMax", + windows: [{ label: "day", usedPercent: 20 }], + }, + ] + : [], + })); + + const text = await buildStatusText({ + cfg: { + ...baseCfg, + models: { + providers: { + "minimax-portal": { + baseUrl: "https://api.minimax.test/v1", + models: [fallbackModel], + }, + xiaomi: { + baseUrl: "https://api.xiaomi.test/v1", + models: [selectedModel], }, }, }, - sessionEntry: { - sessionId: "sess-status-codex-stale-profile", - updatedAt: 0, - authProfileOverride: "anthropic:work", - }, - sessionKey: "agent:main:main", - parentSessionKey: "agent:main:main", - sessionScope: "per-sender", - statusChannel: "mobilechat", - provider: "openai", - model: "gpt-5.5", - contextTokens: 32_000, - resolvedFastMode: false, - resolvedVerboseLevel: "off", - resolvedReasoningLevel: "off", - resolveDefaultThinkingLevel: async () => undefined, - isGroup: false, - defaultGroupActivation: () => "mention", - }); - - const providerUsageCall = providerUsageMock.loadProviderUsageSummary.mock.calls.find( - ([params]) => params?.providers?.includes("openai"), - ); - expect(providerUsageCall?.[0]?.auth).toEqual(expectedCodexRuntimeUsageAuth); + }, + sessionEntry: { + sessionId: "sess-status-legacy-fallback-usage", + updatedAt: 0, + providerOverride: "xiaomi", + modelOverride: "mimo-v2-flash", + modelProvider: "minimax-portal", + model: "MiniMax-M2.7", + fallbackNoticeSelectedModel: "xiaomi/mimo-v2-flash", + fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.7", + fallbackNoticeReason: "model not allowed", + totalTokens: 49_000, + totalTokensFresh: true, + contextTokens: 1_048_576, + }, + sessionKey: "agent:main:main", + parentSessionKey: "agent:main:main", + sessionScope: "per-sender", + statusChannel: "mobilechat", + provider: "xiaomi", + model: "mimo-v2-flash", + contextTokens: 1_048_576, + resolvedFastMode: false, + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolveDefaultThinkingLevel: async () => undefined, + isGroup: false, + defaultGroupActivation: () => "mention", + modelAuthOverride: "api-key", + activeModelAuthOverride: "api-key", }); + + const normalized = normalizeTestText(text); + expect(normalized).toContain("Fallback: minimax-portal/MiniMax-M2.7"); + expect(normalized).toContain("Context: 49k/200k"); + expect(normalized).toContain("Usage: day 80% left"); + expect(providerUsageMock.loadProviderUsageSummary).toHaveBeenCalledWith( + expect.objectContaining({ providers: ["minimax"] }), + ); + }); + + it("uses live runtime context for unresolved active fallback notices", async () => { + const selectedModel: ModelDefinitionConfig = { + id: "mimo-v2-flash", + name: "MiMo V2 Flash", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_048_576, + maxTokens: 32_000, + }; + + const text = await buildStatusText({ + cfg: { + ...baseCfg, + models: { + providers: { + xiaomi: { + baseUrl: "https://api.xiaomi.test/v1", + models: [selectedModel], + }, + }, + }, + }, + sessionEntry: { + sessionId: "sess-status-unresolved-fallback-context", + updatedAt: 0, + providerOverride: "xiaomi", + modelOverride: "mimo-v2-flash", + modelProvider: "custom-runtime", + model: "unknown-fallback-model", + fallbackNoticeSelectedModel: "xiaomi/mimo-v2-flash", + fallbackNoticeActiveModel: "custom-runtime/unknown-fallback-model", + fallbackNoticeReason: "model not allowed", + totalTokens: 49_000, + totalTokensFresh: true, + contextTokens: 1_048_576, + }, + sessionKey: "agent:main:main", + parentSessionKey: "agent:main:main", + sessionScope: "per-sender", + statusChannel: "mobilechat", + provider: "xiaomi", + model: "mimo-v2-flash", + contextTokens: 123_456, + resolvedFastMode: false, + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolveDefaultThinkingLevel: async () => undefined, + isGroup: false, + defaultGroupActivation: () => "mention", + modelAuthOverride: "api-key", + activeModelAuthOverride: "api-key", + }); + + const normalized = normalizeTestText(text); + expect(normalized).toContain("Fallback: custom-runtime/unknown-fallback-model"); + expect(normalized).toContain("Context: 49k/123k"); + expect(normalized).not.toContain("Context: 49k/1.0m"); }); it("shows DeepSeek balance summaries in /status output", async () => { @@ -1297,6 +1509,241 @@ describe("buildStatusReply subagent summary", () => { expect(providerUsageCall[0]?.providers).toEqual(["deepseek"]); }); + it("uses the session-selected model provider for /status usage", async () => { + const usageResetBase = Math.floor(Date.now() / 1000); + providerUsageMock.loadProviderUsageSummary.mockImplementation( + async ({ providers = [] } = {}) => ({ + updatedAt: Date.now(), + providers: providers.map((provider) => + provider === "openai" + ? { + provider: "openai", + displayName: "OpenAI", + windows: [ + { + label: "5h", + usedPercent: 9, + resetAt: (usageResetBase + 60 * 60) * 1000, + }, + ], + } + : { + provider, + displayName: "DeepSeek", + windows: [], + summary: "Balance ¥42.50", + }, + ), + }), + ); + + const text = await buildStatusText({ + cfg: { + ...baseCfg, + agents: { + defaults: { + model: "deepseek/deepseek-v4-flash", + }, + }, + }, + sessionEntry: { + sessionId: "sess-status-session-selected-usage", + updatedAt: 0, + providerOverride: "openai", + modelOverride: "gpt-5.5", + }, + sessionKey: "agent:main:main", + parentSessionKey: "agent:main:main", + sessionScope: "per-sender", + statusChannel: "telegram", + provider: "deepseek", + model: "deepseek-v4-flash", + contextTokens: 1_000_000, + resolvedFastMode: false, + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolveDefaultThinkingLevel: async () => undefined, + isGroup: false, + defaultGroupActivation: () => "mention", + modelAuthOverride: "oauth (openai:status)", + activeModelAuthOverride: "oauth (openai:status)", + }); + + const normalized = normalizeTestText(text); + expect(normalized).toContain("Model: openai/gpt-5.5"); + expect(normalized).toContain("pinned session; config primary deepseek/deepseek-v4-flash"); + expect(normalized).toContain("clear /model default"); + expect(normalized).toContain("Usage: 5h 91% left"); + expect(normalized).not.toContain("Usage: Balance ¥42.50"); + expect(providerUsageMock.loadProviderUsageSummary).toHaveBeenCalledWith( + expect.objectContaining({ providers: ["openai"] }), + ); + }); + + it("uses the session-selected provider for /status usage when runtime state is stale", async () => { + const usageResetBase = Math.floor(Date.now() / 1000); + providerUsageMock.loadProviderUsageSummary.mockImplementation( + async ({ providers = [] } = {}) => ({ + updatedAt: Date.now(), + providers: providers.map((provider) => + provider === "openai" + ? { + provider: "openai", + displayName: "OpenAI", + windows: [ + { + label: "5h", + usedPercent: 9, + resetAt: (usageResetBase + 60 * 60) * 1000, + }, + ], + } + : { + provider, + displayName: "DeepSeek", + windows: [], + summary: "Balance ¥42.50", + }, + ), + }), + ); + + const text = await buildStatusText({ + cfg: { + ...baseCfg, + agents: { + defaults: { + model: "deepseek/deepseek-v4-flash", + }, + }, + }, + sessionEntry: { + sessionId: "sess-status-stale-runtime-selected-usage", + updatedAt: 0, + providerOverride: "openai", + modelOverride: "gpt-5.5", + modelOverrideSource: "user", + modelProvider: "deepseek", + model: "deepseek-v4-flash", + }, + sessionKey: "agent:main:main", + parentSessionKey: "agent:main:main", + sessionScope: "per-sender", + statusChannel: "telegram", + provider: "deepseek", + model: "deepseek-v4-flash", + contextTokens: 1_000_000, + resolvedFastMode: false, + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolveDefaultThinkingLevel: async () => undefined, + isGroup: false, + defaultGroupActivation: () => "mention", + modelAuthOverride: "oauth (openai:status)", + activeModelAuthOverride: "api-key", + }); + + const normalized = normalizeTestText(text); + expect(normalized).toContain("Model: openai/gpt-5.5"); + expect(normalized).toContain("pinned session; config primary deepseek/deepseek-v4-flash"); + expect(normalized).toContain("clear /model default"); + expect(normalized).toContain("Usage: 5h 91% left"); + expect(normalized).not.toContain("Usage: Balance ¥42.50"); + expect(providerUsageMock.loadProviderUsageSummary).toHaveBeenCalledWith( + expect.objectContaining({ providers: ["openai"] }), + ); + }); + + it("uses provider-qualified model overrides for /status usage lookup", async () => { + await withTempHome( + async (dir) => { + saveStatusTestAuthProfile({ dir, profileId: "openai:status", provider: "openai" }); + + const usageResetBase = Math.floor(Date.now() / 1000); + providerUsageMock.loadProviderUsageSummary.mockImplementation( + async ({ providers = [] } = {}) => ({ + updatedAt: Date.now(), + providers: providers.map((provider) => + provider === "openai" + ? { + provider: "openai", + displayName: "OpenAI", + windows: [ + { + label: "5h", + usedPercent: 9, + resetAt: (usageResetBase + 60 * 60) * 1000, + }, + ], + } + : { + provider, + displayName: "DeepSeek", + windows: [], + summary: "Balance ¥42.50", + }, + ), + }), + ); + + const text = await buildStatusText({ + cfg: { + ...baseCfg, + models: { + providers: { + openai: { + baseUrl: "https://chatgpt.com/backend-api/codex", + models: [{ ...codexStatusModel, contextWindow: 258_000, contextTokens: 258_000 }], + }, + }, + }, + agents: { + defaults: { + model: "deepseek/deepseek-v4-flash", + }, + }, + auth: { + order: { + openai: ["openai:status"], + }, + }, + }, + sessionEntry: { + sessionId: "sess-status-qualified-session-selected-usage", + updatedAt: 0, + modelOverride: "openai/gpt-5.5", + }, + sessionKey: "agent:main:main", + parentSessionKey: "agent:main:main", + sessionScope: "per-sender", + statusChannel: "telegram", + provider: "deepseek", + model: "deepseek-v4-flash", + contextTokens: 1_000_000, + resolvedFastMode: false, + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolveDefaultThinkingLevel: async () => undefined, + isGroup: false, + defaultGroupActivation: () => "mention", + }); + + const normalized = normalizeTestText(text); + expect(normalized).toContain("Model: openai/gpt-5.5"); + expect(normalized).toContain("pinned session; config primary deepseek/deepseek-v4-flash"); + expect(normalized).toContain("clear /model default"); + expect(normalized).toContain("oauth (openai:status)"); + expect(normalized).toContain("Context: ?/258k"); + expect(normalized).toContain("Usage: 5h 91% left"); + expect(normalized).not.toContain("Usage: Balance ¥42.50"); + expect(providerUsageMock.loadProviderUsageSummary).toHaveBeenCalledWith( + expect.objectContaining({ providers: ["openai"] }), + ); + }, + { env: { OPENAI_API_KEY: undefined } }, + ); + }); + it("uses Codex OAuth auth labels for explicit OpenAI OpenClaw auth order", async () => { await withTempHome( async (dir) => { @@ -1367,7 +1814,7 @@ describe("buildStatusReply subagent summary", () => { expect(normalized).toContain("oauth (openai:status)"); expect(normalized).not.toContain("api-key (openai:backup)"); }, - { env: { OPENAI_API_KEY: undefined } }, + { env: { OPENAI_API_KEY: undefined }, skipSessionCleanup: true, skipHomeCleanup: true }, ); }); diff --git a/src/auto-reply/reply/current-turn-images.test.ts b/src/auto-reply/reply/current-turn-images.test.ts index 88fa5da0c80b..21668e67f0ef 100644 --- a/src/auto-reply/reply/current-turn-images.test.ts +++ b/src/auto-reply/reply/current-turn-images.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { withTempDir } from "../../test-helpers/temp-dir.js"; +import { deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js"; import type { MsgContext } from "../templating.js"; import { resolveCurrentTurnImages } from "./current-turn-images.js"; @@ -11,9 +12,9 @@ const originalStateDirEnv = process.env.OPENCLAW_STATE_DIR; function restoreProcessState() { if (originalStateDirEnv === undefined) { - delete process.env.OPENCLAW_STATE_DIR; + deleteTestEnvValue("OPENCLAW_STATE_DIR"); } else { - process.env.OPENCLAW_STATE_DIR = originalStateDirEnv; + setTestEnvValue("OPENCLAW_STATE_DIR", originalStateDirEnv); } } @@ -33,7 +34,7 @@ describe("resolveCurrentTurnImages", () => { await fs.mkdir(path.dirname(attachmentPath), { recursive: true }); await fs.mkdir(cwd, { recursive: true }); await fs.writeFile(attachmentPath, imageBytes); - process.env.OPENCLAW_STATE_DIR = stateDir; + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); vi.spyOn(process, "cwd").mockReturnValue(cwd); const result = await resolveCurrentTurnImages({ diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 69c0295699fd..05f2767c5dd1 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -3813,6 +3813,137 @@ describe("createFollowupRunner messaging delivery and dedupe", () => { persistSpy.mockRestore(); }); + it("appends configured responseUsage footers during followup delivery", async () => { + const sessionKey = "main"; + const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now() }; + const cfg = { + messages: { + responseUsage: "tokens", + }, + } as OpenClawConfig; + + const { onBlockReply } = await runMessagingCase({ + agentResult: { + payloads: [{ text: "hello world!" }], + meta: { + agentMeta: { + usage: { input: 1_000, output: 50 }, + model: "claude-opus-4-6", + provider: "anthropic", + }, + }, + }, + runnerOverrides: { + sessionEntry, + sessionStore: { [sessionKey]: sessionEntry }, + sessionKey, + }, + queued: createQueuedRun({ + run: { + config: cfg, + messageProvider: "discord", + sessionKey, + }, + }), + }); + + const payload = requireMockCallArg(onBlockReply, 0); + expect(payload.text).toContain("hello world!"); + expect(payload.text).toContain("Usage:"); + expect(payload.text).toContain("out"); + }); + + it("renders full responseUsage followup footers without exposing the session key", async () => { + const sessionKey = "discord:channel:user"; + const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now() }; + const cfg = { + messages: { + responseUsage: "full", + usageTemplate: { + output: { + default: [ + { + text: "model={model.display_name} tokens={usage.input_tokens|num}/{usage.output_tokens|num}", + }, + ], + }, + }, + }, + } as OpenClawConfig; + + const { onBlockReply } = await runMessagingCase({ + agentResult: { + payloads: [{ text: "hello world!" }], + meta: { + agentMeta: { + usage: { input: 1_000, output: 50 }, + model: "claude-opus-4-6", + provider: "anthropic", + }, + }, + }, + runnerOverrides: { + sessionEntry, + sessionStore: { [sessionKey]: sessionEntry }, + sessionKey, + }, + queued: createQueuedRun({ + run: { + config: cfg, + messageProvider: "discord", + sessionKey, + }, + }), + }); + + const payload = requireMockCallArg(onBlockReply, 0); + expect(payload.text).toContain("hello world!"); + expect(payload.text).toContain("model=claude-opus-4-6 tokens=1.0k/50"); + expect(payload.text).not.toContain(sessionKey); + }); + + it("keeps explicit responseUsage off during followup delivery", async () => { + const sessionKey = "main"; + const sessionEntry: SessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + responseUsage: "off", + }; + const cfg = { + messages: { + responseUsage: "tokens", + }, + } as OpenClawConfig; + + const { onBlockReply } = await runMessagingCase({ + agentResult: { + payloads: [{ text: "hello world!" }], + meta: { + agentMeta: { + usage: { input: 1_000, output: 50 }, + model: "claude-opus-4-6", + provider: "anthropic", + }, + }, + }, + runnerOverrides: { + sessionEntry, + sessionStore: { [sessionKey]: sessionEntry }, + sessionKey, + }, + queued: createQueuedRun({ + run: { + config: cfg, + messageProvider: "discord", + sessionKey, + }, + }), + }); + + const payload = requireMockCallArg(onBlockReply, 0); + expect(payload.text).toBe("hello world!"); + }); + it("uses providerUsed for snapshot freshness when agent metadata overrides the run provider", async () => { const storePath = "/tmp/openclaw-followup-usage-provider.json"; const sessionKey = "main"; diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 03a81b636ee5..afe62c2b2ffb 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -64,6 +64,7 @@ import { resolveSessionRuntimeOverrideForProvider, } from "./agent-runner-execution.js"; import { runPreflightCompactionIfNeeded } from "./agent-runner-memory.js"; +import { appendUsageLine, resolveResponseUsageLine } from "./agent-runner-usage-line.js"; import { resolveQueuedReplyExecutionConfig, resolveQueuedReplyRuntimeConfig, @@ -90,6 +91,7 @@ import { import type { ReplyDispatchKind } from "./reply-dispatcher.types.js"; import type { ReplyOperation } from "./reply-run-registry.js"; import { admitReplyTurn } from "./reply-turn-admission.js"; +import { buildReplyUsageState } from "./reply-usage-state.js"; import { isRoutableChannel, routeReply } from "./route-reply.js"; import { incrementRunCompactionCount, persistRunSessionUsage } from "./session-run-accounting.js"; import { createTypingSignaler } from "./typing-mode.js"; @@ -1404,6 +1406,60 @@ export function createFollowupRunner(params: { } let deliveryPayloads = finalPayloads; + const responseUsageSessionRaw = + activeSessionEntry?.responseUsage ?? + (replySessionKey ? sessionStore?.[replySessionKey]?.responseUsage : undefined); + const winnerProvider = fallbackExhausted + ? undefined + : (runResult.meta?.executionTrace?.winnerProvider ?? providerUsed); + const winnerModel = fallbackExhausted + ? undefined + : (runResult.meta?.executionTrace?.winnerModel ?? modelUsed); + const lastCallUsage = runResult.meta?.agentMeta?.lastCallUsage; + const replyUsageState = buildReplyUsageState({ + config: runtimeConfig, + provider: providerUsed, + model: modelUsed, + fallbackExhausted, + winnerProvider, + winnerModel, + reasoningEffort: typeof run.thinkLevel === "string" ? run.thinkLevel : undefined, + fallbackUsed: runResult.meta?.executionTrace?.fallbackUsed === true, + agentId: run.agentId, + sessionId: run.sessionId, + chatType: queued.originatingChatType, + authMode: runResult.meta?.requestShaping?.authMode ?? undefined, + overrideSource: activeSessionEntry?.modelOverrideSource ?? undefined, + requestedProvider: run.provider, + requestedModel: run.model, + compactionCount: + typeof runResult.meta?.agentMeta?.compactionCount === "number" + ? runResult.meta.agentMeta.compactionCount + : undefined, + contextTokenBudget: + typeof contextTokensUsed === "number" && Number.isFinite(contextTokensUsed) + ? contextTokensUsed + : undefined, + promptTokens, + usage, + lastCallUsage, + }); + const responseUsageLine = resolveResponseUsageLine({ + config: runtimeConfig, + sessionRaw: responseUsageSessionRaw, + channel: resolveOriginMessageProvider({ + originatingChannel: queued.originatingChannel, + provider: run.messageProvider, + }), + usage, + provider: providerUsed, + model: modelUsed, + preserveUserFacingSessionState, + replyUsageState, + }); + if (responseUsageLine) { + deliveryPayloads = appendUsageLine(deliveryPayloads, responseUsageLine); + } if (autoCompactionCount > 0) { const previousSessionId = run.sessionId; const count = await incrementRunCompactionCount({ @@ -1438,7 +1494,7 @@ export function createFollowupRunner(params: { { text: `🧹 Auto-compaction complete${suffix}.`, }, - ...finalPayloads, + ...deliveryPayloads, ]; } } diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 22b2f25ab568..a5a6e93894a6 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -389,6 +389,7 @@ export async function getReplyFromConfig( onReplyStart: opts?.onReplyStart, onCleanup: opts?.onTypingCleanup, typingIntervalSeconds, + keepalive: opts?.typingKeepalive ?? true, silentToken: SILENT_REPLY_TOKEN, log: defaultRuntime.log, }); diff --git a/src/auto-reply/reply/provider-request-error-classifier.test.ts b/src/auto-reply/reply/provider-request-error-classifier.test.ts index 24f02b8774e3..0b13ef6012e6 100644 --- a/src/auto-reply/reply/provider-request-error-classifier.test.ts +++ b/src/auto-reply/reply/provider-request-error-classifier.test.ts @@ -1,13 +1,60 @@ /** Tests provider request error classification for retry/fallback decisions. */ import { describe, expect, it } from "vitest"; +import { FailoverError } from "../../agents/failover-error.js"; import { classifyProviderRequestError, + PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE, PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE, PROVIDER_INTERNAL_ERROR_USER_MESSAGE, PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE, } from "./provider-request-error-classifier.js"; describe("provider request error classifier", () => { + it("classifies provider HTTP 401 authentication failures", () => { + const message = + "unexpected status 401 Unauthorized: Missing bearer or basic authentication in header, url: https://api.openai.com/v1/responses"; + + expect(classifyProviderRequestError(new Error(message))).toEqual({ + code: "provider_authentication_error", + userMessage: PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE, + technicalMessage: message, + }); + }); + + it("classifies typed authentication failures without relying on raw provider text", () => { + const error = new FailoverError("LLM request unauthorized.", { + reason: "auth", + provider: "openai", + model: "gpt-5.5", + status: 401, + }); + + expect(classifyProviderRequestError(error)).toEqual({ + code: "provider_authentication_error", + userMessage: PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE, + technicalMessage: "LLM request unauthorized.", + }); + }); + + it("does not label typed HTTP 403 authorization failures as HTTP 401", () => { + const error = new FailoverError("Provider access denied.", { + reason: "auth_permanent", + provider: "openai", + model: "gpt-5.5", + status: 403, + }); + + expect(classifyProviderRequestError(error)).toBeUndefined(); + }); + + it("leaves unrelated HTTP 401 failures unclassified", () => { + expect( + classifyProviderRequestError( + new Error("401 input item id does not belong to this conversation"), + ), + ).toBeUndefined(); + }); + it.each([ [ "OpenAI missing custom tool output", diff --git a/src/auto-reply/reply/provider-request-error-classifier.ts b/src/auto-reply/reply/provider-request-error-classifier.ts index 4db001f03649..8f9326690cd3 100644 --- a/src/auto-reply/reply/provider-request-error-classifier.ts +++ b/src/auto-reply/reply/provider-request-error-classifier.ts @@ -1,9 +1,15 @@ // Classifies provider request failures into retry and user-facing categories. import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import { + AUTH_INVALID_TOKEN_USER_TEXT, + classifyProviderRuntimeFailureKind, +} from "../../agents/embedded-agent-helpers/errors.js"; +import { isFailoverError } from "../../agents/failover-error.js"; import { formatErrorMessage } from "../../infra/errors.js"; /** Provider request error classes that get a specialized user-facing reply. */ export type ProviderRequestErrorCode = + | "provider_authentication_error" | "provider_conversation_state_error" | "provider_internal_error" | "provider_rate_limit_or_quota_error"; @@ -25,11 +31,24 @@ export const PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE = export const PROVIDER_INTERNAL_ERROR_USER_MESSAGE = "⚠️ The model provider returned a temporary internal error before replying. Try again in a moment, or switch to another model if it keeps happening."; +export const PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE = `⚠️ ${AUTH_INVALID_TOKEN_USER_TEXT}`; + /** Classifies provider request failures that are actionable for users. */ export function classifyProviderRequestError( err: unknown, ): ProviderRequestErrorClassification | undefined { const technicalMessage = formatErrorMessage(err); + const isTypedAuthFailure = isFailoverError(err) && err.reason === "auth" && err.status === 401; + if ( + isTypedAuthFailure || + classifyProviderRuntimeFailureKind(technicalMessage) === "auth_invalid_token" + ) { + return { + code: "provider_authentication_error", + userMessage: PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE, + technicalMessage, + }; + } if ( hasHttp429Evidence(err, technicalMessage) && isGenericProviderRuntimeErrorMessage(technicalMessage) diff --git a/src/auto-reply/reply/reply-media-paths.test.ts b/src/auto-reply/reply/reply-media-paths.test.ts index e46fdbd7e938..fbc484050db2 100644 --- a/src/auto-reply/reply/reply-media-paths.test.ts +++ b/src/auto-reply/reply/reply-media-paths.test.ts @@ -2,12 +2,14 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { captureEnv, setTestEnvValue } from "../../test-utils/env.js"; import { getReplyPayloadMetadata, setReplyPayloadMetadata } from "../reply-payload.js"; const ensureSandboxWorkspaceForSession = vi.hoisted(() => vi.fn()); const resolveOutboundAttachmentFromUrl = vi.hoisted(() => vi.fn()); const resolveAgentScopedOutboundMediaAccess = vi.hoisted(() => vi.fn()); +const stateDirEnvSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); vi.mock("../../agents/sandbox.js", () => ({ ensureSandboxWorkspaceForSession, @@ -86,7 +88,10 @@ describe("createReplyMediaPathNormalizer", () => { localRoots: workspaceDir ? [workspaceDir] : undefined, readFile: async () => Buffer.from("image"), })); - vi.unstubAllEnvs(); + }); + + afterEach(() => { + stateDirEnvSnapshot.restore(); }); it("stages workspace-relative media through shared outbound attachment loading", async () => { @@ -355,7 +360,7 @@ describe("createReplyMediaPathNormalizer", () => { }); it("keeps managed generated media under the shared media root", async () => { - vi.stubEnv("OPENCLAW_STATE_DIR", "/Users/peter/.openclaw"); + setTestEnvValue("OPENCLAW_STATE_DIR", "/Users/peter/.openclaw"); const normalize = createReplyMediaPathNormalizer({ cfg: {}, sessionKey: "session-key", @@ -377,7 +382,7 @@ describe("createReplyMediaPathNormalizer", () => { workspaceDir: "/tmp/sandboxes/session-1", containerWorkdir: "/workspace", }); - vi.stubEnv("OPENCLAW_STATE_DIR", "/Users/peter/.openclaw"); + setTestEnvValue("OPENCLAW_STATE_DIR", "/Users/peter/.openclaw"); const normalize = createReplyMediaPathNormalizer({ cfg: {}, sessionKey: "session-key", @@ -406,7 +411,7 @@ describe("createReplyMediaPathNormalizer", () => { await fs.mkdir(path.dirname(symlinkPath), { recursive: true }); await fs.writeFile(outsideFile, "secret", "utf8"); await fs.symlink(outsideFile, symlinkPath); - vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); const normalize = createReplyMediaPathNormalizer({ cfg: {}, sessionKey: "session-key", diff --git a/src/auto-reply/reply/reply-usage-state.ts b/src/auto-reply/reply/reply-usage-state.ts index 39104b4138ae..1f141e4c8574 100644 --- a/src/auto-reply/reply/reply-usage-state.ts +++ b/src/auto-reply/reply/reply-usage-state.ts @@ -1,9 +1,109 @@ +import { resolveAgentIdentity } from "../../agents/identity.js"; +import { deriveContextPromptTokens, type NormalizedUsage } from "../../agents/usage.js"; +import type { OpenClawConfig } from "../../config/config.js"; import type { PluginHookReplyUsageState } from "../../plugins/hook-types.js"; +import { estimateUsageCost, resolveModelCostConfig } from "../../utils/usage-format.js"; const TTL_MS = 5 * 60_000; const store = new Map(); +export function buildReplyUsageState(params: { + config: OpenClawConfig; + provider?: string; + model?: string; + fallbackExhausted?: boolean; + winnerProvider?: string; + winnerModel?: string; + reasoningEffort?: string; + fastMode?: boolean; + fallbackUsed?: boolean; + agentId: string; + sessionId: string; + chatType?: string; + authMode?: string; + overrideSource?: string; + requestedProvider?: string; + requestedModel?: string; + compactionCount?: number; + contextTokenBudget?: number; + contextUsedTokens?: number; + promptTokens?: number; + usage?: NormalizedUsage; + lastCallUsage?: NormalizedUsage; + durationMs?: number; +}): PluginHookReplyUsageState { + const resolvedProvider = params.fallbackExhausted ? undefined : params.winnerProvider; + const resolvedModel = params.fallbackExhausted ? undefined : params.winnerModel; + const hasBillableUsageBuckets = + params.usage && + (params.usage.input !== undefined || + params.usage.output !== undefined || + params.usage.cacheRead !== undefined || + params.usage.cacheWrite !== undefined); + return { + provider: params.provider, + model: params.model, + resolvedRef: + resolvedProvider && resolvedModel ? `${resolvedProvider}/${resolvedModel}` : undefined, + reasoningEffort: params.reasoningEffort, + fastMode: params.fastMode, + fallbackUsed: params.fallbackUsed, + agentId: params.agentId, + sessionId: params.sessionId, + chatType: params.chatType, + authMode: params.authMode, + overrideSource: params.overrideSource, + requested: + params.requestedProvider && params.requestedModel + ? `${params.requestedProvider}/${params.requestedModel}` + : undefined, + turnUsd: hasBillableUsageBuckets + ? estimateUsageCost({ + usage: params.usage, + cost: resolveModelCostConfig({ + provider: params.provider, + model: params.model, + config: params.config, + }), + }) + : undefined, + durationMs: params.durationMs, + identity: resolveAgentIdentity(params.config, params.agentId), + compactionCount: params.compactionCount, + contextTokenBudget: + typeof params.contextTokenBudget === "number" && Number.isFinite(params.contextTokenBudget) + ? params.contextTokenBudget + : undefined, + contextUsedTokens: + typeof params.contextUsedTokens === "number" && Number.isFinite(params.contextUsedTokens) + ? params.contextUsedTokens + : deriveContextPromptTokens({ + lastCallUsage: params.lastCallUsage, + promptTokens: params.promptTokens, + usage: params.usage, + }), + usage: params.usage + ? { + input: params.usage.input, + output: params.usage.output, + cacheRead: params.usage.cacheRead, + cacheWrite: params.usage.cacheWrite, + total: params.usage.total, + } + : undefined, + lastUsage: params.lastCallUsage + ? { + input: params.lastCallUsage.input, + output: params.lastCallUsage.output, + cacheRead: params.lastCallUsage.cacheRead, + cacheWrite: params.lastCallUsage.cacheWrite, + total: params.lastCallUsage.total, + } + : undefined, + }; +} + function prune(now: number): void { for (const [key, value] of store) { if (value.expiresAt < now) { diff --git a/src/auto-reply/reply/reply-utils.test.ts b/src/auto-reply/reply/reply-utils.test.ts index b7ea9b767ebe..6a58dcc1e6d5 100644 --- a/src/auto-reply/reply/reply-utils.test.ts +++ b/src/auto-reply/reply/reply-utils.test.ts @@ -483,6 +483,27 @@ describe("typing controller", () => { await vi.advanceTimersByTimeAsync(5_000); expect(onReplyStart).toHaveBeenCalledTimes(1); }); + + it("can send the first typing signal without periodic keepalive refreshes", async () => { + vi.useFakeTimers(); + const onReplyStart = vi.fn(); + const typing = createTypingController({ + onReplyStart, + typingIntervalSeconds: 1, + typingTtlMs: 30_000, + keepalive: false, + }); + + await typing.startTypingLoop(); + expect(onReplyStart).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(5_000); + expect(onReplyStart).toHaveBeenCalledTimes(1); + + await typing.startTypingLoop(); + await vi.advanceTimersByTimeAsync(5_000); + expect(onReplyStart).toHaveBeenCalledTimes(1); + }); }); describe("resolveTypingMode", () => { @@ -530,6 +551,17 @@ describe("resolveTypingMode", () => { }, expected: "message", }, + { + name: "configured instant typing mode wins over message-tool-only default", + input: { + configured: "instant" as const, + isGroupChat: true, + wasMentioned: false, + isHeartbeat: false, + sourceReplyDeliveryMode: "message_tool_only" as const, + }, + expected: "instant", + }, { name: "default mentioned group chat", input: { @@ -848,6 +880,19 @@ describe("createTypingSignaler", () => { } }); + it("starts typing on execution activity for active reply modes", async () => { + for (const mode of ["instant", "message", "thinking"] as const) { + const typing = createMockTypingController(); + const signaler = createTypingSignaler({ typing, mode, isHeartbeat: false }); + + await signaler.signalExecutionActivity?.(); + + expect(typing.startTypingLoop, `mode=${mode}`).toHaveBeenCalledTimes(1); + expect(typing.refreshTypingTtl, `mode=${mode}`).toHaveBeenCalledTimes(1); + expect(typing.startTypingOnText, `mode=${mode}`).not.toHaveBeenCalled(); + } + }); + it("suppresses typing when disabled", async () => { const disabledCases = [ { mode: "instant" as const, isHeartbeat: true }, @@ -860,6 +905,7 @@ describe("createTypingSignaler", () => { await signaler.signalRunStart(); await signaler.signalTextDelta("hi"); await signaler.signalReasoningDelta(); + await signaler.signalExecutionActivity?.(); expect(typing.startTypingLoop, `mode=${params.mode}`).not.toHaveBeenCalled(); expect(typing.startTypingOnText, `mode=${params.mode}`).not.toHaveBeenCalled(); diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 0e5824c052ea..d7e4a8f24c6e 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -10,6 +10,7 @@ import { import * as bootstrapCache from "../../agents/bootstrap-cache.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; +import { runExclusiveSessionStoreWrite } from "../../config/sessions/store-writer.js"; import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.ts"; import { testing as sessionBindingTesting, @@ -468,6 +469,49 @@ afterEach(async () => { resetSystemEventsForTest(); await sessionMcpTesting.resetSessionMcpRuntimeManager(); }); +describe("initSessionState guarded initialization", () => { + it("serializes concurrent initializers before reading the guarded snapshot", async () => { + const storePath = await createStorePath("openclaw-session-init-race-"); + const sessionKey = "agent:main:telegram:chat:42"; + await writeSessionStoreFast(storePath, { + [sessionKey]: { + sessionId: "existing-session", + updatedAt: 100, + }, + }); + const cfg = { session: { store: storePath } } as OpenClawConfig; + let releaseWriter = () => {}; + const writerReleased = new Promise((resolve) => { + releaseWriter = resolve; + }); + let markWriterStarted = () => {}; + const writerStarted = new Promise((resolve) => { + markWriterStarted = resolve; + }); + const heldWriter = runExclusiveSessionStoreWrite(storePath, async () => { + markWriterStarted(); + await writerReleased; + }); + await writerStarted; + + const turns = Array.from({ length: 8 }, (_, index) => + initSessionState({ + ctx: { + Body: `turn ${index}`, + SessionKey: sessionKey, + }, + cfg, + commandAuthorized: true, + }), + ); + + releaseWriter(); + await heldWriter; + + await expect(Promise.all(turns)).resolves.toHaveLength(8); + }); +}); + describe("initSessionState thread forking", () => { it("forks a new session from the parent session file", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index c075867fa0bb..58cbd8701cd2 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -35,6 +35,7 @@ import { } from "../../config/sessions/session-accessor.js"; import { resolveSessionKey } from "../../config/sessions/session-key.js"; import { resolveMaintenanceConfigFromInput } from "../../config/sessions/store-maintenance.js"; +import { runExclusiveSessionStoreWrite } from "../../config/sessions/store-writer.js"; import { parseSessionThreadInfoFast } from "../../config/sessions/thread-info.js"; import { DEFAULT_RESET_TRIGGERS, @@ -186,6 +187,14 @@ export type InitSessionStateParams = { resumeRequestedSession?: boolean; }; +type InitSessionStateAttemptContext = { + agentId: string; + conversationBindingContext: ReturnType; + isSystemEvent: boolean; + sessionCtxForState: MsgContext; + storePath: string; +}; + function resolveSessionConversationBindingContext( cfg: OpenClawConfig, ctx: MsgContext, @@ -244,32 +253,19 @@ function resolveBoundConversationSessionKey(params: { return binding.targetSessionKey; } -/** Initializes or reuses the reply session state for one inbound turn. */ -export async function initSessionState(params: InitSessionStateParams): Promise { - return await initSessionStateAttempt(params, false); -} - -async function initSessionStateAttempt( +function resolveInitSessionStateAttemptContext( params: InitSessionStateParams, - staleSnapshotRetried: boolean, -): Promise { - const { ctx, cfg, commandAuthorized } = params; - // Heartbeat, cron-event, and exec-event runs should NEVER trigger session - // resets or conversation binding retargeting. These are automated system - // events, not user interactions that should affect session continuity. - // See #58409 for details on silent session reset bug. +): InitSessionStateAttemptContext { + const { cfg, ctx } = params; + // Automated system events must not reset sessions or retarget conversation bindings. const isSystemEvent = ctx.Provider === "heartbeat" || ctx.Provider === "cron-event" || ctx.Provider === "exec-event"; const conversationBindingContext = isSystemEvent ? null : resolveSessionConversationBindingContext(cfg, ctx); - // Native slash commands (Telegram/Discord/Slack) are delivered on a separate - // "slash session" key, but should mutate the target chat session. + // Slash/menu commands may arrive on a transport session while targeting the chat session. + // Prefer explicit command target before binding lookup so command mutations land there. const commandTargetSessionKey = resolveCommandTurnTargetSessionKey(ctx); - // Native slash/menu commands can arrive on a transport-specific "slash session" - // while explicitly targeting an existing chat session. Honor that explicit target - // before any binding lookup so command-side mutations land on the intended session. - // Priority: commandTargetSessionKey > boundConversation > route. const targetSessionKey = commandTargetSessionKey ?? resolveBoundConversationSessionKey({ @@ -281,20 +277,54 @@ async function initSessionStateAttempt( targetSessionKey && targetSessionKey !== ctx.SessionKey ? { ...ctx, SessionKey: targetSessionKey } : ctx; - const sessionCfg = cfg.session; - const maintenanceConfig = resolveMaintenanceConfigFromInput(sessionCfg?.maintenance); - const mainKey = normalizeMainKey(sessionCfg?.mainKey); const agentId = resolveSessionAgentId({ sessionKey: sessionCtxForState.SessionKey, config: cfg, fallbackAgentId: sessionCtxForState.AgentId, }); + return { + agentId, + conversationBindingContext, + isSystemEvent, + sessionCtxForState, + storePath: resolveStorePath(cfg.session?.store, { agentId }), + }; +} + +/** Initializes or reuses the reply session state for one inbound turn. */ +export async function initSessionState(params: InitSessionStateParams): Promise { + return await initSessionStateAttempt(params, false); +} + +async function initSessionStateAttempt( + params: InitSessionStateParams, + staleSnapshotRetried: boolean, +): Promise { + const attemptContext = resolveInitSessionStateAttemptContext(params); + // Guarded revision checks only serialize correctly when the snapshot and + // commit share the same writer lane. + return await runExclusiveSessionStoreWrite( + attemptContext.storePath, + async () => await initSessionStateAttemptLocked(params, attemptContext, staleSnapshotRetried), + ); +} + +async function initSessionStateAttemptLocked( + params: InitSessionStateParams, + attemptContext: InitSessionStateAttemptContext, + staleSnapshotRetried: boolean, +): Promise { + const { ctx, cfg, commandAuthorized } = params; + const { agentId, conversationBindingContext, isSystemEvent, sessionCtxForState, storePath } = + attemptContext; + const sessionCfg = cfg.session; + const maintenanceConfig = resolveMaintenanceConfigFromInput(sessionCfg?.maintenance); + const mainKey = normalizeMainKey(sessionCfg?.mainKey); const groupResolution = resolveGroupSessionKey(sessionCtxForState) ?? undefined; const resetTriggers = sessionCfg?.resetTriggers?.length ? sessionCfg.resetTriggers : DEFAULT_RESET_TRIGGERS; const sessionScope = sessionCfg?.scope ?? "per-sender"; - const storePath = resolveStorePath(sessionCfg?.store, { agentId }); const ingressTimingEnabled = process.env.OPENCLAW_DEBUG_INGRESS_TIMING === "1"; let sessionEntry: SessionEntry; @@ -858,7 +888,7 @@ async function initSessionStateAttempt( }); if (!committed.ok) { if (!staleSnapshotRetried) { - return await initSessionStateAttempt(params, true); + return await initSessionStateAttemptLocked(params, attemptContext, true); } throw new Error(`reply session initialization conflicted for ${sessionKey}`); } diff --git a/src/auto-reply/reply/stored-model-override.test.ts b/src/auto-reply/reply/stored-model-override.test.ts new file mode 100644 index 000000000000..1d4397f9c1b7 --- /dev/null +++ b/src/auto-reply/reply/stored-model-override.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveStoredModelOverride } from "./stored-model-override.js"; + +describe("resolveStoredModelOverride", () => { + it("loads parent overrides without requiring a whole session store", () => { + const loadSessionEntry = vi.fn((sessionKey: string) => + sessionKey === "agent:main:telegram:dm:parent" + ? { + sessionId: "parent-session", + updatedAt: 1782259200000, + providerOverride: "anthropic", + modelOverride: "claude-sonnet-4-7", + } + : undefined, + ); + + expect( + resolveStoredModelOverride({ + defaultProvider: "openai", + loadSessionEntry, + sessionKey: "agent:main:telegram:dm:parent:thread:child", + }), + ).toEqual({ + provider: "anthropic", + model: "claude-sonnet-4-7", + source: "parent", + }); + expect(loadSessionEntry).toHaveBeenCalledWith("agent:main:telegram:dm:parent"); + }); +}); diff --git a/src/auto-reply/reply/stored-model-override.ts b/src/auto-reply/reply/stored-model-override.ts index ce6b83c3e681..2d3260ac934a 100644 --- a/src/auto-reply/reply/stored-model-override.ts +++ b/src/auto-reply/reply/stored-model-override.ts @@ -34,6 +34,7 @@ function resolveParentSessionKeyCandidate(params: { /** Resolves the persisted model override visible to the current session. */ export function resolveStoredModelOverride(params: { + loadSessionEntry?: (sessionKey: string) => SessionEntry | undefined; sessionEntry?: SessionEntry; sessionStore?: Record; sessionKey?: string; @@ -56,10 +57,10 @@ export function resolveStoredModelOverride(params: { sessionKey: params.sessionKey, parentSessionKey: params.parentSessionKey, }); - if (!parentKey || !params.sessionStore) { + if (!parentKey) { return null; } - const parentEntry = params.sessionStore[parentKey]; + const parentEntry = params.loadSessionEntry?.(parentKey) ?? params.sessionStore?.[parentKey]; const normalizedParentOverride = normalizeStoredOverrideModel({ providerOverride: parentEntry?.providerOverride, modelOverride: parentEntry?.modelOverride, diff --git a/src/auto-reply/reply/typing-mode.ts b/src/auto-reply/reply/typing-mode.ts index 60ab541c4327..8df0e98dfac1 100644 --- a/src/auto-reply/reply/typing-mode.ts +++ b/src/auto-reply/reply/typing-mode.ts @@ -63,6 +63,7 @@ export type TypingSignaler = { signalTextDelta: (text?: string) => Promise; signalReasoningDelta: () => Promise; signalToolStart: () => Promise; + signalExecutionActivity?: () => Promise; }; /** Creates a typing signaler that starts or refreshes typing from stream events. */ @@ -156,6 +157,16 @@ export function createTypingSignaler(params: { typing.refreshTypingTtl(); }; + const signalExecutionActivity = async () => { + if (disabled) { + return; + } + if (!typing.isActive()) { + await typing.startTypingLoop(); + } + typing.refreshTypingTtl(); + }; + return { mode, shouldStartImmediately, @@ -167,5 +178,6 @@ export function createTypingSignaler(params: { signalTextDelta, signalReasoningDelta, signalToolStart, + signalExecutionActivity, }; } diff --git a/src/auto-reply/reply/typing.ts b/src/auto-reply/reply/typing.ts index 3d289b4d61be..01155bdb50b3 100644 --- a/src/auto-reply/reply/typing.ts +++ b/src/auto-reply/reply/typing.ts @@ -39,10 +39,17 @@ export function createTypingController(params: { onCleanup?: () => void; typingIntervalSeconds?: number; typingTtlMs?: number; + keepalive?: boolean; silentToken?: string; log?: (message: string) => void; }): TypingController { - const { onReplyStart, onCleanup, silentToken = SILENT_REPLY_TOKEN, log } = params; + const { + onReplyStart, + onCleanup, + keepalive = true, + silentToken = SILENT_REPLY_TOKEN, + log, + } = params; if (!onReplyStart && !onCleanup) { return { onReplyStart: async () => {}, @@ -202,6 +209,10 @@ export function createTypingController(params: { if (!onReplyStart) { return; } + if (!keepalive) { + await ensureStart(); + return; + } if (typingLoop.isRunning()) { return; } diff --git a/src/auto-reply/thinking.shared.ts b/src/auto-reply/thinking.shared.ts index 34a96c3112a7..2a0f35f9004c 100644 --- a/src/auto-reply/thinking.shared.ts +++ b/src/auto-reply/thinking.shared.ts @@ -182,6 +182,37 @@ export function resolveResponseUsageMode(raw?: string | null): UsageDisplayLevel return normalizeUsageDisplay(raw) ?? "off"; } +export type ResponseUsageInput = "on" | "off" | "tokens" | "full"; +export type ResponseUsageDefaultConfig = + | ResponseUsageInput + | { default?: ResponseUsageInput; [channel: string]: ResponseUsageInput | undefined }; + +export function resolveMessagesResponseUsageDefault( + configured: ResponseUsageDefaultConfig | undefined, + channel?: string, +): ResponseUsageInput | undefined { + if (typeof configured === "string") { + return configured; + } + if (configured && typeof configured === "object") { + return (channel ? configured[channel] : undefined) ?? configured.default; + } + return undefined; +} + +export function resolveEffectiveResponseUsage( + sessionRaw: string | undefined | null, + configured: ResponseUsageDefaultConfig | undefined, + channel?: string, +): UsageDisplayLevel { + const sessionNormalized = normalizeUsageDisplay(sessionRaw); + if (sessionNormalized !== undefined) { + return sessionNormalized; + } + const configDefault = resolveMessagesResponseUsageDefault(configured, channel); + return resolveResponseUsageMode(configDefault); +} + /** Normalizes elevated execution policy values. */ export function normalizeElevatedLevel(raw?: string | null): ElevatedLevel | undefined { if (!raw) { diff --git a/src/auto-reply/thinking.test.ts b/src/auto-reply/thinking.test.ts index 1273ebb3f7ba..c089e424b376 100644 --- a/src/auto-reply/thinking.test.ts +++ b/src/auto-reply/thinking.test.ts @@ -25,6 +25,8 @@ const { formatThinkingLevels, resolveSupportedThinkingLevel, resolveThinkingDefaultForModel, + resolveMessagesResponseUsageDefault, + resolveEffectiveResponseUsage, } = await import("./thinking.js"); beforeEach(() => { @@ -807,3 +809,71 @@ describe("normalizeReasoningLevel", () => { expect(normalizeReasoningLevel("streaming")).toBe("stream"); }); }); + +describe("resolveMessagesResponseUsageDefault", () => { + it("returns undefined when unset (preserves off-by-default behavior)", () => { + expect(resolveMessagesResponseUsageDefault(undefined)).toBeUndefined(); + expect(resolveMessagesResponseUsageDefault(undefined, "discord")).toBeUndefined(); + }); + + it("returns a bare string default for any channel", () => { + expect(resolveMessagesResponseUsageDefault("full")).toBe("full"); + expect(resolveMessagesResponseUsageDefault("full", "telegram")).toBe("full"); + }); + + it("resolves the channel entry from a map", () => { + const cfg = { default: "off", discord: "full", telegram: "tokens" } as const; + expect(resolveMessagesResponseUsageDefault(cfg, "discord")).toBe("full"); + expect(resolveMessagesResponseUsageDefault(cfg, "telegram")).toBe("tokens"); + }); + + it("falls back to default for an unmapped channel", () => { + const cfg = { default: "tokens", discord: "full" } as const; + expect(resolveMessagesResponseUsageDefault(cfg, "whatsapp")).toBe("tokens"); + }); + + it("returns undefined for a map with neither the channel nor a default", () => { + expect(resolveMessagesResponseUsageDefault({ discord: "full" }, "telegram")).toBeUndefined(); + }); +}); + +describe("resolveEffectiveResponseUsage", () => { + it("returns off when session is unset and no config is provided", () => { + expect(resolveEffectiveResponseUsage(undefined, undefined)).toBe("off"); + expect(resolveEffectiveResponseUsage(null, undefined)).toBe("off"); + }); + + it("applies config default when session is unset", () => { + expect(resolveEffectiveResponseUsage(undefined, "tokens")).toBe("tokens"); + expect(resolveEffectiveResponseUsage(undefined, "full")).toBe("full"); + }); + + it("applies per-channel config entry when session is unset", () => { + const cfg = { default: "off", discord: "full", telegram: "tokens" } as const; + expect(resolveEffectiveResponseUsage(undefined, cfg, "discord")).toBe("full"); + expect(resolveEffectiveResponseUsage(undefined, cfg, "telegram")).toBe("tokens"); + // Unknown channel falls back to config default + expect(resolveEffectiveResponseUsage(undefined, cfg, "whatsapp")).toBe("off"); + }); + + it("session explicit off overrides any config default", () => { + // Explicit "off" is stored and wins — non-off config default cannot re-enable it. + expect(resolveEffectiveResponseUsage("off", "tokens")).toBe("off"); + expect(resolveEffectiveResponseUsage("off", "full")).toBe("off"); + expect(resolveEffectiveResponseUsage("off", { default: "full", discord: "full" }, "discord")).toBe("off"); + }); + + it("session explicit on value overrides config default", () => { + expect(resolveEffectiveResponseUsage("tokens", "full")).toBe("tokens"); + expect(resolveEffectiveResponseUsage("full", "off")).toBe("full"); + }); + + it("unset (undefined/null) falls through to config; explicit off does not", () => { + // These two are distinct states: + // - undefined = unset/inherit → gets config default + // - "off" = explicit off → stays off + const cfg = "tokens" as const; + expect(resolveEffectiveResponseUsage(undefined, cfg)).toBe("tokens"); // inherits + expect(resolveEffectiveResponseUsage("off", cfg)).toBe("off"); // explicit off persists + }); +}); diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts index eb42abc24c90..ac63e65ff928 100644 --- a/src/auto-reply/thinking.ts +++ b/src/auto-reply/thinking.ts @@ -17,6 +17,8 @@ export { normalizeThinkLevel, normalizeUsageDisplay, normalizeVerboseLevel, + resolveEffectiveResponseUsage, + resolveMessagesResponseUsageDefault, resolveResponseUsageMode, } from "./thinking.shared.js"; export type { @@ -24,6 +26,8 @@ export type { FastMode, NoticeLevel, ReasoningLevel, + ResponseUsageDefaultConfig, + ResponseUsageInput, TraceLevel, ThinkLevel, ThinkingCatalogEntry, diff --git a/src/channels/message/ingress-queue.test.ts b/src/channels/message/ingress-queue.test.ts index ae3c3ec0e670..b68cd7f024f7 100644 --- a/src/channels/message/ingress-queue.test.ts +++ b/src/channels/message/ingress-queue.test.ts @@ -277,6 +277,105 @@ describe("channel ingress queue", () => { }); }); + it("refreshes claimed rows only with the active claim token", async () => { + await withTempState(async (stateDir) => { + const queue = createChannelIngressQueue<{ text: string }>({ + channelId: "test", + accountId: "account", + stateDir, + now: () => 10, + }); + + await queue.enqueue("event-1", { text: "claimed" }); + const claimed = await queue.claim("event-1", { ownerId: "worker" }); + if (!claimed) { + throw new Error("Expected a claimed ingress event"); + } + + expect(await queue.refreshClaim?.(claimed, { refreshedAt: 20 })).toBe(true); + expect( + (await queue.listClaims()).map((claim) => ({ + id: claim.id, + claimedAt: claim.claim.claimedAt, + updatedAt: claim.updatedAt, + })), + ).toEqual([{ id: "event-1", claimedAt: 20, updatedAt: 20 }]); + + expect( + await queue.refreshClaim?.( + { id: "event-1", claim: { token: "wrong" } }, + { + refreshedAt: 30, + }, + ), + ).toBe(false); + expect((await queue.listClaims())[0]?.claim.claimedAt).toBe(20); + }); + }); + + it("does not let old claim tokens refresh recovered and reclaimed rows", async () => { + await withTempState(async (stateDir) => { + const queue = createChannelIngressQueue<{ text: string }>({ + channelId: "test", + accountId: "account", + stateDir, + now: () => 10, + }); + + await queue.enqueue("event-1", { text: "claimed" }); + const oldClaim = await queue.claim("event-1", { ownerId: "worker-1" }); + if (!oldClaim) { + throw new Error("Expected a claimed ingress event"); + } + expect(await queue.recoverStaleClaims({ staleMs: 5, now: 20 })).toBe(1); + const newClaim = await queue.claim("event-1", { ownerId: "worker-2" }); + if (!newClaim) { + throw new Error("Expected reclaimed ingress event"); + } + + expect(await queue.refreshClaim?.(oldClaim, { refreshedAt: 30 })).toBe(false); + expect(await queue.refreshClaim?.(newClaim, { refreshedAt: 40 })).toBe(true); + expect((await queue.listClaims())[0]?.claim).toMatchObject({ + ownerId: "worker-2", + claimedAt: 40, + }); + }); + }); + + it("does not recover a claim refreshed after stale recovery snapshots it", async () => { + await withTempState(async (stateDir) => { + const queue = createChannelIngressQueue<{ text: string }>({ + channelId: "test", + accountId: "account", + stateDir, + now: () => 10, + }); + + await queue.enqueue("event-1", { text: "claimed" }); + const claimed = await queue.claim("event-1", { ownerId: "worker" }); + if (!claimed) { + throw new Error("Expected a claimed ingress event"); + } + + expect( + await queue.recoverStaleClaims({ + staleMs: 5, + now: 20, + shouldRecover: async (claim) => { + expect(claim.id).toBe("event-1"); + expect(await queue.refreshClaim?.(claim, { refreshedAt: 20 })).toBe(true); + return true; + }, + }), + ).toBe(0); + expect((await queue.listPending()).map((record) => record.id)).toEqual([]); + expect((await queue.listClaims())[0]?.claim).toMatchObject({ + ownerId: "worker", + claimedAt: 20, + }); + }); + }); + it("recovers stale claims and prunes completed or failed rows", async () => { await withTempState(async (stateDir) => { const queue = createChannelIngressQueue<{ text: string }>({ diff --git a/src/channels/message/ingress-queue.ts b/src/channels/message/ingress-queue.ts index c125cd35d06b..49afa7a3bd65 100644 --- a/src/channels/message/ingress-queue.ts +++ b/src/channels/message/ingress-queue.ts @@ -142,6 +142,10 @@ export type ChannelIngressQueue | null>; + refreshClaim?( + claim: ChannelIngressQueueClaimRef, + options?: { refreshedAt?: number }, + ): Promise; complete( idOrClaim: string | ChannelIngressQueueClaimRef, options?: { metadata?: TCompletedMetadata; completedAt?: number }, @@ -440,26 +444,6 @@ export function createChannelIngressQueue< return rows.map((row) => claimedRecord(row)); }; - const recoverStaleClaims: ChannelIngressQueue< - TPayload, - TMetadata, - TCompletedMetadata - >["recoverStaleClaims"] = async (recoverOptions) => { - const staleMs = Math.max(0, Math.floor(recoverOptions?.staleMs ?? 0)); - const cutoff = (recoverOptions?.now ?? now()) - staleMs; - const claims = (await listClaims()).filter((claim) => claim.claim.claimedAt <= cutoff); - let recovered = 0; - for (const claim of claims) { - if (recoverOptions?.shouldRecover && !(await recoverOptions.shouldRecover(claim))) { - continue; - } - if (await release(claim, { releasedAt: recoverOptions?.now ?? now() })) { - recovered += 1; - } - } - return recovered; - }; - const claimNext: ChannelIngressQueue< TPayload, TMetadata, @@ -561,6 +545,89 @@ export function createChannelIngressQueue< ); }; + const refreshClaim: NonNullable< + ChannelIngressQueue["refreshClaim"] + > = async (claimRef, refreshOptions) => { + const eventId = idFrom(claimRef); + const refreshedAt = refreshOptions?.refreshedAt ?? now(); + const database = openStateDatabase(options.stateDir); + return runOpenClawStateWriteTransaction( + (tx) => { + const kysely = getChannelIngressKysely(tx.db); + const result = executeSqliteQuerySync( + tx.db, + kysely + .updateTable("channel_ingress_events") + .set({ + claimed_at: refreshedAt, + updated_at: refreshedAt, + }) + .where("queue_name", "=", queueName) + .where("event_id", "=", eventId) + .where("status", "=", "claimed") + .where("claim_token", "=", claimRef.claim.token), + ); + return affectedRows(result) > 0; + }, + { path: database.path }, + ); + }; + + const releaseClaimIfStillStale = async ( + claimRef: ChannelIngressQueueClaimRef, + releaseOptions: { cutoff: number; releasedAt: number }, + ): Promise => { + const eventId = idFrom(claimRef); + const database = openStateDatabase(options.stateDir); + return runOpenClawStateWriteTransaction( + (tx) => { + const kysely = getChannelIngressKysely(tx.db); + const result = executeSqliteQuerySync( + tx.db, + kysely + .updateTable("channel_ingress_events") + .set((eb) => ({ + status: "pending", + claim_token: null, + claim_owner: null, + claimed_at: null, + attempts: eb("attempts", "+", 1), + last_attempt_at: releaseOptions.releasedAt, + updated_at: releaseOptions.releasedAt, + })) + .where("queue_name", "=", queueName) + .where("event_id", "=", eventId) + .where("status", "=", "claimed") + .where("claim_token", "=", claimRef.claim.token) + .where("claimed_at", "<=", releaseOptions.cutoff), + ); + return affectedRows(result) > 0; + }, + { path: database.path }, + ); + }; + + const recoverStaleClaims: ChannelIngressQueue< + TPayload, + TMetadata, + TCompletedMetadata + >["recoverStaleClaims"] = async (recoverOptions) => { + const current = recoverOptions?.now ?? now(); + const staleMs = Math.max(0, Math.floor(recoverOptions?.staleMs ?? 0)); + const cutoff = current - staleMs; + const staleClaims = (await listClaims()).filter((claimed) => claimed.claim.claimedAt <= cutoff); + let recovered = 0; + for (const staleClaim of staleClaims) { + if (recoverOptions?.shouldRecover && !(await recoverOptions.shouldRecover(staleClaim))) { + continue; + } + if (await releaseClaimIfStillStale(staleClaim, { cutoff, releasedAt: current })) { + recovered += 1; + } + } + return recovered; + }; + const complete: ChannelIngressQueue["complete"] = async ( idOrClaim, completeOptions, @@ -845,6 +912,7 @@ export function createChannelIngressQueue< listClaims, claimNext, claim, + refreshClaim, complete, release, fail, diff --git a/src/channels/plugins/contracts/test-helpers/surface-contract-suite.ts b/src/channels/plugins/contracts/test-helpers/surface-contract-suite.ts index 212b0e311751..ff4df8d95a34 100644 --- a/src/channels/plugins/contracts/test-helpers/surface-contract-suite.ts +++ b/src/channels/plugins/contracts/test-helpers/surface-contract-suite.ts @@ -92,6 +92,14 @@ export function expectChannelSurfaceContract(params: { expect(typeof messaging.targetResolver.hint).toBe("string"); expect(messaging.targetResolver.hint.trim()).not.toBe(""); } + if (messaging.targetResolver.reservedLiterals !== undefined) { + expect(Array.isArray(messaging.targetResolver.reservedLiterals)).toBe(true); + expect( + messaging.targetResolver.reservedLiterals.every( + (value) => typeof value === "string" && value.trim(), + ), + ).toBe(true); + } if (messaging.targetResolver.resolveTarget) { expect(typeof messaging.targetResolver.resolveTarget).toBe("function"); } diff --git a/src/channels/plugins/read-only.test.ts b/src/channels/plugins/read-only.test.ts index 4fb0f1ca2c5b..3562f3edb51a 100644 --- a/src/channels/plugins/read-only.test.ts +++ b/src/channels/plugins/read-only.test.ts @@ -1075,6 +1075,81 @@ describe("listReadOnlyChannelPluginsForConfig", () => { expect(inheritedAccount?.config?.token).not.toBe("prototype-token"); }); + it("ignores manifest account keys that normalize to blocked object keys", () => { + const { pluginDir } = writeExternalSetupChannelPlugin({ + setupEntry: false, + pluginId: "external-chat-plugin", + channelId: "external-chat", + manifestChannelConfig: true, + }); + const cfg = { + channels: { + "external-chat": { + accounts: { + "constructor ": { + token: "blocked-token", + }, + }, + }, + }, + plugins: { + load: { paths: [pluginDir] }, + allow: ["external-chat-plugin"], + }, + } as never; + const plugin = listReadOnlyChannelPluginsForConfig(cfg, { + env: { ...process.env }, + includePersistedAuthState: false, + }).find((entry) => entry.id === "external-chat"); + + expect(plugin?.config.listAccountIds(cfg)).toEqual([]); + const account = plugin?.config.resolveAccount(cfg, "default"); + const accountFields = expectRecordFields(account, { + accountId: "default", + }); + const configFields = expectRecordFields(accountFields.config, {}); + expect(configFields.token).toBeUndefined(); + }); + + it("resolves manifest channel account config from raw account keys with opaque provider ids", () => { + const { pluginDir } = writeExternalSetupChannelPlugin({ + setupEntry: false, + pluginId: "external-chat-plugin", + channelId: "external-chat", + manifestChannelConfig: true, + }); + const cfg = { + channels: { + "external-chat": { + accounts: { + "59000514e8ad@im.bot": { + enabled: true, + baseUrl: "https://ilinkai.weixin.qq.com", + }, + }, + }, + }, + plugins: { + load: { paths: [pluginDir] }, + allow: ["external-chat-plugin"], + }, + } as never; + const plugin = listReadOnlyChannelPluginsForConfig(cfg, { + env: { ...process.env }, + includePersistedAuthState: false, + }).find((entry) => entry.id === "external-chat"); + + expect(plugin?.config.listAccountIds(cfg)).toEqual(["59000514e8ad-im-bot"]); + const account = plugin?.config.resolveAccount(cfg, "59000514e8ad-im-bot"); + const fields = expectRecordFields(account, { + accountId: "59000514e8ad-im-bot", + }); + expectRecordFields(fields.config, { + enabled: true, + baseUrl: "https://ilinkai.weixin.qq.com", + }); + }); + it("keeps setup-entry precedence when channel config descriptors are not runtime cutoffs", () => { const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({ pluginId: "external-chat-plugin", diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index d1ec2bf73f5d..4fdb731c8c22 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -35,7 +35,12 @@ import { type PluginModuleLoaderCache, } from "../../plugins/plugin-module-loader-cache.js"; import { getActivePluginChannelRegistryVersion } from "../../plugins/runtime.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; +import { resolveNormalizedAccountEntry } from "../../routing/account-lookup.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + normalizeOptionalAccountId, +} from "../../routing/session-key.js"; import { getBundledChannelSetupPlugin } from "./bundled.js"; import { isSafeManifestChannelId, @@ -349,6 +354,10 @@ function getChannelConfigRecord(cfg: OpenClawConfig, channelId: string): Record< : {}; } +function normalizeManifestAccountConfigKey(accountId: string): string { + return normalizeOptionalAccountId(accountId) ?? ""; +} + function listManifestChannelAccountIds(cfg: OpenClawConfig, channelId: string): string[] { const channelConfig = getChannelConfigRecord(cfg, channelId); const accounts = channelConfig.accounts; @@ -356,8 +365,8 @@ function listManifestChannelAccountIds(cfg: OpenClawConfig, channelId: string): return sortUniqueStrings( Object.keys(accounts) .filter((accountId) => !isBlockedObjectKey(accountId)) - .map((accountId) => normalizeAccountId(accountId)) - .filter((accountId) => !isBlockedObjectKey(accountId)), + .map((accountId) => normalizeOptionalAccountId(accountId)) + .filter((accountId): accountId is string => Boolean(accountId)), ); } return hasExplicitChannelConfig({ config: cfg, channelId }) ? [DEFAULT_ACCOUNT_ID] : []; @@ -372,9 +381,10 @@ function resolveManifestChannelAccountConfig(params: { const resolvedAccountId = normalizeAccountId(params.accountId); const accounts = channelConfig.accounts; if (accounts && typeof accounts === "object" && !Array.isArray(accounts)) { - const accountConfig = readOwnRecordValue( + const accountConfig = resolveNormalizedAccountEntry( accounts as Record, resolvedAccountId, + normalizeManifestAccountConfigKey, ); if (accountConfig && typeof accountConfig === "object" && !Array.isArray(accountConfig)) { return accountConfig as Record; diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index c4abb996c995..57e9c9fb6cbd 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -608,6 +608,8 @@ export type ChannelMessagingAdapter = { targetResolver?: { looksLikeId?: (raw: string, normalized?: string) => boolean; hint?: string; + /** Bare words that are command/session references for this channel, not literal destinations. */ + reservedLiterals?: readonly string[]; /** * Plugin-owned fallback for explicit/native targets or post-directory-miss * resolution. This should complement directory lookup, not duplicate it. diff --git a/src/chat/canvas-render.test.ts b/src/chat/canvas-render.test.ts new file mode 100644 index 000000000000..c7945195797f --- /dev/null +++ b/src/chat/canvas-render.test.ts @@ -0,0 +1,38 @@ +// Canvas-render tests cover [embed] shortcode extraction and text stripping. +import { describe, expect, it } from "vitest"; +import { extractCanvasShortcodes } from "./canvas-render.ts"; + +describe("extractCanvasShortcodes", () => { + it("does not let a self-closing embed start a greedy block match", () => { + // Regression: the block regex used to greedily swallow the span from a + // self-closing "[embed ... /]" open tag up to a later stray "[/embed]", + // deleting the visible text in between (" keep me ") from channel delivery. + const input = '[embed url="https://a.com" /] keep me [/embed]'; + const { text, previews } = extractCanvasShortcodes(input); + + expect(previews).toHaveLength(1); + expect(previews[0]?.url).toBe("https://a.com"); + // The visible text between the self-closing embed and the stray close + // marker must be preserved, not silently stripped. + expect(text).toContain("keep me"); + expect(text).toBe("keep me [/embed]"); + }); + + it("still extracts a normal block embed and strips only the shortcode span", () => { + const input = 'before [embed ref="doc1"] hi [/embed] after'; + const { text, previews } = extractCanvasShortcodes(input); + + expect(previews).toHaveLength(1); + expect(previews[0]?.viewId).toBe("doc1"); + expect(text).toBe("before after"); + }); + + it("still extracts a plain self-closing embed and keeps surrounding text", () => { + const input = 'see [embed url="https://b.com" /] end'; + const { text, previews } = extractCanvasShortcodes(input); + + expect(previews).toHaveLength(1); + expect(previews[0]?.url).toBe("https://b.com"); + expect(text).toBe("see end"); + }); +}); diff --git a/src/chat/canvas-render.ts b/src/chat/canvas-render.ts index c97e15f0baad..167d2c11b400 100644 --- a/src/chat/canvas-render.ts +++ b/src/chat/canvas-render.ts @@ -203,7 +203,10 @@ export function extractCanvasShortcodes(text: string | undefined): { attrs: Record; body?: string; }> = []; - const blockRe = /\[embed\s+([^\]]*?)\]([\s\S]*?)\[\/embed\]/gi; + // Exclude a self-closing open tag ("[embed ... /]") from starting a block + // match by requiring the attrs group not to end with a slash; otherwise the + // block regex greedily swallows visible text up to a later stray [/embed]. + const blockRe = /\[embed\s+([^\]]*?[^\]/]|)\]([\s\S]*?)\[\/embed\]/gi; const selfClosingRe = /\[embed\s+([^\]]*?)\/\]/gi; for (const re of [blockRe, selfClosingRe]) { let match: RegExpExecArray | null; diff --git a/src/cli/clawhub-risk-acknowledgement.test.ts b/src/cli/clawhub-risk-acknowledgement.test.ts new file mode 100644 index 000000000000..218b41c2dab5 --- /dev/null +++ b/src/cli/clawhub-risk-acknowledgement.test.ts @@ -0,0 +1,211 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveClawHubRiskAcknowledgementCliOptions } from "./clawhub-risk-acknowledgement.js"; + +const promptYesNoMock = vi.hoisted(() => vi.fn()); +const promptTextMock = vi.hoisted(() => vi.fn()); + +vi.mock("./prompt.js", () => ({ + promptYesNo: promptYesNoMock, + promptText: promptTextMock, +})); + +const ORIGINAL_STDIN_TTY = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); +const ORIGINAL_STDOUT_TTY = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + +function setTty(value: boolean): void { + Object.defineProperty(process.stdin, "isTTY", { + value, + configurable: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + value, + configurable: true, + }); +} + +function restoreTty(): void { + if (ORIGINAL_STDIN_TTY) { + Object.defineProperty(process.stdin, "isTTY", ORIGINAL_STDIN_TTY); + } else { + Reflect.deleteProperty(process.stdin, "isTTY"); + } + if (ORIGINAL_STDOUT_TTY) { + Object.defineProperty(process.stdout, "isTTY", ORIGINAL_STDOUT_TTY); + } else { + Reflect.deleteProperty(process.stdout, "isTTY"); + } +} + +describe("resolveClawHubRiskAcknowledgementCliOptions", () => { + afterEach(() => { + promptYesNoMock.mockReset(); + promptTextMock.mockReset(); + restoreTty(); + }); + + it("does not create a prompt handler when ClawHub risk is already acknowledged", () => { + setTty(true); + + const options = resolveClawHubRiskAcknowledgementCliOptions({ + acknowledgeClawHubRisk: true, + action: "installing", + }); + + expect(options.acknowledgeClawHubRisk).toBe(true); + expect(options.onClawHubRisk).toBeUndefined(); + }); + + it("does not create a prompt handler outside an interactive terminal", () => { + setTty(false); + + const options = resolveClawHubRiskAcknowledgementCliOptions({ + action: "updating", + }); + + expect(options.acknowledgeClawHubRisk).toBeUndefined(); + expect(options.onClawHubRisk).toBeUndefined(); + }); + + it("does not create a prompt handler when prompting is disabled", () => { + setTty(true); + + const options = resolveClawHubRiskAcknowledgementCliOptions({ + action: "updating", + allowPrompt: false, + }); + + expect(options.acknowledgeClawHubRisk).toBeUndefined(); + expect(options.onClawHubRisk).toBeUndefined(); + }); + + it("sanitizes ClawHub package labels before prompting", async () => { + promptTextMock.mockResolvedValueOnce("demo\\npkg"); + setTty(true); + + const options = resolveClawHubRiskAcknowledgementCliOptions({ + action: "installing", + }); + + if (!options.onClawHubRisk) { + throw new Error("expected ClawHub risk prompt handler"); + } + await options.onClawHubRisk({ + packageName: "demo\npkg", + version: "1.2.3\u001b[2K", + trust: { + scanStatus: "suspicious", + moderationState: null, + blockedFromDownload: false, + reasons: ["payload_strings"], + pending: false, + stale: false, + }, + acknowledgementKind: "type-package", + warning: "warning", + }); + + const prompt = promptTextMock.mock.calls[0]?.[0]; + expect(prompt).toContain("type: 'demo\\npkg' to install anyway"); + expect(prompt).not.toContain("demo\npkg"); + expect(prompt).not.toContain("\u001b"); + }); + + it("requires typing the package name for review-required releases", async () => { + promptTextMock.mockResolvedValueOnce("demo"); + setTty(true); + + const options = resolveClawHubRiskAcknowledgementCliOptions({ + action: "installing", + }); + + if (!options.onClawHubRisk) { + throw new Error("expected ClawHub risk prompt handler"); + } + await expect( + options.onClawHubRisk({ + packageName: "demo", + version: "1.2.3", + trust: { + scanStatus: "suspicious", + moderationState: null, + blockedFromDownload: false, + reasons: ["payload_strings"], + pending: false, + stale: false, + }, + acknowledgementKind: "type-package", + warning: "warning", + }), + ).resolves.toBe(true); + + expect(promptTextMock).toHaveBeenCalledWith( + expect.stringContaining("type: 'demo' to install anyway"), + ); + expect(promptYesNoMock).not.toHaveBeenCalled(); + }); + + it("uses yes/no confirmation for review-recommended releases", async () => { + promptYesNoMock.mockResolvedValueOnce(true); + setTty(true); + + const options = resolveClawHubRiskAcknowledgementCliOptions({ + action: "installing", + }); + + if (!options.onClawHubRisk) { + throw new Error("expected ClawHub risk prompt handler"); + } + await expect( + options.onClawHubRisk({ + packageName: "demo", + version: "1.2.3", + trust: { + scanStatus: "pending", + moderationState: null, + blockedFromDownload: false, + reasons: ["scan:pending"], + pending: true, + stale: false, + }, + acknowledgementKind: "confirm", + warning: "warning", + }), + ).resolves.toBe(true); + + expect(promptYesNoMock).toHaveBeenCalledWith( + 'Install ClawHub package "demo@1.2.3" after reviewing the warning above?', + ); + expect(promptTextMock).not.toHaveBeenCalled(); + }); + + it("uses update wording for update confirmations", async () => { + promptYesNoMock.mockResolvedValueOnce(true); + setTty(true); + + const options = resolveClawHubRiskAcknowledgementCliOptions({ + action: "updating", + }); + + if (!options.onClawHubRisk) { + throw new Error("expected ClawHub risk prompt handler"); + } + await options.onClawHubRisk({ + packageName: "demo", + version: "1.2.3", + trust: { + scanStatus: "pending", + moderationState: null, + blockedFromDownload: false, + reasons: ["scan:pending"], + pending: true, + stale: false, + }, + acknowledgementKind: "confirm", + warning: "warning", + }); + + expect(promptYesNoMock).toHaveBeenCalledWith( + 'Update ClawHub package "demo@1.2.3" after reviewing the warning above?', + ); + }); +}); diff --git a/src/cli/clawhub-risk-acknowledgement.ts b/src/cli/clawhub-risk-acknowledgement.ts new file mode 100644 index 000000000000..403e81d1cfb9 --- /dev/null +++ b/src/cli/clawhub-risk-acknowledgement.ts @@ -0,0 +1,39 @@ +import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; +import type { ClawHubRiskAcknowledgementRequest } from "../infra/clawhub-install-trust.js"; +import { promptText, promptYesNo } from "./prompt.js"; + +export type ClawHubRiskAcknowledgementCliOptions = { + acknowledgeClawHubRisk?: boolean; +}; + +function canPromptForClawHubRisk(): boolean { + return process.stdin.isTTY && process.stdout.isTTY; +} + +export function resolveClawHubRiskAcknowledgementCliOptions(params: { + acknowledgeClawHubRisk?: boolean; + action: "installing" | "updating"; + allowPrompt?: boolean; +}): ClawHubRiskAcknowledgementCliOptions & { + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => Promise; +} { + return { + acknowledgeClawHubRisk: params.acknowledgeClawHubRisk, + onClawHubRisk: + params.acknowledgeClawHubRisk || params.allowPrompt === false || !canPromptForClawHubRisk() + ? undefined + : async (request) => { + const packageName = sanitizeTerminalText(request.packageName); + const releaseLabel = `${packageName}@${sanitizeTerminalText(request.version)}`; + if (request.acknowledgementKind === "type-package") { + const answer = await promptText( + `type: '${packageName}' to ${params.action === "installing" ? "install" : "update"} anyway\n> `, + ); + return answer.trim() === packageName; + } + return await promptYesNo( + `${params.action === "installing" ? "Install" : "Update"} ClawHub package "${releaseLabel}" after reviewing the warning above?`, + ); + }, + }; +} diff --git a/src/cli/daemon-cli.coverage.test.ts b/src/cli/daemon-cli.coverage.test.ts index a916cd3ea44c..5e8b07e966f4 100644 --- a/src/cli/daemon-cli.coverage.test.ts +++ b/src/cli/daemon-cli.coverage.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { captureEnv } from "../test-utils/env.js"; +import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js"; import { registerDaemonCli } from "./daemon-cli/register.js"; const probeGatewayStatus = vi.fn(async (..._args: unknown[]) => ({ ok: true })); @@ -191,10 +191,10 @@ describe("daemon-cli coverage", () => { "OPENCLAW_GATEWAY_PORT", "OPENCLAW_PROFILE", ]); - process.env.OPENCLAW_STATE_DIR = tmpDir; - process.env.OPENCLAW_CONFIG_PATH = path.join(tmpDir, "openclaw.json"); - delete process.env.OPENCLAW_GATEWAY_PORT; - delete process.env.OPENCLAW_PROFILE; + setTestEnvValue("OPENCLAW_STATE_DIR", tmpDir); + setTestEnvValue("OPENCLAW_CONFIG_PATH", path.join(tmpDir, "openclaw.json")); + deleteTestEnvValue("OPENCLAW_GATEWAY_PORT"); + deleteTestEnvValue("OPENCLAW_PROFILE"); serviceReadCommand.mockResolvedValue(null); resolveGatewayProbeAuthSafeWithSecretInputs.mockClear(); findExtraGatewayServices.mockClear(); diff --git a/src/cli/gateway-cli/run.option-collisions.test.ts b/src/cli/gateway-cli/run.option-collisions.test.ts index b833c03f2587..1fa292607e72 100644 --- a/src/cli/gateway-cli/run.option-collisions.test.ts +++ b/src/cli/gateway-cli/run.option-collisions.test.ts @@ -5,7 +5,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites import type { ConfigFileSnapshot } from "../../config/types.js"; import { GATEWAY_SERVICE_RUNTIME_PID_ENV } from "../../daemon/constants.js"; import { SUPERVISOR_HINT_ENV_VARS } from "../../infra/supervisor-markers.js"; -import { captureEnv, withEnvAsync } from "../../test-utils/env.js"; +import { + captureEnv, + deleteTestEnvValue, + setTestEnvValue, + withEnvAsync, +} from "../../test-utils/env.js"; import { withTempSecretFiles } from "../../test-utils/secret-file-fixture.js"; import { createCliRuntimeCapture } from "../test-runtime-capture.js"; import { installGatewayRunRuntimeHooks } from "./runtime-hooks.js"; @@ -299,7 +304,7 @@ describe("gateway run option collisions", () => { beforeEach(() => { delete process.env.OPENCLAW_SERVICE_MARKER; delete process.env.OPENCLAW_SERVICE_KIND; - delete process.env[GATEWAY_SERVICE_RUNTIME_PID_ENV]; + deleteTestEnvValue(GATEWAY_SERVICE_RUNTIME_PID_ENV); resetRuntimeCapture(); configState.cfg = {}; configState.snapshot = { config: {}, exists: false, sourceConfig: {}, valid: true }; @@ -1035,7 +1040,9 @@ describe("gateway run option collisions", () => { loadGlobalRuntimeDotEnvFiles.mockImplementation(() => { process.env.OPENCLAW_GATEWAY_TOKEN ??= "trusted-token"; process.env.OPENCLAW_PROFILE ??= "dev"; - process.env.OPENCLAW_WORKSPACE_DIR ??= "/tmp/openclaw-reset-workspace"; + if (process.env.OPENCLAW_WORKSPACE_DIR === undefined) { + setTestEnvValue("OPENCLAW_WORKSPACE_DIR", "/tmp/openclaw-reset-workspace"); + } }); await prepareGatewayReset(); @@ -1059,7 +1066,7 @@ describe("gateway run option collisions", () => { }; await prepareGatewayReset(); loadGlobalRuntimeDotEnvFiles.mockImplementation(() => { - process.env.OPENCLAW_STATE_DIR = "/tmp/openclaw-reset-retargeted"; + setTestEnvValue("OPENCLAW_STATE_DIR", "/tmp/openclaw-reset-retargeted"); return { gatewayEnvAppliedKeys: [], stateEnvAppliedKeys: ["OPENCLAW_STATE_DIR"], @@ -1091,7 +1098,7 @@ describe("gateway run option collisions", () => { ])("blocks trusted dotenv selector drift for %s after startup mutations", async (selector) => { await withEnvAsync({ [selector]: "/tmp/openclaw-reset-value" }, async () => { loadGlobalRuntimeDotEnvFiles.mockImplementation(() => { - process.env[selector] = "/tmp/openclaw-reset-retargeted"; + setTestEnvValue(selector, "/tmp/openclaw-reset-retargeted"); }); const { reloadTrustedGatewayRunEnvironment } = await import("./pre-bootstrap.js"); diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index a2d512932917..2167374c097e 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -81,6 +81,7 @@ const uninstallPlugin: AsyncUnknownMock = vi.fn(); export const updateNpmInstalledPlugins: Mock = vi.fn(); export const updateNpmInstalledHookPacks: Mock = vi.fn(); export const promptYesNo: AsyncUnknownMock = vi.fn(); +export const promptText: AsyncUnknownMock = vi.fn(); export class PromptInputClosedError extends Error { constructor() { super("Prompt input closed before an answer was received."); @@ -528,6 +529,11 @@ vi.mock("../hooks/update.js", () => ({ vi.mock("./prompt.js", () => ({ PromptInputClosedError, + promptText: ((...args: Parameters<(typeof import("./prompt.js"))["promptText"]>) => + invokeMock< + Parameters<(typeof import("./prompt.js"))["promptText"]>, + ReturnType<(typeof import("./prompt.js"))["promptText"]> + >(promptText, ...args)) as (typeof import("./prompt.js"))["promptText"], promptYesNo: ((...args: Parameters<(typeof import("./prompt.js"))["promptYesNo"]>) => invokeMock< Parameters<(typeof import("./prompt.js"))["promptYesNo"]>, @@ -725,6 +731,7 @@ export function resetPluginsCliTestState() { uninstallPlugin.mockReset(); updateNpmInstalledPlugins.mockReset(); updateNpmInstalledHookPacks.mockReset(); + promptText.mockReset(); promptYesNo.mockReset(); installPluginFromGitSpec.mockReset(); parseGitPluginSpec.mockReset(); @@ -866,6 +873,7 @@ export function resetPluginsCliTestState() { config: {} as OpenClawConfig, }); promptYesNo.mockResolvedValue(true); + promptText.mockResolvedValue("demo"); installPluginFromPath.mockResolvedValue({ ok: false, error: "path install disabled in test" }); installPluginFromGitSpec.mockResolvedValue({ ok: false, diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index 5845b6b98ee4..5720b90087de 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -93,6 +93,16 @@ function createClawHubInstallResult(params: { packageName: string; version: string; channel: string; + trust?: { + disposition: "clean" | "review-recommended" | "review-required"; + scanStatus?: string; + moderationState?: string; + reasons?: string[]; + pending?: boolean; + stale?: boolean; + checkedAt?: string; + acknowledgedAt?: string; + }; }): Awaited> { return { ok: true, @@ -113,6 +123,22 @@ function createClawHubInstallResult(params: { clawpackSpecVersion: 1, clawpackManifestSha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", clawpackSize: 4096, + ...(params.trust + ? { + clawhubTrustDisposition: params.trust.disposition, + ...(params.trust.scanStatus ? { clawhubTrustScanStatus: params.trust.scanStatus } : {}), + ...(params.trust.moderationState + ? { clawhubTrustModerationState: params.trust.moderationState } + : {}), + ...(params.trust.reasons ? { clawhubTrustReasons: params.trust.reasons } : {}), + ...(params.trust.pending ? { clawhubTrustPending: true } : {}), + ...(params.trust.stale ? { clawhubTrustStale: true } : {}), + ...(params.trust.checkedAt ? { clawhubTrustCheckedAt: params.trust.checkedAt } : {}), + ...(params.trust.acknowledgedAt + ? { clawhubTrustAcknowledgedAt: params.trust.acknowledgedAt } + : {}), + } + : {}), }, }; } @@ -1269,6 +1295,81 @@ describe("plugins cli install", () => { expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); }); + it("passes ClawHub risk acknowledgement to explicit ClawHub installs", async () => { + loadConfig.mockReturnValue(createEmptyPluginConfig()); + parseClawHubPluginSpec.mockReturnValue({ name: "demo" }); + installPluginFromClawHub.mockResolvedValue( + createClawHubInstallResult({ + pluginId: "demo", + packageName: "demo", + version: "1.2.3", + channel: "official", + trust: { + disposition: "review-required", + scanStatus: "suspicious", + reasons: ["payload_strings"], + checkedAt: "2026-05-14T18:00:00.000Z", + acknowledgedAt: "2026-05-14T18:00:03.000Z", + }, + }), + ); + enablePluginInConfig.mockReturnValue({ config: createEnabledPluginConfig("demo") }); + applyExclusiveSlotSelection.mockReturnValue({ + config: createEnabledPluginConfig("demo"), + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", "clawhub:demo", "--acknowledge-clawhub-risk"]); + + expect(installPluginFromClawHub).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "clawhub:demo", + acknowledgeClawHubRisk: true, + }), + ); + const record = persistedInstallRecord("demo"); + expect(record.clawhubTrustDisposition).toBe("review-required"); + expect(record.clawhubTrustScanStatus).toBe("suspicious"); + expect(record.clawhubTrustReasons).toEqual(["payload_strings"]); + expect(record.clawhubTrustCheckedAt).toBe("2026-05-14T18:00:00.000Z"); + expect(record.clawhubTrustAcknowledgedAt).toBe("2026-05-14T18:00:03.000Z"); + }); + + it("prints acknowledgement guidance for unacknowledged ClawHub plugin installs", async () => { + loadConfig.mockReturnValue(createEmptyPluginConfig()); + parseClawHubPluginSpec.mockReturnValue({ name: "demo" }); + installPluginFromClawHub.mockResolvedValue({ + ok: false, + code: "clawhub_risk_acknowledgement_required", + error: + "Install cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning.", + warning: "WARNING - ClawHub found security risks in this release", + }); + + await expect(runPluginsCommand(["plugins", "install", "clawhub:demo"])).rejects.toThrow( + "__exit__:1", + ); + + expect(runtimeErrors.at(-1)).toContain("--acknowledge-clawhub-risk"); + }); + + it("prints blocked ClawHub download failures when no trust warning was emitted", async () => { + loadConfig.mockReturnValue(createEmptyPluginConfig()); + parseClawHubPluginSpec.mockReturnValue({ name: "demo" }); + installPluginFromClawHub.mockResolvedValue({ + ok: false, + code: "clawhub_download_blocked", + error: + 'ClawHub blocked artifact download for "demo@1.2.3"; install was not started. ClawHub /api/v1/packages/demo/versions/1.2.3/artifact/download failed (403): blocked.', + }); + + await expect(runPluginsCommand(["plugins", "install", "clawhub:demo"])).rejects.toThrow( + "__exit__:1", + ); + + expect(runtimeErrors.at(-1)).toContain("ClawHub blocked artifact download"); + }); + it("passes the active profile extensions dir to ClawHub installs", async () => { const extensionsDir = useProfileExtensionsDir(); const cfg = createEmptyPluginConfig(); diff --git a/src/cli/plugins-cli.runtime.ts b/src/cli/plugins-cli.runtime.ts index 69406ff91eb0..0266a072e34b 100644 --- a/src/cli/plugins-cli.runtime.ts +++ b/src/cli/plugins-cli.runtime.ts @@ -20,6 +20,7 @@ import { formatMissingPluginMessage } from "./error-format.js"; import type { PluginMarketplaceListOptions, PluginRegistryOptions } from "./plugins-cli.js"; type PluginInstallActionOptions = { + acknowledgeClawHubRisk?: boolean; dangerouslyForceUnsafeInstall?: boolean; force?: boolean; link?: boolean; diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index cc0457d37488..f7d18763bfd9 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -9,10 +9,19 @@ import { applyParentDefaultHelpAction } from "./program/parent-default-help.js"; export type PluginUpdateOptions = { all?: boolean; + acknowledgeClawhubRisk?: boolean; dryRun?: boolean; dangerouslyForceUnsafeInstall?: boolean; }; +type CommanderClawHubRiskOptions = Record & { + acknowledgeClawhubRisk?: boolean; +}; + +function normalizeCommanderClawHubRiskOption(opts: CommanderClawHubRiskOptions): boolean { + return opts.acknowledgeClawhubRisk === true || opts.acknowledgeClawHubRisk === true; +} + export type PluginMarketplaceListOptions = { json?: boolean; }; @@ -156,6 +165,11 @@ export function registerPluginsCli(program: Command) { "Deprecated no-op; security.installPolicy may still block", false, ) + .option( + "--acknowledge-clawhub-risk", + "Acknowledge ClawHub release trust warnings without prompting", + false, + ) .option( "--marketplace ", "Install a Claude marketplace plugin from a local repo/path or git/GitHub source", @@ -163,7 +177,7 @@ export function registerPluginsCli(program: Command) { .action( async ( raw: string, - opts: { + opts: CommanderClawHubRiskOptions & { dangerouslyForceUnsafeInstall?: boolean; force?: boolean; link?: boolean; @@ -172,7 +186,10 @@ export function registerPluginsCli(program: Command) { }, ) => { const { runPluginsInstallAction } = await loadPluginsRuntime(); - await runPluginsInstallAction(raw, opts); + await runPluginsInstallAction(raw, { + ...opts, + acknowledgeClawHubRisk: normalizeCommanderClawHubRiskOption(opts), + }); }, ); @@ -187,9 +204,20 @@ export function registerPluginsCli(program: Command) { "Deprecated no-op; security.installPolicy may still block", false, ) + .option( + "--acknowledge-clawhub-risk", + "Acknowledge ClawHub release trust warnings without prompting", + false, + ) .action(async (id: string | undefined, opts: PluginUpdateOptions) => { const { runPluginUpdateCommand } = await import("./plugins-update-command.js"); - await runPluginUpdateCommand({ id, opts }); + await runPluginUpdateCommand({ + id, + opts: { + ...opts, + acknowledgeClawHubRisk: normalizeCommanderClawHubRiskOption(opts), + }, + }); }); plugins diff --git a/src/cli/plugins-cli.update.test.ts b/src/cli/plugins-cli.update.test.ts index c62c8a9f3a2e..c36c843cdb50 100644 --- a/src/cli/plugins-cli.update.test.ts +++ b/src/cli/plugins-cli.update.test.ts @@ -6,6 +6,7 @@ import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { hashConfigIncludeRaw } from "../config/includes.js"; +import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub-error-codes.js"; import { loadConfig, readConfigFileSnapshotForWrite, @@ -24,6 +25,32 @@ import { } from "./plugins-cli-test-helpers.js"; const ORIGINAL_OPENCLAW_NIX_MODE = process.env.OPENCLAW_NIX_MODE; +const ORIGINAL_STDIN_TTY = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); +const ORIGINAL_STDOUT_TTY = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + +function setTty(value: boolean): void { + Object.defineProperty(process.stdin, "isTTY", { + value, + configurable: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + value, + configurable: true, + }); +} + +function restoreTty(): void { + if (ORIGINAL_STDIN_TTY) { + Object.defineProperty(process.stdin, "isTTY", ORIGINAL_STDIN_TTY); + } else { + Reflect.deleteProperty(process.stdin, "isTTY"); + } + if (ORIGINAL_STDOUT_TTY) { + Object.defineProperty(process.stdout, "isTTY", ORIGINAL_STDOUT_TTY); + } else { + Reflect.deleteProperty(process.stdout, "isTTY"); + } +} function createTrackedPluginConfig(params: { pluginId: string; @@ -124,6 +151,7 @@ describe("plugins cli update", () => { }); afterEach(() => { + restoreTty(); if (ORIGINAL_OPENCLAW_NIX_MODE === undefined) { delete process.env.OPENCLAW_NIX_MODE; } else { @@ -978,6 +1006,83 @@ describe("plugins cli update", () => { const updateParams = expectSingleCallParams(updateNpmInstalledPlugins); expect(updateParams.pluginIds).toEqual(["codex"]); expect(updateParams.syncOfficialPluginInstalls).toBeUndefined(); + expect(updateParams.updateChannel).toBeUndefined(); + expect(updateParams.officialPluginUpdateChannel).toBeUndefined(); + }); + + it("syncs official catalog specs with beta channel context for update --all", async () => { + const config = createTrackedPluginConfig({ + pluginId: "codex", + spec: "@openclaw/codex@2026.6.8-beta.1", + resolvedName: "@openclaw/codex", + }); + config.update = { channel: "beta" }; + loadConfig.mockReturnValue(config); + setInstalledPluginIndexInstallRecords(config.plugins?.installs ?? {}); + updateNpmInstalledPlugins.mockResolvedValue({ + config, + changed: false, + outcomes: [], + }); + + await runPluginsCommand(["plugins", "update", "--all"]); + + const updateParams = expectSingleCallParams(updateNpmInstalledPlugins); + expect(updateParams.pluginIds).toEqual(["codex"]); + expect(updateParams.syncOfficialPluginInstalls).toBe(true); + expect(updateParams.officialPluginUpdateChannel).toBe("beta"); + expect(updateParams.updateChannel).toBeUndefined(); + }); + + it("passes ClawHub risk acknowledgement to plugin updates", async () => { + const config = createTrackedPluginConfig({ + pluginId: "openclaw-codex-app-server", + spec: "openclaw-codex-app-server@beta", + }); + loadConfig.mockReturnValue(config); + setInstalledPluginIndexInstallRecords(config.plugins?.installs ?? {}); + updateNpmInstalledPlugins.mockResolvedValue({ + config, + changed: false, + outcomes: [], + }); + + await runPluginsCommand([ + "plugins", + "update", + "openclaw-codex-app-server", + "--acknowledge-clawhub-risk", + ]); + + expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config, + pluginIds: ["openclaw-codex-app-server"], + acknowledgeClawHubRisk: true, + }), + ); + }); + + it("does not pass an interactive ClawHub risk prompt to dry-run plugin updates", async () => { + setTty(true); + const config = createTrackedPluginConfig({ + pluginId: "openclaw-codex-app-server", + spec: "clawhub:openclaw-codex-app-server", + }); + loadConfig.mockReturnValue(config); + setInstalledPluginIndexInstallRecords(config.plugins?.installs ?? {}); + updateNpmInstalledPlugins.mockResolvedValue({ + config, + changed: false, + outcomes: [], + }); + + await runPluginsCommand(["plugins", "update", "openclaw-codex-app-server", "--dry-run"]); + + const updateParams = expectSingleCallParams(updateNpmInstalledPlugins); + expect(updateParams.dryRun).toBe(true); + expect(updateParams.acknowledgeClawHubRisk).not.toBe(true); + expect(updateParams.onClawHubRisk).toBeUndefined(); }); it("writes updated config when updater reports changes", async () => { @@ -1116,6 +1221,123 @@ describe("plugins cli update", () => { expect(runtimeLogs).toContain("Failed to update beta: registry timeout"); }); + it("exits non-zero when a ClawHub update is skipped for missing risk acknowledgement", async () => { + const cfg = { + plugins: { + installs: { + demo: { + source: "clawhub", + spec: "clawhub:@openclaw/plugin-demo@1.0.0", + clawhubPackage: "@openclaw/plugin-demo", + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(cfg); + setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {}); + updateNpmInstalledPlugins.mockResolvedValue({ + outcomes: [ + { + pluginId: "demo", + status: "skipped", + code: CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED, + message: + "Skipped demo ClawHub update: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. Existing installed plugin left unchanged.", + }, + ], + changed: false, + config: cfg, + }); + updateNpmInstalledHookPacks.mockResolvedValue({ + outcomes: [], + changed: false, + config: cfg, + }); + + await expect(runPluginsCommand(["plugins", "update", "demo"])).rejects.toThrow("__exit__:1"); + + expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled(); + expect(runtimeLogs.at(-1)).toContain("--acknowledge-clawhub-risk"); + }); + + it("exits non-zero when a ClawHub update is skipped because the target release is blocked", async () => { + const cfg = { + plugins: { + installs: { + demo: { + source: "clawhub", + spec: "clawhub:@openclaw/plugin-demo", + clawhubPackage: "@openclaw/plugin-demo", + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(cfg); + setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {}); + updateNpmInstalledPlugins.mockResolvedValue({ + outcomes: [ + { + pluginId: "demo", + status: "skipped", + code: "clawhub_download_blocked", + message: + "Skipped demo ClawHub update: ClawHub blocked this release; update was not started. Existing installed plugin left unchanged.", + }, + ], + changed: false, + config: cfg, + }); + updateNpmInstalledHookPacks.mockResolvedValue({ + outcomes: [], + changed: false, + config: cfg, + }); + + await expect(runPluginsCommand(["plugins", "update", "demo"])).rejects.toThrow("__exit__:1"); + + expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled(); + expect(runtimeLogs.at(-1)).toContain("ClawHub blocked this release"); + }); + + it("exits non-zero when a ClawHub update is skipped because security data is unavailable", async () => { + const cfg = { + plugins: { + installs: { + demo: { + source: "clawhub", + spec: "clawhub:@openclaw/plugin-demo", + clawhubPackage: "@openclaw/plugin-demo", + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(cfg); + setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {}); + updateNpmInstalledPlugins.mockResolvedValue({ + outcomes: [ + { + pluginId: "demo", + status: "skipped", + code: "clawhub_security_unavailable", + message: + 'Skipped demo ClawHub update: ClawHub security data for "@openclaw/plugin-demo@1.1.0" is unavailable, so OpenClaw left the existing installed plugin unchanged. Try again later or choose a different version.', + }, + ], + changed: false, + config: cfg, + }); + updateNpmInstalledHookPacks.mockResolvedValue({ + outcomes: [], + changed: false, + config: cfg, + }); + + await expect(runPluginsCommand(["plugins", "update", "demo"])).rejects.toThrow("__exit__:1"); + + expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled(); + expect(runtimeLogs.at(-1)).toContain("security data"); + }); + it("exits non-zero when a hook pack update reports an error", async () => { const cfg = { hooks: { diff --git a/src/cli/plugins-command-helpers.ts b/src/cli/plugins-command-helpers.ts index 2c86e762804c..b2bac96e7717 100644 --- a/src/cli/plugins-command-helpers.ts +++ b/src/cli/plugins-command-helpers.ts @@ -127,7 +127,7 @@ export function createPluginInstallLogger(runtime: RuntimeEnv = defaultRuntime): } { return { info: (msg) => runtime.log(msg), - warn: (msg) => runtime.log(theme.warn(msg)), + warn: (msg) => runtime.log(msg.includes("╭─") ? msg : theme.warn(msg)), }; } diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index b29c4c693f7e..06f7afab55ee 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -18,7 +18,7 @@ import { parseClawHubPluginSpec } from "../infra/clawhub.js"; import { formatErrorMessage } from "../infra/errors.js"; import { type BundledPluginSource, findBundledPluginSource } from "../plugins/bundled-sources.js"; import { buildClawHubPluginInstallRecordFields } from "../plugins/clawhub-install-records.js"; -import { installPluginFromClawHub } from "../plugins/clawhub.js"; +import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "../plugins/clawhub.js"; import { installPluginFromGitSpec, parseGitPluginSpec } from "../plugins/git-install.js"; import { resolveDefaultPluginExtensionsDir } from "../plugins/install-paths.js"; import type { InstallSafetyOverrides } from "../plugins/install-security-scan.js"; @@ -43,6 +43,7 @@ import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trac import { validateJsonSchemaValue } from "../plugins/schema-validator.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { resolveUserPath, shortenHomePath } from "../utils.js"; +import { resolveClawHubRiskAcknowledgementCliOptions } from "./clawhub-risk-acknowledgement.js"; import { formatCliCommand } from "./command-format.js"; import { looksLikeLocalInstallSpec } from "./install-spec.js"; import { resolvePinnedNpmInstallRecordForCli } from "./npm-resolution.js"; @@ -80,6 +81,14 @@ type ConfigSnapshotForInstallExecution = ConfigSnapshotForInstallPersist & { pluginMutation: ConfigMutationPreflight; }; +function isClawHubBlockedCliFailure(result: { code?: string; warning?: string }): boolean { + return ( + result.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_DOWNLOAD_BLOCKED && + typeof result.warning === "string" && + result.warning.trim().length > 0 + ); +} + function resolveInstallMode(force?: boolean): "install" | "update" { return force ? "update" : "install"; } @@ -854,6 +863,7 @@ export async function loadConfigForInstall( export async function runPluginInstallCommand(params: { raw: string; opts: InstallSafetyOverrides & { + acknowledgeClawHubRisk?: boolean; force?: boolean; link?: boolean; pin?: boolean; @@ -994,7 +1004,9 @@ export async function runPluginInstallCommand(params: { logger: createPluginInstallLogger(runtime), }); if (!result.ok) { - runtime.error(result.error); + if (!isClawHubBlockedCliFailure(result)) { + runtime.error(result.error); + } return runtime.exit(1); } @@ -1301,13 +1313,19 @@ export async function runPluginInstallCommand(params: { if (clawhubSpec) { const result = await installPluginFromClawHub({ ...safetyOverrides, + ...resolveClawHubRiskAcknowledgementCliOptions({ + acknowledgeClawHubRisk: opts.acknowledgeClawHubRisk, + action: "installing", + }), mode: installMode, spec: raw, extensionsDir, logger: createPluginInstallLogger(runtime), }); if (!result.ok) { - runtime.error(result.error); + if (!isClawHubBlockedCliFailure(result)) { + runtime.error(result.error); + } return runtime.exit(1); } diff --git a/src/cli/plugins-update-command.ts b/src/cli/plugins-update-command.ts index 8b8c764177dc..28e4918022b9 100644 --- a/src/cli/plugins-update-command.ts +++ b/src/cli/plugins-update-command.ts @@ -12,6 +12,7 @@ import { extractShippedPluginInstallConfigRecords } from "../config/plugin-insta import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { updateNpmInstalledHookPacks } from "../hooks/update.js"; +import { normalizeUpdateChannel } from "../infra/update-channels.js"; import { loadInstalledPluginIndexInstallRecords, withoutPluginInstallRecords, @@ -23,6 +24,7 @@ import { updateNpmInstalledPlugins, } from "../plugins/update.js"; import { defaultRuntime } from "../runtime.js"; +import { resolveClawHubRiskAcknowledgementCliOptions } from "./clawhub-risk-acknowledgement.js"; import { containsConfigIncludeDirective, resolveCombinedPluginAndHookConfigMutationPreflight, @@ -100,7 +102,12 @@ function projectUpdaterResultOntoSourceConfig(params: { /** Run plugin/hook-pack updates, persist changed install records, and refresh runtime registry. */ export async function runPluginUpdateCommand(params: { id?: string; - opts: { all?: boolean; dryRun?: boolean; dangerouslyForceUnsafeInstall?: boolean }; + opts: { + all?: boolean; + acknowledgeClawHubRisk?: boolean; + dryRun?: boolean; + dangerouslyForceUnsafeInstall?: boolean; + }; }) { assertConfigWriteAllowedInCurrentMode(); @@ -142,7 +149,7 @@ export async function runPluginUpdateCommand(params: { ); const logger = { info: (msg: string) => defaultRuntime.log(msg), - warn: (msg: string) => defaultRuntime.log(theme.warn(msg)), + warn: (msg: string) => defaultRuntime.log(msg.includes("╭─") ? msg : theme.warn(msg)), }; if (params.opts.dangerouslyForceUnsafeInstall) { defaultRuntime.log(theme.warn(DEPRECATED_DANGEROUS_FORCE_UNSAFE_UPDATE_WARNING)); @@ -260,7 +267,16 @@ export async function runPluginUpdateCommand(params: { pluginIds: pluginSelection.pluginIds, specOverrides: pluginSelection.specOverrides, dryRun: params.opts.dryRun, + officialPluginUpdateChannel: params.opts.all + ? (normalizeUpdateChannel(cfg.update?.channel) ?? undefined) + : undefined, + syncOfficialPluginInstalls: params.opts.all ? true : undefined, dangerouslyForceUnsafeInstall: params.opts.dangerouslyForceUnsafeInstall, + ...resolveClawHubRiskAcknowledgementCliOptions({ + acknowledgeClawHubRisk: params.opts.acknowledgeClawHubRisk, + action: "updating", + allowPrompt: !params.opts.dryRun, + }), logger, onIntegrityDrift: async (drift) => { const specLabel = drift.resolvedSpec ?? drift.spec; diff --git a/src/cli/plugins-update-outcomes.ts b/src/cli/plugins-update-outcomes.ts index 818b7f0967f9..c10b0d196dfa 100644 --- a/src/cli/plugins-update-outcomes.ts +++ b/src/cli/plugins-update-outcomes.ts @@ -1,5 +1,6 @@ // User-facing logging for plugin and hook-pack update outcomes. import { theme } from "../../packages/terminal-core/src/theme.js"; +import { isClawHubTrustSkippedOutcome } from "../plugins/update.js"; type PluginUpdateCliOutcome = { status: string; @@ -7,6 +8,7 @@ type PluginUpdateCliOutcome = { channelFallback?: { message: string; }; + code?: string; }; /** Log update outcomes with severity styling and report whether any errors occurred. */ @@ -25,6 +27,9 @@ export function logPluginUpdateOutcomes(params: { continue; } if (outcome.status === "skipped") { + if (isClawHubTrustSkippedOutcome(outcome)) { + hasErrors = true; + } params.log(theme.warn(outcome.message)); if (outcome.channelFallback) { params.log(theme.warn(outcome.channelFallback.message)); diff --git a/src/cli/program/register.maintenance.test.ts b/src/cli/program/register.maintenance.test.ts index 246d0918cb2f..e905a3c2391a 100644 --- a/src/cli/program/register.maintenance.test.ts +++ b/src/cli/program/register.maintenance.test.ts @@ -112,6 +112,7 @@ describe("registerMaintenanceCommands doctor action", () => { "--json", "--severity-min", "error", + "--all", "--skip", "a", "--only", @@ -123,6 +124,7 @@ describe("registerMaintenanceCommands doctor action", () => { expect(runDoctorLintCli).toHaveBeenCalledWith(runtime, { json: true, severityMin: "error", + includeAllChecks: true, skipIds: ["a"], onlyIds: ["b"], allowExec: true, @@ -141,6 +143,17 @@ describe("registerMaintenanceCommands doctor action", () => { expect(runtime.exit).toHaveBeenCalledWith(2); }); + it("rejects --all outside doctor lint mode", async () => { + await runMaintenanceCli(["doctor", "--all"]); + + expect(doctorCommand).not.toHaveBeenCalled(); + expect(runDoctorLintCli).not.toHaveBeenCalled(); + expect(runtime.error).toHaveBeenCalledWith( + "doctor lint options require --lint. Use `openclaw doctor --lint ...`.", + ); + expect(runtime.exit).toHaveBeenCalledWith(2); + }); + it("exits with code 2 when doctor lint mode fails before findings are emitted", async () => { runDoctorLintCli.mockRejectedValue(new Error("lint failed")); diff --git a/src/cli/program/register.maintenance.ts b/src/cli/program/register.maintenance.ts index ed7fa200ff06..83090498c2ee 100644 --- a/src/cli/program/register.maintenance.ts +++ b/src/cli/program/register.maintenance.ts @@ -39,6 +39,7 @@ export function registerMaintenanceCommands(program: Command) { "--severity-min ", "With --lint: drop findings below this severity (info|warning|error)", ) + .option("--all", "With --lint: run all registered checks, including opt-in checks", false) .option( "--skip ", "With --lint: skip a specific check id (repeatable)", @@ -60,6 +61,7 @@ export function registerMaintenanceCommands(program: Command) { const exitCode = await runDoctorLintCli(defaultRuntime, { json: Boolean(opts.json), severityMin: typeof opts.severityMin === "string" ? opts.severityMin : undefined, + includeAllChecks: Boolean(opts.all), skipIds: Array.isArray(opts.skip) ? opts.skip : [], onlyIds: Array.isArray(opts.only) ? opts.only : [], allowExec: Boolean(opts.allowExec), @@ -180,12 +182,14 @@ function hasLintOnlyDoctorOptions(opts: { readonly json?: boolean; readonly postUpgrade?: boolean; readonly severityMin?: unknown; + readonly all?: boolean; readonly skip?: unknown; readonly only?: unknown; }): boolean { return ( (opts.json === true && opts.postUpgrade !== true) || typeof opts.severityMin === "string" || + opts.all === true || (Array.isArray(opts.skip) && opts.skip.length > 0) || (Array.isArray(opts.only) && opts.only.length > 0) ); diff --git a/src/cli/prompt.ts b/src/cli/prompt.ts index 7c662f57f3ad..d2cb564a5994 100644 --- a/src/cli/prompt.ts +++ b/src/cli/prompt.ts @@ -57,3 +57,10 @@ export async function promptYesNo(question: string, defaultYes = false): Promise } return answer.startsWith("y"); } + +export async function promptText(question: string): Promise { + const rl = readline.createInterface({ input, output }); + return await questionUntilClose(rl, question).finally(() => { + rl.close(); + }); +} diff --git a/src/cli/skills-cli.commands.test.ts b/src/cli/skills-cli.commands.test.ts index 4f44d6ff2688..f40f748ae96a 100644 --- a/src/cli/skills-cli.commands.test.ts +++ b/src/cli/skills-cli.commands.test.ts @@ -659,6 +659,56 @@ describe("skills cli commands", () => { ); }); + it("passes --acknowledge-clawhub-risk through for ClawHub skill installs", async () => { + installSkillFromClawHubMock.mockResolvedValue({ + ok: true, + slug: "calendar", + version: "1.2.3", + targetDir: "/tmp/workspace/skills/calendar", + }); + + await runCommand(["skills", "install", "calendar", "--acknowledge-clawhub-risk"]); + + expect(installSkillFromClawHubMock).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceDir: "/tmp/workspace", + slug: "calendar", + acknowledgeClawHubRisk: true, + }), + ); + }); + + it("prints acknowledgement guidance for unacknowledged ClawHub skill installs", async () => { + installSkillFromClawHubMock.mockResolvedValue({ + ok: false, + code: "clawhub_risk_acknowledgement_required", + error: + "Install cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning.", + warning: "WARNING - ClawHub found security risks in this release", + }); + + await expect(runCommand(["skills", "install", "calendar"])).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors).toContain( + "Install cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning.", + ); + }); + + it("prints blocked ClawHub skill install failures when no trust warning was emitted", async () => { + installSkillFromClawHubMock.mockResolvedValue({ + ok: false, + code: "clawhub_download_blocked", + error: + 'ClawHub blocked artifact download for "calendar@1.2.3"; install was not started. ClawHub /api/v1/skills/calendar/versions/1.2.3/download failed (403): blocked.', + }); + + await expect(runCommand(["skills", "install", "calendar"])).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors).toContain( + 'ClawHub blocked artifact download for "calendar@1.2.3"; install was not started. ClawHub /api/v1/skills/calendar/versions/1.2.3/download failed (403): blocked.', + ); + }); + it("rejects using --global and --agent together for installs", async () => { await expect( runCommand(["skills", "install", "calendar", "--global", "--agent", "main"]), @@ -749,6 +799,48 @@ describe("skills cli commands", () => { ); }); + it("passes --acknowledge-clawhub-risk through for ClawHub skill updates", async () => { + readTrackedClawHubSkillSlugsMock.mockResolvedValue(["calendar"]); + updateSkillsFromClawHubMock.mockResolvedValue([ + { + ok: true, + slug: "calendar", + previousVersion: "1.2.2", + version: "1.2.3", + changed: true, + targetDir: "/tmp/workspace/skills/calendar", + }, + ]); + + await runCommand(["skills", "update", "--all", "--acknowledge-clawhub-risk"]); + + expect(updateSkillsFromClawHubMock).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceDir: "/tmp/workspace", + acknowledgeClawHubRisk: true, + }), + ); + }); + + it("prints acknowledgement guidance for unacknowledged ClawHub skill updates", async () => { + readTrackedClawHubSkillSlugsMock.mockResolvedValue(["calendar"]); + updateSkillsFromClawHubMock.mockResolvedValue([ + { + ok: false, + code: "clawhub_risk_acknowledgement_required", + error: + "Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning.", + warning: "WARNING - ClawHub found security risks in this release", + }, + ]); + + await expect(runCommand(["skills", "update", "calendar"])).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors).toContain( + "Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning.", + ); + }); + it("updates tracked ClawHub skills in the cwd-inferred agent workspace", async () => { routeWorkspaceByAgent(); resolveAgentIdByWorkspacePathMock.mockReturnValue("writer"); diff --git a/src/cli/skills-cli.ts b/src/cli/skills-cli.ts index 5a151bc454e6..635983f5b876 100644 --- a/src/cli/skills-cli.ts +++ b/src/cli/skills-cli.ts @@ -13,6 +13,7 @@ import { resolveDefaultAgentId, } from "../agents/agent-scope.js"; import { getRuntimeConfig } from "../config/config.js"; +import { CLAWHUB_TRUST_ERROR_CODE } from "../infra/clawhub-install-trust.js"; import { fetchClawHubSkillCard, fetchClawHubSkillVerification, @@ -49,6 +50,7 @@ import type { SkillProposalSupportFileInput, } from "../skills/workshop/types.js"; import { CONFIG_DIR } from "../utils.js"; +import { resolveClawHubRiskAcknowledgementCliOptions } from "./clawhub-risk-acknowledgement.js"; import { resolveOptionFromCommand } from "./cli-utils.js"; import { parseStrictPositiveIntOption } from "./program/helpers.js"; import { formatSkillInfo, formatSkillsCheck, formatSkillsList } from "./skills-cli.format.js"; @@ -68,6 +70,32 @@ type ResolvedClawHubSkillVerificationTarget = Extract< { ok: true } >; +function resolveSkillClawHubRiskOptions( + acknowledgeClawHubRisk: boolean, + action: "installing" | "updating", +) { + const riskOptions = resolveClawHubRiskAcknowledgementCliOptions({ + acknowledgeClawHubRisk, + action, + }); + return { + ...(riskOptions.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), + ...(riskOptions.onClawHubRisk ? { onClawHubRisk: riskOptions.onClawHubRisk } : {}), + }; +} + +function formatSkillWarning(message: string): string { + return message.includes("╭─") ? message : theme.warn(message); +} + +function isClawHubSkillBlockedCliFailure(result: { code?: string; warning?: string }): boolean { + return ( + result.code === CLAWHUB_TRUST_ERROR_CODE.CLAWHUB_DOWNLOAD_BLOCKED && + typeof result.warning === "string" && + result.warning.trim().length > 0 + ); +} + type ResolveSkillsWorkspaceOptions = { agentId?: string; cwd?: string; @@ -334,6 +362,11 @@ export function registerSkillsCli(program: Command) { "Install a pending GitHub-backed skill before ClawHub scan completes", false, ) + .option( + "--acknowledge-clawhub-risk", + "Acknowledge ClawHub release trust warnings without prompting", + false, + ) .option("--global", "Install into the shared managed skills directory", false) .option("--agent ", "Target agent workspace (defaults to cwd-inferred, then default agent)") .option("--as ", "Install a git/local skill under this slug") @@ -345,6 +378,8 @@ export function registerSkillsCli(program: Command) { version?: string; force?: boolean; forceInstall?: boolean; + acknowledgeClawhubRisk?: boolean; + acknowledgeClawHubRisk?: boolean; global?: boolean; agent?: string; as?: string; @@ -369,7 +404,7 @@ export function registerSkillsCli(program: Command) { force: Boolean(opts.force), logger: { info: (message) => defaultRuntime.log(message), - warn: (message) => defaultRuntime.log(theme.warn(message)), + warn: (message) => defaultRuntime.log(formatSkillWarning(message)), }, }); if (!result.ok) { @@ -395,12 +430,19 @@ export function registerSkillsCli(program: Command) { version: opts.version, force: Boolean(opts.force), ...(opts.forceInstall ? { forceInstall: true } : {}), + ...resolveSkillClawHubRiskOptions( + opts.acknowledgeClawhubRisk === true || opts.acknowledgeClawHubRisk === true, + "installing", + ), logger: { info: (message) => defaultRuntime.log(message), + warn: (message) => defaultRuntime.log(formatSkillWarning(message)), }, }); if (!result.ok) { - defaultRuntime.error(result.error); + if (!isClawHubSkillBlockedCliFailure(result)) { + defaultRuntime.error(result.error); + } defaultRuntime.exit(1); return; } @@ -422,12 +464,24 @@ export function registerSkillsCli(program: Command) { "Install a pending GitHub-backed skill before ClawHub scan completes", false, ) + .option( + "--acknowledge-clawhub-risk", + "Acknowledge ClawHub release trust warnings without prompting", + false, + ) .option("--global", "Update skills in the shared managed skills directory", false) .option("--agent ", "Target agent workspace (defaults to cwd-inferred, then default agent)") .action( async ( slug: string | undefined, - opts: { all?: boolean; forceInstall?: boolean; global?: boolean; agent?: string }, + opts: { + all?: boolean; + forceInstall?: boolean; + acknowledgeClawhubRisk?: boolean; + acknowledgeClawHubRisk?: boolean; + global?: boolean; + agent?: string; + }, command: Command, ) => { try { @@ -454,8 +508,13 @@ export function registerSkillsCli(program: Command) { workspaceDir: target.workspaceDir, slug, ...(opts.forceInstall ? { forceInstall: true } : {}), + ...resolveSkillClawHubRiskOptions( + opts.acknowledgeClawhubRisk === true || opts.acknowledgeClawHubRisk === true, + "updating", + ), logger: { info: (message) => defaultRuntime.log(message), + warn: (message) => defaultRuntime.log(formatSkillWarning(message)), }, config: target.config, }); @@ -463,7 +522,9 @@ export function registerSkillsCli(program: Command) { for (const result of results) { if (!result.ok) { failed = true; - defaultRuntime.error(result.error); + if (!isClawHubSkillBlockedCliFailure(result)) { + defaultRuntime.error(result.error); + } continue; } if (result.changed) { diff --git a/src/cli/update-cli.option-collisions.test.ts b/src/cli/update-cli.option-collisions.test.ts index 16a14610d7cf..9e5fb3630390 100644 --- a/src/cli/update-cli.option-collisions.test.ts +++ b/src/cli/update-cli.option-collisions.test.ts @@ -47,6 +47,13 @@ function firstCallOptions(mock: { mock: { calls: unknown[][] } }) { return mock.mock.calls[0]?.[0]; } +type UpdateFinalizeCommandOptions = { + acknowledgeClawHubRisk?: boolean; + json?: boolean; + timeout?: string; + restart?: boolean; +}; + describe("update cli option collisions", () => { beforeEach(() => { updateCommand.mockClear(); @@ -72,20 +79,25 @@ describe("update cli option collisions", () => { }, }, { - name: "forwards parent-captured --json/--timeout to hidden `update finalize`", - argv: ["update", "finalize", "--json", "--timeout", "17"], + name: "forwards parent-captured options to hidden `update finalize`", + argv: [ + "update", + "--acknowledge-clawhub-risk", + "finalize", + "--json", + "--timeout", + "17", + "--no-restart", + ], assert: () => { expect(updateFinalizeCommand).toHaveBeenCalledTimes(1); - const opts = firstCallOptions(updateFinalizeCommand); - expect( - (opts as { json?: boolean; timeout?: string; restart?: boolean } | undefined)?.json, - ).toBe(true); - expect( - (opts as { json?: boolean; timeout?: string; restart?: boolean } | undefined)?.timeout, - ).toBe("17"); - expect( - (opts as { json?: boolean; timeout?: string; restart?: boolean } | undefined)?.restart, - ).toBe(false); + const opts = firstCallOptions(updateFinalizeCommand) as + | UpdateFinalizeCommandOptions + | undefined; + expect(opts?.json).toBe(true); + expect(opts?.timeout).toBe("17"); + expect(opts?.restart).toBe(false); + expect(opts?.acknowledgeClawHubRisk).toBe(true); }, }, { diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 77e472fc840a..e4210bc047c0 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -10,6 +10,7 @@ import { TEST_BUNDLED_RUNTIME_SIDECAR_PATHS } from "../../test/helpers/bundled-r import type { OpenClawConfig, ConfigFileSnapshot } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { GATEWAY_SERVICE_RUNTIME_PID_ENV } from "../daemon/constants.js"; +import type { ClawHubRiskAcknowledgementRequest } from "../infra/clawhub-install-trust.js"; import { writePackageDistInventory } from "../infra/package-dist-inventory.js"; import { isBetaTag } from "../infra/update-channels.js"; import { @@ -19,6 +20,7 @@ import { writeUpdatePostInstallDoctorResult, } from "../infra/update-doctor-result.js"; import type { UpdateRunResult } from "../infra/update-runner.js"; +import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub-error-codes.js"; import { captureEnv, withEnvAsync } from "../test-utils/env.js"; import { VERSION } from "../version.js"; import { createCliRuntimeCapture } from "./test-runtime-capture.js"; @@ -26,9 +28,14 @@ import { isOwningNpmCommand } from "./update-cli.test-helpers.js"; const confirm = vi.fn(); const select = vi.fn(); +const text = vi.fn(); const spinner = vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })); const isCancel = (value: unknown) => value === "cancel"; +type ClawHubRiskHandler = ( + request: ClawHubRiskAcknowledgementRequest, +) => boolean | Promise; + const readPackageName = vi.fn(); const readPackageVersion = vi.fn(); const resolveGlobalManager = vi.fn(); @@ -54,6 +61,7 @@ const loadInstalledPluginIndexInstallRecords = vi.fn( const checkShellCompletionStatus = vi.fn(); const ensureCompletionCacheExists = vi.fn(); const installCompletion = vi.fn(); +const createPreUpdateConfigSnapshotMock = vi.fn(); const legacyConfigRepairMocks = vi.hoisted(() => ({ repairLegacyConfigForUpdateChannel: vi.fn(), })); @@ -79,6 +87,7 @@ const serviceEnvSnapshot = captureEnv([ vi.mock("@clack/prompts", () => ({ confirm, select, + text, isCancel, spinner, })); @@ -228,10 +237,14 @@ vi.mock("../plugins/official-external-install-records.js", () => ({ resolveTrustedSourceLinkedOfficialNpmSpec: vi.fn(() => undefined), })); -vi.mock("../plugins/update.js", () => ({ - syncPluginsForUpdateChannel: (...args: unknown[]) => syncPluginsForUpdateChannel(...args), - updateNpmInstalledPlugins: (...args: unknown[]) => updateNpmInstalledPlugins(...args), -})); +vi.mock("../plugins/update.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + syncPluginsForUpdateChannel: (...args: unknown[]) => syncPluginsForUpdateChannel(...args), + updateNpmInstalledPlugins: (...args: unknown[]) => updateNpmInstalledPlugins(...args), + }; +}); vi.mock("../plugins/installed-plugin-index-records.js", async (importOriginal) => { const actual = @@ -269,6 +282,10 @@ vi.mock("./update-cli/post-core-plugin-convergence.js", () => ({ })), })); +vi.mock("../config/backup-rotation.js", () => ({ + createPreUpdateConfigSnapshot: (...args: unknown[]) => createPreUpdateConfigSnapshotMock(...args), +})); + vi.mock("../daemon/service.js", () => ({ readGatewayServiceState: async () => { const command = await serviceReadCommand(); @@ -366,8 +383,18 @@ const { runCommandWithTimeout } = await import("../process/exec.js"); const { runDaemonRestart, runDaemonInstall } = await import("./daemon-cli.js"); const { doctorCommand } = await import("../commands/doctor.js"); const { defaultRuntime } = await import("../runtime.js"); -const { updateCommand, updateFinalizeCommand, updateStatusCommand, updateWizardCommand } = - await import("./update-cli.js"); +const postCorePluginConvergence = await import("./update-cli/post-core-plugin-convergence.js"); +const runPostCorePluginConvergenceSpy = vi.spyOn( + postCorePluginConvergence, + "runPostCorePluginConvergence", +); +const { + registerUpdateCli, + updateCommand, + updateFinalizeCommand, + updateStatusCommand, + updateWizardCommand, +} = await import("./update-cli.js"); const updateCliShared = await import("./update-cli/shared.js"); const { ensureGitCheckout, resolveGitInstallDir } = updateCliShared; const { spawnSync } = await import("node:child_process"); @@ -508,6 +535,11 @@ describe("update-cli", () => { ([argv]) => argv[2] === "doctor" && argv[3] === "--non-interactive" && argv[4] === "--fix", ); + const doctorCommandCallIndex = () => + commandCalls().findIndex( + ([argv]) => argv[2] === "doctor" && argv[3] === "--non-interactive" && argv[4] === "--fix", + ); + const gatewayCommandCall = (entryPath: string, action: "install" | "restart") => commandCalls().find( ([argv]) => argv[1] === entryPath && argv[2] === "gateway" && argv[3] === action, @@ -529,20 +561,37 @@ describe("update-cli", () => { const syncPluginCall = (index = 0) => { const calls = syncPluginsForUpdateChannel.mock.calls as unknown as Array< - [{ channel?: string; config?: OpenClawConfig }] + [Record & { channel?: string; config?: OpenClawConfig }] >; return calls[index]?.[0]; }; const npmPluginUpdateCall = (index = 0) => { const calls = updateNpmInstalledPlugins.mock.calls as unknown as Array< - [{ config?: OpenClawConfig; timeoutMs?: number }] + [Record & { config?: OpenClawConfig; timeoutMs?: number }] >; return calls[index]?.[0]; }; const lastNpmPluginUpdateCall = () => npmPluginUpdateCall(updateNpmInstalledPlugins.mock.calls.length - 1); + const hasClawHubRiskHandler = ( + call: Record | undefined, + ): call is Record & { onClawHubRisk: ClawHubRiskHandler } => + typeof call?.onClawHubRisk === "function"; + + const getConfirmMessage = (): string => { + const options = confirm.mock.calls[0]?.[0]; + if (!options || typeof options !== "object" || !("message" in options)) { + throw new Error("expected confirm message"); + } + const message = options.message; + if (typeof message !== "string") { + throw new Error("expected confirm message to be a string"); + } + return message; + }; + const replaceConfigCall = (index = 0) => vi.mocked(replaceConfigFile).mock.calls[index]?.[0]; const lastReplaceConfigCall = () => replaceConfigCall(vi.mocked(replaceConfigFile).mock.calls.length - 1); @@ -1248,6 +1297,33 @@ describe("update-cli", () => { expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); }); + it("carries ClawHub risk acknowledgement into post-core resume", async () => { + const { entrypoints } = setupUpdatedRootRefresh({ + gatewayUpdateImpl: async (root) => + makeOkUpdateResult({ + mode: "git", + root, + before: { sha: "old-sha", version: "2026.4.26" }, + after: { sha: "new-sha", version: "2026.4.27" }, + }), + }); + + await updateCommand({ + channel: "dev", + yes: true, + restart: false, + acknowledgeClawHubRisk: true, + }); + + expect(spawnCall()?.[1]).toEqual([ + entrypoints[0], + "update", + "--no-restart", + "--yes", + "--acknowledge-clawhub-risk", + ]); + }); + it("keeps downgrade post-update work in the current process", async () => { const downgradedRoot = createCaseDir("openclaw-downgraded-root"); setupUpdatedRootRefresh({ @@ -1766,6 +1842,181 @@ describe("update-cli", () => { ); }); + it("includes non-blocking ClawHub trust warnings in json post-core plugin output", async () => { + const trustWarning = + "╭─ REVIEW RECOMMENDED - ClawHub has not completed a fresh clean check ─╮\n" + + "│ • Security scan: pending │\n" + + "│ • Status: security scan is pending │\n" + + "╰────────────────────────────────────────────────────────────────────────╯"; + updateNpmInstalledPlugins.mockImplementationOnce( + async (params: { + config: OpenClawConfig; + logger?: { terminalLinks?: boolean; warn?: (message: string) => void }; + }) => { + expect(params.logger?.terminalLinks).toBe(false); + params.logger?.warn?.(trustWarning); + return { + changed: false, + config: params.config, + outcomes: [ + { + pluginId: "demo", + status: "unchanged", + message: "demo is up to date.", + }, + ], + }; + }, + ); + vi.mocked(defaultRuntime.writeJson).mockClear(); + + await updateCommand({ json: true, restart: false }); + + const jsonOutput = lastWriteJsonCall() as UpdateRunResult | undefined; + expect(jsonOutput?.postUpdate?.plugins?.status).toBe("warning"); + expect(pluginWarning(jsonOutput)?.reason).toBe(trustWarning); + expect(pluginWarning(jsonOutput)?.guidance).toEqual([]); + expect(pluginOutcome(jsonOutput)?.status).toBe("unchanged"); + }); + + it("includes colored ClawHub trust warnings in json post-core plugin output", async () => { + const trustWarning = + "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + + "│ • Security scan: suspicious │\n" + + "╰───────────────────────────────────────────────────────────────────────╯"; + const coloredTrustWarning = `\u001b[33m${trustWarning}\u001b[39m`; + updateNpmInstalledPlugins.mockImplementationOnce( + async (params: { + config: OpenClawConfig; + logger?: { terminalLinks?: boolean; warn?: (message: string) => void }; + }) => { + expect(params.logger?.terminalLinks).toBe(false); + params.logger?.warn?.(coloredTrustWarning); + return { + changed: true, + config: params.config, + outcomes: [ + { + pluginId: "demo", + status: "updated", + currentVersion: "1.2.3", + nextVersion: "1.2.4", + message: "Updated demo: 1.2.3 -> 1.2.4.", + }, + ], + }; + }, + ); + vi.mocked(defaultRuntime.writeJson).mockClear(); + + await updateCommand({ json: true, restart: false, acknowledgeClawHubRisk: true }); + + const jsonOutput = lastWriteJsonCall() as UpdateRunResult | undefined; + expect(jsonOutput?.postUpdate?.plugins?.status).toBe("warning"); + expect(pluginWarning(jsonOutput)?.reason).toBe(trustWarning); + expect(pluginWarning(jsonOutput)?.reason).not.toContain("\u001b"); + expect(pluginOutcome(jsonOutput)?.status).toBe("updated"); + }); + + it("includes failed ClawHub sync trust warnings in json post-core plugin output", async () => { + const trustWarning = + "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + + "│ • Security scan: suspicious │\n" + + "│ • Finding: suspicious payload strings │\n" + + "╰───────────────────────────────────────────────────────────────────────╯"; + syncPluginsForUpdateChannel.mockResolvedValueOnce({ + changed: false, + config: baseConfig, + summary: { + switchedToBundled: [], + switchedToNpm: [], + warnings: [trustWarning], + errors: [ + "Failed to update demo: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. (ClawHub clawhub:demo@1.2.4).", + ], + }, + }); + vi.mocked(defaultRuntime.writeJson).mockClear(); + + await updateCommand({ json: true, restart: false }); + + const jsonOutput = lastWriteJsonCall() as UpdateRunResult | undefined; + expect(jsonOutput?.postUpdate?.plugins?.status).toBe("warning"); + expect(jsonOutput?.postUpdate?.plugins?.sync.warnings).toEqual([trustWarning]); + expect(jsonOutput?.postUpdate?.plugins?.sync.errors).toEqual([ + "Failed to update demo: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. (ClawHub clawhub:demo@1.2.4).", + ]); + }); + + it("does not print duplicate failed ClawHub sync trust warnings in human post-core output", async () => { + const trustWarning = + "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + + "│ • Security scan: suspicious │\n" + + "│ • Finding: suspicious payload strings │\n" + + "╰───────────────────────────────────────────────────────────────────────╯"; + syncPluginsForUpdateChannel.mockImplementationOnce( + async (params: { config: OpenClawConfig; logger?: { warn?: (message: string) => void } }) => { + params.logger?.warn?.(trustWarning); + return { + changed: false, + config: params.config, + summary: { + switchedToBundled: [], + switchedToNpm: [], + warnings: [trustWarning], + errors: [ + "Failed to update demo: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. (ClawHub clawhub:demo@1.2.4).", + ], + }, + }; + }, + ); + + await updateCommand({ yes: true, restart: false }); + + const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); + expect(logs.filter((line) => line === trustWarning)).toHaveLength(1); + }); + + it("does not print duplicate ClawHub update trust warnings in human post-core output", async () => { + const trustWarning = + "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + + "│ • Security scan: suspicious │\n" + + "│ • Finding: suspicious payload strings │\n" + + "╰───────────────────────────────────────────────────────────────────────╯"; + updateNpmInstalledPlugins.mockImplementationOnce( + async (params: { config: OpenClawConfig; logger?: { warn?: (message: string) => void } }) => { + params.logger?.warn?.(trustWarning); + return { + changed: false, + config: params.config, + outcomes: [ + { + pluginId: "demo", + status: "skipped", + code: CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED, + warning: trustWarning, + message: + "Skipped demo ClawHub update: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. Existing installed plugin left unchanged.", + }, + ], + }; + }, + ); + + await updateCommand({ yes: true, restart: false }); + + const output = vi + .mocked(defaultRuntime.log) + .mock.calls.map((call) => String(call[0])) + .join("\n"); + const trustWarningOccurrences = output.split(trustWarning).length - 1; + expect(trustWarningOccurrences).toBe(1); + expect(output).toContain("Skipped demo ClawHub update"); + expect(output).toContain("Run openclaw update repair to retry post-update plugin repair."); + expect(output).toContain("Run openclaw plugins inspect demo --runtime --json for details."); + }); + it("detects missing plugin payloads from persisted records before npm updates", async () => { const installPath = createCaseDir("openclaw-missing-plugin-payload"); fsSync.mkdirSync(installPath, { recursive: true }); @@ -1879,6 +2130,103 @@ describe("update-cli", () => { expect(pluginOutcome(jsonOutput)?.status).toBe("skipped"); }); + it("marks unacknowledged ClawHub risk skips as post-update warnings", async () => { + const trustWarning = + "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + + "│ • Security scan: suspicious │\n" + + "│ • Finding: suspicious payload strings │\n" + + "╰───────────────────────────────────────────────────────────────────────╯"; + updateNpmInstalledPlugins.mockResolvedValueOnce({ + changed: false, + config: baseConfig, + outcomes: [ + { + pluginId: "demo", + status: "skipped", + code: CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED, + warning: trustWarning, + message: + "Skipped demo ClawHub update: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. Existing installed plugin left unchanged.", + }, + ], + }); + vi.mocked(defaultRuntime.writeJson).mockClear(); + + await updateCommand({ json: true, restart: false }); + + const jsonOutput = lastWriteJsonCall() as UpdateRunResult | undefined; + expect(jsonOutput?.postUpdate?.plugins?.status).toBe("warning"); + expect(pluginWarning(jsonOutput)?.pluginId).toBe("demo"); + expect(pluginWarning(jsonOutput)?.reason).toContain("Security scan: suspicious"); + expect(pluginWarning(jsonOutput)?.reason).toContain("suspicious payload strings"); + expect(pluginWarning(jsonOutput)?.reason).toContain("--acknowledge-clawhub-risk"); + expect(pluginWarning(jsonOutput)?.guidance).toEqual([ + "Run openclaw update repair to retry post-update plugin repair.", + "Run openclaw plugins inspect demo --runtime --json for details.", + ]); + expect(pluginOutcome(jsonOutput)?.pluginId).toBe("demo"); + expect(pluginOutcome(jsonOutput)?.status).toBe("skipped"); + }); + + it("marks blocked ClawHub update skips as post-update warnings", async () => { + const trustWarning = + "╭─ BLOCKED - ClawHub flagged this release as malicious ─╮\n" + + "│ • Security scan: malicious │\n" + + "╰──────────────────────────────────────────────────────╯"; + updateNpmInstalledPlugins.mockResolvedValueOnce({ + changed: false, + config: baseConfig, + outcomes: [ + { + pluginId: "demo", + status: "skipped", + code: "clawhub_download_blocked", + warning: trustWarning, + message: + "Skipped demo ClawHub update: ClawHub blocked this release; update was not started. Existing installed plugin left unchanged.", + }, + ], + }); + vi.mocked(defaultRuntime.writeJson).mockClear(); + + await updateCommand({ json: true, restart: false }); + + const jsonOutput = lastWriteJsonCall() as UpdateRunResult | undefined; + expect(jsonOutput?.postUpdate?.plugins?.status).toBe("warning"); + expect(pluginWarning(jsonOutput)?.pluginId).toBe("demo"); + expect(pluginWarning(jsonOutput)?.reason).toContain("Security scan: malicious"); + expect(pluginWarning(jsonOutput)?.reason).toContain("ClawHub blocked this release"); + expect(pluginOutcome(jsonOutput)?.pluginId).toBe("demo"); + expect(pluginOutcome(jsonOutput)?.status).toBe("skipped"); + expect(pluginOutcome(jsonOutput)?.message).toContain("Run openclaw update repair"); + }); + + it("prints unacknowledged ClawHub risk skips in human post-update output", async () => { + updateNpmInstalledPlugins.mockResolvedValueOnce({ + changed: false, + config: baseConfig, + outcomes: [ + { + pluginId: "demo", + status: "skipped", + code: CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED, + message: + "Skipped demo ClawHub update: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. Existing installed plugin left unchanged.", + }, + ], + }); + + await updateCommand({ yes: true, restart: false }); + + const logs = vi + .mocked(defaultRuntime.log) + .mock.calls.map((call) => String(call[0])) + .join("\n"); + expect(logs).toContain("--acknowledge-clawhub-risk"); + expect(logs).toContain("Run openclaw update repair to retry post-update plugin repair."); + expect(logs).toContain("Run openclaw plugins inspect demo --runtime --json for details."); + }); + it("fails unexpected post-core plugin sync exceptions", async () => { syncPluginsForUpdateChannel.mockRejectedValueOnce(new Error("plugin sync invariant broke")); @@ -2068,6 +2416,29 @@ describe("update-cli", () => { expect(seenJson).toBe(true); }); + it("parses update --acknowledge-clawhub-risk as the update command option", async () => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + const program = new Command(); + program.name("openclaw"); + program.exitOverride(); + registerUpdateCli(program); + + await program.parseAsync([ + "node", + "openclaw", + "update", + "--channel", + "beta", + "--yes", + "--no-restart", + "--acknowledge-clawhub-risk", + ]); + + expect(syncPluginCall()?.acknowledgeClawHubRisk).toBe(true); + expect(npmPluginUpdateCall()?.acknowledgeClawHubRisk).toBe(true); + }); + it.each([ { name: "defaults to dev channel for git installs when unset", @@ -2738,6 +3109,12 @@ describe("update-cli", () => { expect( (doctorCall?.[1].env as NodeJS.ProcessEnv | undefined)?.OPENCLAW_UPDATE_IN_PROGRESS, ).toBe("1"); + const doctorIndex = doctorCommandCallIndex(); + const snapshotOrder = createPreUpdateConfigSnapshotMock.mock.invocationCallOrder[0]; + const doctorOrder = vi.mocked(runCommandWithTimeout).mock.invocationCallOrder[doctorIndex]; + expect(requireValue(snapshotOrder, "pre-update snapshot call order")).toBeLessThan( + requireValue(doctorOrder, "post-update doctor call order"), + ); }); it("continues package post-core work for explicit post-update doctor advisories", async () => { @@ -5336,6 +5713,214 @@ describe("update-cli", () => { expect(updateCall?.syncOfficialPluginInstalls).toBe(true); }); + it("forwards ClawHub risk acknowledgement to post-update plugin work", async () => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + + await updateCommand({ + channel: "beta", + yes: true, + restart: false, + acknowledgeClawHubRisk: true, + }); + + expect(syncPluginCall()?.acknowledgeClawHubRisk).toBe(true); + expect(npmPluginUpdateCall()?.acknowledgeClawHubRisk).toBe(true); + expect(lastNpmPluginUpdateCall()?.acknowledgeClawHubRisk).toBe(true); + expect(runPostCorePluginConvergenceSpy).toHaveBeenCalledWith( + expect.objectContaining({ acknowledgeClawHubRisk: true }), + ); + }); + + it("does not prompt for ClawHub risk during post-update plugin work when stdout is not interactive", async () => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + setTty(true); + setStdoutTty(false); + + await updateCommand({ + channel: "beta", + restart: false, + }); + + expect(syncPluginCall()?.onClawHubRisk).toBeUndefined(); + expect(npmPluginUpdateCall()?.onClawHubRisk).toBeUndefined(); + expect(lastNpmPluginUpdateCall()?.onClawHubRisk).toBeUndefined(); + }); + + it("does not prompt for ClawHub risk during post-update plugin work when --yes is set", async () => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + setTty(true); + setStdoutTty(true); + + await updateCommand({ + channel: "beta", + yes: true, + restart: false, + }); + + expect(syncPluginCall()?.onClawHubRisk).toBeUndefined(); + expect(npmPluginUpdateCall()?.onClawHubRisk).toBeUndefined(); + expect(lastNpmPluginUpdateCall()?.onClawHubRisk).toBeUndefined(); + }); + + it("does not prompt for ClawHub risk during dry-run post-update plugin work", async () => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + setTty(true); + setStdoutTty(true); + + await updateCommand({ + channel: "beta", + dryRun: true, + restart: false, + }); + + expect(syncPluginCall()?.onClawHubRisk).toBeUndefined(); + expect(npmPluginUpdateCall()?.onClawHubRisk).toBeUndefined(); + expect(lastNpmPluginUpdateCall()?.onClawHubRisk).toBeUndefined(); + }); + + it("sanitizes ClawHub risk prompt labels during post-update plugin work", async () => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + setTty(true); + setStdoutTty(true); + + await updateCommand({ + channel: "beta", + restart: false, + }); + + const syncCall = syncPluginCall(); + expect(hasClawHubRiskHandler(syncCall)).toBe(true); + if (!hasClawHubRiskHandler(syncCall)) { + throw new Error("expected ClawHub risk prompt handler"); + } + + confirm.mockClear(); + confirm.mockResolvedValueOnce(true); + await syncCall.onClawHubRisk({ + packageName: "demo\npkg", + version: "1.2.3\u001b[2K", + trust: { + scanStatus: "suspicious", + moderationState: null, + blockedFromDownload: false, + reasons: ["payload_strings"], + pending: false, + stale: false, + }, + acknowledgementKind: "confirm", + warning: "warning", + }); + + const message = getConfirmMessage(); + expect(message).toContain("Update ClawHub package"); + expect(message).toContain('"demo\\npkg@1.2.3"'); + expect(message).not.toContain("\n"); + expect(message).not.toContain("\u001b"); + }); + + it("prints ClawHub risk warnings before interactive post-update acknowledgement prompts", async () => { + const tempDir = createCaseDir("openclaw-update"); + const warning = + "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + + "│ • Security scan: suspicious │\n" + + "╰───────────────────────────────────────────────────────────────────────╯"; + mockPackageInstallStatus(tempDir); + setTty(true); + setStdoutTty(true); + + await updateCommand({ + channel: "beta", + restart: false, + }); + + const syncCall = syncPluginCall(); + expect(hasClawHubRiskHandler(syncCall)).toBe(true); + if (!hasClawHubRiskHandler(syncCall)) { + throw new Error("expected ClawHub risk prompt handler"); + } + + confirm.mockImplementationOnce(async () => { + const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); + expect(logs.some((line) => line.includes(warning))).toBe(true); + return true; + }); + await syncCall.onClawHubRisk({ + packageName: "demo", + version: "1.2.3", + trust: { + scanStatus: "suspicious", + moderationState: null, + blockedFromDownload: false, + reasons: ["payload_strings"], + pending: false, + stale: false, + }, + acknowledgementKind: "confirm", + warning, + }); + }); + + it("does not duplicate ClawHub risk warnings already printed before prompts", async () => { + const tempDir = createCaseDir("openclaw-update"); + const warning = + "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + + "│ • Security scan: suspicious │\n" + + "╰───────────────────────────────────────────────────────────────────────╯"; + mockPackageInstallStatus(tempDir); + setTty(true); + setStdoutTty(true); + + await updateCommand({ + channel: "beta", + restart: false, + }); + + const syncCall = syncPluginCall(); + expect(hasClawHubRiskHandler(syncCall)).toBe(true); + if (!hasClawHubRiskHandler(syncCall)) { + throw new Error("expected ClawHub risk prompt handler"); + } + const logger = syncCall.logger; + if ( + logger === undefined || + logger === null || + typeof logger !== "object" || + !("warn" in logger) || + typeof logger.warn !== "function" + ) { + throw new Error("expected plugin logger"); + } + + logger.warn(`\u001b[33m${warning}\u001b[39m`); + confirm.mockResolvedValueOnce(true); + await syncCall.onClawHubRisk({ + packageName: "demo", + version: "1.2.3", + trust: { + scanStatus: "suspicious", + moderationState: null, + blockedFromDownload: false, + reasons: ["payload_strings"], + pending: false, + stale: false, + }, + acknowledgementKind: "confirm", + warning, + }); + + const output = vi + .mocked(defaultRuntime.log) + .mock.calls.map((call) => String(call[0])) + .join("\n"); + const occurrences = output.split(warning).length - 1; + expect(occurrences).toBe(1); + }); + it("persists channel and runs post-update work after switching from package to git", async () => { const tempDir = createCaseDir("openclaw-update"); const gitRoot = path.join(tempDir, "..", "openclaw"); @@ -6151,6 +6736,20 @@ describe("update-cli", () => { expect(doctorCall?.[0]).toBe(defaultRuntime); expect(doctorCall?.[1]?.nonInteractive).toBe(true); expect(process.env.OPENCLAW_UPDATE_IN_PROGRESS).toBeUndefined(); + const snapshotOrders = createPreUpdateConfigSnapshotMock.mock.invocationCallOrder; + expect(createPreUpdateConfigSnapshotMock).toHaveBeenCalledTimes(2); + expect(requireValue(snapshotOrders[0], "restart snapshot call order")).toBeLessThan( + requireValue( + vi.mocked(runDaemonRestart).mock.invocationCallOrder[0], + "daemon restart call order", + ), + ); + expect(requireValue(snapshotOrders[1], "doctor snapshot call order")).toBeLessThan( + requireValue( + vi.mocked(doctorCommand).mock.invocationCallOrder[0], + "doctor command call order", + ), + ); const logLines = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); expect( @@ -6193,7 +6792,13 @@ describe("update-cli", () => { }); vi.mocked(defaultRuntime.writeJson).mockClear(); - await updateFinalizeCommand({ json: true, yes: true, timeout: "9", restart: false }); + await updateFinalizeCommand({ + json: true, + yes: true, + timeout: "9", + restart: false, + acknowledgeClawHubRisk: true, + }); expect(doctorEnv?.OPENCLAW_UPDATE_IN_PROGRESS).toBe("1"); expect(doctorEnv?.OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR).toBe("1"); @@ -6207,12 +6812,14 @@ describe("update-cli", () => { yes: true, }); expect(syncPluginCall()?.channel).toBe("stable"); + expect(syncPluginCall()?.acknowledgeClawHubRisk).toBe(true); expect(lastNpmPluginUpdateCall()?.timeoutMs).toBe(9_000); expect( vi .mocked(readConfigFileSnapshot) .mock.calls.some(([options]) => options?.skipPluginValidation === true), ).toBe(true); + expect(lastNpmPluginUpdateCall()?.acknowledgeClawHubRisk).toBe(true); const output = lastWriteJsonCall() as | { status?: string; diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 58d1c90e1a63..fb331c9a6be0 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -38,6 +38,29 @@ function inheritedUpdateTimeout( return inheritOptionFromParent(command, "timeout"); } +type CommanderUpdateOptions = Record & { + acknowledgeClawhubRisk?: boolean; + acknowledgeClawHubRisk?: boolean; + channel?: string; + dryRun?: boolean; + json?: boolean; + restart?: boolean; + tag?: string; + timeout?: string; + yes?: boolean; +}; + +function normalizeCommanderClawHubRiskOption(opts: CommanderUpdateOptions): boolean { + return opts.acknowledgeClawhubRisk === true || opts.acknowledgeClawHubRisk === true; +} + +function inheritedUpdateClawHubRisk(command?: Command): boolean { + return Boolean( + inheritOptionFromParent(command, "acknowledgeClawhubRisk") ?? + inheritOptionFromParent(command, "acknowledgeClawHubRisk"), + ); +} + function registerUpdateFinalizationCommand(update: Command, name: string, hidden: boolean) { const command = update.command(name, { hidden }); command @@ -46,6 +69,11 @@ function registerUpdateFinalizationCommand(update: Command, name: string, hidden .option("--channel ", "Persist update channel before repair") .option("--timeout ", "Timeout for update repair steps in seconds (default: 1800)") .option("--yes", "Skip confirmation prompts (non-interactive)", false) + .option( + "--acknowledge-clawhub-risk", + "Acknowledge ClawHub release trust warnings during post-update plugin sync", + false, + ) .option("--no-restart", "Accepted for update command parity; repair never restarts") .addHelpText( "after", @@ -68,6 +96,8 @@ function registerUpdateFinalizationCommand(update: Command, name: string, hidden timeout: inheritedUpdateTimeout(opts, actionCommand), yes: Boolean(opts.yes), restart: false, + acknowledgeClawHubRisk: + normalizeCommanderClawHubRiskOption(opts) || inheritedUpdateClawHubRisk(actionCommand), }); } catch (err) { defaultRuntime.error(String(err)); @@ -92,6 +122,11 @@ export function registerUpdateCli(program: Command) { ) .option("--timeout ", "Timeout for each update step in seconds (default: 1800)") .option("--yes", "Skip confirmation prompts (non-interactive)", false) + .option( + "--acknowledge-clawhub-risk", + "Acknowledge ClawHub release trust warnings during post-update plugin sync", + false, + ) .addHelpText("after", () => { const examples = [ ["openclaw update", "Update a source checkout (git)"], @@ -104,6 +139,7 @@ export function registerUpdateCli(program: Command) { ["openclaw update --json", "Output result as JSON"], ["openclaw update --yes", "Non-interactive (accept downgrade prompts)"], ["openclaw update repair", "Repair stranded post-update plugin state"], + ["openclaw update --acknowledge-clawhub-risk", "Acknowledge ClawHub plugin trust warnings"], ["openclaw update wizard", "Interactive update wizard"], ["openclaw --update", "Shorthand for openclaw update"], ] as const; @@ -123,6 +159,7 @@ ${theme.heading("Switch channels:")} ${theme.heading("Non-interactive:")} - Use --yes to accept downgrade prompts + - Use --acknowledge-clawhub-risk only after reviewing ClawHub plugin trust warnings - Combine with --channel/--tag/--no-restart/--json/--timeout as needed - Use --dry-run to preview actions without writing config/installing/restarting @@ -137,16 +174,17 @@ ${theme.heading("Notes:")} ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/update")}`; }) - .action(async (opts) => { + .action(async (opts: CommanderUpdateOptions) => { try { await updateCommand({ json: Boolean(opts.json), restart: Boolean(opts.restart), dryRun: Boolean(opts.dryRun), - channel: opts.channel as string | undefined, - tag: opts.tag as string | undefined, - timeout: opts.timeout as string | undefined, + channel: opts.channel, + tag: opts.tag, + timeout: opts.timeout, yes: Boolean(opts.yes), + acknowledgeClawHubRisk: normalizeCommanderClawHubRiskOption(opts), }); } catch (err) { defaultRuntime.error(String(err)); diff --git a/src/cli/update-cli/post-core-plugin-convergence.test.ts b/src/cli/update-cli/post-core-plugin-convergence.test.ts index fd089438c23c..54fe9b401701 100644 --- a/src/cli/update-cli/post-core-plugin-convergence.test.ts +++ b/src/cli/update-cli/post-core-plugin-convergence.test.ts @@ -276,6 +276,29 @@ describe("runPostCorePluginConvergence", () => { expect(result.installRecords).toEqual({ brave: baseline.brave }); }); + it("forwards ClawHub risk acknowledgement options to repair", async () => { + const cfg = { + plugins: { entries: { matrix: { enabled: true } } }, + } as unknown as OpenClawConfig; + const onClawHubRisk = vi.fn(async () => true); + await runPostCorePluginConvergence({ + cfg, + env: {}, + acknowledgeClawHubRisk: true, + onClawHubRisk, + }); + expect(mocks.repairMissingConfiguredPluginInstalls).toHaveBeenCalledTimes(1); + expect(mocks.repairMissingConfiguredPluginInstalls).toHaveBeenCalledWith({ + cfg, + env: { + OPENCLAW_COMPATIBILITY_HOST_VERSION: VERSION, + OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1", + }, + acknowledgeClawHubRisk: true, + onClawHubRisk, + }); + }); + it("keeps repair warnings nonblocking with actionable guidance", async () => { mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({ changes: [], @@ -364,6 +387,39 @@ describe("runPostCorePluginConvergence", () => { }); }); + it("surfaces repair notices without marking convergence errored", async () => { + mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({ + changes: ['Installed missing configured plugin "discord".'], + notices: [ + 'ClawHub trust warning for "@openclaw/discord@1.2.3": ClawHub has not completed a fresh clean security check for this release. Status: security scan is pending. Review the package before enabling it.', + ], + warnings: [], + records: { discord: { source: "clawhub", installPath: "/p/discord" } }, + }); + const result = await runPostCorePluginConvergence({ + cfg: { + plugins: { entries: { discord: { enabled: true } } }, + } as unknown as OpenClawConfig, + env: {}, + }); + expect(result.errored).toBe(false); + expect(result.warnings).toStrictEqual([]); + expect(result.notices).toStrictEqual([ + { + reason: + 'ClawHub trust warning for "@openclaw/discord@1.2.3": ClawHub has not completed a fresh clean security check for this release. Status: security scan is pending. Review the package before enabling it.', + message: + 'ClawHub trust warning for "@openclaw/discord@1.2.3": ClawHub has not completed a fresh clean security check for this release. Status: security scan is pending. Review the package before enabling it.', + guidance: [], + }, + ]); + expect(convergenceWarningsToOutcomes(result)).toStrictEqual({ + warnings: result.notices, + outcomes: [], + errored: false, + }); + }); + it("flags errored=true when smoke check finds a missing main entry", async () => { mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({ changes: [], diff --git a/src/cli/update-cli/post-core-plugin-convergence.ts b/src/cli/update-cli/post-core-plugin-convergence.ts index a81e3725a0d2..e0de0d30e474 100644 --- a/src/cli/update-cli/post-core-plugin-convergence.ts +++ b/src/cli/update-cli/post-core-plugin-convergence.ts @@ -3,6 +3,7 @@ import { repairMissingConfiguredPluginInstalls } from "../../commands/doctor/sha import { UPDATE_POST_CORE_CONVERGENCE_ENV } from "../../commands/doctor/shared/update-phase.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { PluginInstallRecord } from "../../config/types.plugins.js"; +import type { ClawHubRiskAcknowledgementRequest } from "../../infra/clawhub-install-trust.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js"; import { resolveDefaultPluginNpmDir } from "../../plugins/install-paths.js"; import { listManagedPluginNpmRoots } from "../../plugins/npm-project-roots.js"; @@ -27,6 +28,7 @@ export type PostCoreConvergenceWarning = { export type PostCoreConvergenceResult = { changes: string[]; + notices?: PostCoreConvergenceWarning[]; warnings: PostCoreConvergenceWarning[]; errored: boolean; smokeFailures: PluginPayloadSmokeFailure[]; @@ -103,6 +105,8 @@ export async function runPostCorePluginConvergence(params: { * map is what gets persisted and returned via `installRecords`. */ baselineInstallRecords?: Record; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; }): Promise { const env: NodeJS.ProcessEnv = { ...params.env, @@ -120,6 +124,8 @@ export async function runPostCorePluginConvergence(params: { cfg: params.cfg, env, ...(prunedBaseline ? { baselineRecords: prunedBaseline.records } : {}), + ...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), + ...(params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}), }); const warnings: PostCoreConvergenceWarning[] = repair.warnings.map((message) => ({ @@ -129,6 +135,11 @@ export async function runPostCorePluginConvergence(params: { })); const peerLinkRepair = await repairManagedNpmOpenClawPeerLinks({ env }); warnings.push(...peerLinkRepair.warnings); + const notices: PostCoreConvergenceWarning[] = (repair.notices ?? []).map((message) => ({ + reason: message, + message, + guidance: [], + })); const records: Record = repair.records; // Filter the smoke-check input to active records ONLY: configured / @@ -157,6 +168,7 @@ export async function runPostCorePluginConvergence(params: { ...repair.changes, ...peerLinkRepair.changes, ], + notices, warnings, errored: smoke.failures.length > 0, smokeFailures: smoke.failures, @@ -230,7 +242,7 @@ export function convergenceWarningsToOutcomes(convergence: PostCoreConvergenceRe .filter((w): w is PostCoreConvergenceWarning & { pluginId: string } => Boolean(w.pluginId)) .map((w) => ({ pluginId: w.pluginId, status: "error" as const, message: w.message })); return { - warnings: convergence.warnings, + warnings: [...convergence.warnings, ...(convergence.notices ?? [])], outcomes, errored: convergence.errored, }; diff --git a/src/cli/update-cli/shared.ts b/src/cli/update-cli/shared.ts index 7e5886c84c1f..5afdcf379b68 100644 --- a/src/cli/update-cli/shared.ts +++ b/src/cli/update-cli/shared.ts @@ -35,6 +35,7 @@ export type UpdateCommandOptions = { tag?: string; timeout?: string; yes?: boolean; + acknowledgeClawHubRisk?: boolean; }; export type UpdateStatusOptions = { @@ -48,6 +49,7 @@ export type UpdateFinalizeOptions = { timeout?: string; yes?: boolean; restart?: boolean; + acknowledgeClawHubRisk?: boolean; }; export type UpdateWizardOptions = { diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index dd550bd191a4..58d33b3ac848 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -5,10 +5,12 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { Writable } from "node:stream"; -import { confirm, isCancel } from "@clack/prompts"; +import { confirm, isCancel, text } from "@clack/prompts"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { stripAnsi } from "../../../packages/terminal-core/src/ansi.js"; import { stylePromptMessage } from "../../../packages/terminal-core/src/prompt-style.js"; +import { sanitizeTerminalText } from "../../../packages/terminal-core/src/safe-text.js"; import { theme } from "../../../packages/terminal-core/src/theme.js"; import { checkShellCompletionStatus, @@ -49,6 +51,7 @@ import { resolveGatewayService, type GatewayService, } from "../../daemon/service.js"; +import type { ClawHubRiskAcknowledgementRequest } from "../../infra/clawhub-install-trust.js"; import { createLowDiskSpaceWarning } from "../../infra/disk-space.js"; import { pathExists } from "../../infra/fs-safe.js"; import { readJsonIfExists, writeJson } from "../../infra/json-files.js"; @@ -113,6 +116,7 @@ import { resolveTrustedSourceLinkedOfficialNpmSpec, } from "../../plugins/official-external-install-records.js"; import { + isClawHubTrustSkippedOutcome, syncPluginsForUpdateChannel, updateNpmInstalledPlugins, type PluginUpdateIntegrityDriftParams, @@ -234,6 +238,66 @@ type MissingPluginInstallPayload = { type PostUpdatePluginWarning = NonNullable[number]; +function isClawHubTrustNotice(message: string): boolean { + const trimmed = stripAnsi(message).trimStart(); + return ( + trimmed.startsWith("ClawHub trust warning ") || + trimmed.startsWith("╭─ REVIEW RECOMMENDED - ClawHub ") || + trimmed.startsWith("╭─ WARNING - ClawHub found security risks ") || + trimmed.startsWith("╭─ BLOCKED - ClawHub ") + ); +} + +function isNonBlockingClawHubTrustNotice(message: string): boolean { + const trimmed = stripAnsi(message).trimStart(); + return ( + trimmed.startsWith("ClawHub trust warning ") || + trimmed.startsWith("╭─ REVIEW RECOMMENDED - ClawHub ") + ); +} + +function formatPluginUpdateWarning(message: string): string { + return message.includes("╭─") ? message : theme.warn(message); +} + +function resolveUpdateClawHubRiskAcknowledgementOptions( + opts: UpdateCommandOptions, + params: { + renderWarningBeforePrompt?: (warning: string) => void; + } = {}, +): { + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => Promise; +} { + if (opts.acknowledgeClawHubRisk) { + return { acknowledgeClawHubRisk: true }; + } + if (opts.dryRun || opts.yes || opts.json || !process.stdin.isTTY || !process.stdout.isTTY) { + return {}; + } + return { + onClawHubRisk: async (request) => { + params.renderWarningBeforePrompt?.(request.warning); + const packageName = sanitizeTerminalText(request.packageName); + const releaseLabel = `${packageName}@${sanitizeTerminalText(request.version)}`; + if (request.acknowledgementKind === "type-package") { + const answer = await text({ + message: stylePromptMessage(`type: '${packageName}' to update anyway`), + placeholder: packageName, + }); + return !isCancel(answer) && answer.trim() === packageName; + } + const ok = await confirm({ + message: stylePromptMessage( + `Update ClawHub package "${releaseLabel}" after reviewing the warning above?`, + ), + initialValue: false, + }); + return !isCancel(ok) && ok; + }, + }; +} + function pickUpdateQuip(): string { return UPDATE_QUIPS[Math.floor(Math.random() * UPDATE_QUIPS.length)] ?? "Update complete."; } @@ -551,16 +615,24 @@ function createPostUpdatePluginWarning(params: { }; } -function createGuidedPostUpdatePluginOutcome(outcome: PluginUpdateOutcome): { +function createGuidedPostUpdatePluginOutcome( + outcome: PluginUpdateOutcome, + options: { includeWarningInReason?: boolean } = {}, +): { outcome: PluginUpdateOutcome; warning?: PostUpdatePluginWarning; } { - if (outcome.status !== "error" && !isDisabledAfterFailureOutcome(outcome)) { + if (outcome.status !== "error" && !isActionableSkippedPostUpdateOutcome(outcome)) { return { outcome }; } + const includeWarningInReason = options.includeWarningInReason ?? true; + const warningReason = + outcome.warning && includeWarningInReason + ? `${outcome.warning}\n${outcome.message}` + : outcome.message; const warning = createPostUpdatePluginWarning({ ...(outcome.pluginId && outcome.pluginId !== "unknown" ? { pluginId: outcome.pluginId } : {}), - reason: outcome.message, + reason: warningReason, }); return { outcome: { @@ -589,6 +661,10 @@ function isDisabledAfterFailureOutcome(outcome: PluginUpdateOutcome): boolean { return outcome.status === "skipped" && outcome.message.includes("after plugin update failure"); } +function isActionableSkippedPostUpdateOutcome(outcome: PluginUpdateOutcome): boolean { + return isDisabledAfterFailureOutcome(outcome) || isClawHubTrustSkippedOutcome(outcome); +} + /** * Build the post-core-update result we return when the active config cannot * even be parsed. Mandatory post-core convergence requires a parseable @@ -1767,13 +1843,41 @@ export async function updatePluginsAfterCoreUpdate(params: { return invalid.result; } - const pluginLogger = params.opts.json - ? {} - : { - info: (msg: string) => defaultRuntime.log(msg), - warn: (msg: string) => defaultRuntime.log(theme.warn(msg)), - error: (msg: string) => defaultRuntime.log(theme.error(msg)), - }; + const clawHubTrustNotices = new Set(); + const loggedPluginWarnings = new Set(); + const hasLoggedPluginWarning = (message: string): boolean => + loggedPluginWarnings.has(stripAnsi(message)); + const recordLoggedPluginWarning = (message: string): void => { + loggedPluginWarnings.add(stripAnsi(message)); + }; + const recordClawHubTrustNotice = (message: string): void => { + const shouldRecord = params.opts.json + ? isClawHubTrustNotice(message) + : isNonBlockingClawHubTrustNotice(message); + if (shouldRecord) { + clawHubTrustNotices.add(stripAnsi(message)); + } + }; + const pluginLogger = { + ...(params.opts.json ? { terminalLinks: false } : {}), + info: (msg: string) => { + if (!params.opts.json) { + defaultRuntime.log(msg); + } + }, + warn: (msg: string) => { + recordLoggedPluginWarning(msg); + recordClawHubTrustNotice(msg); + if (!params.opts.json) { + defaultRuntime.log(formatPluginUpdateWarning(msg)); + } + }, + error: (msg: string) => { + if (!params.opts.json) { + defaultRuntime.log(theme.error(msg)); + } + }, + }; if (!params.opts.json) { defaultRuntime.log(""); @@ -1781,6 +1885,21 @@ export async function updatePluginsAfterCoreUpdate(params: { } const warnings: PostUpdatePluginWarning[] = []; + const clawHubRiskAcknowledgementOptions = resolveUpdateClawHubRiskAcknowledgementOptions( + params.opts, + { + renderWarningBeforePrompt: (warning) => { + if (hasLoggedPluginWarning(warning)) { + return; + } + recordLoggedPluginWarning(warning); + recordClawHubTrustNotice(warning); + if (!params.opts.json) { + defaultRuntime.log(formatPluginUpdateWarning(warning)); + } + }, + }, + ); const pluginInstallRecords = params.pluginInstallRecords ?? (await loadInstalledPluginIndexInstallRecords()); const syncConfig = withPluginInstallRecords( @@ -1794,6 +1913,7 @@ export async function updatePluginsAfterCoreUpdate(params: { externalizedBundledPluginBridges: await listPersistedBundledPluginLocationBridges({ workspaceDir: params.root, }), + ...clawHubRiskAcknowledgementOptions, logger: pluginLogger, }); for (const error of syncResult.summary.errors) { @@ -1867,6 +1987,7 @@ export async function updatePluginsAfterCoreUpdate(params: { disableOnFailure: true, logger: pluginLogger, onIntegrityDrift: onPluginIntegrityDrift, + ...clawHubRiskAcknowledgementOptions, }); pluginConfig = repairResult.config; pluginsChanged ||= repairResult.changed; @@ -1875,24 +1996,27 @@ export async function updatePluginsAfterCoreUpdate(params: { return missingIds; }; - const missingPayloadIds = await collectMissingPayloadWarnings(pluginInstallRecords); + const missingPayloadIdSet = new Set(await collectMissingPayloadWarnings(pluginInstallRecords)); const npmResult = await updateNpmInstalledPlugins({ config: pluginConfig, timeoutMs: params.timeoutMs, updateChannel: params.channel, - skipIds: new Set([...syncResult.summary.switchedToNpm, ...missingPayloadIds]), + skipIds: new Set([...syncResult.summary.switchedToNpm, ...missingPayloadIdSet]), skipDisabledPlugins: true, syncOfficialPluginInstalls: true, disableOnFailure: true, logger: pluginLogger, onIntegrityDrift: onPluginIntegrityDrift, + ...clawHubRiskAcknowledgementOptions, }); pluginConfig = npmResult.config; pluginsChanged ||= npmResult.changed; npmPluginsChanged ||= npmResult.changed; for (const rawOutcome of npmResult.outcomes) { - const guided = createGuidedPostUpdatePluginOutcome(rawOutcome); + const includeWarningInReason = + params.opts.json || !rawOutcome.warning || !hasLoggedPluginWarning(rawOutcome.warning); + const guided = createGuidedPostUpdatePluginOutcome(rawOutcome, { includeWarningInReason }); pluginUpdateOutcomes.push(guided.outcome); if (guided.warning) { warnings.push(guided.warning); @@ -1907,7 +2031,7 @@ export async function updatePluginsAfterCoreUpdate(params: { }); pluginUpdateOutcomes.push( ...remainingMissingPayloads - .filter((entry) => !missingPayloadIds.includes(entry.pluginId)) + .filter((entry) => !missingPayloadIdSet.has(entry.pluginId)) .map((entry): PluginUpdateOutcome => { const warning = createPostUpdatePluginWarning({ pluginId: entry.pluginId, @@ -1938,6 +2062,7 @@ export async function updatePluginsAfterCoreUpdate(params: { cfg: pluginConfig, env: process.env, baselineInstallRecords: convergenceBaselineRecords, + ...clawHubRiskAcknowledgementOptions, }); for (const change of convergence.changes) { if (!params.opts.json) { @@ -1992,6 +2117,17 @@ export async function updatePluginsAfterCoreUpdate(params: { }); } + for (const notice of clawHubTrustNotices) { + if (warnings.some((warning) => warning.reason.includes(notice))) { + continue; + } + warnings.push({ + reason: notice, + message: notice, + guidance: [], + }); + } + if (params.opts.json) { return { status: convergenceErrored ? "error" : warnings.length > 0 ? "warning" : "ok", @@ -2032,7 +2168,9 @@ export async function updatePluginsAfterCoreUpdate(params: { ); } for (const warning of syncResult.summary.warnings) { - defaultRuntime.log(theme.warn(warning)); + if (!hasLoggedPluginWarning(warning)) { + defaultRuntime.log(formatPluginUpdateWarning(warning)); + } } for (const error of syncResult.summary.errors) { defaultRuntime.log(theme.warn(createPostUpdatePluginWarning({ reason: error }).message)); @@ -2061,7 +2199,7 @@ export async function updatePluginsAfterCoreUpdate(params: { } for (const outcome of pluginUpdateOutcomes) { - if (outcome.status !== "error") { + if (outcome.status !== "error" && !isActionableSkippedPostUpdateOutcome(outcome)) { continue; } defaultRuntime.log(theme.warn(outcome.message)); @@ -2543,6 +2681,7 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis timeout: opts.timeout, yes: opts.yes, restart: false, + acknowledgeClawHubRisk: opts.acknowledgeClawHubRisk, }, timeoutMs: timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS, pluginInstallRecords, @@ -2968,6 +3107,9 @@ async function continuePostCoreUpdateInFreshProcess(params: { if (params.opts.yes) { argv.push("--yes"); } + if (params.opts.acknowledgeClawHubRisk) { + argv.push("--acknowledge-clawhub-risk"); + } if (params.opts.timeout) { argv.push("--timeout", params.opts.timeout); } diff --git a/src/commands/codex-runtime-plugin-install.test.ts b/src/commands/codex-runtime-plugin-install.test.ts new file mode 100644 index 000000000000..8514b03c5df5 --- /dev/null +++ b/src/commands/codex-runtime-plugin-install.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + repairMissingPluginInstallsForIds: vi.fn(), +})); + +type MissingPluginInstallRepairCall = { + pluginIds: string[]; + env?: NodeJS.ProcessEnv; +}; + +function readOnlyMissingPluginInstallRepairCall(): MissingPluginInstallRepairCall { + expect(mocks.repairMissingPluginInstallsForIds).toHaveBeenCalledOnce(); + const calls = mocks.repairMissingPluginInstallsForIds.mock.calls as unknown as Array< + [MissingPluginInstallRepairCall] + >; + const call = calls[0]?.[0]; + if (!call) { + throw new Error("Expected missing plugin install repair call"); + } + return call; +} + +vi.mock("./doctor/shared/missing-configured-plugin-install.js", () => ({ + repairMissingPluginInstallsForIds: mocks.repairMissingPluginInstallsForIds, +})); + +describe("Codex runtime plugin install repair", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.repairMissingPluginInstallsForIds.mockResolvedValue({ + changes: [], + warnings: [], + }); + }); + + it("surfaces non-fatal ClawHub repair notices to warning-only callers", async () => { + const reviewNotice = "REVIEW RECOMMENDED - ClawHub has not completed a fresh clean check"; + mocks.repairMissingPluginInstallsForIds.mockResolvedValue({ + changes: ['Repaired missing configured plugin "codex".'], + warnings: [], + notices: [reviewNotice], + }); + + const { repairCodexRuntimePluginInstallForModelSelection } = + await import("./codex-runtime-plugin-install.js"); + const result = await repairCodexRuntimePluginInstallForModelSelection({ + cfg: {}, + model: "openai/gpt-5.5", + env: {}, + }); + + const repairCall = readOnlyMissingPluginInstallRepairCall(); + expect(repairCall.pluginIds).toStrictEqual(["codex"]); + expect(repairCall.env).toStrictEqual({}); + expect(result).toStrictEqual({ + required: true, + changes: ['Repaired missing configured plugin "codex".'], + warnings: [reviewNotice], + }); + }); +}); diff --git a/src/commands/doctor-config-audit-scrub.test.ts b/src/commands/doctor-config-audit-scrub.test.ts new file mode 100644 index 000000000000..36d9a7e509c1 --- /dev/null +++ b/src/commands/doctor-config-audit-scrub.test.ts @@ -0,0 +1,71 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + configAuditScrubToHealthFinding, + configAuditScrubToRepairEffect, + detectConfigAuditScrubIssue, +} from "./doctor-config-audit-scrub.js"; + +let tempRoot: string | null = null; + +async function makeHome(): Promise { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-doctor-config-audit-")); + return tempRoot; +} + +afterEach(async () => { + if (tempRoot !== null) { + await fs.rm(tempRoot, { recursive: true, force: true }); + tempRoot = null; + } +}); + +describe("detectConfigAuditScrubIssue", () => { + it("detects config-audit scrub work without rewriting the log", async () => { + const home = await makeHome(); + const auditPath = path.join(home, ".openclaw", "logs", "config-audit.jsonl"); + await fs.mkdir(path.dirname(auditPath), { recursive: true, mode: 0o700 }); + const record = { + ts: "2026-05-02T00:03:48.471Z", + argv: ["node", "openclaw.mjs", "config", "set", "x", "xoxb-bad-token-1234567890abcdef"], + execArgv: [], + }; + await fs.writeFile(auditPath, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 }); + + const result = await detectConfigAuditScrubIssue({ + env: {} as NodeJS.ProcessEnv, + homedir: () => home, + }); + + expect(result).toEqual({ + scanned: 1, + rewritten: 1, + skipped: 0, + aborted: false, + auditPath, + }); + expect(await fs.readFile(auditPath, "utf8")).toBe(`${JSON.stringify(record)}\n`); + }); + + it("maps scrub work to structured findings and dry-run effects", async () => { + const home = await makeHome(); + const auditPath = path.join(home, ".openclaw", "logs", "config-audit.jsonl"); + const result = { scanned: 2, rewritten: 1, skipped: 0, aborted: false, auditPath }; + + expect(configAuditScrubToHealthFinding(result)).toEqual( + expect.objectContaining({ + checkId: "core/doctor/config-audit-scrub", + severity: "warning", + path: auditPath, + }), + ); + expect(configAuditScrubToRepairEffect(result)).toEqual({ + kind: "file", + action: "would-scrub-config-audit-log", + target: auditPath, + dryRunSafe: false, + }); + }); +}); diff --git a/src/commands/doctor-config-audit-scrub.ts b/src/commands/doctor-config-audit-scrub.ts index 0de3eec887a5..915820e1432d 100644 --- a/src/commands/doctor-config-audit-scrub.ts +++ b/src/commands/doctor-config-audit-scrub.ts @@ -2,14 +2,62 @@ import fs from "node:fs/promises"; import os from "node:os"; import { note } from "../../packages/terminal-core/src/note.js"; -import { scrubConfigAuditLog } from "../config/io.audit.js"; +import { + resolveConfigAuditLogPath, + scrubConfigAuditLog, + type ConfigAuditScrubResult, +} from "../config/io.audit.js"; +import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js"; const NOTE_TITLE = "Config audit"; +const CONFIG_AUDIT_SCRUB_CHECK_ID = "core/doctor/config-audit-scrub"; function formatEntryCount(count: number): string { return `${count} ${count === 1 ? "entry" : "entries"}`; } +export async function detectConfigAuditScrubIssue(params?: { + env?: NodeJS.ProcessEnv; + homedir?: () => string; +}): Promise { + const env = params?.env ?? process.env; + const homedir = params?.homedir ?? os.homedir; + const result = await scrubConfigAuditLog({ + fs: { promises: fs }, + env, + homedir, + dryRun: true, + }); + return { + ...result, + auditPath: resolveConfigAuditLogPath(env, homedir), + }; +} + +export function configAuditScrubToHealthFinding( + result: ConfigAuditScrubResult & { auditPath: string }, +): HealthFinding { + return { + checkId: CONFIG_AUDIT_SCRUB_CHECK_ID, + severity: "warning", + message: `${formatEntryCount(result.rewritten)} in config-audit.jsonl still contain pre-redactor argv values.`, + path: result.auditPath, + fixHint: + "Run `openclaw doctor --fix` to rewrite argv/execArgv fields through the current redactor.", + }; +} + +export function configAuditScrubToRepairEffect( + result: ConfigAuditScrubResult & { auditPath: string }, +): HealthRepairEffect { + return { + kind: "file", + action: "would-scrub-config-audit-log", + target: result.auditPath, + dryRunSafe: false, + }; +} + /** * Scrubs pre-redactor config audit records or previews the number of affected entries. * diff --git a/src/commands/doctor-lint.test.ts b/src/commands/doctor-lint.test.ts index d41678fc5b1e..1b0bfa78ec1a 100644 --- a/src/commands/doctor-lint.test.ts +++ b/src/commands/doctor-lint.test.ts @@ -155,7 +155,7 @@ describe("runDoctorLintCli", () => { try { const exitCode = await runDoctorLintCli(runtime, { json: true, - onlyIds: ["core/doctor/session-locks"], + onlyIds: ["core/doctor/not-a-check"], }); expect(exitCode).toBe(1); @@ -167,7 +167,7 @@ describe("runDoctorLintCli", () => { { checkId: "core/doctor/lint-selection", severity: "error", - path: "core/doctor/session-locks", + path: "core/doctor/not-a-check", }, ], }); diff --git a/src/commands/doctor-lint.ts b/src/commands/doctor-lint.ts index b3de814da928..d11c0a91540d 100644 --- a/src/commands/doctor-lint.ts +++ b/src/commands/doctor-lint.ts @@ -26,6 +26,7 @@ interface DoctorLintCliOptions { readonly onlyIds?: readonly string[]; readonly allowExec?: boolean; readonly deep?: boolean; + readonly includeAllChecks?: boolean; } function detectMode(opts: DoctorLintCliOptions): "human" | "json" { @@ -86,6 +87,7 @@ export async function runDoctorLintCli( const runOpts: DoctorLintRunOptions = { checks: [...coreChecks.map((check) => withCoreLintContext(check, coreCtx)), ...extensionChecks], + includeAllChecks: opts.includeAllChecks === true, ...(opts.skipIds && opts.skipIds.length > 0 ? { skipIds: opts.skipIds } : {}), ...(opts.onlyIds && opts.onlyIds.length > 0 ? { onlyIds: opts.onlyIds } : {}), }; diff --git a/src/commands/doctor-session-locks.test.ts b/src/commands/doctor-session-locks.test.ts index eee190511888..5edbdd3826c8 100644 --- a/src/commands/doctor-session-locks.test.ts +++ b/src/commands/doctor-session-locks.test.ts @@ -13,7 +13,12 @@ vi.mock("../../packages/terminal-core/src/note.js", () => ({ note, })); -import { noteSessionLockHealth } from "./doctor-session-locks.js"; +import { + detectStaleSessionLocks, + noteSessionLockHealth, + sessionLockToHealthFinding, + sessionLockToRepairEffect, +} from "./doctor-session-locks.js"; async function expectPathMissing(targetPath: string): Promise { try { @@ -105,6 +110,154 @@ describe("noteSessionLockHealth", () => { await expect(fs.access(freshLock)).resolves.toBeUndefined(); }); + it("detects stale locks without removing them for structured lint", async () => { + const sessionsDir = state.sessionsDir(); + await fs.mkdir(sessionsDir, { recursive: true }); + + const staleLock = path.join(sessionsDir, "stale.jsonl.lock"); + const freshLock = path.join(sessionsDir, "fresh.jsonl.lock"); + + await fs.writeFile( + staleLock, + JSON.stringify({ pid: -1, createdAt: new Date(Date.now() - 120_000).toISOString() }), + "utf8", + ); + await fs.writeFile( + freshLock, + JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }), + "utf8", + ); + + const locks = await detectStaleSessionLocks({ + staleMs: 30_000, + readOwnerProcessArgs: () => ["node", "/opt/openclaw/openclaw.mjs", "doctor"], + }); + + expect(locks).toHaveLength(1); + expect(locks[0]?.lockPath).toBe(staleLock); + await expect(fs.access(staleLock)).resolves.toBeUndefined(); + await expect(fs.access(freshLock)).resolves.toBeUndefined(); + }); + + it("maps stale locks to structured findings and dry-run effects", async () => { + const sessionsDir = state.sessionsDir(); + await fs.mkdir(sessionsDir, { recursive: true }); + const lockPath = path.join(sessionsDir, "stale.jsonl.lock"); + await fs.writeFile( + lockPath, + JSON.stringify({ pid: -1, createdAt: new Date(Date.now() - 120_000).toISOString() }), + "utf8", + ); + + const [lock] = await detectStaleSessionLocks({ + staleMs: 30_000, + readOwnerProcessArgs: () => ["node", "/opt/openclaw/openclaw.mjs", "doctor"], + }); + if (!lock) { + throw new Error("expected stale session lock"); + } + + expect(sessionLockToHealthFinding(lock)).toEqual( + expect.objectContaining({ + checkId: "core/doctor/session-locks", + severity: "warning", + path: lockPath, + }), + ); + expect(sessionLockToRepairEffect(lock)).toEqual({ + kind: "state", + action: "would-remove-stale-session-lock", + target: lockPath, + dryRunSafe: false, + }); + }); + + it("preserves fresh malformed stale locks in dry-run repair effects", async () => { + const sessionsDir = state.sessionsDir(); + await fs.mkdir(sessionsDir, { recursive: true }); + + const malformedLock = path.join(sessionsDir, "malformed.jsonl.lock"); + await fs.writeFile(malformedLock, "{}", "utf8"); + + const [lock] = await detectStaleSessionLocks({ + staleMs: 30_000, + readOwnerProcessArgs: () => ["node", "/opt/openclaw/openclaw.mjs", "doctor"], + }); + if (!lock) { + throw new Error("expected stale session lock"); + } + + expect(lock.staleReasons).toEqual(["missing-pid", "invalid-createdAt"]); + expect(lock.removable).toBe(false); + expect(sessionLockToHealthFinding(lock).fixHint).toContain("after the cleanup grace period"); + expect(sessionLockToRepairEffect(lock)).toEqual({ + kind: "state", + action: "would-preserve-mtime-gated-stale-session-lock", + target: malformedLock, + dryRunSafe: false, + }); + await expect(fs.access(malformedLock)).resolves.toBeUndefined(); + }); + + it("uses the supplied env to choose the structured lint state dir", async () => { + const other = await createOpenClawTestState({ + layout: "state-only", + prefix: "openclaw-doctor-locks-other-", + applyEnv: false, + }); + try { + await fs.mkdir(other.sessionsDir(), { recursive: true }); + const lockPath = path.join(other.sessionsDir(), "other-stale.jsonl.lock"); + await fs.writeFile( + lockPath, + JSON.stringify({ pid: -1, createdAt: new Date(Date.now() - 120_000).toISOString() }), + "utf8", + ); + + const locks = await detectStaleSessionLocks({ + env: other.env, + staleMs: 30_000, + readOwnerProcessArgs: () => ["node", "/opt/openclaw/openclaw.mjs", "doctor"], + }); + + expect(locks.map((lock) => lock.lockPath)).toEqual([lockPath]); + } finally { + await other.cleanup(); + } + }); + + it("preserves report-only live OpenClaw locks in dry-run repair effects", async () => { + const sessionsDir = state.sessionsDir(); + await fs.mkdir(sessionsDir, { recursive: true }); + + const reportOnlyLock = path.join(sessionsDir, "report-only.jsonl.lock"); + await fs.writeFile( + reportOnlyLock, + JSON.stringify({ pid: process.pid, createdAt: new Date(Date.now() - 45_000).toISOString() }), + "utf8", + ); + + const [lock] = await detectStaleSessionLocks({ + staleMs: 30_000, + readOwnerProcessArgs: () => ["node", "/opt/openclaw/openclaw.mjs", "doctor"], + }); + if (!lock) { + throw new Error("expected stale session lock"); + } + + expect(lock.staleReasons).toEqual(["too-old"]); + expect(sessionLockToHealthFinding(lock).fixHint).toBe( + "OpenClaw is preserving this live owned lock; inspect the owning process if it appears stuck.", + ); + expect(sessionLockToRepairEffect(lock)).toEqual({ + kind: "state", + action: "would-preserve-report-only-stale-session-lock", + target: reportOnlyLock, + dryRunSafe: false, + }); + await expect(fs.access(reportOnlyLock)).resolves.toBeUndefined(); + }); + it("uses configured stale threshold without removing live OpenClaw lock files", async () => { const sessionsDir = state.sessionsDir(); await fs.mkdir(sessionsDir, { recursive: true }); diff --git a/src/commands/doctor-session-locks.ts b/src/commands/doctor-session-locks.ts index 29aaa5b75ebc..2a392407db8f 100644 --- a/src/commands/doctor-session-locks.ts +++ b/src/commands/doctor-session-locks.ts @@ -9,8 +9,19 @@ import { type SessionWriteLockAcquireTimeoutConfig, } from "../agents/session-write-lock.js"; import { resolveStateDir } from "../config/paths.js"; +import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js"; import { shortenHomePath } from "../utils.js"; +const SESSION_LOCKS_CHECK_ID = "core/doctor/session-locks"; +const REPORT_ONLY_STALE_LOCK_REASONS = new Set(["too-old", "hold-exceeded"]); + +function isReportOnlyStaleLock(lock: SessionLockInspection): boolean { + return ( + lock.staleReasons.length > 0 && + lock.staleReasons.every((reason) => REPORT_ONLY_STALE_LOCK_REASONS.has(reason)) + ); +} + function formatAge(ageMs: number | null): string { if (ageMs === null) { return "unknown"; @@ -40,6 +51,57 @@ function formatLockLine(lock: SessionLockInspection): string { return `- ${shortenHomePath(lock.lockPath)} ${pidStatus} ${ageStatus} ${staleStatus}${removedStatus}`; } +export async function detectStaleSessionLocks(params?: { + config?: SessionWriteLockAcquireTimeoutConfig; + env?: NodeJS.ProcessEnv; + staleMs?: number; + readOwnerProcessArgs?: SessionLockOwnerProcessArgsReader; +}): Promise { + const staleMs = params?.staleMs ?? resolveSessionWriteLockStaleMs(params?.config, params?.env); + const env = params?.env ?? process.env; + const sessionDirs = await resolveAgentSessionDirs(resolveStateDir(env)); + const staleLocks: SessionLockInspection[] = []; + for (const sessionsDir of sessionDirs) { + const result = await cleanStaleLockFiles({ + sessionsDir, + staleMs, + removeStale: false, + readOwnerProcessArgs: params?.readOwnerProcessArgs, + }); + staleLocks.push(...result.locks.filter((lock) => lock.stale)); + } + return staleLocks.toSorted((a, b) => a.lockPath.localeCompare(b.lockPath)); +} + +export function sessionLockToHealthFinding(lock: SessionLockInspection): HealthFinding { + const fixHint = lock.removable + ? 'Run "openclaw doctor --fix" to remove this stale lock file automatically.' + : isReportOnlyStaleLock(lock) + ? "OpenClaw is preserving this live owned lock; inspect the owning process if it appears stuck." + : 'Run "openclaw doctor --fix" after the cleanup grace period if this stale lock remains.'; + return { + checkId: SESSION_LOCKS_CHECK_ID, + severity: "warning", + message: `Stale session lock file: ${shortenHomePath(lock.lockPath)} (${lock.staleReasons.join(", ") || "unknown"})`, + path: lock.lockPath, + fixHint, + }; +} + +export function sessionLockToRepairEffect(lock: SessionLockInspection): HealthRepairEffect { + const action = lock.removable + ? "would-remove-stale-session-lock" + : isReportOnlyStaleLock(lock) + ? "would-preserve-report-only-stale-session-lock" + : "would-preserve-mtime-gated-stale-session-lock"; + return { + kind: "state", + action, + target: lock.lockPath, + dryRunSafe: false, + }; +} + /** Reports session write locks and removes stale locks when doctor repair is enabled. */ export async function noteSessionLockHealth(params?: { shouldRepair?: boolean; diff --git a/src/commands/doctor-session-snapshots.test.ts b/src/commands/doctor-session-snapshots.test.ts index 957db75fa235..641f83e426d1 100644 --- a/src/commands/doctor-session-snapshots.test.ts +++ b/src/commands/doctor-session-snapshots.test.ts @@ -15,8 +15,11 @@ vi.mock("../../packages/terminal-core/src/note.js", () => ({ })); import { + detectSessionSnapshotHealthIssues, noteSessionSnapshotHealth, scanSessionStoreForStaleRuntimeSnapshotPaths, + sessionSnapshotIssueToHealthFinding, + sessionSnapshotIssueToRepairEffect, } from "./doctor-session-snapshots.js"; function sessionEntry(patch: Partial): SessionEntry { @@ -66,6 +69,23 @@ async function writeSessionStore( await fs.writeFile(storePath, JSON.stringify(store, null, 2)); } +function readMainSessionEntry(raw: string): SessionEntry { + const parsed = JSON.parse(raw) as Record; + const entry = parsed["agent:main"]; + if (!entry) { + throw new Error("expected agent:main session entry"); + } + return entry; +} + +function readMainSkillsSnapshot(raw: string): NonNullable { + const snapshot = readMainSessionEntry(raw).skillsSnapshot; + if (!snapshot) { + throw new Error("expected agent:main skills snapshot"); + } + return snapshot; +} + describe("doctor session snapshot stale runtime metadata", () => { let root = ""; let bundledSkillsDir = ""; @@ -135,6 +155,57 @@ describe("doctor session snapshot stale runtime metadata", () => { ]); }); + it("maps stale snapshot paths to structured findings and dry-run effects", async () => { + const stalePath = path.join( + root, + "old-runtime", + "node_modules", + "openclaw", + "skills", + "doctor", + "SKILL.md", + ); + const storePath = path.join(root, "state", "agents", "main", "sessions", "sessions.json"); + await writeSessionStore(storePath, { + "agent:main": sessionEntry({ + skillsSnapshot: { + prompt: skillPrompt(stalePath), + skills: [{ name: "doctor" }], + }, + }), + }); + + const [issue] = await detectSessionSnapshotHealthIssues({ + storePaths: [storePath], + bundledSkillsDir, + }); + + if (!issue) { + throw new Error("expected session snapshot health issue"); + } + expect(issue).toMatchObject({ + storePath, + sessionKey: "agent:main", + field: "skillsSnapshot.prompt", + cachedPath: stalePath, + expectedPath: path.join(bundledSkillsDir, "doctor", "SKILL.md"), + }); + expect(sessionSnapshotIssueToHealthFinding(issue)).toMatchObject({ + checkId: "core/doctor/session-snapshots", + severity: "info", + path: storePath, + target: stalePath, + requirement: expect.stringContaining(bundledSkillsDir), + fixHint: expect.stringContaining("openclaw doctor --fix"), + }); + expect(sessionSnapshotIssueToRepairEffect(issue)).toEqual({ + kind: "file", + action: "would-rewrite-session-snapshot-path", + target: storePath, + dryRunSafe: false, + }); + }); + it("expands home-relative cached bundled skill locations before classifying them", () => { const homeDir = path.join(root, "home"); const stalePath = "~/old-runtime/node_modules/openclaw/skills/doctor/SKILL.md"; @@ -456,8 +527,9 @@ describe("doctor session snapshot repair (shouldRepair)", () => { }); const raw = await fs.readFile(storePath, "utf-8"); - expect(raw).not.toContain(stalePath); - expect(raw).toContain(path.join(bundledSkillsDir, "doctor", "SKILL.md")); + const snapshot = readMainSkillsSnapshot(raw); + expect(snapshot.prompt).not.toContain(stalePath); + expect(snapshot.prompt).toContain(path.join(bundledSkillsDir, "doctor", "SKILL.md")); expect(note).toHaveBeenCalledTimes(1); const [message] = note.mock.calls[0] as [string, string]; expect(message).toContain("Repaired"); @@ -535,9 +607,13 @@ describe("doctor session snapshot repair (shouldRepair)", () => { const raw = await fs.readFile(storePath, "utf-8"); const expectedBaseDir = path.dirname(path.join(bundledSkillsDir, "doctor", "SKILL.md")); - expect(raw).toContain(path.join(bundledSkillsDir, "doctor", "SKILL.md")); - expect(raw).toContain(expectedBaseDir); - expect(raw).not.toContain(path.join(root, "old-runtime")); + const expectedPath = path.join(bundledSkillsDir, "doctor", "SKILL.md"); + const snapshot = readMainSkillsSnapshot(raw); + const skill = snapshot.resolvedSkills?.[0]; + expect(skill?.filePath).toBe(expectedPath); + expect(skill?.baseDir).toBe(expectedBaseDir); + expect(skill?.sourceInfo.path).toBe(expectedPath); + expect(skill?.sourceInfo.baseDir).toBe(expectedBaseDir); expect(note).toHaveBeenCalledTimes(1); const [message] = note.mock.calls[0] as [string, string]; expect(message).toContain("Repaired"); @@ -576,9 +652,12 @@ describe("doctor session snapshot repair (shouldRepair)", () => { }); const raw = await fs.readFile(storePath, "utf-8"); - expect(raw).toContain(currentPath); - expect(raw).toContain(path.dirname(currentPath)); - expect(raw).not.toContain(path.join(root, "old-runtime")); + const snapshot = readMainSkillsSnapshot(raw); + const repairedSkill = snapshot.resolvedSkills?.[0]; + expect(repairedSkill?.filePath).toBe(currentPath); + expect(repairedSkill?.baseDir).toBe(path.dirname(currentPath)); + expect(repairedSkill?.sourceInfo.path).toBe(currentPath); + expect(repairedSkill?.sourceInfo.baseDir).toBe(path.dirname(currentPath)); expect(note).toHaveBeenCalledTimes(1); const [message] = note.mock.calls[0] as [string, string]; expect(message).toContain("Repaired"); @@ -743,7 +822,8 @@ describe("doctor session snapshot repair (shouldRepair)", () => { expect(backupFiles.length).toBe(1); const backupContent = await fs.readFile(path.join(dir, backupFiles[0]), "utf-8"); - expect(backupContent).toContain(stalePath); + const backupSnapshot = readMainSkillsSnapshot(backupContent); + expect(backupSnapshot.prompt).toContain(stalePath); }); it("is idempotent — second repair finds nothing", async () => { diff --git a/src/commands/doctor-session-snapshots.ts b/src/commands/doctor-session-snapshots.ts index 3137ca9e2a40..53cfe0e2bef3 100644 --- a/src/commands/doctor-session-snapshots.ts +++ b/src/commands/doctor-session-snapshots.ts @@ -12,11 +12,14 @@ import { import { resolveAllAgentSessionStoreTargetsSync } from "../config/sessions/targets.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js"; import { expandHomePrefix } from "../infra/home-dir.js"; import { writeTextAtomic } from "../infra/json-files.js"; import { resolveBundledSkillsDir } from "../skills/loading/bundled-dir.js"; import { shortenHomePath } from "../utils.js"; +const SESSION_SNAPSHOTS_CHECK_ID = "core/doctor/session-snapshots"; + type SnapshotPathSource = | "skillsSnapshot.prompt" | "skillsSnapshot.resolvedSkills" @@ -34,6 +37,10 @@ type StaleSessionSnapshotPathFinding = { expectedPath: string; }; +export type SessionSnapshotHealthIssue = StaleSessionSnapshotPathFinding & { + storePath: string; +}; + function decodeXmlText(value: string): string { return value .replace(/</g, "<") @@ -286,6 +293,72 @@ function loadSessionStoreForSnapshotScan(storePath: string): Record { + const bundledSkillsDir = params?.bundledSkillsDir ?? resolveBundledSkillsDir(); + if (!bundledSkillsDir) { + return []; + } + const storePaths = + params?.storePaths ?? + resolveSessionStorePaths({ cfg: params?.cfg, env: params?.env }) ?? + (await listSessionStorePaths(resolveStateDir(params?.env))); + const issues: SessionSnapshotHealthIssue[] = []; + for (const storePath of storePaths) { + let store: Record; + try { + store = loadSessionStoreForSnapshotScan(storePath); + } catch { + continue; + } + const findings = scanSessionStoreForStaleRuntimeSnapshotPaths({ + store, + bundledSkillsDir, + env: params?.env, + }); + for (const finding of findings) { + issues.push({ + sessionKey: finding.sessionKey, + field: finding.field, + cachedPath: finding.cachedPath, + expectedPath: finding.expectedPath, + storePath, + }); + } + } + return issues; +} + +export function sessionSnapshotIssueToHealthFinding( + issue: SessionSnapshotHealthIssue, +): HealthFinding { + return { + checkId: SESSION_SNAPSHOTS_CHECK_ID, + severity: "info", + message: `${issue.sessionKey} cached session metadata references an inactive runtime root that can be cleaned up.`, + path: issue.storePath, + target: issue.cachedPath, + requirement: `Current bundled skill path: ${issue.expectedPath}`, + fixHint: + "To clean up the advisory artifact, run `openclaw doctor --fix` to rewrite stale cached session metadata paths, or start a fresh session after confirming history can be retired.", + }; +} + +export function sessionSnapshotIssueToRepairEffect( + issue: SessionSnapshotHealthIssue, +): HealthRepairEffect { + return { + kind: "file", + action: "would-rewrite-session-snapshot-path", + target: issue.storePath, + dryRunSafe: false, + }; +} + /** Replaces stale paths in raw, JSON-escaped, and XML-escaped prompt text. */ function replaceStalePathsInText(text: string, finding: StaleSessionSnapshotPathFinding): string { const jsonEscaped = JSON.stringify(finding.cachedPath).slice(1, -1); diff --git a/src/commands/doctor-session-transcripts.test.ts b/src/commands/doctor-session-transcripts.test.ts index 0e2d0f3aab04..6e0e7f40322c 100644 --- a/src/commands/doctor-session-transcripts.test.ts +++ b/src/commands/doctor-session-transcripts.test.ts @@ -12,8 +12,11 @@ vi.mock("../../packages/terminal-core/src/note.js", () => ({ })); import { + detectSessionTranscriptHealthIssues, noteSessionTranscriptHealth, repairBrokenSessionTranscriptFile, + sessionTranscriptIssueToHealthFinding, + sessionTranscriptIssueToRepairEffect, } from "./doctor-session-transcripts.js"; function countNonEmptyLines(value: string): number { @@ -150,6 +153,44 @@ describe("doctor session transcript repair", () => { expect(countNonEmptyLines(await fs.readFile(filePath, "utf-8"))).toBe(3); }); + it("maps affected transcripts to structured findings and dry-run effects", async () => { + const filePath = await writeTranscript([ + { type: "session", version: 3, id: "session-1", timestamp: "2026-04-25T00:00:00Z" }, + { + type: "message", + id: "legacy-assistant", + parentId: null, + message: { + role: "assistant", + provider: "openai-codex", + api: "openai-codex-responses", + content: [{ type: "text", text: "hello" }], + }, + }, + ]); + const sessionsDir = path.dirname(filePath); + + const [issue] = await detectSessionTranscriptHealthIssues({ sessionDirs: [sessionsDir] }); + + if (!issue) { + throw new Error("expected session transcript health issue"); + } + expect(issue?.filePath).toBe(filePath); + expect(sessionTranscriptIssueToHealthFinding(issue)).toMatchObject({ + checkId: "core/doctor/session-transcripts", + severity: "info", + path: filePath, + fixHint: expect.stringContaining("openclaw doctor --fix"), + }); + expect(sessionTranscriptIssueToRepairEffect(issue)).toEqual({ + kind: "file", + action: "would-rewrite-session-transcript", + target: filePath, + dryRunSafe: false, + }); + expect(await fs.readFile(filePath, "utf-8")).toContain("openai-codex"); + }); + it("repairs supported current-version linear transcripts", async () => { const filePath = await writeTranscript([ { type: "session", version: 3, id: "session-linear", timestamp: "2026-06-15T00:00:00Z" }, diff --git a/src/commands/doctor-session-transcripts.ts b/src/commands/doctor-session-transcripts.ts index 9ae33f4a35cf..8ae36e28accc 100644 --- a/src/commands/doctor-session-transcripts.ts +++ b/src/commands/doctor-session-transcripts.ts @@ -16,8 +16,11 @@ import { scanSessionTranscriptTree, selectSessionTranscriptTreePathNodes, } from "../config/sessions/transcript-tree.js"; +import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js"; import { shortenHomePath } from "../utils.js"; +const SESSION_TRANSCRIPTS_CHECK_ID = "core/doctor/session-transcripts"; + type TranscriptEntry = Record & { id?: unknown; parentId?: unknown; @@ -36,6 +39,10 @@ type TranscriptRepairResult = { reason?: string; }; +export type SessionTranscriptHealthIssue = TranscriptRepairResult & { + broken: true; +}; + type ActiveTranscriptPath = { entries: TranscriptEntry[]; entriesToPersist: TranscriptEntry[]; @@ -372,6 +379,57 @@ async function listSessionTranscriptFiles(sessionDirs: string[]): Promise a.localeCompare(b)); } +export async function detectSessionTranscriptHealthIssues(params?: { + sessionDirs?: string[]; +}): Promise { + let sessionDirs = params?.sessionDirs; + try { + sessionDirs ??= await resolveAgentSessionDirs(resolveStateDir(process.env)); + } catch { + return []; + } + + const files = await listSessionTranscriptFiles(sessionDirs); + const issues: SessionTranscriptHealthIssue[] = []; + for (const filePath of files) { + const result = await repairBrokenSessionTranscriptFile({ filePath, shouldRepair: false }); + if (result.broken) { + issues.push(result as SessionTranscriptHealthIssue); + } + } + return issues; +} + +export function sessionTranscriptIssueToHealthFinding( + issue: SessionTranscriptHealthIssue, +): HealthFinding { + const metadata = + issue.legacyOpenAICodexEntries > 0 + ? ` ${issue.legacyOpenAICodexEntries} legacy OpenAI Codex metadata entr${ + issue.legacyOpenAICodexEntries === 1 ? "y" : "ies" + }` + : ""; + return { + checkId: SESSION_TRANSCRIPTS_CHECK_ID, + severity: "info", + message: `Session transcript has legacy branch or provider metadata that can be cleaned up.${metadata}`, + path: issue.filePath, + fixHint: + "To clean up the advisory artifact, run `openclaw doctor --fix` to rewrite affected transcripts to their active branch.", + }; +} + +export function sessionTranscriptIssueToRepairEffect( + issue: SessionTranscriptHealthIssue, +): HealthRepairEffect { + return { + kind: "file", + action: "would-rewrite-session-transcript", + target: issue.filePath, + dryRunSafe: false, + }; +} + /** Scans session transcript files and reports or repairs legacy/broken transcript state. */ export async function noteSessionTranscriptHealth(params?: { shouldRepair?: boolean; @@ -386,14 +444,14 @@ export async function noteSessionTranscriptHealth(params?: { return; } - const files = await listSessionTranscriptFiles(sessionDirs); - if (files.length === 0) { - return; - } - const results: TranscriptRepairResult[] = []; - for (const filePath of files) { - results.push(await repairBrokenSessionTranscriptFile({ filePath, shouldRepair })); + if (shouldRepair) { + const files = await listSessionTranscriptFiles(sessionDirs); + for (const filePath of files) { + results.push(await repairBrokenSessionTranscriptFile({ filePath, shouldRepair })); + } + } else { + results.push(...(await detectSessionTranscriptHealthIssues({ sessionDirs }))); } const broken = results.filter((result) => result.broken); if (broken.length === 0) { diff --git a/src/commands/doctor-workspace-status.test.ts b/src/commands/doctor-workspace-status.test.ts index 1fe9c44d6eaf..4286d22c98eb 100644 --- a/src/commands/doctor-workspace-status.test.ts +++ b/src/commands/doctor-workspace-status.test.ts @@ -372,10 +372,7 @@ describe("noteWorkspaceStatus", () => { } }); - const makeSkill = ( - skillKey: string, - fields: { eligible: boolean; platformIncompatible: boolean }, - ) => + const makeSkill = (skillKey: string, fields: { eligible: boolean; platformIncompatible: boolean }) => ({ skillKey, disabled: false, diff --git a/src/commands/doctor/repair-sequencing.test.ts b/src/commands/doctor/repair-sequencing.test.ts index db667cdb9c1b..ea15543da366 100644 --- a/src/commands/doctor/repair-sequencing.test.ts +++ b/src/commands/doctor/repair-sequencing.test.ts @@ -620,6 +620,69 @@ describe("doctor repair sequencing", () => { ]); }); + it("surfaces ClawHub notices from successful missing configured plugin repair", async () => { + mocks.repairMissingConfiguredPluginInstalls.mockResolvedValueOnce({ + changes: ['Installed missing configured plugin "brave" from @openclaw/brave-plugin.'], + warnings: [], + notices: [ + 'ClawHub trust warning for "@openclaw/brave-plugin@1.2.3": scan=pending; reasons=pending.', + ], + }); + mocks.maybeRepairStalePluginConfig.mockImplementationOnce((cfg: OpenClawConfig) => ({ + config: { + ...cfg, + plugins: { + ...cfg.plugins, + allow: [], + entries: {}, + }, + }, + changes: ["- plugins.entries: removed 1 stale plugin entry (brave)"], + })); + + const result = await runDoctorRepairSequence({ + state: { + cfg: { + plugins: { + allow: ["brave"], + entries: { + brave: { + enabled: true, + source: "clawhub", + package: "@openclaw/brave-plugin", + }, + }, + }, + } as OpenClawConfig, + candidate: { + plugins: { + allow: ["brave"], + entries: { + brave: { + enabled: true, + source: "clawhub", + package: "@openclaw/brave-plugin", + }, + }, + }, + } as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(result.changeNotes).toStrictEqual([ + 'Installed missing configured plugin "brave" from @openclaw/brave-plugin.', + "- plugins.entries: removed 1 stale plugin entry (brave)", + ]); + expect(result.warningNotes).toStrictEqual([ + 'ClawHub trust warning for "@openclaw/brave-plugin@1.2.3": scan=pending; reasons=pending.', + ]); + expect(mocks.maybeRepairStalePluginConfig).toHaveBeenCalledOnce(); + expect(result.state.pendingChanges).toBe(true); + }); + it("moves legacy Codex routes to canonical OpenAI before missing plugin install repair", async () => { mocks.repairMissingConfiguredPluginInstalls.mockImplementationOnce( async (params: { cfg: OpenClawConfig }) => { diff --git a/src/commands/doctor/repair-sequencing.ts b/src/commands/doctor/repair-sequencing.ts index 7234b7751a2e..0264a97527f2 100644 --- a/src/commands/doctor/repair-sequencing.ts +++ b/src/commands/doctor/repair-sequencing.ts @@ -143,6 +143,10 @@ export async function runDoctorRepairSequence(params: { if (missingConfiguredPluginInstallRepair.warnings.length > 0) { warningNotes.push(sanitizeLines(missingConfiguredPluginInstallRepair.warnings)); } + const missingConfiguredPluginInstallNotices = missingConfiguredPluginInstallRepair.notices ?? []; + if (missingConfiguredPluginInstallNotices.length > 0) { + warningNotes.push(sanitizeLines(missingConfiguredPluginInstallNotices)); + } const failedPluginIds = missingConfiguredPluginInstallRepair.failedPluginIds ?? []; const hasUnscopedInstallRepairWarnings = missingConfiguredPluginInstallRepair.warnings.length > 0 && failedPluginIds.length === 0; diff --git a/src/commands/doctor/shared/context-engine-host-compat.test.ts b/src/commands/doctor/shared/context-engine-host-compat.test.ts index b55b5fdd5267..202975312b5c 100644 --- a/src/commands/doctor/shared/context-engine-host-compat.test.ts +++ b/src/commands/doctor/shared/context-engine-host-compat.test.ts @@ -1,7 +1,12 @@ // Context engine host compatibility tests cover doctor warnings for host/context mismatches. import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; -import { registerContextEngine } from "../../../context-engine/registry.js"; +import { + getContextEngineFactory, + getContextEngineRegistration, + registerContextEngine, + registerContextEngineForOwner, +} from "../../../context-engine/registry.js"; import type { ContextEngine, ContextEngineHostCapability } from "../../../context-engine/types.js"; import { collectConfiguredContextEngineAgentRunHosts, @@ -60,6 +65,23 @@ function configWithEngine(engineId: string, cfg: OpenClawConfig = {}): OpenClawC } describe("doctor context-engine host compatibility", () => { + it("distinguishes read-only discovery registrations from runtime entries", () => { + const id = uniqueEngineId(); + const factory = () => { + throw new Error("discovery-only"); + }; + const result = registerContextEngineForOwner(id, factory, `doctor-test-owner-${id}`, { + lifecycle: "readOnlyDiscovery", + }); + + expect(result).toEqual({ ok: true }); + expect(getContextEngineRegistration(id)).toMatchObject({ + factory, + lifecycle: "readOnlyDiscovery", + }); + expect(getContextEngineFactory(id)).toBeUndefined(); + }); + it("collects native Codex and OpenClaw as compatible agent-run hosts", () => { const hosts = collectConfiguredContextEngineAgentRunHosts({ cfg: { diff --git a/src/commands/doctor/shared/context-engine-host-compat.ts b/src/commands/doctor/shared/context-engine-host-compat.ts index 99685871e3dc..aa23c1144df4 100644 --- a/src/commands/doctor/shared/context-engine-host-compat.ts +++ b/src/commands/doctor/shared/context-engine-host-compat.ts @@ -16,7 +16,10 @@ import { type ContextEngineHostSupport, } from "../../../context-engine/host-compat.js"; import { ensureContextEnginesInitialized } from "../../../context-engine/init.js"; -import { getContextEngineFactory, resolveContextEngine } from "../../../context-engine/registry.js"; +import { + getContextEngineRegistration, + resolveContextEngine, +} from "../../../context-engine/registry.js"; import type { ContextEngineInfo } from "../../../context-engine/types.js"; import { ensurePluginRegistryLoaded } from "../../../plugins/runtime/runtime-registry-loader.js"; import { defaultSlotIdForKey } from "../../../plugins/slots.js"; @@ -254,7 +257,7 @@ async function resolveSelectedContextEngineInfo(params: { } ensureContextEnginesInitialized(); - if (!getContextEngineFactory(engineId)) { + if (getContextEngineRegistration(engineId)?.lifecycle !== "runtime") { try { ensurePluginRegistryLoaded({ scope: "all", @@ -263,7 +266,7 @@ async function resolveSelectedContextEngineInfo(params: { onlyPluginIds: [engineId], }); } catch (error) { - if (!getContextEngineFactory(engineId)) { + if (getContextEngineRegistration(engineId)?.lifecycle !== "runtime") { const message = error instanceof Error ? error.message : String(error); return { warnings: [ @@ -272,7 +275,7 @@ async function resolveSelectedContextEngineInfo(params: { }; } } - if (!getContextEngineFactory(engineId)) { + if (getContextEngineRegistration(engineId)?.lifecycle !== "runtime") { return { warnings: [ `- plugins.slots.contextEngine: could not inspect context engine "${engineId}" host requirements because it is not registered.`, diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index 517031f00fc8..4affb8d0ac76 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resolveRegistryUpdateChannel } from "../../../infra/update-channels.js"; +import { CLAWHUB_INSTALL_ERROR_CODE } from "../../../plugins/clawhub-error-codes.js"; import { resolveClawHubInstallSpecsForUpdateChannel, resolveNpmInstallSpecsForUpdateChannel, @@ -150,6 +151,8 @@ vi.mock("../../../plugins/clawhub.js", () => ({ VERSION_NOT_FOUND: "version_not_found", ARTIFACT_UNAVAILABLE: "artifact_unavailable", ARTIFACT_DOWNLOAD_UNAVAILABLE: "artifact_download_unavailable", + CLAWHUB_DOWNLOAD_BLOCKED: "clawhub_download_blocked", + CLAWHUB_SECURITY_UNAVAILABLE: "clawhub_security_unavailable", }, installPluginFromClawHub: mocks.installPluginFromClawHub, })); @@ -179,9 +182,13 @@ vi.mock("../../../plugins/provider-install-catalog.js", () => ({ resolveProviderInstallCatalogEntries: mocks.resolveProviderInstallCatalogEntries, })); -vi.mock("../../../plugins/update.js", () => ({ - updateNpmInstalledPlugins: mocks.updateNpmInstalledPlugins, -})); +vi.mock("../../../plugins/update.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + updateNpmInstalledPlugins: mocks.updateNpmInstalledPlugins, + }; +}); describe("repairMissingConfiguredPluginInstalls", () => { beforeEach(() => { @@ -363,6 +370,36 @@ describe("repairMissingConfiguredPluginInstalls", () => { }); it("uses an explicit ClawHub install spec before npm", async () => { + const reviewNotice = + "╭─ REVIEW RECOMMENDED - ClawHub has not completed a fresh clean check ─╮\n" + + "│ • Status: security scan is pending │\n" + + "╰───────────────────────────────────────────────────────────────────────╯"; + const coloredReviewNotice = `\u001b[33m${reviewNotice}\u001b[39m`; + mocks.installPluginFromClawHub.mockImplementationOnce( + async (params: { logger?: { warn?: (message: string) => void } }) => { + params.logger?.warn?.(coloredReviewNotice); + return { + ok: true, + pluginId: "matrix", + targetDir: "/tmp/openclaw-plugins/matrix", + version: "1.2.3", + clawhub: { + source: "clawhub", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "@openclaw/plugin-matrix", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + version: "1.2.3", + integrity: "sha256-clawhub", + resolvedAt: "2026-05-01T00:00:00.000Z", + clawpackSha256: "0".repeat(64), + clawpackSpecVersion: 1, + clawpackManifestSha256: "1".repeat(64), + clawpackSize: 1234, + }, + }; + }, + ); mocks.listChannelPluginCatalogEntries.mockReturnValue([ { id: "matrix", @@ -387,17 +424,146 @@ describe("repairMissingConfiguredPluginInstalls", () => { env: {}, }); - expectRecordFields(mockCallArg(mocks.installPluginFromClawHub), { + const clawHubCall = expectRecordFields(mockCallArg(mocks.installPluginFromClawHub), { spec: "clawhub:@openclaw/plugin-matrix@stable", expectedPluginId: "matrix", }); + expect(clawHubCall.logger).toEqual(expect.objectContaining({ terminalLinks: false })); expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled(); expect(result.changes).toEqual([ 'Installed missing configured plugin "matrix" from clawhub:@openclaw/plugin-matrix@stable.', ]); + expect(result.notices).toContain(reviewNotice); + expect(result.notices?.[0]).not.toContain("\u001b"); expect(result.warnings).toStrictEqual([]); }); + it("adds actionable acknowledgement guidance for risky ClawHub candidate failures", async () => { + mocks.installPluginFromClawHub.mockResolvedValueOnce({ + ok: false, + code: "clawhub_risk_acknowledgement_required", + error: + 'ClawHub release "@openclaw/plugin-matrix@stable" has trust warnings. Review the package and rerun with --acknowledge-clawhub-risk to continue.', + }); + mocks.listChannelPluginCatalogEntries.mockReturnValue([ + { + id: "matrix", + pluginId: "matrix", + meta: { label: "Matrix" }, + install: { + clawhubSpec: "clawhub:@openclaw/plugin-matrix@stable", + }, + }, + ]); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + channels: { + matrix: { enabled: true, homeserver: "https://matrix.example.org" }, + }, + }, + env: {}, + }); + + expect(result.warnings[0]).toContain( + "openclaw plugins install clawhub:@openclaw/plugin-matrix@stable --acknowledge-clawhub-risk", + ); + }); + + it("adds repair warnings for blocked ClawHub update outcomes", async () => { + const records = { + demo: { + source: "clawhub", + spec: "clawhub:@openclaw/plugin-demo@stable", + clawhubPackage: "@openclaw/plugin-demo", + installPath: "/missing/demo", + }, + }; + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records); + mocks.updateNpmInstalledPlugins.mockResolvedValueOnce({ + changed: false, + config: { + plugins: { + installs: records, + }, + }, + outcomes: [ + { + pluginId: "demo", + status: "skipped", + code: "clawhub_download_blocked", + message: + 'Skipped demo ClawHub update: ClawHub release "@openclaw/plugin-demo@1.2.4" cannot be installed because ClawHub flagged it as blocked or malicious. Review the security details above or choose a different version. Existing installed plugin left unchanged.', + }, + ], + }); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + }, + env: {}, + }); + + expect(mocks.updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + pluginIds: ["demo"], + }), + ); + expect(result.changes).toStrictEqual([]); + expect(result.warnings).toStrictEqual([ + 'Skipped demo ClawHub update: ClawHub release "@openclaw/plugin-demo@1.2.4" cannot be installed because ClawHub flagged it as blocked or malicious. Review the security details above or choose a different version. Existing installed plugin left unchanged.', + ]); + }); + + it("sanitizes and shell-quotes ClawHub acknowledgement guidance specs before rendering commands", async () => { + mocks.installPluginFromClawHub.mockResolvedValueOnce({ + ok: false, + code: "clawhub_risk_acknowledgement_required", + error: + 'ClawHub release "@openclaw/plugin-matrix@stable" has trust warnings. Review the package and rerun with --acknowledge-clawhub-risk to continue.', + }); + mocks.listChannelPluginCatalogEntries.mockReturnValue([ + { + id: "matrix", + pluginId: "matrix", + meta: { label: "Matrix" }, + install: { + clawhubSpec: "clawhub:@openclaw/plugin-matrix\n\u001b[31m@stable;$(touch /tmp/pwned)", + }, + }, + ]); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + channels: { + matrix: { enabled: true, homeserver: "https://matrix.example.org" }, + }, + }, + env: {}, + }); + + const warning = result.warnings[0] ?? ""; + expect(warning).toContain( + "openclaw plugins install 'clawhub:@openclaw/plugin-matrix\\n@stable;$(touch /tmp/pwned)' --acknowledge-clawhub-risk", + ); + expect(warning).not.toContain( + "openclaw plugins install clawhub:@openclaw/plugin-matrix\\n@stable;$(touch /tmp/pwned) --acknowledge-clawhub-risk", + ); + expect(warning).not.toContain("\u001b"); + expect(warning).not.toContain("plugin-matrix\n"); + }); + it("installs a missing channel plugin selected by environment config from npm", async () => { mocks.installPluginFromNpmSpec.mockResolvedValueOnce({ ok: true, @@ -2674,6 +2840,268 @@ describe("repairMissingConfiguredPluginInstalls", () => { expect(result.changes).toEqual(['Repaired missing configured plugin "demo".']); }); + it("forwards ClawHub risk acknowledgement to persisted-record repair", async () => { + const records = { + demo: { + source: "clawhub", + spec: "clawhub:@openclaw/plugin-demo@1.0.0", + clawhubPackage: "@openclaw/plugin-demo", + installPath: "/missing/demo", + }, + }; + const onClawHubRisk = vi.fn(async () => true); + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records); + mocks.updateNpmInstalledPlugins.mockResolvedValue({ + changed: true, + config: { + plugins: { + installs: { + demo: { + source: "clawhub", + spec: "clawhub:@openclaw/plugin-demo@1.0.0", + installPath: "/tmp/openclaw-plugins/demo", + }, + }, + }, + }, + outcomes: [ + { + pluginId: "demo", + status: "updated", + message: "Updated demo.", + }, + ], + }); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + await repairMissingConfiguredPluginInstalls({ + cfg: { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + }, + env: {}, + acknowledgeClawHubRisk: true, + onClawHubRisk, + }); + + const updateArg = expectRecordFields(mockCallArg(mocks.updateNpmInstalledPlugins), { + pluginIds: ["demo"], + acknowledgeClawHubRisk: true, + onClawHubRisk, + }); + expect(updateArg.logger).toEqual(expect.objectContaining({ terminalLinks: false })); + const updateConfig = updateArg.config as Record; + expectRecordFields(updateConfig.plugins, { installs: records }); + }); + + it("keeps non-ClawHub updater warnings as persisted-record repair warnings", async () => { + const records = { + demo: { + source: "npm", + spec: "@openclaw/plugin-demo@1.0.0", + installPath: "/missing/demo", + }, + }; + const repairWarning = + 'Could not repair openclaw peer link for "demo" at /tmp/openclaw-plugins/demo: permission denied'; + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records); + mocks.updateNpmInstalledPlugins.mockImplementationOnce( + async (params: { + logger?: { warn?: (message: string) => void }; + config: Record; + }) => { + params.logger?.warn?.(repairWarning); + return { + changed: true, + config: { + plugins: { + installs: { + demo: { + source: "npm", + spec: "@openclaw/plugin-demo@1.0.0", + installPath: "/tmp/openclaw-plugins/demo", + }, + }, + }, + }, + outcomes: [ + { + pluginId: "demo", + status: "updated", + message: "Updated demo.", + }, + ], + }; + }, + ); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + }, + env: {}, + }); + + expect(result.warnings).toContain(repairWarning); + expect(result.notices ?? []).not.toContain(repairWarning); + }); + + it("keeps ClawHub review notices non-fatal during persisted-record repair", async () => { + const records = { + demo: { + source: "clawhub", + spec: "clawhub:@openclaw/plugin-demo@1.0.0", + clawhubPackage: "@openclaw/plugin-demo", + installPath: "/missing/demo", + }, + }; + const reviewNotice = + "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + + "│ • Security scan: suspicious │\n" + + "╰───────────────────────────────────────────────────────────────────────╯"; + const coloredReviewNotice = `\u001b[33m${reviewNotice}\u001b[39m`; + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records); + mocks.updateNpmInstalledPlugins.mockImplementationOnce( + async (params: { + logger?: { warn?: (message: string) => void }; + config: Record; + }) => { + params.logger?.warn?.(coloredReviewNotice); + return { + changed: true, + config: { + plugins: { + installs: { + demo: { + source: "clawhub", + spec: "clawhub:@openclaw/plugin-demo@1.0.0", + installPath: "/tmp/openclaw-plugins/demo", + }, + }, + }, + }, + outcomes: [ + { + pluginId: "demo", + status: "updated", + message: "Updated demo.", + }, + ], + }; + }, + ); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + }, + env: {}, + }); + + expect(result.notices).toContain(reviewNotice); + expect(result.notices?.[0]).not.toContain("\u001b"); + expect(result.warnings).toStrictEqual([]); + }); + + it("adds actionable acknowledgement guidance for risky persisted ClawHub repair failures", async () => { + const records = { + demo: { + source: "clawhub", + spec: "clawhub:@openclaw/plugin-demo@1.0.0", + clawhubPackage: "@openclaw/plugin-demo", + installPath: "/missing/demo", + }, + }; + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records); + mocks.updateNpmInstalledPlugins.mockResolvedValue({ + changed: false, + config: { plugins: { installs: records } }, + outcomes: [ + { + pluginId: "demo", + status: "skipped", + code: CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED, + message: + 'Skipped demo ClawHub update: ClawHub release "@openclaw/plugin-demo@1.0.0" has trust warnings. Review the package and rerun with --acknowledge-clawhub-risk to continue. Existing installed plugin left unchanged.', + }, + ], + }); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + }, + env: {}, + }); + + expect(result.warnings[0]).toContain( + "openclaw plugins install clawhub:@openclaw/plugin-demo@1.0.0 --acknowledge-clawhub-risk", + ); + }); + + it("prefixes legacy persisted ClawHub package records in acknowledgement guidance", async () => { + const records = { + demo: { + source: "clawhub", + clawhubPackage: "@openclaw/plugin-demo", + installPath: "/missing/demo", + }, + }; + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records); + mocks.updateNpmInstalledPlugins.mockResolvedValue({ + changed: false, + config: { plugins: { installs: records } }, + outcomes: [ + { + pluginId: "demo", + status: "skipped", + code: CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED, + message: + 'Skipped demo ClawHub update: ClawHub release "@openclaw/plugin-demo@latest" has trust warnings. Review the package and rerun with --acknowledge-clawhub-risk to continue. Existing installed plugin left unchanged.', + }, + ], + }); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + }, + env: {}, + }); + + expect(result.warnings[0]).toContain( + "openclaw plugins install clawhub:@openclaw/plugin-demo --acknowledge-clawhub-risk", + ); + }); + it("repairs a broken managed package entry from its attributed registry diagnostic", async () => { const records = { demo: { diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index 158e61a141f7..91f153d1c2ac 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -3,6 +3,8 @@ import { existsSync } from "node:fs"; import { readFile, rm } from "node:fs/promises"; import path from "node:path"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { stripAnsi } from "../../../../packages/terminal-core/src/ansi.js"; +import { sanitizeTerminalText } from "../../../../packages/terminal-core/src/safe-text.js"; import { listExplicitlyDisabledChannelIdsForConfig, listPotentialConfiguredChannelIds, @@ -23,7 +25,11 @@ import { } from "../../../infra/update-channels.js"; import { resolveConfiguredChannelPresencePolicy } from "../../../plugins/channel-plugin-ids.js"; import { buildClawHubPluginInstallRecordFields } from "../../../plugins/clawhub-install-records.js"; -import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "../../../plugins/clawhub.js"; +import { + CLAWHUB_INSTALL_ERROR_CODE, + installPluginFromClawHub, + type ClawHubRiskAcknowledgementRequest, +} from "../../../plugins/clawhub.js"; import { collectConfiguredMemoryEmbeddingProviderIds } from "../../../plugins/gateway-startup-plugin-ids.js"; import { collectConfiguredSpeechProviderIds } from "../../../plugins/gateway-startup-speech-providers.js"; import { @@ -57,7 +63,10 @@ import { } from "../../../plugins/official-external-plugin-catalog.js"; import type { PluginMetadataSnapshot } from "../../../plugins/plugin-metadata-snapshot.types.js"; import { resolveProviderInstallCatalogEntries } from "../../../plugins/provider-install-catalog.js"; -import { updateNpmInstalledPlugins } from "../../../plugins/update.js"; +import { + isClawHubTrustSkippedOutcome, + updateNpmInstalledPlugins, +} from "../../../plugins/update.js"; import { resolveWebSearchInstallCatalogEntriesForEnv, resolveWebSearchInstallCatalogEntry, @@ -117,6 +126,50 @@ function shouldFallbackClawHubToNpm(params: { ); } +function appendClawHubRiskAcknowledgementGuidance(params: { + message: string; + spec: string | undefined; +}): string { + if (!params.spec || !params.message.includes("--acknowledge-clawhub-risk")) { + return params.message; + } + const sanitizedSpec = sanitizeTerminalText(params.spec); + const shellSpec = shellQuotePosixArg(sanitizedSpec); + return `${params.message} To review and acknowledge this ClawHub package, run \`openclaw plugins install ${shellSpec} --acknowledge-clawhub-risk\` from a trusted shell, then rerun repair.`; +} + +function shellQuotePosixArg(value: string): string { + if (/^[A-Za-z0-9_./:@%+=,-]+$/u.test(value)) { + return value; + } + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function isActionableClawHubSkippedOutcome(outcome: { status: string; code?: string }): boolean { + return isClawHubTrustSkippedOutcome(outcome); +} + +function isClawHubReviewNotice(message: string): boolean { + const trimmed = stripAnsi(message).trimStart(); + return ( + trimmed.startsWith("╭─ REVIEW RECOMMENDED - ClawHub ") || + trimmed.startsWith("╭─ WARNING - ClawHub found security risks ") + ); +} + +function recordClawHubInstallSpec(record: PluginInstallRecord | undefined): string | undefined { + if (!record || record.source !== "clawhub") { + return undefined; + } + if (record.spec) { + return record.spec; + } + if (record.clawhubPackage) { + return `clawhub:${record.clawhubPackage}`; + } + return undefined; +} + function resolveCandidateClawHubSpec(install: PluginPackageInstall): string | undefined { const explicit = install.clawhubSpec?.trim(); if (explicit) { @@ -958,15 +1011,19 @@ async function installCandidate(params: { mode?: "install" | "update"; preferNpm?: boolean; repairReason?: InstallCandidateRepairReason; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; }): Promise<{ records: Record; changes: string[]; + notices: string[]; warnings: string[]; failedPluginId?: string; }> { const { candidate } = params; const extensionsDir = resolveDefaultPluginExtensionsDir(params.env); const changes: string[] = []; + const warnings: string[] = []; const clawhubSpecs = candidate.clawhubSpec ? resolveClawHubInstallSpecsForUpdateChannel({ spec: candidate.clawhubSpec, @@ -1016,12 +1073,19 @@ async function installCandidate(params: { !(params.preferNpm && npmInstallSpec) && candidate.defaultChoice !== "npm"; if (shouldTryClawHub) { + const clawhubInstallSpecLabel = sanitizeTerminalText(clawhubInstallSpec); const clawhubResult = await installPluginFromClawHub({ spec: clawhubInstallSpec, extensionsDir, env: params.env, expectedPluginId: candidate.pluginId, mode: params.mode === "update" || existingClawHubPackagePath ? "update" : "install", + logger: { + terminalLinks: false, + warn: (message) => warnings.push(stripAnsi(message)), + }, + ...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), + ...(params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}), }); if (clawhubResult.ok) { const pluginId = clawhubResult.pluginId; @@ -1038,10 +1102,11 @@ async function installCandidate(params: { changes: [ formatInstalledConfiguredPluginChange({ pluginId, - installSpec: clawhubInstallSpec, + installSpec: clawhubInstallSpecLabel, repairReason: params.repairReason, }), ], + notices: warnings, warnings: [], }; } @@ -1049,24 +1114,33 @@ async function installCandidate(params: { !npmInstallSpec || !shouldFallbackClawHubToNpm({ result: clawhubResult, npmSpec: npmInstallSpec }) ) { + const failure = `Failed to install missing configured plugin "${candidate.pluginId}" from ${clawhubInstallSpecLabel}: ${clawhubResult.error}`; return { records: params.records, changes: [], + notices: [], warnings: [ - `Failed to install missing configured plugin "${candidate.pluginId}" from ${clawhubInstallSpec}: ${clawhubResult.error}`, + ...warnings, + appendClawHubRiskAcknowledgementGuidance({ + message: failure, + spec: clawhubInstallSpec, + }), ], failedPluginId: candidate.pluginId, }; } + const npmInstallSpecLabel = sanitizeTerminalText(npmInstallSpec); changes.push( - `ClawHub ${clawhubInstallSpec} unavailable for "${candidate.pluginId}"; falling back to npm ${npmInstallSpec}.`, + `ClawHub ${clawhubInstallSpecLabel} unavailable for "${candidate.pluginId}"; falling back to npm ${npmInstallSpecLabel}.`, ); } if (!npmInstallSpec) { return { records: params.records, changes: [], + notices: [], warnings: [ + ...warnings, `Failed to install missing configured plugin "${candidate.pluginId}": missing npm spec.`, ], failedPluginId: candidate.pluginId, @@ -1101,7 +1175,9 @@ async function installCandidate(params: { return { records: params.records, changes: [], + notices: [], warnings: [ + ...warnings, `Failed to install missing configured plugin "${candidate.pluginId}" from ${npmInstallSpec}: ${result.error}`, ], failedPluginId: candidate.pluginId, @@ -1132,6 +1208,7 @@ async function installCandidate(params: { repairReason: params.repairReason, }), ], + notices: [], warnings: [], }; } @@ -1199,6 +1276,7 @@ async function adoptExistingNpmPackage(params: { }): Promise<{ records: Record; changes: string[]; + notices: string[]; warnings: string[]; }> { const npmName = parseRegistryNpmSpec(params.npmInstallSpec)?.name; @@ -1230,6 +1308,7 @@ async function adoptExistingNpmPackage(params: { changes: [ `Repaired missing configured plugin "${params.candidate.pluginId}" from existing npm payload ${params.npmInstallSpec}.`, ], + notices: [], warnings: [], }; } @@ -1238,6 +1317,8 @@ export type RepairMissingPluginInstallsResult = { /** User-facing repair notes for installed or recovered plugin records. */ changes: string[]; /** User-facing warnings for failed or skipped plugin install repairs. */ + /** User-facing notices from successful repairs that still need operator review. */ + notices?: string[]; warnings: string[]; /** Plugin ids successfully repaired from current configuration. */ repairedPluginIds?: string[]; @@ -1261,6 +1342,8 @@ export type RepairMissingPluginInstallsResult = { export async function repairMissingConfiguredPluginInstalls(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; /** * Optional pre-seeded records. When provided, this map is used instead of * the disk-loaded install-record snapshot. Pass the in-memory records @@ -1276,6 +1359,8 @@ export async function repairMissingConfiguredPluginInstalls(params: { pluginIds: collectConfiguredPluginIds(params.cfg, params.env), channelIds: collectConfiguredChannelIds(params.cfg, params.env), blockedPluginIds: collectBlockedPluginIds(params.cfg), + ...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), + ...(params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}), ...(params.baselineRecords ? { baselineRecords: params.baselineRecords } : {}), }); } @@ -1288,6 +1373,8 @@ export async function repairMissingPluginInstallsForIds(params: { blockedPluginIds?: Iterable; env?: NodeJS.ProcessEnv; baselineRecords?: Record; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; }): Promise { return repairMissingPluginInstalls({ cfg: params.cfg, @@ -1305,6 +1392,8 @@ export async function repairMissingPluginInstallsForIds(params: { .map((pluginId) => pluginId.trim()) .filter((pluginId) => pluginId), ), + ...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), + ...(params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}), ...(params.baselineRecords ? { baselineRecords: params.baselineRecords } : {}), }); } @@ -1316,6 +1405,8 @@ async function repairMissingPluginInstalls(params: { blockedPluginIds?: ReadonlySet; env?: NodeJS.ProcessEnv; baselineRecords?: Record; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; }): Promise { const env = params.env ?? process.env; const snapshot = loadManifestMetadataSnapshot({ @@ -1389,6 +1480,7 @@ async function repairMissingPluginInstalls(params: { }); const officialReplacementPluginIds = new Set(officialReplacementInstallCandidates.keys()); const changes: string[] = []; + const notices: string[] = []; const warnings: string[] = []; const deferredRepairDetails: string[] = []; const failedPluginIds = new Set(); @@ -1467,9 +1559,18 @@ async function repairMissingPluginInstalls(params: { pluginIds: missingRecordedPluginIds, updateChannel, logger: { - warn: (message) => warnings.push(message), + terminalLinks: false, + warn: (message) => { + if (isClawHubReviewNotice(message)) { + notices.push(stripAnsi(message)); + return; + } + warnings.push(message); + }, error: (message) => warnings.push(message), }, + ...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), + ...(params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}), }); for (const outcome of updateResult.outcomes) { if (outcome.status === "updated" || outcome.status === "unchanged") { @@ -1484,6 +1585,14 @@ async function repairMissingPluginInstalls(params: { } else if (outcome.status === "error") { warnings.push(outcome.message); failedPluginIds.add(outcome.pluginId); + } else if (isActionableClawHubSkippedOutcome(outcome)) { + warnings.push( + appendClawHubRiskAcknowledgementGuidance({ + message: outcome.message, + spec: recordClawHubInstallSpec(nextRecords[outcome.pluginId]), + }), + ); + failedPluginIds.add(outcome.pluginId); } } nextRecords = updateResult.config.plugins?.installs ?? nextRecords; @@ -1562,6 +1671,8 @@ async function repairMissingPluginInstalls(params: { ...(installedPluginIdsWithStaleVersionBoundRuntimePackages.has(candidate.pluginId) ? { repairReason: "stale-version-bound-runtime" as const } : {}), + ...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), + ...(params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}), }); if (shouldReplaceBrokenOfficialInstall) { const installedRecord = installed.records[candidate.pluginId]; @@ -1583,6 +1694,7 @@ async function repairMissingPluginInstalls(params: { } nextRecords = installed.records; changes.push(...installed.changes); + notices.push(...installed.notices); warnings.push(...installed.warnings); if (!installed.failedPluginId && installed.records[candidate.pluginId]) { repairedPluginIds.add(candidate.pluginId); @@ -1605,6 +1717,7 @@ async function repairMissingPluginInstalls(params: { return { changes, warnings, + ...(notices.length > 0 ? { notices } : {}), ...(deferredRepairDetails.length > 0 ? { deferredRepairDetails } : {}), ...(repairedPluginIds.size > 0 ? { diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts index 1e0ee10356db..065910656eaa 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts @@ -577,6 +577,38 @@ describe("configured plugin install release step", () => { expect(result.completed).toBe(true); }); + it("surfaces non-fatal repair notices without blocking release repair completion", async () => { + const reviewNotice = "REVIEW RECOMMENDED - ClawHub has not completed a fresh clean check"; + mocks.repairMissingPluginInstallsForIds.mockResolvedValue({ + changes: ['Installed missing configured plugin "codex".'], + warnings: [], + notices: [reviewNotice], + }); + + const { maybeRunConfiguredPluginInstallReleaseStep } = + await import("./release-configured-plugin-installs.js"); + const result = await maybeRunConfiguredPluginInstallReleaseStep({ + cfg: { + agents: { + defaults: { + model: "openai/gpt-5.4", + agentRuntime: { id: "codex" }, + }, + }, + }, + currentVersion: "2026.5.2-beta.1", + touchedVersion: "2026.5.1", + env: {}, + }); + + expect(result).toEqual({ + changes: ['Installed missing configured plugin "codex".'], + warnings: [reviewNotice], + completed: true, + touchedConfig: true, + }); + }); + it("does not stamp config during update-time deferred install repair", async () => { mocks.repairMissingPluginInstallsForIds.mockResolvedValue({ changes: [ diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.ts b/src/commands/doctor/shared/release-configured-plugin-installs.ts index 30b206b7469b..5bd2ec6a0902 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.ts @@ -370,6 +370,7 @@ export async function maybeRunConfiguredPluginInstallReleaseStep(params: { blockedPluginIds: collectBlockedPluginIds(params.cfg), env, }); + const warnings = [...repaired.warnings, ...(repaired.notices ?? [])]; const postInstallDoctorResult = createPostInstallDoctorResultForDeferredRepair({ updateInProgress, details: repaired.deferredRepairDetails ?? [], @@ -377,7 +378,7 @@ export async function maybeRunConfiguredPluginInstallReleaseStep(params: { }); return { changes: repaired.changes, - warnings: repaired.warnings, + warnings, completed: repaired.warnings.length === 0, touchedConfig: false, ...(postInstallDoctorResult ? { postInstallDoctorResult } : {}), @@ -394,6 +395,7 @@ export async function maybeRunConfiguredPluginInstallReleaseStep(params: { env, }); const completed = repaired.warnings.length === 0 && !updateInProgress; + const warnings = [...repaired.warnings, ...(repaired.notices ?? [])]; const postInstallDoctorResult = createPostInstallDoctorResultForDeferredRepair({ updateInProgress, details: repaired.deferredRepairDetails ?? [], @@ -401,7 +403,7 @@ export async function maybeRunConfiguredPluginInstallReleaseStep(params: { }); return { changes: repaired.changes, - warnings: repaired.warnings, + warnings, completed, touchedConfig: completed, ...(postInstallDoctorResult ? { postInstallDoctorResult } : {}), diff --git a/src/commands/onboarding-plugin-install.test.ts b/src/commands/onboarding-plugin-install.test.ts index 43923707bc16..669929c300b0 100644 --- a/src/commands/onboarding-plugin-install.test.ts +++ b/src/commands/onboarding-plugin-install.test.ts @@ -160,7 +160,18 @@ type NpmSpecInstallCall = { type ClawHubInstallCall = { config?: OpenClawConfig; expectedPluginId?: string; + logger?: { + info?: (message: string) => void; + warn?: (message: string) => void; + }; mode?: string; + onClawHubRisk?: (request: { + acknowledgementKind: "confirm" | "type-package"; + packageName: string; + trust: unknown; + version: string; + warning: string; + }) => boolean | Promise; spec?: string; timeoutMs?: number; }; @@ -532,6 +543,7 @@ describe("ensureOnboardingPluginInstalled", () => { expect(clawHubCall.expectedPluginId).toBe("demo-plugin"); expect(clawHubCall.mode).toBe("install"); expect(clawHubCall.timeoutMs).toBe(300_000); + expect(typeof clawHubCall.onClawHubRisk).toBe("function"); expect(update).toHaveBeenCalledWith("Downloading"); expect(stop).toHaveBeenCalledWith("Installed Demo Provider plugin"); const [, recordUpdate] = readFirstMockCall(recordPluginInstall, "recordPluginInstall") as [ @@ -556,6 +568,65 @@ describe("ensureOnboardingPluginInstalled", () => { expect(installed?.spec).toBe("clawhub:demo-plugin@2026.5.2"); }); + it("renders ClawHub trust warnings with line breaks before prompting during onboarding", async () => { + const warning = [ + "╭─ WARNING - ClawHub found security risks in this release ─╮", + "│ • Security scan: suspicious │", + "│ Review before installing. │", + "╰───────────────────────────────────────────────────────────────────────╯", + ].join("\n"); + installPluginFromClawHub.mockImplementation(async (params: ClawHubInstallCall) => { + params.logger?.warn?.(warning); + const acknowledged = + (await params.onClawHubRisk?.({ + acknowledgementKind: "type-package", + packageName: "demo-plugin", + trust: {}, + version: "2026.5.2", + warning, + })) ?? false; + return { + ok: false, + code: "clawhub_risk_acknowledgement_required", + error: acknowledged ? "unexpected acknowledgement" : "risk was not acknowledged", + warning, + }; + }); + const log = vi.fn(); + const text = vi.fn(async () => "wrong-package"); + + const result = await ensureOnboardingPluginInstalled({ + cfg: {}, + entry: { + pluginId: "demo-plugin", + label: "Demo Provider", + install: { + clawhubSpec: "clawhub:demo-plugin@2026.5.2", + defaultChoice: "clawhub", + }, + }, + prompter: { + select: vi.fn(async () => "clawhub"), + note: vi.fn(), + text, + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + } as never, + runtime: { log } as never, + }); + + expect(result.status).toBe("failed"); + expect(text).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('type the package name for "demo-plugin@2026.5.2"'), + }), + ); + const renderedWarning = log.mock.calls.map(([message]) => String(message)).join("\n"); + expect(renderedWarning).toContain("Security scan: suspicious"); + expect(renderedWarning).toContain("\n│ Review before installing."); + expect(renderedWarning).not.toContain("\\n│ Review before installing."); + expect(log.mock.invocationCallOrder[0]).toBeLessThan(text.mock.invocationCallOrder[0]); + }); + it("passes npm specs and optional expected integrity to npm installs with progress", async () => { const cfg: OpenClawConfig = { security: { diff --git a/src/commands/onboarding-plugin-install.ts b/src/commands/onboarding-plugin-install.ts index e9de43ecfccb..289847469264 100644 --- a/src/commands/onboarding-plugin-install.ts +++ b/src/commands/onboarding-plugin-install.ts @@ -115,6 +115,13 @@ function shouldFallbackClawHubToNpm(params: { ); } +function readInstallFailureWarning(result: InstallPluginFromClawHubResult): string | undefined { + if (result.ok || !("warning" in result) || typeof result.warning !== "string") { + return undefined; + } + return result.warning; +} + function resolveRealDirectory(dir: string): string | null { try { const resolved = fs.realpathSync(dir); @@ -678,6 +685,30 @@ function logInstallWarningWithSpacing(runtime: RuntimeEnv, message: string): voi runtime.log?.(`${sanitized}\n`); } +function logInstallWarningWithLineBreaks(runtime: RuntimeEnv, message: string): void { + const sanitized = message + .split("\n") + .map((line) => sanitizeTerminalText(line)) + .join("\n") + .trim(); + if (!sanitized) { + return; + } + runtime.log?.(`${sanitized}\n`); +} + +function isReviewRequiredClawHubTrustWarning(message: string): boolean { + return message.includes("WARNING - ClawHub found security risks"); +} + +function isClawHubTrustWarning(message: string): boolean { + return ( + isReviewRequiredClawHubTrustWarning(message) || + message.includes("BLOCKED - ClawHub") || + message.includes("REVIEW RECOMMENDED - ClawHub") + ); +} + async function installPluginFromNpmSpecWithProgress(params: { cfg: OpenClawConfig; entry: OnboardingPluginInstallEntry; @@ -969,6 +1000,11 @@ async function installPluginFromClawHubSpecWithProgress(params: { } animated.setLabel(shortenInstallLabel(sanitized)); }; + let renderedTrustWarning = false; + const renderTrustWarning = (message: string) => { + logInstallWarningWithLineBreaks(params.runtime, message); + renderedTrustWarning = true; + }; try { const { installPluginFromClawHub } = await import("../plugins/clawhub.js"); @@ -984,13 +1020,43 @@ async function installPluginFromClawHubSpecWithProgress(params: { info: updateProgress, warn: (message) => { updateProgress(message); + if (isReviewRequiredClawHubTrustWarning(message)) { + return; + } + if (isClawHubTrustWarning(message)) { + renderTrustWarning(message); + return; + } logInstallWarningWithSpacing(params.runtime, message); }, }, + onClawHubRisk: async (request) => { + animated.stop(); + progress.stop("Review ClawHub warning"); + renderTrustWarning(request.warning); + const packageName = sanitizeTerminalText(request.packageName); + const releaseLabel = `${packageName}@${sanitizeTerminalText(request.version)}`; + if (request.acknowledgementKind === "type-package") { + const answer = await params.prompter.text({ + message: `To install anyway, type the package name for "${releaseLabel}"`, + placeholder: packageName, + }); + return answer.trim() === packageName; + } + return await params.prompter.confirm({ + message: `Install ClawHub package "${releaseLabel}" after reviewing the warning above?`, + initialValue: false, + }); + }, }), ONBOARDING_PLUGIN_INSTALL_WATCHDOG_TIMEOUT_MS, ); animated.stop(); + const failureWarning = readInstallFailureWarning(result); + if (failureWarning && !renderedTrustWarning) { + progress.stop("Review ClawHub warning"); + renderTrustWarning(failureWarning); + } if (result.ok) { progress.stop(formatPluginInstalled(safeLabel)); } else { diff --git a/src/commands/runtime-plugin-install.ts b/src/commands/runtime-plugin-install.ts index 76f82c19e208..ba58e83abeab 100644 --- a/src/commands/runtime-plugin-install.ts +++ b/src/commands/runtime-plugin-install.ts @@ -160,7 +160,7 @@ export async function repairRuntimePluginInstallForModelSelection(params: { return { required: true, changes: result.changes, - warnings: result.warnings, + warnings: [...result.warnings, ...(result.notices ?? [])], }; } diff --git a/src/commands/sessions-cleanup.test.ts b/src/commands/sessions-cleanup.test.ts index 3681c01b8629..47a9cb0672e6 100644 --- a/src/commands/sessions-cleanup.test.ts +++ b/src/commands/sessions-cleanup.test.ts @@ -91,6 +91,7 @@ describe("sessionsCleanupCommand", () => { mocks.resolveMaintenanceConfig.mockReturnValue({ mode: "warn", pruneAfterMs: 7 * 24 * 60 * 60 * 1000, + modelRunPruneAfterMs: 24 * 60 * 60 * 1000, maxEntries: 500, resetArchiveRetentionMs: 7 * 24 * 60 * 60 * 1000, maxDiskBytes: null, @@ -126,6 +127,7 @@ describe("sessionsCleanupCommand", () => { cappedKeys: Set; budgetEvictedKeys: Set; dmScopeRetiredKeys: Set; + modelRunPrunedKeys?: Set; }) => { if (params.dmScopeRetiredKeys.has(params.key)) { return "retire-dm-scope"; @@ -192,6 +194,7 @@ describe("sessionsCleanupCommand", () => { afterCount: 1, missing: 0, dmScopeRetired: 0, + modelRunPruned: 0, pruned: 0, capped: 2, diskBudget: { @@ -231,6 +234,7 @@ describe("sessionsCleanupCommand", () => { afterCount: 1, missing: 0, dmScopeRetired: 0, + modelRunPruned: 0, pruned: 0, capped: 2, diskBudget: { @@ -267,6 +271,7 @@ describe("sessionsCleanupCommand", () => { afterCount: 1, missing: 0, dmScopeRetired: 0, + modelRunPruned: 0, pruned: 2, capped: 0, diskBudget: null, @@ -300,6 +305,7 @@ describe("sessionsCleanupCommand", () => { afterCount: 1, missing: 0, dmScopeRetired: 0, + modelRunPruned: 0, pruned: 2, capped: 0, diskBudget: null, @@ -323,6 +329,7 @@ describe("sessionsCleanupCommand", () => { afterCount: 1, missing: 0, dmScopeRetired: 0, + modelRunPruned: 0, pruned: 1, capped: 0, diskBudget: { @@ -343,6 +350,7 @@ describe("sessionsCleanupCommand", () => { cappedKeys: new Set(), budgetEvictedKeys: new Set(), dmScopeRetiredKeys: new Set(), + modelRunPrunedKeys: new Set(), }, ], appliedSummaries: [], @@ -367,6 +375,7 @@ describe("sessionsCleanupCommand", () => { afterCount: 1, missing: 0, dmScopeRetired: 0, + modelRunPruned: 0, pruned: 1, capped: 0, diskBudget: { @@ -400,6 +409,7 @@ describe("sessionsCleanupCommand", () => { afterCount: 0, missing: 1, dmScopeRetired: 0, + modelRunPruned: 0, pruned: 0, capped: 0, diskBudget: null, @@ -411,6 +421,7 @@ describe("sessionsCleanupCommand", () => { cappedKeys: new Set(), budgetEvictedKeys: new Set(), dmScopeRetiredKeys: new Set(), + modelRunPrunedKeys: new Set(), }, ], appliedSummaries: [], @@ -436,6 +447,7 @@ describe("sessionsCleanupCommand", () => { afterCount: 0, missing: 1, dmScopeRetired: 0, + modelRunPruned: 0, pruned: 0, capped: 0, diskBudget: null, @@ -458,6 +470,7 @@ describe("sessionsCleanupCommand", () => { afterCount: 1, missing: 0, dmScopeRetired: 0, + modelRunPruned: 0, pruned: 1, capped: 0, unreferencedArtifacts: { @@ -478,6 +491,7 @@ describe("sessionsCleanupCommand", () => { cappedKeys: new Set(), budgetEvictedKeys: new Set(), dmScopeRetiredKeys: new Set(), + modelRunPrunedKeys: new Set(), }, ], appliedSummaries: [], @@ -620,6 +634,7 @@ describe("sessionsCleanupCommand", () => { afterCount: 0, missing: 0, dmScopeRetired: 0, + modelRunPruned: 0, pruned: 1, capped: 0, diskBudget: null, @@ -631,6 +646,7 @@ describe("sessionsCleanupCommand", () => { cappedKeys: new Set(), budgetEvictedKeys: new Set(), dmScopeRetiredKeys: new Set(), + modelRunPrunedKeys: new Set(), }, { summary: { @@ -642,6 +658,7 @@ describe("sessionsCleanupCommand", () => { afterCount: 0, missing: 0, dmScopeRetired: 0, + modelRunPruned: 0, pruned: 1, capped: 0, diskBudget: null, @@ -653,6 +670,7 @@ describe("sessionsCleanupCommand", () => { cappedKeys: new Set(), budgetEvictedKeys: new Set(), dmScopeRetiredKeys: new Set(), + modelRunPrunedKeys: new Set(), }, ], appliedSummaries: [], @@ -683,6 +701,7 @@ describe("sessionsCleanupCommand", () => { afterCount: 0, missing: 0, dmScopeRetired: 0, + modelRunPruned: 0, pruned: 1, capped: 0, diskBudget: null, @@ -697,6 +716,7 @@ describe("sessionsCleanupCommand", () => { afterCount: 0, missing: 0, dmScopeRetired: 0, + modelRunPruned: 0, pruned: 1, capped: 0, diskBudget: null, diff --git a/src/commands/sessions-cleanup.ts b/src/commands/sessions-cleanup.ts index 6124ceab1566..d09f3591d909 100644 --- a/src/commands/sessions-cleanup.ts +++ b/src/commands/sessions-cleanup.ts @@ -59,6 +59,9 @@ function formatCleanupActionCell( if (action === "prune-missing") { return theme.error(label); } + if (action === "prune-model-run") { + return theme.warn(label); + } if (action === "prune-stale") { return theme.warn(label); } @@ -74,6 +77,7 @@ function formatCleanupActionCell( function buildActionRows(params: { beforeStore: Parameters[0]; missingKeys: Set; + modelRunPrunedKeys: Set; staleKeys: Set; cappedKeys: Set; budgetEvictedKeys: Set; @@ -87,6 +91,7 @@ function buildActionRows(params: { action: resolveSessionCleanupAction({ key: row.key, missingKeys: params.missingKeys, + modelRunPrunedKeys: params.modelRunPrunedKeys, staleKeys: params.staleKeys, cappedKeys: params.cappedKeys, budgetEvictedKeys: params.budgetEvictedKeys, @@ -154,6 +159,7 @@ function renderStoreDryRunPlan(params: { ); params.runtime.log(`Would prune missing transcripts: ${params.summary.missing}`); params.runtime.log(`Would retire stale direct DM sessions: ${params.summary.dmScopeRetired}`); + params.runtime.log(`Would prune stale model-run probes: ${params.summary.modelRunPruned}`); params.runtime.log(`Would prune stale: ${params.summary.pruned}`); params.runtime.log(`Would cap overflow: ${params.summary.capped}`); if (params.summary.unreferencedArtifacts?.scannedFiles) { diff --git a/src/commands/status-runtime-shared.test.ts b/src/commands/status-runtime-shared.test.ts index 4730ee71dbcd..98d3a8b048a0 100644 --- a/src/commands/status-runtime-shared.test.ts +++ b/src/commands/status-runtime-shared.test.ts @@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({ getDaemonStatusSummary: vi.fn(), getNodeDaemonStatusSummary: vi.fn(), resolveReadOnlyChannelPluginsForConfig: vi.fn(), + resolveModelAuthLabel: vi.fn(), })); vi.mock("../channels/plugins/read-only.js", () => ({ @@ -28,6 +29,10 @@ vi.mock("../infra/provider-usage.js", () => ({ loadProviderUsageSummary: mocks.loadProviderUsageSummary, })); +vi.mock("../agents/model-auth-label.js", () => ({ + resolveModelAuthLabel: mocks.resolveModelAuthLabel, +})); + vi.mock("../security/audit.runtime.js", () => ({ runSecurityAudit: mocks.runSecurityAudit, })); @@ -45,6 +50,8 @@ function requireProviderUsageCall(): { timeoutMs?: number; config?: unknown; agentDir?: string; + providers?: string[]; + auth?: Array>; } { const call = mocks.loadProviderUsageSummary.mock.calls[0]; if (!call) { @@ -58,6 +65,8 @@ function requireProviderUsageCall(): { timeoutMs?: number; config?: unknown; agentDir?: string; + providers?: string[]; + auth?: Array>; }; } @@ -69,6 +78,7 @@ describe("status-runtime-shared", () => { mocks.callGateway.mockResolvedValue({ ok: true }); mocks.getDaemonStatusSummary.mockResolvedValue({ label: "LaunchAgent" }); mocks.getNodeDaemonStatusSummary.mockResolvedValue({ label: "node" }); + mocks.resolveModelAuthLabel.mockReturnValue(undefined); mocks.resolveReadOnlyChannelPluginsForConfig.mockReturnValue({ plugins: [{ id: "telegram" }], configuredChannelIds: ["telegram"], @@ -134,6 +144,176 @@ describe("status-runtime-shared", () => { expect(usageCall.agentDir).toContain("main"); }); + it("adds Codex synthetic usage for configured OpenAI Codex runtime routes without profiles", async () => { + mocks.loadProviderUsageSummary + .mockResolvedValueOnce({ + updatedAt: 1, + providers: [ + { + provider: "anthropic", + displayName: "Claude", + windows: [], + error: "HTTP 429", + }, + ], + }) + .mockResolvedValueOnce({ + updatedAt: 2, + providers: [ + { + provider: "openai", + displayName: "OpenAI", + windows: [{ label: "5h", usedPercent: 9 }], + }, + ], + }); + + await expect( + resolveStatusUsageSummary({ + timeoutMs: 3456, + config: { + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + models: { + "openai/gpt-5.5": { agentRuntime: { id: "codex" } }, + }, + }, + }, + }, + agentDir: "/tmp/status-agent", + }), + ).resolves.toEqual({ + updatedAt: 1, + providers: [ + { + provider: "anthropic", + displayName: "Claude", + windows: [], + error: "HTTP 429", + }, + { + provider: "openai", + displayName: "OpenAI", + windows: [{ label: "5h", usedPercent: 9 }], + }, + ], + }); + + expect(mocks.loadProviderUsageSummary).toHaveBeenNthCalledWith(2, { + timeoutMs: 3456, + providers: ["openai"], + auth: [ + { + provider: "openai", + token: "codex-app-server", + hookProvider: "codex", + }, + ], + config: expect.any(Object), + agentDir: "/tmp/status-agent", + }); + }); + + it("keeps existing OpenAI usage when Codex synthetic usage has no windows", async () => { + mocks.loadProviderUsageSummary + .mockResolvedValueOnce({ + updatedAt: 1, + providers: [ + { + provider: "openai", + displayName: "OpenAI", + windows: [{ label: "5h", usedPercent: 22 }], + }, + ], + }) + .mockResolvedValueOnce({ + updatedAt: 2, + providers: [ + { + provider: "openai", + displayName: "OpenAI", + windows: [], + }, + ], + }); + + await expect( + resolveStatusUsageSummary({ + timeoutMs: 3456, + config: { + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + models: { + "openai/gpt-5.5": { agentRuntime: { id: "codex" } }, + }, + }, + }, + }, + agentDir: "/tmp/status-agent", + }), + ).resolves.toEqual({ + updatedAt: 1, + providers: [ + { + provider: "openai", + displayName: "OpenAI", + windows: [{ label: "5h", usedPercent: 22 }], + }, + ], + }); + }); + + it("does not add Codex synthetic usage for OpenAI routes pinned to OpenClaw runtime", async () => { + await resolveStatusUsageSummary({ + timeoutMs: 3456, + config: { + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + models: { + "openai/gpt-5.5": { agentRuntime: { id: "openclaw" } }, + }, + }, + }, + }, + agentDir: "/tmp/status-agent", + }); + + expect(mocks.loadProviderUsageSummary).toHaveBeenCalledOnce(); + expect(requireProviderUsageCall()).not.toHaveProperty("auth"); + }); + + it("does not add Codex synthetic usage for API-key-backed OpenAI Codex runtime routes", async () => { + mocks.resolveModelAuthLabel.mockReturnValue("api-key (openai:api)"); + + await resolveStatusUsageSummary({ + timeoutMs: 3456, + config: { + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + models: { + "openai/gpt-5.5": { agentRuntime: { id: "codex" } }, + }, + }, + }, + }, + agentDir: "/tmp/status-agent", + }); + + expect(mocks.loadProviderUsageSummary).toHaveBeenCalledOnce(); + expect(requireProviderUsageCall()).not.toHaveProperty("auth"); + expect(mocks.resolveModelAuthLabel).toHaveBeenCalledWith({ + provider: "openai", + acceptedProviderIds: ["openai"], + cfg: expect.any(Object), + agentDir: "/tmp/status-agent", + includeExternalProfiles: false, + }); + }); + it("resolves usage summaries with explicit agent scope", async () => { await resolveStatusUsageSummary({ timeoutMs: 2345, diff --git a/src/commands/status-runtime-shared.ts b/src/commands/status-runtime-shared.ts index 55296cc48ff3..499a25adeff5 100644 --- a/src/commands/status-runtime-shared.ts +++ b/src/commands/status-runtime-shared.ts @@ -1,10 +1,20 @@ // Shared runtime probes used by status text and JSON commands. // Heavy modules stay lazily loaded so fast status output avoids security/provider/gateway costs. +import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import { resolveDefaultAgentDir } from "../agents/agent-scope.js"; +import { resolveAgentHarnessPolicy } from "../agents/harness/policy.js"; +import { resolveModelAuthLabel } from "../agents/model-auth-label.js"; +import { resolveDefaultModelForAgent } from "../agents/model-selection.js"; +import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../agents/openai-routing.js"; import type { OpenClawConfig } from "../config/types.js"; import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; +import { + buildCodexSyntheticUsageAuth, + mergeUsageSummaries, + shouldUseCodexSyntheticUsageForRuntime, +} from "../status/codex-synthetic-usage.js"; import type { HealthSummary } from "./health.js"; import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js"; @@ -33,6 +43,58 @@ function loadGatewayCallModule() { return gatewayCallModuleLoader.load(); } +function resolveUsageCredentialType(authLabel?: string): "oauth" | "token" | "api_key" | undefined { + const auth = normalizeOptionalLowercaseString(authLabel); + if (!auth) { + return undefined; + } + if (auth.startsWith("oauth")) { + return "oauth"; + } + if (auth.startsWith("token")) { + return "token"; + } + if (auth.startsWith("api-key") || auth.startsWith("api key")) { + return "api_key"; + } + return undefined; +} + +function shouldUseConfiguredCodexSyntheticUsage(params: { + config: OpenClawConfig; + agentDir: string; +}): boolean { + const configuredDefault = resolveDefaultModelForAgent({ + cfg: params.config, + allowPluginNormalization: false, + }); + const policy = resolveAgentHarnessPolicy({ + config: params.config, + provider: configuredDefault.provider, + modelId: configuredDefault.model, + }); + if ( + !shouldUseCodexSyntheticUsageForRuntime({ + provider: configuredDefault.provider, + effectiveHarness: policy.runtime, + }) + ) { + return false; + } + const authLabel = resolveModelAuthLabel({ + provider: configuredDefault.provider, + acceptedProviderIds: listOpenAIAuthProfileProvidersForAgentRuntime({ + provider: configuredDefault.provider, + harnessRuntime: policy.runtime, + config: params.config, + }), + cfg: params.config, + agentDir: params.agentDir, + includeExternalProfiles: false, + }); + return resolveUsageCredentialType(authLabel) !== "api_key"; +} + /** Runs the lightweight security audit used by status JSON/all output. */ export async function resolveStatusSecurityAudit(params: { config: OpenClawConfig; @@ -69,11 +131,23 @@ type StatusUsageSummaryOptions = { /** Loads provider usage for status output, defaulting to the config's default agent directory. */ export async function resolveStatusUsageSummary(params: StatusUsageSummaryOptions) { const { loadProviderUsageSummary } = await loadProviderUsage(); - return await loadProviderUsageSummary({ + const agentDir = params.agentDir ?? resolveDefaultAgentDir(params.config); + const usage = await loadProviderUsageSummary({ timeoutMs: params.timeoutMs, config: params.config, - agentDir: params.agentDir ?? resolveDefaultAgentDir(params.config), + agentDir, }); + if (!shouldUseConfiguredCodexSyntheticUsage({ config: params.config, agentDir })) { + return usage; + } + const codexUsage = await loadProviderUsageSummary({ + timeoutMs: params.timeoutMs, + providers: ["openai"], + auth: [buildCodexSyntheticUsageAuth()], + config: params.config, + agentDir, + }); + return mergeUsageSummaries(usage, codexUsage); } /** Exposes the lazily loaded provider-usage module for callers that need its helpers. */ diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 5ca2268054d4..80a4b8a67417 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -3,7 +3,7 @@ import type { Mock } from "vitest"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import type { PluginCompatibilityNotice } from "../plugins/status.js"; import { createCompatibilityNotice } from "../plugins/status.test-helpers.js"; -import { captureEnv } from "../test-utils/env.js"; +import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js"; let envSnapshot: ReturnType; @@ -404,17 +404,17 @@ async function withOptionalEnvVar( ): Promise { const prevValue = process.env[key]; if (value === undefined) { - delete process.env[key]; + deleteTestEnvValue(key); } else { - process.env[key] = value; + setTestEnvValue(key, value); } try { return await run(); } finally { if (prevValue === undefined) { - delete process.env[key]; + deleteTestEnvValue(key); } else { - process.env[key] = prevValue; + setTestEnvValue(key, prevValue); } } } diff --git a/src/commands/tasks.test.ts b/src/commands/tasks.test.ts index 9eaba5f87aaa..20e5f1f0e83b 100644 --- a/src/commands/tasks.test.ts +++ b/src/commands/tasks.test.ts @@ -370,6 +370,125 @@ describe("tasks commands", () => { }); }); + it("preserves both cron-run session key shapes for a running non-slug job id", async () => { + await withTaskCommandStateDir(async (state) => { + const now = Date.now(); + const old = now - 8 * 24 * 60 * 60_000; + await saveCronStore(state.statePath("cron", "jobs.json"), { + version: 1, + jobs: [ + { + id: "Daily Report", + name: "Daily Report", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + sessionKey: "cron:daily-report", + wakeMode: "now", + payload: { kind: "agentTurn", message: "ping" }, + delivery: { mode: "none" }, + createdAtMs: now, + updatedAtMs: now, + state: { runningAtMs: now - 5_000 }, + }, + ], + }); + + const sessionsDir = state.sessionsDir("main"); + const storePath = path.join(sessionsDir, "sessions.json"); + await fs.mkdir(sessionsDir, { recursive: true }); + // A running job can be retargeted after its session is created, so maintenance must preserve + // both the raw and slugged historical shapes. + const slugKey = "agent:main:cron:daily-report:run:old-run"; + const rawKey = "agent:main:cron:daily report:run:old-run"; + const retiredKey = "agent:main:cron:retired-job:run:old-run"; + await fs.writeFile( + storePath, + JSON.stringify( + { + [slugKey]: { sessionId: "slug-run", updatedAt: old }, + [rawKey]: { sessionId: "raw-run", updatedAt: old }, + [retiredKey]: { sessionId: "retired-run", updatedAt: old }, + }, + null, + 2, + ), + "utf8", + ); + + const runtime = createRuntime(); + await tasksMaintenanceCommand({ json: true, apply: true }, runtime); + + const payload = readFirstJsonLog(runtime) as { + maintenance: { sessions: { runningCronJobs: number } }; + }; + expect(payload.maintenance.sessions.runningCronJobs).toBe(1); + const updated = JSON.parse(await fs.readFile(storePath, "utf8")) as Record; + expect(updated[slugKey]).toBeDefined(); + expect(updated[rawKey]).toBeDefined(); + expect(updated[retiredKey]).toBeUndefined(); + }); + }); + + it("preserves a running cron session with an explicit session key", async () => { + await withTaskCommandStateDir(async (state) => { + const now = Date.now(); + const old = now - 8 * 24 * 60 * 60_000; + await saveCronStore(state.statePath("cron", "jobs.json"), { + version: 1, + jobs: [ + { + id: "job-uuid", + name: "Daily monitor", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + sessionKey: "cron:daily-monitor", + wakeMode: "now", + payload: { kind: "agentTurn", message: "ping" }, + delivery: { mode: "none" }, + createdAtMs: now, + updatedAtMs: now, + state: { runningAtMs: now - 5_000 }, + }, + ], + }); + + const sessionsDir = state.sessionsDir("main"); + const storePath = path.join(sessionsDir, "sessions.json"); + await fs.mkdir(sessionsDir, { recursive: true }); + await fs.writeFile( + storePath, + JSON.stringify( + { + "agent:main:cron:daily-monitor:run:old-run": { + sessionId: "explicit-run", + updatedAt: old, + }, + "agent:main:cron:job-uuid:run:old-run": { + sessionId: "job-id-run", + updatedAt: old, + }, + "agent:main:cron:retired-job:run:old-run": { + sessionId: "retired-run", + updatedAt: old, + }, + }, + null, + 2, + ), + "utf8", + ); + + const runtime = createRuntime(); + await tasksMaintenanceCommand({ json: true, apply: true }, runtime); + + const updated = JSON.parse(await fs.readFile(storePath, "utf8")) as Record; + expect(updated["agent:main:cron:daily-monitor:run:old-run"]).toBeDefined(); + expect(updated["agent:main:cron:retired-job:run:old-run"]).toBeUndefined(); + }); + }); + it("does not build JSON-only diagnostics for text maintenance output", async () => { await withTaskCommandStateDir(async () => { const diagnosticsSpy = vi.spyOn( diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts index 578a6a9f0d79..a00a07752656 100644 --- a/src/commands/tasks.ts +++ b/src/commands/tasks.ts @@ -11,6 +11,7 @@ import { resolveAllAgentSessionStoreTargetsSync, runSessionRegistryMaintenanceForStore, } from "../config/sessions.js"; +import { normalizeCronLaneSegment } from "../cron/service/task-runs.js"; import { loadCronJobsStoreSync, resolveCronJobsStorePath } from "../cron/store.js"; import type { RuntimeEnv } from "../runtime.js"; import { getTaskById, updateTaskNotifyPolicyById } from "../tasks/runtime-internal.js"; @@ -128,17 +129,36 @@ type SessionRegistryMaintenanceSummary = { stores: SessionRegistryMaintenanceStoreSummary[]; }; -function readRunningCronJobIds(): Set { +function resolveExplicitCronSessionSegment(sessionKey: string | undefined): string | undefined { + const match = /^(?:agent:[^:]+:)?cron:([^:]+)$/u.exec(sessionKey?.trim() ?? ""); + return match?.[1]?.toLowerCase(); +} + +function readRunningCronJobIds(): { ids: Set; count: number } { try { const cronStorePath = resolveCronJobsStorePath(getRuntimeConfig().cron?.store); - return new Set( - loadCronJobsStoreSync(cronStorePath) - .jobs.filter((job) => typeof job.state?.runningAtMs === "number") - // Cron session keys are matched case-insensitively against job ids. - .map((job) => job.id.toLowerCase()), + const runningJobs = loadCronJobsStoreSync(cronStorePath).jobs.filter( + (job) => typeof job.state?.runningAtMs === "number", ); + return { + // A running job may have been retargeted after its session was created. Keep both historical + // shapes; the registry has no producer metadata, so retaining an ambiguous alias is safer + // than pruning a live transcript. + ids: new Set( + runningJobs.flatMap((job) => [ + job.id.toLowerCase(), + normalizeCronLaneSegment(job.id, "job"), + ...(job.sessionTarget !== "main" && job.sessionKey + ? [resolveExplicitCronSessionSegment(job.sessionKey)].filter( + (segment): segment is string => segment !== undefined, + ) + : []), + ]), + ), + count: runningJobs.length, + }; } catch { - return new Set(); + return { ids: new Set(), count: 0 }; } } @@ -146,13 +166,13 @@ async function runSessionRegistryMaintenance(params: { apply: boolean; }): Promise { const cfg = getRuntimeConfig(); - const runningCronJobIds = readRunningCronJobIds(); + const runningCronJobs = readRunningCronJobIds(); const stores: SessionRegistryMaintenanceStoreSummary[] = []; for (const target of resolveAllAgentSessionStoreTargetsSync(cfg)) { const result = await runSessionRegistryMaintenanceForStore({ apply: params.apply, retentionMs: SESSION_REGISTRY_RETENTION_MS, - runningCronJobIds, + runningCronJobIds: runningCronJobs.ids, storePath: target.storePath, }); stores.push({ @@ -166,7 +186,7 @@ async function runSessionRegistryMaintenance(params: { } return { retentionMs: SESSION_REGISTRY_RETENTION_MS, - runningCronJobs: runningCronJobIds.size, + runningCronJobs: runningCronJobs.count, pruned: stores.reduce((total, store) => total + store.pruned, 0), stores, }; diff --git a/src/config/io.audit.ts b/src/config/io.audit.ts index dbe9355ac329..4dce1f081f44 100644 --- a/src/config/io.audit.ts +++ b/src/config/io.audit.ts @@ -430,7 +430,7 @@ function resolveConfigAuditAppendRecord(params: ConfigAuditAppendParams): Config return redactSecrets(record as ConfigAuditRecord); } -type ConfigAuditScrubResult = { +export type ConfigAuditScrubResult = { scanned: number; rewritten: number; skipped: number; diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index cfb5e6de8216..613d6832c4f3 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1875,6 +1875,8 @@ export const FIELD_HELP: Record = { "Prefix text prepended to outbound assistant replies before sending to channels. Use for lightweight branding/context tags and avoid long prefixes that reduce content density.", "messages.usageTemplate": "Custom /usage full footer template, either an inline object or a JSON file path. Invalid or unavailable templates fall back to the built-in usage line.", + "messages.responseUsage": + 'Default per-reply usage footer mode ("off"|"tokens"|"full") seeded into sessions that have not chosen one via /usage. Also accepts "on" as a legacy alias for "tokens". Accepts a bare mode or a per-channel map with a "default" fallback. Precedence: session value -> channel entry -> default -> off; an explicit /usage choice (including off) is persisted and overrides the default. Use /usage reset (aliases: inherit, clear, default) to clear a session override and re-inherit this configured default.', "messages.groupChat": "Group-message handling controls including mention triggers and history window sizing. Keep mention patterns narrow so group channels do not trigger on every message.", "messages.groupChat.mentionPatterns": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 35cc9102816d..566c82fa46f1 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -968,6 +968,7 @@ export const FIELD_LABELS: Record = { "messages.visibleReplies": "Visible Replies", "messages.responsePrefix": "Outbound Response Prefix", "messages.usageTemplate": "Usage Footer Template", + "messages.responseUsage": "Default Usage Footer Mode", "messages.groupChat": "Group Chat Rules", "messages.groupChat.mentionPatterns": "Group Mention Patterns", "messages.groupChat.historyLimit": "Group History Limit", diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 6b95c5cc3455..8f45a92f8d8a 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -13,12 +13,16 @@ export * from "./sessions/reset.js"; export { canonicalizeSessionEntryAliases, deleteSessionEntryLifecycle, + patchSessionEntryWithKey, resetSessionEntryLifecycle, + resolveSessionEntryCandidateTarget, type CanonicalizeSessionEntryAliasesResult, type DeleteSessionEntryLifecycleParams, type DeleteSessionEntryLifecycleResult, + type ResolvedSessionEntryCandidateTarget, type ResetSessionEntryLifecycleParams, type ResetSessionEntryLifecycleResult, + type SessionEntryCandidateAccessScope, type SessionLifecycleArchivedTranscript, type SessionLifecycleStoreTarget, } from "./sessions/session-accessor.js"; diff --git a/src/config/sessions/cleanup-service.ts b/src/config/sessions/cleanup-service.ts index 6e63b7afac36..9bd5141a8d72 100644 --- a/src/config/sessions/cleanup-service.ts +++ b/src/config/sessions/cleanup-service.ts @@ -28,7 +28,9 @@ import { collectSessionMaintenancePreserveKeys } from "./store-maintenance-prese import { resolveMaintenanceConfig } from "./store-maintenance-runtime.js"; import { capEntryCount, + pruneStaleModelRunEntries, pruneStaleEntries, + shouldRunModelRunPrune, type ResolvedSessionMaintenanceConfig, } from "./store-maintenance.js"; import { loadSessionStore } from "./store.js"; @@ -51,6 +53,7 @@ export type SessionsCleanupOptions = SessionStoreSelectionOptions & { export type SessionCleanupAction = | "keep" | "prune-missing" + | "prune-model-run" | "prune-stale" | "cap-overflow" | "evict-budget" @@ -65,6 +68,7 @@ export type SessionCleanupSummary = { afterCount: number; missing: number; dmScopeRetired: number; + modelRunPruned: number; pruned: number; capped: number; unreferencedArtifacts: SessionUnreferencedArtifactSweepResult; @@ -89,6 +93,7 @@ export type SessionsCleanupRunResult = { summary: SessionCleanupSummary; beforeStore: Record; missingKeys: Set; + modelRunPrunedKeys: Set; staleKeys: Set; cappedKeys: Set; budgetEvictedKeys: Set; @@ -169,6 +174,7 @@ function transcriptHasNoMessageRecords(transcriptPath: string): boolean { export function resolveSessionCleanupAction(params: { key: string; missingKeys: Set; + modelRunPrunedKeys: Set; staleKeys: Set; cappedKeys: Set; budgetEvictedKeys: Set; @@ -180,6 +186,9 @@ export function resolveSessionCleanupAction(params: { if (params.missingKeys.has(params.key)) { return "prune-missing"; } + if (params.modelRunPrunedKeys.has(params.key)) { + return "prune-model-run"; + } if (params.staleKeys.has(params.key)) { return "prune-stale"; } @@ -333,6 +342,7 @@ async function previewStoreCleanup(params: { const staleKeys = new Set(); const cappedKeys = new Set(); const missingKeys = new Set(); + const modelRunPrunedKeys = new Set(); const dmScopeRetiredKeys = new Set(); const missing = params.fixMissing === true @@ -357,6 +367,22 @@ async function previewStoreCleanup(params: { }) : 0; const preserveSessionKeys = collectSessionMaintenancePreserveKeys([params.activeKey]); + const modelRunPruned = shouldRunModelRunPrune({ + maintenance: params.maintenance, + entryCount: Object.keys(previewStore).length, + // `sessions cleanup` applies the cap immediately (apply path forces maintenance and the + // preview caps unconditionally below), so mirror that here: prune stale probes before the + // forced cap can evict real sessions in their place. + force: true, + }) + ? pruneStaleModelRunEntries(previewStore, params.maintenance.modelRunPruneAfterMs, { + log: false, + preserveKeys: preserveSessionKeys, + onPruned: ({ key }) => { + modelRunPrunedKeys.add(key); + }, + }) + : 0; const pruned = pruneStaleEntries(previewStore, params.maintenance.pruneAfterMs, { log: false, preserveKeys: preserveSessionKeys, @@ -372,6 +398,12 @@ async function previewStoreCleanup(params: { }, }); const entryCleanupArtifactPaths = new Set(); + addEntryArtifactPathsToSet({ + paths: entryCleanupArtifactPaths, + store: beforeStore, + storePath: params.target.storePath, + keys: modelRunPrunedKeys, + }); addEntryArtifactPathsToSet({ paths: entryCleanupArtifactPaths, store: beforeStore, @@ -422,6 +454,7 @@ async function previewStoreCleanup(params: { const wouldMutate = missing > 0 || dmScopeRetired > 0 || + modelRunPruned > 0 || pruned > 0 || capped > 0 || unreferencedArtifacts.removedFiles > 0 || @@ -437,6 +470,7 @@ async function previewStoreCleanup(params: { afterCount: afterPreviewCount, missing, dmScopeRetired, + modelRunPruned, pruned, capped, unreferencedArtifacts, @@ -448,6 +482,7 @@ async function previewStoreCleanup(params: { summary, beforeStore, missingKeys, + modelRunPrunedKeys, staleKeys, cappedKeys, budgetEvictedKeys, @@ -577,6 +612,7 @@ export async function runSessionsCleanup(params: { afterCount: 0, missing: 0, dmScopeRetired: 0, + modelRunPruned: 0, pruned: 0, capped: 0, unreferencedArtifacts, @@ -599,6 +635,7 @@ export async function runSessionsCleanup(params: { afterCount: appliedReport.afterCount, missing: missingApplied, dmScopeRetired: dmScopeRetiredApplied, + modelRunPruned: appliedReport.modelRunPruned, pruned: appliedReport.pruned, capped: appliedReport.capped, unreferencedArtifacts, @@ -606,6 +643,7 @@ export async function runSessionsCleanup(params: { wouldMutate: missingApplied > 0 || dmScopeRetiredApplied > 0 || + appliedReport.modelRunPruned > 0 || appliedReport.pruned > 0 || appliedReport.capped > 0 || unreferencedArtifacts.removedFiles > 0 || diff --git a/src/config/sessions/runtime-types.ts b/src/config/sessions/runtime-types.ts index e6d3129ed535..300328c25d84 100644 --- a/src/config/sessions/runtime-types.ts +++ b/src/config/sessions/runtime-types.ts @@ -25,6 +25,7 @@ export type ResolvedSessionMaintenanceConfigRuntime = { mode: SessionMaintenanceMode; pruneAfterMs: number; maxEntries: number; + modelRunPruneAfterMs: number; resetArchiveRetentionMs: number | null; maxDiskBytes: number | null; highWaterBytes: number | null; @@ -34,6 +35,7 @@ export type SessionMaintenanceApplyReportRuntime = { mode: SessionMaintenanceMode; beforeCount: number; afterCount: number; + modelRunPruned: number; pruned: number; capped: number; diskBudget: Record | null; diff --git a/src/config/sessions/session-accessor.test.ts b/src/config/sessions/session-accessor.test.ts index 794c3646ec66..6ef67e05f920 100644 --- a/src/config/sessions/session-accessor.test.ts +++ b/src/config/sessions/session-accessor.test.ts @@ -11,6 +11,7 @@ import { appendTranscriptEvent, applySessionEntryLifecycleMutation, applySessionPatchProjection, + branchSessionFromCompactionCheckpoint, canonicalizeSessionEntryAliases, cleanupSessionLifecycleArtifacts, commitReplySessionInitialization, @@ -27,7 +28,9 @@ import { publishTranscriptUpdate, readSessionUpdatedAt, replaceSessionEntry, + resolveSessionEntryCandidateTarget, resolveSessionEntryAccessTarget, + restoreSessionFromCompactionCheckpoint, resolveSessionTranscriptReadTarget, resolveSessionTranscriptRuntimeReadTarget, resolveSessionTranscriptRuntimeTarget, @@ -224,6 +227,69 @@ describe("session accessor file-backed seam", () => { expect(persisted.main).toBeUndefined(); }); + it("resolves status-style ordered candidate keys without exposing the store", async () => { + fs.writeFileSync( + storePath, + JSON.stringify({ + "agent:main:current": { + label: "literal-current", + sessionId: "session-current", + updatedAt: 30, + }, + "agent:main:main": { + label: "main", + sessionId: "session-main", + updatedAt: 10, + }, + } satisfies Record), + "utf8", + ); + + const resolved = resolveSessionEntryCandidateTarget({ + agentId: "main", + candidateKeys: ["agent:main:main", "agent:main:current"], + cfg: { session: { store: storePath } }, + }); + + expect(resolved).toEqual({ + agentId: "main", + candidateKey: "agent:main:main", + entry: expect.objectContaining({ + label: "main", + sessionId: "session-main", + }), + persisted: true, + sessionKey: "agent:main:main", + }); + }); + + it("returns an implicit candidate fallback without persisting it", () => { + const resolved = resolveSessionEntryCandidateTarget({ + agentId: "main", + candidateKeys: ["agent:main:missing"], + cfg: { session: { store: storePath } }, + fallback: { + sessionKey: "agent:main:current", + entry: { + sessionId: "", + updatedAt: 40, + }, + }, + }); + + expect(resolved).toEqual({ + agentId: "main", + candidateKey: "agent:main:current", + entry: { + sessionId: "", + updatedAt: 40, + }, + persisted: false, + sessionKey: "agent:main:current", + }); + expect(fs.existsSync(storePath)).toBe(false); + }); + it("purges deleted-agent entries from the current locked store", async () => { const cfg = { session: { store: storePath }, @@ -887,6 +953,134 @@ describe("session accessor file-backed seam", () => { }); }); + it("branches checkpoint sessions without exposing mutable store rows", async () => { + const sourceSessionId = "11111111-1111-4111-8111-111111111111"; + const branchSessionId = "22222222-2222-4222-8222-222222222222"; + const branchPath = path.join(tempDir, "branch.jsonl"); + const now = Date.now(); + fs.writeFileSync(transcriptPath, `{"type":"session","id":"${sourceSessionId}"}\n`, "utf8"); + fs.writeFileSync(branchPath, `{"type":"session","id":"${branchSessionId}"}\n`, "utf8"); + const checkpoint = { + checkpointId: "checkpoint-1", + sessionKey: "agent:main:main", + sessionId: sourceSessionId, + createdAt: now, + reason: "manual", + preCompaction: { + sessionId: sourceSessionId, + sessionFile: transcriptPath, + leafId: "leaf-1", + }, + postCompaction: { sessionId: "33333333-3333-4333-8333-333333333333" }, + } satisfies NonNullable[number]; + fs.writeFileSync( + storePath, + JSON.stringify( + { + main: { + label: "Main", + sessionFile: transcriptPath, + sessionId: sourceSessionId, + updatedAt: now, + compactionCheckpoints: [checkpoint], + }, + } satisfies Record, + null, + 2, + ), + "utf8", + ); + + const result = await branchSessionFromCompactionCheckpoint({ + storePath, + sourceKey: "agent:main:main", + sourceStoreKey: "main", + nextKey: "agent:main:branch", + checkpointId: "checkpoint-1", + forkTranscriptFromCheckpoint: async (selectedCheckpoint) => { + expect(selectedCheckpoint).toEqual(checkpoint); + return { + status: "created", + transcript: { + sessionFile: branchPath, + sessionId: branchSessionId, + totalTokens: 42, + }, + }; + }, + buildEntry: ({ currentEntry, forkedTranscript }) => ({ + ...currentEntry, + sessionFile: forkedTranscript.sessionFile, + sessionId: forkedTranscript.sessionId, + totalTokens: forkedTranscript.totalTokens, + updatedAt: now + 1, + }), + }); + + expect(result).toMatchObject({ + status: "created", + key: "agent:main:branch", + entry: { + sessionFile: branchPath, + sessionId: branchSessionId, + totalTokens: 42, + }, + }); + expect(loadSessionStore(storePath)).toEqual({ + main: expect.objectContaining({ sessionId: sourceSessionId }), + "agent:main:branch": expect.objectContaining({ + sessionFile: branchPath, + sessionId: branchSessionId, + totalTokens: 42, + }), + }); + }); + + it("does not persist checkpoint restores when the transcript boundary is missing", async () => { + fs.writeFileSync( + storePath, + JSON.stringify( + { + "agent:main:main": { + sessionId: "session-1", + updatedAt: 10, + compactionCheckpoints: [ + { + checkpointId: "checkpoint-1", + sessionKey: "agent:main:main", + sessionId: "session-1", + createdAt: 20, + reason: "manual", + preCompaction: { + sessionId: "session-1", + leafId: "leaf-1", + }, + postCompaction: { sessionId: "session-2" }, + }, + ], + }, + } satisfies Record, + null, + 2, + ), + "utf8", + ); + + const before = fs.readFileSync(storePath, "utf8"); + const result = await restoreSessionFromCompactionCheckpoint({ + storePath, + sessionKey: "agent:main:main", + checkpointId: "checkpoint-1", + forkTranscriptFromCheckpoint: async () => ({ status: "missing-boundary" }), + buildEntry: () => { + throw new Error("missing boundary should skip entry replacement"); + }, + }); + + expect(result).toEqual({ status: "missing-boundary" }); + expect(fs.readFileSync(storePath, "utf8")).toBe(before); + }); + it("cleans scoped lifecycle entries and unreferenced transcript artifacts", async () => { const nowMs = Date.now(); const oldDate = new Date(nowMs - 600_000); diff --git a/src/config/sessions/session-accessor.ts b/src/config/sessions/session-accessor.ts index 444208e743cd..af53aa512d90 100644 --- a/src/config/sessions/session-accessor.ts +++ b/src/config/sessions/session-accessor.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import { acquireSessionWriteLock, resolveSessionWriteLockOptions, @@ -98,7 +99,7 @@ import { resolveOwnedSessionTranscriptWriteLockRunner, withOwnedSessionTranscriptWrites, } from "./transcript-write-context.js"; -import type { SessionEntry } from "./types.js"; +import type { SessionCompactionCheckpoint, SessionEntry } from "./types.js"; /** * Session access API for callers that need entries or transcripts without @@ -142,6 +143,8 @@ export type LogicalSessionAccessScope = { sessionKey: string; }; +type SessionEntryListScope = Partial>; + export type ResolvedSessionEntryAccessTarget = { /** Agent owner inferred from the canonical session key. */ agentId: string; @@ -159,6 +162,35 @@ type ResolvedSessionEntryStoreTarget = ResolvedSessionEntryAccessTarget & { storePath: string; }; +export type SessionEntryCandidateAccessScope = { + /** Agent owner whose session store is searched. */ + agentId: string; + /** Ordered session keys to test inside the resolved store. */ + candidateKeys: readonly string[]; + /** Runtime config whose session store rule selects the backend target. */ + cfg: OpenClawConfig; + /** Environment override used when resolving agent-scoped store paths in tests/tools. */ + env?: NodeJS.ProcessEnv; + /** Optional synthesized entry returned only when no candidate exists. */ + fallback?: { + entry: SessionEntry; + sessionKey: string; + }; +}; + +export type ResolvedSessionEntryCandidateTarget = { + /** Agent owner whose session store produced this result. */ + agentId: string; + /** Candidate key that selected the result, or the fallback key. */ + candidateKey: string; + /** Session metadata cloned from storage or from the synthesized fallback. */ + entry: SessionEntry; + /** False only for synthesized fallback entries that have not been written. */ + persisted: boolean; + /** Persisted key selected by the backend, or the fallback key. */ + sessionKey: string; +}; + export type ResolvedSessionEntryUpdateContext = Omit & { /** Mutable entry inside the storage operation. */ entry: SessionEntry; @@ -466,6 +498,121 @@ export type RestartRecoveryLifecycleUpdate = { replacements?: Iterable; }; +/** File-backed checkpoint transcript fork produced by the checkpoint storage boundary. */ +export type SessionCompactionCheckpointForkedTranscript = { + sessionFile: string; + sessionId: string; + totalTokens?: number; +}; + +/** Result of resolving and copying checkpoint transcript content for branch/restore. */ +export type SessionCompactionCheckpointTranscriptForkResult = + | { status: "created"; transcript: SessionCompactionCheckpointForkedTranscript } + | { status: "missing-boundary" } + | { status: "failed" }; + +/** Result of applying a checkpoint branch or restore mutation to session storage. */ +export type SessionCompactionCheckpointMutationResult = + | { + status: "created"; + key: string; + checkpoint: SessionCompactionCheckpoint; + entry: SessionEntry; + } + | { status: "missing-session" } + | { status: "missing-checkpoint" } + | { status: "missing-boundary" } + | { status: "failed" }; + +export type SessionCompactionCheckpointEntryBuildContext = { + /** Checkpoint row selected from the current persisted session entry. */ + checkpoint: SessionCompactionCheckpoint; + /** Persisted entry that owns the selected checkpoint. */ + currentEntry: SessionEntry; + /** Forked transcript identity created from the stored checkpoint boundary. */ + forkedTranscript: SessionCompactionCheckpointForkedTranscript; +}; + +export type SessionCompactionCheckpointTranscriptForker = ( + checkpoint: SessionCompactionCheckpoint, +) => Promise; + +export type SessionCompactionCheckpointEntryBuilder = ( + context: SessionCompactionCheckpointEntryBuildContext, +) => Promise | SessionEntry; + +export type BranchSessionFromCompactionCheckpointParams = { + /** Checkpoint id stored on the source session entry. */ + checkpointId: string; + /** Builds the branched session entry from the forked transcript. */ + buildEntry: SessionCompactionCheckpointEntryBuilder; + /** Copies transcript content through the stored checkpoint boundary. */ + forkTranscriptFromCheckpoint: SessionCompactionCheckpointTranscriptForker; + /** Persisted key for the new checkpoint branch. */ + nextKey: string; + /** Canonical key used as the branch parent. */ + sourceKey: string; + /** Actual persisted key to read when a legacy alias still owns the row. */ + sourceStoreKey?: string; + /** Explicit store target for file-backed stores and SQLite migration adapters. */ + storePath: string; +}; + +export type RestoreSessionFromCompactionCheckpointParams = { + /** Checkpoint id stored on the current session entry. */ + checkpointId: string; + /** Builds the restored session entry from the forked transcript. */ + buildEntry: SessionCompactionCheckpointEntryBuilder; + /** Copies transcript content through the stored checkpoint boundary. */ + forkTranscriptFromCheckpoint: SessionCompactionCheckpointTranscriptForker; + /** Canonical key to replace with the restored checkpoint state. */ + sessionKey: string; + /** Actual persisted key to read when a legacy alias still owns the row. */ + sessionStoreKey?: string; + /** Explicit store target for file-backed stores and SQLite migration adapters. */ + storePath: string; +}; + +export type TemporarySessionMappingPreservationResult = { + /** Result returned by the operation while the temporary mapping may exist. */ + result: T; + /** Snapshot failure; callers may continue when temporary cleanup is best-effort. */ + snapshotFailure?: string; + /** Restore/delete failure for the original temporary mapping state. */ + restoreFailure?: string; +}; + +type TemporarySessionMappingSnapshot = + | { + canRestore: false; + sessionKey: string; + snapshotFailure: string; + storePath: string; + } + | { + canRestore: true; + hadEntry: false; + sessionKey: string; + storePath: string; + } + | { + canRestore: true; + entry: SessionEntry; + hadEntry: true; + sessionKey: string; + storePath: string; + }; + +type TemporarySessionMappingOperationResult = + | { + ok: true; + result: T; + } + | { + error: unknown; + ok: false; + }; + export type SessionEntryCreateWithTranscriptContext = { /** Current entry under the requested key before creation, if any. */ existingEntry?: SessionEntry; @@ -632,6 +779,44 @@ export function resolveSessionEntryAccessTarget( }; } +/** Resolves ordered candidate keys inside one agent-owned session store. */ +export function resolveSessionEntryCandidateTarget( + scope: SessionEntryCandidateAccessScope, +): ResolvedSessionEntryCandidateTarget | null { + const storePath = resolveStorePath(scope.cfg.session?.store, { + agentId: scope.agentId, + env: scope.env, + }); + const store = loadSessionStore(storePath); + for (const candidateKey of uniqueStrings(scope.candidateKeys.map((key) => key.trim()))) { + if (!candidateKey) { + continue; + } + const resolved = resolveSessionStoreEntry({ store, sessionKey: candidateKey }); + if (!resolved.existing) { + continue; + } + return { + agentId: scope.agentId, + candidateKey, + entry: structuredClone(resolved.existing), + persisted: true, + sessionKey: resolved.normalizedKey, + }; + } + const fallbackKey = scope.fallback?.sessionKey.trim(); + if (!fallbackKey || !scope.fallback) { + return null; + } + return { + agentId: scope.agentId, + candidateKey: fallbackKey, + entry: structuredClone(scope.fallback.entry), + persisted: false, + sessionKey: fallbackKey, + }; +} + function resolveSessionEntryStoreTarget( scope: LogicalSessionAccessScope, ): ResolvedSessionEntryStoreTarget { @@ -734,9 +919,7 @@ export function loadSessionEntry(scope: SessionAccessScope): SessionEntry | unde } /** Lists entries from the resolved store, preserving the persisted key for each row. */ -export function listSessionEntries( - scope: Partial> = {}, -): SessionEntrySummary[] { +export function listSessionEntries(scope: SessionEntryListScope = {}): SessionEntrySummary[] { if (scope.clone === false) { return Object.entries( loadSessionStore(resolveAccessStorePath({ ...scope, sessionKey: "" }), { @@ -1164,6 +1347,103 @@ function applySessionAbortCutoff( entry.abortCutoffTimestamp = cutoff?.timestamp; } +function findSessionCompactionCheckpoint(params: { + checkpointId: string; + entry: SessionEntry; +}): SessionCompactionCheckpoint | undefined { + const checkpointId = params.checkpointId.trim(); + if (!checkpointId || !Array.isArray(params.entry.compactionCheckpoints)) { + return undefined; + } + return [...params.entry.compactionCheckpoints] + .toSorted((a, b) => b.createdAt - a.createdAt) + .find((checkpoint) => checkpoint.checkpointId === checkpointId); +} + +type ApplySessionCompactionCheckpointMutationParams = { + buildEntry: SessionCompactionCheckpointEntryBuilder; + checkpointId: string; + forkTranscriptFromCheckpoint: SessionCompactionCheckpointTranscriptForker; + readKey: string; + storePath: string; + writeKey: string; +}; + +async function applySessionCompactionCheckpointMutation( + params: ApplySessionCompactionCheckpointMutationParams, +): Promise { + return await updateSessionStore( + params.storePath, + async (store) => { + const currentEntry = store[params.readKey]; + if (!currentEntry?.sessionId) { + return { status: "missing-session" }; + } + const checkpoint = findSessionCompactionCheckpoint({ + entry: currentEntry, + checkpointId: params.checkpointId, + }); + if (!checkpoint) { + return { status: "missing-checkpoint" }; + } + const forkedSession = await params.forkTranscriptFromCheckpoint(checkpoint); + if (forkedSession.status !== "created") { + return forkedSession; + } + + const nextEntry = await params.buildEntry({ + checkpoint, + currentEntry, + forkedTranscript: forkedSession.transcript, + }); + store[params.writeKey] = nextEntry; + return { + status: "created", + key: params.writeKey, + checkpoint, + entry: nextEntry, + }; + }, + { skipSaveWhenResult: (result) => result.status !== "created" }, + ); +} + +/** + * Forks checkpoint transcript content and persists a new branch entry in one + * storage-sized mutation. SQLite adapters implement the transcript row copy + * and `session_entries.entry_json` insert inside the same write transaction. + */ +export async function branchSessionFromCompactionCheckpoint( + params: BranchSessionFromCompactionCheckpointParams, +): Promise { + return await applySessionCompactionCheckpointMutation({ + buildEntry: params.buildEntry, + checkpointId: params.checkpointId, + forkTranscriptFromCheckpoint: params.forkTranscriptFromCheckpoint, + readKey: params.sourceStoreKey ?? params.sourceKey, + storePath: params.storePath, + writeKey: params.nextKey, + }); +} + +/** + * Forks checkpoint transcript content and replaces the current entry in one + * storage-sized mutation. SQLite adapters implement the transcript row copy + * and `session_entries.entry_json` update inside the same write transaction. + */ +export async function restoreSessionFromCompactionCheckpoint( + params: RestoreSessionFromCompactionCheckpointParams, +): Promise { + return await applySessionCompactionCheckpointMutation({ + buildEntry: params.buildEntry, + checkpointId: params.checkpointId, + forkTranscriptFromCheckpoint: params.forkTranscriptFromCheckpoint, + readKey: params.sessionStoreKey ?? params.sessionKey, + storePath: params.storePath, + writeKey: params.sessionKey, + }); +} + /** * Applies a session patch projection through the accessor boundary. * The resolver sees a read-only snapshot and names the persisted key set; the @@ -1221,6 +1501,37 @@ export async function applyRestartRecoveryLifecycle(params: { return writerResult.result; } +/** + * Runs an operation while preserving one temporary session mapping. + * The storage backend snapshots exactly the named key before the operation and + * restores that entry, or deletes it when it did not previously exist, after + * the operation finishes. SQLite backends can implement the same named + * preservation lifecycle without exposing mutable store access to callers. + */ +export async function preserveTemporarySessionMapping( + scope: SessionAccessScope, + operation: () => Promise | T, +): Promise> { + const snapshot = snapshotTemporarySessionMapping(scope); + let operationResult: TemporarySessionMappingOperationResult; + try { + operationResult = { ok: true, result: await operation() }; + } catch (err) { + operationResult = { error: err, ok: false }; + } + + const restoreFailure = await restoreTemporarySessionMapping(snapshot); + if (!operationResult.ok) { + throw operationResult.error; + } + + return { + result: operationResult.result, + ...(snapshot.canRestore ? {} : { snapshotFailure: snapshot.snapshotFailure }), + ...(restoreFailure ? { restoreFailure } : {}), + }; +} + /** Removes entries and orphan transcript artifacts owned by a named session lifecycle. */ export async function cleanupSessionLifecycleArtifacts( params: SessionLifecycleArtifactCleanupParams, @@ -1465,6 +1776,7 @@ export async function commitReplySessionInitialization(params: { activeSessionKey: params.activeSessionKey, maintenanceConfig: params.maintenanceConfig, onWarn: params.onMaintenanceWarning, + reentrant: true, skipSaveWhenResult: (result) => !result.ok, }, ); @@ -2343,6 +2655,53 @@ function createFallbackSessionEntry(patch: Partial): SessionEntry }; } +function snapshotTemporarySessionMapping( + scope: SessionAccessScope, +): TemporarySessionMappingSnapshot { + const storePath = resolveAccessStorePath(scope); + try { + const store = loadSessionStore(storePath, { skipCache: true }); + const entry = store[scope.sessionKey]; + return { + canRestore: true, + ...(entry ? { entry: structuredClone(entry), hadEntry: true } : { hadEntry: false }), + sessionKey: scope.sessionKey, + storePath, + }; + } catch (err) { + return { + canRestore: false, + sessionKey: scope.sessionKey, + snapshotFailure: formatErrorMessage(err), + storePath, + }; + } +} + +async function restoreTemporarySessionMapping( + snapshot: TemporarySessionMappingSnapshot, +): Promise { + if (!snapshot.canRestore) { + return undefined; + } + try { + await updateSessionStore( + snapshot.storePath, + (store) => { + if (snapshot.hadEntry) { + store[snapshot.sessionKey] = structuredClone(snapshot.entry); + return; + } + delete store[snapshot.sessionKey]; + }, + { activeSessionKey: snapshot.sessionKey }, + ); + return undefined; + } catch (err) { + return formatErrorMessage(err); + } +} + function cleanupPreviousResetTranscripts(params: { agentId: string; previousEntry: SessionEntry; diff --git a/src/config/sessions/store-load.ts b/src/config/sessions/store-load.ts index cc3cfabf4acd..34eb0c2b828e 100644 --- a/src/config/sessions/store-load.ts +++ b/src/config/sessions/store-load.ts @@ -34,6 +34,8 @@ import { resolveMaintenanceConfig } from "./store-maintenance-runtime.js"; import { capEntryCount, pruneStaleEntries, + pruneStaleModelRunEntries, + shouldRunModelRunPrune, shouldRunSessionEntryMaintenance, type ResolvedSessionMaintenanceConfig, } from "./store-maintenance.js"; @@ -436,9 +438,24 @@ export function loadSessionStore( if (opts.runMaintenance) { const maintenance = opts.maintenanceConfig ?? resolveMaintenanceConfig(); const beforeCount = Object.keys(store).length; + let modelRunPruned = 0; let pruned = 0; let capped = 0; - if (maintenance.mode === "enforce" && beforeCount > maintenance.maxEntries) { + if (maintenance.mode === "enforce") { + const preserveSessionKeys = collectSessionMaintenancePreserveKeys(); + if ( + shouldRunModelRunPrune({ + maintenance, + entryCount: beforeCount, + }) + ) { + modelRunPruned = pruneStaleModelRunEntries(store, maintenance.modelRunPruneAfterMs, { + log: false, + preserveKeys: preserveSessionKeys, + }); + } + } + if (maintenance.mode === "enforce" && Object.keys(store).length > maintenance.maxEntries) { const preserveSessionKeys = collectSessionMaintenancePreserveKeys(); pruned = pruneStaleEntries(store, maintenance.pruneAfterMs, { log: false, @@ -456,12 +473,13 @@ export function loadSessionStore( : 0; } const afterCount = Object.keys(store).length; - if (pruned > 0 || capped > 0) { + if (modelRunPruned > 0 || pruned > 0 || capped > 0) { serializedFromDisk = undefined; log.info("applied load-time maintenance to session store", { storePath, before: beforeCount, after: afterCount, + modelRunPruned, pruned, capped, maxEntries: maintenance.maxEntries, diff --git a/src/config/sessions/store-maintenance-operations.ts b/src/config/sessions/store-maintenance-operations.ts index 24d45dc4d3fe..76eeed8f632f 100644 --- a/src/config/sessions/store-maintenance-operations.ts +++ b/src/config/sessions/store-maintenance-operations.ts @@ -6,7 +6,9 @@ import { resolveMaintenanceConfig } from "./store-maintenance-runtime.js"; import { capEntryCount, getActiveSessionMaintenanceWarning, + pruneStaleModelRunEntries, pruneStaleEntries, + shouldRunModelRunPrune, shouldRunSessionEntryMaintenance, type ResolvedSessionMaintenanceConfig, type SessionMaintenanceWarning, @@ -17,6 +19,7 @@ export type SessionMaintenanceApplyReport = { mode: ResolvedSessionMaintenanceConfig["mode"]; beforeCount: number; afterCount: number; + modelRunPruned: number; pruned: number; capped: number; diskBudget: SessionDiskBudgetSweepResult | null; @@ -133,6 +136,7 @@ async function applyWarnOnlyMaintenance(params: { mode: params.maintenance.mode, beforeCount: params.beforeCount, afterCount: Object.keys(params.operation.store).length, + modelRunPruned: 0, pruned: 0, capped: 0, diskBudget, @@ -195,6 +199,18 @@ async function applyEnforcedMaintenance(params: { params.operation.activeSessionKey, ]); const removedSessionFiles = new Map(); + const modelRunPruned = shouldRunModelRunPrune({ + maintenance: params.maintenance, + entryCount: params.beforeCount, + force: params.forceMaintenance, + }) + ? pruneStaleModelRunEntries(params.operation.store, params.maintenance.modelRunPruneAfterMs, { + onPruned: ({ entry }) => { + rememberRemovedSessionFile(removedSessionFiles, entry); + }, + preserveKeys: preserveSessionKeys, + }) + : 0; const pruned = pruneStaleEntries(params.operation.store, params.maintenance.pruneAfterMs, { onPruned: ({ entry }) => { rememberRemovedSessionFile(removedSessionFiles, entry); @@ -240,12 +256,14 @@ async function applyEnforcedMaintenance(params: { mode: params.maintenance.mode, beforeCount: params.beforeCount, afterCount: Object.keys(params.operation.store).length, + modelRunPruned, pruned, capped, diskBudget, }); return { - changedStore: pruned > 0 || capped > 0 || (diskBudget?.removedEntries ?? 0) > 0, + changedStore: + modelRunPruned > 0 || pruned > 0 || capped > 0 || (diskBudget?.removedEntries ?? 0) > 0, }; } diff --git a/src/config/sessions/store-maintenance.ts b/src/config/sessions/store-maintenance.ts index 3a580fa280a2..a0d1090f6380 100644 --- a/src/config/sessions/store-maintenance.ts +++ b/src/config/sessions/store-maintenance.ts @@ -19,6 +19,7 @@ import type { SessionEntry } from "./types.js"; const log = createSubsystemLogger("sessions/store"); const DEFAULT_SESSION_PRUNE_AFTER_MS = 30 * 24 * 60 * 60 * 1000; +const DEFAULT_MODEL_RUN_PRUNE_AFTER_MS = 24 * 60 * 60 * 1000; const DEFAULT_SESSION_MAX_ENTRIES = 500; const DEFAULT_SESSION_MAINTENANCE_MODE: SessionMaintenanceMode = "enforce"; const DEFAULT_SESSION_DISK_BUDGET_HIGH_WATER_RATIO = 0.8; @@ -40,11 +41,18 @@ export type ResolvedSessionMaintenanceConfig = { mode: SessionMaintenanceMode; pruneAfterMs: number; maxEntries: number; + modelRunPruneAfterMs: number; resetArchiveRetentionMs: number | null; maxDiskBytes: number | null; highWaterBytes: number | null; }; +export type ResolvedSessionMaintenanceConfigInput = Omit< + ResolvedSessionMaintenanceConfig, + "modelRunPruneAfterMs" +> & + Partial>; + function resolvePruneAfterMs(maintenance?: SessionMaintenanceConfig): number { const raw = maintenance?.pruneAfter ?? maintenance?.pruneDays; const normalized = normalizeStringifiedOptionalString(raw); @@ -138,12 +146,22 @@ export function resolveMaintenanceConfigFromInput( mode: maintenance?.mode ?? DEFAULT_SESSION_MAINTENANCE_MODE, pruneAfterMs, maxEntries: maintenance?.maxEntries ?? DEFAULT_SESSION_MAX_ENTRIES, + modelRunPruneAfterMs: DEFAULT_MODEL_RUN_PRUNE_AFTER_MS, resetArchiveRetentionMs: resolveResetArchiveRetentionMs(maintenance, pruneAfterMs), maxDiskBytes, highWaterBytes: resolveHighWaterBytes(maintenance, maxDiskBytes), }; } +export function normalizeResolvedMaintenanceConfigInput( + maintenance: ResolvedSessionMaintenanceConfigInput, +): ResolvedSessionMaintenanceConfig { + return { + ...maintenance, + modelRunPruneAfterMs: maintenance.modelRunPruneAfterMs ?? DEFAULT_MODEL_RUN_PRUNE_AFTER_MS, + }; +} + export function resolveSessionEntryMaintenanceHighWater(maxEntries: number): number { if (!Number.isSafeInteger(maxEntries) || maxEntries <= 0) { return 1; @@ -170,6 +188,50 @@ export function shouldRunSessionEntryMaintenance(params: { return params.entryCount >= resolveSessionEntryMaintenanceHighWater(params.maxEntries); } +export function shouldRunModelRunPrune(params: { + maintenance: Pick; + entryCount: number; + /** + * True when the caller caps immediately to `maxEntries` in the same pass (forced + * maintenance / `sessions cleanup`) rather than using the batched high-water trigger. + */ + force?: boolean; +}): boolean { + // Model-run cleanup is pressure-gated, and must align with whichever cap step runs alongside it. + // Forced maintenance caps immediately down to `maxEntries`, so prune stale probes first whenever + // that cap would actually evict; otherwise stale probes would survive while real sessions get + // capped (the inverse of #88632). Batched runtime writes instead use the high-water trigger. + if (params.force) { + return params.entryCount > params.maintenance.maxEntries; + } + return shouldRunSessionEntryMaintenance({ + entryCount: params.entryCount, + maxEntries: params.maintenance.maxEntries, + }); +} + +export function isGatewayModelRunSessionKey(sessionKey: string): boolean { + const match = + /^agent:([^:\s]+):explicit:model-run-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.exec( + sessionKey, + ); + if (!match) { + return false; + } + const agentId = match[1]; + if (!agentId || /\s/.test(agentId)) { + return false; + } + const parsed = parseAgentSessionKey(sessionKey); + if (!parsed || parsed.agentId !== agentId.toLowerCase()) { + return false; + } + const rest = normalizeLowercaseStringOrEmpty(parsed.rest); + return /^explicit:model-run-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test( + rest, + ); +} + /** * Remove entries whose `updatedAt` is older than the configured threshold. * Entries without `updatedAt` are kept (cannot determine staleness). @@ -203,6 +265,47 @@ export function pruneStaleEntries( return pruned; } +/** + * Remove stale one-shot gateway model-run probe sessions before normal retention/capping. + * Existing polluted stores may not carry modelRun metadata, so this intentionally keys off the + * strict explicit model-run UUID session shape created by the gateway probe CLI path. + */ +export function pruneStaleModelRunEntries( + store: Record, + overrideMaxAgeMs?: number | null, + opts: { + log?: boolean; + onPruned?: (params: { key: string; entry: SessionEntry }) => void; + preserveKeys?: ReadonlySet; + } = {}, +): number { + if (overrideMaxAgeMs == null) { + return 0; + } + const cutoffMs = Date.now() - overrideMaxAgeMs; + let pruned = 0; + for (const [key, entry] of Object.entries(store)) { + if (opts.preserveKeys?.has(key) === true) { + continue; + } + if (!isGatewayModelRunSessionKey(key)) { + continue; + } + if (entry?.updatedAt != null && entry.updatedAt < cutoffMs) { + opts.onPruned?.({ key, entry }); + delete store[key]; + pruned++; + } + } + if (pruned > 0 && opts.log !== false) { + log.info("pruned stale gateway model-run session entries", { + pruned, + maxAgeMs: overrideMaxAgeMs, + }); + } + return pruned; +} + export const DEFAULT_QUOTA_SUSPENSION_TTL_MS = 30 * 60 * 1000; // 30 minutes const QUOTA_SUSPENSION_CLEANUP_FACTOR = 2; // entries beyond N*ttl are deleted outright diff --git a/src/config/sessions/store-writer.test.ts b/src/config/sessions/store-writer.test.ts index 1cd370f69328..4590e5945c22 100644 --- a/src/config/sessions/store-writer.test.ts +++ b/src/config/sessions/store-writer.test.ts @@ -36,6 +36,76 @@ describe("session store writer", () => { expect(getSessionStoreWriterQueueSizeForTest()).toBe(0); }); + it("runs nested writes for the active store without requeueing behind itself", async () => { + const storePath = "/tmp/openclaw-store.json"; + const order: string[] = []; + + const result = await runExclusiveSessionStoreWrite(storePath, async () => { + order.push("outer:start"); + const nested = await runExclusiveSessionStoreWrite( + storePath, + async () => { + order.push("inner"); + return "nested-result"; + }, + { reentrant: true }, + ); + order.push("outer:end"); + return nested; + }); + + expect(result).toBe("nested-result"); + expect(order).toEqual(["outer:start", "inner", "outer:end"]); + expect(getSessionStoreWriterQueueSizeForTest()).toBe(0); + }); + + it("does not leak active writer state to async children after the writer returns", async () => { + const storePath = "/tmp/openclaw-store.json"; + const order: string[] = []; + let releaseChild = () => {}; + const childReleased = new Promise((resolve) => { + releaseChild = resolve; + }); + let child: Promise = Promise.resolve("not-started"); + + await runExclusiveSessionStoreWrite(storePath, async () => { + child = (async () => { + await childReleased; + return await runExclusiveSessionStoreWrite(storePath, async () => { + order.push("child"); + return "child-result"; + }); + })(); + }); + + let releaseBlocker = () => {}; + const blockerReleased = new Promise((resolve) => { + releaseBlocker = resolve; + }); + let markBlockerStarted = () => {}; + const blockerStarted = new Promise((resolve) => { + markBlockerStarted = resolve; + }); + const blocker = runExclusiveSessionStoreWrite(storePath, async () => { + order.push("blocker:start"); + markBlockerStarted(); + await blockerReleased; + order.push("blocker:end"); + }); + await blockerStarted; + + releaseChild(); + await Promise.resolve(); + expect(order).toEqual(["blocker:start"]); + + releaseBlocker(); + await Promise.all([blocker, child]); + + expect(order).toEqual(["blocker:start", "blocker:end", "child"]); + expect(await child).toBe("child-result"); + expect(getSessionStoreWriterQueueSizeForTest()).toBe(0); + }); + it("rejects empty store paths before enqueuing work", async () => { await expect(runExclusiveSessionStoreWrite("", async () => undefined)).rejects.toThrow( /storePath must be a non-empty string/, diff --git a/src/config/sessions/store-writer.ts b/src/config/sessions/store-writer.ts index a015566821f2..605b686de694 100644 --- a/src/config/sessions/store-writer.ts +++ b/src/config/sessions/store-writer.ts @@ -2,14 +2,20 @@ import { runQueuedStoreWrite } from "../../shared/store-writer-queue.js"; import { WRITER_QUEUES } from "./store-writer-state.js"; +export type RunExclusiveSessionStoreWriteOptions = { + reentrant?: boolean; +}; + export async function runExclusiveSessionStoreWrite( storePath: string, fn: () => Promise, + opts: RunExclusiveSessionStoreWriteOptions = {}, ): Promise { return await runQueuedStoreWrite({ queues: WRITER_QUEUES, storePath, label: "runExclusiveSessionStoreWrite", fn, + reentrant: opts.reentrant, }); } diff --git a/src/config/sessions/store.pruning.integration.test.ts b/src/config/sessions/store.pruning.integration.test.ts index 03d5ae80d8d7..d99872bf694e 100644 --- a/src/config/sessions/store.pruning.integration.test.ts +++ b/src/config/sessions/store.pruning.integration.test.ts @@ -33,6 +33,7 @@ const ENFORCED_MAINTENANCE_OVERRIDE = { mode: "enforce" as const, pruneAfterMs: 7 * DAY_MS, maxEntries: 500, + modelRunPruneAfterMs: DAY_MS, resetArchiveRetentionMs: 7 * DAY_MS, maxDiskBytes: null, highWaterBytes: null, @@ -133,6 +134,126 @@ describe("Integration: saveSessionStore with pruning", () => { } }); + it("saveSessionStore prunes stale model-run probes before capping real sessions", async () => { + const now = Date.now(); + const staleModelRun = "agent:main:explicit:model-run-123e4567-e89b-12d3-a456-426614174000"; + const recentModelRun = "agent:main:explicit:model-run-123e4567-e89b-12d3-a456-426614174001"; + const normalRecent = "agent:main:explicit:normal-recent"; + const store: Record = { + [staleModelRun]: makeEntry(now - 2 * DAY_MS), + [recentModelRun]: makeEntry(now), + [normalRecent]: makeEntry(now - 2 * DAY_MS), + }; + + await saveSessionStore(storePath, store, { + maintenanceOverride: { + ...ENFORCED_MAINTENANCE_OVERRIDE, + pruneAfterMs: 30 * DAY_MS, + maxEntries: 2, + }, + }); + + const loaded = loadSessionStore(storePath, { skipCache: true }); + expect(loaded[staleModelRun]).toBeUndefined(); + expect(loaded).toHaveProperty(recentModelRun); + expect(loaded).toHaveProperty(normalRecent); + }); + + it("sessions cleanup dry-run and apply report stale model-run probe pruning", async () => { + const now = Date.now(); + const staleModelRun = "agent:main:explicit:model-run-123e4567-e89b-12d3-a456-426614174010"; + const recentModelRun = "agent:main:explicit:model-run-123e4567-e89b-12d3-a456-426614174011"; + const store: Record = { + [staleModelRun]: makeEntry(now - 2 * DAY_MS), + [recentModelRun]: makeEntry(now), + }; + await fs.writeFile(storePath, JSON.stringify(store), "utf-8"); + + const cfg = { session: { store: storePath } }; + mockLoadConfig.mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "30d", + maxEntries: 500, + }, + }, + }); + const defaultDryRun = await runSessionsCleanup({ + cfg, + opts: { dryRun: true, enforce: true }, + targets: [{ agentId: "main", storePath }], + }); + + expect(defaultDryRun.previewResults[0]?.summary.modelRunPruned).toBe(0); + expect(loadSessionStore(storePath, { skipCache: true })).toHaveProperty(staleModelRun); + + mockLoadConfig.mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "30d", + maxEntries: 1, + }, + }, + }); + const dryRun = await runSessionsCleanup({ + cfg, + opts: { dryRun: true, enforce: true }, + targets: [{ agentId: "main", storePath }], + }); + + expect(dryRun.previewResults[0]?.summary.modelRunPruned).toBe(1); + expect(loadSessionStore(storePath, { skipCache: true })).toHaveProperty(staleModelRun); + + const applied = await runSessionsCleanup({ + cfg, + opts: { dryRun: false, enforce: true }, + targets: [{ agentId: "main", storePath }], + }); + + expect(applied.appliedSummaries[0]?.modelRunPruned).toBe(1); + const loaded = loadSessionStore(storePath, { skipCache: true }); + expect(loaded[staleModelRun]).toBeUndefined(); + expect(loaded).toHaveProperty(recentModelRun); + }); + + it("saveSessionStore pressure-gates unset default model-run pruning", async () => { + const now = Date.now(); + const staleModelRun = "agent:main:explicit:model-run-123e4567-e89b-12d3-a456-426614174020"; + const recentModelRun = "agent:main:explicit:model-run-123e4567-e89b-12d3-a456-426614174021"; + + mockLoadConfig.mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "30d", + maxEntries: 500, + }, + }, + }); + await saveSessionStore(storePath, { + [staleModelRun]: makeEntry(now - 2 * DAY_MS), + [recentModelRun]: makeEntry(now), + }); + expect(loadSessionStore(storePath, { skipCache: true })).toHaveProperty(staleModelRun); + + mockLoadConfig.mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "30d", + maxEntries: 1, + }, + }, + }); + await saveSessionStore(storePath, loadSessionStore(storePath, { skipCache: true })); + + const loaded = loadSessionStore(storePath, { skipCache: true }); + expect(loaded[staleModelRun]).toBeUndefined(); + expect(loaded).toHaveProperty(recentModelRun); + }); + it("saveSessionStore prunes stale entries on write", async () => { applyEnforcedMaintenanceConfig(mockLoadConfig); diff --git a/src/config/sessions/store.pruning.test.ts b/src/config/sessions/store.pruning.test.ts index d2a0a79e3d3f..5b51fba28356 100644 --- a/src/config/sessions/store.pruning.test.ts +++ b/src/config/sessions/store.pruning.test.ts @@ -8,12 +8,19 @@ import { registerSessionMaintenancePreserveKeysProvider, } from "./store-maintenance-preserve.js"; import { + isGatewayModelRunSessionKey, isProtectedSessionMaintenanceEntry, resolveMaintenanceConfigFromInput, resolveQuotaSuspensionEntryMaintenance, resolveSessionEntryMaintenanceHighWater, + shouldRunModelRunPrune, } from "./store-maintenance.js"; -import { capEntryCount, getActiveSessionMaintenanceWarning, pruneStaleEntries } from "./store.js"; +import { + capEntryCount, + getActiveSessionMaintenanceWarning, + pruneStaleEntries, + pruneStaleModelRunEntries, +} from "./store.js"; import type { SessionEntry } from "./types.js"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -178,6 +185,7 @@ describe("applyFileBackedSessionStoreMaintenance", () => { mode: "enforce", pruneAfterMs: 7 * DAY_MS, maxEntries: 500, + modelRunPruneAfterMs: DAY_MS, resetArchiveRetentionMs: null, maxDiskBytes: null, highWaterBytes: null, @@ -214,6 +222,164 @@ describe("applyFileBackedSessionStoreMaintenance", () => { ]); expect(trajectoryCleanupReferencedIds).toEqual(new Set(["shared-session", "active-session"])); }); + + it("forced cleanup prunes stale model-run probes before the cap evicts real sessions", async () => { + const now = Date.now(); + const staleProbe = "agent:main:explicit:model-run-123e4567-e89b-12d3-a456-426614174099"; + const store: Record = { + [staleProbe]: makeEntry(now - 2 * DAY_MS), + }; + for (let i = 0; i < 50; i++) { + store[`agent:main:explicit:real-${i}`] = makeEntry(now - 3 * DAY_MS); + } + let report: { modelRunPruned: number; pruned: number; capped: number } | undefined; + + const result = await applyFileBackedSessionStoreMaintenance({ + storePath: "/tmp/openclaw-sessions/sessions.json", + store, + maintenanceConfig: { + mode: "enforce", + pruneAfterMs: 7 * DAY_MS, + maxEntries: 50, + modelRunPruneAfterMs: DAY_MS, + resetArchiveRetentionMs: null, + maxDiskBytes: null, + highWaterBytes: null, + }, + maintenanceOverride: { mode: "enforce" }, + onMaintenanceApplied: (applied) => { + report = { + modelRunPruned: applied.modelRunPruned, + pruned: applied.pruned, + capped: applied.capped, + }; + }, + log: { warn: () => {}, info: () => {} }, + artifacts: { + archiveRemovedSessionTranscripts: async () => new Set(), + removeRemovedSessionTrajectoryArtifacts: async () => {}, + cleanupArchivedSessionTranscripts: async () => {}, + }, + }); + + expect(result.changedStore).toBe(true); + expect(report?.modelRunPruned).toBe(1); + expect(report?.capped).toBe(0); + expect(store[staleProbe]).toBeUndefined(); + expect(Object.keys(store)).toHaveLength(50); + for (let i = 0; i < 50; i++) { + expect(store).toHaveProperty(`agent:main:explicit:real-${i}`); + } + }); +}); + +describe("pruneStaleModelRunEntries", () => { + it("removes only stale generated gateway model-run sessions", () => { + const now = Date.now(); + const staleModelRun = "agent:main:explicit:model-run-123e4567-e89b-12d3-a456-426614174000"; + const recentModelRun = "agent:main:explicit:model-run-123e4567-e89b-12d3-a456-426614174001"; + const store = makeStore([ + [staleModelRun, makeEntry(now - 25 * 60 * 60 * 1000)], + [recentModelRun, makeEntry(now)], + ["agent:main:explicit:model-run-not-a-uuid", makeEntry(now - 10 * DAY_MS)], + [ + "agent:main:explicit:model-runner-123e4567-e89b-12d3-a456-426614174002", + makeEntry(now - 10 * DAY_MS), + ], + ["agent:main:telegram:group:-100123:topic:77", makeEntry(now - 10 * DAY_MS)], + ["agent:main:cron:job:run:123", makeEntry(now - 10 * DAY_MS)], + ]); + + const pruned = pruneStaleModelRunEntries(store, DAY_MS); + + expect(pruned).toBe(1); + expect(store[staleModelRun]).toBeUndefined(); + expect(store).toHaveProperty(recentModelRun); + expect(store).toHaveProperty("agent:main:explicit:model-run-not-a-uuid"); + expect(store).toHaveProperty( + "agent:main:explicit:model-runner-123e4567-e89b-12d3-a456-426614174002", + ); + expect(store).toHaveProperty("agent:main:telegram:group:-100123:topic:77"); + expect(store).toHaveProperty("agent:main:cron:job:run:123"); + }); + + it("honors preserve keys and disabled retention", () => { + const staleModelRun = "agent:main:explicit:model-run-123e4567-e89b-12d3-a456-426614174000"; + const store = makeStore([[staleModelRun, makeEntry(Date.now() - 10 * DAY_MS)]]); + + expect( + pruneStaleModelRunEntries(store, DAY_MS, { preserveKeys: new Set([staleModelRun]) }), + ).toBe(0); + expect(store).toHaveProperty(staleModelRun); + expect(pruneStaleModelRunEntries(store, null)).toBe(0); + expect(store).toHaveProperty(staleModelRun); + }); + + it("matches only explicit model-run uuid session keys", () => { + expect( + isGatewayModelRunSessionKey( + "agent:main:explicit:model-run-123e4567-e89b-12d3-a456-426614174000", + ), + ).toBe(true); + expect(isGatewayModelRunSessionKey("agent:main:explicit:model-run-not-a-uuid")).toBe(false); + expect( + isGatewayModelRunSessionKey( + "agent:main:explicit:model-runner-123e4567-e89b-12d3-a456-426614174000", + ), + ).toBe(false); + }); + + it("rejects non-canonical session keys that do not parse as agent-scoped", () => { + // Unscoped: missing `agent::` prefix — parseAgentSessionKey returns null. + expect( + isGatewayModelRunSessionKey("explicit:model-run-123e4567-e89b-12d3-a456-426614174000"), + ).toBe(false); + // Empty agent id segment: not a canonical `agent::` scoped key. + expect( + isGatewayModelRunSessionKey("agent::explicit:model-run-123e4567-e89b-12d3-a456-426614174000"), + ).toBe(false); + // Extra colon segment between agent id and `explicit:` — rest starts + // with `extra:` and fails the predicate's regex. + expect( + isGatewayModelRunSessionKey( + "agent:main:extra:explicit:model-run-123e4567-e89b-12d3-a456-426614174000", + ), + ).toBe(false); + // Whitespace-padded keys are non-canonical even though parseAgentSessionKey + // trims before normalizing; the predicate intentionally checks the original + // key shape before accepting a model-run key. + expect( + isGatewayModelRunSessionKey( + " agent:main:explicit:model-run-123e4567-e89b-12d3-a456-426614174000", + ), + ).toBe(false); + expect( + isGatewayModelRunSessionKey( + "agent:main:explicit:model-run-123e4567-e89b-12d3-a456-426614174000 ", + ), + ).toBe(false); + }); + + it("matches canonical keys whose agent id begins with model-run-", () => { + // Guards against an over-tight fix that confuses the agent id segment + // with the `explicit:model-run-` rest segment. + expect( + isGatewayModelRunSessionKey( + "agent:model-run-foo:explicit:model-run-123e4567-e89b-12d3-a456-426614174000", + ), + ).toBe(true); + }); + + it("preserves case-insensitive matching for canonical keys", () => { + // normalizeLowercaseStringOrEmpty + parseAgentSessionKey's normalization + // lower-case everything outside opaque peer IDs, so a mixed-case + // canonical key still matches. + expect( + isGatewayModelRunSessionKey( + "agent:Main:Explicit:Model-Run-123E4567-E89B-12D3-A456-426614174000", + ), + ).toBe(true); + }); }); describe("capEntryCount", () => { @@ -403,6 +569,22 @@ describe("resolveMaintenanceConfigFromInput", () => { expect(maintenance.mode).toBe("enforce"); }); + it("defaults gateway model-run probes to fixed 24h retention", () => { + expect(resolveMaintenanceConfigFromInput().modelRunPruneAfterMs).toBe(DAY_MS); + }); + + it("force-gates the unset model-run prune default to the cap-eviction threshold", () => { + const defaultMaintenance = resolveMaintenanceConfigFromInput({ maxEntries: 50 }); + expect(resolveSessionEntryMaintenanceHighWater(50)).toBe(75); + expect(shouldRunModelRunPrune({ maintenance: defaultMaintenance, entryCount: 60 })).toBe(false); + expect( + shouldRunModelRunPrune({ maintenance: defaultMaintenance, entryCount: 60, force: true }), + ).toBe(true); + expect( + shouldRunModelRunPrune({ maintenance: defaultMaintenance, entryCount: 50, force: true }), + ).toBe(false); + }); + it("batches normal entry-count maintenance for production-sized caps", () => { expect(resolveSessionEntryMaintenanceHighWater(2)).toBe(3); expect(resolveSessionEntryMaintenanceHighWater(50)).toBe(75); diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index 8ec5459c0d71..81ce715e16f0 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -26,7 +26,11 @@ import { } from "./disk-budget.js"; import { extractGeneratedTranscriptSessionId } from "./generated-transcript-session-id.js"; import { deriveSessionMetaPatch } from "./metadata.js"; -import { resolveExplicitSessionFilePath, resolveSessionFilePath, resolveStorePath } from "./paths.js"; +import { + resolveExplicitSessionFilePath, + resolveSessionFilePath, + resolveStorePath, +} from "./paths.js"; import { ensureSessionStorePromptBlobsForPersistence, isSessionSkillPromptBlobReadable, @@ -62,8 +66,10 @@ import { resolveMaintenanceConfig } from "./store-maintenance-runtime.js"; import { capEntryCount, getActiveSessionMaintenanceWarning, + pruneStaleModelRunEntries, pruneStaleEntries, type ResolvedSessionMaintenanceConfig, + type ResolvedSessionMaintenanceConfigInput, type SessionMaintenanceWarning, } from "./store-maintenance.js"; import { runExclusiveSessionStoreWrite } from "./store-writer.js"; @@ -163,11 +169,16 @@ export { capEntryCount, getActiveSessionMaintenanceWarning, getSessionStoreCacheVersion, + pruneStaleModelRunEntries, pruneStaleEntries, resolveMaintenanceConfig, }; export type { SessionMaintenanceApplyReport } from "./store-maintenance-operations.js"; -export type { ResolvedSessionMaintenanceConfig, SessionMaintenanceWarning }; +export type { + ResolvedSessionMaintenanceConfig, + ResolvedSessionMaintenanceConfigInput, + SessionMaintenanceWarning, +}; type SaveSessionStoreOptions = { /** Skip pruning, capping, and rotation (e.g. during one-time migrations). */ @@ -193,6 +204,8 @@ type SaveSessionStoreOptions = { }; type UpdateSessionStoreOptions = SaveSessionStoreOptions & { + /** Allow a nested mutation only when the caller already owns this store writer lane. */ + reentrant?: boolean; /** * Specialized callers can prove their mutator made no changes through its result. * When true, the writer-owned object cache is restored and sessions.json is untouched. @@ -1008,19 +1021,23 @@ export async function updateSessionStore( mutator: (store: Record) => Promise | T, opts?: UpdateSessionStoreOptions, ): Promise { - return await runExclusiveSessionStoreWrite(storePath, async () => { - const store = loadMutableSessionStoreForWriter(storePath); - const result = await mutator(store); - if (opts?.skipSaveWhenResult?.(result)) { - restoreUnchangedSessionStoreCache(storePath, store); + return await runExclusiveSessionStoreWrite( + storePath, + async () => { + const store = loadMutableSessionStoreForWriter(storePath); + const result = await mutator(store); + if (opts?.skipSaveWhenResult?.(result)) { + restoreUnchangedSessionStoreCache(storePath, store); + return result; + } + await saveSessionStoreUnlocked(storePath, store, { + ...opts, + singleEntryPersistence: opts?.resolveSingleEntryPersistence?.(result) ?? undefined, + }); return result; - } - await saveSessionStoreUnlocked(storePath, store, { - ...opts, - singleEntryPersistence: opts?.resolveSingleEntryPersistence?.(result) ?? undefined, - }); - return result; - }); + }, + { reentrant: opts?.reentrant }, + ); } function cloneSessionEntryProjectionSnapshot( diff --git a/src/config/sessions/transcript.test.ts b/src/config/sessions/transcript.test.ts index 162d137c5140..6861632bd7ae 100644 --- a/src/config/sessions/transcript.test.ts +++ b/src/config/sessions/transcript.test.ts @@ -6,6 +6,7 @@ import { beforeAll, describe, expect, it, vi } from "vitest"; import { repairToolUseResultPairing } from "../../agents/session-transcript-repair.js"; import * as transcriptEvents from "../../sessions/transcript-events.js"; import type { SessionTranscriptUpdate } from "../../sessions/transcript-events.js"; +import { deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js"; import { resolveSessionTranscriptPathInDir } from "./paths.js"; import { updateSessionStoreEntry } from "./store.js"; import { useTempSessionsFixture } from "./test-helpers.js"; @@ -122,7 +123,7 @@ describe("appendAssistantMessageToSessionTranscript", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "transcript-config-store-")); const previousStateDir = process.env.OPENCLAW_STATE_DIR; try { - process.env.OPENCLAW_STATE_DIR = path.join(tempDir, "default-state"); + setTestEnvValue("OPENCLAW_STATE_DIR", path.join(tempDir, "default-state")); const sessionsDir = path.join(tempDir, "configured", "sessions"); fs.mkdirSync(sessionsDir, { recursive: true }); const storePath = path.join(sessionsDir, "sessions.json"); @@ -158,9 +159,9 @@ describe("appendAssistantMessageToSessionTranscript", () => { ); } finally { if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; + deleteTestEnvValue("OPENCLAW_STATE_DIR"); } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; + setTestEnvValue("OPENCLAW_STATE_DIR", previousStateDir); } fs.rmSync(tempDir, { recursive: true, force: true }); } @@ -171,7 +172,7 @@ describe("appendAssistantMessageToSessionTranscript", () => { const previousStateDir = process.env.OPENCLAW_STATE_DIR; const emitSpy = vi.spyOn(transcriptEvents, "emitSessionTranscriptUpdate"); try { - process.env.OPENCLAW_STATE_DIR = path.join(tempDir, "default-state"); + setTestEnvValue("OPENCLAW_STATE_DIR", path.join(tempDir, "default-state")); const storeTemplate = path.join(tempDir, "agents", "{agentId}", "sessions", "sessions.json"); const sessionsDir = path.join(tempDir, "agents", "worker", "sessions"); fs.mkdirSync(sessionsDir, { recursive: true }); @@ -219,9 +220,9 @@ describe("appendAssistantMessageToSessionTranscript", () => { } finally { emitSpy.mockRestore(); if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; + deleteTestEnvValue("OPENCLAW_STATE_DIR"); } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; + setTestEnvValue("OPENCLAW_STATE_DIR", previousStateDir); } fs.rmSync(tempDir, { recursive: true, force: true }); } diff --git a/src/config/types.installs.ts b/src/config/types.installs.ts index c1503f798872..57491727a6e8 100644 --- a/src/config/types.installs.ts +++ b/src/config/types.installs.ts @@ -16,6 +16,14 @@ export type InstallRecordBase = { clawhubPackage?: string; clawhubFamily?: "code-plugin" | "bundle-plugin"; clawhubChannel?: "official" | "community" | "private"; + clawhubTrustDisposition?: "clean" | "review-recommended" | "review-required" | "blocked"; + clawhubTrustScanStatus?: string; + clawhubTrustModerationState?: string; + clawhubTrustReasons?: string[]; + clawhubTrustPending?: boolean; + clawhubTrustStale?: boolean; + clawhubTrustCheckedAt?: string; + clawhubTrustAcknowledgedAt?: string; artifactKind?: "legacy-zip" | "npm-pack"; artifactFormat?: "zip" | "tgz"; npmIntegrity?: string; diff --git a/src/config/types.messages.ts b/src/config/types.messages.ts index 3724f31cda91..e17b0be3ec23 100644 --- a/src/config/types.messages.ts +++ b/src/config/types.messages.ts @@ -141,6 +141,23 @@ export type MessagesConfig = { responsePrefix?: string; /** Custom `/usage full` footer template, inline or JSON file path. */ usageTemplate?: string | Record; + /** + * Default per-reply usage footer mode (`responseUsage`) seeded into any session + * that has not set its own via `/usage`. Precedence: session value → channel entry + * → `default` → `off`. Absent ⇒ `off` (unchanged behavior). + * + * - string: one default for every channel, e.g. `"full"`. + * - object: per-channel with a fallback, e.g. `{ "default": "off", "discord": "full" }`. + */ + responseUsage?: + | "on" + | "off" + | "tokens" + | "full" + | { + default?: "on" | "off" | "tokens" | "full"; + [channel: string]: "on" | "off" | "tokens" | "full" | undefined; + }; groupChat?: GroupChatConfig; queue?: QueueConfig; /** Debounce rapid inbound messages per sender (global + per-channel overrides). */ diff --git a/src/config/zod-schema.installs.ts b/src/config/zod-schema.installs.ts index 59d43a8e7dbb..956608efee5e 100644 --- a/src/config/zod-schema.installs.ts +++ b/src/config/zod-schema.installs.ts @@ -31,6 +31,21 @@ export const InstallRecordShape = { clawhubChannel: z .union([z.literal("official"), z.literal("community"), z.literal("private")]) .optional(), + clawhubTrustDisposition: z + .union([ + z.literal("clean"), + z.literal("review-recommended"), + z.literal("review-required"), + z.literal("blocked"), + ]) + .optional(), + clawhubTrustScanStatus: z.string().optional(), + clawhubTrustModerationState: z.string().optional(), + clawhubTrustReasons: z.array(z.string()).optional(), + clawhubTrustPending: z.boolean().optional(), + clawhubTrustStale: z.boolean().optional(), + clawhubTrustCheckedAt: z.string().optional(), + clawhubTrustAcknowledgedAt: z.string().optional(), artifactKind: z.union([z.literal("legacy-zip"), z.literal("npm-pack")]).optional(), artifactFormat: z.union([z.literal("zip"), z.literal("tgz")]).optional(), npmIntegrity: z.string().optional(), diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index 1b85be18af87..f3006ca8959f 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -153,12 +153,17 @@ export const SessionSchema = z .strict() .optional(); +const ResponseUsageModeSchema = z.enum(["on", "off", "tokens", "full"]); + export const MessagesSchema = z .object({ messagePrefix: z.string().optional(), visibleReplies: VisibleRepliesSchema.optional(), responsePrefix: z.string().optional(), usageTemplate: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(), + responseUsage: z + .union([ResponseUsageModeSchema, z.record(z.string(), ResponseUsageModeSchema)]) + .optional(), groupChat: GroupChatSchema, queue: QueueSchema, inbound: InboundDebounceSchema, diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index 024c0c2cfac3..029c3231a9c7 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -1084,6 +1084,88 @@ describe("Factory context passing", () => { }); }); +describe("Read-only plugin discovery registrations", () => { + beforeEach(() => { + registerLegacyContextEngine(); + clearContextEngineRuntimeQuarantine(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("does not construct or quarantine read-only discovery context-engine factories", async () => { + const engineId = uniqueEngineId("lossless-readonly"); + const owner = "plugin:lossless-claw"; + let readOnlyFactoryCalls = 0; + let runtimeFactoryCalls = 0; + + registerContextEngineForOwner( + engineId, + () => { + readOnlyFactoryCalls += 1; + throw new Error("Engine initialization is disabled during read-only plugin registration"); + }, + owner, + { allowSameOwnerRefresh: true, lifecycle: "readOnlyDiscovery" }, + ); + + const discoveryFallback = await resolveContextEngine(configWithSlot(engineId)); + + expect(discoveryFallback.info.id).toBe("legacy"); + expect(readOnlyFactoryCalls).toBe(0); + expect(listContextEngineQuarantines().some((entry) => entry.engineId === engineId)).toBe(false); + expect(console.warn).toHaveBeenCalledWith( + `[context-engine] Context engine "${engineId}" owner=${owner} is registered for read-only discovery only; falling back to default engine "legacy" without quarantine until runtime activation registers it.`, + ); + + registerContextEngineForOwner( + engineId, + () => { + runtimeFactoryCalls += 1; + return { + info: { id: "lossless-claw", name: "Lossless Claw" }, + async ingest() { + return { ingested: true }; + }, + async assemble({ messages }: { messages: AgentMessage[] }) { + return { messages, estimatedTokens: 0 }; + }, + async compact() { + return { ok: true, compacted: false }; + }, + } satisfies ContextEngine; + }, + owner, + { allowSameOwnerRefresh: true, lifecycle: "runtime" }, + ); + + const runtimeEngine = await resolveContextEngine(configWithSlot(engineId)); + + expect(runtimeEngine.info.id).toBe("lossless-claw"); + expect(readOnlyFactoryCalls).toBe(0); + expect(runtimeFactoryCalls).toBe(1); + expect(listContextEngineQuarantines().some((entry) => entry.engineId === engineId)).toBe(false); + + registerContextEngineForOwner( + engineId, + () => { + readOnlyFactoryCalls += 1; + throw new Error("read-only discovery should not replace runtime registration"); + }, + owner, + { allowSameOwnerRefresh: true, lifecycle: "readOnlyDiscovery" }, + ); + + const stillRuntimeEngine = await resolveContextEngine(configWithSlot(engineId)); + + expect(stillRuntimeEngine.info.id).toBe("lossless-claw"); + expect(readOnlyFactoryCalls).toBe(0); + expect(runtimeFactoryCalls).toBe(2); + }); +}); + // ═══════════════════════════════════════════════════════════════════════════ // 4. Invalid engine fallback // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/context-engine/registry.ts b/src/context-engine/registry.ts index 745e31939e35..77939db6a4cc 100644 --- a/src/context-engine/registry.ts +++ b/src/context-engine/registry.ts @@ -44,9 +44,16 @@ export type ContextEngineFactory = ( ctx: ContextEngineFactoryContext, ) => ContextEngine | Promise; export type ContextEngineRegistrationResult = { ok: true } | { ok: false; existingOwner: string }; +export type ContextEngineRegistrationLifecycle = "runtime" | "readOnlyDiscovery"; +export type ContextEngineRegistration = { + factory: ContextEngineFactory; + owner: string; + lifecycle: ContextEngineRegistrationLifecycle; +}; type RegisterContextEngineForOwnerOptions = { allowSameOwnerRefresh?: boolean; + lifecycle?: ContextEngineRegistrationLifecycle; }; const LEGACY_SESSION_KEY_COMPAT = Symbol.for("openclaw.contextEngine.sessionKeyCompat"); @@ -389,13 +396,7 @@ export type ContextEngineRuntimeQuarantine = { }; type ContextEngineRegistryState = { - engines: Map< - string, - { - factory: ContextEngineFactory; - owner: string; - } - >; + engines: Map; quarantinedEngines: Map; }; @@ -512,6 +513,7 @@ export function registerContextEngineForOwner( opts?: RegisterContextEngineForOwnerOptions, ): ContextEngineRegistrationResult { const normalizedOwner = requireContextEngineOwner(owner); + const lifecycle = opts?.lifecycle ?? "runtime"; const registry = getContextEngineRegistryState().engines; const existing = registry.get(id); if ( @@ -524,11 +526,18 @@ export function registerContextEngineForOwner( if (existing && existing.owner !== normalizedOwner) { return { ok: false, existingOwner: existing.owner }; } + if (existing?.lifecycle === "runtime" && lifecycle === "readOnlyDiscovery") { + // Read-only discovery may re-run after live activation. It can collect metadata, but it must + // not replace the runtime-safe factory with a closure that captured a read-only plugin mode. + return { ok: true }; + } if (existing && opts?.allowSameOwnerRefresh !== true) { return { ok: false, existingOwner: existing.owner }; } - registry.set(id, { factory, owner: normalizedOwner }); - clearContextEngineRuntimeQuarantine(id); + registry.set(id, { factory, owner: normalizedOwner, lifecycle }); + if (lifecycle === "runtime") { + clearContextEngineRuntimeQuarantine(id); + } return { ok: true }; } @@ -550,7 +559,13 @@ export function registerContextEngine( * Return the factory for a registered engine, or undefined. */ export function getContextEngineFactory(id: string): ContextEngineFactory | undefined { - return getContextEngineRegistryState().engines.get(id)?.factory; + const registration = getContextEngineRegistration(id); + return registration?.lifecycle === "runtime" ? registration.factory : undefined; +} + +/** Returns registration metadata so callers can distinguish discovery snapshots from runtime entries. */ +export function getContextEngineRegistration(id: string): ContextEngineRegistration | undefined { + return getContextEngineRegistryState().engines.get(id); } /** @@ -945,6 +960,13 @@ export async function resolveContextEngine( return resolveDefaultContextEngine(defaultEngineId, factoryCtx); } + if (!isDefaultEngine && entry.lifecycle === "readOnlyDiscovery") { + console.warn( + `[context-engine] Context engine "${engineId}" owner=${entry.owner} is registered for read-only discovery only; falling back to default engine "${defaultEngineId}" without quarantine until runtime activation registers it.`, + ); + return resolveDefaultContextEngine(defaultEngineId, factoryCtx); + } + let engine: ContextEngine; try { engine = await entry.factory(factoryCtx); diff --git a/src/crestodian/operations.test.ts b/src/crestodian/operations.test.ts index 839ebf3f6de8..d65469b2c53c 100644 --- a/src/crestodian/operations.test.ts +++ b/src/crestodian/operations.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; +import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; import { createCrestodianTestRuntime } from "./crestodian.test-helpers.js"; import { executeCrestodianOperation, parseCrestodianOperation } from "./operations.js"; @@ -192,12 +193,16 @@ vi.mock("../config/model-input.js", () => ({ })); describe("parseCrestodianOperation", () => { + let stateDirSnapshot: ReturnType | undefined; + beforeEach(() => { mockConfig.reset(); + stateDirSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); vi.stubEnv("OPENCLAW_TEST_FAST", "1"); }); afterEach(() => { + stateDirSnapshot?.restore(); vi.unstubAllEnvs(); }); @@ -321,7 +326,7 @@ describe("parseCrestodianOperation", () => { it("applies config set through typed deps and writes an audit entry", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-config-set-")); - vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); const { runtime, lines } = createCrestodianTestRuntime(); const runConfigSet = vi.fn(async () => {}); @@ -357,7 +362,7 @@ describe("parseCrestodianOperation", () => { it("applies SecretRef config set through typed deps and writes an audit entry", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-config-ref-")); - vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); const { runtime, lines } = createCrestodianTestRuntime(); const runConfigSet = vi.fn(async () => {}); @@ -435,7 +440,7 @@ describe("parseCrestodianOperation", () => { it("installs plugins only after approval and audits the write", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-plugin-install-")); - vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); const { runtime, lines } = createCrestodianTestRuntime(); const runPluginInstall = vi.fn(async (spec: string, pluginRuntime: RuntimeEnv) => { pluginRuntime.log(`installed ${spec}`); @@ -481,7 +486,7 @@ describe("parseCrestodianOperation", () => { it("uninstalls plugins only after approval and audits the write", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-plugin-uninstall-")); - vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); const { runtime, lines } = createCrestodianTestRuntime(); const runPluginUninstall = vi.fn(async (pluginId: string, pluginRuntime: RuntimeEnv) => { pluginRuntime.log(`uninstalled ${pluginId}`); @@ -527,7 +532,7 @@ describe("parseCrestodianOperation", () => { it("runs setup bootstrap only after approval and audits it", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-setup-")); - vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); vi.stubEnv("OPENAI_API_KEY", "test-key"); const { runtime, lines } = createCrestodianTestRuntime(); @@ -576,7 +581,7 @@ describe("parseCrestodianOperation", () => { it("runs doctor repairs only after approval and audits them", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-doctor-fix-")); - vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); const { runtime, lines } = createCrestodianTestRuntime(); const runDoctor = vi.fn(async () => {}); diff --git a/src/crestodian/rescue-channel.live.test.ts b/src/crestodian/rescue-channel.live.test.ts index 7bf337aed19a..f43e69fa7892 100644 --- a/src/crestodian/rescue-channel.live.test.ts +++ b/src/crestodian/rescue-channel.live.test.ts @@ -2,10 +2,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import type { CommandContext } from "../auto-reply/reply/commands-types.js"; import { clearConfigCache } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js"; import { runCrestodianRescueMessage } from "./rescue-message.js"; const originalStateDir = process.env.OPENCLAW_STATE_DIR; @@ -54,22 +55,22 @@ describeLive("Crestodian live rescue channel smoke", () => { afterEach(() => { clearConfigCache(); if (originalStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; + deleteTestEnvValue("OPENCLAW_STATE_DIR"); } else { - process.env.OPENCLAW_STATE_DIR = originalStateDir; + setTestEnvValue("OPENCLAW_STATE_DIR", originalStateDir); } if (originalConfigPath === undefined) { - delete process.env.OPENCLAW_CONFIG_PATH; + deleteTestEnvValue("OPENCLAW_CONFIG_PATH"); } else { - process.env.OPENCLAW_CONFIG_PATH = originalConfigPath; + setTestEnvValue("OPENCLAW_CONFIG_PATH", originalConfigPath); } }); it("handles /crestodian status and a persistent approval roundtrip", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-live-rescue-")); const configPath = path.join(tempDir, "openclaw.json"); - vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); - vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); + setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath); await fs.writeFile( configPath, JSON.stringify( diff --git a/src/cron/active-jobs-manual-run.test.ts b/src/cron/active-jobs-manual-run.test.ts index 104e4261fd42..eb8196edf0e4 100644 --- a/src/cron/active-jobs-manual-run.test.ts +++ b/src/cron/active-jobs-manual-run.test.ts @@ -182,7 +182,7 @@ describe("cron activeJobIds — manual-run mark/clear", () => { } }); - it("requests one setup-timeout restart when concurrent manual runs both stall before runner start", async () => { + it("sends one setup-timeout notification when concurrent manual runs both stall before runner start", async () => { vi.useFakeTimers(); const now = Date.parse("2025-12-13T17:00:00.000Z"); vi.setSystemTime(now); diff --git a/src/cron/isolated-agent.direct-delivery-core-channels.test.ts b/src/cron/isolated-agent.direct-delivery-core-channels.test.ts index f9f1e44f95b5..f9118d03539f 100644 --- a/src/cron/isolated-agent.direct-delivery-core-channels.test.ts +++ b/src/cron/isolated-agent.direct-delivery-core-channels.test.ts @@ -1,6 +1,6 @@ // Direct delivery tests cover isolated agent delivery through core channel targets. import "./isolated-agent.mocks.js"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; import type { ChannelOutboundAdapter, ChannelOutboundContext } from "../channels/plugins/types.js"; import type { CliDeps } from "../cli/deps.js"; @@ -455,6 +455,55 @@ describe("runCronIsolatedAgentTurn telegram forum-topic direct delivery", () => }); }); + it("does not report delivered when telegram announce produces no platform result", async () => { + await withTempCronHome(async (home) => { + const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); + const sendText = vi.fn(async () => ({ channel: "telegram", messageId: "" })); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "telegram", + plugin: createOutboundTestPlugin({ + id: "telegram", + outbound: { + deliveryMode: "direct", + preferFinalAssistantVisibleText: true, + sendText, + resolveTarget: ({ to }) => + to?.trim() + ? { ok: true, to: to.trim() } + : { ok: false, error: new Error("target is required") }, + }, + messaging: { + parseExplicitTarget: ({ raw }) => ({ to: raw.trim() }), + }, + }), + source: "test", + }, + ]), + ); + const deps = createCliDeps(); + mockAgentPayloads([{ text: "cron message with no platform receipt" }]); + + const res = await runTelegramAnnounceTurn({ + home, + storePath, + deps, + delivery: { mode: "announce", channel: "telegram", to: "123" }, + }); + + expect(res.status).toBe("ok"); + expect(res.delivered).toBe(false); + expect(res.deliveryAttempted).toBe(true); + expect(res.delivery).toMatchObject({ + fallbackUsed: true, + delivered: false, + }); + expect(sendText).toHaveBeenCalledTimes(1); + expect(deps.sendMessageTelegram).not.toHaveBeenCalled(); + }); + }); + it("delivers only the final assistant-visible text to forum-topic telegram targets", async () => { await expectTelegramAnnounceDelivery({ to: "123:topic:42", diff --git a/src/cron/isolated-agent/delivery-target.test.ts b/src/cron/isolated-agent/delivery-target.test.ts index abea13f9157e..e1bbe776063b 100644 --- a/src/cron/isolated-agent/delivery-target.test.ts +++ b/src/cron/isolated-agent/delivery-target.test.ts @@ -1,6 +1,9 @@ // Isolated agent delivery target tests cover target resolution for cron runs. import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js"; +import type { + ChannelDirectoryEntry, + ChannelOutboundAdapter, +} from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import { @@ -741,6 +744,57 @@ describe("resolveDeliveryTarget", () => { expect(result.threadId).toBeUndefined(); }); + it("resolves cron reserved explicit targets through directory entries", async () => { + setMainSessionEntry(undefined); + const listGroups = vi.fn(async () => [ + { + kind: "group", + id: "-1002458651455", + name: "current", + handle: "@current", + } satisfies ChannelDirectoryEntry, + ]); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "telegram", + source: "test", + plugin: { + ...createOutboundTestPlugin({ + id: "telegram", + outbound: createStubOutbound("Telegram"), + capabilities: { chatTypes: ["direct", "group", "channel"] }, + messaging: { + ...telegramMessagingForTest, + normalizeTarget: normalizeTelegramTargetForDeliveryTest, + targetResolver: { + reservedLiterals: ["current", "self", "this", "me"], + hint: "", + }, + }, + }), + directory: { listGroups }, + }, + }, + ]), + ); + + const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, { + channel: "telegram", + to: "current", + }); + + expect(result.ok).toBe(true); + expect(result.to).toBe("-1002458651455"); + expect(result.threadId).toBeUndefined(); + expect(listGroups).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: undefined, + query: "current", + }), + ); + }); + it("uses canonical route targets even when the route has no thread", async () => { setMainSessionEntry(undefined); setActivePluginRegistry( diff --git a/src/cron/isolated-agent/delivery-target.ts b/src/cron/isolated-agent/delivery-target.ts index 15dfadb638c6..ff36f6806d17 100644 --- a/src/cron/isolated-agent/delivery-target.ts +++ b/src/cron/isolated-agent/delivery-target.ts @@ -11,6 +11,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { stripTargetProviderPrefix } from "../../infra/outbound/channel-target-prefix.js"; import type { OutboundSessionRoute } from "../../infra/outbound/outbound-session.js"; +import { isReservedTargetLiteralError } from "../../infra/outbound/target-errors.js"; import type { ResolvedMessagingTarget } from "../../infra/outbound/target-resolver.js"; import { tryResolveLoadedOutboundTarget } from "../../infra/outbound/targets-loaded.js"; import { resolveSessionDeliveryTarget } from "../../infra/outbound/targets-session.js"; @@ -349,17 +350,20 @@ export async function resolveDeliveryTarget( allowFrom: effectiveAllowFrom, }); if (!docked.ok) { - return { - ok: false, - channel, - to: undefined, - accountId, - threadId: explicitThreadId, - mode, - error: docked.error, - }; + if (!toCandidate || !isReservedTargetLiteralError(docked.error)) { + return { + ok: false, + channel, + to: undefined, + accountId, + threadId: explicitThreadId, + mode, + error: docked.error, + }; + } + } else { + toCandidate = docked.to; } - toCandidate = docked.to; const targetResolution = await deliveryTargetRuntime.resolveChannelTargetForDelivery({ cfg, channel, diff --git a/src/cron/isolated-agent/run-executor.ts b/src/cron/isolated-agent/run-executor.ts index 6a6ef4ffa196..6ad29366cd03 100644 --- a/src/cron/isolated-agent/run-executor.ts +++ b/src/cron/isolated-agent/run-executor.ts @@ -161,8 +161,19 @@ function buildCronDeliveryTargetRuntimeContext(params: { ].join("\n"); } -function resolveCliRuntimeToolsAllow(toolsAllow?: string[]): string[] | undefined { - return toolsAllow?.some((toolName) => normalizeToolName(toolName) === "*") +function resolveCliRuntimeToolsAllow( + toolsAllow?: string[], + toolsAllowIsDefault?: boolean, +): string[] | undefined { + if (toolsAllow === undefined) { + return undefined; + } + // CLI runners reject runtime toolsAllow. Drop only the auto-stamped default; + // explicit per-cron restrictions stay fail-closed in prepareCliRunContext. + if (toolsAllowIsDefault) { + return undefined; + } + return toolsAllow.some((toolName) => normalizeToolName(toolName) === "*") ? undefined : toolsAllow; } @@ -326,7 +337,10 @@ export function createCronPromptExecutor(params: { messageChannel, sourceReplyDeliveryMode, requireExplicitMessageTarget: params.sourceDelivery.messageTool.requireExplicitTarget, - toolsAllow: resolveCliRuntimeToolsAllow(params.agentPayload?.toolsAllow), + toolsAllow: resolveCliRuntimeToolsAllow( + params.agentPayload?.toolsAllow, + params.agentPayload?.toolsAllowIsDefault, + ), abortSignal: params.abortSignal, onExecutionStarted: params.onExecutionStarted, onExecutionPhase: params.onExecutionPhase, diff --git a/src/cron/isolated-agent/run.message-tool-policy.test.ts b/src/cron/isolated-agent/run.message-tool-policy.test.ts index f14ac1d23694..f36084264f37 100644 --- a/src/cron/isolated-agent/run.message-tool-policy.test.ts +++ b/src/cron/isolated-agent/run.message-tool-policy.test.ts @@ -825,6 +825,41 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { expect(cliRun.prompt).toContain("Message delivery destination metadata"); }); + it("drops the auto-applied default toolsAllow cap for CLI-backed runs instead of failing", async () => { + // A CLI backend cannot enforce a runtime toolsAllow, so the auto-applied + // creator-surface cap (#91499, flagged toolsAllowIsDefault) is dropped at + // run time rather than handed to the CLI runner — which would otherwise + // reject the run. An explicit user restriction (no flag) is still + // propagated; see the "restricted toolsAllow" case above. + mockRunCronFallbackPassthrough(); + resolveCronDeliveryPlanMock.mockReturnValue(makeAnnounceDeliveryPlan()); + isCliProviderMock.mockReturnValue(true); + runCliAgentMock.mockResolvedValue({ + payloads: [{ text: "done" }], + meta: { agentMeta: { usage: { input: 10, output: 20 } } }, + }); + + await runCronIsolatedAgentTurn({ + ...makeParams(), + job: makeMessageToolPolicyJob( + { mode: "announce", channel: "messagechat", to: "123" }, + { + kind: "agentTurn", + message: "send a message", + toolsAllow: ["read", "cron"], + toolsAllowIsDefault: true, + }, + ), + }); + + const cliRun = expectRecordFields( + getMockCallArg(runCliAgentMock, 0, 0, "CLI run"), + {}, + "CLI run params", + ); + expect(cliRun.toolsAllow).toBeUndefined(); + }); + it("keeps automatic exec completion notifications when announce delivery is active", async () => { mockRunCronFallbackPassthrough(); resolveCronDeliveryPlanMock.mockReturnValue(makeAnnounceDeliveryPlan()); diff --git a/src/cron/service.jobs.test.ts b/src/cron/service.jobs.test.ts index 3834ef59d648..04b33a923056 100644 --- a/src/cron/service.jobs.test.ts +++ b/src/cron/service.jobs.test.ts @@ -392,6 +392,63 @@ describe("applyJobPatch", () => { } }); + it("clears the default toolsAllow flag when editing to an explicit restriction", () => { + const job = createIsolatedAgentTurnJob("job-tools-explicit", { + mode: "announce", + channel: "telegram", + }); + job.payload = { + kind: "agentTurn", + message: "do it", + toolsAllow: ["exec", "read"], + toolsAllowIsDefault: true, + }; + + applyJobPatch(job, { + payload: { + kind: "agentTurn", + message: "do it", + toolsAllow: ["read"], + toolsAllowIsDefault: true, + }, + }); + + expect(job.payload.kind).toBe("agentTurn"); + if (job.payload.kind === "agentTurn") { + expect(job.payload.toolsAllow).toEqual(["read"]); + expect(job.payload.toolsAllowIsDefault).toBeUndefined(); + } + }); + + it("preserves the default toolsAllow flag when a full payload edit keeps the default list", () => { + const job = createIsolatedAgentTurnJob("job-tools-default-edit", { + mode: "announce", + channel: "telegram", + }); + job.payload = { + kind: "agentTurn", + message: "do it", + toolsAllow: ["exec", "read"], + toolsAllowIsDefault: true, + }; + + applyJobPatch(job, { + payload: { + kind: "agentTurn", + message: "do it later", + toolsAllow: ["exec", "read"], + toolsAllowIsDefault: true, + }, + }); + + expect(job.payload.kind).toBe("agentTurn"); + if (job.payload.kind === "agentTurn") { + expect(job.payload.message).toBe("do it later"); + expect(job.payload.toolsAllow).toEqual(["exec", "read"]); + expect(job.payload.toolsAllowIsDefault).toBe(true); + } + }); + it("clears agentTurn payload.toolsAllow when patch requests null", () => { const job = createIsolatedAgentTurnJob("job-tools-clear", { mode: "announce", @@ -401,6 +458,7 @@ describe("applyJobPatch", () => { kind: "agentTurn", message: "do it", toolsAllow: ["exec", "read"], + toolsAllowIsDefault: true, }; applyJobPatch(job, { @@ -414,6 +472,7 @@ describe("applyJobPatch", () => { expect(job.payload.kind).toBe("agentTurn"); if (job.payload.kind === "agentTurn") { expect(job.payload.toolsAllow).toBeUndefined(); + expect(job.payload.toolsAllowIsDefault).toBeUndefined(); } }); @@ -527,6 +586,30 @@ describe("applyJobPatch", () => { } }); + it("carries payload.toolsAllow default flag when replacing payload kind via patch", () => { + const job = createIsolatedAgentTurnJob("job-tools-default-switch", { + mode: "announce", + channel: "telegram", + }); + job.payload = { kind: "systemEvent", text: "ping" }; + + applyJobPatch(job, { + payload: { + kind: "agentTurn", + message: "do it", + toolsAllow: ["exec", "read"], + toolsAllowIsDefault: true, + }, + }); + + const payload = job.payload as CronJob["payload"]; + expect(payload.kind).toBe("agentTurn"); + if (payload.kind === "agentTurn") { + expect(payload.toolsAllow).toEqual(["exec", "read"]); + expect(payload.toolsAllowIsDefault).toBe(true); + } + }); + it.each([ { name: "no delivery update", patch: { enabled: true } satisfies CronJobPatch }, { diff --git a/src/cron/service.test-harness.ts b/src/cron/service.test-harness.ts index 78d11d7a188e..2f6b534d5255 100644 --- a/src/cron/service.test-harness.ts +++ b/src/cron/service.test-harness.ts @@ -236,7 +236,7 @@ export function createMockCronStateForJobs(params: { stopped: false, restartRecoveryPending: false, activeManualRunJobIds: new Set(), - manualSetupTimeoutRestartNotified: false, + manualSetupTimeoutNotified: false, timer: null, storeLoadedAtMs: nowMs, op: Promise.resolve(), diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index 39343809098f..c00c4d6c0330 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -40,6 +40,9 @@ const STUCK_RUN_MS = 2 * 60 * 60 * 1000; const STAGGER_OFFSET_CACHE_MAX = 4096; const staggerOffsetCache = new Map(); +type CronAgentTurnPayload = Extract; +type CronAgentTurnPayloadPatch = Extract; + /** Default retry delays applied after consecutive cron execution errors. */ export const DEFAULT_ERROR_BACKOFF_SCHEDULE_MS = [ 30_000, @@ -897,6 +900,42 @@ export function applyJobPatch( } } +function applyAgentTurnToolsAllowPatch( + payload: CronAgentTurnPayload, + patch: CronAgentTurnPayloadPatch, + existing?: CronAgentTurnPayload, +): void { + if (Array.isArray(patch.toolsAllow)) { + payload.toolsAllow = patch.toolsAllow; + // Same-kind edits keep the marker only when the default list is unchanged; + // kind replacements carry the cron-tool-stamped marker into persistence. + if ( + patch.toolsAllowIsDefault === true && + (!existing || (existing.toolsAllowIsDefault === true && toolsAllowEqual(existing, patch))) + ) { + payload.toolsAllowIsDefault = true; + } else { + delete payload.toolsAllowIsDefault; + } + } else if (patch.toolsAllow === null) { + delete payload.toolsAllow; + delete payload.toolsAllowIsDefault; + } +} + +function toolsAllowEqual( + left: Pick, + right: Pick, +): boolean { + const rightToolsAllow = right.toolsAllow; + return ( + Array.isArray(left.toolsAllow) && + Array.isArray(rightToolsAllow) && + left.toolsAllow.length === rightToolsAllow.length && + left.toolsAllow.every((toolName, index) => toolName === rightToolsAllow[index]) + ); +} + function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronPayload { if (patch.kind !== existing.kind) { return buildPayloadFromPatch(patch); @@ -943,7 +982,7 @@ function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronP return buildPayloadFromPatch(patch); } - const next: Extract = { ...existing }; + const next: CronAgentTurnPayload = { ...existing }; if (typeof patch.message === "string") { next.message = patch.message; } @@ -957,11 +996,7 @@ function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronP } else if (patch.fallbacks === null) { delete next.fallbacks; } - if (Array.isArray(patch.toolsAllow)) { - next.toolsAllow = patch.toolsAllow; - } else if (patch.toolsAllow === null) { - delete next.toolsAllow; - } + applyAgentTurnToolsAllowPatch(next, patch, existing); if (typeof patch.thinking === "string") { next.thinking = patch.thinking; } @@ -1005,17 +1040,18 @@ function buildPayloadFromPatch(patch: CronPayloadPatch): CronPayload { throw new Error('cron.update payload.kind="agentTurn" requires message'); } - return { + const next: CronAgentTurnPayload = { kind: "agentTurn", message: patch.message, model: typeof patch.model === "string" ? patch.model : undefined, fallbacks: Array.isArray(patch.fallbacks) ? patch.fallbacks : undefined, - toolsAllow: Array.isArray(patch.toolsAllow) ? patch.toolsAllow : undefined, thinking: patch.thinking, timeoutSeconds: patch.timeoutSeconds, lightContext: patch.lightContext, allowUnsafeExternalContent: patch.allowUnsafeExternalContent, }; + applyAgentTurnToolsAllowPatch(next, patch); + return next; } function mergeCronDelivery( diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index 803ccbac8010..ea0b9b570d77 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -86,7 +86,7 @@ function clearManualCronJobActive( state.activeManualRunJobIds.delete(jobId); clearCronJobActive(jobId, activeJobMarker); if (state.activeManualRunJobIds.size === 0) { - state.manualSetupTimeoutRestartNotified = false; + state.manualSetupTimeoutNotified = false; } } @@ -98,11 +98,11 @@ function maybeNotifyManualIsolatedSetupTimeout( isolatedAgentSetupTimeout?: IsolatedAgentSetupTimeoutSignal; }, ): boolean { - if (!result.isolatedAgentSetupTimeout || state.manualSetupTimeoutRestartNotified) { + if (!result.isolatedAgentSetupTimeout || state.manualSetupTimeoutNotified) { return false; } const notified = maybeNotifyIsolatedAgentSetupTimeout(state, result); - state.manualSetupTimeoutRestartNotified ||= notified; + state.manualSetupTimeoutNotified ||= notified; return notified; } diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts index 7b8bf7f844c3..08dd2cce45d5 100644 --- a/src/cron/service/state.ts +++ b/src/cron/service/state.ts @@ -197,7 +197,7 @@ export type CronServiceState = { stopped: boolean; restartRecoveryPending: boolean; activeManualRunJobIds: Set; - manualSetupTimeoutRestartNotified: boolean; + manualSetupTimeoutNotified: boolean; /** Serializes mutating service operations so store writes and timers stay ordered. */ op: Promise; warnedDisabled: boolean; @@ -221,7 +221,7 @@ export function createCronServiceState(deps: CronServiceDeps): CronServiceState stopped: false, restartRecoveryPending: false, activeManualRunJobIds: new Set(), - manualSetupTimeoutRestartNotified: false, + manualSetupTimeoutNotified: false, op: Promise.resolve(), warnedDisabled: false, warnedInvalidPersistedJobKeys: new Set(), diff --git a/src/cron/service/timer.regression.test.ts b/src/cron/service/timer.regression.test.ts index 673db6b858a1..f0d0645fc0b7 100644 --- a/src/cron/service/timer.regression.test.ts +++ b/src/cron/service/timer.regression.test.ts @@ -1301,7 +1301,7 @@ describe("cron service timer regressions", () => { } }); - it("notifies setup-timeout restart after startup catch-up finalization", async () => { + it("notifies setup timeout after startup catch-up finalization", async () => { vi.useFakeTimers(); try { const store = timerRegressionFixtures.makeStorePath(); @@ -1926,7 +1926,7 @@ describe("cron service timer regressions", () => { expect(jobs.find((job) => job.id === second.id)?.state.lastStatus).toBe("ok"); }); - it("requests one setup-timeout restart when a concurrent cron batch stalls before runners start", async () => { + it("sends one setup-timeout notification when a concurrent cron batch stalls before runners start", async () => { vi.useFakeTimers(); try { const store = timerRegressionFixtures.makeStorePath(); @@ -1990,7 +1990,7 @@ describe("cron service timer regressions", () => { } }); - it("requests setup-timeout restart after a prior serial cron job completes", async () => { + it("sends setup-timeout notification after a prior serial cron job completes", async () => { vi.useFakeTimers(); try { const store = timerRegressionFixtures.makeStorePath(); @@ -2058,7 +2058,7 @@ describe("cron service timer regressions", () => { } }); - it("requests setup-timeout restart when manual and scheduled runs both stall", async () => { + it("sends setup-timeout notification when manual and scheduled runs both stall", async () => { vi.useFakeTimers(); try { const store = timerRegressionFixtures.makeStorePath(); @@ -2128,7 +2128,7 @@ describe("cron service timer regressions", () => { } }); - it("suppresses scheduled rearm after manual setup-timeout restart request", async () => { + it("rearms scheduled jobs after manual setup timeout notification", async () => { vi.useFakeTimers(); try { const store = timerRegressionFixtures.makeStorePath(); @@ -2179,8 +2179,8 @@ describe("cron service timer regressions", () => { await vi.advanceTimersByTimeAsync(1); expect(onIsolatedAgentSetupTimeout).toHaveBeenCalledTimes(1); - expect(state.restartRecoveryPending).toBe(true); - expect(state.timer).toBeNull(); + expect(state.restartRecoveryPending).toBe(false); + expect(state.timer).not.toBeNull(); expect(scheduledStarted).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); @@ -2352,7 +2352,7 @@ describe("cron service timer regressions", () => { ).toBe(replacementReservationMs); }); - it("stops an active scheduled batch from claiming more jobs after manual setup-timeout recovery", async () => { + it("continues an active scheduled batch after manual setup-timeout notification", async () => { vi.useFakeTimers(); try { const store = timerRegressionFixtures.makeStorePath(); @@ -2419,14 +2419,14 @@ describe("cron service timer regressions", () => { await vi.advanceTimersByTimeAsync(60_100); now += 60_100; await manualRun; - expect(state.restartRecoveryPending).toBe(true); + expect(state.restartRecoveryPending).toBe(false); finishFirstScheduled.resolve(); await timerRun; const second = requireJob(state, secondScheduledJob.id); expect(onIsolatedAgentSetupTimeout).toHaveBeenCalledTimes(1); - expect(secondScheduledStarted).not.toHaveBeenCalled(); + expect(secondScheduledStarted).toHaveBeenCalledWith(secondScheduledJob.id); expect(second.state.runningAtMs).toBeUndefined(); } finally { vi.useRealTimers(); @@ -2794,7 +2794,7 @@ describe("cron service timer regressions", () => { } }); - it("does not request setup-timeout restart for cron-nested lane contention", async () => { + it("does not notify setup timeout for cron-nested lane contention", async () => { vi.useFakeTimers(); try { const store = timerRegressionFixtures.makeStorePath(); @@ -2854,7 +2854,7 @@ describe("cron service timer regressions", () => { } }); - it("does not notify setup-timeout restart for custom-session cron waits", async () => { + it("does not notify setup timeout for custom-session cron waits", async () => { vi.useFakeTimers(); try { const store = timerRegressionFixtures.makeStorePath(); diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index e8994987e929..84f701feca43 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -344,7 +344,6 @@ export function maybeNotifyIsolatedAgentSetupTimeout( if (!notified) { return false; } - state.restartRecoveryPending = true; return true; } diff --git a/src/cron/store.test.ts b/src/cron/store.test.ts index 3cffecf4a94f..a937a63f2830 100644 --- a/src/cron/store.test.ts +++ b/src/cron/store.test.ts @@ -7,6 +7,7 @@ import { archiveLegacyCronStoreForMigration, loadLegacyCronStoreForMigration, } from "../commands/doctor/cron/legacy-store-migration.js"; +import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; import { loadCronJobsStoreWithConfigJobs, loadCronJobsStoreSync, @@ -79,13 +80,15 @@ function requireRecord(value: unknown, label: string): Record { } describe("resolveCronStorePath", () => { + const envSnapshot = captureEnv(["OPENCLAW_HOME", "HOME"]); + afterEach(() => { - vi.unstubAllEnvs(); + envSnapshot.restore(); }); it("uses OPENCLAW_HOME for tilde expansion", () => { - vi.stubEnv("OPENCLAW_HOME", "/srv/openclaw-home"); - vi.stubEnv("HOME", "/home/other"); + setTestEnvValue("OPENCLAW_HOME", "/srv/openclaw-home"); + setTestEnvValue("HOME", "/home/other"); const result = resolveCronStorePath("~/cron/jobs.json"); expect(result).toBe(path.resolve("/srv/openclaw-home", "cron", "jobs.json")); @@ -522,6 +525,48 @@ describe("cron store", () => { }); }); + it("round-trips the toolsAllow default-cap flag through SQLite", async () => { + // The flag must survive a gateway restart: without it, a CLI-resolved run + // would re-hit the prepare.ts toolsAllow rejection after reload (#91499). + const store = await makeStorePath(); + const payload = makeStore("tools-allow-default-job", true); + payload.jobs[0].sessionTarget = "isolated"; + payload.jobs[0].payload = { + kind: "agentTurn", + message: "scheduled continuation", + toolsAllow: ["read", "cron"], + toolsAllowIsDefault: true, + }; + + await saveCronStore(store.storePath, payload); + + expect((await loadCronStore(store.storePath)).jobs[0]?.payload).toMatchObject({ + kind: "agentTurn", + toolsAllow: ["read", "cron"], + toolsAllowIsDefault: true, + }); + }); + + it("does not persist a default-cap flag for an explicit toolsAllow restriction", async () => { + // An explicit user restriction is fail-closed: it carries no flag, so a CLI + // run still surfaces the prepare.ts rejection rather than silently dropping + // the requested policy. + const store = await makeStorePath(); + const payload = makeStore("tools-allow-explicit-job", true); + payload.jobs[0].sessionTarget = "isolated"; + payload.jobs[0].payload = { + kind: "agentTurn", + message: "scheduled continuation", + toolsAllow: ["read"], + }; + + await saveCronStore(store.storePath, payload); + + const reloaded = (await loadCronStore(store.storePath)).jobs[0]?.payload; + expect(reloaded).toMatchObject({ kind: "agentTurn", toolsAllow: ["read"] }); + expect(reloaded && "toolsAllowIsDefault" in reloaded).toBe(false); + }); + it("round-trips command payloads through SQLite", async () => { const store = await makeStorePath(); const payload = makeStore("command-job", true); diff --git a/src/cron/store/failure-alert-codec.test.ts b/src/cron/store/failure-alert-codec.test.ts new file mode 100644 index 000000000000..2252592ed761 --- /dev/null +++ b/src/cron/store/failure-alert-codec.test.ts @@ -0,0 +1,54 @@ +// Unit tests for failure-alert SQLite column codec roundtrip. +import { describe, expect, it } from "vitest"; +import { bindFailureAlertColumns, failureAlertFromRow } from "./failure-alert-codec.js"; +import type { CronJobRow } from "./schema.js"; + +function roundtrip( + input: Parameters[0], +): ReturnType { + const columns = bindFailureAlertColumns(input); + return failureAlertFromRow(columns as CronJobRow); +} + +describe("failureAlertFromRow", () => { + it("round-trips disabled config (false)", () => { + expect(roundtrip(false)).toBe(false); + }); + + it("round-trips undefined (no alert config) as undefined", () => { + expect(roundtrip(undefined)).toBeUndefined(); + }); + + it("round-trips enabled-with-defaults ({}) as {}", () => { + const result = roundtrip({}); + expect(result).toEqual({}); + }); + + it("round-trips populated config with all fields", () => { + const config = { + after: 3, + cooldownMs: 120_000, + channel: "telegram" as const, + to: "@user", + mode: "announce" as const, + accountId: "acc-1", + includeSkipped: true, + }; + expect(roundtrip(config)).toEqual(config); + }); + + it("round-trips partial config (only after)", () => { + expect(roundtrip({ after: 5 })).toEqual({ after: 5 }); + }); + + it("enabled-with-defaults does not collapse to undefined on read", () => { + const columns = bindFailureAlertColumns({}); + const row = columns as CronJobRow; + expect(row.failure_alert_disabled).toBe(0); + expect(row.failure_alert_after).toBeNull(); + const decoded = failureAlertFromRow(row); + expect(decoded).toEqual({}); + expect(decoded).not.toBeUndefined(); + expect(decoded).toBeTruthy(); + }); +}); diff --git a/src/cron/store/failure-alert-codec.ts b/src/cron/store/failure-alert-codec.ts index 37eaffba98c8..b84c16f2cac6 100644 --- a/src/cron/store/failure-alert-codec.ts +++ b/src/cron/store/failure-alert-codec.ts @@ -46,6 +46,7 @@ export function failureAlertFromRow(row: CronJobRow): CronFailureAlert | false | if (row.failure_alert_disabled === 1) { return false; } + const failureAlertExplicitlyEnabled = row.failure_alert_disabled === 0; if ( row.failure_alert_after == null && !row.failure_alert_channel && @@ -53,7 +54,8 @@ export function failureAlertFromRow(row: CronJobRow): CronFailureAlert | false | row.failure_alert_cooldown_ms == null && row.failure_alert_include_skipped == null && !row.failure_alert_mode && - !row.failure_alert_account_id + !row.failure_alert_account_id && + !failureAlertExplicitlyEnabled ) { return undefined; } diff --git a/src/cron/store/payload-codec.ts b/src/cron/store/payload-codec.ts index de62fd9fbe20..20bea5977d68 100644 --- a/src/cron/store/payload-codec.ts +++ b/src/cron/store/payload-codec.ts @@ -75,6 +75,7 @@ export function bindPayloadColumns( | "payload_thinking" | "payload_timeout_seconds" | "payload_tools_allow_json" + | "payload_tools_allow_is_default" > { if (payload.kind === "systemEvent") { return { @@ -88,6 +89,7 @@ export function bindPayloadColumns( payload_external_content_source_json: null, payload_light_context: null, payload_tools_allow_json: null, + payload_tools_allow_is_default: null, }; } if (payload.kind === "command") { @@ -103,6 +105,7 @@ export function bindPayloadColumns( payload_external_content_source_json: null, payload_light_context: null, payload_tools_allow_json: null, + payload_tools_allow_is_default: null, }; } return { @@ -116,6 +119,9 @@ export function bindPayloadColumns( payload_external_content_source_json: serializeJson(payload.externalContentSource), payload_light_context: booleanToInteger(payload.lightContext), payload_tools_allow_json: serializeJson(payload.toolsAllow), + payload_tools_allow_is_default: payload.toolsAllow + ? booleanToInteger(payload.toolsAllowIsDefault) + : null, }; } @@ -144,6 +150,10 @@ export function payloadFromRow(row: CronJobRow): CronPayload | null { const toolsAllow = row.payload_tools_allow_json ? parseJsonArray(row.payload_tools_allow_json) : undefined; + const toolsAllowIsDefault = + row.payload_tools_allow_is_default != null + ? integerToBoolean(row.payload_tools_allow_is_default) + : undefined; return { kind: "agentTurn", message: row.payload_message, @@ -155,6 +165,7 @@ export function payloadFromRow(row: CronJobRow): CronPayload | null { ...(externalContentSource ? { externalContentSource } : {}), ...(lightContext != null ? { lightContext } : {}), ...(toolsAllow ? { toolsAllow } : {}), + ...(toolsAllow && toolsAllowIsDefault ? { toolsAllowIsDefault: true } : {}), }; } if (row.payload_kind === "command") { diff --git a/src/cron/types.ts b/src/cron/types.ts index efc1114256ff..df132805a6cb 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -248,6 +248,8 @@ type CronAgentTurnPayloadFields = { lightContext?: boolean; /** Optional tool allow-list; when set, only these tools are sent to the model. */ toolsAllow?: string[]; + /** Server-managed marker for auto-stamped defaults; explicit restrictions omit it. */ + toolsAllowIsDefault?: boolean; }; type CronAgentTurnPayload = { diff --git a/src/docker-build-cache.test.ts b/src/docker-build-cache.test.ts index c04de58f18f9..c589ec0dd0c6 100644 --- a/src/docker-build-cache.test.ts +++ b/src/docker-build-cache.test.ts @@ -92,8 +92,11 @@ describe("docker build cache layout", () => { it("does not leave empty shell continuation lines in sandbox-common", async () => { const dockerfile = await readRepoFile("scripts/docker/sandbox/Dockerfile.common"); expect(dockerfile).not.toContain("apt-get install -y --no-install-recommends ${PACKAGES} \\"); + expect(dockerfile).toContain("ARG INSTALL_NODE=1"); + expect(dockerfile).toContain("ARG NODE_MAJOR=24"); + expect(dockerfile).toContain('curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x"'); expect(dockerfile).toContain( - 'RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi', + 'RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm && pnpm --version; fi', ); }); diff --git a/src/flows/doctor-core-checks.test.ts b/src/flows/doctor-core-checks.test.ts index 3c872b6f81a7..f6ca7327cb8f 100644 --- a/src/flows/doctor-core-checks.test.ts +++ b/src/flows/doctor-core-checks.test.ts @@ -748,4 +748,28 @@ describe("CORE_HEALTH_CHECKS", () => { }), ); }); + + it("registers stale session locks as a legacy-owned structured check", async () => { + const check = getCheck(createCoreHealthChecks(createDeps()), "core/doctor/session-locks"); + + if (typeof check.repair !== "function") { + throw new Error("expected session lock check repair"); + } + await expect( + check.repair( + { + mode: "fix", + runtime, + cfg: {}, + cwd: "/tmp/openclaw-test-workspace", + }, + [], + ), + ).resolves.toEqual( + expect.objectContaining({ + status: "skipped", + reason: "legacy doctor session lock contribution owns cleanup", + }), + ); + }); }); diff --git a/src/flows/doctor-core-checks.ts b/src/flows/doctor-core-checks.ts index b9a659e591b1..b5ec92514df6 100644 --- a/src/flows/doctor-core-checks.ts +++ b/src/flows/doctor-core-checks.ts @@ -13,6 +13,11 @@ import { shellCompletionStatusToHealthFindings, shellCompletionStatusToRepairEffects, } from "../commands/doctor-completion.js"; +import { + detectStaleSessionLocks, + sessionLockToHealthFinding, + sessionLockToRepairEffect, +} from "../commands/doctor-session-locks.js"; import { disableUnavailableSkillsInConfig, formatMissingSkillSummary, @@ -31,6 +36,7 @@ import { resolveGatewayAuth } from "../gateway/auth.js"; import { getSkippedExecRefStaticError } from "../secrets/exec-resolution-policy.js"; import type { SkillStatusEntry } from "../skills/discovery/status.js"; import { registerHealthCheck } from "./health-check-registry.js"; +import type { SplitHealthCheckInput } from "./health-check-runner-types.js"; import type { HealthCheck, HealthCheckContext, @@ -42,6 +48,7 @@ const BROWSER_CLAWD_PROFILE_RESIDUE_CHECK_ID = "core/doctor/browser-clawd-profil const CODEX_SESSION_ROUTES_CHECK_ID = "core/doctor/codex-session-routes"; const FINAL_CONFIG_VALIDATION_CHECK_ID = "core/doctor/final-config-validation"; const GATEWAY_SERVICES_EXTRA_CHECK_ID = "core/doctor/gateway-services/extra"; +const SESSION_LOCKS_CHECK_ID = "core/doctor/session-locks"; type CoreHealthCheckContext = HealthCheckContext & { readonly deep?: boolean; @@ -729,6 +736,33 @@ const gatewayPlatformNotesCheck: HealthCheck = { }, }; +const sessionLocksCheck: SplitHealthCheckInput = { + id: SESSION_LOCKS_CHECK_ID, + kind: "core", + description: "Stale session lock files are represented as structured findings.", + source: "doctor", + defaultEnabled: false, + async detect(ctx) { + return (await detectStaleSessionLocks({ config: ctx.cfg, env: process.env })).map( + sessionLockToHealthFinding, + ); + }, + async repair(ctx) { + const effects = (await detectStaleSessionLocks({ config: ctx.cfg, env: process.env })).map( + sessionLockToRepairEffect, + ); + if (ctx.dryRun === true) { + return { status: "repaired", changes: [], effects }; + } + return { + status: "skipped", + reason: "legacy doctor session lock contribution owns cleanup", + changes: [], + effects, + }; + }, +}; + const browserCheck: HealthCheck = { id: "core/doctor/browser", kind: "core", @@ -974,13 +1008,16 @@ function createWorkspaceSuggestionsCheck(deps: CoreHealthCheckDeps): HealthCheck }; } -function createConvertedWorkflowChecks(deps: CoreHealthCheckDeps): readonly HealthCheck[] { +function createConvertedWorkflowChecks( + deps: CoreHealthCheckDeps, +): readonly SplitHealthCheckInput[] { return [ claudeCliCheck, gatewayAuthCheck, legacyStateCheck, legacyWhatsAppCrontabCheck, codexSessionRoutesCheck, + sessionLocksCheck, shellCompletionCheck, uiProtocolFreshnessCheck, gatewayServicesExtraCheck, @@ -1015,7 +1052,7 @@ export function resetCoreHealthChecksForTest(): void { export function createCoreHealthChecks( deps: CoreHealthCheckDeps = defaultCoreHealthCheckDeps, -): readonly HealthCheck[] { +): readonly SplitHealthCheckInput[] { return [ gatewayConfigCheck, ...createConvertedWorkflowChecks(deps), @@ -1026,4 +1063,4 @@ export function createCoreHealthChecks( ]; } -export const CORE_HEALTH_CHECKS: readonly HealthCheck[] = createCoreHealthChecks(); +export const CORE_HEALTH_CHECKS: readonly SplitHealthCheckInput[] = createCoreHealthChecks(); diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index f6d51f9bb504..ec16e918f664 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -1013,6 +1013,9 @@ describe("doctor health contributions", () => { } expect(contributionIds).toContain("core/doctor/sandbox/registry-files"); expect(contributionIds).toContain("core/doctor/gateway-services/extra"); + expect(contributionIds).toContain("core/doctor/config-audit-scrub"); + expect(contributionIds).toContain("core/doctor/session-transcripts"); + expect(contributionIds).toContain("core/doctor/session-snapshots"); expect(contributionChecks.map((check) => check.id)).toEqual(contributionIds); }); diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index a527261b9650..55efcc829317 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -1292,21 +1292,108 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] { createDoctorHealthContribution({ id: "doctor:session-locks", label: "Session locks", + healthCheckIds: ["core/doctor/session-locks"], run: runSessionLocksHealth, }), createDoctorHealthContribution({ id: "doctor:session-transcripts", label: "Session transcripts", + healthChecks: { + id: "core/doctor/session-transcripts", + description: "Legacy or branchy session transcript files are represented as findings.", + async detect() { + const { detectSessionTranscriptHealthIssues, sessionTranscriptIssueToHealthFinding } = + await import("../commands/doctor-session-transcripts.js"); + return (await detectSessionTranscriptHealthIssues()).map( + sessionTranscriptIssueToHealthFinding, + ); + }, + async repair(ctx) { + const { detectSessionTranscriptHealthIssues, sessionTranscriptIssueToRepairEffect } = + await import("../commands/doctor-session-transcripts.js"); + const effects = (await detectSessionTranscriptHealthIssues()).map( + sessionTranscriptIssueToRepairEffect, + ); + if (ctx.dryRun === true) { + return { status: "repaired", changes: [], effects }; + } + return { + status: "skipped", + reason: "legacy doctor session transcript contribution owns transcript rewrites", + changes: [], + effects, + }; + }, + }, run: runSessionTranscriptsHealth, }), createDoctorHealthContribution({ id: "doctor:session-snapshots", label: "Session snapshots", + healthChecks: { + id: "core/doctor/session-snapshots", + description: "Stale cached session snapshot paths are represented as findings.", + async detect(ctx) { + const { detectSessionSnapshotHealthIssues, sessionSnapshotIssueToHealthFinding } = + await import("../commands/doctor-session-snapshots.js"); + return ( + await detectSessionSnapshotHealthIssues({ + cfg: ctx.cfg, + env: process.env, + }) + ).map(sessionSnapshotIssueToHealthFinding); + }, + async repair(ctx) { + const { detectSessionSnapshotHealthIssues, sessionSnapshotIssueToRepairEffect } = + await import("../commands/doctor-session-snapshots.js"); + const effects = ( + await detectSessionSnapshotHealthIssues({ + cfg: ctx.cfg, + env: process.env, + }) + ).map(sessionSnapshotIssueToRepairEffect); + if (ctx.dryRun === true) { + return { status: "repaired", changes: [], effects }; + } + return { + status: "skipped", + reason: "legacy doctor session snapshot contribution owns snapshot rewrites", + changes: [], + effects, + }; + }, + }, run: runSessionSnapshotsHealth, }), createDoctorHealthContribution({ id: "doctor:config-audit-scrub", label: "Config audit", + healthChecks: { + description: + "Historical config-audit argv redaction gaps are represented as structured findings.", + defaultEnabled: false, + async detect() { + const { configAuditScrubToHealthFinding, detectConfigAuditScrubIssue } = + await import("../commands/doctor-config-audit-scrub.js"); + const result = await detectConfigAuditScrubIssue(); + return result.rewritten > 0 ? [configAuditScrubToHealthFinding(result)] : []; + }, + async repair(ctx) { + const { configAuditScrubToRepairEffect, detectConfigAuditScrubIssue } = + await import("../commands/doctor-config-audit-scrub.js"); + const result = await detectConfigAuditScrubIssue(); + const effects = result.rewritten > 0 ? [configAuditScrubToRepairEffect(result)] : []; + if (ctx.dryRun === true) { + return { status: "repaired", changes: [], effects }; + } + return { + status: "skipped", + reason: "legacy doctor config audit contribution owns cleanup", + changes: [], + effects, + }; + }, + }, run: runConfigAuditScrubHealth, }), createDoctorHealthContribution({ diff --git a/src/flows/doctor-lint-flow.test.ts b/src/flows/doctor-lint-flow.test.ts index 205fbd675cf0..deef65db2b83 100644 --- a/src/flows/doctor-lint-flow.test.ts +++ b/src/flows/doctor-lint-flow.test.ts @@ -39,6 +39,57 @@ describe("runDoctorLintChecks", () => { expect(result.findings.map((finding) => finding.checkId)).toEqual(["a"]); }); + it("skips default-disabled checks unless explicitly selected", async () => { + const defaultDisabled = normalizeHealthCheck({ + ...check("targeted", async () => [ + { checkId: "targeted", severity: "warning" as const, message: "warn" }, + ]), + defaultEnabled: false, + }); + + await expect( + runDoctorLintChecks(ctx, { + checks: [defaultDisabled], + }), + ).resolves.toMatchObject({ + checksRun: 0, + checksSkipped: 1, + findings: [], + }); + + await expect( + runDoctorLintChecks(ctx, { + checks: [defaultDisabled], + onlyIds: ["targeted"], + }), + ).resolves.toMatchObject({ + checksRun: 1, + checksSkipped: 0, + findings: [expect.objectContaining({ checkId: "targeted" })], + }); + }); + + it("runs default-disabled checks when all checks are requested", async () => { + const defaultDisabled = normalizeHealthCheck({ + ...check("targeted", async () => [ + { checkId: "targeted", severity: "warning" as const, message: "warn" }, + ]), + defaultEnabled: false, + }); + const defaultEnabled = check("regular", async () => []); + + const result = await runDoctorLintChecks(ctx, { + checks: [defaultDisabled, defaultEnabled], + includeAllChecks: true, + }); + + expect(result).toMatchObject({ + checksRun: 2, + checksSkipped: 0, + findings: [expect.objectContaining({ checkId: "targeted" })], + }); + }); + it("supports single-run checks in lint mode", async () => { const runnable: RunnableHealthCheck = { id: "run-check", diff --git a/src/flows/doctor-lint-flow.ts b/src/flows/doctor-lint-flow.ts index fa867fecd2bc..b7bf33bd95f0 100644 --- a/src/flows/doctor-lint-flow.ts +++ b/src/flows/doctor-lint-flow.ts @@ -15,6 +15,7 @@ export interface DoctorLintRunOptions { readonly checks?: readonly HealthCheck[]; readonly skipIds?: ReadonlySet | readonly string[]; readonly onlyIds?: ReadonlySet | readonly string[]; + readonly includeAllChecks?: boolean; } export interface DoctorLintRunResult { @@ -32,11 +33,15 @@ export async function runDoctorLintChecks( const skip = opts.skipIds instanceof Set ? opts.skipIds : new Set(opts.skipIds ?? []); const only = opts.onlyIds instanceof Set ? opts.onlyIds : new Set(opts.onlyIds ?? []); const allIds = new Set(all.map((check) => check.id)); + const includeDefaultDisabled = opts.includeAllChecks === true; const selected = all.filter((c) => { if (only.size > 0 && !only.has(c.id)) { return false; } + if (only.size === 0 && !includeDefaultDisabled && isDefaultDisabled(c)) { + return false; + } if (skip.has(c.id)) { return false; } @@ -78,6 +83,10 @@ export async function runDoctorLintChecks( }; } +function isDefaultDisabled(check: HealthCheck): boolean { + return "defaultEnabled" in check && check.defaultEnabled === false; +} + // Stable ordering keeps CLI output and tests deterministic across registry order changes. function compareFindings(a: HealthFinding, b: HealthFinding): number { const sevDelta = diff --git a/src/flows/health-check-adapter.ts b/src/flows/health-check-adapter.ts index 7a54999bfcbf..972dd67ac693 100644 --- a/src/flows/health-check-adapter.ts +++ b/src/flows/health-check-adapter.ts @@ -3,17 +3,19 @@ import type { HealthCheckInput, HealthCheckRunResult, RegisteredHealthCheck, + SplitHealthCheckInput, } from "./health-check-runner-types.js"; -import type { HealthCheck, HealthRepairContext } from "./health-checks.js"; +import type { HealthRepairContext } from "./health-checks.js"; // Adapts legacy split detect/repair checks and newer runnable checks to one runner contract. /** Wraps a detect/repair health check in the runnable health-check contract. */ -export function defineSplitHealthCheck(check: HealthCheck): RegisteredHealthCheck { +export function defineSplitHealthCheck(check: SplitHealthCheckInput): RegisteredHealthCheck { return { id: check.id, kind: check.kind, description: check.description, source: check.source, + defaultEnabled: check.defaultEnabled, sourceContract: "split", detect: (ctx, scope) => check.detect(ctx, scope), repair: @@ -73,6 +75,7 @@ export function normalizeHealthCheck(check: HealthCheckInput): RegisteredHealthC kind: check.kind, description: check.description, source: check.source, + defaultEnabled: check.defaultEnabled, sourceContract: "run", async detect(ctx, scope) { const result = await check.run({ ...ctx, repair: false }, scope); diff --git a/src/flows/health-check-runner-types.ts b/src/flows/health-check-runner-types.ts index 7417c902b7d6..2f90ab87130d 100644 --- a/src/flows/health-check-runner-types.ts +++ b/src/flows/health-check-runner-types.ts @@ -25,18 +25,23 @@ export interface HealthCheckRunResult extends Omit { +export interface RunnableHealthCheck + extends Pick, HealthCheckSelectionOptions { run(ctx: HealthCheckRunContext, scope?: HealthCheckScope): Promise; } -export type HealthCheckInput = HealthCheck | RunnableHealthCheck; +export type HealthCheckInput = SplitHealthCheckInput | RunnableHealthCheck; /** Normalized check contract consumed by lint and repair runners. */ -export interface RegisteredHealthCheck extends HealthCheck { +export interface RegisteredHealthCheck extends HealthCheck, HealthCheckSelectionOptions { readonly sourceContract: "split" | "run"; run(ctx: HealthCheckRunContext, scope?: HealthCheckScope): Promise; } diff --git a/src/gateway/auth-rate-limit.test.ts b/src/gateway/auth-rate-limit.test.ts index 4d4da3034d05..a8798be4c87d 100644 --- a/src/gateway/auth-rate-limit.test.ts +++ b/src/gateway/auth-rate-limit.test.ts @@ -22,6 +22,7 @@ describe("auth rate limiter", () => { lockoutMs: number; exemptLoopback: boolean; pruneIntervalMs: number; + maxEntries: number; }>, ) { limiter = createAuthRateLimiter({ @@ -159,6 +160,87 @@ describe("auth rate limiter", () => { expect(limiter.check("10.0.0.11").remaining).toBe(2); }); + it("caps unique client entries under flood", () => { + createLimiter({ maxEntries: 3, pruneIntervalMs: 0 }); + + limiter.recordFailure("10.0.1.1"); + limiter.recordFailure("10.0.1.2"); + limiter.recordFailure("10.0.1.3"); + limiter.recordFailure("10.0.1.4"); + + expect(limiter.size()).toBe(3); + expect(limiter.check("10.0.1.1").remaining).toBe(2); + expect(limiter.check("10.0.1.4").remaining).toBe(1); + }); + + it("preserves locked entries when flood eviction runs", () => { + createLimiter({ maxEntries: 3, pruneIntervalMs: 0 }); + + limiter.recordFailure("10.0.2.1"); + limiter.recordFailure("10.0.2.1"); + expect(limiter.check("10.0.2.1").allowed).toBe(false); + limiter.recordFailure("10.0.2.2"); + limiter.recordFailure("10.0.2.3"); + + limiter.recordFailure("10.0.2.4"); + + expect(limiter.size()).toBe(3); + expect(limiter.check("10.0.2.1").allowed).toBe(false); + expect(limiter.check("10.0.2.2").remaining).toBe(2); + expect(limiter.check("10.0.2.4").remaining).toBe(1); + }); + + it("fails closed when every tracked entry is locked", () => { + vi.useFakeTimers(); + try { + limiter = createAuthRateLimiter({ + maxAttempts: 1, + windowMs: 60_000, + lockoutMs: 60_000, + maxEntries: 2, + pruneIntervalMs: 0, + }); + + limiter.recordFailure("10.0.3.1"); + limiter.recordFailure("10.0.3.2"); + limiter.recordFailure("10.0.3.3"); + + expect(limiter.size()).toBe(2); + expect(limiter.check("10.0.3.1").allowed).toBe(false); + expect(limiter.check("10.0.3.2").allowed).toBe(false); + const overflowResult = limiter.check("10.0.3.3"); + expect(overflowResult.allowed).toBe(false); + expect(overflowResult.retryAfterMs).toBeGreaterThan(0); + + vi.advanceTimersByTime(60_001); + expect(limiter.check("10.0.3.3").allowed).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it.each([ + { value: 0, expectedSize: 1 }, + { value: -2, expectedSize: 1 }, + { value: 1.9, expectedSize: 1 }, + { value: 2.9, expectedSize: 2 }, + { value: Number.NaN, expectedSize: 2 }, + { value: Number.POSITIVE_INFINITY, expectedSize: 2 }, + ])("normalizes maxEntries value $value", ({ value, expectedSize }) => { + limiter = createAuthRateLimiter({ + maxAttempts: 2, + windowMs: 60_000, + lockoutMs: 60_000, + maxEntries: value, + pruneIntervalMs: 0, + }); + + limiter.recordFailure("10.0.4.1"); + limiter.recordFailure("10.0.4.2"); + + expect(limiter.size()).toBe(expectedSize); + }); + it("treats ipv4 and ipv4-mapped ipv6 forms as the same client", () => { limiter = createAuthRateLimiter({ maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000 }); limiter.recordFailure("1.2.3.4"); diff --git a/src/gateway/auth-rate-limit.ts b/src/gateway/auth-rate-limit.ts index 89672f5c7bf3..006394fd4973 100644 --- a/src/gateway/auth-rate-limit.ts +++ b/src/gateway/auth-rate-limit.ts @@ -8,15 +8,15 @@ * * Design decisions: * - Pure in-memory Map – no external dependencies; suitable for a single - * gateway process. The Map is periodically pruned to avoid unbounded - * growth. + * gateway process. The Map is periodically pruned and capped to avoid + * unbounded growth. * - Loopback addresses (127.0.0.1 / ::1) are exempt by default so that local * CLI sessions are never locked out. * - The module is side-effect-free: callers create an instance via * {@link createAuthRateLimiter} and pass it where needed. */ -import { resolveTimerTimeoutMs } from "../shared/number-coercion.js"; +import { resolveIntegerOption, resolveTimerTimeoutMs } from "../shared/number-coercion.js"; import { isLoopbackAddress, resolveClientIp } from "./net.js"; // --------------------------------------------------------------------------- @@ -34,6 +34,8 @@ export interface RateLimitConfig { exemptLoopback?: boolean; /** Background prune interval in milliseconds; set <= 0 to disable auto-prune. @default 60_000 */ pruneIntervalMs?: number; + /** Maximum tracked client identities before old unlocked entries are evicted. @default 10_000 */ + maxEntries?: number; } export const AUTH_RATE_LIMIT_SCOPE_DEFAULT = "default"; @@ -96,6 +98,7 @@ const DEFAULT_MAX_ATTEMPTS = 10; const DEFAULT_WINDOW_MS = 60_000; // 1 minute const DEFAULT_LOCKOUT_MS = 300_000; // 5 minutes const PRUNE_INTERVAL_MS = 60_000; // prune stale entries every minute +const DEFAULT_MAX_ENTRIES = 10_000; // --------------------------------------------------------------------------- // Implementation @@ -137,8 +140,10 @@ export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter const lockoutMs = resolveTimerTimeoutMs(config?.lockoutMs, DEFAULT_LOCKOUT_MS, 0); const exemptLoopback = config?.exemptLoopback ?? true; const pruneIntervalMs = resolvePruneIntervalMs(config?.pruneIntervalMs); + const maxEntries = resolveIntegerOption(config?.maxEntries, DEFAULT_MAX_ENTRIES, { min: 1 }); const entries = new Map(); + let overflowLockedUntil: number | undefined; // Periodic cleanup to avoid unbounded map growth. const pruneTimer = pruneIntervalMs > 0 ? setInterval(() => prune(), pruneIntervalMs) : null; @@ -187,6 +192,10 @@ export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter const entry = entries.get(key); if (!entry) { + const overflowLock = checkOverflowLock(now); + if (overflowLock) { + return overflowLock; + } return { allowed: true, remaining: maxAttempts, retryAfterMs: 0 }; } @@ -220,6 +229,10 @@ export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter let entry = entries.get(key); if (!entry) { + if (!enforceMaxEntries(now)) { + overflowLockedUntil = Math.max(overflowLockedUntil ?? 0, now + lockoutMs); + return; + } entry = { attempts: [] }; entries.set(key, entry); } @@ -242,8 +255,7 @@ export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter entries.delete(key); } - function prune(): void { - const now = Date.now(); + function pruneExpiredEntries(now: number): void { for (const [key, entry] of entries) { // If locked out, keep the entry until the lockout expires. if (entry.lockedUntil && now < entry.lockedUntil) { @@ -256,6 +268,52 @@ export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter } } + function checkOverflowLock(now: number): RateLimitCheckResult | undefined { + if (!overflowLockedUntil) { + return undefined; + } + if (now >= overflowLockedUntil) { + overflowLockedUntil = undefined; + return undefined; + } + if (entries.size >= maxEntries) { + pruneExpiredEntries(now); + } + if (entries.size < maxEntries) { + overflowLockedUntil = undefined; + return undefined; + } + return { + allowed: false, + remaining: 0, + retryAfterMs: overflowLockedUntil - now, + }; + } + + function enforceMaxEntries(now: number): boolean { + if (entries.size < maxEntries) { + return true; + } + + pruneExpiredEntries(now); + if (entries.size < maxEntries) { + return true; + } + + // Preserve active lockouts so a flood cannot evict the attacker's own block. + for (const [entryKey, entry] of entries) { + if (!entry.lockedUntil || now >= entry.lockedUntil) { + entries.delete(entryKey); + return true; + } + } + return false; + } + + function prune(): void { + pruneExpiredEntries(Date.now()); + } + function size(): number { return entries.size; } @@ -265,6 +323,7 @@ export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter clearInterval(pruneTimer); } entries.clear(); + overflowLockedUntil = undefined; } return { check, recordFailure, reset, size, prune, dispose }; diff --git a/src/gateway/boot.ts b/src/gateway/boot.ts index c7b5e25ec989..7510dcdf04c3 100644 --- a/src/gateway/boot.ts +++ b/src/gateway/boot.ts @@ -18,8 +18,7 @@ import { resolveMainSessionKey, } from "../config/sessions/main-session.js"; import { resolveStorePath } from "../config/sessions/paths.js"; -import { loadSessionStore, updateSessionStore } from "../config/sessions/store.js"; -import type { SessionEntry } from "../config/sessions/types.js"; +import { preserveTemporarySessionMapping } from "../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -33,14 +32,6 @@ function generateBootSessionId(): string { return `boot-${ts}-${suffix}`; } -type SessionMappingSnapshot = { - storePath: string; - sessionKey: string; - canRestore: boolean; - hadEntry: boolean; - entry?: SessionEntry; -}; - const log = createSubsystemLogger("gateway/boot"); const BOOT_FILENAME = "BOOT.md"; @@ -101,68 +92,6 @@ async function loadBootFile( } } -function snapshotSessionMapping(params: { - cfg: OpenClawConfig; - sessionKey: string; -}): SessionMappingSnapshot { - const agentId = resolveAgentIdFromSessionKey(params.sessionKey); - const storePath = resolveStorePath(params.cfg.session?.store, { agentId }); - try { - const store = loadSessionStore(storePath, { skipCache: true }); - const entry = store[params.sessionKey]; - if (!entry) { - return { - storePath, - sessionKey: params.sessionKey, - canRestore: true, - hadEntry: false, - }; - } - return { - storePath, - sessionKey: params.sessionKey, - canRestore: true, - hadEntry: true, - entry: structuredClone(entry), - }; - } catch (err) { - log.debug("boot: could not snapshot session mapping", { - sessionKey: params.sessionKey, - error: String(err), - }); - return { - storePath, - sessionKey: params.sessionKey, - canRestore: false, - hadEntry: false, - }; - } -} - -async function restoreSessionMapping( - snapshot: SessionMappingSnapshot, -): Promise { - if (!snapshot.canRestore) { - return undefined; - } - try { - await updateSessionStore( - snapshot.storePath, - (store) => { - if (snapshot.hadEntry && snapshot.entry) { - store[snapshot.sessionKey] = snapshot.entry; - return; - } - delete store[snapshot.sessionKey]; - }, - { activeSessionKey: snapshot.sessionKey }, - ); - return undefined; - } catch (err) { - return formatErrorMessage(err); - } -} - export async function runBootOnce(params: { cfg: OpenClawConfig; deps: CliDeps; @@ -193,39 +122,49 @@ export async function runBootOnce(params: { const sessionKey = resolveBootSessionKey(mainSessionKey); const message = buildBootPrompt(result.content ?? ""); const sessionId = generateBootSessionId(); - const mappingSnapshot = snapshotSessionMapping({ - cfg: params.cfg, - sessionKey, - }); + const agentId = resolveAgentIdFromSessionKey(sessionKey); + const storePath = resolveStorePath(params.cfg.session?.store, { agentId }); - // Register the boot prompt for the message-tool echo guard so the - // tool layer can drop fallback-model echoes that copy substantial - // BOOT.md content without preserving the wrapper markers above. - // Always cleared in finally so a failed run does not leave a stale - // entry that mis-fires on an unrelated subsequent run reusing the - // same session key. Refs #53732. - setBootEchoContextForSession(sessionKey, message); - let agentFailure: string | undefined; - try { - await agentCommand( - { - message, - sessionKey, - sessionId, - deliver: false, - suppressPromptPersistence: true, - }, - bootRuntime, - params.deps, - ); - } catch (err) { - agentFailure = formatErrorMessage(err); - log.error(`boot: agent run failed: ${agentFailure}`); - } finally { - clearBootEchoContextForSession(sessionKey); + const mappingPreservation = await preserveTemporarySessionMapping( + { storePath, sessionKey }, + async () => { + // Register the boot prompt for the message-tool echo guard so the + // tool layer can drop fallback-model echoes that copy substantial + // BOOT.md content without preserving the wrapper markers above. + // Always cleared in finally so a failed run does not leave a stale + // entry that mis-fires on an unrelated subsequent run reusing the + // same session key. Refs #53732. + setBootEchoContextForSession(sessionKey, message); + try { + await agentCommand( + { + message, + sessionKey, + sessionId, + deliver: false, + suppressPromptPersistence: true, + }, + bootRuntime, + params.deps, + ); + return undefined; + } catch (err) { + const failure = formatErrorMessage(err); + log.error(`boot: agent run failed: ${failure}`); + return failure; + } finally { + clearBootEchoContextForSession(sessionKey); + } + }, + ); + const agentFailure = mappingPreservation.result; + if (mappingPreservation.snapshotFailure) { + log.debug("boot: could not snapshot session mapping", { + sessionKey, + error: mappingPreservation.snapshotFailure, + }); } - - const mappingRestoreFailure = await restoreSessionMapping(mappingSnapshot); + const mappingRestoreFailure = mappingPreservation.restoreFailure; if (mappingRestoreFailure) { log.error(`boot: failed to restore session mapping: ${mappingRestoreFailure}`); } diff --git a/src/gateway/channel-health-monitor.test.ts b/src/gateway/channel-health-monitor.test.ts index c42d8d36aa0f..399bae437b02 100644 --- a/src/gateway/channel-health-monitor.test.ts +++ b/src/gateway/channel-health-monitor.test.ts @@ -456,6 +456,34 @@ describe("channel-health-monitor", () => { monitor.stop(); }); + it("continues pending recovery on the next check without waiting for cooldown", async () => { + const account: Partial = disconnectedAccount(Date.now() - 300_000); + const manager = createSnapshotManager( + { + discord: { + default: account, + }, + }, + { + startChannel: vi.fn(async () => { + account.running = false; + account.connected = false; + account.restartPending = true; + account.reconnectAttempts = 0; + }), + }, + ); + const monitor = await startAndRunCheck(manager); + expect(manager.stopChannel).toHaveBeenCalledTimes(1); + expect(manager.startChannel).toHaveBeenCalledTimes(1); + + await advanceHealthCheck(); + + expect(manager.stopChannel).toHaveBeenCalledTimes(1); + expect(manager.startChannel).toHaveBeenCalledTimes(2); + monitor.stop(); + }); + it("caps at 3 health-monitor restarts per channel per hour", async () => { const manager = createSnapshotManager({ discord: { diff --git a/src/gateway/channel-health-monitor.ts b/src/gateway/channel-health-monitor.ts index 7072325aa869..e3c822ced1df 100644 --- a/src/gateway/channel-health-monitor.ts +++ b/src/gateway/channel-health-monitor.ts @@ -145,12 +145,20 @@ export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): Chann restartsThisHour: [], }; - if (now - record.lastRestartAt <= cooldownMs) { + const continuingPendingRestart = + status.running !== true && + status.restartPending === true && + (status.reconnectAttempts ?? 0) === 0; + + // A timed-out recovery stop uses the first start request to mark + // restartPending; the next monitor pass must finish that same recovery + // instead of waiting behind this monitor's fresh-restart cooldown. + if (!continuingPendingRestart && now - record.lastRestartAt <= cooldownMs) { continue; } pruneOldRestarts(record, now); - if (record.restartsThisHour.length >= maxRestartsPerHour) { + if (!continuingPendingRestart && record.restartsThisHour.length >= maxRestartsPerHour) { log.warn?.( `[${channelId}:${accountId}] health-monitor: hit ${maxRestartsPerHour} restarts/hour limit, skipping`, ); @@ -161,9 +169,11 @@ export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): Chann log.info?.(`[${channelId}:${accountId}] health-monitor: restarting (reason: ${reason})`); - record.lastRestartAt = now; - record.restartsThisHour.push({ at: now }); - restartRecords.set(key, record); + if (!continuingPendingRestart) { + record.lastRestartAt = now; + record.restartsThisHour.push({ at: now }); + restartRecords.set(key, record); + } try { if (status.running) { diff --git a/src/gateway/gateway-acp-bind.live.test.ts b/src/gateway/gateway-acp-bind.live.test.ts index 7a7ef31e0332..6b2f44e85641 100644 --- a/src/gateway/gateway-acp-bind.live.test.ts +++ b/src/gateway/gateway-acp-bind.live.test.ts @@ -22,6 +22,7 @@ import { } from "../plugins/runtime.js"; import { extractFirstTextBlock } from "../shared/chat-message-content.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { setTestEnvValue } from "../test-utils/env.js"; import { sleep } from "../utils.js"; import type { GatewayClient } from "./client.js"; import { connectTestGatewayClient } from "./gateway-cli-backend.live-helpers.js"; @@ -593,7 +594,7 @@ describeLive("gateway live (ACP bind)", () => { | undefined; clearRuntimeConfigSnapshot(); - process.env.OPENCLAW_STATE_DIR = tempStateDir; + setTestEnvValue("OPENCLAW_STATE_DIR", tempStateDir); process.env.OPENCLAW_SKIP_CHANNELS = "1"; process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; process.env.OPENCLAW_SKIP_CRON = "0"; @@ -682,7 +683,7 @@ describeLive("gateway live (ACP bind)", () => { }, }; await fs.writeFile(tempConfigPath, `${JSON.stringify(nextCfg, null, 2)}\n`); - process.env.OPENCLAW_CONFIG_PATH = tempConfigPath; + setTestEnvValue("OPENCLAW_CONFIG_PATH", tempConfigPath); logLiveStep(`using parent live model ${parentModel}`); clearConfigCache(); clearRuntimeConfigSnapshot(); diff --git a/src/gateway/gateway-acp-spawn-defaults.live.test.ts b/src/gateway/gateway-acp-spawn-defaults.live.test.ts index a89eeb8bb26c..a427dfa8fefc 100644 --- a/src/gateway/gateway-acp-spawn-defaults.live.test.ts +++ b/src/gateway/gateway-acp-spawn-defaults.live.test.ts @@ -23,6 +23,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { clearPluginLoaderCache } from "../plugins/loader.js"; import { resetPluginRuntimeStateForTest } from "../plugins/runtime.js"; +import { setTestEnvValue } from "../test-utils/env.js"; import { sleep } from "../utils.js"; import { restoreLiveEnv, snapshotLiveEnv, type LiveEnvSnapshot } from "./live-env-test-helpers.js"; import { startGatewayServer } from "./server.js"; @@ -282,8 +283,8 @@ describeLive("gateway live (ACP spawn defaults)", () => { const sessionKeys: string[] = []; let server: Awaited> | undefined; - process.env.OPENCLAW_CONFIG_PATH = tempConfigPath; - process.env.OPENCLAW_STATE_DIR = tempStateDir; + setTestEnvValue("OPENCLAW_CONFIG_PATH", tempConfigPath); + setTestEnvValue("OPENCLAW_STATE_DIR", tempStateDir); process.env.OPENCLAW_SKIP_CHANNELS = "1"; process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; process.env.OPENCLAW_SKIP_CRON = "1"; diff --git a/src/gateway/gateway-cli-backend.live.test.ts b/src/gateway/gateway-cli-backend.live.test.ts index d1cb3eb1b9f7..b9639db67b50 100644 --- a/src/gateway/gateway-cli-backend.live.test.ts +++ b/src/gateway/gateway-cli-backend.live.test.ts @@ -10,6 +10,7 @@ import { shouldSkipLiveProviderDrift } from "../agents/live-test-provider-drift. import { parseModelRef } from "../agents/model-selection.js"; import { clearRuntimeConfigSnapshot, type OpenClawConfig } from "../config/config.js"; import { isTruthyEnvValue } from "../infra/env.js"; +import { setTestEnvValue } from "../test-utils/env.js"; import { applyCliBackendLiveEnv, createBootstrapWorkspace, @@ -285,7 +286,7 @@ describeLive("gateway live (cli backend)", () => { applyCliBackendLiveEnv(preservedEnv); const token = `test-${randomUUID()}`; - process.env.OPENCLAW_GATEWAY_TOKEN = token; + setTestEnvValue("OPENCLAW_GATEWAY_TOKEN", token); const port = await getFreeGatewayPort(); logCliBackendLiveStep("env-ready", { port }); @@ -369,7 +370,7 @@ describeLive("gateway live (cli backend)", () => { ? await createMcpSchemaProbePlugin(tempDir) : undefined; const useMinimalToolsProfile = providerId === "codex-cli" && !schemaProbePluginPath; - process.env.OPENCLAW_STATE_DIR = stateDir; + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); const bundleMcp = backendResolved?.bundleMcp === true; const bootstrapWorkspace = await createBootstrapWorkspace(tempDir); const disableMcpConfig = process.env.OPENCLAW_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG !== "0"; @@ -473,7 +474,7 @@ describeLive("gateway live (cli backend)", () => { }; const tempConfigPath = path.join(tempDir, "openclaw.json"); await fs.writeFile(tempConfigPath, `${JSON.stringify(nextCfg, null, 2)}\n`); - process.env.OPENCLAW_CONFIG_PATH = tempConfigPath; + setTestEnvValue("OPENCLAW_CONFIG_PATH", tempConfigPath); const deviceIdentity = await ensurePairedTestGatewayClientIdentity(); let server: Awaited> | undefined; let client: Awaited> | undefined; diff --git a/src/gateway/gateway-codex-bind.live.test.ts b/src/gateway/gateway-codex-bind.live.test.ts index d065fe2c10d2..d7e30ad15cf3 100644 --- a/src/gateway/gateway-codex-bind.live.test.ts +++ b/src/gateway/gateway-codex-bind.live.test.ts @@ -21,12 +21,14 @@ import { } from "../plugins/runtime.js"; import { extractFirstTextBlock } from "../shared/chat-message-content.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js"; import { sleep } from "../utils.js"; import type { GatewayClient } from "./client.js"; import { connectTestGatewayClient, getFreeGatewayPort, } from "./gateway-cli-backend.live-helpers.js"; +import { restoreLiveEnv, snapshotLiveEnv, type LiveEnvSnapshot } from "./live-env-test-helpers.js"; import { startGatewayServer } from "./server.js"; const LIVE = isLiveTestEnabled(); @@ -172,14 +174,6 @@ async function waitForOutboundText(params: { ); } -function restoreEnvVar(name: string, value: string | undefined): void { - if (value === undefined) { - delete process.env[name]; - return; - } - process.env[name] = value; -} - async function waitForAgentRunOk( client: GatewayClient, runId: string, @@ -400,17 +394,7 @@ describeLive("gateway live (native Codex conversation binding)", () => { it( "binds a Slack DM to Codex app-server, updates controls, and forwards image media paths", async () => { - const previous = { - codexHome: process.env.CODEX_HOME, - configPath: process.env.OPENCLAW_CONFIG_PATH, - gatewayToken: process.env.OPENCLAW_GATEWAY_TOKEN, - home: process.env.HOME, - skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST, - skipChannels: process.env.OPENCLAW_SKIP_CHANNELS, - skipCron: process.env.OPENCLAW_SKIP_CRON, - skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER, - stateDir: process.env.OPENCLAW_STATE_DIR, - }; + const previous: LiveEnvSnapshot = snapshotLiveEnv(["CODEX_HOME", "HOME"]); const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-live-codex-bind-")); const tempHome = path.join(tempRoot, "home"); const stateDir = path.join(tempRoot, "state"); @@ -453,20 +437,20 @@ describeLive("gateway live (native Codex conversation binding)", () => { clearPluginLoaderCache(); resetPluginRuntimeStateForTest(); const codexHome = - previous.codexHome || (previous.home ? path.join(previous.home, ".codex") : ""); + previous.CODEX_HOME || (previous.HOME ? path.join(previous.HOME, ".codex") : ""); if (codexHome) { - process.env.CODEX_HOME = codexHome; + setTestEnvValue("CODEX_HOME", codexHome); } else { - delete process.env.CODEX_HOME; + deleteTestEnvValue("CODEX_HOME"); } - process.env.HOME = tempHome; - process.env.OPENCLAW_CONFIG_PATH = configPath; - process.env.OPENCLAW_GATEWAY_TOKEN = token; - process.env.OPENCLAW_SKIP_CANVAS_HOST = "1"; - process.env.OPENCLAW_SKIP_CHANNELS = "1"; - process.env.OPENCLAW_SKIP_CRON = "1"; - process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; - process.env.OPENCLAW_STATE_DIR = stateDir; + setTestEnvValue("HOME", tempHome); + setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath); + setTestEnvValue("OPENCLAW_GATEWAY_TOKEN", token); + setTestEnvValue("OPENCLAW_SKIP_CANVAS_HOST", "1"); + setTestEnvValue("OPENCLAW_SKIP_CHANNELS", "1"); + setTestEnvValue("OPENCLAW_SKIP_CRON", "1"); + setTestEnvValue("OPENCLAW_SKIP_GMAIL_WATCHER", "1"); + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); let server: Awaited> | undefined; let client: Awaited> | undefined; let pinnedChannelRegistry: @@ -631,15 +615,7 @@ describeLive("gateway live (native Codex conversation binding)", () => { } } finally { await fs.rm(tempRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); - restoreEnvVar("CODEX_HOME", previous.codexHome); - restoreEnvVar("OPENCLAW_CONFIG_PATH", previous.configPath); - restoreEnvVar("OPENCLAW_GATEWAY_TOKEN", previous.gatewayToken); - restoreEnvVar("HOME", previous.home); - restoreEnvVar("OPENCLAW_SKIP_CANVAS_HOST", previous.skipCanvas); - restoreEnvVar("OPENCLAW_SKIP_CHANNELS", previous.skipChannels); - restoreEnvVar("OPENCLAW_SKIP_CRON", previous.skipCron); - restoreEnvVar("OPENCLAW_SKIP_GMAIL_WATCHER", previous.skipGmail); - restoreEnvVar("OPENCLAW_STATE_DIR", previous.stateDir); + restoreLiveEnv(previous); } } }, diff --git a/src/gateway/gateway-codex-harness.live.test.ts b/src/gateway/gateway-codex-harness.live.test.ts index 2a2271d1e9db..d9252217ae0d 100644 --- a/src/gateway/gateway-codex-harness.live.test.ts +++ b/src/gateway/gateway-codex-harness.live.test.ts @@ -16,6 +16,7 @@ import type { OpenClawConfig } from "../config/config.js"; import type { ContextEngine } from "../context-engine/types.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { extractFirstTextBlock } from "../shared/chat-message-content.js"; +import { setTestEnvValue } from "../test-utils/env.js"; import type { CallGatewayOptions } from "./call.js"; import type { GatewayClient } from "./client.js"; import { @@ -1056,14 +1057,14 @@ describeLive("gateway live (Codex harness)", () => { } else if (!process.env.OPENAI_BASE_URL?.trim()) { delete process.env.OPENAI_BASE_URL; } - process.env.OPENCLAW_CONFIG_PATH = configPath; + setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath); process.env.OPENCLAW_GATEWAY_TOKEN = token; process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1"; process.env.OPENCLAW_SKIP_CANVAS_HOST = "1"; process.env.OPENCLAW_SKIP_CHANNELS = "1"; process.env.OPENCLAW_SKIP_CRON = "1"; process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; - process.env.OPENCLAW_STATE_DIR = stateDir; + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); await fs.mkdir(stateDir, { recursive: true }); await writeLiveGatewayConfig({ diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index 07afa1a252b9..9c3fc4c7c9eb 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -59,6 +59,7 @@ import type { ProviderThinkingModelCompat } from "../plugins/provider-thinking.t import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; import { stripAssistantInternalScaffolding } from "../shared/text/assistant-visible-text.js"; import { findFinalTagMatches, stripFinalTags } from "../shared/text/final-tags.js"; +import { deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { GatewayClient } from "./client.js"; import { @@ -570,9 +571,9 @@ function restoreProductionEnvForLiveRun(previous: { function restoreOptionalEnv(key: string, value: string | undefined): void { if (value === undefined) { - delete process.env[key]; + deleteTestEnvValue(key); } else { - process.env[key] = value; + setTestEnvValue(key, value); } } @@ -1083,9 +1084,9 @@ describe("resolveGatewayLiveMaxModels", () => { const originalSharedMax = process.env.OPENCLAW_LIVE_MAX_MODELS; function restoreEnvValue(name: string, value: string | undefined): void { if (value === undefined) { - delete process.env[name]; + deleteTestEnvValue(name); } else { - process.env[name] = value; + setTestEnvValue(name, value); } } @@ -2878,10 +2879,8 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { lastGood: hostStore.lastGood ? { ...hostStore.lastGood } : undefined, usageStats: hostStore.usageStats ? { ...hostStore.usageStats } : undefined, }); - const tempStateDir: string | undefined = await fs.mkdtemp( - path.join(os.tmpdir(), "openclaw-live-state-"), - ); - process.env.OPENCLAW_STATE_DIR = tempStateDir; + const tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-live-state-")); + setTestEnvValue("OPENCLAW_STATE_DIR", tempStateDir); const tempAgentDir: string | undefined = path.join( tempStateDir, "agents", @@ -2893,7 +2892,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { if (tempSessionAgentDir !== tempAgentDir) { saveAuthProfileStore(sanitizedStore, tempSessionAgentDir); } - process.env.OPENCLAW_AGENT_DIR = tempAgentDir; + setTestEnvValue("OPENCLAW_AGENT_DIR", tempAgentDir); const workspaceDir = resolveAgentWorkspaceDir(params.cfg, agentId); await fs.mkdir(workspaceDir, { recursive: true }); @@ -2928,7 +2927,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-live-")); const tempConfigPath = path.join(tempDir, "openclaw.json"); await fs.writeFile(tempConfigPath, `${JSON.stringify(nextCfg, null, 2)}\n`); - process.env.OPENCLAW_CONFIG_PATH = tempConfigPath; + setTestEnvValue("OPENCLAW_CONFIG_PATH", tempConfigPath); const liveProviders = nextCfg.models?.providers; if (liveProviders && Object.keys(liveProviders).length > 0) { diff --git a/src/gateway/gateway-trajectory-export.live.test.ts b/src/gateway/gateway-trajectory-export.live.test.ts index 72ea5ebffd95..f7f0a18f4fe7 100644 --- a/src/gateway/gateway-trajectory-export.live.test.ts +++ b/src/gateway/gateway-trajectory-export.live.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from "vitest"; import type { EventFrame } from "../../packages/gateway-protocol/src/index.js"; import { isLiveTestEnabled } from "../agents/live-test-helpers.js"; import type { OpenClawConfig } from "../config/config.js"; +import { setTestEnvValue } from "../test-utils/env.js"; import { GatewayClient } from "./client.js"; import { connectTestGatewayClient, @@ -465,14 +466,14 @@ describeLive("gateway live trajectory export", () => { } else if (!process.env.OPENAI_BASE_URL?.trim()) { delete process.env.OPENAI_BASE_URL; } - process.env.OPENCLAW_CONFIG_PATH = configPath; + setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath); process.env.OPENCLAW_GATEWAY_TOKEN = token; process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1"; process.env.OPENCLAW_SKIP_CANVAS_HOST = "1"; process.env.OPENCLAW_SKIP_CHANNELS = "1"; process.env.OPENCLAW_SKIP_CRON = "1"; process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; - process.env.OPENCLAW_STATE_DIR = stateDir; + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); process.env.OPENCLAW_TRAJECTORY = "1"; process.env.OPENCLAW_TRAJECTORY_DIR = trajectoryDir; diff --git a/src/gateway/server-channels.test.ts b/src/gateway/server-channels.test.ts index 55b19420be8a..91cab0ba3484 100644 --- a/src/gateway/server-channels.test.ts +++ b/src/gateway/server-channels.test.ts @@ -511,6 +511,82 @@ describe("server-channels auto restart", () => { expect(account?.lastError).toContain("channel stop timed out"); }); + it("resumes startup on the second recovery pass while the stale task is still pending", async () => { + const startAccount = vi.fn(async ({ abortSignal }: { abortSignal: AbortSignal }) => { + abortSignal.addEventListener("abort", () => {}, { once: true }); + await new Promise(() => {}); + }); + installTestRegistry( + createTestPlugin({ + startAccount, + }), + ); + const manager = createManager(); + + await manager.startChannels(); + const recoveryStopTask = manager.stopChannel("discord", DEFAULT_ACCOUNT_ID, { + manual: false, + }); + await vi.advanceTimersByTimeAsync(5_000); + await recoveryStopTask; + + await manager.startChannel("discord", DEFAULT_ACCOUNT_ID); + let account = manager.getRuntimeSnapshot().channelAccounts.discord?.[DEFAULT_ACCOUNT_ID]; + expect(startAccount).toHaveBeenCalledTimes(1); + expect(account?.running).toBe(false); + expect(account?.restartPending).toBe(true); + + await manager.startChannel("discord", DEFAULT_ACCOUNT_ID); + + account = manager.getRuntimeSnapshot().channelAccounts.discord?.[DEFAULT_ACCOUNT_ID]; + expect(startAccount).toHaveBeenCalledTimes(2); + expect(account?.running).toBe(true); + expect(account?.restartPending).toBe(false); + expect(account?.reconnectAttempts).toBe(0); + expect(account?.lastError).toBeNull(); + }); + + it("keeps the second recovery task running when the stale task rejects", async () => { + const releaseFirstTask = createDeferred(); + let startCount = 0; + const startAccount = vi.fn(async ({ abortSignal }: { abortSignal: AbortSignal }) => { + startCount += 1; + abortSignal.addEventListener("abort", () => {}, { once: true }); + if (startCount === 1) { + await releaseFirstTask.promise; + throw new Error("late stale worker exit"); + } + await new Promise(() => {}); + }); + installTestRegistry( + createTestPlugin({ + startAccount, + }), + ); + const manager = createManager(); + + await manager.startChannels(); + const recoveryStopTask = manager.stopChannel("discord", DEFAULT_ACCOUNT_ID, { + manual: false, + }); + await vi.advanceTimersByTimeAsync(5_000); + await recoveryStopTask; + + await manager.startChannel("discord", DEFAULT_ACCOUNT_ID); + await manager.startChannel("discord", DEFAULT_ACCOUNT_ID); + expect(startAccount).toHaveBeenCalledTimes(2); + + releaseFirstTask.resolve(); + await flushMicrotasks(); + + const account = manager.getRuntimeSnapshot().channelAccounts.discord?.[DEFAULT_ACCOUNT_ID]; + expect(startAccount).toHaveBeenCalledTimes(2); + expect(account?.running).toBe(true); + expect(account?.restartPending).toBe(false); + expect(account?.lastError).toBeNull(); + expect(hoisted.sleepWithAbort).not.toHaveBeenCalled(); + }); + it("restarts immediately when recovery stop timeout settles with an error", async () => { const rejectFirstTask = createDeferred(); let startCount = 0; diff --git a/src/gateway/server-channels.ts b/src/gateway/server-channels.ts index a5ced7149ed2..3289f818c78e 100644 --- a/src/gateway/server-channels.ts +++ b/src/gateway/server-channels.ts @@ -449,6 +449,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage tasks: accountIds.map((id) => async () => { const rKey = restartKey(channelId, id); if (store.tasks.has(id)) { + let clearedTimedOutRecoveryTask = false; if (recoveryStopTimedOut.has(rKey)) { if (!preserveManualStop) { manuallyStopped.delete(rKey); @@ -456,10 +457,30 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage if (manuallyStopped.has(rKey)) { return; } - recoveryStartRequested.add(rKey); - setRuntime(channelId, id, { accountId: id, restartPending: true }); + // When a previous stop timed out and the health monitor is + // requesting recovery again, clean up the stuck task so the + // channel can actually restart instead of staying in limbo. + if (recoveryStartRequested.has(rKey)) { + recoveryStopTimedOut.delete(rKey); + recoveryStartRequested.delete(rKey); + restartAttempts.delete(rKey); + store.aborts.delete(id); + store.tasks.delete(id); + clearedTimedOutRecoveryTask = true; + setRuntime(channelId, id, { + accountId: id, + restartPending: false, + reconnectAttempts: 0, + }); + } else { + recoveryStartRequested.add(rKey); + setRuntime(channelId, id, { accountId: id, restartPending: true }); + return; + } + } + if (!clearedTimedOutRecoveryTask) { + return; } - return; } const existingStart = store.starting.get(id); if (existingStart) { @@ -607,7 +628,10 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage abortSignal: abort.signal, log, getStatus: () => getRuntime(channelId, id), - setStatus: (next) => setRuntimeFromTaskStatus(channelId, id, next, abort.signal), + setStatus: (next) => + isCurrentTask() + ? setRuntimeFromTaskStatus(channelId, id, next, abort.signal) + : getRuntime(channelId, id), ...(channelRuntimeForTask ? { channelRuntime: channelRuntimeForTask } : {}), }); const routeRegistry = getPluginHttpRouteRegistry?.(); @@ -620,9 +644,11 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage } await startAccountTask; }); + // Recovery can replace a timed-out task before the old promise settles. + // Only the task that still owns the store slot may write lifecycle state. const trackedPromise = task .then(() => { - if (abort.signal.aborted || manuallyStopped.has(rKey)) { + if (abort.signal.aborted || manuallyStopped.has(rKey) || !isCurrentTask()) { return; } const message = "channel exited without an error"; @@ -630,17 +656,26 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage log.error?.(`[${id}] ${message}`); }) .catch((err: unknown) => { + if (!isCurrentTask()) { + return; + } const message = formatErrorMessage(err); setRuntime(channelId, id, { accountId: id, lastError: message }); log.error?.(`[${id}] channel exited: ${message}`); }) .then(async () => { await cleanupTaskScopedApprovalRuntime("channel cleanup failed"); + if (!isCurrentTask()) { + return; + } setStoppedRuntime(channelId, id, { lastStopAt: Date.now(), }); }) .then(async () => { + if (!isCurrentTask()) { + return; + } if (manuallyStopped.has(rKey)) { recoveryStopTimedOut.delete(rKey); recoveryStartRequested.delete(rKey); @@ -731,6 +766,9 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage store.aborts.delete(id); } }); + function isCurrentTask() { + return store.tasks.get(id) === trackedPromise; + } handedOffTask = true; store.tasks.set(id, trackedPromise); } catch (error) { diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index d9515c21948f..b9b75f5dc16e 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -475,6 +475,9 @@ export function createAgentEventHandler({ contextTokens: row?.contextTokens, estimatedCostUsd: row?.estimatedCostUsd, responseUsage: row?.responseUsage, + // Carry the row-built channel-aware effective mode so the chat snapshot + // matches the session-event/list projections. + effectiveResponseUsage: row?.effectiveResponseUsage, modelProvider: row?.modelProvider, model: row?.model, status: snapshotSource.status, diff --git a/src/gateway/server-cron-notifications.ts b/src/gateway/server-cron-notifications.ts index ea23c646c6e0..0748f33146b2 100644 --- a/src/gateway/server-cron-notifications.ts +++ b/src/gateway/server-cron-notifications.ts @@ -91,6 +91,40 @@ function buildCronWebhookHeaders(webhookToken?: string): Record return headers; } +function buildCronFailureWebhookPayload(params: { evt: CronEvent; job: CronJob }) { + const failureMessage = `Cron job "${params.job.name}" failed: ${params.evt.error ?? "unknown error"}`; + return { + jobId: params.job.id, + jobName: params.job.name, + message: failureMessage, + status: params.evt.status, + error: params.evt.error, + runAtMs: params.evt.runAtMs, + durationMs: params.evt.durationMs, + nextRunAtMs: params.evt.nextRunAtMs, + }; +} + +function buildCronFinishedWebhookPayload(evt: CronEvent) { + if (evt.status !== "error") { + return evt; + } + const { summary: _summary, diagnostics: _diagnostics, ...payload } = evt; + if (evt.job) { + const state = { ...evt.job.state }; + delete state.lastDiagnostics; + delete state.lastDiagnosticSummary; + return { + ...payload, + job: { + ...evt.job, + state, + }, + }; + } + return payload; +} + /** Posts a cron webhook without throwing back into scheduler completion flow. */ async function postCronWebhook(params: { webhookUrl: string; @@ -261,13 +295,14 @@ export function dispatchGatewayCronFinishedNotifications(params: { if (params.evt.summary) { for (const webhookTarget of webhookTargets) { + const payload = buildCronFinishedWebhookPayload(params.evt); // Completion notification fanout is best-effort; the cron service has // already recorded the run result and must not wait on slow webhooks. void (async () => { await postCronWebhook({ webhookUrl: webhookTarget.url, webhookToken, - payload: params.evt, + payload, logContext: { jobId: params.evt.jobId, source: webhookTarget.source }, blockedLog: "cron: webhook delivery blocked by SSRF guard", failedLog: "cron: webhook delivery failed", @@ -301,22 +336,11 @@ function dispatchCronFailureDestinationNotifications(params: { return; } - const failureMessage = `Cron job "${params.job.name}" failed: ${params.evt.error ?? "unknown error"}`; const failureDest = resolveFailureDestination(params.job, params.globalFailureDestination); const deliverySessionKey = resolveCronDeliverySessionKey(params.job); + const failurePayload = buildCronFailureWebhookPayload({ evt: params.evt, job: params.job }); if (failureDest) { - const failurePayload = { - jobId: params.job.id, - jobName: params.job.name, - message: failureMessage, - status: params.evt.status, - error: params.evt.error, - runAtMs: params.evt.runAtMs, - durationMs: params.evt.durationMs, - nextRunAtMs: params.evt.nextRunAtMs, - }; - if (failureDest.mode === "webhook" && failureDest.to) { const webhookUrl = normalizeHttpWebhookUrl(failureDest.to); if (webhookUrl) { @@ -361,7 +385,7 @@ function dispatchCronFailureDestinationNotifications(params: { // session only for context, not for reattaching the primary topic. inheritSessionThread: false, }, - `⚠️ ${failureMessage}`, + `⚠️ ${failurePayload.message}`, ); } return; @@ -384,6 +408,6 @@ function dispatchCronFailureDestinationNotifications(params: { accountId: primaryPlan.accountId, sessionKey: deliverySessionKey, }, - `⚠️ ${failureMessage}`, + `⚠️ ${failurePayload.message}`, ); } diff --git a/src/gateway/server-cron.test.ts b/src/gateway/server-cron.test.ts index 0ee15194b9e3..e41d0f86c323 100644 --- a/src/gateway/server-cron.test.ts +++ b/src/gateway/server-cron.test.ts @@ -285,7 +285,7 @@ describe("buildGatewayCronService", () => { }); }); - it("requests a safe gateway restart when isolated cron setup times out", async () => { + it("backs off isolated cron setup timeout without gateway restart", async () => { vi.useFakeTimers(); const cfg = createCronConfig("server-cron-isolated-setup-timeout"); loadConfigMock.mockReturnValue(cfg); @@ -315,12 +315,7 @@ describe("buildGatewayCronService", () => { const runResult = await runPromise; expect(runResult).toEqual({ ok: true, ran: true }); - expect(requestSafeGatewayRestartMock).toHaveBeenCalledTimes(1); - expect(requestSafeGatewayRestartMock).toHaveBeenCalledWith({ - reason: "cron.isolated_agent_setup_timeout", - delayMs: 0, - preservePendingEmitHooks: true, - }); + expect(requestSafeGatewayRestartMock).not.toHaveBeenCalled(); } finally { state.cron.stop(); vi.useRealTimers(); diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index f2af85e59c8d..62165956cd3e 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -32,7 +32,6 @@ import { formatErrorMessage } from "../infra/errors.js"; import { resolveMainScopedEventSessionKey } from "../infra/event-session-routing.js"; import { runHeartbeatOnce } from "../infra/heartbeat-runner.js"; import { requestHeartbeat } from "../infra/heartbeat-wake.js"; -import { requestSafeGatewayRestart } from "../infra/restart-coordinator.js"; import { consumeSelectedSystemEventEntries, enqueueSystemEventEntry, @@ -547,23 +546,14 @@ export function buildGatewayCronService(params: { }).catch(() => {}); }, onIsolatedAgentSetupTimeout: ({ job, error, timeoutMs }) => { - const restart = requestSafeGatewayRestart({ - reason: "cron.isolated_agent_setup_timeout", - delayMs: 0, - preservePendingEmitHooks: true, - }); cronLogger.warn( { jobId: job.id, jobName: job.name, timeoutMs, error, - restartStatus: restart.status, - restartCoalesced: restart.restart.coalesced, - restartSummary: restart.preflight.summary, - restartDelayMs: restart.restart.delayMs, }, - "cron: isolated agent setup timed out before runner start; requested safe gateway restart", + "cron: isolated agent setup timed out before runner start; backing off job without gateway restart", ); }, sendCronFailureAlert: async ({ job, text, channel, to, mode, accountId }) => diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index a6aeb97c547d..a6d257fb8570 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -2300,7 +2300,7 @@ export const agentHandlers: GatewayRequestHandlers = { let resolvedTo = deliveryPlan.resolvedTo; let effectivePlan = deliveryPlan; let deliveryDowngradeReason: string | null = null; - let deliveryTargetResolutionError: Error | undefined; + let deliveryTargetResolutionError: Error | undefined = deliveryPlan.targetResolutionError; if (wantsDelivery && resolvedChannel === INTERNAL_MESSAGE_CHANNEL) { const cfgResolved = cfgForAgent ?? cfg; @@ -2328,6 +2328,27 @@ export const agentHandlers: GatewayRequestHandlers = { } } + if (wantsDelivery && deliveryTargetResolutionError) { + if (!bestEffortDeliver) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, String(deliveryTargetResolutionError)), + ); + return; + } + deliveryDowngradeReason = String(deliveryTargetResolutionError); + resolvedChannel = INTERNAL_MESSAGE_CHANNEL; + deliveryTargetMode = undefined; + resolvedTo = undefined; + effectivePlan = { + ...deliveryPlan, + resolvedChannel, + resolvedTo, + deliveryTargetMode, + }; + } + if (!resolvedTo && isDeliverableMessageChannel(resolvedChannel)) { const cfgResolved = cfgForAgent ?? cfg; const fallback = resolveAgentOutboundTarget({ diff --git a/src/gateway/server-methods/chat-history-budget.test.ts b/src/gateway/server-methods/chat-history-budget.test.ts index 92fc58e34b45..9f5a4cbd912c 100644 --- a/src/gateway/server-methods/chat-history-budget.test.ts +++ b/src/gateway/server-methods/chat-history-budget.test.ts @@ -21,21 +21,21 @@ describe("enforceChatHistoryFinalBudget", () => { ]; const result = enforceChatHistoryFinalBudget({ messages, maxBytes: 1_000_000 }); expect(result.messages).toEqual(messages); - expect(result.placeholderCount).toBe(0); }); it("returns the empty array unchanged for empty input", () => { const result = enforceChatHistoryFinalBudget({ messages: [], maxBytes: 10 }); expect(result.messages).toEqual([]); - expect(result.placeholderCount).toBe(0); }); it("keeps just the last message when the full set is over budget but the last fits", () => { const big = { role: "user", content: [{ type: "text", text: "x".repeat(4000) }] }; const last = { role: "assistant", content: [{ type: "text", text: "ok" }] }; const result = enforceChatHistoryFinalBudget({ messages: [big, last], maxBytes: 2_000 }); + // The same last-message reference survives so callers can detect which + // originals were omitted by identity. expect(result.messages).toEqual([last]); - expect(result.placeholderCount).toBe(0); + expect(result.messages[0]).toBe(last); }); it("falls back to a small placeholder when even the last message is too large", () => { @@ -48,7 +48,8 @@ describe("enforceChatHistoryFinalBudget", () => { const result = enforceChatHistoryFinalBudget({ messages: [last], maxBytes: 2_000 }); expect(result.messages).toHaveLength(1); expect(firstText(result.messages)).toContain("chat.history omitted: message too large"); - expect(result.placeholderCount).toBe(1); + // The placeholder is a new object, not the oversized original. + expect(result.messages[0]).not.toBe(last); }); it("returns a metadata-free sentinel (never an empty transcript) when even the placeholder is over budget", () => { @@ -68,6 +69,5 @@ describe("enforceChatHistoryFinalBudget", () => { expect(firstText(result.messages)).toContain("chat.history unavailable"); // The sentinel does not carry the oversized source metadata. expect((result.messages[0] as Record)["__openclaw"]).toBeUndefined(); - expect(result.placeholderCount).toBe(1); }); }); diff --git a/src/gateway/server-methods/chat-history-omission-logging.test.ts b/src/gateway/server-methods/chat-history-omission-logging.test.ts new file mode 100644 index 000000000000..7491441a2ee9 --- /dev/null +++ b/src/gateway/server-methods/chat-history-omission-logging.test.ts @@ -0,0 +1,121 @@ +// Real-behavior proof that the chat.history budget pipeline emits the +// `payload.large` / `truncated` diagnostic whenever older history is omitted, +// and that the omitted count reflects unique source messages (a message that is +// first replaced and then trimmed is not double-counted). These run the real +// production helpers and capture the real diagnostic event bus output. +import { describe, expect, it } from "vitest"; +import { onDiagnosticEvent } from "../../infra/diagnostic-events.js"; +import type { DiagnosticPayloadLargeEvent } from "../../infra/diagnostic-events.js"; +import { capArrayByJsonBytes } from "../session-utils.js"; +import { + enforceChatHistoryFinalBudget, + replaceOversizedChatHistoryMessages, + reportOmittedChatHistory, +} from "./chat.js"; + +type Captured = DiagnosticPayloadLargeEvent[]; + +// Mirrors the production sequence in handleChatHistoryRequest: replace oversized +// messages, cap the array by byte budget, enforce the final budget, then report +// omissions. Captures any emitted `payload.large` diagnostic event. +function runHistoryBudgetPipeline(params: { + messages: unknown[]; + maxHistoryBytes: number; + perMessageHardCap: number; +}): { emittedCount: number; events: Captured; replacedCount: number; frontCapDropped: number } { + const { messages, maxHistoryBytes, perMessageHardCap } = params; + const events: Captured = []; + const unsubscribe = onDiagnosticEvent((evt) => { + if (evt.type === "payload.large") { + events.push(evt); + } + }); + try { + const replaced = replaceOversizedChatHistoryMessages({ + messages, + maxSingleMessageBytes: perMessageHardCap, + }); + const capped = capArrayByJsonBytes(replaced.messages, maxHistoryBytes).items; + const bounded = enforceChatHistoryFinalBudget({ messages: capped, maxBytes: maxHistoryBytes }); + const emittedCount = reportOmittedChatHistory({ + originalMessages: messages, + finalMessages: bounded.messages, + normalizedBytes: Buffer.byteLength(JSON.stringify(messages), "utf8"), + maxHistoryBytes, + logDebug: () => {}, + }); + return { + emittedCount, + events, + replacedCount: replaced.replacedCount, + frontCapDropped: replaced.messages.length - capped.length, + }; + } finally { + unsubscribe(); + } +} + +function textMessage(role: string, text: string): Record { + return { role, content: [{ type: "text", text }] }; +} + +describe("chat.history truncation logging (real diagnostic bus)", () => { + it("emits a truncated diagnostic when history is trimmed to the last message", () => { + const big = textMessage("user", "x".repeat(8000)); + const last = textMessage("assistant", "ok"); + const result = runHistoryBudgetPipeline({ + messages: [big, last], + maxHistoryBytes: 2_000, + perMessageHardCap: 2_000, + }); + + expect(result.events).toHaveLength(1); + const event = result.events[0]; + expect(event.surface).toBe("gateway.chat.history"); + expect(event.action).toBe("truncated"); + expect(event.reason).toBe("chat_history_budget"); + expect(event.count).toBe(1); + expect(result.emittedCount).toBe(1); + }); + + it("emits no diagnostic when nothing is omitted", () => { + const result = runHistoryBudgetPipeline({ + messages: [textMessage("user", "hello"), textMessage("assistant", "hi")], + maxHistoryBytes: 1_000_000, + perMessageHardCap: 1_000_000, + }); + + expect(result.events).toHaveLength(0); + expect(result.emittedCount).toBe(0); + }); + + it("counts a replaced-then-trimmed message once, not twice", () => { + // `huge` is oversized so it is replaced with a small placeholder, then the + // placeholder sits at the front and is dropped by the byte cap. The naive + // sum of replacedCount + front-cap drops would count `huge` twice. + const huge = textMessage("user", "h".repeat(8000)); + const big1 = textMessage("assistant", "a".repeat(2000)); + const big2 = textMessage("user", "b".repeat(2000)); + const last = textMessage("assistant", "ok"); + const messages = [huge, big1, big2, last]; + + const result = runHistoryBudgetPipeline({ + messages, + maxHistoryBytes: 4_000, + perMessageHardCap: 3_000, + }); + + // Scenario preconditions: a message was replaced AND front-capped, so the + // old additive count would have over-reported. + expect(result.replacedCount).toBeGreaterThan(0); + expect(result.frontCapDropped).toBeGreaterThan(0); + const naiveAdditive = result.replacedCount + result.frontCapDropped; + + // The emitted count equals the number of original messages that lost their + // verbatim representation, and is strictly less than the double-counting sum. + expect(result.events).toHaveLength(1); + expect(result.events[0].count).toBe(result.emittedCount); + expect(result.emittedCount).toBe(2); + expect(naiveAdditive).toBeGreaterThan(result.emittedCount); + }); +}); diff --git a/src/gateway/server-methods/chat-history-omission-request.test.ts b/src/gateway/server-methods/chat-history-omission-request.test.ts new file mode 100644 index 000000000000..575180ad4934 --- /dev/null +++ b/src/gateway/server-methods/chat-history-omission-request.test.ts @@ -0,0 +1,98 @@ +// Real-behavior proof that a full `chat.history` Gateway request — issued over a +// real WebSocket to a real booted Gateway server, reading a real on-disk +// transcript — emits the `payload.large` / `truncated` diagnostic when older +// history is omitted by the byte budget. This drives the actual server method +// (`handleChatHistoryRequest`), not the budget helpers in isolation, so it +// covers the caller gate that previously swallowed front-cap and drop-to-last +// omissions. It imports only the WebSocket harness and the diagnostic bus (no +// changed symbols), so the same test reproduces the missing diagnostic on +// pre-fix `main`. +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import type { WebSocket } from "ws"; +import { + onDiagnosticEvent, + type DiagnosticPayloadLargeEvent, +} from "../../infra/diagnostic-events.js"; +import { setMaxChatHistoryMessagesBytesForTest } from "../server-constants.js"; +import { installGatewayTestHooks, rpcReq, testState, writeSessionStore } from "../test-helpers.js"; +import { installConnectedControlUiServerSuite } from "../test-with-server.js"; + +installGatewayTestHooks({ scope: "suite" }); + +let ws: WebSocket; +installConnectedControlUiServerSuite((started) => { + ws = started.ws; +}); + +describe("chat.history request emits truncation diagnostic (real WS gateway)", () => { + test("a real chat.history request logs payload.large when older history is omitted", async () => { + const SESSION_ID = "sess-omission-proof"; + const MESSAGE_COUNT = 12; + const TEXT_BYTES = 2_000; + // Budget far below the seeded transcript but well above one message, so the + // front byte cap drops older messages without per-message placeholdering. + const BUDGET_BYTES = 8_000; + + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-chat-history-omit-")); + const captured: DiagnosticPayloadLargeEvent[] = []; + const unsubscribe = onDiagnosticEvent((evt) => { + if (evt.type === "payload.large" && evt.surface === "gateway.chat.history") { + captured.push(evt); + } + }); + setMaxChatHistoryMessagesBytesForTest(BUDGET_BYTES); + testState.sessionStorePath = path.join(dir, "sessions.json"); + try { + await writeSessionStore({ + entries: { + main: { + sessionId: SESSION_ID, + sessionFile: path.join(dir, `${SESSION_ID}.jsonl`), + updatedAt: Date.now(), + }, + }, + }); + const messages = Array.from({ length: MESSAGE_COUNT }, (_, i) => ({ + role: i % 2 === 0 ? "user" : "assistant", + content: [{ type: "text", text: `m${i} ${"x".repeat(TEXT_BYTES)}` }], + timestamp: i + 1, + })); + const lines = messages.map((message) => JSON.stringify({ message })); + await fs.writeFile(path.join(dir, `${SESSION_ID}.jsonl`), lines.join("\n"), "utf-8"); + + const res = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", { + sessionKey: "main", + limit: 1000, + }); + + expect(res.ok).toBe(true); + const returned = res.payload?.messages ?? []; + // The response keeps only the survivors under budget (never empty), so the + // request genuinely omitted older history. + expect(returned.length).toBeGreaterThan(0); + expect(returned.length).toBeLessThan(MESSAGE_COUNT); + + expect(captured).toHaveLength(1); + const event = captured[0]; + expect(event.action).toBe("truncated"); + expect(event.reason).toBe("chat_history_budget"); + expect(event.count).toBeGreaterThan(0); + expect(event.count).toBe(MESSAGE_COUNT - returned.length); + + // Print the real runtime diagnostic so a `run-vitest` run shows the + // captured Gateway event (used as the PR real-behavior proof). + console.log( + `chat.history real-request diagnostic: returned=${returned.length} ` + + `event=${JSON.stringify(event)}`, + ); + } finally { + unsubscribe(); + setMaxChatHistoryMessagesBytesForTest(undefined); + testState.sessionStorePath = undefined; + await fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); +}); diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index f5e5eb8fc65d..767a0fd6aaef 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -577,7 +577,7 @@ function buildChatHistoryUnavailableSentinel(): Record { } const CHAT_STARTUP_OPTIONAL_MODEL_CATALOG_TIMEOUT_MS = 25; const MANAGED_OUTGOING_IMAGE_PATH_PREFIX = "/api/chat/media/outgoing/"; -let chatHistoryPlaceholderEmitCount = 0; +let chatHistoryOmittedEmitCount = 0; const chatHistoryManagedImageCleanupState = new Map>(); const CHANNEL_AGNOSTIC_SESSION_SCOPES = new Set([ "main", @@ -1672,30 +1672,73 @@ export function replaceOversizedChatHistoryMessages(params: { return { messages: replacedCount > 0 ? next : messages, replacedCount }; } +// Enforces the final byte budget for chat.history. Returns only the surviving +// messages; how many original messages were omitted is measured end-to-end by +// reportOmittedChatHistory, which alone sees the full replace/cap/final pipeline +// and so can count unique omitted originals without double-counting. export function enforceChatHistoryFinalBudget(params: { messages: unknown[]; maxBytes: number }): { messages: unknown[]; - placeholderCount: number; } { const { messages, maxBytes } = params; if (messages.length === 0) { - return { messages, placeholderCount: 0 }; + return { messages }; } if (jsonUtf8Bytes(messages) <= maxBytes) { - return { messages, placeholderCount: 0 }; + return { messages }; } const last = messages.at(-1); if (last && jsonUtf8Bytes([last]) <= maxBytes) { - return { messages: [last], placeholderCount: 0 }; + return { messages: [last] }; } const placeholder = buildOversizedHistoryPlaceholder(last); if (jsonUtf8Bytes([placeholder]) <= maxBytes) { - return { messages: [placeholder], placeholderCount: 1 }; + return { messages: [placeholder] }; } // The oversized placeholder still does not fit (e.g. the source message // carried very large metadata). Never return an empty history — that renders // as a blank transcript and reads as data loss even though the on-disk // transcript is intact. Fall back to a small metadata-free sentinel. - return { messages: [buildChatHistoryUnavailableSentinel()], placeholderCount: 1 }; + return { messages: [buildChatHistoryUnavailableSentinel()] }; +} + +// Counts how many of the original chat.history messages lost their verbatim +// representation by the time the budget pipeline finished — whether they were +// replaced with a placeholder, dropped by the front byte cap, or collapsed by +// the final budget. Identity membership counts each omitted original exactly +// once (a message that is first replaced and then trimmed is not counted twice), +// and emits the truncation diagnostic so operators see when history is omitted. +// Returns the omitted count (0 when nothing was omitted, so no diagnostic fires). +export function reportOmittedChatHistory(params: { + originalMessages: unknown[]; + finalMessages: unknown[]; + normalizedBytes: number; + maxHistoryBytes: number; + logDebug: (message: string) => void; +}): number { + const { originalMessages, finalMessages, normalizedBytes, maxHistoryBytes, logDebug } = params; + const survivors = new Set(finalMessages); + let omittedCount = 0; + for (const message of originalMessages) { + if (!survivors.has(message)) { + omittedCount += 1; + } + } + if (omittedCount === 0) { + return 0; + } + chatHistoryOmittedEmitCount += omittedCount; + logLargePayload({ + surface: "gateway.chat.history", + action: "truncated", + bytes: normalizedBytes, + limitBytes: maxHistoryBytes, + count: omittedCount, + reason: "chat_history_budget", + }); + logDebug( + `chat.history omitted oversized payloads count=${omittedCount} total=${chatHistoryOmittedEmitCount}`, + ); + return omittedCount; } function resolveTranscriptPath(params: { @@ -2760,21 +2803,13 @@ async function handleChatHistoryRequest({ }); const capped = capArrayByJsonBytes(replaced.messages, maxHistoryBytes).items; const bounded = enforceChatHistoryFinalBudget({ messages: capped, maxBytes: maxHistoryBytes }); - const placeholderCount = replaced.replacedCount + bounded.placeholderCount; - if (placeholderCount > 0) { - chatHistoryPlaceholderEmitCount += placeholderCount; - logLargePayload({ - surface: "gateway.chat.history", - action: "truncated", - bytes: jsonUtf8Bytes(normalized), - limitBytes: maxHistoryBytes, - count: placeholderCount, - reason: "chat_history_budget", - }); - context.logGateway.debug( - `chat.history omitted oversized payloads placeholders=${placeholderCount} total=${chatHistoryPlaceholderEmitCount}`, - ); - } + reportOmittedChatHistory({ + originalMessages: normalized, + finalMessages: bounded.messages, + normalizedBytes: jsonUtf8Bytes(normalized), + maxHistoryBytes, + logDebug: (message) => context.logGateway.debug(message), + }); const modelCatalog = await modelCatalogPromise; const defaultAgentId = resolveDefaultAgentId(cfg); const startupMetadata = includeMetadata diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index 9b49412d4eaa..1735645fea82 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -923,6 +923,37 @@ describe("gateway send mirroring", () => { expect(response?.[2]?.message).toContain("Use `chat.send`"); }); + it("accepts bundled channels before plugin registry normalization for message actions", async () => { + const { respond } = await runMessageActionRequest({ + channel: "TELEGRAM", + action: "send", + params: { target: "123", message: "hi" }, + idempotencyKey: "idem-telegram-message-action", + }); + + const call = lastDispatchChannelMessageActionCall(); + expect(call?.channel).toBe("telegram"); + expect(firstRespondCall(respond)[0]).toBe(true); + }); + + it("rejects unknown send channels without delivering", async () => { + mocks.getChannelPlugin.mockReturnValue(undefined); + + const { respond } = await runSend({ + to: "x", + message: "hi", + channel: "definitely-not-a-real-channel-xyz", + idempotencyKey: "idem-unknown-channel", + }); + + expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled(); + const response = firstRespondCall(respond); + expect(response?.[0]).toBe(false); + expect(response?.[2]?.message).toContain( + "unsupported channel: definitely-not-a-real-channel-xyz", + ); + }); + it("auto-picks the single configured channel for send", async () => { mockDeliverySuccess("m-single-send"); diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 64976156f3d6..7e3a9ae2ddfe 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -15,7 +15,6 @@ import { } from "../../../packages/gateway-protocol/src/index.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { sendDurableMessageBatch } from "../../channels/message/runtime.js"; -import { normalizeChannelId } from "../../channels/plugins/index.js"; import { dispatchChannelMessageAction } from "../../channels/plugins/message-action-dispatch.js"; import { createOutboundSendDeps } from "../../cli/deps.js"; import { @@ -49,6 +48,7 @@ import { normalizeSessionKeyPreservingOpaquePeerIds, parseThreadSessionSuffix, } from "../../sessions/session-key-utils.js"; +import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; import { ADMIN_SCOPE } from "../operator-scopes.js"; import { resolveGatewayPluginConfig } from "../runtime-plugin-config.js"; import { formatForLog } from "../ws-log.js"; @@ -177,17 +177,16 @@ async function resolveRequestedChannel(params: { } > { const channelInput = readStringValue(params.requestChannel); - const normalizedChannel = channelInput ? normalizeChannelId(channelInput) : null; + const normalizedChannel = channelInput ? normalizeMessageChannel(channelInput) : undefined; + if (params.rejectWebchatAsInternalOnly && normalizedChannel === INTERNAL_MESSAGE_CHANNEL) { + return { + error: errorShape( + ErrorCodes.INVALID_REQUEST, + "unsupported channel: webchat (internal-only). Use `chat.send` for WebChat UI messages or choose a deliverable channel.", + ), + }; + } if (channelInput && !normalizedChannel) { - const normalizedInput = normalizeOptionalLowercaseString(channelInput) ?? ""; - if (params.rejectWebchatAsInternalOnly && normalizedInput === "webchat") { - return { - error: errorShape( - ErrorCodes.INVALID_REQUEST, - "unsupported channel: webchat (internal-only). Use `chat.send` for WebChat UI messages or choose a deliverable channel.", - ), - }; - } return { error: errorShape(ErrorCodes.INVALID_REQUEST, params.unsupportedMessage(channelInput)), }; diff --git a/src/gateway/server-methods/skills.clawhub.test.ts b/src/gateway/server-methods/skills.clawhub.test.ts index eb590099fc4e..f2e911a896b9 100644 --- a/src/gateway/server-methods/skills.clawhub.test.ts +++ b/src/gateway/server-methods/skills.clawhub.test.ts @@ -263,6 +263,7 @@ describe("skills gateway handlers (clawhub)", () => { slug: "calendar", version: "1.2.3", targetDir: "/tmp/workspace/skills/calendar", + warning: "Review ClawHub security details before installing.", }); const { ok, response, error } = await callSkillsHandler("skills.install", { @@ -281,12 +282,73 @@ describe("skills gateway handlers (clawhub)", () => { expect(ok).toBe(true); expect(error).toBeUndefined(); const result = response as - | { ok?: boolean; message?: string; slug?: string; version?: string } + | { ok?: boolean; message?: string; slug?: string; version?: string; warning?: string } | undefined; expect(result?.ok).toBe(true); expect(result?.message).toBe("Installed calendar@1.2.3"); expect(result?.slug).toBe("calendar"); expect(result?.version).toBe("1.2.3"); + expect(result?.warning).toBe("Review ClawHub security details before installing."); + }); + + it("returns ClawHub skill install trust warnings in Gateway error details", async () => { + installSkillFromClawHubMock.mockResolvedValue({ + ok: false, + error: "ClawHub blocked this release; install was not started.", + code: "clawhub_download_blocked", + version: "1.2.3", + warning: "BLOCKED - ClawHub flagged this release as malicious", + }); + + const { ok, response, error } = await callSkillsHandler("skills.install", { + source: "clawhub", + slug: "calendar", + }); + + expect(ok).toBe(false); + expect(response).toEqual({ + ok: false, + error: "ClawHub blocked this release; install was not started.", + code: "clawhub_download_blocked", + version: "1.2.3", + warning: "BLOCKED - ClawHub flagged this release as malicious", + }); + expect(error).toEqual({ + code: "UNAVAILABLE", + message: "ClawHub blocked this release; install was not started.", + details: { + clawhubTrustCode: "clawhub_download_blocked", + version: "1.2.3", + warning: "BLOCKED - ClawHub flagged this release as malicious", + }, + }); + }); + + it("forwards ClawHub skill install risk acknowledgements", async () => { + installSkillFromClawHubMock.mockResolvedValue({ + ok: true, + slug: "calendar", + version: "1.2.3", + targetDir: "/tmp/workspace/skills/calendar", + }); + + const { ok, error } = await callSkillsHandler("skills.install", { + source: "clawhub", + slug: "calendar", + version: "1.2.3", + acknowledgeClawHubRisk: true, + }); + + expect(installSkillFromClawHubMock).toHaveBeenCalledWith({ + workspaceDir: "/tmp/workspace", + slug: "calendar", + version: "1.2.3", + force: false, + acknowledgeClawHubRisk: true, + config: {}, + }); + expect(ok).toBe(true); + expect(error).toBeUndefined(); }); it("routes explicit agent ClawHub installs through that agent workspace", async () => { @@ -359,6 +421,7 @@ describe("skills gateway handlers (clawhub)", () => { version: "1.2.3", changed: true, targetDir: "/tmp/workspace/skills/calendar", + warning: "Latest skill version needs review before use.", }, ]); @@ -380,7 +443,7 @@ describe("skills gateway handlers (clawhub)", () => { skillKey?: string; config?: { source?: string; - results?: Array<{ ok?: boolean; slug?: string; version?: string }>; + results?: Array<{ ok?: boolean; slug?: string; version?: string; warning?: string }>; }; } | undefined; @@ -391,6 +454,85 @@ describe("skills gateway handlers (clawhub)", () => { expect(result?.config?.results?.[0]?.ok).toBe(true); expect(result?.config?.results?.[0]?.slug).toBe("calendar"); expect(result?.config?.results?.[0]?.version).toBe("1.2.3"); + expect(result?.config?.results?.[0]?.warning).toBe( + "Latest skill version needs review before use.", + ); + }); + + it("forwards ClawHub skill update risk acknowledgements", async () => { + updateSkillsFromClawHubMock.mockResolvedValue([ + { + ok: true, + slug: "calendar", + previousVersion: "1.2.2", + version: "1.2.3", + changed: true, + targetDir: "/tmp/workspace/skills/calendar", + }, + ]); + + const { ok, error } = await callSkillsHandler("skills.update", { + source: "clawhub", + slug: "calendar", + acknowledgeClawHubRisk: true, + }); + + expect(updateSkillsFromClawHubMock).toHaveBeenCalledWith({ + workspaceDir: "/tmp/workspace", + slug: "calendar", + acknowledgeClawHubRisk: true, + config: {}, + }); + expect(ok).toBe(true); + expect(error).toBeUndefined(); + }); + + it("returns ClawHub skill update trust warnings in Gateway error details", async () => { + updateSkillsFromClawHubMock.mockResolvedValue([ + { + ok: false, + error: "ClawHub blocked this release; update was not started.", + code: "clawhub_download_blocked", + warning: "Latest skill version is marked malicious; OpenClaw will not download it.", + }, + ]); + + const { ok, response, error } = await callSkillsHandler("skills.update", { + source: "clawhub", + slug: "calendar", + }); + + expect(ok).toBe(false); + expect(response).toEqual({ + ok: false, + skillKey: "calendar", + config: { + source: "clawhub", + results: [ + { + ok: false, + error: "ClawHub blocked this release; update was not started.", + code: "clawhub_download_blocked", + warning: "Latest skill version is marked malicious; OpenClaw will not download it.", + }, + ], + }, + }); + expect(error).toEqual({ + code: "UNAVAILABLE", + message: "ClawHub blocked this release; update was not started.", + details: { + results: [ + { + ok: false, + error: "ClawHub blocked this release; update was not started.", + code: "clawhub_download_blocked", + warning: "Latest skill version is marked malicious; OpenClaw will not download it.", + }, + ], + warnings: ["Latest skill version is marked malicious; OpenClaw will not download it."], + }, + }); }); it("rejects ClawHub skills.update requests without slug or all", async () => { diff --git a/src/gateway/server-methods/skills.ts b/src/gateway/server-methods/skills.ts index e0b8ea76b92a..fec542142236 100644 --- a/src/gateway/server-methods/skills.ts +++ b/src/gateway/server-methods/skills.ts @@ -1,6 +1,7 @@ // Gateway RPC handlers for skill discovery, install/update, and proposal workflows. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { + buildClawHubTrustErrorDetails, ErrorCodes, errorShape, validateSkillsBinsParams, @@ -118,6 +119,12 @@ function respondSkillWorkshopError(respond: RespondFn, err: unknown) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, formatErrorMessage(err))); } +function collectClawHubTrustWarnings(results: Array<{ warning?: string }>): string[] { + return results + .map((result) => normalizeOptionalString(result.warning)) + .filter((warning): warning is string => Boolean(warning)); +} + function buildRevisionAgentInstruction(proposal: Awaited>) { if (!proposal) { return ""; @@ -539,14 +546,17 @@ export const skillsHandlers: GatewayRequestHandlers = { slug: string; version?: string; force?: boolean; + acknowledgeClawHubRisk?: boolean; }; const result = await installSkillFromClawHub({ workspaceDir: workspaceDirRaw, slug: p.slug, version: p.version, force: Boolean(p.force), + ...(p.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), config: cfg, }); + const errorDetails = result.ok ? undefined : buildClawHubTrustErrorDetails(result); respond( result.ok, result.ok @@ -559,9 +569,16 @@ export const skillsHandlers: GatewayRequestHandlers = { slug: result.slug, version: result.version, targetDir: result.targetDir, + ...(result.warning ? { warning: result.warning } : {}), } : result, - result.ok ? undefined : errorShape(ErrorCodes.UNAVAILABLE, result.error), + result.ok + ? undefined + : errorShape( + ErrorCodes.UNAVAILABLE, + result.error, + errorDetails ? { details: errorDetails } : undefined, + ), ); return; } @@ -629,6 +646,7 @@ export const skillsHandlers: GatewayRequestHandlers = { source: "clawhub"; slug?: string; all?: boolean; + acknowledgeClawHubRisk?: boolean; }; if (!p.slug && !p.all) { respond( @@ -657,9 +675,11 @@ export const skillsHandlers: GatewayRequestHandlers = { const results = await updateSkillsFromClawHub({ workspaceDir: resolved.workspaceDir, slug: p.slug, + ...(p.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), config: resolved.cfg, }); const errors = results.filter((result) => !result.ok); + const warnings = collectClawHubTrustWarnings(results); respond( errors.length === 0, { @@ -672,7 +692,12 @@ export const skillsHandlers: GatewayRequestHandlers = { }, errors.length === 0 ? undefined - : errorShape(ErrorCodes.UNAVAILABLE, errors.map((result) => result.error).join("; ")), + : errorShape(ErrorCodes.UNAVAILABLE, errors.map((result) => result.error).join("; "), { + details: { + results, + ...(warnings.length > 0 ? { warnings } : {}), + }, + }), ); return; } diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index 9953f1a4f0d1..5224b16a2eaa 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -1497,6 +1497,7 @@ describe("gateway server cron", () => { const notifyBody = notifyCall.body; expect(notifyBody.action).toBe("finished"); expect(notifyBody.jobId).toBe(notifyJobId); + expect(notifyBody.summary).toBe("send webhook"); const legacyFinished = waitForCronEvent( ws, @@ -1532,6 +1533,7 @@ describe("gateway server cron", () => { expect(completionCall.init.headers?.Authorization).toBe("Bearer cron-webhook-token"); expect(completionCall.body.action).toBe("finished"); expect(completionCall.body.jobId).toBe(completionJobId); + expect(completionCall.body.summary).toBe("ok"); const silentRes = await rpcReq(ws, "cron.add", { name: "webhook disabled", @@ -1628,6 +1630,110 @@ describe("gateway server cron", () => { } }, 60_000); + test("omits raw summaries from failed cron webhook payloads", async () => { + const { prevSkipCron } = await setupCronTestRun({ + tempPrefix: "openclaw-gw-cron-webhook-failure-summary-", + cronEnabled: false, + }); + + await writeCronConfig({ + cron: { + webhookToken: "cron-webhook-token", + }, + }); + + fetchWithSsrFGuardMock.mockClear(); + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + try { + const rawSummary = [ + "stdout:", + "To sign in, use a web browser to open https://microsoft.com/devicelogin", + "and enter the code ABCD-1234 to authenticate.", + ].join("\n"); + cronIsolatedRun.mockResolvedValueOnce({ + status: "error", + error: "command exited with code 7", + summary: rawSummary, + diagnostics: { + summary: rawSummary, + entries: [ + { + ts: 123, + source: "exec", + severity: "error", + message: rawSummary, + }, + ], + }, + }); + const directJobId = await addWebhookCronJob({ + ws, + name: "failed direct webhook", + sessionTarget: "isolated", + delivery: { mode: "webhook", to: "https://example.invalid/failed-direct" }, + }); + await runCronJobAndWaitForFinished(ws, directJobId); + const directCall = getWebhookCall(0); + expect(directCall.url).toBe("https://example.invalid/failed-direct"); + expect(directCall.init.headers?.Authorization).toBe("Bearer cron-webhook-token"); + expect(directCall.body).toMatchObject({ + action: "finished", + jobId: directJobId, + status: "error", + error: "command exited with code 7", + }); + expect(directCall.body).not.toHaveProperty("summary"); + expect(directCall.body).not.toHaveProperty("diagnostics"); + expect(JSON.stringify(directCall.body)).not.toContain("ABCD-1234"); + + const completionSummary = `${rawSummary}\nstderr:\nSECRET_TOKEN=super-secret-value`; + cronIsolatedRun.mockResolvedValueOnce({ + status: "error", + error: "command exited with code 9", + summary: completionSummary, + diagnostics: { + summary: completionSummary, + entries: [ + { + ts: 456, + source: "exec", + severity: "error", + message: completionSummary, + }, + ], + }, + }); + const completionJobId = await addWebhookCronJob({ + ws, + name: "failed completion webhook", + sessionTarget: "isolated", + delivery: { + mode: "announce", + completionDestination: { + mode: "webhook", + to: "https://example.invalid/failed-completion", + }, + }, + }); + await runCronJobAndWaitForFinished(ws, completionJobId); + const completionCall = getWebhookCall(1); + expect(completionCall.url).toBe("https://example.invalid/failed-completion"); + expect(completionCall.body).toMatchObject({ + action: "finished", + jobId: completionJobId, + status: "error", + error: "command exited with code 9", + }); + expect(completionCall.body).not.toHaveProperty("summary"); + expect(completionCall.body).not.toHaveProperty("diagnostics"); + expect(JSON.stringify(completionCall.body)).not.toContain("SECRET_TOKEN"); + } finally { + await cleanupCronTestRun({ ws, server, prevSkipCron }); + } + }, 45_000); + test("falls back to the primary delivery channel on job failure and preserves sessionKey", async () => { const { prevSkipCron } = await setupCronTestRun({ tempPrefix: "openclaw-gw-cron-failure-primary-fallback-", diff --git a/src/gateway/server.sessions.list-changed.test.ts b/src/gateway/server.sessions.list-changed.test.ts index 58d5c3651693..0b195c0bd8b4 100644 --- a/src/gateway/server.sessions.list-changed.test.ts +++ b/src/gateway/server.sessions.list-changed.test.ts @@ -209,9 +209,9 @@ async function writeMainSessionStore(options?: SessionStoreEntryOptions) { function expectMainPatchBroadcast( result: Awaited>, expected: Record, -) { +): Record { expectFields(result.responsePayload, { ok: true, key: "agent:main:main" }); - expectChangedBroadcast(result.broadcastToConnIds, { + return expectChangedBroadcast(result.broadcastToConnIds, { sessionKey: "agent:main:main", reason: "patch", ...expected, @@ -685,7 +685,31 @@ test("sessions.changed mutation events include live session setting metadata", a verboseLevel: "on", }); - expectMainPatchBroadcast(result, sessionSettings); + expectMainPatchBroadcast(result, { + ...sessionSettings, + // An explicit session override resolves to the same effective mode and the + // sessions.changed builder carries the row-built channel-aware value. + effectiveResponseUsage: "full", + }); +}); + +test("sessions.changed mutation events carry the resolved effectiveResponseUsage when the session has no override", async () => { + // No explicit responseUsage and no configured default → the row builder resolves + // effectiveResponseUsage to "off". The event must carry that resolved value, not + // the absent raw responseUsage, so a UI consumer's effective display stays fresh. + await writeMainSessionStore({ verboseLevel: "on" }); + + const result = await invokeSessionsPatch({ + key: "main", + verboseLevel: "on", + }); + + const payload = expectMainPatchBroadcast(result, { + effectiveResponseUsage: "off", + }); + // Raw responseUsage is genuinely absent (no override), proving the event does not + // merely echo the raw field. + expect(payload.responseUsage).toBeUndefined(); }); test("sessions.changed mutation events include sendPolicy metadata", async () => { diff --git a/src/gateway/server.sessions.reset-cleanup.test.ts b/src/gateway/server.sessions.reset-cleanup.test.ts index 8a5038f473b6..69761bea1753 100644 --- a/src/gateway/server.sessions.reset-cleanup.test.ts +++ b/src/gateway/server.sessions.reset-cleanup.test.ts @@ -654,3 +654,24 @@ test("sessions.reset directly unbinds thread bindings when hooks are unavailable reason: "session-reset", }); }); + +test("sessions.reset preserves explicit responseUsage preference across session rollover", async () => { + // Regression: a full session reset must carry the user's display preference forward + // so the usage footer mode survives rollovers. Only /usage reset clears the override. + const { dir } = await createSessionStoreDir(); + await writeSingleLineSession(dir, "sess-main", "hello"); + await writeSessionStore({ + entries: { + main: sessionStoreEntry("sess-main", { responseUsage: "tokens" }), + }, + }); + + const reset = await directSessionReq<{ + ok: true; + key: string; + entry: { sessionId: string; responseUsage?: string }; + }>("sessions.reset", { key: "main" }); + + expect(reset.ok).toBe(true); + expect(reset.payload?.entry.responseUsage).toBe("tokens"); +}); diff --git a/src/gateway/session-compaction-checkpoints.test.ts b/src/gateway/session-compaction-checkpoints.test.ts index 1c7cfb44525f..18f1bef67514 100644 --- a/src/gateway/session-compaction-checkpoints.test.ts +++ b/src/gateway/session-compaction-checkpoints.test.ts @@ -930,6 +930,37 @@ describe("session-compaction-checkpoints", () => { expect(nextStore[MAIN_SESSION_KEY]?.compactionCheckpoints).toBeUndefined(); }); + test("persist skips malformed session rows without synthesizing a session id", async () => { + const { storePath, sessionId, sessionKey, now } = await makeTempSessionStore( + "openclaw-checkpoint-malformed-row-", + ); + await writeSessionStore(storePath, sessionKey, { + sessionId: "", + updatedAt: now, + }); + + const stored = await persistMainCheckpoint(storePath, { + sessionId, + snapshot: { + sessionId, + leafId: "pre-leaf", + }, + postSessionFile: path.join(path.dirname(storePath), "sess.compacted.jsonl"), + postLeafId: "post-leaf", + createdAt: now, + }); + + expect(stored).toBeNull(); + const nextStore = await readSessionStore<{ + compactionCheckpoints?: unknown[]; + sessionId?: string; + }>(storePath); + expect(nextStore[MAIN_SESSION_KEY]).toEqual({ + sessionId: "", + updatedAt: now, + }); + }); + test("persist trims retained checkpoint snapshots by total byte budget", async () => { const { dir, storePath, sessionId, sessionKey, now } = await makeTempSessionStore( "openclaw-checkpoint-byte-trim-", diff --git a/src/gateway/session-compaction-checkpoints.ts b/src/gateway/session-compaction-checkpoints.ts index b24d2a269791..90898ea01910 100644 --- a/src/gateway/session-compaction-checkpoints.ts +++ b/src/gateway/session-compaction-checkpoints.ts @@ -8,7 +8,6 @@ import { SessionManager, type FileEntry as SessionFileEntry, } from "../agents/sessions/session-manager.js"; -import { updateSessionStore } from "../config/sessions.js"; import type { SessionCompactionCheckpoint, SessionCompactionCheckpointReason, @@ -16,6 +15,12 @@ import type { } from "../config/sessions.js"; import { isCompactionCheckpointTranscriptFileName } from "../config/sessions/artifacts.js"; import { readFileRangeAsync } from "../config/sessions/file-range.js"; +import { + branchSessionFromCompactionCheckpoint, + restoreSessionFromCompactionCheckpoint, + type SessionCompactionCheckpointMutationResult, + updateSessionEntry, +} from "../config/sessions/session-accessor.js"; import { streamSessionTranscriptLines } from "../config/sessions/transcript-stream.js"; import { scanSessionTranscriptTree } from "../config/sessions/transcript-tree.js"; import { CURRENT_SESSION_VERSION } from "../config/sessions/version.js"; @@ -66,17 +71,7 @@ export type CompactionCheckpointTranscriptForkResult = | { status: "missing-boundary" } | { status: "failed" }; -export type CompactionCheckpointSessionMutationResult = - | { - status: "created"; - key: string; - checkpoint: SessionCompactionCheckpoint; - entry: SessionEntry; - } - | { status: "missing-session" } - | { status: "missing-checkpoint" } - | { status: "missing-boundary" } - | { status: "failed" }; +export type CompactionCheckpointSessionMutationResult = SessionCompactionCheckpointMutationResult; export type BranchCheckpointSessionParams = { storePath: string; @@ -583,30 +578,19 @@ function cloneCheckpointSessionEntry(params: { async function branchCheckpointSessionFromStoredBoundary( params: BranchCheckpointSessionParams, ): Promise { - return await updateSessionStore( - params.storePath, - async (store) => { - const currentEntry = store[params.sourceStoreKey ?? params.sourceKey]; - if (!currentEntry?.sessionId) { - return { status: "missing-session" }; - } - const checkpoint = getSessionCompactionCheckpoint({ - entry: currentEntry, - checkpointId: params.checkpointId, - }); - if (!checkpoint) { - return { status: "missing-checkpoint" }; - } - const forkedSession = await forkCheckpointTranscriptFromStoredBoundary({ checkpoint }); - if (forkedSession.status !== "created") { - return forkedSession; - } - - const forkedTranscript = forkedSession.transcript; + return await branchSessionFromCompactionCheckpoint({ + storePath: params.storePath, + sourceKey: params.sourceKey, + nextKey: params.nextKey, + checkpointId: params.checkpointId, + ...(params.sourceStoreKey ? { sourceStoreKey: params.sourceStoreKey } : {}), + forkTranscriptFromCheckpoint: async (checkpoint) => + await forkCheckpointTranscriptFromStoredBoundary({ checkpoint }), + buildEntry: ({ currentEntry, forkedTranscript }) => { const label = currentEntry.label?.trim() ? `${currentEntry.label.trim()} (checkpoint)` : "Checkpoint branch"; - const nextEntry = cloneCheckpointSessionEntry({ + return cloneCheckpointSessionEntry({ currentEntry, nextSessionId: forkedTranscript.sessionId, nextSessionFile: forkedTranscript.sessionFile, @@ -614,58 +598,29 @@ async function branchCheckpointSessionFromStoredBoundary( parentSessionKey: params.sourceKey, totalTokens: forkedTranscript.totalTokens, }); - store[params.nextKey] = nextEntry; - return { - status: "created", - key: params.nextKey, - checkpoint, - entry: nextEntry, - }; }, - { skipSaveWhenResult: (result) => result.status !== "created" }, - ); + }); } async function restoreCheckpointSessionFromStoredBoundary( params: RestoreCheckpointSessionParams, ): Promise { - return await updateSessionStore( - params.storePath, - async (store) => { - const currentEntry = store[params.sessionStoreKey ?? params.sessionKey]; - if (!currentEntry?.sessionId) { - return { status: "missing-session" }; - } - const checkpoint = getSessionCompactionCheckpoint({ - entry: currentEntry, - checkpointId: params.checkpointId, - }); - if (!checkpoint) { - return { status: "missing-checkpoint" }; - } - const restoredSession = await forkCheckpointTranscriptFromStoredBoundary({ checkpoint }); - if (restoredSession.status !== "created") { - return restoredSession; - } - - const restoredTranscript = restoredSession.transcript; - const nextEntry = cloneCheckpointSessionEntry({ + return await restoreSessionFromCompactionCheckpoint({ + storePath: params.storePath, + sessionKey: params.sessionKey, + checkpointId: params.checkpointId, + ...(params.sessionStoreKey ? { sessionStoreKey: params.sessionStoreKey } : {}), + forkTranscriptFromCheckpoint: async (checkpoint) => + await forkCheckpointTranscriptFromStoredBoundary({ checkpoint }), + buildEntry: ({ currentEntry, forkedTranscript }) => + cloneCheckpointSessionEntry({ currentEntry, - nextSessionId: restoredTranscript.sessionId, - nextSessionFile: restoredTranscript.sessionFile, - totalTokens: restoredTranscript.totalTokens, + nextSessionId: forkedTranscript.sessionId, + nextSessionFile: forkedTranscript.sessionFile, + totalTokens: forkedTranscript.totalTokens, preserveCompactionCheckpoints: true, - }); - store[params.sessionKey] = nextEntry; - return { - status: "created", - key: params.sessionKey, - checkpoint, - entry: nextEntry, - }; - }, - { skipSaveWhenResult: (result) => result.status !== "created" }, - ); + }), + }); } /** @@ -818,31 +773,35 @@ export async function persistSessionCompactionCheckpoint( }, }; - let stored = false; let trimmedCheckpoints: | { kept: SessionCompactionCheckpoint[] | undefined; removed: SessionCompactionCheckpoint[]; } | undefined; - await updateSessionStore(target.storePath, async (store) => { - const existing = store[target.canonicalKey]; - if (!existing?.sessionId) { - return; - } - const checkpoints = sessionStoreCheckpoints(existing); - checkpoints.push(checkpoint); - const snapshotBytesByPath = await statCheckpointSnapshotBytes(checkpoints); - trimmedCheckpoints = trimSessionCheckpoints(checkpoints, snapshotBytesByPath); - store[target.canonicalKey] = { - ...existing, - updatedAt: Math.max(existing.updatedAt ?? 0, createdAt), - compactionCheckpoints: trimmedCheckpoints.kept, - }; - stored = true; - }); + let stored = false; + const updatedEntry = await updateSessionEntry( + { + storePath: target.storePath, + sessionKey: target.canonicalKey, + }, + async (existing) => { + if (!existing.sessionId) { + return null; + } + const checkpoints = sessionStoreCheckpoints(existing); + checkpoints.push(checkpoint); + const snapshotBytesByPath = await statCheckpointSnapshotBytes(checkpoints); + trimmedCheckpoints = trimSessionCheckpoints(checkpoints, snapshotBytesByPath); + stored = true; + return { + updatedAt: Math.max(existing.updatedAt ?? 0, createdAt), + compactionCheckpoints: trimmedCheckpoints.kept, + }; + }, + ); - if (!stored) { + if (!updatedEntry || !stored) { log.warn("skipping compaction checkpoint persist: session not found", { sessionKey: params.sessionKey, }); diff --git a/src/gateway/session-event-payload.ts b/src/gateway/session-event-payload.ts index d259a50644e4..9c227df01a86 100644 --- a/src/gateway/session-event-payload.ts +++ b/src/gateway/session-event-payload.ts @@ -52,6 +52,7 @@ export function buildGatewaySessionEventFields(params: { contextTokens: sessionRow.contextTokens, estimatedCostUsd: sessionRow.estimatedCostUsd, responseUsage: sessionRow.responseUsage, + effectiveResponseUsage: sessionRow.effectiveResponseUsage, modelProvider: sessionRow.modelProvider, model: sessionRow.model, status: sessionRow.status, diff --git a/src/gateway/session-transcript-json.ts b/src/gateway/session-transcript-json.ts index bba0bd043cf4..36dc818304b2 100644 --- a/src/gateway/session-transcript-json.ts +++ b/src/gateway/session-transcript-json.ts @@ -5,8 +5,34 @@ export function normalizeOptionalString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value : undefined; } +// Transcript readers repeatedly extract a fixed set of metadata fields from +// oversized JSONL prefixes. Keep the compiled regexes process-local instead of +// rebuilding them for every field on every oversized record. +const TRANSCRIPT_FIELD_REGEX_CACHE = new Map< + string, + { stringRe: RegExp; nullRe: RegExp; numberRe: RegExp } +>(); + +function getTranscriptFieldRegexes(field: string): { + stringRe: RegExp; + nullRe: RegExp; + numberRe: RegExp; +} { + let cached = TRANSCRIPT_FIELD_REGEX_CACHE.get(field); + if (!cached) { + const escapedField = escapeRegExp(field); + cached = { + stringRe: new RegExp(`"${escapedField}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`), + nullRe: new RegExp(`"${escapedField}"\\s*:\\s*null`), + numberRe: new RegExp(`"${escapedField}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)`), + }; + TRANSCRIPT_FIELD_REGEX_CACHE.set(field, cached); + } + return cached; +} + export function extractJsonStringFieldPrefix(prefix: string, field: string): string | undefined { - const match = new RegExp(`"${escapeRegExp(field)}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(prefix); + const match = getTranscriptFieldRegexes(field).stringRe.exec(prefix); if (!match) { return undefined; } @@ -22,16 +48,14 @@ export function extractJsonNullableStringFieldPrefix( prefix: string, field: string, ): string | null | undefined { - if (new RegExp(`"${escapeRegExp(field)}"\\s*:\\s*null`).test(prefix)) { + if (getTranscriptFieldRegexes(field).nullRe.test(prefix)) { return null; } return extractJsonStringFieldPrefix(prefix, field); } export function extractJsonNumberFieldPrefix(prefix: string, field: string): number | undefined { - const match = new RegExp( - `"${escapeRegExp(field)}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)`, - ).exec(prefix); + const match = getTranscriptFieldRegexes(field).numberRe.exec(prefix); if (!match) { return undefined; } diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 1b0aea675ca7..3c9e8dfc3b66 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -809,6 +809,86 @@ describe("gateway session utils", () => { expect(row.displayName).toBe("Alice"); }); + test("buildGatewaySessionRow projects effectiveResponseUsage from a bare config default", () => { + const cfg = { + agents: { list: [{ id: "main", default: true }] }, + messages: { responseUsage: "tokens" }, + } as OpenClawConfig; + const entry = { sessionId: "s1", updatedAt: 1 } as SessionEntry; + const row = buildGatewaySessionRow({ + cfg, + storePath: "", + store: { "agent:main:main": entry }, + key: "agent:main:main", + entry, + }); + // Session has no explicit override → inherits the configured default. + expect(row.responseUsage).toBeUndefined(); + expect(row.effectiveResponseUsage).toBe("tokens"); + }); + + test("buildGatewaySessionRow effectiveResponseUsage respects a per-channel responseUsage map", () => { + const cfg = { + agents: { list: [{ id: "main", default: true }] }, + messages: { + responseUsage: { default: "off", discord: "full", telegram: "tokens" }, + }, + } as OpenClawConfig; + const discordEntry = { sessionId: "d1", updatedAt: 1, channel: "discord" } as SessionEntry; + const discordRow = buildGatewaySessionRow({ + cfg, + storePath: "", + store: { "agent:main:discord:direct:1": discordEntry }, + key: "agent:main:discord:direct:1", + entry: discordEntry, + }); + expect(discordRow.effectiveResponseUsage).toBe("full"); + + const telegramEntry = { sessionId: "t1", updatedAt: 1, channel: "telegram" } as SessionEntry; + const telegramRow = buildGatewaySessionRow({ + cfg, + storePath: "", + store: { "agent:main:telegram:direct:1": telegramEntry }, + key: "agent:main:telegram:direct:1", + entry: telegramEntry, + }); + expect(telegramRow.effectiveResponseUsage).toBe("tokens"); + + // A channel with no entry falls back to the config "default" (off). + const slackEntry = { sessionId: "x1", updatedAt: 1, channel: "slack" } as SessionEntry; + const slackRow = buildGatewaySessionRow({ + cfg, + storePath: "", + store: { "agent:main:slack:direct:1": slackEntry }, + key: "agent:main:slack:direct:1", + entry: slackEntry, + }); + expect(slackRow.effectiveResponseUsage).toBe("off"); + }); + + test("buildGatewaySessionRow effectiveResponseUsage keeps an explicit session off over a channel default", () => { + const cfg = { + agents: { list: [{ id: "main", default: true }] }, + messages: { responseUsage: { default: "full", discord: "full" } }, + } as OpenClawConfig; + const entry = { + sessionId: "d1", + updatedAt: 1, + channel: "discord", + responseUsage: "off", + } as SessionEntry; + const row = buildGatewaySessionRow({ + cfg, + storePath: "", + store: { "agent:main:discord:direct:1": entry }, + key: "agent:main:discord:direct:1", + entry, + }); + // Explicit off persists and wins over the per-channel default. + expect(row.responseUsage).toBe("off"); + expect(row.effectiveResponseUsage).toBe("off"); + }); + test("resolveSessionStoreKey maps main aliases to default agent main", () => { const cfg = { session: { mainKey: "work" }, diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index c91e40838fd4..bcefe6959234 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -55,7 +55,7 @@ import { RECENT_ENDED_SUBAGENT_CHILD_SESSION_MS, shouldKeepSubagentRunChildLink, } from "../agents/subagent-run-liveness.js"; -import { listThinkingLevelOptions } from "../auto-reply/thinking.js"; +import { listThinkingLevelOptions, resolveEffectiveResponseUsage } from "../auto-reply/thinking.js"; import { getRuntimeConfig } from "../config/io.js"; import { resolveAgentModelFallbackValues } from "../config/model-input.js"; import { @@ -2193,6 +2193,11 @@ export function buildGatewaySessionRow(params: { parentSessionKey: subagentOwner || entry?.parentSessionKey, childSessions, responseUsage: entry?.responseUsage, + effectiveResponseUsage: resolveEffectiveResponseUsage( + entry?.responseUsage, + cfg.messages?.responseUsage, + channel, + ), modelProvider: rowModelProvider, model: rowModel, agentRuntime, diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index aedde6254841..75408577f722 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -92,6 +92,8 @@ export type GatewaySessionRow = { parentSessionKey?: string; childSessions?: string[]; responseUsage?: "on" | "off" | "tokens" | "full"; + /** Resolved effective usage mode (session override → channel config → default → off). Populated by surfaces that have config access; absent from the raw session store row. */ + effectiveResponseUsage?: "on" | "off" | "tokens" | "full"; modelProvider?: string; model?: string; agentRuntime?: GatewayAgentRuntime; diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts index 90b86c79961e..aba5524920b6 100644 --- a/src/gateway/sessions-patch.test.ts +++ b/src/gateway/sessions-patch.test.ts @@ -238,6 +238,30 @@ describe("gateway sessions patch", () => { expect(entry.thinkingLevel).toBeUndefined(); }); + test("persists responseUsage=off (does not clear)", async () => { + const entry = expectPatchOk( + await runPatch({ + patch: { key: MAIN_SESSION_KEY, responseUsage: "off" }, + }), + ); + // Explicit off must persist so a configured messages.responseUsage default + // cannot re-enable the footer the user turned off. + expect(entry.responseUsage).toBe("off"); + }); + + test("clears responseUsage when patch sets null", async () => { + const store: Record = { + [MAIN_SESSION_KEY]: { responseUsage: "tokens" } as SessionEntry, + }; + const entry = expectPatchOk( + await runPatch({ + store, + patch: { key: MAIN_SESSION_KEY, responseUsage: null }, + }), + ); + expect(entry.responseUsage).toBeUndefined(); + }); + test("persists reasoningLevel=off (does not clear)", async () => { const entry = expectPatchOk( await runPatch({ diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index b1500b059025..fe14db3e0063 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -444,11 +444,7 @@ export async function projectSessionsPatchEntry(params: { if (!normalized) { return invalid('invalid responseUsage (use "off"|"tokens"|"full")'); } - if (normalized === "off") { - delete next.responseUsage; - } else { - next.responseUsage = normalized; - } + next.responseUsage = normalized; } } diff --git a/src/image-generation/image-assets.test.ts b/src/image-generation/image-assets.test.ts index 7a146ed68973..d165a432d1c9 100644 --- a/src/image-generation/image-assets.test.ts +++ b/src/image-generation/image-assets.test.ts @@ -6,10 +6,13 @@ import { imageSourceUploadFileName, parseImageDataUrl, parseOpenAiCompatibleImageResponse, + resolveInlineImageJsonResponseMaxBytes, sniffImageMimeType, toImageDataUrl, } from "./image-assets.js"; +const DEFAULT_TEST_IMAGE_MAX_BYTES = 6 * 1024 * 1024; + describe("image asset helpers", () => { it("converts buffers to image data URLs and parses them back", () => { const buffer = Buffer.from("png-bytes"); @@ -46,6 +49,16 @@ describe("image asset helpers", () => { expect(imageFileExtensionForMimeType(undefined, "jpg")).toBe("jpg"); }); + it("sizes inline image JSON caps from decoded image payload limits", () => { + expect(resolveInlineImageJsonResponseMaxBytes(4, DEFAULT_TEST_IMAGE_MAX_BYTES)).toBe( + 34_603_008, + ); + expect(resolveInlineImageJsonResponseMaxBytes(Number.NaN, DEFAULT_TEST_IMAGE_MAX_BYTES)).toBe( + 9_437_184, + ); + expect(resolveInlineImageJsonResponseMaxBytes(2, 8 * 1024 * 1024)).toBe(23_418_198); + }); + it("sniffs common generated image types", () => { expect(sniffImageMimeType(Buffer.from([0xff, 0xd8, 0xff]))).toEqual({ mimeType: "image/jpeg", diff --git a/src/image-generation/image-assets.ts b/src/image-generation/image-assets.ts index e58396cafe34..bf86a86225bb 100644 --- a/src/image-generation/image-assets.ts +++ b/src/image-generation/image-assets.ts @@ -1,5 +1,6 @@ /** Converts image provider base64/data-url payloads into generated or source image assets. */ import { canonicalizeBase64 } from "@openclaw/media-core/base64"; +import { MAX_IMAGE_BYTES } from "@openclaw/media-core/constants"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalLowercaseString, @@ -9,6 +10,7 @@ import type { GeneratedImageAsset, ImageGenerationSourceImage } from "./types.js const DEFAULT_IMAGE_MIME_TYPE = "image/png"; const DEFAULT_IMAGE_FILE_PREFIX = "image"; +const INLINE_IMAGE_JSON_RESPONSE_ENVELOPE_BYTES = 1024 * 1024; // Image asset helpers for provider responses and source uploads. They normalize // base64/data-url inputs into in-memory assets with predictable filenames. @@ -28,6 +30,19 @@ export type OpenAiCompatibleImageResponsePayload = { data?: unknown; }; +export function resolveInlineImageJsonResponseMaxBytes( + maxImages: number, + maxImageBytes: number, +): number { + const imageCount = Number.isFinite(maxImages) ? Math.max(1, Math.trunc(maxImages)) : 1; + const imageBytes = + Number.isFinite(maxImageBytes) && maxImageBytes > 0 + ? Math.max(1, Math.trunc(maxImageBytes)) + : MAX_IMAGE_BYTES; + const maxBase64Bytes = Math.ceil((imageBytes * imageCount * 4) / 3); + return maxBase64Bytes + INLINE_IMAGE_JSON_RESPONSE_ENVELOPE_BYTES; +} + function throwMalformedImageResponse(message: string | undefined): never | undefined { if (message) { throw new Error(message); diff --git a/src/image-generation/openai-compatible-image-provider.test.ts b/src/image-generation/openai-compatible-image-provider.test.ts index 9a94d5f940eb..9a6c0ccae1d6 100644 --- a/src/image-generation/openai-compatible-image-provider.test.ts +++ b/src/image-generation/openai-compatible-image-provider.test.ts @@ -51,15 +51,28 @@ vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({ resolveApiKeyForProvider: resolveApiKeyForProviderMock, })); -vi.mock("openclaw/plugin-sdk/provider-http", () => ({ - assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock, - createProviderOperationDeadline: createProviderOperationDeadlineMock, - postJsonRequest: postJsonRequestMock, - postMultipartRequest: postMultipartRequestMock, - resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock, - resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock, - sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock, -})); +vi.mock("openclaw/plugin-sdk/provider-http", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/provider-http", + ); + return { + assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock, + createProviderOperationDeadline: createProviderOperationDeadlineMock, + postJsonRequest: postJsonRequestMock, + postMultipartRequest: postMultipartRequestMock, + readProviderJsonResponse: actual.readProviderJsonResponse, + resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock, + resolveProviderOperationTimeoutMs: resolveProviderOperationTimeoutMsMock, + sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock, + }; +}); + +function jsonResponse(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} function requireFirstRequestHeaders(mock: ReturnType): Headers { const [call] = mock.mock.calls; @@ -138,8 +151,8 @@ function mockGeneratedResponse() { }, ], }; - postJsonRequestMock.mockResolvedValue({ response: { json: async () => payload }, release }); - postMultipartRequestMock.mockResolvedValue({ response: { json: async () => payload }, release }); + postJsonRequestMock.mockResolvedValue({ response: jsonResponse(payload), release }); + postMultipartRequestMock.mockResolvedValue({ response: jsonResponse(payload), release }); return release; } @@ -226,6 +239,74 @@ describe("OpenAI-compatible image provider helper", () => { expect(release).toHaveBeenCalledOnce(); }); + it("accepts valid multi-image JSON above the generic provider JSON cap", async () => { + const imageBytes = Buffer.alloc(6 * 1024 * 1024, 1); + postJsonRequestMock.mockResolvedValue({ + response: jsonResponse({ + data: Array.from({ length: 3 }, () => ({ + b64_json: imageBytes.toString("base64"), + })), + }), + release: vi.fn(async () => {}), + }); + const provider = createProvider(); + + const result = await provider.generateImage({ + provider: "sample", + model: "sample-image", + prompt: "large", + count: 3, + cfg: {} as never, + }); + + expect(result.images).toHaveLength(3); + expect(result.images.map((image) => image.buffer.byteLength)).toEqual([ + imageBytes.byteLength, + imageBytes.byteLength, + imageBytes.byteLength, + ]); + }); + + it("honors configured generated media caps above the default image limit", async () => { + const imageBytes = Buffer.alloc(7 * 1024 * 1024, 1); + postJsonRequestMock.mockResolvedValue({ + response: jsonResponse({ + data: [{ b64_json: imageBytes.toString("base64") }], + }), + release: vi.fn(async () => {}), + }); + const provider = createProvider(); + + const result = await provider.generateImage({ + provider: "sample", + model: "sample-image", + prompt: "large", + cfg: { agents: { defaults: { mediaMaxMb: 8 } } } as never, + }); + + expect(result.images).toHaveLength(1); + expect(result.images[0]?.buffer.byteLength).toBe(imageBytes.byteLength); + }); + + it("rejects oversized OpenAI-compatible image JSON", async () => { + postJsonRequestMock.mockResolvedValue({ + response: jsonResponse({ + data: [{ b64_json: "x".repeat(35 * 1024 * 1024) }], + }), + release: vi.fn(async () => {}), + }); + const provider = createProvider(); + + await expect( + provider.generateImage({ + provider: "sample", + model: "sample-image", + prompt: "too large", + cfg: {} as never, + }), + ).rejects.toThrow("sample.image-generation: JSON response exceeds"); + }); + it("posts multipart edit requests without forwarding a content-type header", async () => { mockGeneratedResponse(); const provider = createProvider(); @@ -250,7 +331,7 @@ describe("OpenAI-compatible image provider helper", () => { it("honors default operation timeouts and empty-response errors", async () => { postJsonRequestMock.mockResolvedValue({ - response: { json: async () => ({ data: [] }) }, + response: jsonResponse({ data: [] }), release: vi.fn(async () => {}), }); const provider = createProvider({ @@ -282,7 +363,7 @@ describe("OpenAI-compatible image provider helper", () => { it("wraps malformed successful image responses with provider-owned errors", async () => { postJsonRequestMock.mockResolvedValue({ - response: { json: async () => ({ data: { b64_json: "not-an-array" } }) }, + response: jsonResponse({ data: { b64_json: "not-an-array" } }), release: vi.fn(async () => {}), }); const provider = createProvider(); diff --git a/src/image-generation/openai-compatible-image-provider.ts b/src/image-generation/openai-compatible-image-provider.ts index 44caee81bc1a..45276f542ed3 100644 --- a/src/image-generation/openai-compatible-image-provider.ts +++ b/src/image-generation/openai-compatible-image-provider.ts @@ -7,12 +7,17 @@ import { createProviderOperationDeadline, postJsonRequest, postMultipartRequest, + readProviderJsonResponse, resolveProviderHttpRequestConfig, resolveProviderOperationTimeoutMs, sanitizeConfiguredModelProviderRequest, } from "openclaw/plugin-sdk/provider-http"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { parseOpenAiCompatibleImageResponse } from "./image-assets.js"; +import { resolveGeneratedMediaMaxBytes } from "../media/configured-max-bytes.js"; +import { + parseOpenAiCompatibleImageResponse, + resolveInlineImageJsonResponseMaxBytes, +} from "./image-assets.js"; import type { ImageGenerationProvider, ImageGenerationProviderCapabilities, @@ -127,6 +132,16 @@ function resolveRequestTimeoutMs(params: { }); } +function resolveResponseMaxImages(params: { + count: number; + mode: OpenAiCompatibleImageRequestMode; + options: OpenAiCompatibleImageProviderOptions; +}): number { + return params.mode === "edit" + ? (params.options.capabilities.edit.maxCount ?? params.count) + : (params.options.capabilities.generate.maxCount ?? params.count); +} + /** Creates an image-generation provider backed by OpenAI-style image endpoints. */ export function createOpenAiCompatibleImageGenerationProvider( options: OpenAiCompatibleImageProviderOptions, @@ -269,7 +284,13 @@ export function createOpenAiCompatibleImageGenerationProvider( ? (options.failureLabels?.edit ?? `${options.label} image edit failed`) : (options.failureLabels?.generate ?? `${options.label} image generation failed`), ); - const images = parseOpenAiCompatibleImageResponse(await response.json(), { + const payload = await readProviderJsonResponse(response, `${options.id}.image-generation`, { + maxBytes: resolveInlineImageJsonResponseMaxBytes( + resolveResponseMaxImages({ count, mode, options }), + resolveGeneratedMediaMaxBytes(req.cfg, "image"), + ), + }); + const images = parseOpenAiCompatibleImageResponse(payload, { ...options.response, malformedResponseError: mode === "edit" diff --git a/src/infra/clawhub-install-trust.ts b/src/infra/clawhub-install-trust.ts new file mode 100644 index 000000000000..0bd949f9e8b1 --- /dev/null +++ b/src/infra/clawhub-install-trust.ts @@ -0,0 +1,1098 @@ +// Shared ClawHub exact-release trust gate for plugin and skill installs. +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { stripAnsi, visibleWidth } from "../../packages/terminal-core/src/ansi.js"; +import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; +import { formatTerminalLink } from "../../packages/terminal-core/src/terminal-link.js"; +import { theme } from "../../packages/terminal-core/src/theme.js"; +import { + fetchClawHubPackageSecurity, + fetchClawHubSkillVerification, + fetchClawHubSkillSecurityVerdicts, + resolveClawHubBaseUrl, + type ClawHubPackageSecurityResponse, + type ClawHubPackageSecurityTrust, + type ClawHubSkillSecurityVerdictItem, + type ClawHubSkillVerificationResponse, +} from "./clawhub.js"; +import { formatErrorMessage } from "./errors.js"; + +export const CLAWHUB_TRUST_ERROR_CODE = { + CLAWHUB_SECURITY_UNAVAILABLE: "clawhub_security_unavailable", + CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED: "clawhub_risk_acknowledgement_required", + CLAWHUB_DOWNLOAD_BLOCKED: "clawhub_download_blocked", +} as const; + +export type ClawHubTrustErrorCode = + (typeof CLAWHUB_TRUST_ERROR_CODE)[keyof typeof CLAWHUB_TRUST_ERROR_CODE]; + +export type ClawHubRiskAcknowledgementRequest = { + packageName: string; + version: string; + trust: ClawHubPackageSecurityTrust; + acknowledgementKind: "confirm" | "type-package"; + warning: string; +}; + +export type ClawHubTrustInstallRecordFields = { + clawhubTrustDisposition: "clean" | "review-recommended" | "review-required" | "blocked"; + clawhubTrustScanStatus?: string; + clawhubTrustModerationState?: string; + clawhubTrustReasons?: string[]; + clawhubTrustPending?: true; + clawhubTrustStale?: true; + clawhubTrustCheckedAt: string; + clawhubTrustAcknowledgedAt?: string; +}; + +export type ClawHubTrustAcceptedResult = { + ok: true; + trustInstallRecordFields: ClawHubTrustInstallRecordFields; + warning?: string; +}; + +export type ClawHubTrustFailure = { + ok: false; + error: string; + code?: ClawHubTrustErrorCode; + warning?: string; + version?: string; +}; + +type ClawHubInstallLogger = { + warn?: (message: string) => void; + terminalLinks?: boolean; +}; + +type ClawHubTrustSubject = { + kind: "plugin" | "skill"; + packageName: string; + ownerHandle?: string; +}; + +type ClawHubSkillSecurityLinks = { + subject: string; + security: string; +}; + +type ClawHubPluginSecurityLinks = { + subject: string; + clawscan: string; +}; + +type ClawHubSecurityLinks = ClawHubSkillSecurityLinks | ClawHubPluginSecurityLinks; +type ClawHubFetchedSubjectSecurity = { + security: ClawHubPackageSecurityResponse; + links?: { + subject?: string; + security?: string; + }; +}; + +const CLAWHUB_RISK_MODERATION_STATES = new Set(["blocked", "quarantined", "revoked"]); +const CLAWHUB_BLOCKING_MODERATION_STATES = new Set(["blocked", "quarantined", "revoked"]); +const CLAWHUB_SAFE_MODERATION_STATES = new Set(["", "approved"]); +const CLAWHUB_NON_RISK_SCAN_STATUSES = new Set(["pending", "scan_pending", "stale", "stale_scan"]); +const CLAWHUB_NON_RISK_REASONS = new Set([ + "pending", + "pending_scan", + "scan:pending", + "scan_pending", + "stale", + "scan:stale", + "stale_scan", +]); +const CLAWHUB_NON_SECURITY_SKILL_VERIFY_REASONS = new Set(["card.missing", "card_missing"]); +const CLAWHUB_EVIDENCE_LABEL_WIDTH = 15; +const CLAWHUB_RAW_LINK_LABEL_WIDTH = 16; + +function normalizeClawHubTrustToken(value: string | null | undefined): string { + return normalizeOptionalString(value)?.toLowerCase() ?? ""; +} + +function formatClawHubTrustStatus(label: string, token: string): string { + return token ? `${label} is ${token}` : `${label} is missing`; +} + +function formatClawHubReasonCode(reason: string): string { + const normalized = normalizeClawHubTrustToken(reason); + switch (normalized) { + case "scan:malicious": + return "malicious behavior detected"; + case "static:malicious": + return "malicious behavior detected"; + case "payload_strings": + return "suspicious payload strings"; + case "security.status_not_clean": + return "security status is not clean"; + case "skill.not_found": + return "skill was not found"; + case "version.not_found": + return "skill version was not found"; + case "scan:pending": + case "pending_scan": + case "scan_pending": + return "scan pending"; + case "scan:stale": + case "stale_scan": + return "scan data stale"; + default: + return reason; + } +} + +type ClawHubTrustAssessment = { + disposition: ClawHubTrustInstallRecordFields["clawhubTrustDisposition"]; + riskReasons: string[]; + notices: string[]; +}; + +function isPendingOrStaleTrustWarning(trust: ClawHubPackageSecurityTrust): boolean { + return trust.pending || trust.stale; +} + +function isNonRiskScanStatus(trust: ClawHubPackageSecurityTrust, scanStatus: string): boolean { + return isPendingOrStaleTrustWarning(trust) && CLAWHUB_NON_RISK_SCAN_STATUSES.has(scanStatus); +} + +function isNonRiskReason(trust: ClawHubPackageSecurityTrust, reason: string): boolean { + return isPendingOrStaleTrustWarning(trust) && CLAWHUB_NON_RISK_REASONS.has(reason); +} + +function resolveClawHubRiskReasons(trust: ClawHubPackageSecurityTrust): string[] { + const reasons: string[] = []; + if (trust.blockedFromDownload) { + reasons.push("Download disabled by ClawHub for this release"); + } + const scanStatus = normalizeClawHubTrustToken(trust.scanStatus); + if (scanStatus !== "clean" && !isNonRiskScanStatus(trust, scanStatus)) { + reasons.push(formatClawHubTrustStatus("security scan status", scanStatus)); + } + const moderationState = normalizeClawHubTrustToken(trust.moderationState); + if ( + CLAWHUB_RISK_MODERATION_STATES.has(moderationState) || + !CLAWHUB_SAFE_MODERATION_STATES.has(moderationState) + ) { + reasons.push(formatClawHubTrustStatus("moderation state", moderationState)); + } + for (const reason of trust.reasons) { + const normalized = normalizeClawHubTrustToken(reason); + if (normalized && !isNonRiskReason(trust, normalized)) { + reasons.push(formatClawHubReasonCode(reason)); + } + } + return reasons; +} + +function resolveClawHubTrustStatusNotices(trust: ClawHubPackageSecurityTrust): string[] { + const notices: string[] = []; + if (trust.pending) { + notices.push("security scan is pending"); + } + if (trust.stale) { + notices.push("scan data is stale"); + } + for (const reason of trust.reasons) { + const normalized = normalizeClawHubTrustToken(reason); + if (normalized && isNonRiskReason(trust, normalized)) { + notices.push(formatClawHubReasonCode(reason)); + } + } + return notices; +} + +function isBlockingClawHubTrust(trust: ClawHubPackageSecurityTrust): boolean { + if (trust.blockedFromDownload) { + return true; + } + if (normalizeClawHubTrustToken(trust.scanStatus) === "malicious") { + return true; + } + if (CLAWHUB_BLOCKING_MODERATION_STATES.has(normalizeClawHubTrustToken(trust.moderationState))) { + return true; + } + return trust.reasons.some((reason) => { + const normalized = normalizeClawHubTrustToken(reason); + return normalized === "scan:malicious" || normalized === "static:malicious"; + }); +} + +function hasMaliciousClawHubTrustSignal(trust: ClawHubPackageSecurityTrust): boolean { + if (normalizeClawHubTrustToken(trust.scanStatus) === "malicious") { + return true; + } + return trust.reasons.some((reason) => { + const normalized = normalizeClawHubTrustToken(reason); + return normalized === "scan:malicious" || normalized === "static:malicious"; + }); +} + +function assessClawHubTrust(trust: ClawHubPackageSecurityTrust): ClawHubTrustAssessment { + const riskReasons = resolveClawHubRiskReasons(trust); + const notices = resolveClawHubTrustStatusNotices(trust); + if (riskReasons.length === 0 && notices.length === 0) { + return { disposition: "clean", riskReasons, notices }; + } + if (isBlockingClawHubTrust(trust)) { + return { disposition: "blocked", riskReasons, notices }; + } + if (riskReasons.length > 0) { + return { disposition: "review-required", riskReasons, notices }; + } + return { disposition: "review-recommended", riskReasons, notices }; +} + +function buildClawHubTrustInstallRecordFields(params: { + trust: ClawHubPackageSecurityTrust; + assessment: ClawHubTrustAssessment; + checkedAt: string; + acknowledgedAt?: string; +}): ClawHubTrustInstallRecordFields { + const scanStatus = normalizeClawHubTrustToken(params.trust.scanStatus); + const moderationState = normalizeClawHubTrustToken(params.trust.moderationState); + const reasons = params.trust.reasons + .map((reason) => normalizeOptionalString(reason)) + .filter((reason): reason is string => Boolean(reason)); + return { + clawhubTrustDisposition: params.assessment.disposition, + ...(scanStatus ? { clawhubTrustScanStatus: scanStatus } : {}), + ...(moderationState ? { clawhubTrustModerationState: moderationState } : {}), + ...(reasons.length > 0 ? { clawhubTrustReasons: reasons } : {}), + ...(params.trust.pending ? { clawhubTrustPending: true } : {}), + ...(params.trust.stale ? { clawhubTrustStale: true } : {}), + clawhubTrustCheckedAt: params.checkedAt, + ...(params.acknowledgedAt ? { clawhubTrustAcknowledgedAt: params.acknowledgedAt } : {}), + }; +} + +function encodeClawHubPackagePath(packageName: string): string { + return packageName + .split("/") + .map((part) => encodeURIComponent(part).replaceAll("%40", "@")) + .join("/"); +} + +function resolveClawHubSubjectUrl(params: { + baseUrl?: string; + subject: ClawHubTrustSubject; +}): string { + if (params.subject.kind === "skill" && params.subject.ownerHandle) { + return `${resolveClawHubBaseUrl(params.baseUrl)}/${encodeURIComponent(params.subject.ownerHandle)}/skills/${encodeURIComponent(params.subject.packageName)}`; + } + const pathRoot = params.subject.kind === "skill" ? "skills" : "plugins"; + return `${resolveClawHubBaseUrl(params.baseUrl)}/${pathRoot}/${encodeClawHubPackagePath(params.subject.packageName)}`; +} + +function resolveClawHubSecurityLinks(params: { + baseUrl?: string; + subject: ClawHubTrustSubject; + version: string; + links?: { + subject?: string; + security?: string; + }; +}): ClawHubSecurityLinks { + const subjectUrl = resolveClawHubSubjectUrl(params); + if (params.subject.kind === "skill") { + const resolvedSubjectUrl = normalizeOptionalString(params.links?.subject) ?? subjectUrl; + return { + subject: resolvedSubjectUrl, + security: + normalizeOptionalString(params.links?.security) ?? + `${resolvedSubjectUrl}/security-audit?version=${encodeURIComponent(params.version)}`, + }; + } + return { + subject: subjectUrl, + clawscan: `${subjectUrl}/security/clawscan`, + }; +} + +function padRight(value: string, width: number): string { + return `${value}${" ".repeat(Math.max(0, width - visibleWidth(value)))}`; +} + +function wrapWords(text: string, width: number): string[] { + if (visibleWidth(text) <= width) { + return [text]; + } + const words = text.split(/\s+/).filter(Boolean); + const lines: string[] = []; + let line = ""; + for (const word of words) { + const next = line ? `${line} ${word}` : word; + if (visibleWidth(next) > width && line) { + lines.push(line); + line = word; + } else { + line = next; + } + } + if (line) { + lines.push(line); + } + return lines; +} + +function resolveClawHubTrustAccent( + disposition: ClawHubTrustAssessment["disposition"], +): (value: string) => string { + switch (disposition) { + case "blocked": + return theme.error; + case "review-required": + return theme.warn; + case "review-recommended": + return theme.info; + case "clean": + return theme.success; + } + return theme.info; +} + +function formatClawHubEvidenceLine(params: { + label: string; + value: string; + accent: (value: string) => string; +}): string { + const label = sanitizeTerminalText(params.label).replace(/:$/u, ""); + return `${theme.muted(`• ${padRight(label, CLAWHUB_EVIDENCE_LABEL_WIDTH)}`)} ${params.accent(params.value)}`; +} + +function renderClawHubTrustBox( + title: string, + lines: string[], + disposition: ClawHubTrustAssessment["disposition"], +): string { + const accent = resolveClawHubTrustAccent(disposition); + const columns = Math.max(72, Math.min(process.stdout.columns ?? 88, 104)); + const innerWidth = Math.max(54, Math.min(columns - 4, 78)); + const totalWidth = innerWidth + 4; + const borderWidth = totalWidth - 2; + const titleSegment = `─ ${title} `; + const titleFillWidth = Math.max(0, borderWidth - visibleWidth(titleSegment)); + const top = accent(`╭${titleSegment}${"─".repeat(titleFillWidth)}╮`); + const bottom = accent(`╰${"─".repeat(borderWidth)}╯`); + const body = lines.flatMap((line) => { + if (line === "") { + return [`${accent("│")} ${" ".repeat(innerWidth)} ${accent("│")}`]; + } + return wrapWords(line, innerWidth).map( + (wrapped) => `${accent("│")} ${padRight(wrapped, innerWidth)} ${accent("│")}`, + ); + }); + return [top, ...body, bottom].join("\n"); +} + +function formatLinkedClawHubValue(params: { + label: string; + url: string; + terminalLinks?: boolean; +}): string { + const label = sanitizeTerminalText(params.label); + return formatTerminalLink(label, sanitizeTerminalText(params.url), { + fallback: label, + ...(params.terminalLinks !== undefined ? { force: params.terminalLinks } : {}), + }); +} + +function formatClawHubTrustEvidenceLines(params: { + trust: ClawHubPackageSecurityTrust; + assessment: ClawHubTrustAssessment; + links: ClawHubSecurityLinks; + terminalLinks?: boolean; +}): string[] { + const lines: string[] = []; + const accent = resolveClawHubTrustAccent(params.assessment.disposition); + const securityLink = "clawscan" in params.links ? params.links.clawscan : params.links.security; + const addLine = (label: string, value: string): void => { + lines.push(formatClawHubEvidenceLine({ label, value, accent })); + }; + const linked = (label: string, url: string): string => + formatLinkedClawHubValue({ label, url, terminalLinks: params.terminalLinks }); + const scanStatus = normalizeClawHubTrustToken(params.trust.scanStatus); + if (scanStatus) { + addLine("Security scan:", linked(scanStatus, securityLink)); + } + const moderationState = normalizeClawHubTrustToken(params.trust.moderationState); + if (moderationState && !CLAWHUB_SAFE_MODERATION_STATES.has(moderationState)) { + addLine("Moderation:", sanitizeTerminalText(moderationState)); + } + for (const reason of params.trust.reasons) { + const normalized = normalizeClawHubTrustToken(reason); + if (!normalized) { + continue; + } + if ( + params.assessment.disposition === "review-recommended" && + isNonRiskReason(params.trust, normalized) + ) { + continue; + } + switch (normalized) { + case "scan:malicious": + addLine("Scanner:", linked("malicious behavior detected", securityLink)); + break; + case "static:malicious": + addLine("Scanner:", linked("malicious behavior detected", securityLink)); + break; + case "payload_strings": + addLine("Finding:", linked("suspicious payload strings", securityLink)); + break; + default: + addLine("Finding:", sanitizeTerminalText(formatClawHubReasonCode(reason))); + break; + } + } + if (params.assessment.disposition === "review-recommended") { + for (const notice of params.assessment.notices) { + addLine("Status:", sanitizeTerminalText(notice)); + } + } + if (params.trust.blockedFromDownload) { + addLine("Finding:", "Download disabled by ClawHub for this release"); + } + if (lines.length === 0) { + for (const reason of params.assessment.riskReasons) { + addLine("Finding:", sanitizeTerminalText(reason)); + } + } + return lines; +} + +function formatClawHubRawLinkLine(label: string, url: string): string { + return ` ${theme.muted(padRight(label, CLAWHUB_RAW_LINK_LABEL_WIDTH))} ${theme.info(sanitizeTerminalText(url))}`; +} + +function formatClawHubRawLinks(params: { + subject: ClawHubTrustSubject; + links: ClawHubSecurityLinks; +}): string { + const subjectUrl = sanitizeTerminalText(params.links.subject); + if ("security" in params.links) { + return [ + "", + "Links:", + formatClawHubRawLinkLine("Skill", subjectUrl), + formatClawHubRawLinkLine("Security details", params.links.security), + ].join("\n"); + } + return [ + "", + "Links:", + formatClawHubRawLinkLine("Plugin", subjectUrl), + formatClawHubRawLinkLine("Security scan", params.links.clawscan), + ].join("\n"); +} + +function formatClawHubTrustWarning(params: { + baseUrl?: string; + subject: ClawHubTrustSubject; + version: string; + trust: ClawHubPackageSecurityTrust; + assessment: ClawHubTrustAssessment; + mode?: "install" | "update"; + terminalLinks?: boolean; + links?: { + subject?: string; + security?: string; + }; +}): string { + const links = resolveClawHubSecurityLinks({ + baseUrl: params.baseUrl, + subject: params.subject, + version: params.version, + links: params.links, + }); + const evidenceLines = formatClawHubTrustEvidenceLines({ + trust: params.trust, + assessment: params.assessment, + links, + terminalLinks: params.terminalLinks, + }); + const noun = params.subject.kind; + if (params.assessment.disposition === "blocked") { + const malicious = hasMaliciousClawHubTrustSignal(params.trust); + const blockedActionLines = + params.mode === "update" + ? malicious + ? [ + `Latest ${noun} version is marked malicious; OpenClaw will not download it.`, + `Uninstall the installed ${noun} unless you have independently reviewed it.`, + ] + : [`Latest ${noun} version is blocked by ClawHub; OpenClaw will not download it.`] + : [`OpenClaw will not install this ${noun} release from ClawHub.`]; + const blockedTitle = malicious + ? "BLOCKED - ClawHub flagged this release as malicious" + : "BLOCKED - ClawHub blocked this release"; + return [ + renderClawHubTrustBox( + blockedTitle, + [ + ...evidenceLines, + "", + ...blockedActionLines, + "Review the ClawHub security details or contact the package maintainer if you believe this is wrong.", + ], + params.assessment.disposition, + ), + formatClawHubRawLinks({ subject: params.subject, links }), + ].join("\n"); + } + if (params.assessment.disposition === "review-required") { + const riskContext = + params.subject.kind === "plugin" + ? "This plugin is not marked malicious, but ClawHub found security findings or a large local system blast radius." + : "This skill is not marked malicious, but ClawHub found security findings or a large instruction/tool-use blast radius."; + return [ + renderClawHubTrustBox( + "WARNING - ClawHub found security risks in this release", + [ + ...evidenceLines, + "", + riskContext, + `Review the ClawHub security details before ${params.mode === "update" ? "updating" : "installing"}.`, + ], + params.assessment.disposition, + ), + formatClawHubRawLinks({ subject: params.subject, links }), + ].join("\n"); + } + return [ + renderClawHubTrustBox( + "REVIEW RECOMMENDED - ClawHub has not completed a fresh clean check", + [ + ...evidenceLines, + "", + `This does not mean the ${noun} is malicious, but ClawHub has not completed a clean security check for this release yet.`, + `Review the ClawHub security details before ${params.mode === "update" ? "updating" : "installing"}.`, + ], + params.assessment.disposition, + ), + formatClawHubRawLinks({ subject: params.subject, links }), + ].join("\n"); +} + +function formatClawHubReleaseLabel(packageName: string, version: string): string { + return `${sanitizeTerminalText(packageName)}@${sanitizeTerminalText(version)}`; +} + +function formatClawHubSubjectPackageName(subject: ClawHubTrustSubject): string { + return subject.kind === "skill" && subject.ownerHandle + ? `@${subject.ownerHandle}/${subject.packageName}` + : subject.packageName; +} + +function formatClawHubSubjectReleaseLabel(subject: ClawHubTrustSubject, version: string): string { + return formatClawHubReleaseLabel(formatClawHubSubjectPackageName(subject), version); +} + +function validateClawHubSecurityIdentity(params: { + security: ClawHubPackageSecurityResponse; + packageName: string; + packageLabel?: string; + version: string; +}): ClawHubTrustFailure | null { + const packageLabel = params.packageLabel ?? params.packageName; + const responsePackageName = normalizeOptionalString(params.security.package?.name); + if (responsePackageName !== params.packageName) { + return { + ok: false, + error: `ClawHub release trust check for "${formatClawHubReleaseLabel(packageLabel, params.version)}" returned package "${sanitizeTerminalText(responsePackageName ?? "unknown")}".`, + code: CLAWHUB_TRUST_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE, + version: params.version, + }; + } + const responseVersion = normalizeOptionalString(params.security.release?.version); + if (responseVersion !== params.version) { + return { + ok: false, + error: `ClawHub release trust check for "${formatClawHubReleaseLabel(packageLabel, params.version)}" returned version "${sanitizeTerminalText(responseVersion ?? "unknown")}".`, + code: CLAWHUB_TRUST_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE, + version: params.version, + }; + } + return null; +} + +function readSkillVerdictSecurityStatus(item: ClawHubSkillSecurityVerdictItem): string | undefined { + if (!item.security || typeof item.security !== "object") { + return undefined; + } + const security = item.security as { status?: unknown; rawStatus?: unknown }; + if (typeof security.status === "string") { + return security.status; + } + return typeof security.rawStatus === "string" ? security.rawStatus : undefined; +} + +function readSkillVerdictSecurityPassed( + item: ClawHubSkillSecurityVerdictItem, +): boolean | undefined { + if (!item.security || typeof item.security !== "object") { + return undefined; + } + const passed = (item.security as { passed?: unknown }).passed; + return typeof passed === "boolean" ? passed : undefined; +} + +function hasUsablePassingSkillVerdictSecurity(item: ClawHubSkillSecurityVerdictItem): boolean { + return ( + Boolean(readSkillVerdictSecurityStatus(item)) && readSkillVerdictSecurityPassed(item) === true + ); +} + +function hasSkillVerdictSecurityError(item: ClawHubSkillSecurityVerdictItem): boolean { + return Boolean(item.error?.code || item.error?.message || item.version === null); +} + +function isSkillVerdictPendingReason(reason: string): boolean { + const normalized = normalizeClawHubTrustToken(reason); + return normalized === "pending" || normalized === "pending_scan" || normalized === "scan_pending"; +} + +function isSkillVerdictStaleReason(reason: string): boolean { + const normalized = normalizeClawHubTrustToken(reason); + return normalized === "stale" || normalized === "scan:stale" || normalized === "stale_scan"; +} + +function isSkillVerdictBlockingReason(reason: string): boolean { + const normalized = normalizeClawHubTrustToken(reason); + return ( + normalized.includes("malicious") || + normalized.includes("malware") || + normalized.endsWith("_blocked") || + normalized.endsWith(".blocked") || + normalized === "blocked" + ); +} + +function mapSkillSecurityVerdictToPackageSecurity(params: { + item: ClawHubSkillSecurityVerdictItem; + packageName: string; + ownerHandle?: string; + version: string; +}): ClawHubPackageSecurityResponse { + const responseSlug = normalizeOptionalString(params.item.slug ?? params.item.requestedSlug); + if (responseSlug !== params.packageName) { + throw new Error( + `ClawHub skill trust check for "${formatClawHubReleaseLabel(params.packageName, params.version)}" returned skill "${sanitizeTerminalText(responseSlug ?? "unknown")}".`, + ); + } + const responsePublisher = normalizeOptionalString(params.item.publisherHandle); + if (params.ownerHandle && responsePublisher !== params.ownerHandle) { + throw new Error( + `ClawHub skill trust check for "${formatClawHubReleaseLabel(params.packageName, params.version)}" returned publisher "${sanitizeTerminalText(responsePublisher ?? "unknown")}", expected "${sanitizeTerminalText(params.ownerHandle)}".`, + ); + } + const responseVersion = normalizeOptionalString(params.item.version); + if (responseVersion !== params.version) { + const reason = params.item.error?.message + ? `: ${sanitizeTerminalText(params.item.error.message)}` + : ""; + throw new Error( + `ClawHub skill trust check for "${formatClawHubReleaseLabel(params.packageName, params.version)}" returned version "${sanitizeTerminalText(responseVersion ?? "unknown")}"${reason}.`, + ); + } + if (hasSkillVerdictSecurityError(params.item)) { + const reason = params.item.error?.message + ? `: ${sanitizeTerminalText(params.item.error.message)}` + : ""; + throw new Error( + `ClawHub skill trust check for "${formatClawHubReleaseLabel(params.packageName, params.version)}" did not return a usable security verdict${reason}.`, + ); + } + const decision = normalizeClawHubTrustToken(params.item.decision); + if (params.item.ok && decision === "pass" && !hasUsablePassingSkillVerdictSecurity(params.item)) { + throw new Error( + `ClawHub skill trust check for "${formatClawHubReleaseLabel(params.packageName, params.version)}" did not return a usable security verdict.`, + ); + } + + const securityStatus = normalizeClawHubTrustToken(readSkillVerdictSecurityStatus(params.item)); + const securityPassed = readSkillVerdictSecurityPassed(params.item); + const reasons = params.item.reasons + .map((reason) => normalizeOptionalString(reason)) + .filter((reason): reason is string => Boolean(reason)); + const securityPassedAllowsInstall = securityPassed ?? true; + const verdictPassed = params.item.ok && decision === "pass" && securityPassedAllowsInstall; + const scanStatus = verdictPassed + ? securityStatus || "clean" + : securityStatus && securityStatus !== "clean" + ? securityStatus + : "suspicious"; + if (!verdictPassed && reasons.length === 0) { + reasons.push(decision ? `decision:${decision}` : "decision:fail"); + } + const hasBlockingReason = reasons.some(isSkillVerdictBlockingReason); + const displayName = normalizeOptionalString(params.item.displayName); + return { + package: { + name: params.packageName, + family: "skill", + ...(displayName ? { displayName } : {}), + }, + release: { + version: params.version, + }, + trust: { + scanStatus, + moderationState: null, + blockedFromDownload: + decision === "blocked" || securityStatus === "malicious" || hasBlockingReason, + reasons, + pending: securityStatus === "pending" || reasons.some(isSkillVerdictPendingReason), + stale: securityStatus === "stale" || reasons.some(isSkillVerdictStaleReason), + }, + }; +} + +function resolveSkillSecurityLinks( + item: ClawHubSkillSecurityVerdictItem, +): ClawHubFetchedSubjectSecurity["links"] { + const subject = normalizeOptionalString(item.skillUrl); + const security = normalizeOptionalString(item.securityAuditUrl); + if (!subject && !security) { + return undefined; + } + return { + ...(subject ? { subject } : {}), + ...(security ? { security } : {}), + }; +} + +function readObject(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function readOptionalStringField(value: unknown, field: string): string | undefined { + const record = readObject(value); + return normalizeOptionalString(record?.[field]); +} + +function readOptionalNumberField(value: unknown, field: string): number | undefined { + const record = readObject(value); + const raw = record?.[field]; + return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined; +} + +function mapSkillVerificationSecurityForVerdict( + verification: ClawHubSkillVerificationResponse, + opts?: { allowCleanCardOnlyPass?: boolean }, +): unknown { + const security = readObject(verification.security); + if (!security || Object.hasOwn(security, "passed")) { + return verification.security; + } + const status = + normalizeOptionalString(security.status) ?? normalizeOptionalString(security.rawStatus); + const decisionPass = + verification.ok && normalizeClawHubTrustToken(verification.decision) === "pass"; + if (!status || (!decisionPass && opts?.allowCleanCardOnlyPass !== true)) { + return verification.security; + } + // The owner-qualified fallback uses the older verify endpoint, whose pass + // decision plus concrete status predates the batched verdict `passed` flag. + return { ...security, passed: true }; +} + +function hasOnlyNonSecuritySkillVerifyReasons(reasons: readonly string[]): boolean { + return ( + reasons.length > 0 && + reasons.every((reason) => + CLAWHUB_NON_SECURITY_SKILL_VERIFY_REASONS.has(normalizeClawHubTrustToken(reason)), + ) + ); +} + +function isOwnerQualifiedSkillNotFoundVerdict(item: ClawHubSkillSecurityVerdictItem): boolean { + return item.error?.code === "skill_not_found"; +} + +function mapSkillVerificationToSecurityVerdictItem(params: { + verification: ClawHubSkillVerificationResponse; + slug: string; + ownerHandle: string; + version: string; +}): ClawHubSkillSecurityVerdictItem { + const skill = readObject(params.verification.skill); + const publisher = readObject(params.verification.publisher); + const versionRecord = readObject(params.verification.version); + const pageUrl = normalizeOptionalString(params.verification.pageUrl); + const reasons = params.verification.reasons + .map((reason) => normalizeOptionalString(reason)) + .filter((reason): reason is string => Boolean(reason)); + const securityStatus = normalizeClawHubTrustToken( + readOptionalStringField(params.verification.security, "status") ?? + readOptionalStringField(params.verification.security, "rawStatus"), + ); + const cardOnlyCleanFailure = + !params.verification.ok && + securityStatus === "clean" && + hasOnlyNonSecuritySkillVerifyReasons(reasons); + const verifiedVersion = + normalizeOptionalString(params.verification.version) ?? + readOptionalStringField(versionRecord, "version"); + return { + ok: cardOnlyCleanFailure ? true : params.verification.ok, + decision: cardOnlyCleanFailure ? "pass" : params.verification.decision, + reasons: cardOnlyCleanFailure ? [] : reasons, + requestedSlug: params.slug, + requestedVersion: params.version, + slug: + normalizeOptionalString(params.verification.slug) ?? readOptionalStringField(skill, "slug"), + version: verifiedVersion ?? (cardOnlyCleanFailure ? params.version : null), + displayName: + normalizeOptionalString(params.verification.displayName) ?? + readOptionalStringField(skill, "displayName"), + publisherHandle: + normalizeOptionalString(params.verification.publisherHandle) ?? + readOptionalStringField(publisher, "handle") ?? + params.ownerHandle, + publisherDisplayName: + normalizeOptionalString(params.verification.publisherDisplayName) ?? + readOptionalStringField(publisher, "displayName"), + createdAt: + params.verification.createdAt ?? readOptionalNumberField(versionRecord, "createdAt") ?? null, + checkedAt: readOptionalNumberField(params.verification.security, "checkedAt") ?? null, + ...(pageUrl ? { skillUrl: pageUrl } : {}), + ...(pageUrl + ? { + securityAuditUrl: `${pageUrl}/security-audit?version=${encodeURIComponent(params.version)}`, + } + : {}), + security: mapSkillVerificationSecurityForVerdict(params.verification, { + allowCleanCardOnlyPass: cardOnlyCleanFailure, + }), + }; +} + +async function fetchOwnerQualifiedSkillSecurityFallback(params: { + subject: { + kind: "skill"; + packageName: string; + ownerHandle?: string; + }; + version: string; + baseUrl?: string; + token?: string; + timeoutMs?: number; +}): Promise { + const ownerHandle = params.subject.ownerHandle; + if (!ownerHandle) { + throw new Error("owner-qualified skill fallback requires ownerHandle"); + } + const verification = await fetchClawHubSkillVerification({ + slug: params.subject.packageName, + ownerHandle, + version: params.version, + baseUrl: params.baseUrl, + token: params.token, + timeoutMs: params.timeoutMs, + }); + const item = mapSkillVerificationToSecurityVerdictItem({ + verification, + slug: params.subject.packageName, + ownerHandle, + version: params.version, + }); + return { + security: mapSkillSecurityVerdictToPackageSecurity({ + item, + packageName: params.subject.packageName, + ownerHandle, + version: params.version, + }), + links: resolveSkillSecurityLinks(item), + }; +} + +async function fetchClawHubSubjectSecurity(params: { + subject: ClawHubTrustSubject; + version: string; + baseUrl?: string; + token?: string; + timeoutMs?: number; +}): Promise { + if (params.subject.kind === "plugin") { + return { + security: await fetchClawHubPackageSecurity({ + name: params.subject.packageName, + version: params.version, + baseUrl: params.baseUrl, + token: params.token, + timeoutMs: params.timeoutMs, + }), + }; + } + const response = await fetchClawHubSkillSecurityVerdicts({ + items: [ + { + slug: params.subject.packageName, + ...(params.subject.ownerHandle ? { ownerHandle: params.subject.ownerHandle } : {}), + version: params.version, + }, + ], + baseUrl: params.baseUrl, + token: params.token, + timeoutMs: params.timeoutMs, + }); + if (response.items.length !== 1) { + throw new Error( + `ClawHub skill trust check for "${formatClawHubReleaseLabel(params.subject.packageName, params.version)}" returned ${response.items.length} verdicts.`, + ); + } + const item = response.items[0]; + if (!item) { + throw new Error( + `ClawHub skill trust check for "${formatClawHubReleaseLabel(params.subject.packageName, params.version)}" returned no verdict.`, + ); + } + if (params.subject.ownerHandle && isOwnerQualifiedSkillNotFoundVerdict(item)) { + return await fetchOwnerQualifiedSkillSecurityFallback({ + subject: { + kind: "skill", + packageName: params.subject.packageName, + ownerHandle: params.subject.ownerHandle, + }, + version: params.version, + baseUrl: params.baseUrl, + token: params.token, + timeoutMs: params.timeoutMs, + }); + } + return { + security: mapSkillSecurityVerdictToPackageSecurity({ + item, + packageName: params.subject.packageName, + ...(params.subject.ownerHandle ? { ownerHandle: params.subject.ownerHandle } : {}), + version: params.version, + }), + links: resolveSkillSecurityLinks(item), + }; +} + +export async function ensureClawHubPackageTrustAcknowledged(params: { + subject: ClawHubTrustSubject; + version: string; + baseUrl?: string; + token?: string; + timeoutMs?: number; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; + logger?: ClawHubInstallLogger; + mode?: "install" | "update"; +}): Promise { + let trust: ClawHubPackageSecurityTrust; + let warningLinks: ClawHubFetchedSubjectSecurity["links"]; + const packageLabel = formatClawHubSubjectPackageName(params.subject); + const releaseLabel = formatClawHubSubjectReleaseLabel(params.subject, params.version); + try { + const fetchedSecurity = await fetchClawHubSubjectSecurity({ + subject: params.subject, + version: params.version, + baseUrl: params.baseUrl, + token: params.token, + timeoutMs: params.timeoutMs, + }); + const identityFailure = validateClawHubSecurityIdentity({ + security: fetchedSecurity.security, + packageName: params.subject.packageName, + packageLabel, + version: params.version, + }); + if (identityFailure) { + return identityFailure; + } + trust = fetchedSecurity.security.trust; + warningLinks = fetchedSecurity.links; + } catch (error) { + return { + ok: false, + error: `ClawHub release trust check failed for "${releaseLabel}": ${sanitizeTerminalText(formatErrorMessage(error))}`, + code: CLAWHUB_TRUST_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE, + version: params.version, + }; + } + + const assessment = assessClawHubTrust(trust); + const checkedAt = new Date().toISOString(); + const acceptTrust = (opts?: { + acknowledgedAt?: string; + warning?: string; + }): ClawHubTrustAcceptedResult => ({ + ok: true, + trustInstallRecordFields: buildClawHubTrustInstallRecordFields({ + trust, + assessment, + checkedAt, + ...(opts?.acknowledgedAt ? { acknowledgedAt: opts.acknowledgedAt } : {}), + }), + ...(opts?.warning ? { warning: opts.warning } : {}), + }); + if (assessment.disposition === "clean") { + return acceptTrust(); + } + + const terminalWarning = formatClawHubTrustWarning({ + baseUrl: params.baseUrl, + subject: params.subject, + version: params.version, + trust, + assessment, + mode: params.mode, + terminalLinks: params.logger?.terminalLinks, + links: warningLinks, + }); + const warning = stripAnsi( + formatClawHubTrustWarning({ + baseUrl: params.baseUrl, + subject: params.subject, + version: params.version, + trust, + assessment, + mode: params.mode, + terminalLinks: false, + links: warningLinks, + }), + ); + params.logger?.warn?.(terminalWarning); + if (assessment.disposition === "review-recommended") { + return acceptTrust({ warning }); + } + if (assessment.disposition === "blocked") { + const blockedVerb = params.mode === "update" ? "update" : "install"; + return { + ok: false, + error: `ClawHub blocked this release; ${blockedVerb} was not started.`, + code: CLAWHUB_TRUST_ERROR_CODE.CLAWHUB_DOWNLOAD_BLOCKED, + warning, + version: params.version, + }; + } + if (params.acknowledgeClawHubRisk) { + return acceptTrust({ acknowledgedAt: new Date().toISOString(), warning }); + } + + const acknowledged = params.onClawHubRisk + ? await params.onClawHubRisk({ + packageName: packageLabel, + version: params.version, + trust, + acknowledgementKind: + assessment.disposition === "review-required" ? "type-package" : "confirm", + warning, + }) + : false; + if (acknowledged) { + return acceptTrust({ acknowledgedAt: new Date().toISOString(), warning }); + } + return { + ok: false, + error: `${params.mode === "update" ? "Update" : "Install"} cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning.`, + code: CLAWHUB_TRUST_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED, + warning, + version: params.version, + }; +} diff --git a/src/infra/clawhub.test.ts b/src/infra/clawhub.test.ts index 6d228d5a5922..2fcaa0811134 100644 --- a/src/infra/clawhub.test.ts +++ b/src/infra/clawhub.test.ts @@ -16,6 +16,7 @@ import { fetchClawHubSkillCard, fetchClawHubSkillSecurityVerdicts, fetchClawHubPackageArtifact, + fetchClawHubPackageSecurity, fetchClawHubSkillVerification, normalizeClawHubSha256Integrity, normalizeClawHubSha256Hex, @@ -687,6 +688,85 @@ describe("clawhub helpers", () => { ); }); + it("fetches typed package security reports", async () => { + let requestedUrl = ""; + await expect( + fetchClawHubPackageSecurity({ + name: "@openclaw/diagnostics-otel", + version: "2026.3.22", + fetchImpl: async (input) => { + requestedUrl = input instanceof Request ? input.url : String(input); + return new Response( + JSON.stringify({ + package: { + name: "@openclaw/diagnostics-otel", + displayName: "Diagnostics", + family: "code-plugin", + }, + release: { + releaseId: "rel_demo", + version: "2026.3.22", + }, + trust: { + scanStatus: "clean", + moderationState: null, + blockedFromDownload: false, + reasons: [], + pending: false, + stale: true, + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }, + }), + ).resolves.toEqual({ + package: { + name: "@openclaw/diagnostics-otel", + displayName: "Diagnostics", + family: "code-plugin", + }, + release: { + id: "rel_demo", + version: "2026.3.22", + }, + trust: { + scanStatus: "clean", + moderationState: null, + blockedFromDownload: false, + reasons: [], + pending: false, + stale: true, + }, + }); + expect(new URL(requestedUrl).pathname).toBe( + "/api/v1/packages/%40openclaw%2Fdiagnostics-otel/versions/2026.3.22/security", + ); + }); + + it("rejects malformed package security reports", async () => { + await expect( + fetchClawHubPackageSecurity({ + name: "@openclaw/diagnostics-otel", + version: "2026.3.22", + fetchImpl: async () => + new Response( + JSON.stringify({ + trust: { + scanStatus: "clean", + moderationState: null, + blockedFromDownload: false, + reasons: "clean", + pending: false, + stale: false, + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + }), + ).rejects.toThrow("expected reasons to be a string array"); + }); + it("downloads package archives to sanitized temp paths and cleans them up", async () => { const archive = await downloadClawHubPackageArchive({ name: "@hyf/zai-external-alpha", diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts index 48d9984f97bd..979bc522757f 100644 --- a/src/infra/clawhub.ts +++ b/src/infra/clawhub.ts @@ -76,6 +76,14 @@ export type ClawHubArtifactScanState = | "not-run" | (string & {}); export type ClawHubArtifactModerationState = "approved" | "quarantined" | "revoked" | (string & {}); +export type ClawHubPackageSecurityTrust = { + scanStatus?: ClawHubArtifactScanState | null; + moderationState?: ClawHubArtifactModerationState | null; + blockedFromDownload: boolean; + reasons: string[]; + pending: boolean; + stale: boolean; +}; export type ClawHubResolvedArtifact = | { source: "clawhub"; @@ -121,6 +129,25 @@ export type ClawHubPackageArtifactResolverResponse = { | null; artifact?: ClawHubResolvedArtifact | null; }; +export type ClawHubPackageSecurityResponse = { + package?: { + name?: string | null; + displayName?: string | null; + family?: ClawHubPackageFamily | (string & {}) | null; + } | null; + release?: { + id?: string | null; + version?: string | null; + } | null; + trust: ClawHubPackageSecurityTrust; +}; +export type ClawHubPackageReadiness = { + ok?: boolean; + ready?: boolean; + status?: string | null; + reasons?: string[]; + checks?: Record; +} & Record; export type ClawHubPackageClawPackSummary = { available: boolean; specVersion?: number | null; @@ -250,6 +277,8 @@ export type ClawHubSkillDetail = { displayName: string; summary?: string; tags?: Record; + channel?: string | null; + isOfficial?: boolean | null; createdAt: number; updatedAt: number; } | null; @@ -266,6 +295,9 @@ export type ClawHubSkillDetail = { handle?: string | null; displayName?: string | null; image?: string | null; + official?: boolean | null; + channel?: string | null; + isOfficial?: boolean | null; } | null; }; @@ -273,16 +305,23 @@ export type ClawHubSkillInstallResolutionResponse = | { ok: true; slug: string; + channel?: string | null; + isOfficial?: boolean | null; installKind: "archive"; archive: { version: string; downloadUrl: string; + channel?: string | null; + isOfficial?: boolean | null; }; } | { ok: true; slug: string; + channel?: string | null; + isOfficial?: boolean | null; installKind: "github"; + /** Commit-pinned source approved by ClawHub's install resolver policy. */ github: { repo: string; path: string; @@ -306,6 +345,12 @@ export type ClawHubSkillVerificationResponse = { ok: boolean; decision: ClawHubSkillVerificationDecision; reasons: string[]; + slug?: string | null; + displayName?: string | null; + pageUrl?: string | null; + publisherHandle?: string | null; + publisherDisplayName?: string | null; + createdAt?: number | null; skill: unknown; publisher: unknown; version: unknown; @@ -318,6 +363,7 @@ export type ClawHubSkillVerificationResponse = { export type ClawHubSkillSecurityVerdictRequestItem = { slug: string; + ownerHandle?: string; version: string; }; @@ -772,6 +818,128 @@ async function readClawHubResponseBytes(params: { }); } +function isJsonObject(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function optionalStringField( + source: Record, + field: string, + context: string, +): string | null | undefined { + const value = source[field]; + if (value === undefined || value === null || typeof value === "string") { + return value; + } + throw new Error(`Malformed ClawHub ${context}: expected ${field} to be a string or null.`); +} + +function requiredBooleanField( + source: Record, + field: string, + context: string, +): boolean { + const value = source[field]; + if (typeof value === "boolean") { + return value; + } + throw new Error(`Malformed ClawHub ${context}: expected ${field} to be a boolean.`); +} + +function requiredStringArrayField( + source: Record, + field: string, + context: string, +): string[] { + const value = source[field]; + if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) { + return value; + } + throw new Error(`Malformed ClawHub ${context}: expected ${field} to be a string array.`); +} + +function parseOptionalSecurityPackage(value: unknown): ClawHubPackageSecurityResponse["package"] { + if (value === undefined || value === null) { + return value; + } + if (!isJsonObject(value)) { + throw new Error( + "Malformed ClawHub security response: expected package to be an object or null.", + ); + } + const result: NonNullable = {}; + const name = optionalStringField(value, "name", "security package"); + const displayName = optionalStringField(value, "displayName", "security package"); + const family = optionalStringField(value, "family", "security package"); + if (name !== undefined) { + result.name = name; + } + if (displayName !== undefined) { + result.displayName = displayName; + } + if (family !== undefined) { + result.family = family; + } + return result; +} + +function parseOptionalSecurityRelease(value: unknown): ClawHubPackageSecurityResponse["release"] { + if (value === undefined || value === null) { + return value; + } + if (!isJsonObject(value)) { + throw new Error( + "Malformed ClawHub security response: expected release to be an object or null.", + ); + } + const result: NonNullable = {}; + const releaseId = optionalStringField(value, "releaseId", "security release"); + const legacyId = optionalStringField(value, "id", "security release"); + const version = optionalStringField(value, "version", "security release"); + const id = releaseId ?? legacyId; + if (id !== undefined) { + result.id = id; + } + if (version !== undefined) { + result.version = version; + } + return result; +} + +function parseClawHubPackageSecurityResponse(value: unknown): ClawHubPackageSecurityResponse { + if (!isJsonObject(value)) { + throw new Error("Malformed ClawHub security response: expected an object."); + } + const trust = value.trust; + if (!isJsonObject(trust)) { + throw new Error("Malformed ClawHub security response: expected trust to be an object."); + } + const parsedTrust: ClawHubPackageSecurityTrust = { + blockedFromDownload: requiredBooleanField(trust, "blockedFromDownload", "security trust"), + reasons: requiredStringArrayField(trust, "reasons", "security trust"), + pending: requiredBooleanField(trust, "pending", "security trust"), + stale: requiredBooleanField(trust, "stale", "security trust"), + }; + const scanStatus = optionalStringField(trust, "scanStatus", "security trust"); + const moderationState = optionalStringField(trust, "moderationState", "security trust"); + if (scanStatus !== undefined) { + parsedTrust.scanStatus = scanStatus; + } + if (moderationState !== undefined) { + parsedTrust.moderationState = moderationState; + } + const result: ClawHubPackageSecurityResponse = { trust: parsedTrust }; + const parsedPackage = parseOptionalSecurityPackage(value.package); + const parsedRelease = parseOptionalSecurityRelease(value.release); + if (parsedPackage !== undefined) { + result.package = parsedPackage; + } + if (parsedRelease !== undefined) { + result.release = parsedRelease; + } + return result; +} + /** Resolves the configured ClawHub base URL, falling back to the default public host. */ export function resolveClawHubBaseUrl(baseUrl?: string): string { return normalizeBaseUrl(baseUrl); @@ -931,6 +1099,42 @@ export async function fetchClawHubPackageArtifact(params: { }); } +export async function fetchClawHubPackageSecurity(params: { + name: string; + version: string; + baseUrl?: string; + token?: string; + timeoutMs?: number; + fetchImpl?: FetchLike; +}): Promise { + const response = await fetchJson({ + baseUrl: params.baseUrl, + path: `/api/v1/packages/${encodeURIComponent(params.name)}/versions/${encodeURIComponent( + params.version, + )}/security`, + token: params.token, + timeoutMs: params.timeoutMs, + fetchImpl: params.fetchImpl, + }); + return parseClawHubPackageSecurityResponse(response); +} + +export async function fetchClawHubPackageReadiness(params: { + name: string; + baseUrl?: string; + token?: string; + timeoutMs?: number; + fetchImpl?: FetchLike; +}): Promise { + return await fetchJson({ + baseUrl: params.baseUrl, + path: `/api/v1/packages/${encodeURIComponent(params.name)}/readiness`, + token: params.token, + timeoutMs: params.timeoutMs, + fetchImpl: params.fetchImpl, + }); +} + export async function searchClawHubPackages(params: { query: string; family?: ClawHubPackageFamily; diff --git a/src/infra/command-analysis/inline-eval.test.ts b/src/infra/command-analysis/inline-eval.test.ts index 8aef4b15ea70..10ba2c8cd636 100644 --- a/src/infra/command-analysis/inline-eval.test.ts +++ b/src/infra/command-analysis/inline-eval.test.ts @@ -18,8 +18,14 @@ function expectInlineEvalDescription(hit: InterpreterInlineEvalHit | null, expec describe("exec inline eval detection", () => { it.each([ { argv: ["python3", "-c", "print('hi')"], expected: "python3 -c" }, + { argv: ["python3.13", "-c", "print('hi')"], expected: "python3.13 -c" }, + { argv: ["/usr/bin/pypy3.10", "-c", "print('hi')"], expected: "pypy3.10 -c" }, { argv: ["/usr/bin/node", "--eval", "console.log('hi')"], expected: "node --eval" }, { argv: ["perl", "-E", "say 1"], expected: "perl -e" }, + { argv: ["php", "-B", "system('id');"], expected: "php -B" }, + { argv: ["php", "-E", "system('id');"], expected: "php -E" }, + { argv: ["php", "-R", "system('id');"], expected: "php -R" }, + { argv: ["Rscript", "-e", "system('id')"], expected: "rscript -e" }, { argv: ["osascript", "-e", "beep"], expected: "osascript -e" }, { argv: ["awk", "BEGIN { print 1 }"], expected: "awk inline program" }, { argv: ["gawk", "-F", ",", "{print $1}", "data.csv"], expected: "gawk inline program" }, @@ -60,7 +66,11 @@ describe("exec inline eval detection", () => { it("ignores normal script execution", () => { expect(detectInterpreterInlineEvalArgv(["python3", "script.py"])).toBeNull(); + expect(detectInterpreterInlineEvalArgv(["python3.13", "script.py"])).toBeNull(); expect(detectInterpreterInlineEvalArgv(["node", "script.js"])).toBeNull(); + expect(detectInterpreterInlineEvalArgv(["php", "-F", "filter.php"])).toBeNull(); + expect(detectInterpreterInlineEvalArgv(["Rscript", "script.R"])).toBeNull(); + expect(detectInterpreterInlineEvalArgv(["r2", "-e", "bin.cache=true", "program"])).toBeNull(); expect(detectInterpreterInlineEvalArgv(["awk", "-f", "script.awk", "data.csv"])).toBeNull(); expect(detectInterpreterInlineEvalArgv(["find", ".", "-name", "*.ts"])).toBeNull(); expect(detectInterpreterInlineEvalArgv(["xargs", "-0"])).toBeNull(); @@ -76,7 +86,12 @@ describe("exec inline eval detection", () => { it("matches interpreter-like allowlist patterns", () => { expect(isInterpreterLikeAllowlistPattern("/usr/bin/python3")).toBe(true); + expect(isInterpreterLikeAllowlistPattern("/usr/bin/python3.13")).toBe(true); + expect(isInterpreterLikeAllowlistPattern("python3.*")).toBe(true); + expect(isInterpreterLikeAllowlistPattern("pypy3.10")).toBe(true); expect(isInterpreterLikeAllowlistPattern("**/node")).toBe(true); + expect(isInterpreterLikeAllowlistPattern("Rscript")).toBe(true); + expect(isInterpreterLikeAllowlistPattern("r2")).toBe(false); expect(isInterpreterLikeAllowlistPattern("/usr/bin/awk")).toBe(true); expect(isInterpreterLikeAllowlistPattern("**/gawk")).toBe(true); expect(isInterpreterLikeAllowlistPattern("/usr/bin/mawk")).toBe(true); diff --git a/src/infra/command-analysis/inline-eval.ts b/src/infra/command-analysis/inline-eval.ts index e8093660fae5..842585c94b24 100644 --- a/src/infra/command-analysis/inline-eval.ts +++ b/src/infra/command-analysis/inline-eval.ts @@ -34,6 +34,8 @@ type PositionalInterpreterSpec = { flag: "" | ""; }; +const VERSION_SUFFIX_PATTERN = /-?\d+(?:\.\d+)*$/; + const FLAG_INTERPRETER_INLINE_EVAL_SPECS: readonly InterpreterFlagSpec[] = [ { names: ["python", "python2", "python3", "pypy", "pypy3"], exactFlags: new Set(["-c"]) }, { @@ -47,7 +49,16 @@ const FLAG_INTERPRETER_INLINE_EVAL_SPECS: readonly InterpreterFlagSpec[] = [ }, { names: ["ruby"], exactFlags: new Set(["-e"]) }, { names: ["perl"], exactFlags: new Set(["-e", "-E"]) }, - { names: ["php"], exactFlags: new Set(["-r"]) }, + { + names: ["php"], + exactFlags: new Set(["-r"]), + rawExactFlags: new Map([ + ["-B", "-B"], + ["-E", "-E"], + ["-R", "-R"], + ]), + }, + { names: ["r", "rscript"], exactFlags: new Set(["-e"]) }, { names: ["lua"], exactFlags: new Set(["-e"]) }, { names: ["osascript"], exactFlags: new Set(["-e"]) }, { @@ -154,10 +165,28 @@ const INTERPRETER_ALLOWLIST_NAMES = new Set( ), ); +function stripInterpreterVersionSuffix(value: string): string { + const stripped = value.replace(VERSION_SUFFIX_PATTERN, ""); + return stripped.length > 0 ? stripped : value; +} + +function interpreterNameVariants(value: string): readonly string[] { + const stripped = stripInterpreterVersionSuffix(value); + // Do not synthesize one-letter interpreter names: commands like r2 can use + // their own eval flags and must not be mistaken for R inline execution. + return stripped === value || stripped.length < 2 ? [value] : [value, stripped]; +} + +function specNamesInclude(names: readonly string[], normalizedExecutable: string): boolean { + return interpreterNameVariants(normalizedExecutable).some((candidate) => + names.includes(candidate), + ); +} + function findInterpreterSpec(executable: string): InterpreterFlagSpec | null { const normalized = normalizeExecutableToken(executable); for (const spec of FLAG_INTERPRETER_INLINE_EVAL_SPECS) { - if (spec.names.includes(normalized)) { + if (specNamesInclude(spec.names, normalized)) { return spec; } } @@ -167,7 +196,7 @@ function findInterpreterSpec(executable: string): InterpreterFlagSpec | null { function findPositionalInterpreterSpec(executable: string): PositionalInterpreterSpec | null { const normalized = normalizeExecutableToken(executable); for (const spec of POSITIONAL_INTERPRETER_INLINE_EVAL_SPECS) { - if (spec.names.includes(normalized)) { + if (specNamesInclude(spec.names, normalized)) { return spec; } } @@ -299,11 +328,17 @@ export function isInterpreterLikeAllowlistPattern(pattern: string | undefined | return false; } const normalized = normalizeExecutableToken(trimmed); - if (INTERPRETER_ALLOWLIST_NAMES.has(normalized)) { + if ( + interpreterNameVariants(normalized).some((candidate) => + INTERPRETER_ALLOWLIST_NAMES.has(candidate), + ) + ) { return true; } const basename = trimmed.replace(/\\/g, "/").split("/").pop() ?? trimmed; const withoutExe = basename.endsWith(".exe") ? basename.slice(0, -4) : basename; - const strippedWildcards = withoutExe.replace(/[*?[\]{}()]/g, ""); - return INTERPRETER_ALLOWLIST_NAMES.has(strippedWildcards); + const strippedWildcards = withoutExe.replace(/[*?[\]{}()]/g, "").replace(/[.-]+$/, ""); + return interpreterNameVariants(strippedWildcards).some((candidate) => + INTERPRETER_ALLOWLIST_NAMES.has(candidate), + ); } diff --git a/src/infra/openclaw-exec-env.test.ts b/src/infra/openclaw-exec-env.test.ts index 15813bb8d098..011ef0d76b26 100644 --- a/src/infra/openclaw-exec-env.test.ts +++ b/src/infra/openclaw-exec-env.test.ts @@ -1,5 +1,6 @@ // Tests OpenClaw execution environment construction. import { describe, expect, it } from "vitest"; +import { deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js"; import { ensureOpenClawExecMarkerOnProcess, markOpenClawExecEnv, @@ -38,16 +39,16 @@ describe("ensureOpenClawExecMarkerOnProcess", () => { it("defaults to mutating process.env when no env object is provided", () => { const previous = process.env[OPENCLAW_CLI_ENV_VAR]; - delete process.env[OPENCLAW_CLI_ENV_VAR]; + deleteTestEnvValue(OPENCLAW_CLI_ENV_VAR); try { expect(ensureOpenClawExecMarkerOnProcess()).toBe(process.env); expect(process.env[OPENCLAW_CLI_ENV_VAR]).toBe(OPENCLAW_CLI_ENV_VALUE); } finally { if (previous === undefined) { - delete process.env[OPENCLAW_CLI_ENV_VAR]; + deleteTestEnvValue(OPENCLAW_CLI_ENV_VAR); } else { - process.env[OPENCLAW_CLI_ENV_VAR] = previous; + setTestEnvValue(OPENCLAW_CLI_ENV_VAR, previous); } } }); diff --git a/src/infra/outbound/agent-delivery.test.ts b/src/infra/outbound/agent-delivery.test.ts index e5c217b3548f..cb9616dc44dd 100644 --- a/src/infra/outbound/agent-delivery.test.ts +++ b/src/infra/outbound/agent-delivery.test.ts @@ -4,6 +4,15 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ resolveOutboundChannelPlugin: vi.fn<() => unknown>(() => null), + resolveChannelTarget: vi.fn<() => Promise>(async () => ({ + ok: true, + target: { + to: "+1999", + kind: "group", + source: "normalized", + resolutionSource: "normalized", + }, + })), resolveOutboundTarget: vi.fn<() => { ok: true; to: string } | { ok: false; error: Error }>( () => ({ ok: true, to: "+1999" }), ), @@ -82,11 +91,16 @@ vi.mock("./outbound-session.js", () => ({ resolveOutboundSessionRoute: mocks.resolveOutboundSessionRoute, })); +vi.mock("./target-resolver.js", () => ({ + resolveChannelTarget: mocks.resolveChannelTarget, +})); + vi.mock("../../utils/message-channel.js", () => ({ INTERNAL_MESSAGE_CHANNEL: "webchat", - isDeliverableMessageChannel: (channel: string) => ["directchat", "workspace"].includes(channel), + isDeliverableMessageChannel: (channel: string) => + ["directchat", "workspace", "telegram"].includes(channel), isGatewayMessageChannel: (channel: string) => - ["directchat", "workspace", "webchat"].includes(channel), + ["directchat", "workspace", "telegram", "webchat"].includes(channel), normalizeMessageChannel: (value: string) => value.trim().toLowerCase(), })); @@ -106,7 +120,18 @@ beforeAll(async () => { beforeEach(() => { mocks.resolveOutboundChannelPlugin.mockReset(); mocks.resolveOutboundChannelPlugin.mockReturnValue(null); - mocks.resolveOutboundTarget.mockClear(); + mocks.resolveChannelTarget.mockReset(); + mocks.resolveChannelTarget.mockResolvedValue({ + ok: true, + target: { + to: "+1999", + kind: "group", + source: "normalized", + resolutionSource: "normalized", + }, + }); + mocks.resolveOutboundTarget.mockReset(); + mocks.resolveOutboundTarget.mockReturnValue({ ok: true, to: "+1999" }); mocks.resolveOutboundSessionRoute.mockReset(); mocks.resolveOutboundSessionRoute.mockResolvedValue(null); mocks.resolveSessionDeliveryTarget.mockClear(); @@ -313,6 +338,181 @@ describe("agent delivery helpers", () => { expect(plan.resolvedTo).toBe("1470130713209602050"); }); + it("resolves reserved explicit targets through directory-capable resolution before session routing", async () => { + mocks.resolveOutboundChannelPlugin.mockReturnValue({ + messaging: { resolveOutboundSessionRoute: vi.fn(), targetResolver: {} }, + }); + mocks.resolveOutboundTarget.mockReturnValueOnce({ + ok: false, + error: new Error('Reserved target "current" for Telegram'), + }); + mocks.resolveChannelTarget.mockResolvedValueOnce({ + ok: true, + target: { + to: "telegram:-1002458651455", + kind: "group", + source: "directory", + resolutionSource: "directory", + }, + }); + mocks.resolveOutboundSessionRoute.mockResolvedValueOnce({ + sessionKey: "agent:telegram:group:-1002458651455", + baseSessionKey: "agent:telegram:group:-1002458651455", + peer: { kind: "group", id: "-1002458651455" }, + chatType: "group", + from: "telegram:group:-1002458651455", + to: "telegram:-1002458651455", + }); + + const plan = await resolveAgentDeliveryPlanWithSessionRoute({ + cfg: {} as OpenClawConfig, + agentId: "agent", + currentSessionKey: "agent:main", + sessionEntry: undefined, + requestedChannel: "telegram", + explicitTo: "current", + accountId: "work", + wantsDelivery: true, + }); + + expect(mocks.resolveChannelTarget).toHaveBeenCalledWith({ + cfg: {}, + channel: "telegram", + input: "current", + accountId: "work", + unknownTargetMode: "normalized", + plugin: { + messaging: { resolveOutboundSessionRoute: expect.any(Function), targetResolver: {} }, + }, + }); + expect(mocks.resolveOutboundSessionRoute).toHaveBeenCalledWith({ + cfg: {}, + channel: "telegram", + agentId: "agent", + accountId: "work", + target: "telegram:-1002458651455", + resolvedTarget: { + to: "telegram:-1002458651455", + kind: "group", + source: "directory", + resolutionSource: "directory", + }, + currentSessionKey: "agent:main", + threadId: undefined, + }); + expect(plan.resolvedTo).toBe("telegram:-1002458651455"); + expect(plan.targetResolutionError).toBeUndefined(); + }); + + it("keeps reserved explicit target errors when directory-capable resolution misses", async () => { + const reservedError = new Error('Reserved target "current" for Telegram'); + mocks.resolveOutboundChannelPlugin.mockReturnValue({ + messaging: { resolveOutboundSessionRoute: vi.fn(), targetResolver: {} }, + }); + mocks.resolveOutboundTarget.mockReturnValueOnce({ + ok: false, + error: reservedError, + }); + mocks.resolveChannelTarget.mockResolvedValueOnce({ + ok: false, + error: reservedError, + }); + + const plan = await resolveAgentDeliveryPlanWithSessionRoute({ + cfg: {} as OpenClawConfig, + agentId: "agent", + sessionEntry: undefined, + requestedChannel: "telegram", + explicitTo: "current", + accountId: undefined, + wantsDelivery: true, + }); + + expect(mocks.resolveChannelTarget).toHaveBeenCalledWith({ + cfg: {}, + channel: "telegram", + input: "current", + accountId: undefined, + unknownTargetMode: "normalized", + plugin: { + messaging: { resolveOutboundSessionRoute: expect.any(Function), targetResolver: {} }, + }, + }); + expect(mocks.resolveOutboundSessionRoute).not.toHaveBeenCalled(); + expect(plan.resolvedTo).toBe("current"); + expect(plan.targetResolutionError).toBe(reservedError); + }); + + it("keeps directory-resolved reserved explicit targets when session-route canonicalization misses", async () => { + mocks.resolveOutboundChannelPlugin.mockReturnValue({ + messaging: { resolveOutboundSessionRoute: vi.fn(), targetResolver: {} }, + }); + mocks.resolveOutboundTarget.mockReturnValueOnce({ + ok: false, + error: new Error('Reserved target "current" for Telegram'), + }); + mocks.resolveChannelTarget.mockResolvedValueOnce({ + ok: true, + target: { + to: "telegram:-1002458651455", + kind: "group", + source: "directory", + resolutionSource: "directory", + }, + }); + mocks.resolveOutboundSessionRoute.mockResolvedValueOnce(null); + + const plan = await resolveAgentDeliveryPlanWithSessionRoute({ + cfg: {} as OpenClawConfig, + agentId: "agent", + currentSessionKey: "agent:main", + sessionEntry: undefined, + requestedChannel: "telegram", + explicitTo: "current", + accountId: "work", + wantsDelivery: true, + }); + + expect(mocks.resolveOutboundSessionRoute).toHaveBeenCalledWith({ + cfg: {}, + channel: "telegram", + agentId: "agent", + accountId: "work", + target: "telegram:-1002458651455", + resolvedTarget: { + to: "telegram:-1002458651455", + kind: "group", + source: "directory", + resolutionSource: "directory", + }, + currentSessionKey: "agent:main", + threadId: undefined, + }); + expect(plan.resolvedTo).toBe("telegram:-1002458651455"); + expect(plan.targetResolutionError).toBeUndefined(); + }); + + it("surfaces stored explicit target errors even when explicit validation is disabled", () => { + const targetResolutionError = new Error('reserved target "current"'); + + const resolved = resolveAgentOutboundTarget({ + cfg: {} as OpenClawConfig, + plan: { + baseDelivery: { mode: "explicit" }, + resolvedChannel: "workspace", + resolvedTo: "current", + deliveryTargetMode: "explicit", + targetResolutionError, + }, + targetMode: "explicit", + validateExplicitTarget: false, + }); + + expect(mocks.resolveOutboundTarget).not.toHaveBeenCalled(); + expect(resolved.resolvedTarget).toEqual({ ok: false, error: targetResolutionError }); + expect(resolved.resolvedTo).toBeUndefined(); + }); + it("falls back to the original plan when session-route canonicalization fails", async () => { mocks.resolveOutboundChannelPlugin.mockReturnValue({ messaging: { resolveOutboundSessionRoute: vi.fn() }, diff --git a/src/infra/outbound/agent-delivery.ts b/src/infra/outbound/agent-delivery.ts index 6143ca964622..cf0bd6fe8e2d 100644 --- a/src/infra/outbound/agent-delivery.ts +++ b/src/infra/outbound/agent-delivery.ts @@ -15,6 +15,8 @@ import { } from "../../utils/message-channel.js"; import { resolveOutboundChannelPlugin } from "./channel-resolution.js"; import { resolveOutboundSessionRoute } from "./outbound-session.js"; +import { isReservedTargetLiteralError } from "./target-errors.js"; +import { resolveChannelTarget, type ResolvedMessagingTarget } from "./target-resolver.js"; import type { OutboundTargetResolution } from "./targets.js"; import { resolveOutboundTarget, @@ -29,6 +31,7 @@ export type AgentDeliveryPlan = { resolvedAccountId?: string; resolvedThreadId?: string | number; deliveryTargetMode?: ChannelOutboundTargetMode; + targetResolutionError?: Error; }; export function resolveAgentDeliveryPlan(params: { @@ -143,27 +146,46 @@ export async function resolveAgentDeliveryPlanWithSessionRoute( }, ): Promise { const plan = resolveAgentDeliveryPlan(params); - if ( - !params.wantsDelivery || - !plan.resolvedTo || - !isDeliverableMessageChannel(plan.resolvedChannel) || - !resolveOutboundChannelPlugin({ - channel: plan.resolvedChannel, - cfg: params.cfg, - allowBootstrap: true, - })?.messaging?.resolveOutboundSessionRoute - ) { + const { resolvedChannel, resolvedTo } = plan; + if (!params.wantsDelivery || !resolvedTo || !isDeliverableMessageChannel(resolvedChannel)) { + return plan; + } + const plugin = resolveOutboundChannelPlugin({ + channel: resolvedChannel, + cfg: params.cfg, + allowBootstrap: true, + }); + if (!plugin?.messaging?.resolveOutboundSessionRoute) { return plan; } const normalizedTarget = resolveOutboundTarget({ - channel: plan.resolvedChannel, - to: plan.resolvedTo, + channel: resolvedChannel, + to: resolvedTo, cfg: params.cfg, accountId: plan.resolvedAccountId, mode: plan.deliveryTargetMode ?? "explicit", }); - if (!normalizedTarget.ok) { - return plan; + let sessionRouteTarget: string; + let resolvedSessionRouteTarget: ResolvedMessagingTarget | undefined; + if (normalizedTarget.ok) { + sessionRouteTarget = normalizedTarget.to; + } else { + if (!isReservedTargetLiteralError(normalizedTarget.error)) { + return { ...plan, targetResolutionError: normalizedTarget.error }; + } + const resolvedTarget = await resolveChannelTarget({ + cfg: params.cfg, + channel: resolvedChannel as ChannelId, + input: resolvedTo, + accountId: plan.resolvedAccountId, + unknownTargetMode: "normalized", + plugin, + }); + if (!resolvedTarget.ok) { + return { ...plan, targetResolutionError: resolvedTarget.error }; + } + sessionRouteTarget = resolvedTarget.target.to; + resolvedSessionRouteTarget = resolvedTarget.target; } const explicitThreadId = params.explicitThreadId != null && params.explicitThreadId !== "" @@ -173,10 +195,11 @@ export async function resolveAgentDeliveryPlanWithSessionRoute( try { return await resolveOutboundSessionRoute({ cfg: params.cfg, - channel: plan.resolvedChannel as ChannelId, + channel: resolvedChannel as ChannelId, agentId: params.agentId, accountId: plan.resolvedAccountId, - target: normalizedTarget.to, + target: sessionRouteTarget, + ...(resolvedSessionRouteTarget ? { resolvedTarget: resolvedSessionRouteTarget } : {}), currentSessionKey: params.currentSessionKey, threadId: plan.deliveryTargetMode === "explicit" ? explicitThreadId : plan.resolvedThreadId, }); @@ -185,6 +208,14 @@ export async function resolveAgentDeliveryPlanWithSessionRoute( } })(); if (!route) { + if (resolvedSessionRouteTarget) { + return { + ...plan, + resolvedTo: resolvedSessionRouteTarget.to, + resolvedThreadId: + plan.deliveryTargetMode === "explicit" ? explicitThreadId : plan.resolvedThreadId, + }; + } return plan; } return { @@ -210,6 +241,13 @@ export function resolveAgentOutboundTarget(params: { params.targetMode ?? params.plan.deliveryTargetMode ?? (params.plan.resolvedTo ? "explicit" : "implicit"); + if (params.plan.targetResolutionError) { + return { + resolvedTarget: { ok: false, error: params.plan.targetResolutionError }, + resolvedTo: undefined, + targetMode, + }; + } if (!isDeliverableMessageChannel(params.plan.resolvedChannel)) { return { resolvedTarget: null, diff --git a/src/infra/outbound/deliver.queue-integration.test.ts b/src/infra/outbound/deliver.queue-integration.test.ts new file mode 100644 index 000000000000..cde71d07f864 --- /dev/null +++ b/src/infra/outbound/deliver.queue-integration.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { createEmptyPluginRegistry } from "../../plugins/registry.js"; +import { + releasePinnedPluginChannelRegistry, + setActivePluginRegistry, +} from "../../plugins/runtime.js"; +import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { drainPendingDeliveries, type DeliverFn, loadPendingDeliveries } from "./delivery-queue.js"; +import { + createRecoveryLog, + installDeliveryQueueTmpDirHooks, +} from "./delivery-queue.test-helpers.js"; + +let deliverOutboundPayloads: typeof import("./deliver.js").deliverOutboundPayloads; + +type MatrixSendFn = ( + to: string, + text: string, + options?: Record, +) => Promise<{ messageId: string } & Record>; + +function resolveMatrixSender( + deps: Parameters>[0]["deps"], +): MatrixSendFn { + const sender = deps?.matrix; + if (typeof sender !== "function") { + throw new Error("missing matrix sender"); + } + return sender as MatrixSendFn; +} + +function withMatrixChannel(result: Awaited>) { + return { + channel: "matrix" as const, + ...result, + }; +} + +const matrixOutboundForQueueTest: ChannelOutboundAdapter = { + deliveryMode: "direct", + sendText: async ({ cfg, to, text, accountId, deps }) => + withMatrixChannel( + await resolveMatrixSender(deps)(to, text, { + cfg, + accountId: accountId ?? undefined, + }), + ), +}; + +async function drainMatrixReconnect(opts: { deliver: DeliverFn; stateDir: string }): Promise { + await drainPendingDeliveries({ + drainKey: "matrix:reconnect-test", + logLabel: "Matrix reconnect drain", + cfg: {} as OpenClawConfig, + log: createRecoveryLog(), + stateDir: opts.stateDir, + deliver: opts.deliver, + selectEntry: (entry) => ({ match: entry.channel === "matrix" }), + }); +} + +function createPartialSendFailure() { + return vi + .fn() + .mockResolvedValueOnce({ messageId: "m1" }) + .mockRejectedValueOnce(new Error("second payload send failed")); +} + +async function deliverPartialMatrixBatch(sendMatrix: ReturnType, tmpDir: string) { + process.env.OPENCLAW_STATE_DIR = tmpDir; + await expect( + deliverOutboundPayloads({ + cfg: {} as OpenClawConfig, + channel: "matrix", + to: "!room:example", + payloads: [{ text: "first" }, { text: "second" }], + deps: { matrix: sendMatrix }, + queuePolicy: "required", + }), + ).rejects.toThrow("second payload send failed"); +} + +describe("deliverOutboundPayloads queue integration: mid-batch failure with send evidence", () => { + const fixtures = installDeliveryQueueTmpDirHooks(); + let tmpDir: string; + + beforeAll(async () => { + ({ deliverOutboundPayloads } = await import("./deliver.js")); + }); + + beforeEach(() => { + tmpDir = fixtures.tmpDir(); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "matrix", + source: "test", + plugin: createOutboundTestPlugin({ id: "matrix", outbound: matrixOutboundForQueueTest }), + }, + ]), + ); + }); + + afterEach(() => { + releasePinnedPluginChannelRegistry(); + setActivePluginRegistry(createEmptyPluginRegistry()); + }); + + it("advances queued entry to unknown_after_send when a later payload fails after an earlier one succeeded", async () => { + const sendMatrix = createPartialSendFailure(); + + await deliverPartialMatrixBatch(sendMatrix, tmpDir); + + const entries = await loadPendingDeliveries(tmpDir); + expect(entries).toHaveLength(1); + const entry = entries[0]; + expect(entry.recoveryState).toBe("unknown_after_send"); + expect(entry.retryCount).toBe(0); + expect(entry.lastError).toBeUndefined(); + expect(sendMatrix).toHaveBeenCalledTimes(2); + }); + + it("drain does not replay an unknown_after_send entry when no adapter reconciliation is available", async () => { + const sendMatrix = createPartialSendFailure(); + + await deliverPartialMatrixBatch(sendMatrix, tmpDir); + + const beforeDrain = await loadPendingDeliveries(tmpDir); + expect(beforeDrain[0]?.recoveryState).toBe("unknown_after_send"); + + const deliver = vi.fn(async () => {}); + await drainMatrixReconnect({ deliver, stateDir: tmpDir }); + + expect(deliver).not.toHaveBeenCalled(); + expect(await loadPendingDeliveries(tmpDir)).toHaveLength(0); + }); + + it("leaves entry for retry in send_attempt_started when no send evidence exists", async () => { + process.env.OPENCLAW_STATE_DIR = tmpDir; + const sendMatrix = vi.fn().mockRejectedValueOnce(new Error("first payload send failed")); + + await expect( + deliverOutboundPayloads({ + cfg: {} as OpenClawConfig, + channel: "matrix", + to: "!room:example", + payloads: [{ text: "first" }], + deps: { matrix: sendMatrix }, + queuePolicy: "required", + }), + ).rejects.toThrow("first payload send failed"); + + const entries = await import("./delivery-queue.js").then((m) => + m.loadPendingDeliveries(tmpDir), + ); + expect(entries).toHaveLength(1); + const entry = entries[0]; + expect(entry.retryCount).toBe(1); + expect(entry.recoveryState).toBe("send_attempt_started"); + expect(entry.lastError).toContain("first payload send failed"); + }); +}); diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 4d58a11c24da..83d3291b4b85 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -1045,6 +1045,51 @@ describe("deliverOutboundPayloads", () => { expect(queueMocks.ackDelivery).not.toHaveBeenCalled(); }); + it("marks queued delivery as unknown-after-send (not failed) when a later payload fails after an earlier one succeeded", async () => { + const sendMatrix = vi + .fn() + .mockResolvedValueOnce({ messageId: "m1" }) + .mockRejectedValueOnce(new Error("second payload send failed")); + + await expect( + deliverOutboundPayloads({ + cfg: {}, + channel: "matrix", + to: "!room:example", + payloads: [{ text: "first" }, { text: "second" }], + deps: { matrix: sendMatrix }, + queuePolicy: "required", + }), + ).rejects.toThrow("second payload send failed"); + + expect(sendMatrix).toHaveBeenCalledTimes(2); + expect(queueMocks.markDeliveryPlatformOutcomeUnknown).toHaveBeenCalledWith("mock-queue-id"); + expect(queueMocks.failDelivery).not.toHaveBeenCalled(); + expect(queueMocks.ackDelivery).not.toHaveBeenCalled(); + }); + + it("still calls failDelivery when a payload fails before any send succeeded", async () => { + const sendMatrix = vi.fn().mockRejectedValueOnce(new Error("first payload send failed")); + + await expect( + deliverOutboundPayloads({ + cfg: {}, + channel: "matrix", + to: "!room:example", + payloads: [{ text: "first" }], + deps: { matrix: sendMatrix }, + queuePolicy: "required", + }), + ).rejects.toThrow("first payload send failed"); + + expect(queueMocks.failDelivery).toHaveBeenCalledWith( + "mock-queue-id", + expect.stringContaining("first payload send failed"), + ); + expect(queueMocks.markDeliveryPlatformOutcomeUnknown).not.toHaveBeenCalled(); + expect(queueMocks.ackDelivery).not.toHaveBeenCalled(); + }); + it("fails required delivery when the post-send unknown marker cannot be written", async () => { queueMocks.markDeliveryPlatformOutcomeUnknown.mockRejectedValueOnce( new Error("unknown marker offline"), @@ -3553,6 +3598,54 @@ describe("deliverOutboundPayloads", () => { expect(mocks.appendAssistantMessageToSessionTranscript).not.toHaveBeenCalled(); }); + it("does not reuse a previous payload message id for a suppressed text send", async () => { + hookMocks.runner.hasHooks.mockReturnValue(true); + const sendText = vi + .fn() + .mockResolvedValueOnce({ channel: "matrix", messageId: "mx-1" }) + .mockResolvedValueOnce({ channel: "matrix", messageId: "" }); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "matrix", + source: "test", + plugin: createOutboundTestPlugin({ + id: "matrix", + outbound: { deliveryMode: "direct", sendText }, + }), + }, + ]), + ); + + const results = await deliverOutboundPayloads({ + cfg: {}, + channel: "matrix", + to: "!room:1", + payloads: [{ text: "first" }, { text: "second" }], + }); + + expect(results).toStrictEqual([{ channel: "matrix", messageId: "mx-1" }]); + expect(hookMocks.runner.runMessageSent).toHaveBeenCalledTimes(2); + expect(hookMocks.runner.runMessageSent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + content: "first", + success: true, + messageId: "mx-1", + }), + expect.objectContaining({ channelId: "matrix" }), + ); + expect(hookMocks.runner.runMessageSent).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + content: "second", + success: false, + }), + expect.objectContaining({ channelId: "matrix" }), + ); + expect(hookMocks.runner.runMessageSent.mock.calls[1]?.[0]).not.toHaveProperty("messageId"); + }); + it("emits message_sent success for sendPayload deliveries", async () => { hookMocks.runner.hasHooks.mockReturnValue(true); const sendPayload = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "mx-1" }); diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 1c1b9499c715..afb632b7ccb7 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -868,6 +868,23 @@ function hasDeliveryResultIdentity(result: OutboundDeliveryResult): boolean { ); } +function pushIdentifiedDeliveryResult( + results: OutboundDeliveryResult[], + delivery: OutboundDeliveryResult, +): boolean { + if (!hasDeliveryResultIdentity(delivery)) { + return false; + } + results.push(delivery); + return true; +} + +function filterIdentifiedDeliveryResults( + results: readonly OutboundDeliveryResult[], +): OutboundDeliveryResult[] { + return results.filter((result) => hasDeliveryResultIdentity(result)); +} + function normalizeDeliveryPin(payload: ReplyPayload): ReplyPayloadDeliveryPin | undefined { const pin = payload.delivery?.pin; if (pin === true) { @@ -1326,9 +1343,9 @@ async function deliverOutboundPayloadsWithQueueCleanup( }; const queuePolicy = params.queuePolicy ?? "best_effort"; let platformResultsReturned = false; + let platformSendStarted = false; try { - let platformSendStarted = false; const results = await deliverOutboundPayloadsCore({ ...wrappedParams, ...(queueId @@ -1390,11 +1407,29 @@ async function deliverOutboundPayloadsWithQueueCleanup( if (isDeliveryAbortError(err)) { await ackDelivery(queueId).catch(() => {}); } else if (!platformResultsReturned) { - await failDelivery(queueId, formatErrorMessage(err)).catch((failErr: unknown) => { - log.warn( - `failed to mark queued delivery ${queueId} as failed: ${formatErrorMessage(failErr)}`, - ); - }); + const sendEvidence = + platformSendStarted && err instanceof OutboundDeliveryError && err.sentBeforeError; + if (sendEvidence) { + await markQueuedPlatformOutcomeUnknown({ + queueId, + queuePolicy, + }).catch((markErr: unknown) => { + log.warn( + `failed to mark queued delivery ${queueId} as platform-outcome-unknown after mid-send error; falling back to fail: ${formatErrorMessage(markErr)}`, + ); + return failDelivery(queueId, formatErrorMessage(err)).catch((failErr: unknown) => { + log.warn( + `failed to mark queued delivery ${queueId} as failed: ${formatErrorMessage(failErr)}`, + ); + }); + }); + } else { + await failDelivery(queueId, formatErrorMessage(err)).catch((failErr: unknown) => { + log.warn( + `failed to mark queued delivery ${queueId} as failed: ${formatErrorMessage(failErr)}`, + ); + }); + } } } throw err; @@ -1511,7 +1546,7 @@ async function deliverOutboundPayloadsCore( continue; } throwIfAborted(abortSignal); - results.push(await sendHandler.sendText(unit.text, unit.overrides)); + pushIdentifiedDeliveryResult(results, await sendHandler.sendText(unit.text, unit.overrides)); } }; const normalizedPayloads = normalizePayloadsForChannelDelivery(outboundPayloadPlan, handler); @@ -1765,10 +1800,12 @@ async function deliverOutboundPayloadsCore( const beforeCount = results.length; if (deliveryHandler.sendFormattedText) { results.push( - ...(await deliveryHandler.sendFormattedText( - payloadSummary.text, - applySendReplyToConsumption(sendOverrides), - )), + ...filterIdentifiedDeliveryResults( + await deliveryHandler.sendFormattedText( + payloadSummary.text, + applySendReplyToConsumption(sendOverrides), + ), + ), ); } else { await sendTextChunks(deliveryHandler, payloadSummary.text, sendOverrides); @@ -1789,7 +1826,7 @@ async function deliverOutboundPayloadsCore( }), ); } - const messageId = results.at(-1)?.messageId; + const messageId = deliveredResults.at(-1)?.messageId; const pinMessageId = deliveredResults.find((entry) => entry.messageId)?.messageId; await maybePinDeliveredMessage({ handler: deliveryHandler, @@ -1806,7 +1843,7 @@ async function deliverOutboundPayloadsCore( }); completeDeliveryDiagnostics(deliveredResults.length); emitMessageSent({ - success: results.length > beforeCount, + success: deliveredResults.length > 0, content: payloadSummary.hookContent ?? payloadSummary.text, messageId, }); @@ -1846,7 +1883,7 @@ async function deliverOutboundPayloadsCore( }), ); } - const messageId = results.at(-1)?.messageId; + const messageId = deliveredResults.at(-1)?.messageId; const pinMessageId = deliveredResults.find((entry) => entry.messageId)?.messageId; await maybePinDeliveredMessage({ handler: deliveryHandler, @@ -1863,7 +1900,7 @@ async function deliverOutboundPayloadsCore( }); completeDeliveryDiagnostics(deliveredResults.length); emitMessageSent({ - success: results.length > beforeCount, + success: deliveredResults.length > 0, content: payloadSummary.hookContent ?? payloadSummary.text, messageId, }); @@ -1891,9 +1928,10 @@ async function deliverOutboundPayloadsCore( unit.overrides, ) : await deliveryHandler.sendMedia(unit.caption ?? "", unit.mediaUrl, unit.overrides); - results.push(delivery); - firstMessageId ??= delivery.messageId; - lastMessageId = delivery.messageId; + if (pushIdentifiedDeliveryResult(results, delivery)) { + firstMessageId ??= delivery.messageId; + lastMessageId = delivery.messageId; + } } await maybePinDeliveredMessage({ handler: deliveryHandler, @@ -1926,7 +1964,7 @@ async function deliverOutboundPayloadsCore( } completeDeliveryDiagnostics(results.length - beforeCount); emitMessageSent({ - success: true, + success: results.length > beforeCount, content: payloadSummary.hookContent ?? payloadSummary.text, messageId: lastMessageId, }); diff --git a/src/infra/outbound/delivery-queue-recovery.ts b/src/infra/outbound/delivery-queue-recovery.ts index 743f9064cddc..5a41b3bdc5e9 100644 --- a/src/infra/outbound/delivery-queue-recovery.ts +++ b/src/infra/outbound/delivery-queue-recovery.ts @@ -389,15 +389,19 @@ async function drainQueuedEntry(opts: { return "failed"; } } - if (reconciliation?.status === "not_sent") { + const reconciliationProvedPreSendFailure = + reconciliation?.status === "not_sent" && entry.recoveryState === "send_attempt_started"; + if (reconciliationProvedPreSendFailure) { opts.log.info( `Delivery entry ${entry.id} reconciled ${entry.recoveryState} as not sent; replaying`, ); } else { - const errMsg = - reconciliation?.status === "unresolved" && reconciliation.error - ? `delivery state is ${entry.recoveryState} and reconciliation is unresolved: ${reconciliation.error}` - : `delivery state is ${entry.recoveryState}; refusing blind replay without adapter reconciliation`; + let errMsg = `delivery state is ${entry.recoveryState}; refusing blind replay without adapter reconciliation`; + if (reconciliation?.status === "not_sent") { + errMsg = `delivery state is ${entry.recoveryState}; refusing full replay after post-send evidence`; + } else if (reconciliation?.status === "unresolved" && reconciliation.error) { + errMsg = `delivery state is ${entry.recoveryState} and reconciliation is unresolved: ${reconciliation.error}`; + } opts.log.warn(`Delivery entry ${entry.id} ${errMsg}`); opts.onFailed?.(entry, errMsg); if (reconciliation?.status === "unresolved" && reconciliation.retryable === true) { diff --git a/src/infra/outbound/delivery-queue.recovery.test.ts b/src/infra/outbound/delivery-queue.recovery.test.ts index 2425d728851e..eecc8bf8c8e1 100644 --- a/src/infra/outbound/delivery-queue.recovery.test.ts +++ b/src/infra/outbound/delivery-queue.recovery.test.ts @@ -328,7 +328,7 @@ describe("delivery-queue recovery", () => { expect(await loadPendingDeliveries(tmpDir())).toHaveLength(0); }); - it("replays unknown-after-send entries only after adapter proves they were not sent", async () => { + it("moves unknown-after-send entries to failed when adapter reports not sent", async () => { const id = await enqueueDelivery( { channel: "demo-channel-a", to: "+1", payloads: [{ text: "not sent" }] }, tmpDir(), @@ -346,24 +346,19 @@ describe("delivery-queue recovery", () => { }); const deliver = vi.fn().mockResolvedValue([]); - const { result } = await runRecovery({ deliver }); + const log = createRecoveryLog(); + const { result } = await runRecovery({ deliver, log }); - expect(deliver).toHaveBeenCalledTimes(1); - const deliverInput = mockCallArg(deliver) as { - channel?: string; - to?: string; - skipQueue?: boolean; - }; - expect(deliverInput.channel).toBe("demo-channel-a"); - expect(deliverInput.to).toBe("+1"); - expect(deliverInput.skipQueue).toBe(true); + expect(deliver).not.toHaveBeenCalled(); expect(result).toEqual({ - recovered: 1, - failed: 0, + recovered: 0, + failed: 1, skippedMaxRetries: 0, deferredBackoff: 0, }); expect(await loadPendingDeliveries(tmpDir())).toHaveLength(0); + expect(readOutboundQueueStatus(tmpDir(), id)).toBe("failed"); + expectMockMessageContaining(log.warn, "refusing full replay after post-send evidence"); }); it("keeps retryable unresolved unknown-after-send entries on the queue without replaying", async () => { diff --git a/src/infra/outbound/target-errors.test.ts b/src/infra/outbound/target-errors.test.ts index cb0240828ff2..343fa5dce4cd 100644 --- a/src/infra/outbound/target-errors.test.ts +++ b/src/infra/outbound/target-errors.test.ts @@ -3,8 +3,10 @@ import { describe, expect, it } from "vitest"; import { ambiguousTargetError, ambiguousTargetMessage, + isReservedTargetLiteralError, missingTargetError, missingTargetMessage, + reservedTargetLiteralError, unknownTargetError, unknownTargetMessage, } from "./target-errors.js"; @@ -69,4 +71,13 @@ describe("target error helpers", () => { "Hint: Use channel:123", ); }); + + it("identifies reserved target literal errors", () => { + expect(isReservedTargetLiteralError(reservedTargetLiteralError("Telegram", "current"))).toBe( + true, + ); + expect(isReservedTargetLiteralError(new Error('Unknown target "current" for Telegram.'))).toBe( + false, + ); + }); }); diff --git a/src/infra/outbound/target-errors.ts b/src/infra/outbound/target-errors.ts index 8f3a858dd9f3..bbb312cda386 100644 --- a/src/infra/outbound/target-errors.ts +++ b/src/infra/outbound/target-errors.ts @@ -40,6 +40,18 @@ export function unknownTargetError(provider: string, raw: string, hint?: string) return new Error(unknownTargetMessage(provider, raw, hint)); } +export function reservedTargetLiteralMessage(provider: string, raw: string, hint?: string): string { + return `Reserved target "${raw}" for ${provider} cannot be used as a literal destination. Provide an explicit id or handle.${formatTargetHint(hint, true)}`; +} + +export function reservedTargetLiteralError(provider: string, raw: string, hint?: string): Error { + return new Error(reservedTargetLiteralMessage(provider, raw, hint)); +} + +export function isReservedTargetLiteralError(error: Error): boolean { + return error.message.includes("Reserved target"); +} + function formatTargetHint(hint?: string, withLabel = false): string { const normalized = hint?.trim(); if (!normalized) { diff --git a/src/infra/outbound/target-normalization.ts b/src/infra/outbound/target-normalization.ts index e3af121e89ca..fe6a55918bf6 100644 --- a/src/infra/outbound/target-normalization.ts +++ b/src/infra/outbound/target-normalization.ts @@ -32,6 +32,52 @@ function resolveChannelPluginForTargetRead(channelId: ChannelId): ChannelPlugin return getLoadedChannelPluginForRead(channelId) ?? getChannelPlugin(channelId); } +function normalizeTargetLiteral(value: string): string | undefined { + return normalizeOptionalLowercaseString(value); +} + +function stripPluginTargetPrefix(raw: string, plugin: ChannelPlugin): string { + let target = raw.trim(); + const prefixes = [plugin.id, ...(plugin.messaging?.targetPrefixes ?? [])] + .map((prefix) => normalizeTargetLiteral(String(prefix))) + .filter((prefix): prefix is string => Boolean(prefix)); + while (target) { + const lowered = normalizeTargetLiteral(target) ?? ""; + const prefix = prefixes.find((candidate) => lowered.startsWith(`${candidate}:`)); + if (!prefix) { + return target; + } + target = target.slice(prefix.length + 1).trim(); + } + return target; +} + +export function resolveReservedTargetLiteral(params: { + raw?: string; + plugin?: ChannelPlugin; +}): string | undefined { + const raw = normalizeOptionalString(params.raw); + const plugin = params.plugin; + const reservedLiterals = plugin?.messaging?.targetResolver?.reservedLiterals; + if (!raw || !plugin || !reservedLiterals?.length) { + return undefined; + } + const stripped = stripPluginTargetPrefix(raw, plugin); + if (!stripped || /^[@#]/.test(stripped) || /^(channel|group|user):/i.test(stripped)) { + return undefined; + } + const normalized = normalizeTargetLiteral(stripped); + if (!normalized) { + return undefined; + } + const reserved = new Set( + reservedLiterals + .map(normalizeTargetLiteral) + .filter((literal): literal is string => Boolean(literal)), + ); + return reserved.has(normalized) ? normalized : undefined; +} + function resetTargetNormalizerCacheForTests(): void { targetNormalizerCacheByChannelId.clear(); } @@ -224,10 +270,15 @@ export function buildTargetResolverSignature( : "pinned"; const resolver = plugin?.messaging?.targetResolver; const hint = resolver?.hint ?? ""; + const reserved = (resolver?.reservedLiterals ?? []) + .map(normalizeTargetLiteral) + .filter((literal): literal is string => Boolean(literal)) + .toSorted() + .join(","); const looksLike = resolver?.looksLikeId; // Function source is only a cheap invalidation hint; resolver behavior still belongs to the plugin. const source = looksLike ? looksLike.toString() : ""; - return hashSignature(`${registryScope}|${hint}|${source}`); + return hashSignature(`${registryScope}|${hint}|${reserved}|${source}`); } function hashSignature(value: string): string { diff --git a/src/infra/outbound/target-resolver.test.ts b/src/infra/outbound/target-resolver.test.ts index a5b2b77ea7ab..2ff3d34afde9 100644 --- a/src/infra/outbound/target-resolver.test.ts +++ b/src/infra/outbound/target-resolver.test.ts @@ -130,6 +130,206 @@ describe("resolveMessagingTarget (directory fallback)", () => { expect(mocks.listGroupsLive).toHaveBeenCalledTimes(1); }); + it("preserves configured directory entries before rejecting reserved literal targets", async () => { + mocks.getChannelPlugin.mockReturnValue({ + ...createChannelTestPluginBase({ + id: "telegram", + label: "Telegram", + capabilities: { chatTypes: ["direct", "group", "channel"] }, + }), + directory: { + listPeers: mocks.listPeers, + listPeersLive: mocks.listPeersLive, + listGroups: mocks.listGroups, + listGroupsLive: mocks.listGroupsLive, + }, + messaging: { + targetResolver: { + reservedLiterals: ["current", "self", "this", "me"], + hint: "", + resolveTarget: mocks.resolveTarget, + }, + }, + }); + mocks.listGroups.mockResolvedValue([ + { + kind: "group", + id: "-1002458651455", + name: "Current x jerry Channel", + handle: "@current", + } satisfies ChannelDirectoryEntry, + ]); + + const result = await resolveMessagingTarget({ + cfg, + channel: "telegram", + input: "current", + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.target.to).toBe("-1002458651455"); + expect(result.target.source).toBe("directory"); + } + expect(mocks.listGroups).toHaveBeenCalled(); + expect(mocks.resolveTarget).not.toHaveBeenCalled(); + }); + + it("keeps reserved literals on the directory path before id-like plugin normalization", async () => { + mocks.getChannelPlugin.mockReturnValue({ + ...createChannelTestPluginBase({ + id: "telegram", + label: "Telegram", + capabilities: { chatTypes: ["direct", "group", "channel"] }, + }), + directory: { + listPeers: mocks.listPeers, + listPeersLive: mocks.listPeersLive, + listGroups: mocks.listGroups, + listGroupsLive: mocks.listGroupsLive, + }, + messaging: { + normalizeTarget: (raw: string) => + raw === "current" || raw === "telegram:current" ? "telegram:@current" : raw, + targetResolver: { + looksLikeId: (raw: string) => raw === "current" || raw === "telegram:current", + reservedLiterals: ["current", "self", "this", "me"], + hint: "", + resolveTarget: mocks.resolveTarget, + }, + }, + }); + mocks.listGroups.mockResolvedValueOnce([ + { kind: "group", id: "room-1", name: "current" } satisfies ChannelDirectoryEntry, + ]); + + const hit = await resolveMessagingTarget({ + cfg, + channel: "telegram", + input: "current", + }); + + expect(hit.ok).toBe(true); + if (hit.ok) { + expect(hit.target.to).toBe("room-1"); + expect(hit.target.source).toBe("directory"); + } + expect(mocks.resolveTarget).not.toHaveBeenCalled(); + + resetDirectoryCache(); + mocks.listGroups.mockResolvedValueOnce([ + { kind: "group", id: "room-1", name: "current" } satisfies ChannelDirectoryEntry, + ]); + + const prefixedHit = await resolveMessagingTarget({ + cfg, + channel: "telegram", + input: "telegram:current", + }); + + expect(prefixedHit.ok).toBe(true); + if (prefixedHit.ok) { + expect(prefixedHit.target.to).toBe("room-1"); + expect(prefixedHit.target.source).toBe("directory"); + } + + resetDirectoryCache(); + mocks.listGroups.mockResolvedValueOnce([]); + mocks.listGroupsLive.mockResolvedValueOnce([]); + + const miss = await resolveMessagingTarget({ + cfg, + channel: "telegram", + input: "current", + }); + + expect(miss.ok).toBe(false); + if (!miss.ok) { + expect(miss.error.message).toContain('Reserved target "current"'); + expect(miss.error.message).toContain("Telegram"); + } + expect(mocks.resolveTarget).not.toHaveBeenCalled(); + }); + + it("rejects reserved literal targets after directory miss", async () => { + mocks.getChannelPlugin.mockReturnValue({ + ...createChannelTestPluginBase({ + id: "telegram", + label: "Telegram", + capabilities: { chatTypes: ["direct", "group", "channel"] }, + }), + directory: { + listPeers: mocks.listPeers, + listPeersLive: mocks.listPeersLive, + listGroups: mocks.listGroups, + listGroupsLive: mocks.listGroupsLive, + }, + messaging: { + targetResolver: { + reservedLiterals: ["current", "self", "this", "me"], + hint: "", + resolveTarget: mocks.resolveTarget, + }, + }, + }); + mocks.listGroups.mockResolvedValue([]); + mocks.listGroupsLive.mockResolvedValue([]); + + const result = await resolveMessagingTarget({ + cfg, + channel: "telegram", + input: "current", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Reserved target "current"'); + expect(result.error.message).toContain("Telegram"); + } + expect(mocks.listGroups).toHaveBeenCalled(); + expect(mocks.resolveTarget).not.toHaveBeenCalled(); + }); + + it("requires exact directory matches before preserving reserved literal targets", async () => { + mocks.getChannelPlugin.mockReturnValue({ + ...createChannelTestPluginBase({ + id: "telegram", + label: "Telegram", + capabilities: { chatTypes: ["direct", "group", "channel"] }, + }), + directory: { + listPeers: mocks.listPeers, + listPeersLive: mocks.listPeersLive, + listGroups: mocks.listGroups, + listGroupsLive: mocks.listGroupsLive, + }, + messaging: { + targetResolver: { + reservedLiterals: ["current", "self", "this", "me"], + hint: "", + resolveTarget: mocks.resolveTarget, + }, + }, + }); + mocks.listGroups.mockResolvedValue([ + { kind: "group", id: "memes-room", name: "memes" } satisfies ChannelDirectoryEntry, + ]); + mocks.listGroupsLive.mockResolvedValue([]); + + const result = await resolveMessagingTarget({ + cfg, + channel: "telegram", + input: "me", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Reserved target "me"'); + expect(result.error.message).toContain("Telegram"); + } + expect(mocks.resolveTarget).not.toHaveBeenCalled(); + }); + it("does not reuse directory cache entries across prepared plugin runtimes", async () => { const firstListGroups = vi .fn() diff --git a/src/infra/outbound/target-resolver.ts b/src/infra/outbound/target-resolver.ts index 29b821e5eff0..696d8c33880d 100644 --- a/src/infra/outbound/target-resolver.ts +++ b/src/infra/outbound/target-resolver.ts @@ -11,7 +11,11 @@ import type { import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { buildDirectoryCacheKey, DirectoryCache } from "./directory-cache.js"; -import { ambiguousTargetError, unknownTargetError } from "./target-errors.js"; +import { + ambiguousTargetError, + reservedTargetLiteralError, + unknownTargetError, +} from "./target-errors.js"; import { maybeResolveIdLikeTarget, type ResolvedIdLikeTarget } from "./target-id-resolution.js"; import { buildTargetResolverSignature, @@ -20,6 +24,7 @@ import { normalizeChannelTargetInput, normalizeTargetForProvider, resolveNormalizedTargetInput, + resolveReservedTargetLiteral, } from "./target-normalization.js"; /** Directory-backed destination kind used by outbound target resolution. */ @@ -91,9 +96,21 @@ function normalizeQuery(value: string): string { return normalizeLowercaseStringOrEmpty(value); } -function stripTargetPrefixes(value: string): string { - return value - .replace(/^(channel|user):/i, "") +function stripTargetPrefixes(value: string, channel?: ChannelId, plugin?: ChannelPlugin): string { + const providerPrefixes = [channel, plugin?.id, ...(plugin?.messaging?.targetPrefixes ?? [])] + .map((prefix) => prefix?.trim().toLowerCase() ?? "") + .filter(Boolean); + let target = value.trim(); + while (target) { + const lowered = target.toLowerCase(); + const prefix = providerPrefixes.find((candidate) => lowered.startsWith(`${candidate}:`)); + if (!prefix) { + break; + } + target = target.slice(prefix.length + 1).trim(); + } + return target + .replace(/^(channel|group|user):/i, "") .replace(/^[@#]/, "") .trim(); } @@ -210,6 +227,7 @@ function matchesDirectoryEntry(params: { entry: ChannelDirectoryEntry; query: string; plugin?: ChannelPlugin; + exactOnly?: boolean; }): boolean { const query = normalizeQuery(params.query); if (!query) { @@ -217,11 +235,19 @@ function matchesDirectoryEntry(params: { } const id = stripTargetPrefixes( normalizeDirectoryEntryId(params.channel, params.entry, params.plugin), + params.channel, + params.plugin, ); - const name = params.entry.name ? stripTargetPrefixes(params.entry.name) : ""; - const handle = params.entry.handle ? stripTargetPrefixes(params.entry.handle) : ""; + const name = params.entry.name + ? stripTargetPrefixes(params.entry.name, params.channel, params.plugin) + : ""; + const handle = params.entry.handle + ? stripTargetPrefixes(params.entry.handle, params.channel, params.plugin) + : ""; const candidates = [id, name, handle].map((value) => normalizeQuery(value)).filter(Boolean); - return candidates.some((value) => value === query || value.includes(query)); + return candidates.some((value) => + params.exactOnly ? value === query : value === query || value.includes(query), + ); } function resolveMatch(params: { @@ -229,6 +255,7 @@ function resolveMatch(params: { entries: ChannelDirectoryEntry[]; query: string; plugin?: ChannelPlugin; + exactOnly?: boolean; }) { const matches = params.entries.filter((entry) => matchesDirectoryEntry({ @@ -236,6 +263,7 @@ function resolveMatch(params: { entry, query: params.query, plugin: params.plugin, + exactOnly: params.exactOnly, }), ); if (matches.length === 0) { @@ -398,8 +426,10 @@ export async function resolveMessagingTarget(params: { const kind = detectTargetKind(params.channel, raw, params.preferredKind, plugin); const normalizedInput = resolveNormalizedTargetInput(params.channel, raw, plugin); const normalized = normalizedInput?.normalized ?? raw; + const reservedLiteral = resolveReservedTargetLiteral({ raw, plugin }); if ( normalizedInput && + !reservedLiteral && looksLikeTargetId({ channel: params.channel, raw: normalizedInput.raw, @@ -426,7 +456,7 @@ export async function resolveMessagingTarget(params: { kind, }); } - const query = stripTargetPrefixes(raw); + const query = stripTargetPrefixes(raw, params.channel, plugin); const entries = await getDirectoryEntries({ cfg: params.cfg, channel: params.channel, @@ -437,7 +467,13 @@ export async function resolveMessagingTarget(params: { preferLiveOnMiss: true, plugin, }); - const match = resolveMatch({ channel: params.channel, entries, query, plugin }); + const match = resolveMatch({ + channel: params.channel, + entries, + query, + plugin, + exactOnly: Boolean(reservedLiteral), + }); if (match.kind === "single") { const entry = match.entry; return { @@ -445,7 +481,8 @@ export async function resolveMessagingTarget(params: { target: { to: normalizeDirectoryEntryId(params.channel, entry, plugin), kind, - display: entry.name ?? entry.handle ?? stripTargetPrefixes(entry.id), + display: + entry.name ?? entry.handle ?? stripTargetPrefixes(entry.id, params.channel, plugin), source: "directory", resolutionSource: "directory", }, @@ -461,7 +498,8 @@ export async function resolveMessagingTarget(params: { target: { to: normalizeDirectoryEntryId(params.channel, best, plugin), kind, - display: best.name ?? best.handle ?? stripTargetPrefixes(best.id), + display: + best.name ?? best.handle ?? stripTargetPrefixes(best.id, params.channel, plugin), source: "directory", resolutionSource: "directory", }, @@ -474,6 +512,10 @@ export async function resolveMessagingTarget(params: { candidates: match.entries, }; } + // Directory misses are the fail-closed boundary for reserved literals. + if (reservedLiteral) { + return { ok: false, error: reservedTargetLiteralError(providerLabel, reservedLiteral, hint) }; + } const resolvedFallbackTarget = asResolvedMessagingTarget( await maybeResolvePluginMessagingTarget({ cfg: params.cfg, diff --git a/src/infra/outbound/targets-resolve-shared.ts b/src/infra/outbound/targets-resolve-shared.ts index fbc1abdbc0e0..976045a2eb31 100644 --- a/src/infra/outbound/targets-resolve-shared.ts +++ b/src/infra/outbound/targets-resolve-shared.ts @@ -8,7 +8,8 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel-constants.js"; import type { GatewayMessageChannel } from "../../utils/message-channel.js"; import { validateTargetProviderPrefix } from "./channel-target-prefix.js"; -import { missingTargetError } from "./target-errors.js"; +import { missingTargetError, reservedTargetLiteralError } from "./target-errors.js"; +import { resolveReservedTargetLiteral } from "./target-normalization.js"; /** * Result of resolving a concrete outbound target for a channel send. @@ -79,6 +80,21 @@ export function resolveOutboundTargetWithPlugin(params: { if (targetPrefixError) { return { ok: false, error: targetPrefixError }; } + const hint = plugin.messaging?.targetResolver?.hint; + // Heartbeats defer reserved literals to the async resolver so directory hits can win. + if (params.target.mode !== "heartbeat") { + const reservedLiteral = resolveReservedTargetLiteral({ raw: effectiveTo, plugin }); + if (reservedLiteral) { + return { + ok: false, + error: reservedTargetLiteralError( + plugin.meta.label ?? params.target.channel, + reservedLiteral, + hint, + ), + }; + } + } const resolveTarget = plugin.outbound?.resolveTarget; if (resolveTarget) { @@ -94,7 +110,6 @@ export function resolveOutboundTargetWithPlugin(params: { if (effectiveTo) { return { ok: true, to: effectiveTo }; } - const hint = plugin.messaging?.targetResolver?.hint; return { ok: false, error: missingTargetError(plugin.meta.label ?? params.target.channel, hint), diff --git a/src/infra/outbound/targets.shared-test.ts b/src/infra/outbound/targets.shared-test.ts index cd186ed10665..f76f31f4cf2d 100644 --- a/src/infra/outbound/targets.shared-test.ts +++ b/src/infra/outbound/targets.shared-test.ts @@ -100,6 +100,73 @@ export function runResolveOutboundTargetCoreTests(): void { } }); + it.each(["current", "telegram:current", "tg:self"])( + "rejects plugin-reserved literal target %s before direct outbound fallback", + (to) => { + setActivePluginRegistry( + createTargetsTestRegistry([ + createTestChannelPlugin({ + id: "telegram", + label: "Telegram", + outbound: { + deliveryMode: "direct", + sendText: async () => ({ channel: "telegram", messageId: "telegram-msg" }), + }, + messaging: { + targetPrefixes: ["telegram", "tg"], + targetResolver: { + reservedLiterals: ["current", "self", "this", "me"], + hint: "", + }, + }, + }), + ]), + ); + + const res = resolveOutboundTarget({ + channel: "telegram", + to, + mode: "explicit", + }); + + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.error.message).toContain("Reserved target"); + expect(res.error.message).toContain("Telegram"); + } + }, + ); + + it("allows explicit handles that include the provider handle marker", () => { + setActivePluginRegistry( + createTargetsTestRegistry([ + createTestChannelPlugin({ + id: "telegram", + label: "Telegram", + outbound: { + deliveryMode: "direct", + sendText: async () => ({ channel: "telegram", messageId: "telegram-msg" }), + }, + messaging: { + targetPrefixes: ["telegram", "tg"], + targetResolver: { + reservedLiterals: ["current", "self", "this", "me"], + hint: "", + }, + }, + }), + ]), + ); + + const res = resolveOutboundTarget({ + channel: "telegram", + to: "telegram:@current", + mode: "explicit", + }); + + expect(res).toEqual({ ok: true, to: "telegram:@current" }); + }); + it("uses the plugin hint when a channel has outbound support but no target resolver", () => { setActivePluginRegistry( createTargetsTestRegistry([ diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 305fcb11927d..26f1e08ae1cc 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -1194,6 +1194,117 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.reason).toBe("dm-blocked"); }); + it("resolves heartbeat reserved targets through directory before session routing", async () => { + const listGroups = vi + .fn() + .mockResolvedValue([{ kind: "group", id: "-1002458651455", name: "current" }]); + const listGroupsLive = vi.fn().mockResolvedValue([]); + setActivePluginRegistry( + createTargetsTestRegistry([ + { + ...createTestChannelPlugin({ + id: "telegram", + label: "Telegram", + outbound: { + deliveryMode: "direct", + resolveTarget: ({ to }) => + to + ? { ok: true as const, to: to.trim() } + : { ok: false as const, error: new Error("target required") }, + }, + messaging: { + targetPrefixes: ["telegram", "tg"], + targetResolver: { + reservedLiterals: ["current", "self", "this", "me"], + hint: "", + }, + resolveOutboundSessionRoute: ({ target, resolvedTarget }) => ({ + sessionKey: `main:telegram:group:${target}`, + baseSessionKey: `main:telegram:group:${target}`, + peer: { kind: resolvedTarget?.kind === "user" ? "direct" : "group", id: target }, + chatType: resolvedTarget?.kind === "user" ? "direct" : "group", + from: `telegram:group:${target}`, + to: target, + }), + }, + }), + directory: { + listGroups, + listGroupsLive, + }, + }, + ]), + ); + + const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({ + cfg: {}, + agentId: "main", + heartbeat: { + target: "telegram", + to: "current", + }, + }); + + expect(resolved.channel).toBe("telegram"); + expect(resolved.to).toBe("-1002458651455"); + expect(listGroups).toHaveBeenCalled(); + }); + + it("fails closed when a heartbeat reserved target misses the directory", async () => { + const listGroups = vi.fn().mockResolvedValue([]); + const listGroupsLive = vi.fn().mockResolvedValue([]); + setActivePluginRegistry( + createTargetsTestRegistry([ + { + ...createTestChannelPlugin({ + id: "telegram", + label: "Telegram", + outbound: { + deliveryMode: "direct", + resolveTarget: ({ to }) => + to + ? { ok: true as const, to: to.trim() } + : { ok: false as const, error: new Error("target required") }, + }, + messaging: { + targetPrefixes: ["telegram", "tg"], + targetResolver: { + reservedLiterals: ["current", "self", "this", "me"], + hint: "", + }, + resolveOutboundSessionRoute: ({ target }) => ({ + sessionKey: `main:telegram:group:${target}`, + baseSessionKey: `main:telegram:group:${target}`, + peer: { kind: "group", id: target }, + chatType: "group", + from: `telegram:group:${target}`, + to: target, + }), + }, + }), + directory: { + listGroups, + listGroupsLive, + }, + }, + ]), + ); + + const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({ + cfg: {}, + agentId: "main", + heartbeat: { + target: "telegram", + to: "current", + }, + }); + + expect(resolved.channel).toBe("none"); + expect(resolved.reason).toBe("no-target"); + expect(listGroups).toHaveBeenCalled(); + expect(listGroupsLive).toHaveBeenCalled(); + }); + it("keeps heartbeat route canonicalization best-effort when target resolution fails", async () => { setActivePluginRegistry( createTargetsTestRegistry([ diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index df2ed21a75dd..d3fa7a9bb61b 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -27,6 +27,7 @@ import { resolveOutboundChannelPlugin, } from "./channel-resolution.js"; import { resolveOutboundSessionRoute } from "./outbound-session.js"; +import { isReservedTargetLiteralError } from "./target-errors.js"; import { resolveChannelTarget, type ResolvedMessagingTarget } from "./target-resolver.js"; import { resolveOutboundTargetWithPlugin, @@ -361,6 +362,13 @@ export async function resolveHeartbeatDeliveryTargetWithSessionRoute(params: { })(); if (targetResolution?.ok) { routeResolvedTarget = targetResolution.target; + } else if (targetResolution && isReservedTargetLiteralError(targetResolution.error)) { + return buildNoHeartbeatDeliveryTarget({ + reason: "no-target", + accountId: delivery.accountId, + lastChannel: delivery.lastChannel, + lastAccountId: delivery.lastAccountId, + }); } if (routeResolvedTarget?.kind === "user" && heartbeat?.directPolicy === "block") { return buildNoHeartbeatDeliveryTarget({ diff --git a/src/mcp/plugin-tools-mcp-client.test.ts b/src/mcp/plugin-tools-mcp-client.test.ts new file mode 100644 index 000000000000..795b9cb15695 --- /dev/null +++ b/src/mcp/plugin-tools-mcp-client.test.ts @@ -0,0 +1,60 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it, vi } from "vitest"; +import type { AnyAgentTool } from "../agents/tools/common.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { createPluginToolsMcpServer } from "./plugin-tools-serve.js"; + +describe("plugin tools MCP client bridge", () => { + it("lists and calls a plugin tool through a real MCP client", async () => { + const execute = vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "MCP fact: the codename is ORBIT-9." }], + }); + const tool = { + name: "memory_search", + description: "Search memory", + parameters: { + type: "object", + properties: { + query: { type: "string" }, + maxResults: { type: "number" }, + }, + required: ["query"], + }, + execute, + } as unknown as AnyAgentTool; + + const server = createPluginToolsMcpServer({ + config: { plugins: { enabled: true } } as OpenClawConfig, + tools: [tool], + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client( + { name: "plugin-tools-test-client", version: "0.0.0" }, + { capabilities: {} }, + ); + + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + + try { + const listed = await client.listTools(); + expect(listed.tools.map((listedTool) => listedTool.name)).toContain("memory_search"); + + const result = await client.callTool({ + name: "memory_search", + arguments: { query: "ORBIT-9 codename", maxResults: 3 }, + }); + + expect(execute).toHaveBeenCalledWith( + expect.stringMatching(/^mcp-\d+$/), + { query: "ORBIT-9 codename", maxResults: 3 }, + expect.any(AbortSignal), + undefined, + ); + expect(JSON.stringify(result.content)).toContain("ORBIT-9"); + } finally { + await client.close(); + await server.close(); + } + }); +}); diff --git a/src/media/web-media.test.ts b/src/media/web-media.test.ts index c2bf84484709..12a94ece2db4 100644 --- a/src/media/web-media.test.ts +++ b/src/media/web-media.test.ts @@ -573,6 +573,31 @@ describe("loadWebMedia", () => { expect(result.fileName).toBe("fake.png"); }); + it("strips internal media-store UUID suffix from outbound fileName", async () => { + const stagedName = "report---a1b2c3d4-5678-90ab-cdef-1234567890ab.png"; + const mediaDir = path.join(stateDir, "media", "outbound"); + const stagedFile = path.join(mediaDir, stagedName); + await fs.mkdir(mediaDir, { recursive: true }); + await fs.writeFile(stagedFile, Buffer.from(TINY_PNG_BASE64, "base64")); + + const result = await loadWebMedia(stagedFile, { + maxBytes: 1024 * 1024, + localRoots: [mediaDir], + }); + + expect(result.fileName).toBe("report.png"); + }); + + it("preserves non-media-store filenames that match the UUID suffix shape", async () => { + const fileName = "report---a1b2c3d4-5678-90ab-cdef-1234567890ab.png"; + const filePath = path.join(fixtureRoot, fileName); + await fs.writeFile(filePath, Buffer.from(TINY_PNG_BASE64, "base64")); + + const result = await loadWebMedia(filePath, createLocalWebMediaOptions()); + + expect(result.fileName).toBe(fileName); + }); + it("uses only the leaf filename from Windows-style sandbox-validated media paths", async () => { const result = await loadWebMedia(String.raw`C:\workspace\captures\tiny.png`, { maxBytes: 1024 * 1024, diff --git a/src/media/web-media.ts b/src/media/web-media.ts index 5150aceab230..f4ccb368ae78 100644 --- a/src/media/web-media.ts +++ b/src/media/web-media.ts @@ -33,6 +33,7 @@ import { readImageMetadataFromHeader, readImageProbeFromHeader, } from "./media-services.js"; +import { extractOriginalFilename, getMediaDir } from "./store.js"; export { getDefaultLocalRoots, LocalMediaAccessError }; export type { LocalMediaAccessErrorCode }; @@ -284,6 +285,13 @@ function isPathInsideRoot(filePath: string | undefined, root: string): boolean { ); } +function resolveLocalMediaFileName(filePath: string): string | undefined { + const fileName = basenameFromAnyPath(filePath) || undefined; + return fileName && isPathInsideRoot(filePath, getMediaDir()) + ? extractOriginalFilename(fileName) + : fileName; +} + function hasHtmlDocumentShape(text: string): boolean { const sample = text.trimStart().slice(0, 8192); return /^(?:/iu.test(sample); @@ -1074,7 +1082,7 @@ async function loadWebMediaInternal( trustedGeneratedHtmlPath, }); } - let fileName = basenameFromAnyPath(mediaUrl) || undefined; + let fileName = resolveLocalMediaFileName(mediaUrl); if (fileName && !extnameFromAnyPath(fileName) && mime) { const ext = extensionForMime(mime); if (ext) { diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index db74e8ca3e62..858104a84bcf 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -1601,6 +1601,10 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { command: ["python3", "-c", "print('hi')"], expected: "python3 -c requires explicit approval in strictInlineEval mode", }, + { + command: ["python3.13", "-c", "print('hi')"], + expected: "python3.13 -c requires explicit approval in strictInlineEval mode", + }, ] as const; setRuntimeConfigSnapshot({ tools: { @@ -1672,7 +1676,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { const tempDir = createFixtureDir("openclaw-inline-eval-bin-"); const executablePath = createTempExecutable({ dir: tempDir, - name: "python3", + name: "python3.13", }); const { runCommand, sendInvokeResult } = await runSystemInvoke({ preferMacAppExecHost: false, diff --git a/src/plugin-sdk/image-generation.ts b/src/plugin-sdk/image-generation.ts index 1f08cc0db401..58aa835d40f1 100644 --- a/src/plugin-sdk/image-generation.ts +++ b/src/plugin-sdk/image-generation.ts @@ -16,6 +16,7 @@ export { imageSourceUploadFileName, parseImageDataUrl, parseOpenAiCompatibleImageResponse, + resolveInlineImageJsonResponseMaxBytes, sniffImageMimeType, toImageDataUrl, type ImageMimeTypeDetection, diff --git a/src/plugin-sdk/memory-core-engine-runtime.ts b/src/plugin-sdk/memory-core-engine-runtime.ts index 21f3bd11e5b7..41f725febad4 100644 --- a/src/plugin-sdk/memory-core-engine-runtime.ts +++ b/src/plugin-sdk/memory-core-engine-runtime.ts @@ -131,7 +131,7 @@ type FacadeModule = { getMemorySearchManager: (params: { cfg: OpenClawConfig; agentId: string; - purpose?: "default" | "status"; + purpose?: "default" | "status" | "cli"; }) => Promise<{ manager: MemorySearchManager | null; error?: string; diff --git a/src/plugin-sdk/provider-http.ts b/src/plugin-sdk/provider-http.ts index 5d23f75d58e9..eafc5184a5ed 100644 --- a/src/plugin-sdk/provider-http.ts +++ b/src/plugin-sdk/provider-http.ts @@ -14,6 +14,7 @@ export { readProviderJsonArrayFieldResponse, readProviderJsonObjectResponse, readProviderJsonResponse, + readProviderTextResponse, readResponseTextLimited, truncateErrorDetail, } from "../agents/provider-http-errors.js"; diff --git a/src/plugin-sdk/sandbox.ts b/src/plugin-sdk/sandbox.ts index 498561344872..1118ff8f970a 100644 --- a/src/plugin-sdk/sandbox.ts +++ b/src/plugin-sdk/sandbox.ts @@ -14,9 +14,12 @@ export type { SandboxBackendHandle, SandboxBackendId, SandboxBackendManager, + SandboxBackendPreparedWorkdirDiscarder, SandboxBackendRegistration, SandboxBackendRuntimeInfo, + SandboxBackendWorkdirValidation, SandboxBackendWorkdirResolver, + SandboxBackendWorkdirValidator, SandboxContext, SandboxResolvedPath, SandboxSshConfig, @@ -27,6 +30,7 @@ export type { OpenClawConfig } from "../config/config.js"; export { buildExecRemoteCommand, + buildRemoteWorkdirValidationCommand, buildRemoteCommand, buildSshSandboxArgv, buildValidatedExecRemoteCommand, diff --git a/src/plugin-sdk/session-store-runtime.test.ts b/src/plugin-sdk/session-store-runtime.test.ts index 34960c7bae5a..e0a1e97f74aa 100644 --- a/src/plugin-sdk/session-store-runtime.test.ts +++ b/src/plugin-sdk/session-store-runtime.test.ts @@ -145,6 +145,7 @@ describe("session-store-runtime compatibility surface", () => { maintenanceConfig: { mode: "enforce", pruneAfterMs: 7 * DAY_MS, + modelRunPruneAfterMs: DAY_MS, maxEntries: 1, resetArchiveRetentionMs: 7 * DAY_MS, maxDiskBytes: null, @@ -164,6 +165,51 @@ describe("session-store-runtime compatibility surface", () => { expect(getSessionEntry({ sessionKey: staleSessionKey, storePath })).toBeUndefined(); }); + it("accepts pre-model-run maintenance configs through entry patches", async () => { + const staleModelRunKey = "agent:main:explicit:model-run-123e4567-e89b-12d3-a456-426614174000"; + const activeSessionKey = "agent:main:active"; + const now = Date.now(); + await saveSessionStore( + storePath, + { + [staleModelRunKey]: { + sessionId: "session-probe", + updatedAt: now - 2 * DAY_MS, + }, + [activeSessionKey]: { + sessionId: "session-active", + updatedAt: now, + }, + }, + { skipMaintenance: true }, + ); + + const legacyMaintenanceConfig = { + mode: "enforce" as const, + pruneAfterMs: 7 * DAY_MS, + maxEntries: 500, + resetArchiveRetentionMs: 7 * DAY_MS, + maxDiskBytes: null, + highWaterBytes: null, + }; + + await expect( + patchSessionEntry({ + sessionKey: activeSessionKey, + storePath, + maintenanceConfig: legacyMaintenanceConfig, + update: () => ({ model: "gpt-5.5" }), + }), + ).resolves.toMatchObject({ + model: "gpt-5.5", + sessionId: "session-active", + }); + + expect(getSessionEntry({ sessionKey: staleModelRunKey, storePath })).toMatchObject({ + sessionId: "session-probe", + }); + }); + it("keeps deprecated whole-store mutations grouped as one compatibility operation", async () => { const firstSessionKey = "agent:main:first"; const secondSessionKey = "agent:main:second"; diff --git a/src/plugin-sdk/session-store-runtime.ts b/src/plugin-sdk/session-store-runtime.ts index 2d31df607404..b9da7a76e763 100644 --- a/src/plugin-sdk/session-store-runtime.ts +++ b/src/plugin-sdk/session-store-runtime.ts @@ -1,5 +1,6 @@ // Narrow session-store helpers for channel hot paths. +import { resolveStorePath as resolveSessionStorePath } from "../config/sessions/paths.js"; import { cleanupSessionLifecycleArtifacts as cleanupAccessorSessionLifecycleArtifacts, listSessionEntries as listAccessorSessionEntries, @@ -10,15 +11,16 @@ import { type SessionAccessScope, updateSessionEntry, } from "../config/sessions/session-accessor.js"; -import { resolveStorePath as resolveSessionStorePath } from "../config/sessions/paths.js"; import { loadSessionStore as loadSessionStoreImpl } from "../config/sessions/store-load.js"; -import type { ResolvedSessionMaintenanceConfig } from "../config/sessions/store.js"; +import { normalizeResolvedMaintenanceConfigInput } from "../config/sessions/store-maintenance.js"; +import type { ResolvedSessionMaintenanceConfigInput } from "../config/sessions/store.js"; import type { SessionEntry } from "../config/sessions/types.js"; type SessionStoreReadParams = { agentId?: string; env?: NodeJS.ProcessEnv; hydrateSkillPromptRefs?: boolean; + readConsistency?: "latest"; sessionKey: string; storePath?: string; }; @@ -41,7 +43,7 @@ type SessionStoreEntryPatch = ( type PatchSessionEntryParams = SessionStoreReadParams & { fallbackEntry?: SessionEntry; - maintenanceConfig?: ResolvedSessionMaintenanceConfig; + maintenanceConfig?: ResolvedSessionMaintenanceConfigInput; preserveActivity?: boolean; replaceEntry?: boolean; update: SessionStoreEntryPatch; @@ -89,6 +91,7 @@ function toSessionAccessScope(params: SessionStoreReadParams): SessionAccessScop ...(params.hydrateSkillPromptRefs !== undefined ? { hydrateSkillPromptRefs: params.hydrateSkillPromptRefs } : {}), + ...(params.readConsistency !== undefined ? { readConsistency: params.readConsistency } : {}), ...(params.storePath !== undefined ? { storePath: params.storePath } : {}), }; } @@ -126,7 +129,10 @@ export async function patchSessionEntry( ): Promise { return await patchAccessorSessionEntry(toSessionAccessScope(params), params.update, { fallbackEntry: params.fallbackEntry, - maintenanceConfig: params.maintenanceConfig, + maintenanceConfig: + params.maintenanceConfig !== undefined + ? normalizeResolvedMaintenanceConfigInput(params.maintenanceConfig) + : undefined, preserveActivity: params.preserveActivity, replaceEntry: params.replaceEntry, }); diff --git a/src/plugin-sdk/test-helpers/temp-home.ts b/src/plugin-sdk/test-helpers/temp-home.ts index 42919c3c642a..06efeca3b969 100644 --- a/src/plugin-sdk/test-helpers/temp-home.ts +++ b/src/plugin-sdk/test-helpers/temp-home.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js"; import { cleanupSessionStateForTest } from "../../test-utils/session-state-cleanup.js"; type EnvValue = string | undefined | ((home: string) => string | undefined); @@ -36,9 +37,9 @@ function snapshotEnv(): EnvSnapshot { function restoreEnv(snapshot: EnvSnapshot) { const restoreKey = (key: string, value: string | undefined) => { if (value === undefined) { - delete process.env[key]; + deleteTestEnvValue(key); } else { - process.env[key] = value; + setTestEnvValue(key, value); } }; restoreKey("HOME", snapshot.home); @@ -60,19 +61,19 @@ function snapshotExtraEnv(keys: string[]): Record { function restoreExtraEnv(snapshot: Record) { for (const [key, value] of Object.entries(snapshot)) { if (value === undefined) { - delete process.env[key]; + deleteTestEnvValue(key); } else { - process.env[key] = value; + setTestEnvValue(key, value); } } } function setTempHome(base: string) { - process.env.HOME = base; - process.env.USERPROFILE = base; + setTestEnvValue("HOME", base); + setTestEnvValue("USERPROFILE", base); // Ensure tests using HOME isolation aren't affected by leaked OPENCLAW_HOME. - delete process.env.OPENCLAW_HOME; - process.env.OPENCLAW_STATE_DIR = path.join(base, ".openclaw"); + deleteTestEnvValue("OPENCLAW_HOME"); + setTestEnvValue("OPENCLAW_STATE_DIR", path.join(base, ".openclaw")); if (process.platform !== "win32") { return; @@ -81,8 +82,8 @@ function setTempHome(base: string) { if (!match) { return; } - process.env.HOMEDRIVE = match[1]; - process.env.HOMEPATH = match[2] || "\\"; + setTestEnvValue("HOMEDRIVE", match[1]); + setTestEnvValue("HOMEPATH", match[2] || "\\"); } async function allocateTempHomeBase(prefix: string): Promise { @@ -126,9 +127,9 @@ export async function withTempHome( for (const [key, raw] of Object.entries(opts.env)) { const value = typeof raw === "function" ? raw(base) : raw; if (value === undefined) { - delete process.env[key]; + deleteTestEnvValue(key); } else { - process.env[key] = value; + setTestEnvValue(key, value); } } } diff --git a/src/plugins/clawhub-error-codes.ts b/src/plugins/clawhub-error-codes.ts index f19099e9caf3..f39f680cb4b2 100644 --- a/src/plugins/clawhub-error-codes.ts +++ b/src/plugins/clawhub-error-codes.ts @@ -13,6 +13,9 @@ export const CLAWHUB_INSTALL_ERROR_CODE = { MISSING_ARCHIVE_INTEGRITY: "missing_archive_integrity", ARTIFACT_DOWNLOAD_UNAVAILABLE: "artifact_download_unavailable", ARCHIVE_INTEGRITY_MISMATCH: "archive_integrity_mismatch", + CLAWHUB_SECURITY_UNAVAILABLE: "clawhub_security_unavailable", + CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED: "clawhub_risk_acknowledgement_required", + CLAWHUB_DOWNLOAD_BLOCKED: "clawhub_download_blocked", } as const; /** Union of stable ClawHub install error code values. */ diff --git a/src/plugins/clawhub-install-records.ts b/src/plugins/clawhub-install-records.ts index 4577080fb7fa..46837be76eab 100644 --- a/src/plugins/clawhub-install-records.ts +++ b/src/plugins/clawhub-install-records.ts @@ -9,6 +9,14 @@ export type ClawHubPluginInstallRecordFields = { clawhubPackage: string; clawhubFamily: Exclude; clawhubChannel?: ClawHubPackageChannel; + clawhubTrustDisposition?: "clean" | "review-recommended" | "review-required" | "blocked"; + clawhubTrustScanStatus?: string; + clawhubTrustModerationState?: string; + clawhubTrustReasons?: string[]; + clawhubTrustPending?: boolean; + clawhubTrustStale?: boolean; + clawhubTrustCheckedAt?: string; + clawhubTrustAcknowledgedAt?: string; version?: string; integrity?: string; resolvedAt?: string; @@ -34,6 +42,14 @@ export function buildClawHubPluginInstallRecordFields( | "clawhubPackage" | "clawhubFamily" | "clawhubChannel" + | "clawhubTrustDisposition" + | "clawhubTrustScanStatus" + | "clawhubTrustModerationState" + | "clawhubTrustReasons" + | "clawhubTrustPending" + | "clawhubTrustStale" + | "clawhubTrustCheckedAt" + | "clawhubTrustAcknowledgedAt" | "version" | "integrity" | "resolvedAt" @@ -54,6 +70,28 @@ export function buildClawHubPluginInstallRecordFields( clawhubPackage: fields.clawhubPackage, clawhubFamily: fields.clawhubFamily, ...(fields.clawhubChannel ? { clawhubChannel: fields.clawhubChannel } : {}), + ...(fields.clawhubTrustDisposition + ? { clawhubTrustDisposition: fields.clawhubTrustDisposition } + : {}), + ...(fields.clawhubTrustScanStatus + ? { clawhubTrustScanStatus: fields.clawhubTrustScanStatus } + : {}), + ...(fields.clawhubTrustModerationState + ? { clawhubTrustModerationState: fields.clawhubTrustModerationState } + : {}), + ...(fields.clawhubTrustReasons ? { clawhubTrustReasons: fields.clawhubTrustReasons } : {}), + ...(fields.clawhubTrustPending !== undefined + ? { clawhubTrustPending: fields.clawhubTrustPending } + : {}), + ...(fields.clawhubTrustStale !== undefined + ? { clawhubTrustStale: fields.clawhubTrustStale } + : {}), + ...(fields.clawhubTrustCheckedAt + ? { clawhubTrustCheckedAt: fields.clawhubTrustCheckedAt } + : {}), + ...(fields.clawhubTrustAcknowledgedAt + ? { clawhubTrustAcknowledgedAt: fields.clawhubTrustAcknowledgedAt } + : {}), ...(fields.version ? { version: fields.version } : {}), ...(fields.integrity ? { integrity: fields.integrity } : {}), ...(fields.resolvedAt ? { resolvedAt: fields.resolvedAt } : {}), diff --git a/src/plugins/clawhub.test.ts b/src/plugins/clawhub.test.ts index 3435b5d0aa1c..41291141f0f0 100644 --- a/src/plugins/clawhub.test.ts +++ b/src/plugins/clawhub.test.ts @@ -11,6 +11,7 @@ import { createZipCentralDirectoryArchive } from "../test-utils/zip-central-dire const parseClawHubPluginSpecMock = vi.fn(); const fetchClawHubPackageDetailMock = vi.fn(); const fetchClawHubPackageArtifactMock = vi.fn(); +const fetchClawHubPackageSecurityMock = vi.fn(); const fetchClawHubPackageVersionMock = vi.fn(); const downloadClawHubPackageArchiveMock = vi.fn(); const archiveCleanupMock = vi.fn(); @@ -25,6 +26,7 @@ vi.mock("../infra/clawhub.js", async () => { parseClawHubPluginSpec: (...args: unknown[]) => parseClawHubPluginSpecMock(...args), fetchClawHubPackageDetail: (...args: unknown[]) => fetchClawHubPackageDetailMock(...args), fetchClawHubPackageArtifact: (...args: unknown[]) => fetchClawHubPackageArtifactMock(...args), + fetchClawHubPackageSecurity: (...args: unknown[]) => fetchClawHubPackageSecurityMock(...args), fetchClawHubPackageVersion: (...args: unknown[]) => fetchClawHubPackageVersionMock(...args), downloadClawHubPackageArchive: (...args: unknown[]) => downloadClawHubPackageArchiveMock(...args), @@ -54,6 +56,7 @@ vi.mock("../infra/archive.js", async () => { const { ClawHubRequestError } = await import("../infra/clawhub.js"); type ClawHubResolvedArtifact = import("../infra/clawhub.js").ClawHubResolvedArtifact; +type ClawHubRiskAcknowledgementRequest = import("./clawhub.js").ClawHubRiskAcknowledgementRequest; const { CLAWHUB_INSTALL_ERROR_CODE, formatClawHubSpecifier, installPluginFromClawHub } = await import("./clawhub.js"); @@ -108,10 +111,29 @@ function createLoggerSpies() { }; } +function mockCommunityClawHubPackageDetail() { + fetchClawHubPackageDetailMock.mockResolvedValue({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + channel: "community", + isOfficial: false, + createdAt: 0, + updatedAt: 0, + compatibility: { + pluginApiRange: ">=2026.3.22", + minGatewayVersion: "2026.3.0", + }, + }, + }); +} + function expectClawHubInstallFlow(params: { baseUrl: string; version: string; archivePath: string; + expectSecurityCall?: boolean; }) { expect(packageDetailCall().name).toBe("demo"); expect(packageDetailCall().baseUrl).toBe(params.baseUrl); @@ -119,17 +141,26 @@ function expectClawHubInstallFlow(params: { expect(packageVersionCall().version).toBe(params.version); expect(packageArtifactCall().name).toBe("demo"); expect(packageArtifactCall().version).toBe(params.version); + if (params.expectSecurityCall ?? true) { + expect(packageSecurityCall().name).toBe("demo"); + expect(packageSecurityCall().version).toBe(params.version); + } else { + expect(fetchClawHubPackageSecurityMock).not.toHaveBeenCalled(); + } expect(archiveInstallCall().archivePath).toBe(params.archivePath); } -function expectSuccessfulClawHubInstall(result: unknown) { +function expectSuccessfulClawHubInstall( + result: unknown, + expected: { clawhubChannel?: string } = {}, +) { const success = expectInstallSuccess(result); expect(success.pluginId).toBe("demo"); expect(success.version).toBe("2026.3.22"); expect(success.clawhub?.source).toBe("clawhub"); expect(success.clawhub?.clawhubPackage).toBe("demo"); expect(success.clawhub?.clawhubFamily).toBe("code-plugin"); - expect(success.clawhub?.clawhubChannel).toBe("official"); + expect(success.clawhub?.clawhubChannel).toBe(expected.clawhubChannel ?? "official"); expect(success.clawhub?.integrity).toBe(DEMO_ARCHIVE_INTEGRITY); } @@ -160,14 +191,18 @@ type ArchiveInstallCall = { type InstallSuccess = { clawhub?: Record; ok: true; + packageName?: string; pluginId?: string; version?: string; + warning?: string; }; type InstallFailure = { code?: string; error: string; ok: false; + version?: string; + warning?: string; }; function mockCallArg(mock: MockWithCalls, callIndex = 0, argIndex = 0): unknown { @@ -193,6 +228,10 @@ function packageArtifactCall(callIndex = 0): PackageLookupCall { return mockCallArg(fetchClawHubPackageArtifactMock, callIndex) as PackageLookupCall; } +function packageSecurityCall(callIndex = 0): PackageLookupCall { + return mockCallArg(fetchClawHubPackageSecurityMock, callIndex) as PackageLookupCall; +} + function archiveDownloadCall(callIndex = 0): PackageLookupCall { return mockCallArg(downloadClawHubPackageArchiveMock, callIndex) as PackageLookupCall; } @@ -232,6 +271,7 @@ describe("installPluginFromClawHub", () => { parseClawHubPluginSpecMock.mockReset(); fetchClawHubPackageDetailMock.mockReset(); fetchClawHubPackageArtifactMock.mockReset(); + fetchClawHubPackageSecurityMock.mockReset(); fetchClawHubPackageVersionMock.mockReset(); downloadClawHubPackageArchiveMock.mockReset(); archiveCleanupMock.mockReset(); @@ -271,6 +311,27 @@ describe("installPluginFromClawHub", () => { fetchClawHubPackageArtifactMock.mockImplementation((params) => fetchClawHubPackageVersionMock(params), ); + fetchClawHubPackageSecurityMock.mockImplementation( + (params: { name?: string; version?: string }) => + Promise.resolve({ + package: { + name: params.name ?? "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: params.version ?? "2026.3.22", + }, + trust: { + scanStatus: "clean", + moderationState: null, + blockedFromDownload: false, + reasons: [], + pending: false, + stale: false, + }, + }), + ); downloadClawHubPackageArchiveMock.mockResolvedValue({ archivePath: "/tmp/clawhub-demo/archive.zip", integrity: DEMO_ARCHIVE_INTEGRITY, @@ -291,7 +352,7 @@ describe("installPluginFromClawHub", () => { expect(formatClawHubSpecifier({ name: "demo", version: "1.2.3" })).toBe("clawhub:demo@1.2.3"); }); - it("installs a ClawHub code plugin through the archive installer", async () => { + it("installs a ClawHub plugin through the archive installer", async () => { const logger = createLoggerSpies(); const result = await installPluginFromClawHub({ spec: "clawhub:demo", @@ -303,16 +364,22 @@ describe("installPluginFromClawHub", () => { baseUrl: "https://clawhub.ai", version: "2026.3.22", archivePath: "/tmp/clawhub-demo/archive.zip", + expectSecurityCall: false, }); expectSuccessfulClawHubInstall(result); expect(archiveInstallCall().installPolicyRequest).toEqual({ kind: "plugin-archive", requestedSpecifier: "clawhub:demo", - source: { kind: "clawhub", authority: "openclaw", mutable: false, network: true }, + source: { kind: "clawhub", authority: "official", mutable: false, network: true }, }); - expect(logger.info).toHaveBeenCalledWith("ClawHub code-plugin demo@2026.3.22 channel=official"); + expect(archiveInstallCall().trustedSourceLinkedOfficialInstall).toBe(true); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Package demo@2026.3.22")); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Type plugin")); expect(logger.info).toHaveBeenCalledWith( - "Compatibility: pluginApi=>=2026.3.22 minGateway=2026.3.0", + expect.stringContaining("Requires pluginApi >=2026.3.22"), + ); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining("ClawHub https://clawhub.ai/plugins/demo"), ); expect(logger.warn).not.toHaveBeenCalled(); expect(archiveCleanupMock).toHaveBeenCalledTimes(1); @@ -337,7 +404,640 @@ describe("installPluginFromClawHub", () => { }); }); - it("marks official source-linked OpenClaw packages as trusted for install scanning", async () => { + it("does not warn just because a ClawHub package is community channel", async () => { + fetchClawHubPackageDetailMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + channel: "community", + isOfficial: false, + createdAt: 0, + updatedAt: 0, + compatibility: { + pluginApiRange: ">=2026.3.22", + minGatewayVersion: "2026.3.0", + }, + }, + }); + const logger = { ...createLoggerSpies(), terminalLinks: true }; + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + logger, + }); + + const success = expectInstallSuccess(result); + expect(success.pluginId).toBe("demo"); + expect(success.clawhub?.clawhubChannel).toBe("community"); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("sanitizes the ClawHub package summary link before logging", async () => { + const logger = createLoggerSpies(); + + await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai/\u001b]8;;https://evil.example\u0007\ninjected", + logger, + }); + + const summary = logger.info.mock.calls.map(([message]) => message).join("\n"); + expect(summary).toContain("ClawHub"); + expect(summary).not.toContain("\u001b"); + expect(summary).not.toContain("\u0007"); + expect(summary).not.toContain("https://clawhub.ai/\ninjected"); + expect(summary).toContain("https://clawhub.ai/\\ninjected/plugins/demo"); + }); + + it("blocks malicious ClawHub releases even when risk is acknowledged", async () => { + mockCommunityClawHubPackageDetail(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: "malicious", + moderationState: "quarantined", + blockedFromDownload: true, + reasons: ["manual_moderation"], + pending: false, + stale: false, + }, + }); + const logger = { ...createLoggerSpies(), terminalLinks: true }; + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + logger, + acknowledgeClawHubRisk: true, + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_DOWNLOAD_BLOCKED); + expect(failure.error).toBe("ClawHub blocked this release; install was not started."); + expect(failure.warning).toContain("ClawHub flagged this release as malicious"); + expect(failure.warning).not.toContain("\u001b"); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("BLOCKED")); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("ClawHub flagged this release as malicious"), + ); + const warning = logger.warn.mock.calls[0]?.[0] ?? ""; + expect(warning).toContain("\u001b]8"); + expect(warning).toContain("• Security scan"); + expect(warning).toContain("malicious"); + expect(warning).toContain("• Moderation quarantined"); + expect(warning).toContain("• Finding manual_moderation"); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("manual_moderation")); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + }); + + it("explains that a malicious plugin update will not be downloaded", async () => { + mockCommunityClawHubPackageDetail(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: "malicious", + moderationState: "quarantined", + blockedFromDownload: true, + reasons: ["scan:malicious"], + pending: false, + stale: false, + }, + }); + const logger = createLoggerSpies(); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + logger, + mode: "update", + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_DOWNLOAD_BLOCKED); + const warning = logger.warn.mock.calls[0]?.[0] ?? ""; + expect(warning).toContain( + "Latest plugin version is marked malicious; OpenClaw will not download it.", + ); + expect(warning).toContain( + "Uninstall the installed plugin unless you have independently reviewed it.", + ); + expect(warning).not.toContain("Choose a different version"); + expect(warning).not.toContain("/security/static-analysis"); + expect(warning).not.toContain("/security/virustotal"); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + }); + + it("includes the blocked-download reason when other trust evidence exists", async () => { + mockCommunityClawHubPackageDetail(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: "clean", + moderationState: null, + blockedFromDownload: true, + reasons: [], + pending: false, + stale: false, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_DOWNLOAD_BLOCKED); + expect(failure.warning).toContain("BLOCKED - ClawHub blocked this release"); + expect(failure.warning).not.toContain("flagged this release as malicious"); + expect(failure.warning).toContain("Security scan clean"); + expect(failure.warning).toContain("Download disabled by ClawHub for this release"); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + }); + + it("requires acknowledgement before downloading non-clean ClawHub releases", async () => { + mockCommunityClawHubPackageDetail(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: "not-run", + moderationState: null, + blockedFromDownload: false, + reasons: [], + pending: false, + stale: false, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED); + expect(failure.warning).toContain("WARNING - ClawHub found security risks"); + expect(failure.warning).toContain("Security scan not-run"); + expect(failure.warning).toContain("large local system blast radius"); + expect(failure.warning).toContain("before installing"); + expect(failure.warning).not.toContain("blockedFromDownload=false"); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + }); + + it("renders derived risk reasons when ClawHub trust evidence fields are missing", async () => { + mockCommunityClawHubPackageDetail(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: null, + moderationState: null, + blockedFromDownload: false, + reasons: [], + pending: false, + stale: false, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED); + expect(failure.warning).toContain("WARNING - ClawHub found security risks"); + expect(failure.warning).toContain("security scan status is missing"); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + }); + + it("uses update wording in non-clean ClawHub release warnings during update", async () => { + mockCommunityClawHubPackageDetail(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: "not-run", + moderationState: null, + blockedFromDownload: false, + reasons: [], + pending: false, + stale: false, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + mode: "update", + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED); + expect(failure.warning).toContain("before updating"); + expect(failure.warning).not.toContain("before installing"); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + }); + + it("sanitizes ClawHub trust warning fields before logging", async () => { + mockCommunityClawHubPackageDetail(); + const logger = createLoggerSpies(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: "clean\u001b[2K", + moderationState: null, + blockedFromDownload: false, + reasons: ["bad\nreason"], + pending: false, + stale: false, + }, + }); + + await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + logger, + }); + + const warning = logger.warn.mock.calls[0]?.[0]; + expect(warning).toContain("bad\\nreason"); + expect(warning).not.toContain("\u001b"); + expect(warning).not.toContain("bad\nreason"); + }); + + it("requires acknowledgement before downloading releases with unknown moderation state", async () => { + mockCommunityClawHubPackageDetail(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: "clean", + moderationState: "manual-review", + blockedFromDownload: false, + reasons: [], + pending: false, + stale: false, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + }); + + it("stops when ClawHub security identity does not match the requested release", async () => { + mockCommunityClawHubPackageDetail(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.21", + }, + trust: { + scanStatus: "clean", + moderationState: null, + blockedFromDownload: false, + reasons: [], + pending: false, + stale: false, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE); + expect(failure.version).toBe("2026.3.22"); + expect(failure.error).toContain('returned version "2026.3.21"'); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + }); + + it("sanitizes ClawHub security identity mismatch labels before returning errors", async () => { + mockCommunityClawHubPackageDetail(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.21\nrewritten\u001b[2K", + }, + trust: { + scanStatus: "clean", + moderationState: null, + blockedFromDownload: false, + reasons: [], + pending: false, + stale: false, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE); + expect(failure.error).toContain('returned version "2026.3.21\\nrewritten"'); + expect(failure.error).not.toContain("\n"); + expect(failure.error).not.toContain("\u001b"); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + }); + + it("sanitizes ClawHub security fetch failure labels before returning errors", async () => { + fetchClawHubPackageDetailMock.mockResolvedValueOnce({ + package: { + name: "demo\npkg", + displayName: "Demo", + family: "code-plugin", + channel: "community", + isOfficial: false, + createdAt: 0, + updatedAt: 0, + compatibility: { + pluginApiRange: ">=2026.3.22", + minGatewayVersion: "2026.3.0", + }, + }, + }); + fetchClawHubPackageSecurityMock.mockRejectedValueOnce(new Error("bad\nupstream\u001b[2K")); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE); + expect(failure.error).toContain('"demo\\npkg@2026.3.22"'); + expect(failure.error).toContain("bad\\nupstream"); + expect(failure.error).not.toContain("\n"); + expect(failure.error).not.toContain("\u001b"); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + }); + + it("continues after a risky ClawHub release is acknowledged", async () => { + mockCommunityClawHubPackageDetail(); + const onClawHubRisk = vi.fn(async (_request: ClawHubRiskAcknowledgementRequest) => true); + const logger = { ...createLoggerSpies(), terminalLinks: true }; + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: "suspicious", + moderationState: null, + blockedFromDownload: false, + reasons: ["payload_strings"], + pending: false, + stale: false, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + logger, + onClawHubRisk, + }); + + expectSuccessfulClawHubInstall(result, { clawhubChannel: "community" }); + const success = expectInstallSuccess(result); + expect(success.clawhub?.clawhubTrustDisposition).toBe("review-required"); + expect(success.clawhub?.clawhubTrustScanStatus).toBe("suspicious"); + expect(success.clawhub?.clawhubTrustReasons).toEqual(["payload_strings"]); + expect(success.clawhub?.clawhubTrustCheckedAt).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u, + ); + expect(success.clawhub?.clawhubTrustAcknowledgedAt).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u, + ); + expect(onClawHubRisk).toHaveBeenCalledWith( + expect.objectContaining({ + acknowledgementKind: "type-package", + packageName: "demo", + version: "2026.3.22", + warning: expect.stringContaining("payload strings"), + }), + ); + expect(onClawHubRisk.mock.calls[0]?.[0].warning).not.toContain("\u001b"); + expect(logger.warn.mock.calls.map(([message]) => message).join("\n")).toContain("\u001b]8"); + expect(downloadClawHubPackageArchiveMock).toHaveBeenCalled(); + }); + + it("warns for stale clean ClawHub trust without requiring acknowledgement", async () => { + mockCommunityClawHubPackageDetail(); + const onClawHubRisk = vi.fn(async () => false); + const logger = createLoggerSpies(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: "clean", + moderationState: null, + blockedFromDownload: false, + reasons: [], + pending: false, + stale: true, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + logger, + onClawHubRisk, + }); + + expectSuccessfulClawHubInstall(result, { clawhubChannel: "community" }); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("REVIEW RECOMMENDED")); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("scan data is stale")); + expect(onClawHubRisk).not.toHaveBeenCalled(); + }); + + it("warns for pending ClawHub scans without requiring acknowledgement", async () => { + mockCommunityClawHubPackageDetail(); + const onClawHubRisk = vi.fn(async () => false); + const logger = createLoggerSpies(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: "pending", + moderationState: null, + blockedFromDownload: false, + reasons: ["scan:pending"], + pending: true, + stale: false, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + logger, + onClawHubRisk, + }); + + expectSuccessfulClawHubInstall(result, { clawhubChannel: "community" }); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("REVIEW RECOMMENDED")); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("security scan is pending")); + expect(onClawHubRisk).not.toHaveBeenCalled(); + }); + + it("requires acknowledgement when pending reason codes appear without pending or stale trust", async () => { + mockCommunityClawHubPackageDetail(); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: "pending", + moderationState: null, + blockedFromDownload: false, + reasons: ["scan:pending"], + pending: false, + stale: false, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED); + expect(failure.warning).toContain("WARNING - ClawHub found security risks"); + expect(failure.warning).toContain("Security scan pending"); + expect(failure.warning).toContain("scan pending"); + expect(failure.warning).not.toContain("blockedFromDownload=false"); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + }); + + it("stops when the ClawHub security response is unavailable", async () => { + mockCommunityClawHubPackageDetail(); + fetchClawHubPackageSecurityMock.mockRejectedValueOnce( + new ClawHubRequestError({ + path: "/api/v1/packages/demo/versions/2026.3.22/security", + status: 404, + body: "not found", + }), + ); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE); + expect(failure.error).toContain("ClawHub release trust check failed"); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + }); + + it("bypasses ClawHub trust checks for official packages", async () => { fetchClawHubPackageDetailMock.mockResolvedValueOnce({ package: { name: "demo", @@ -353,13 +1053,24 @@ describe("installPluginFromClawHub", () => { }, }, }); + fetchClawHubPackageSecurityMock.mockRejectedValueOnce(new Error("should not be called")); - await installPluginFromClawHub({ + const result = await installPluginFromClawHub({ spec: "clawhub:demo", baseUrl: "https://clawhub.ai", }); + const success = expectInstallSuccess(result); + expect(success.clawhub?.clawhubTrustDisposition).toBeUndefined(); + expect(success.clawhub?.clawhubTrustScanStatus).toBeUndefined(); + expect(fetchClawHubPackageSecurityMock).not.toHaveBeenCalled(); expect(archiveInstallCall().trustedSourceLinkedOfficialInstall).toBe(true); + expect(archiveInstallCall().installPolicyRequest?.source).toEqual({ + kind: "clawhub", + authority: "official", + mutable: false, + network: true, + }); }); it("resolves explicit ClawHub dist tags before fetching version metadata", async () => { @@ -761,6 +1472,87 @@ describe("installPluginFromClawHub", () => { expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); }); + it("treats blocked ClawHub ClawPack downloads as non-fallback trust failures", async () => { + fetchClawHubPackageVersionMock.mockResolvedValueOnce({ + version: { + version: "2026.3.22", + createdAt: 0, + changelog: "", + compatibility: { + pluginApiRange: ">=2026.3.22", + minGatewayVersion: "2026.3.0", + }, + artifact: { + kind: "npm-pack", + format: "tgz", + sha256: DEMO_CLAWPACK_SHA256, + }, + }, + }); + downloadClawHubPackageArchiveMock.mockRejectedValueOnce( + new ClawHubRequestError({ + path: "/api/v1/packages/demo/versions/2026.3.22/artifact/download", + status: 403, + body: "Blocked: this package release has been flagged as malicious and cannot be downloaded.", + }), + ); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.error).toBe( + 'ClawHub blocked artifact download for "demo@2026.3.22"; install was not started. ClawHub /api/v1/packages/demo/versions/2026.3.22/artifact/download failed (403): Blocked: this package release has been flagged as malicious and cannot be downloaded.', + ); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_DOWNLOAD_BLOCKED); + expect(failure.version).toBe("2026.3.22"); + expect(archiveDownloadCall().artifact).toBe("clawpack"); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + }); + + it("keeps generic forbidden ClawHub ClawPack downloads fallback-eligible", async () => { + fetchClawHubPackageVersionMock.mockResolvedValueOnce({ + version: { + version: "2026.3.22", + createdAt: 0, + changelog: "", + compatibility: { + pluginApiRange: ">=2026.3.22", + minGatewayVersion: "2026.3.0", + }, + artifact: { + kind: "npm-pack", + format: "tgz", + sha256: DEMO_CLAWPACK_SHA256, + }, + }, + }); + downloadClawHubPackageArchiveMock.mockRejectedValueOnce( + new ClawHubRequestError({ + path: "/api/v1/packages/demo/versions/2026.3.22/artifact/download", + status: 403, + body: "Forbidden.", + }), + ); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.ARTIFACT_UNAVAILABLE); + expect(failure.error).toContain( + 'ClawHub artifact download for "demo@2026.3.22" is not available yet', + ); + expect(failure.error).toContain('Use "npm:demo@2026.3.22"'); + expect(failure.version).toBeUndefined(); + expect(archiveDownloadCall().artifact).toBe("clawpack"); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + }); + it("does not persist package-level ClawPack metadata for version records without ClawPack facts", async () => { parseClawHubPluginSpecMock.mockReturnValueOnce({ name: "demo", version: "2026.3.21" }); fetchClawHubPackageDetailMock.mockResolvedValueOnce({ @@ -810,6 +1602,173 @@ describe("installPluginFromClawHub", () => { expect(success.clawhub?.clawpackSize).toBeUndefined(); }); + it("does not inherit package-level compatibility when version-specific compatibility is absent for pinned older version", async () => { + parseClawHubPluginSpecMock.mockReturnValueOnce({ name: "demo", version: "2026.6.8" }); + resolveLatestVersionFromPackageMock.mockReturnValue("2026.6.10"); + fetchClawHubPackageDetailMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + channel: "official", + isOfficial: true, + createdAt: 0, + updatedAt: 0, + latestVersion: "2026.6.10", + compatibility: { + pluginApiRange: ">=2026.6.10", + minGatewayVersion: "2026.6.10", + }, + }, + }); + resolveCompatibilityHostVersionMock.mockReturnValue("2026.6.8"); + fetchClawHubPackageVersionMock.mockResolvedValueOnce({ + version: { + version: "2026.6.8", + createdAt: 0, + changelog: "", + sha256hash: "a9eac48c6129bc44b6f93c9a9f48f6c700d191b7279a1e1915f28df6f59bb1af", + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo@2026.6.8", + baseUrl: "https://clawhub.ai", + }); + + expectSuccessfulClawHubInstall(result); + }); + + it("recovers version-specific compatibility from version endpoint when artifact metadata is sparse", async () => { + parseClawHubPluginSpecMock.mockReturnValueOnce({ name: "demo", version: "2026.6.8" }); + resolveLatestVersionFromPackageMock.mockReturnValue("2026.6.10"); + fetchClawHubPackageDetailMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + channel: "official", + isOfficial: true, + createdAt: 0, + updatedAt: 0, + latestVersion: "2026.6.10", + compatibility: { + pluginApiRange: ">=2026.6.10", + minGatewayVersion: "2026.6.10", + }, + }, + }); + resolveCompatibilityHostVersionMock.mockReturnValue("2026.6.5"); + // Artifact endpoint returns sparse metadata (no compatibility). + fetchClawHubPackageArtifactMock.mockResolvedValueOnce({ + version: { + version: "2026.6.8", + createdAt: 0, + changelog: "", + sha256hash: "a9eac48c6129bc44b6f93c9a9f48f6c700d191b7279a1e1915f28df6f59bb1af", + }, + }); + // Version endpoint has the real version-specific compatibility. + fetchClawHubPackageVersionMock.mockResolvedValueOnce({ + version: { + version: "2026.6.8", + createdAt: 0, + changelog: "", + sha256hash: "a9eac48c6129bc44b6f93c9a9f48f6c700d191b7279a1e1915f28df6f59bb1af", + compatibility: { + pluginApiRange: ">=2026.6.8", + minGatewayVersion: "2026.6.8", + }, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo@2026.6.8", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.error).toContain("2026.6.8"); + }); + + it("fails closed when version endpoint is unavailable for sparse artifact metadata on pinned version", async () => { + parseClawHubPluginSpecMock.mockReturnValueOnce({ name: "demo", version: "2026.6.8" }); + resolveLatestVersionFromPackageMock.mockReturnValue("2026.6.10"); + fetchClawHubPackageDetailMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + channel: "official", + isOfficial: true, + createdAt: 0, + updatedAt: 0, + latestVersion: "2026.6.10", + compatibility: { + pluginApiRange: ">=2026.6.10", + minGatewayVersion: "2026.6.10", + }, + }, + }); + resolveCompatibilityHostVersionMock.mockReturnValue("2026.6.8"); + // Artifact endpoint returns sparse metadata (no compatibility). + fetchClawHubPackageArtifactMock.mockResolvedValueOnce({ + version: { + version: "2026.6.8", + createdAt: 0, + changelog: "", + sha256hash: "a9eac48c6129bc44b6f93c9a9f48f6c700d191b7279a1e1915f28df6f59bb1af", + }, + }); + // Version endpoint fails. + fetchClawHubPackageVersionMock.mockRejectedValueOnce(new Error("500 Internal Server Error")); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo@2026.6.8", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.ok).toBe(false); + }); + + it("enforces package-level compatibility for unpinned latest install when version response omits compatibility", async () => { + resolveLatestVersionFromPackageMock.mockReturnValue("2026.6.10"); + fetchClawHubPackageDetailMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + channel: "official", + isOfficial: true, + createdAt: 0, + updatedAt: 0, + latestVersion: "2026.6.10", + compatibility: { + pluginApiRange: ">=2026.6.10", + minGatewayVersion: "2026.6.10", + }, + }, + }); + resolveCompatibilityHostVersionMock.mockReturnValue("2026.6.8"); + fetchClawHubPackageVersionMock.mockResolvedValueOnce({ + version: { + version: "2026.6.10", + createdAt: 0, + changelog: "", + sha256hash: "a9eac48c6129bc44b6f93c9a9f48f6c700d191b7279a1e1915f28df6f59bb1af", + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + const failure = expectInstallFailure(result); + expect(failure.error).toContain("2026.6.10"); + }); + it("installs when ClawHub advertises a wildcard plugin API range", async () => { fetchClawHubPackageVersionMock.mockResolvedValueOnce({ version: { @@ -1100,6 +2059,10 @@ describe("installPluginFromClawHub", () => { expect(packageDetailCall().name).toBe("DemoAlias"); expect(packageVersionCall().name).toBe("demo"); expect(packageVersionCall().version).toBe("latest"); + expect(fetchClawHubPackageSecurityMock).not.toHaveBeenCalled(); + expect(archiveDownloadCall().name).toBe("demo"); + expect(success.packageName).toBe("demo"); + expect(success.clawhub?.clawhubPackage).toBe("demo"); expect(logger.warn).toHaveBeenCalledWith( 'ClawHub package "demo@2026.3.22" is missing sha256hash; falling back to files[] verification. Validated files: openclaw.plugin.json. Validated generated metadata files present in archive: _meta.json (JSON parse plus slug/version match only).', ); @@ -1164,6 +2127,51 @@ describe("installPluginFromClawHub", () => { expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); }); + it("checks trust before returning artifact-unavailable fallback errors", async () => { + mockCommunityClawHubPackageDetail(); + fetchClawHubPackageVersionMock.mockResolvedValueOnce({ + version: { + version: "2026.3.22", + createdAt: 0, + changelog: "", + compatibility: { + pluginApiRange: ">=2026.3.22", + minGatewayVersion: "2026.3.0", + }, + }, + }); + fetchClawHubPackageSecurityMock.mockResolvedValueOnce({ + package: { + name: "demo", + displayName: "Demo", + family: "code-plugin", + }, + release: { + version: "2026.3.22", + }, + trust: { + scanStatus: "malicious", + moderationState: "quarantined", + blockedFromDownload: true, + reasons: ["scan:malicious"], + pending: false, + stale: false, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + acknowledgeClawHubRisk: true, + }); + + const failure = expectInstallFailure(result); + expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_DOWNLOAD_BLOCKED); + expect(packageSecurityCall().name).toBe("demo"); + expect(failure.error).toBe("ClawHub blocked this release; install was not started."); + expect(failure.warning).toContain("BLOCKED - ClawHub flagged this release as malicious"); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + }); + it("rejects ClawHub installs when the version metadata has no archive hash or fallback files[]", async () => { fetchClawHubPackageVersionMock.mockResolvedValueOnce({ version: { diff --git a/src/plugins/clawhub.ts b/src/plugins/clawhub.ts index e7100e41cbec..758965e1ac00 100644 --- a/src/plugins/clawhub.ts +++ b/src/plugins/clawhub.ts @@ -1,8 +1,14 @@ // Resolves ClawHub plugin catalog entries and install metadata. import { createHash } from "node:crypto"; import fs from "node:fs/promises"; -import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "@openclaw/normalization-core/string-coerce"; import JSZip from "jszip"; +import { visibleWidth } from "../../packages/terminal-core/src/ansi.js"; +import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; +import { formatTerminalLink } from "../../packages/terminal-core/src/terminal-link.js"; import { ARCHIVE_LIMIT_ERROR_CODE, ArchiveLimitError, @@ -12,6 +18,10 @@ import { DEFAULT_MAX_ENTRY_BYTES, loadZipArchiveWithPreflight, } from "../infra/archive.js"; +import { + ensureClawHubPackageTrustAcknowledged, + type ClawHubRiskAcknowledgementRequest, +} from "../infra/clawhub-install-trust.js"; import { ClawHubRequestError, downloadClawHubPackageArchive, @@ -43,17 +53,20 @@ import type { InstallSafetyOverrides } from "./install-security-scan.js"; import { installPluginFromArchive, type InstallPluginResult } from "./install.js"; export { CLAWHUB_INSTALL_ERROR_CODE }; -export type { ClawHubInstallErrorCode }; +export type { ClawHubInstallErrorCode, ClawHubRiskAcknowledgementRequest }; type PluginInstallLogger = { info?: (message: string) => void; warn?: (message: string) => void; + terminalLinks?: boolean; }; type ClawHubInstallFailure = { ok: false; error: string; code?: ClawHubInstallErrorCode; + warning?: string; + version?: string; }; type ClawHubFileEntryLike = { @@ -182,6 +195,16 @@ function isTrustedSourceLinkedOfficialPackage(pkg: NonNullable; +}): boolean { + return ( + isDefaultClawHubBaseUrl(params.baseUrl) && + (params.pkg.channel === "official" || params.pkg.isOfficial) + ); +} + function resolveClawHubClawPackArtifactSha256( clawpack: ClawHubPackageArtifactSummary | ClawHubPackageClawPackSummary | null | undefined, ): string | null { @@ -307,8 +330,16 @@ export function formatClawHubSpecifier(params: { name: string; version?: string function buildClawHubInstallFailure( error: string, code?: ClawHubInstallErrorCode, + warning?: string, + version?: string, ): ClawHubInstallFailure { - return { ok: false, error, code }; + return { + ok: false, + error, + ...(code ? { code } : {}), + ...(warning ? { warning } : {}), + ...(version ? { version } : {}), + }; } function isClawHubInstallFailure(value: unknown): value is ClawHubInstallFailure { @@ -340,6 +371,25 @@ function mapClawHubRequestError( return buildClawHubInstallFailure(formatErrorMessage(error)); } +function encodeClawHubPackagePath(packageName: string): string { + return packageName + .split("/") + .map((part) => encodeURIComponent(part).replaceAll("%40", "@")) + .join("/"); +} + +function resolveClawHubPluginUrl(params: { baseUrl?: string; packageName: string }): string { + return `${resolveClawHubBaseUrl(params.baseUrl)}/plugins/${encodeClawHubPackagePath(params.packageName)}`; +} + +function padRight(value: string, width: number): string { + return `${value}${" ".repeat(Math.max(0, width - visibleWidth(value)))}`; +} + +function formatClawHubReleaseLabel(packageName: string, version: string): string { + return `${sanitizeTerminalText(packageName)}@${sanitizeTerminalText(version)}`; +} + function isMissingArtifactResolverRoute(error: unknown): boolean { return ( error instanceof ClawHubRequestError && @@ -384,6 +434,32 @@ function formatClawHubClawPackDownloadError(params: { return `ClawHub artifact download for "${params.packageName}@${params.version}" is not available yet (${message}). Use "npm:${params.packageName}@${params.version}" for launch installs while ClawHub artifact routing is being rolled out.`; } +function isClawHubArtifactDownloadPolicyBlock(error: unknown): boolean { + if (!(error instanceof ClawHubRequestError)) { + return false; + } + const body = normalizeLowercaseStringOrEmpty(error.responseBody); + return ( + body.includes("blocked from download") || + body.includes("download disabled") || + body.includes("disabled download") || + body.includes("cannot be downloaded") || + body.includes("flagged as malicious") || + body.includes("malicious") || + body.includes("quarantined") || + body.includes("quarantine") || + body.includes("revoked") + ); +} + +function formatClawHubArtifactDownloadPolicyBlock(params: { + error: unknown; + packageName: string; + version: string; +}): string { + return `ClawHub blocked artifact download for "${params.packageName}@${params.version}"; install was not started. ${formatErrorMessage(params.error)}`; +} + function formatClawHubMissingArtifactMetadataError(params: { packageName: string; version: string; @@ -877,11 +953,42 @@ async function resolveCompatiblePackageVersion(params: { } const artifactVersion = readArtifactResolverVersion(artifactResponse, requestedVersion); const resolvedVersion = normalizeOptionalString(artifactVersion.version) ?? requestedVersion; + const latestVersion = resolveLatestVersionFromPackage(params.detail); + // Only fall back to package-level compatibility when the resolved version is the + // package latest. Older pinned versions should not inherit the latest version's + // compatibility requirements. + const packageCompatibilityFallback = + resolvedVersion === latestVersion ? (params.detail.package?.compatibility ?? null) : null; + // When the artifact endpoint returns sparse metadata (no compatibility) for a + // pinned older version, fetch the version endpoint which may have the real + // version-specific compatibility data. + let versionEndpointCompatibility: ClawHubPackageCompatibility | null = null; + if (!artifactVersion.compatibility && resolvedVersion !== latestVersion) { + try { + const selectedVersion = await fetchClawHubPackageVersion({ + name: params.detail.package?.name ?? "", + version: resolvedVersion, + baseUrl: params.baseUrl, + token: params.token, + timeoutMs: params.timeoutMs, + }); + versionEndpointCompatibility = selectedVersion.version?.compatibility ?? null; + } catch (error) { + return mapClawHubRequestError(error, { + stage: "version", + name: params.detail.package?.name ?? "unknown", + version: resolvedVersion, + }); + } + } if (params.detail.package?.family === "skill") { return { ok: true, version: resolvedVersion, - compatibility: artifactVersion.compatibility ?? params.detail.package?.compatibility ?? null, + compatibility: + artifactVersion.compatibility ?? + versionEndpointCompatibility ?? + packageCompatibilityFallback, verification: null, clawpack: artifactVersion.clawpack ?? resolveTopLevelNpmPackArtifact(artifactResponse.artifact), @@ -930,7 +1037,9 @@ async function resolveCompatiblePackageVersion(params: { ok: true, version: resolvedVersion, compatibility: - versionDetail.version?.compatibility ?? params.detail.package?.compatibility ?? null, + versionDetail.version?.compatibility ?? + versionEndpointCompatibility ?? + packageCompatibilityFallback, verification: null, clawpack, }; @@ -942,7 +1051,9 @@ async function resolveCompatiblePackageVersion(params: { ok: true, version: resolvedVersion, compatibility: - versionDetail.version?.compatibility ?? params.detail.package?.compatibility ?? null, + versionDetail.version?.compatibility ?? + versionEndpointCompatibility ?? + packageCompatibilityFallback, verification: verificationState.verification ?? topLevelLegacyVerification, clawpack, }; @@ -1008,32 +1119,42 @@ function logClawHubPackageSummary(params: { detail: ClawHubPackageDetail; version: string; compatibility?: ClawHubPackageCompatibility | null; + baseUrl?: string; logger?: PluginInstallLogger; }) { const pkg = params.detail.package; if (!pkg) { return; } - const verification = pkg.verification?.tier ? ` verification=${pkg.verification.tier}` : ""; - params.logger?.info?.( - `ClawHub ${pkg.family} ${pkg.name}@${params.version} channel=${pkg.channel}${verification}`, - ); + const familyLabel = pkg.family === "code-plugin" ? "plugin" : pkg.family; const compatibilityParts = [ params.compatibility?.pluginApiRange - ? `pluginApi=${params.compatibility.pluginApiRange}` + ? `pluginApi ${params.compatibility.pluginApiRange}` : null, params.compatibility?.minGatewayVersion - ? `minGateway=${params.compatibility.minGatewayVersion}` + ? `minGateway ${params.compatibility.minGatewayVersion}` : null, ].filter(Boolean); - if (compatibilityParts.length > 0) { - params.logger?.info?.(`Compatibility: ${compatibilityParts.join(" ")}`); - } - if (pkg.channel !== "official") { - params.logger?.warn?.( - `ClawHub package "${pkg.name}" is ${pkg.channel}; review source and verification before enabling.`, - ); - } + const pluginUrl = sanitizeTerminalText( + resolveClawHubPluginUrl({ baseUrl: params.baseUrl, packageName: pkg.name }), + ); + params.logger?.info?.( + [ + ` ${padRight("Package", 9)} ${formatClawHubReleaseLabel(pkg.name, params.version)}`, + ` ${padRight("Type", 9)} ${familyLabel}`, + compatibilityParts.length > 0 + ? ` ${padRight("Requires", 9)} ${compatibilityParts.join(" · ")}` + : null, + ` ${padRight("ClawHub", 9)} ${formatTerminalLink("view plugin", pluginUrl, { + fallback: pluginUrl, + ...(params.logger?.terminalLinks !== undefined + ? { force: params.logger.terminalLinks } + : {}), + })}`, + ] + .filter((line) => line !== null) + .join("\n"), + ); } export async function installPluginFromClawHub( @@ -1048,6 +1169,8 @@ export async function installPluginFromClawHub( dryRun?: boolean; expectedPluginId?: string; env?: RuntimeVersionEnv; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; }, ): Promise< | ({ @@ -1103,6 +1226,32 @@ export async function installPluginFromClawHub( } const expectedClawPackSha256 = resolveClawHubClawPackArtifactSha256(versionState.clawpack); const canonicalPackageName = detail.package?.name ?? parsed.name; + const officialClawHubPackage = detail.package + ? isDefaultOfficialClawHubPackage({ baseUrl: params.baseUrl, pkg: detail.package }) + : false; + logClawHubPackageSummary({ + detail, + version: versionState.version, + compatibility: versionState.compatibility, + baseUrl: params.baseUrl, + logger: params.logger, + }); + const trustResult = officialClawHubPackage + ? null + : await ensureClawHubPackageTrustAcknowledged({ + subject: { kind: "plugin", packageName: canonicalPackageName }, + version: versionState.version, + baseUrl: params.baseUrl, + token: params.token, + timeoutMs: params.timeoutMs, + acknowledgeClawHubRisk: params.acknowledgeClawHubRisk, + onClawHubRisk: params.onClawHubRisk, + logger: params.logger, + mode: params.mode, + }); + if (trustResult && !trustResult.ok) { + return trustResult; + } if (!versionState.verification && !expectedClawPackSha256) { return buildClawHubInstallFailure( formatClawHubMissingArtifactMetadataError({ @@ -1112,17 +1261,12 @@ export async function installPluginFromClawHub( CLAWHUB_INSTALL_ERROR_CODE.ARTIFACT_UNAVAILABLE, ); } - logClawHubPackageSummary({ - detail, - version: versionState.version, - compatibility: versionState.compatibility, - logger: params.logger, - }); + const releaseLabel = formatClawHubReleaseLabel(canonicalPackageName, versionState.version); let archive; try { archive = await downloadClawHubPackageArchive({ - name: parsed.name, + name: canonicalPackageName, version: versionState.version, artifact: expectedClawPackSha256 ? "clawpack" : "archive", baseUrl: params.baseUrl, @@ -1130,6 +1274,18 @@ export async function installPluginFromClawHub( timeoutMs: params.timeoutMs, }); } catch (error) { + if (isClawHubArtifactDownloadPolicyBlock(error)) { + return buildClawHubInstallFailure( + formatClawHubArtifactDownloadPolicyBlock({ + error, + packageName: canonicalPackageName, + version: versionState.version, + }), + CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_DOWNLOAD_BLOCKED, + undefined, + versionState.version, + ); + } // Fix-me(clawhub): remove this npm hint once ClawHub ClawPack artifact // routing is live for official package installs. return buildClawHubInstallFailure( @@ -1161,27 +1317,27 @@ export async function installPluginFromClawHub( archive.integrity !== expectedIntegrity ) { return buildClawHubInstallFailure( - `ClawHub ClawPack integrity mismatch for "${parsed.name}@${versionState.version}": expected ${expectedClawPackSha256}, got ${archive.sha256Hex}.`, + `ClawHub ClawPack integrity mismatch for "${releaseLabel}": expected ${expectedClawPackSha256}, got ${archive.sha256Hex}.`, CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH, ); } if (expectedNpmIntegrity && archive.npmIntegrity !== expectedNpmIntegrity) { return buildClawHubInstallFailure( - `ClawHub ClawPack npm integrity mismatch for "${parsed.name}@${versionState.version}": expected ${expectedNpmIntegrity}, got ${archive.npmIntegrity ?? "unknown"}.`, + `ClawHub ClawPack npm integrity mismatch for "${releaseLabel}": expected ${expectedNpmIntegrity}, got ${archive.npmIntegrity ?? "unknown"}.`, CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH, ); } const expectedNpmShasum = resolveClawHubNpmShasum(versionState.clawpack); if (expectedNpmShasum && archive.npmShasum !== expectedNpmShasum) { return buildClawHubInstallFailure( - `ClawHub ClawPack npm shasum mismatch for "${parsed.name}@${versionState.version}": expected ${expectedNpmShasum}, got ${archive.npmShasum ?? "unknown"}.`, + `ClawHub ClawPack npm shasum mismatch for "${releaseLabel}": expected ${expectedNpmShasum}, got ${archive.npmShasum ?? "unknown"}.`, CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH, ); } } else if (versionState.verification?.kind === "archive-integrity") { if (archive.integrity !== versionState.verification.integrity) { return buildClawHubInstallFailure( - `ClawHub archive integrity mismatch for "${parsed.name}@${versionState.version}": expected ${versionState.verification.integrity}, got ${archive.integrity}.`, + `ClawHub archive integrity mismatch for "${releaseLabel}": expected ${versionState.verification.integrity}, got ${archive.integrity}.`, CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH, ); } @@ -1204,18 +1360,19 @@ export async function installPluginFromClawHub( ? ` Validated generated metadata files present in archive: ${fallbackVerification.validatedGeneratedPaths.join(", ")} (JSON parse plus slug/version match only).` : ""; params.logger?.warn?.( - `ClawHub package "${canonicalPackageName}@${versionState.version}" is missing sha256hash; falling back to files[] verification. Validated files: ${validatedPaths}.${validatedGeneratedPaths}`, + `ClawHub package "${releaseLabel}" is missing sha256hash; falling back to files[] verification. Validated files: ${validatedPaths}.${validatedGeneratedPaths}`, ); } const clawhubRegistry = resolveClawHubBaseUrl(params.baseUrl); const clawhubAuthority = isDefaultClawHubBaseUrl(params.baseUrl) ? "openclaw" : "third-party"; params.logger?.info?.( - `Downloading ${detail.package?.family === "bundle-plugin" ? "bundle" : "plugin"} ${parsed.name}@${versionState.version} from ClawHub…`, + `Downloading ${detail.package?.family === "bundle-plugin" ? "bundle" : "plugin"} ${releaseLabel} from ClawHub…`, ); const installResult = await installPluginFromArchive({ archivePath: archive.archivePath, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, - trustedSourceLinkedOfficialInstall: isTrustedSourceLinkedOfficialPackage(detail.package!), + trustedSourceLinkedOfficialInstall: + officialClawHubPackage || isTrustedSourceLinkedOfficialPackage(detail.package!), config: params.config, logger: params.logger, mode: params.mode, @@ -1226,7 +1383,12 @@ export async function installPluginFromClawHub( installPolicyRequest: { kind: "plugin-archive", requestedSpecifier: params.spec, - source: { kind: "clawhub", authority: clawhubAuthority, mutable: false, network: true }, + source: { + kind: "clawhub", + authority: officialClawHubPackage ? "official" : clawhubAuthority, + mutable: false, + network: true, + }, }, }); if (!installResult.ok) { @@ -1259,11 +1421,11 @@ export async function installPluginFromClawHub( } return { ...installResult, - packageName: parsed.name, + packageName: canonicalPackageName, clawhub: { source: "clawhub", clawhubUrl: clawhubRegistry, - clawhubPackage: parsed.name, + clawhubPackage: canonicalPackageName, clawhubFamily, clawhubChannel: pkg.channel, version: installResult.version ?? versionState.version, @@ -1273,6 +1435,7 @@ export async function installPluginFromClawHub( resolvedAt: new Date().toISOString(), ...clawpackFields, ...observedClawPackArtifactFields, + ...(trustResult ? trustResult.trustInstallRecordFields : {}), ...(expectedTarballName && !archive.npmTarballName ? { npmTarballName: expectedTarballName } : {}), diff --git a/src/plugins/install-security-scan.runtime.test.ts b/src/plugins/install-security-scan.runtime.test.ts new file mode 100644 index 000000000000..d7799aeb9997 --- /dev/null +++ b/src/plugins/install-security-scan.runtime.test.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const runInstallPolicyMock = vi.fn(); +const findBlockedManifestDependenciesMock = vi.fn(); +const findBlockedNodeModulesDirectoryMock = vi.fn(); +const findBlockedNodeModulesFileAliasMock = vi.fn(); +const findBlockedPackageDirectoryInPathMock = vi.fn(); +const findBlockedPackageFileAliasInPathMock = vi.fn(); +const getGlobalHookRunnerMock = vi.fn(); + +vi.mock("../security/install-policy.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runInstallPolicy: (...args: unknown[]) => runInstallPolicyMock(...args), + }; +}); + +vi.mock("./dependency-denylist.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + findBlockedManifestDependencies: (...args: unknown[]) => + findBlockedManifestDependenciesMock(...args), + findBlockedNodeModulesDirectory: (...args: unknown[]) => + findBlockedNodeModulesDirectoryMock(...args), + findBlockedNodeModulesFileAlias: (...args: unknown[]) => + findBlockedNodeModulesFileAliasMock(...args), + findBlockedPackageDirectoryInPath: (...args: unknown[]) => + findBlockedPackageDirectoryInPathMock(...args), + findBlockedPackageFileAliasInPath: (...args: unknown[]) => + findBlockedPackageFileAliasInPathMock(...args), + }; +}); + +vi.mock("./hook-runner-global.js", () => ({ + getGlobalHookRunner: () => getGlobalHookRunnerMock(), +})); + +const { + evaluateSkillInstallPolicyRuntime, + preflightPluginNpmInstallPolicyRuntime, + scanBundleInstallSourceRuntime, +} = await import("./install-security-scan.runtime.js"); + +function expectOnlyOperatorPolicyRan() { + expect(runInstallPolicyMock).toHaveBeenCalledTimes(1); + expect(findBlockedManifestDependenciesMock).not.toHaveBeenCalled(); + expect(findBlockedNodeModulesDirectoryMock).not.toHaveBeenCalled(); + expect(findBlockedNodeModulesFileAliasMock).not.toHaveBeenCalled(); + expect(findBlockedPackageDirectoryInPathMock).not.toHaveBeenCalled(); + expect(findBlockedPackageFileAliasInPathMock).not.toHaveBeenCalled(); + expect(getGlobalHookRunnerMock).not.toHaveBeenCalled(); +} + +describe("install security scan official bypass", () => { + beforeEach(() => { + runInstallPolicyMock.mockReset(); + findBlockedManifestDependenciesMock.mockReset(); + findBlockedNodeModulesDirectoryMock.mockReset(); + findBlockedNodeModulesFileAliasMock.mockReset(); + findBlockedPackageDirectoryInPathMock.mockReset(); + findBlockedPackageFileAliasInPathMock.mockReset(); + getGlobalHookRunnerMock.mockReset(); + }); + + it("bypasses plugin install friction for bundled OpenClaw sources", async () => { + const result = await scanBundleInstallSourceRuntime({ + logger: {}, + pluginId: "openclaw/kitchen-sink", + sourceDir: "/tmp/openclaw-bundled-plugin", + source: { kind: "bundled", authority: "openclaw", mutable: false, network: false }, + }); + + expect(result).toBeUndefined(); + expectOnlyOperatorPolicyRan(); + }); + + it("bypasses plugin install friction for official ClawHub sources", async () => { + const result = await scanBundleInstallSourceRuntime({ + logger: {}, + pluginId: "@openclaw/matrix", + sourceDir: "/tmp/openclaw-official-clawhub-plugin", + source: { kind: "clawhub", authority: "official", mutable: false, network: true }, + }); + + expect(result).toBeUndefined(); + expectOnlyOperatorPolicyRan(); + }); + + it("bypasses skill install friction for bundled OpenClaw sources", async () => { + const result = await evaluateSkillInstallPolicyRuntime({ + installId: "node", + logger: {}, + origin: { + type: "openclaw-bundled", + skillName: "peekaboo", + installId: "node", + }, + source: { kind: "bundled", authority: "openclaw", mutable: false, network: false }, + skillName: "peekaboo", + sourceDir: "/tmp/openclaw-bundled-skill/peekaboo", + }); + + expect(result).toBeUndefined(); + expectOnlyOperatorPolicyRan(); + }); + + it("runs only operator policy for official immutable npm sources", async () => { + const result = await preflightPluginNpmInstallPolicyRuntime({ + logger: {}, + packageName: "@openclaw/matrix", + requestedSpecifier: "@openclaw/matrix@latest", + source: { kind: "npm", authority: "official", mutable: false, network: true }, + sourcePath: "/tmp/openclaw-official-npm", + sourcePathKind: "directory", + }); + + expect(result).toBeUndefined(); + expectOnlyOperatorPolicyRan(); + }); + + it("lets operator policy block official sources", async () => { + runInstallPolicyMock.mockResolvedValueOnce({ + blocked: { + code: "security_scan_blocked", + reason: "blocked by operator policy", + }, + }); + + const result = await scanBundleInstallSourceRuntime({ + logger: {}, + pluginId: "@openclaw/matrix", + sourceDir: "/tmp/openclaw-official-clawhub-plugin", + source: { kind: "clawhub", authority: "official", mutable: false, network: true }, + }); + + expect(result).toEqual({ + blocked: { + code: "security_scan_blocked", + reason: "blocked by operator policy", + }, + }); + expectOnlyOperatorPolicyRan(); + }); + + it("still runs install policy for mutable workspace skill sources", async () => { + runInstallPolicyMock.mockResolvedValueOnce({ + blocked: { + code: "security_scan_blocked", + reason: "blocked by operator policy", + }, + }); + + const result = await evaluateSkillInstallPolicyRuntime({ + installId: "node", + logger: {}, + origin: { + type: "workspace", + skillName: "local-skill", + installId: "node", + }, + source: { kind: "workspace", authority: "user", mutable: true, network: false }, + skillName: "local-skill", + sourceDir: "/tmp/local-skill", + }); + + expect(result).toEqual({ + blocked: { + code: "security_scan_blocked", + reason: "blocked by operator policy", + }, + }); + expect(runInstallPolicyMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/plugins/install-security-scan.runtime.ts b/src/plugins/install-security-scan.runtime.ts index ced0fc6b8205..3dfd27877c24 100644 --- a/src/plugins/install-security-scan.runtime.ts +++ b/src/plugins/install-security-scan.runtime.ts @@ -829,6 +829,25 @@ function resolvePolicySource(params: { return { kind: "local-path", authority: "unknown", mutable: true, network: false }; } +function shouldBypassOpenClawInstallFriction(params: { + source?: InstallPolicySource; + trustedSourceLinkedOfficialInstall?: boolean; +}): boolean { + if (params.trustedSourceLinkedOfficialInstall === true) { + return true; + } + const source = params.source; + if (!source || source.mutable) { + return false; + } + if (source.authority === "official") { + return source.kind === "clawhub" || source.kind === "git" || source.kind === "npm"; + } + return ( + source.authority === "openclaw" && (source.kind === "bundled" || source.kind === "managed") + ); +} + async function runOperatorInstallPolicy(params: { config?: OpenClawConfig; logger: InstallScanLogger; @@ -853,6 +872,7 @@ async function runOperatorInstallPolicy(params: { version?: string; extensions?: string[]; }; + trustedSourceLinkedOfficialInstall?: boolean; }): Promise { const result = await runInstallPolicy({ config: params.config, @@ -897,6 +917,30 @@ export async function scanBundleInstallSourceRuntime( source?: InstallPolicySource; }, ): Promise { + const runPolicy = () => + runOperatorInstallPolicy({ + config: params.config, + logger: params.logger, + origin: { type: "plugin-bundle", ...(params.version ? { version: params.version } : {}) }, + source: + params.source ?? resolvePolicySource({ requestKind: params.requestKind ?? "plugin-dir" }), + sourcePath: params.sourceDir, + sourcePathKind: "directory", + targetName: params.pluginId, + targetType: "plugin", + requestKind: params.requestKind ?? "plugin-dir", + requestMode: params.mode ?? "install", + requestedSpecifier: params.requestedSpecifier, + plugin: { + contentType: "bundle", + pluginId: params.pluginId, + manifestId: params.pluginId, + ...(params.version ? { version: params.version } : {}), + }, + }); + if (shouldBypassOpenClawInstallFriction({ source: params.source })) { + return await runPolicy(); + } const dependencyBlocked = await scanPluginDependencyDenylist({ logger: params.logger, packageDir: params.sourceDir, @@ -906,26 +950,7 @@ export async function scanBundleInstallSourceRuntime( return dependencyBlocked; } - const policyResult = await runOperatorInstallPolicy({ - config: params.config, - logger: params.logger, - origin: { type: "plugin-bundle", ...(params.version ? { version: params.version } : {}) }, - source: - params.source ?? resolvePolicySource({ requestKind: params.requestKind ?? "plugin-dir" }), - sourcePath: params.sourceDir, - sourcePathKind: "directory", - targetName: params.pluginId, - targetType: "plugin", - requestKind: params.requestKind ?? "plugin-dir", - requestMode: params.mode ?? "install", - requestedSpecifier: params.requestedSpecifier, - plugin: { - contentType: "bundle", - pluginId: params.pluginId, - manifestId: params.pluginId, - ...(params.version ? { version: params.version } : {}), - }, - }); + const policyResult = await runPolicy(); if (policyResult?.blocked) { return policyResult; } @@ -966,8 +991,44 @@ export async function scanPackageInstallSourceRuntime( manifestId?: string; version?: string; source?: InstallPolicySource; + trustedSourceLinkedOfficialInstall?: boolean; }, ): Promise { + const runPolicy = () => + runOperatorInstallPolicy({ + config: params.config, + logger: params.logger, + origin: { + type: "plugin-package", + ...(params.packageName ? { packageName: params.packageName } : {}), + ...(params.version ? { version: params.version } : {}), + }, + source: + params.source ?? resolvePolicySource({ requestKind: params.requestKind ?? "plugin-dir" }), + sourcePath: params.packageDir, + sourcePathKind: "directory", + targetName: params.pluginId, + targetType: "plugin", + requestKind: params.requestKind ?? "plugin-dir", + requestMode: params.mode ?? "install", + requestedSpecifier: params.requestedSpecifier, + plugin: { + contentType: "package", + pluginId: params.pluginId, + ...(params.packageName ? { packageName: params.packageName } : {}), + ...(params.manifestId ? { manifestId: params.manifestId } : {}), + ...(params.version ? { version: params.version } : {}), + extensions: params.extensions.slice(), + }, + }); + if ( + shouldBypassOpenClawInstallFriction({ + source: params.source, + trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, + }) + ) { + return await runPolicy(); + } const dependencyBlocked = await scanPluginDependencyDenylist({ logger: params.logger, packageDir: params.packageDir, @@ -977,32 +1038,7 @@ export async function scanPackageInstallSourceRuntime( return dependencyBlocked; } - const policyResult = await runOperatorInstallPolicy({ - config: params.config, - logger: params.logger, - origin: { - type: "plugin-package", - ...(params.packageName ? { packageName: params.packageName } : {}), - ...(params.version ? { version: params.version } : {}), - }, - source: - params.source ?? resolvePolicySource({ requestKind: params.requestKind ?? "plugin-dir" }), - sourcePath: params.packageDir, - sourcePathKind: "directory", - targetName: params.pluginId, - targetType: "plugin", - requestKind: params.requestKind ?? "plugin-dir", - requestMode: params.mode ?? "install", - requestedSpecifier: params.requestedSpecifier, - plugin: { - contentType: "package", - pluginId: params.pluginId, - ...(params.packageName ? { packageName: params.packageName } : {}), - ...(params.manifestId ? { manifestId: params.manifestId } : {}), - ...(params.version ? { version: params.version } : {}), - extensions: params.extensions.slice(), - }, - }); + const policyResult = await runPolicy(); if (policyResult?.blocked) { return policyResult; } @@ -1045,6 +1081,34 @@ export async function scanInstalledPackageDependencyTreeRuntime(params: { source?: InstallPolicySource; trustedSourceLinkedOfficialInstall?: boolean; }): Promise { + const requestKind = params.requestKind ?? "plugin-npm"; + const runPolicy = () => + runOperatorInstallPolicy({ + config: params.config, + logger: params.logger, + origin: { type: "plugin-dependency-tree" }, + source: params.source ?? resolvePolicySource({ requestKind }), + sourcePath: params.dependencyScanRootDir ?? params.packageDir, + sourcePathKind: "directory", + targetName: params.pluginId, + targetType: "plugin", + requestKind, + requestMode: params.mode ?? "install", + requestedSpecifier: params.requestedSpecifier, + plugin: { + contentType: "dependency-tree", + pluginId: params.pluginId, + }, + trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, + }); + if ( + shouldBypassOpenClawInstallFriction({ + source: params.source, + trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, + }) + ) { + return await runPolicy(); + } const scanRoots = await collectInstalledPackageScanRoots({ ...(params.additionalPackageDirs ? { additionalPackageDirs: params.additionalPackageDirs } @@ -1066,24 +1130,7 @@ export async function scanInstalledPackageDependencyTreeRuntime(params: { } } - const requestKind = params.requestKind ?? "plugin-npm"; - return await runOperatorInstallPolicy({ - config: params.config, - logger: params.logger, - origin: { type: "plugin-dependency-tree" }, - source: params.source ?? resolvePolicySource({ requestKind }), - sourcePath: params.dependencyScanRootDir ?? params.packageDir, - sourcePathKind: "directory", - targetName: params.pluginId, - targetType: "plugin", - requestKind, - requestMode: params.mode ?? "install", - requestedSpecifier: params.requestedSpecifier, - plugin: { - contentType: "dependency-tree", - pluginId: params.pluginId, - }, - }); + return await runPolicy(); } export async function scanFileInstallSourceRuntime( @@ -1211,24 +1258,30 @@ export async function evaluateSkillInstallPolicyRuntime(params: { skillName: string; sourceDir: string; }): Promise { - const policyResult = await runOperatorInstallPolicy({ - config: params.config, - logger: params.logger, - origin: params.origin, - source: - params.source ?? resolvePolicySource({ requestKind: "skill-install", origin: params.origin }), - sourcePath: params.sourceDir, - sourcePathKind: "directory", - targetName: params.skillName, - targetType: "skill", - requestKind: "skill-install", - requestMode: params.mode ?? "install", - requestedSpecifier: params.requestedSpecifier, - skill: { - installId: params.installId, - ...(params.installSpec ? { installSpec: params.installSpec } : {}), - }, - }); + const runPolicy = () => + runOperatorInstallPolicy({ + config: params.config, + logger: params.logger, + origin: params.origin, + source: + params.source ?? + resolvePolicySource({ requestKind: "skill-install", origin: params.origin }), + sourcePath: params.sourceDir, + sourcePathKind: "directory", + targetName: params.skillName, + targetType: "skill", + requestKind: "skill-install", + requestMode: params.mode ?? "install", + requestedSpecifier: params.requestedSpecifier, + skill: { + installId: params.installId, + ...(params.installSpec ? { installSpec: params.installSpec } : {}), + }, + }); + if (shouldBypassOpenClawInstallFriction({ source: params.source })) { + return await runPolicy(); + } + const policyResult = await runPolicy(); if (policyResult?.blocked) { return policyResult; } diff --git a/src/plugins/install-security-scan.ts b/src/plugins/install-security-scan.ts index 04100421966a..9236fd6b0f68 100644 --- a/src/plugins/install-security-scan.ts +++ b/src/plugins/install-security-scan.ts @@ -86,6 +86,7 @@ export async function scanPackageInstallSource( manifestId?: string; version?: string; source?: InstallPolicySource; + trustedSourceLinkedOfficialInstall?: boolean; }, ): Promise { const { scanPackageInstallSourceRuntime } = await loadInstallSecurityScanRuntime(); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 5cb315a56872..305fafa1dc89 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -3093,6 +3093,12 @@ export async function installPluginFromNpmSpec( if (compatibilityError) { return compatibilityError; } + const npmInstallPolicySource = { + kind: "npm", + authority: params.trustedSourceLinkedOfficialInstall ? "official" : "third-party", + mutable: false, + network: true, + } as const; const driftResult = await resolveNpmIntegrityDriftWithDefaultMessage({ spec, expectedIntegrity: params.expectedIntegrity, @@ -3162,7 +3168,7 @@ export async function installPluginFromNpmSpec( packageName: parsedSpec.name, ...(expectedPluginId ? { pluginId: expectedPluginId } : {}), requestedSpecifier: spec, - source: { kind: "npm", authority: "third-party", mutable: false, network: true }, + source: npmInstallPolicySource, sourcePath: policyMetadataPath, sourcePathKind: "file", }), @@ -3187,7 +3193,7 @@ export async function installPluginFromNpmSpec( installPolicyRequest: { kind: "plugin-npm", requestedSpecifier: spec, - source: { kind: "npm", authority: "third-party", mutable: false, network: true }, + source: npmInstallPolicySource, }, extensionsDir: params.extensionsDir, npmDir: params.npmDir, diff --git a/src/plugins/installed-plugin-index-install-records.ts b/src/plugins/installed-plugin-index-install-records.ts index f1085ff7cd60..2ff865c7015e 100644 --- a/src/plugins/installed-plugin-index-install-records.ts +++ b/src/plugins/installed-plugin-index-install-records.ts @@ -29,6 +29,31 @@ function setInstallNumberField>( + target: InstalledPluginInstallRecordInfo, + key: Key, + value: PluginInstallRecord[Key], +): void { + if (typeof value === "boolean") { + target[key] = value as InstalledPluginInstallRecordInfo[Key]; + } +} + +function setInstallStringArrayField< + Key extends keyof Omit, +>(target: InstalledPluginInstallRecordInfo, key: Key, value: PluginInstallRecord[Key]): void { + if (!Array.isArray(value)) { + return; + } + const normalized = value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + if (normalized.length > 0) { + target[key] = normalized as InstalledPluginInstallRecordInfo[Key]; + } +} + function normalizeInstallRecord( record: PluginInstallRecord | undefined, ): InstalledPluginInstallRecordInfo | undefined { @@ -53,6 +78,22 @@ function normalizeInstallRecord( setInstallStringField(normalized, "clawhubPackage", record.clawhubPackage); setInstallStringField(normalized, "clawhubFamily", record.clawhubFamily); setInstallStringField(normalized, "clawhubChannel", record.clawhubChannel); + setInstallStringField(normalized, "clawhubTrustDisposition", record.clawhubTrustDisposition); + setInstallStringField(normalized, "clawhubTrustScanStatus", record.clawhubTrustScanStatus); + setInstallStringField( + normalized, + "clawhubTrustModerationState", + record.clawhubTrustModerationState, + ); + setInstallStringArrayField(normalized, "clawhubTrustReasons", record.clawhubTrustReasons); + setInstallBooleanField(normalized, "clawhubTrustPending", record.clawhubTrustPending); + setInstallBooleanField(normalized, "clawhubTrustStale", record.clawhubTrustStale); + setInstallStringField(normalized, "clawhubTrustCheckedAt", record.clawhubTrustCheckedAt); + setInstallStringField( + normalized, + "clawhubTrustAcknowledgedAt", + record.clawhubTrustAcknowledgedAt, + ); setInstallStringField(normalized, "artifactKind", record.artifactKind); setInstallStringField(normalized, "artifactFormat", record.artifactFormat); setInstallStringField(normalized, "npmIntegrity", record.npmIntegrity); diff --git a/src/plugins/installed-plugin-index-records.test.ts b/src/plugins/installed-plugin-index-records.test.ts index ec7eb05f25eb..a95326834986 100644 --- a/src/plugins/installed-plugin-index-records.test.ts +++ b/src/plugins/installed-plugin-index-records.test.ts @@ -594,6 +594,12 @@ describe("plugin index install records store", () => { clawpackManifestSha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", clawpackSize: 4096, + clawhubTrustDisposition: "review-required", + clawhubTrustScanStatus: "suspicious", + clawhubTrustReasons: ["payload_strings"], + clawhubTrustPending: true, + clawhubTrustCheckedAt: "2026-05-14T18:00:00.000Z", + clawhubTrustAcknowledgedAt: "2026-05-14T18:00:03.000Z", }, }, { stateDir, candidates: [candidate] }, @@ -612,6 +618,12 @@ describe("plugin index install records store", () => { clawpackSpecVersion: 1, clawpackManifestSha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", clawpackSize: 4096, + clawhubTrustDisposition: "review-required", + clawhubTrustScanStatus: "suspicious", + clawhubTrustReasons: ["payload_strings"], + clawhubTrustPending: true, + clawhubTrustCheckedAt: "2026-05-14T18:00:00.000Z", + clawhubTrustAcknowledgedAt: "2026-05-14T18:00:03.000Z", }); }); diff --git a/src/plugins/installed-plugin-index-types.ts b/src/plugins/installed-plugin-index-types.ts index e22f1b9c3a6d..e2bfa302c722 100644 --- a/src/plugins/installed-plugin-index-types.ts +++ b/src/plugins/installed-plugin-index-types.ts @@ -68,6 +68,14 @@ export type InstalledPluginInstallRecordInfo = Pick< | "clawhubPackage" | "clawhubFamily" | "clawhubChannel" + | "clawhubTrustDisposition" + | "clawhubTrustScanStatus" + | "clawhubTrustModerationState" + | "clawhubTrustReasons" + | "clawhubTrustPending" + | "clawhubTrustStale" + | "clawhubTrustCheckedAt" + | "clawhubTrustAcknowledgedAt" | "artifactKind" | "artifactFormat" | "npmIntegrity" diff --git a/src/plugins/installs.test.ts b/src/plugins/installs.test.ts index ac8ea5c82438..f699c2ddc65e 100644 --- a/src/plugins/installs.test.ts +++ b/src/plugins/installs.test.ts @@ -146,4 +146,50 @@ describe("recordPluginInstall", () => { expectRecordedInstall("demo", next); }); + + it("clears stale ClawHub trust metadata when a later install omits it", () => { + const existing = recordPluginInstall( + {}, + { + pluginId: "demo", + source: "clawhub", + spec: "clawhub:demo@1.0.0", + installPath: "/tmp/openclaw/plugins/demo", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubTrustDisposition: "review-required", + clawhubTrustScanStatus: "suspicious", + clawhubTrustReasons: ["payload_strings"], + clawhubTrustPending: true, + clawhubTrustCheckedAt: "2026-05-14T18:00:00.000Z", + clawhubTrustAcknowledgedAt: "2026-05-14T18:00:03.000Z", + }, + ); + + const next = recordPluginInstall(existing, { + pluginId: "demo", + source: "clawhub", + spec: "clawhub:demo@1.1.0", + installPath: "/tmp/openclaw/plugins/demo", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubTrustDisposition: "clean", + clawhubTrustCheckedAt: "2026-05-15T00:00:00.000Z", + installedAt: "2026-05-15T00:00:03.000Z", + }); + + expect(next.plugins?.installs?.demo).toEqual({ + source: "clawhub", + spec: "clawhub:demo@1.1.0", + installPath: "/tmp/openclaw/plugins/demo", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubTrustDisposition: "clean", + clawhubTrustCheckedAt: "2026-05-15T00:00:00.000Z", + installedAt: "2026-05-15T00:00:03.000Z", + }); + }); }); diff --git a/src/plugins/installs.ts b/src/plugins/installs.ts index bacdf38faa9a..73a4f7bb934a 100644 --- a/src/plugins/installs.ts +++ b/src/plugins/installs.ts @@ -7,6 +7,17 @@ import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; /** Plugin install record update with the target plugin id attached. */ export type PluginInstallUpdate = PluginInstallRecord & { pluginId: string }; +const CLAWHUB_TRUST_INSTALL_RECORD_FIELDS = [ + "clawhubTrustDisposition", + "clawhubTrustScanStatus", + "clawhubTrustModerationState", + "clawhubTrustReasons", + "clawhubTrustPending", + "clawhubTrustStale", + "clawhubTrustCheckedAt", + "clawhubTrustAcknowledgedAt", +] as const satisfies readonly (keyof PluginInstallRecord)[]; + /** Builds install record fields from resolved npm package metadata. */ export function buildNpmResolutionInstallFields( resolution?: NpmSpecResolution, @@ -40,10 +51,11 @@ export function recordPluginInstall( update: PluginInstallUpdate, ): OpenClawConfig { const { pluginId, ...record } = update; + const previous = clearStaleInstallRecordFields(cfg.plugins?.installs?.[pluginId]); const installs = { ...cfg.plugins?.installs, [pluginId]: { - ...cfg.plugins?.installs?.[pluginId], + ...previous, ...record, installedAt: record.installedAt ?? new Date().toISOString(), }, @@ -60,3 +72,14 @@ export function recordPluginInstall( }, }; } + +function clearStaleInstallRecordFields(record: PluginInstallRecord | undefined) { + if (!record) { + return undefined; + } + const next: PluginInstallRecord = { ...record }; + for (const field of CLAWHUB_TRUST_INSTALL_RECORD_FIELDS) { + delete next[field]; + } + return next; +} diff --git a/src/plugins/manifest-registry-installed.test.ts b/src/plugins/manifest-registry-installed.test.ts index cbe3a2b22309..4dfd5dc8c44d 100644 --- a/src/plugins/manifest-registry-installed.test.ts +++ b/src/plugins/manifest-registry-installed.test.ts @@ -6,7 +6,7 @@ import { readPersistedInstalledPluginIndex, writePersistedInstalledPluginIndex, } from "./installed-plugin-index-store.js"; -import type { InstalledPluginIndex } from "./installed-plugin-index.js"; +import type { InstalledPluginIndex, InstalledPluginIndexRecord } from "./installed-plugin-index.js"; import { loadPluginManifestRegistryForInstalledIndex, resolveInstalledManifestRegistryIndexFingerprint, @@ -151,6 +151,24 @@ function createIndexWithPackageJson(rootDir: string): InstalledPluginIndex { }; } +function createIndexWithUnhashedPackageJson(rootDir: string): InstalledPluginIndex { + const index = createIndexWithFileSignatures(rootDir); + const packageJsonPath = writePackageManifest(rootDir, "Installed"); + const record = index.plugins[0]; + if (!record) { + throw new Error("expected index record"); + } + record.packageJson = { + path: "package.json", + hash: "", + fileSignature: fileSignature(packageJsonPath), + }; + return { + ...index, + plugins: [record], + }; +} + describe("loadPluginManifestRegistryForInstalledIndex", () => { it("reuses frozen installed-index fingerprints when file signatures are persisted", () => { const rootDir = makeTempDir(); @@ -180,6 +198,97 @@ describe("loadPluginManifestRegistryForInstalledIndex", () => { expect(second).not.toBe(first); }); + it("reuses package realpaths across mutable installed-index fingerprint builds", () => { + const rootDir = makeTempDir(); + writePlugin(rootDir, "installed", "installed-"); + const index = createIndexWithUnhashedPackageJson(rootDir); + const packageJsonPath = path.join(fs.realpathSync(rootDir), "package.json"); + const realpathSpy = vi.spyOn(fs, "realpathSync"); + let rootPathCalls: unknown[][]; + let packageJsonPathCalls: unknown[][]; + try { + resolveInstalledManifestRegistryIndexFingerprint(index); + resolveInstalledManifestRegistryIndexFingerprint(index); + rootPathCalls = realpathSpy.mock.calls.filter(([filePath]) => filePath === rootDir); + packageJsonPathCalls = realpathSpy.mock.calls.filter( + ([filePath]) => filePath === packageJsonPath, + ); + } finally { + realpathSpy.mockRestore(); + } + + expect(rootPathCalls).toHaveLength(1); + expect(packageJsonPathCalls).toHaveLength(1); + }); + + it("clears package realpath memoization with plugin metadata lifecycle caches", () => { + const rootDir = makeTempDir(); + writePlugin(rootDir, "installed", "installed-"); + const index = createIndexWithUnhashedPackageJson(rootDir); + const packageJsonPath = path.join(fs.realpathSync(rootDir), "package.json"); + const realpathSpy = vi.spyOn(fs, "realpathSync"); + let rootPathCalls: unknown[][]; + let packageJsonPathCalls: unknown[][]; + try { + resolveInstalledManifestRegistryIndexFingerprint(index); + clearPluginMetadataLifecycleCaches(); + resolveInstalledManifestRegistryIndexFingerprint(index); + rootPathCalls = realpathSpy.mock.calls.filter(([filePath]) => filePath === rootDir); + packageJsonPathCalls = realpathSpy.mock.calls.filter( + ([filePath]) => filePath === packageJsonPath, + ); + } finally { + realpathSpy.mockRestore(); + } + + expect(rootPathCalls).toHaveLength(2); + expect(packageJsonPathCalls).toHaveLength(2); + }); + + it("bounds package realpath memoization across many fingerprint roots", () => { + const firstRootDir = makeTempDir(); + writePlugin(firstRootDir, "installed", "installed-"); + const firstIndex = createIndexWithUnhashedPackageJson(firstRootDir); + resolveInstalledManifestRegistryIndexFingerprint(firstIndex); + + const records: InstalledPluginIndexRecord[] = []; + for (let index = 0; index < 300; index += 1) { + const rootDir = makeTempDir(); + const pluginId = `installed-${index}`; + writePlugin(rootDir, pluginId, `${pluginId}-`); + const record = createIndexWithUnhashedPackageJson(rootDir).plugins[0]; + if (!record) { + throw new Error("expected index record"); + } + records.push({ + ...record, + pluginId, + manifestHash: `manifest-hash-${index}`, + }); + } + resolveInstalledManifestRegistryIndexFingerprint({ + ...firstIndex, + plugins: records, + }); + + const packageJsonPath = path.join(fs.realpathSync(firstRootDir), "package.json"); + const realpathSpy = vi.spyOn(fs, "realpathSync"); + let rootPathCalls: unknown[][]; + let packageJsonPathCalls: unknown[][]; + try { + resolveInstalledManifestRegistryIndexFingerprint(firstIndex); + rootPathCalls = realpathSpy.mock.calls.filter(([filePath]) => filePath === firstRootDir); + packageJsonPathCalls = realpathSpy.mock.calls.filter( + ([filePath]) => filePath === packageJsonPath, + ); + } finally { + realpathSpy.mockRestore(); + } + + expect(rootPathCalls).toHaveLength(1); + expect(packageJsonPathCalls).toHaveLength(1); + }); + it("does not cache shallow-frozen installed-index fingerprints with mutable nested records", () => { const rootDir = makeTempDir(); writePlugin(rootDir, "installed", "installed-"); diff --git a/src/plugins/manifest-registry-installed.ts b/src/plugins/manifest-registry-installed.ts index 226280d7daf0..e64123f69338 100644 --- a/src/plugins/manifest-registry-installed.ts +++ b/src/plugins/manifest-registry-installed.ts @@ -32,8 +32,12 @@ import { const installedManifestRegistryIndexFingerprintCache = new WeakMap(); const installedPackageJsonPathCache = new Map(); const installedPackageMetadataCache = new Map(); +// Installed plugin metadata is process-stable between explicit lifecycle clears. +// Share realpaths across fingerprint builds to avoid repeated package boundary IO. +const installedManifestRegistryRealpathCache = new Map(); const MAX_INSTALLED_PACKAGE_JSON_PATH_CACHE_ENTRIES = 256; const MAX_INSTALLED_PACKAGE_METADATA_CACHE_ENTRIES = 256; +const MAX_INSTALLED_MANIFEST_REGISTRY_REALPATH_CACHE_ENTRIES = 512; type InstalledPackageMetadata = { packageManifest?: OpenClawPackageManifest; @@ -44,6 +48,7 @@ type InstalledPackageMetadata = { export function clearInstalledManifestRegistryProcessCaches(): void { installedPackageJsonPathCache.clear(); installedPackageMetadataCache.clear(); + installedManifestRegistryRealpathCache.clear(); } registerPluginMetadataProcessMemoLifecycleClear(clearInstalledManifestRegistryProcessCaches); @@ -169,6 +174,19 @@ function rememberInstalledPackageJsonPath( return packageJsonPath; } +function trimInstalledManifestRegistryRealpathCache(): void { + while ( + installedManifestRegistryRealpathCache.size > + MAX_INSTALLED_MANIFEST_REGISTRY_REALPATH_CACHE_ENTRIES + ) { + const oldest = installedManifestRegistryRealpathCache.keys().next().value; + if (oldest === undefined) { + break; + } + installedManifestRegistryRealpathCache.delete(oldest); + } +} + function buildInstalledPackageJsonPathCacheKey( record: InstalledPluginIndexRecord, ): string | undefined { @@ -196,7 +214,6 @@ function buildInstalledPackageMetadataCacheKey(params: { } function buildInstalledManifestRegistryIndexKey(index: InstalledPluginIndex) { - const realpathCache = new Map(); return { version: index.version, hostContractVersion: index.hostContractVersion, @@ -206,7 +223,11 @@ function buildInstalledManifestRegistryIndexKey(index: InstalledPluginIndex) { installRecords: index.installRecords, diagnostics: index.diagnostics, plugins: index.plugins.map((record) => { - const packageJsonPath = resolvePackageJsonPath(record, realpathCache); + const packageJsonPath = resolvePackageJsonPath( + record, + installedManifestRegistryRealpathCache, + ); + trimInstalledManifestRegistryRealpathCache(); const packageJsonFile = record.packageJson?.fileSignature ? packageJsonPath ? formatFileSignature(packageJsonPath, record.packageJson.fileSignature) diff --git a/src/plugins/memory-state.ts b/src/plugins/memory-state.ts index 5701d472ce88..1c21db827b6c 100644 --- a/src/plugins/memory-state.ts +++ b/src/plugins/memory-state.ts @@ -102,6 +102,21 @@ export type MemoryPluginRuntime = { purpose?: "default" | "status" | "cli"; }): Promise<{ manager: RegisteredMemorySearchManager | null; + debug?: { + backend?: "builtin" | "qmd"; + purpose?: "default" | "status" | "cli"; + managerMs?: number; + managerCacheState?: + | "cached-full-hit" + | "cached-full-miss" + | "transient-cli" + | "transient-status" + | "pending-create-wait" + | "fallback-builtin" + | "recent-failure-cooldown"; + qmdIdentityHash?: string; + failureCode?: "qmd-unavailable"; + }; error?: string; }>; resolveMemoryBackendConfig(params: { diff --git a/src/plugins/npm-install-security-scan.release.test.ts b/src/plugins/npm-install-security-scan.release.test.ts index 7724d06ecfc2..a67ed607f0fc 100644 --- a/src/plugins/npm-install-security-scan.release.test.ts +++ b/src/plugins/npm-install-security-scan.release.test.ts @@ -34,6 +34,8 @@ const REQUIRED_REVIEWED_PUBLISHABLE_CRITICAL_FINDINGS = new Set([ "@openclaw/google-meet:dangerous-exec:src/node-host.ts", "@openclaw/google-meet:dangerous-exec:src/realtime.ts", "@openclaw/matrix:dangerous-exec:src/matrix/deps.ts", + "@openclaw/raft:dangerous-exec:src/gateway.ts", + "@openclaw/signal:dangerous-exec:src/daemon.ts", "@openclaw/voice-call:dangerous-exec:src/tunnel.ts", "@openclaw/voice-call:dangerous-exec:src/webhook/tailscale.ts", ]); diff --git a/src/plugins/plugin-metadata-snapshot.memo.test.ts b/src/plugins/plugin-metadata-snapshot.memo.test.ts index 0cf9bfb9a5c0..8b51d55c906f 100644 --- a/src/plugins/plugin-metadata-snapshot.memo.test.ts +++ b/src/plugins/plugin-metadata-snapshot.memo.test.ts @@ -274,6 +274,36 @@ describe("loadPluginMetadataSnapshot process memo", () => { expect(second.byPluginId.get("demo")).toBe(second.plugins[0]); }); + it("does not emit metadata scan spans for hot memo hits", () => { + const stateDir = tempStateDir(); + const timelinePath = path.join(stateDir, "timeline", "metadata.jsonl"); + const env = { + OPENCLAW_DIAGNOSTICS: "timeline", + OPENCLAW_DIAGNOSTICS_TIMELINE_PATH: timelinePath, + }; + touchPersistedIndex(stateDir); + loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ + source: "persisted", + snapshot: makeIndex(), + diagnostics: [], + }); + + loadPluginMetadataSnapshot({ config: {}, env, stateDir }); + loadPluginMetadataSnapshot({ config: {}, env, stateDir }); + loadPluginMetadataSnapshot({ config: {}, env, stateDir }); + + const events = fs + .readFileSync(timelinePath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { name?: unknown; type?: unknown }); + expect(events.map((event) => [event.type, event.name])).toEqual([ + ["span.start", "plugins.metadata.scan"], + ["span.end", "plugins.metadata.scan"], + ]); + expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce(); + }); + it("skips persisted registry filesystem fingerprints after a process memo hit", () => { const stateDir = tempStateDir(); touchPersistedIndex(stateDir); diff --git a/src/plugins/plugin-metadata-snapshot.ts b/src/plugins/plugin-metadata-snapshot.ts index 1e5686264fee..6f5095321c8f 100644 --- a/src/plugins/plugin-metadata-snapshot.ts +++ b/src/plugins/plugin-metadata-snapshot.ts @@ -578,16 +578,7 @@ export function loadPluginMetadataSnapshot( const memoKey = computePluginMetadataSnapshotMemoKey({ params, registryState }); const memo = findPluginMetadataSnapshotMemo(memoKey); if (memo?.key === memoKey) { - return measureDiagnosticsTimelineSpanSync("plugins.metadata.scan", () => memo.snapshot, { - phase: activeTimelineSpan?.phase ?? "startup", - config: params.config, - env: params.env, - attributes: { - cacheHit: true, - hasWorkspaceDir: params.workspaceDir !== undefined, - hasInstalledIndex: params.index !== undefined, - }, - }); + return memo.snapshot; } const result = measureDiagnosticsTimelineSpanSync( diff --git a/src/plugins/provider-self-hosted-setup.test.ts b/src/plugins/provider-self-hosted-setup.test.ts index d64299924384..e755848757ed 100644 --- a/src/plugins/provider-self-hosted-setup.test.ts +++ b/src/plugins/provider-self-hosted-setup.test.ts @@ -23,6 +23,42 @@ beforeEach(() => { vi.clearAllMocks(); }); +// Mirrors SELF_HOSTED_DISCOVERY_JSON_MAX_BYTES in the source under test. Kept in +// sync deliberately so the regression asserts the body is capped, not drained. +const SELF_HOSTED_DISCOVERY_JSON_MAX_BYTES = 16 * 1024 * 1024; +const CHUNK_BYTES = 1024 * 1024; + +/** + * Builds a Response body that would never terminate on its own: each pull emits + * a 1 MiB chunk forever. A bounded reader must cancel it after the byte cap. + */ +function createUnboundedJsonStream(): { + body: ReadableStream; + cancelCount: number; + bytesPulled: number; +} { + const state = { cancelCount: 0, bytesPulled: 0 }; + const chunk = new Uint8Array(CHUNK_BYTES).fill(0x20); // ASCII spaces: valid stream, never closes + const body = new ReadableStream({ + pull(controller) { + state.bytesPulled += chunk.byteLength; + controller.enqueue(chunk); + }, + cancel() { + state.cancelCount += 1; + }, + }); + return { + body, + get cancelCount() { + return state.cancelCount; + }, + get bytesPulled() { + return state.bytesPulled; + }, + }; +} + function createRuntime() { return { error: vi.fn(), @@ -437,6 +473,75 @@ describe("discoverOpenAICompatibleLocalModels", () => { }); expect(release).toHaveBeenCalledOnce(); }); + + it("bounds an unbounded /models discovery stream instead of buffering it", async () => { + const release = vi.fn(async () => undefined); + const oversized = createUnboundedJsonStream(); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(oversized.body, { status: 200 }), + finalUrl: "http://127.0.0.1:8000/v1/models", + release, + }); + + const models = await discoverOpenAICompatibleLocalModels({ + baseUrl: "http://127.0.0.1:8000/v1", + label: "vLLM", + env: {}, + }); + + // The reader cancels the body once the byte cap is exceeded; without the + // cap the stream would never finish and the discovery would buffer it all. + expect(models).toEqual([]); + expect(oversized.cancelCount).toBe(1); + expect(oversized.bytesPulled).toBeLessThanOrEqual( + SELF_HOSTED_DISCOVERY_JSON_MAX_BYTES + 2 * CHUNK_BYTES, + ); + expect(release).toHaveBeenCalledOnce(); + }); + + it("bounds an unbounded llama.cpp /props discovery stream instead of buffering it", async () => { + const modelsRelease = vi.fn(async () => undefined); + const propsRelease = vi.fn(async () => undefined); + const oversized = createUnboundedJsonStream(); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(JSON.stringify({ data: [{ id: "qwen3.6-mxfp4-moe" }] }), { + status: 200, + }), + finalUrl: "http://127.0.0.1:8080/v1/models", + release: modelsRelease, + }); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(oversized.body, { status: 200 }), + finalUrl: "http://127.0.0.1:8080/props", + release: propsRelease, + }); + + const models = await discoverOpenAICompatibleLocalModels({ + baseUrl: "http://127.0.0.1:8080/v1", + label: "llama.cpp", + env: {}, + }); + + // /props overflow is swallowed so discovery still succeeds, but the body is + // capped: the runtime context token probe is skipped, not OOM'd. + expect(models).toEqual([ + { + id: "qwen3.6-mxfp4-moe", + name: "qwen3.6-mxfp4-moe", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }, + ]); + expect(oversized.cancelCount).toBe(1); + expect(oversized.bytesPulled).toBeLessThanOrEqual( + SELF_HOSTED_DISCOVERY_JSON_MAX_BYTES + 2 * CHUNK_BYTES, + ); + expect(modelsRelease).toHaveBeenCalledOnce(); + expect(propsRelease).toHaveBeenCalledOnce(); + }); }); describe("configureOpenAICompatibleSelfHostedProviderNonInteractive", () => { diff --git a/src/plugins/provider-self-hosted-setup.ts b/src/plugins/provider-self-hosted-setup.ts index f66f8ef9fea8..fa6980086247 100644 --- a/src/plugins/provider-self-hosted-setup.ts +++ b/src/plugins/provider-self-hosted-setup.ts @@ -1,4 +1,5 @@ // Builds setup metadata for self-hosted provider plugins. +import { readResponseWithLimit } from "@openclaw/media-core/read-response-with-limit"; import { findNormalizedProviderValue, normalizeProviderId, @@ -39,6 +40,12 @@ export { const log = createSubsystemLogger("plugins/self-hosted-provider-setup"); +// Self-hosted provider base URLs are user-supplied and untrusted (an attacker +// who can influence the configured endpoint, e.g. via SSRF, could serve an +// unbounded JSON stream). Cap discovery response bodies before parsing so a +// hostile or buggy endpoint cannot drive the setup wizard into OOM. +const SELF_HOSTED_DISCOVERY_JSON_MAX_BYTES = 16 * 1024 * 1024; + type OpenAICompatModelsResponse = { data?: Array<{ id?: string; @@ -86,6 +93,21 @@ function readPositiveInteger(value: unknown): number | undefined { return Math.trunc(value); } +/** + * Reads and parses a self-hosted discovery JSON body under a hard byte cap. + * Mirrors the byte-bounded reader pattern shared across provider/media reads so + * an untrusted endpoint cannot stream an unbounded body into memory. + */ +async function readSelfHostedDiscoveryJson(response: Response, label: string): Promise { + const bytes = await readResponseWithLimit(response, SELF_HOSTED_DISCOVERY_JSON_MAX_BYTES, { + onOverflow: ({ size, maxBytes }) => + new Error( + `${label} discovery response body too large: ${size} bytes (limit: ${maxBytes} bytes)`, + ), + }); + return JSON.parse(new TextDecoder().decode(bytes)); +} + async function cancelUnreadResponseBody(response: Response): Promise { if (!response.bodyUsed) { await response.body?.cancel().catch(() => undefined); @@ -133,7 +155,10 @@ async function discoverLlamaCppRuntimeContextTokens(params: { await cancelUnreadResponseBody(response); return undefined; } - const data = (await response.json()) as LlamaCppPropsResponse; + const data = (await readSelfHostedDiscoveryJson( + response, + "llama.cpp /props", + )) as LlamaCppPropsResponse; return ( readPositiveInteger(data.default_generation_settings?.n_ctx) ?? readPositiveInteger(data.n_ctx) @@ -178,7 +203,10 @@ export async function discoverOpenAICompatibleLocalModels(params: { log.warn(`Failed to discover ${params.label} models: ${response.status}`); return []; } - const data = (await response.json()) as OpenAICompatModelsResponse; + const data = (await readSelfHostedDiscoveryJson( + response, + params.label, + )) as OpenAICompatModelsResponse; const models = data.data ?? []; if (models.length === 0) { log.warn(`No ${params.label} models found on local instance`); diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index cab5411a7fe6..aeb7f8c4531e 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -2885,6 +2885,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { `plugin:${record.id}`, { allowSameOwnerRefresh: true, + lifecycle: registrationMode === "full" ? "runtime" : "readOnlyDiscovery", }, ); if (!result.ok) { diff --git a/src/plugins/runtime/load-context.test.ts b/src/plugins/runtime/load-context.test.ts index 604af3553157..1212d2a3c810 100644 --- a/src/plugins/runtime/load-context.test.ts +++ b/src/plugins/runtime/load-context.test.ts @@ -21,6 +21,7 @@ const metadataSnapshot = { workspaceDir: "/resolved-workspace", }; const loadPluginMetadataSnapshotMock = vi.fn(() => metadataSnapshot); +const isPluginMetadataSnapshotCompatibleMock = vi.fn(() => true); const getCurrentPluginMetadataSnapshotMock = vi.fn(() => undefined); const setCurrentPluginMetadataSnapshotMock = vi.fn(); const clearCurrentPluginMetadataSnapshotMock = vi.fn(); @@ -45,6 +46,7 @@ vi.mock("../../agents/agent-scope.js", () => ({ })); vi.mock("../plugin-metadata-snapshot.js", () => ({ + isPluginMetadataSnapshotCompatible: isPluginMetadataSnapshotCompatibleMock, loadPluginMetadataSnapshot: loadPluginMetadataSnapshotMock, resolvePluginMetadataSnapshot: loadPluginMetadataSnapshotMock, })); @@ -69,6 +71,8 @@ describe("resolvePluginRuntimeLoadContext", () => { applyPluginAutoEnableMock.mockReset(); getCurrentPluginMetadataSnapshotMock.mockReset(); getCurrentPluginMetadataSnapshotMock.mockReturnValue(undefined); + isPluginMetadataSnapshotCompatibleMock.mockReset(); + isPluginMetadataSnapshotCompatibleMock.mockReturnValue(true); loadPluginMetadataSnapshotMock.mockClear(); getCurrentPluginMetadataSnapshotMock.mockClear(); setCurrentPluginMetadataSnapshotMock.mockClear(); diff --git a/src/plugins/runtime/load-context.ts b/src/plugins/runtime/load-context.ts index 382e25a7e8ef..b42168c5c778 100644 --- a/src/plugins/runtime/load-context.ts +++ b/src/plugins/runtime/load-context.ts @@ -14,7 +14,10 @@ import { import { extractPluginInstallRecordsFromInstalledPluginIndex } from "../installed-plugin-index-install-records.js"; import type { PluginLoadOptions } from "../loader.js"; import type { PluginManifestRegistry } from "../manifest-registry.js"; -import { resolvePluginMetadataSnapshot } from "../plugin-metadata-snapshot.js"; +import { + isPluginMetadataSnapshotCompatible, + resolvePluginMetadataSnapshot, +} from "../plugin-metadata-snapshot.js"; import type { PluginLogger } from "../types.js"; const log = createSubsystemLogger("plugins"); @@ -73,18 +76,16 @@ export function resolvePluginRuntimeLoadContext( const rawConfig = options?.config ?? getRuntimeConfig(); const rawWorkspaceDir = options?.workspaceDir ?? resolveAgentWorkspaceDir(rawConfig, resolveDefaultAgentId(rawConfig)); - const metadataSnapshot = options?.manifestRegistry - ? undefined - : resolvePluginMetadataSnapshot({ - config: rawConfig, - env, - workspaceDir: rawWorkspaceDir, - allowWorkspaceScopedCurrent: true, - }); - const manifestRegistry = options?.manifestRegistry ?? metadataSnapshot?.manifestRegistry; - const installRecords = metadataSnapshot - ? extractPluginInstallRecordsFromInstalledPluginIndex(metadataSnapshot.index) - : undefined; + const initialMetadataSnapshot = + options?.manifestRegistry === undefined + ? resolvePluginMetadataSnapshot({ + config: rawConfig, + env, + workspaceDir: rawWorkspaceDir, + allowWorkspaceScopedCurrent: true, + }) + : undefined; + const manifestRegistry = options?.manifestRegistry ?? initialMetadataSnapshot?.manifestRegistry; const activationSourceConfig = resolvePluginActivationSourceConfig({ config: rawConfig, activationSourceConfig: options?.activationSourceConfig, @@ -93,11 +94,33 @@ export function resolvePluginRuntimeLoadContext( config: rawConfig, env, manifestRegistry, - discovery: metadataSnapshot?.discovery, + discovery: initialMetadataSnapshot?.discovery, }); const config = autoEnabled.config; const workspaceDir = options?.workspaceDir ?? resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); + const metadataSnapshot = + options?.manifestRegistry !== undefined + ? undefined + : initialMetadataSnapshot && + isPluginMetadataSnapshotCompatible({ + snapshot: initialMetadataSnapshot, + config, + env, + workspaceDir, + }) + ? initialMetadataSnapshot + : resolvePluginMetadataSnapshot({ + config, + env, + workspaceDir, + allowWorkspaceScopedCurrent: true, + ...(initialMetadataSnapshot ? { index: initialMetadataSnapshot.index } : {}), + }); + const finalManifestRegistry = options?.manifestRegistry ?? metadataSnapshot?.manifestRegistry; + const installRecords = metadataSnapshot + ? extractPluginInstallRecordsFromInstalledPluginIndex(metadataSnapshot.index) + : undefined; if (metadataSnapshot) { // Reusable snapshots stay available to later manifest-policy lookups for this runtime load. if (isReusableCurrentPluginMetadataSnapshot(metadataSnapshot)) { @@ -119,7 +142,7 @@ export function resolvePluginRuntimeLoadContext( workspaceDir, env, logger: options?.logger ?? createPluginRuntimeLoaderLogger(), - manifestRegistry, + ...(finalManifestRegistry ? { manifestRegistry: finalManifestRegistry } : {}), installRecords, }; } diff --git a/src/plugins/runtime/runtime-agent.ts b/src/plugins/runtime/runtime-agent.ts index 26e810360ac3..6419f3c869f9 100644 --- a/src/plugins/runtime/runtime-agent.ts +++ b/src/plugins/runtime/runtime-agent.ts @@ -19,11 +19,12 @@ import { type SessionAccessScope, updateSessionEntry, } from "../../config/sessions/session-accessor.js"; +import { normalizeResolvedMaintenanceConfigInput } from "../../config/sessions/store-maintenance.js"; import { loadSessionStore, saveSessionStore, updateSessionStore, - type ResolvedSessionMaintenanceConfig, + type ResolvedSessionMaintenanceConfigInput, } from "../../config/sessions/store.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import { createLazyRuntimeMethod, createLazyRuntimeModule } from "../../shared/lazy-runtime.js"; @@ -35,6 +36,7 @@ type RuntimeSessionStoreReadParams = { env?: NodeJS.ProcessEnv; hydrateSkillPromptRefs?: boolean; sessionKey: string; + readConsistency?: "latest"; storePath?: string; }; @@ -58,7 +60,7 @@ type RuntimeSessionStoreEntryUpdateParams = { type RuntimeSessionStoreEntryPatchParams = RuntimeSessionStoreReadParams & { fallbackEntry?: SessionEntry; - maintenanceConfig?: ResolvedSessionMaintenanceConfig; + maintenanceConfig?: ResolvedSessionMaintenanceConfigInput; preserveActivity?: boolean; replaceEntry?: boolean; update: ( @@ -95,6 +97,7 @@ function toSessionAccessScope(params: RuntimeSessionStoreReadParams): SessionAcc ...(params.hydrateSkillPromptRefs !== undefined ? { hydrateSkillPromptRefs: params.hydrateSkillPromptRefs } : {}), + ...(params.readConsistency !== undefined ? { readConsistency: params.readConsistency } : {}), ...(params.storePath !== undefined ? { storePath: params.storePath } : {}), }; } @@ -121,7 +124,10 @@ async function patchSessionEntry( ): Promise { return await patchAccessorSessionEntry(toSessionAccessScope(params), params.update, { fallbackEntry: params.fallbackEntry, - maintenanceConfig: params.maintenanceConfig, + maintenanceConfig: + params.maintenanceConfig !== undefined + ? normalizeResolvedMaintenanceConfigInput(params.maintenanceConfig) + : undefined, preserveActivity: params.preserveActivity, replaceEntry: params.replaceEntry, }); diff --git a/src/plugins/runtime/standalone-runtime-registry-loader.ts b/src/plugins/runtime/standalone-runtime-registry-loader.ts index 0e1c7d7c5b0a..edf431004c2e 100644 --- a/src/plugins/runtime/standalone-runtime-registry-loader.ts +++ b/src/plugins/runtime/standalone-runtime-registry-loader.ts @@ -27,7 +27,7 @@ function resolveRuntimeSubagentMode( return "default"; } -function installStandaloneRegistry( +function installStandaloneRuntimePluginRegistry( registry: PluginRegistry, params: { loadOptions: PluginLoadOptions; @@ -99,7 +99,7 @@ export function ensureStandaloneRuntimePluginRegistryLoaded(params: { return registry; } - installStandaloneRegistry(registry, { + installStandaloneRuntimePluginRegistry(registry, { loadOptions: params.loadOptions, surface, }); diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index 28814fece345..540ec623b59c 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -65,6 +65,7 @@ type RuntimeSessionStoreReadParams = { env?: NodeJS.ProcessEnv; hydrateSkillPromptRefs?: boolean; sessionKey: string; + readConsistency?: "latest"; storePath?: string; }; type RuntimeSessionStoreListParams = Partial>; @@ -74,7 +75,7 @@ type RuntimeSessionStoreEntrySummary = { }; type RuntimeSessionStoreEntryPatchParams = RuntimeSessionStoreReadParams & { fallbackEntry?: RuntimeSessionEntry; - maintenanceConfig?: import("../../config/sessions/store.js").ResolvedSessionMaintenanceConfig; + maintenanceConfig?: import("../../config/sessions/store.js").ResolvedSessionMaintenanceConfigInput; preserveActivity?: boolean; replaceEntry?: boolean; update: ( diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 57490d8b025c..2724f721e22b 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -2147,6 +2147,62 @@ describe("resolvePluginTools optional tools", () => { expect(factory).toHaveBeenCalledTimes(2); }); + it("retains cold-loaded plugin tools for cached descriptor execution after active registry replacement", async () => { + const factory = vi.fn(() => makeTool("cached_lifecycle_tool")); + const gatewayRegistry = setRegistry([ + { + pluginId: "cache-lifecycle-test", + optional: false, + source: "/tmp/cache-lifecycle-test.js", + names: ["cached_lifecycle_tool"], + factory, + }, + ]); + const first = resolvePluginTools( + createResolveToolsParams({ + toolAllowlist: ["cached_lifecycle_tool"], + allowGatewaySubagentBinding: true, + }), + ); + const [tool] = resolvePluginTools( + createResolveToolsParams({ + toolAllowlist: ["cached_lifecycle_tool"], + allowGatewaySubagentBinding: true, + }), + ); + expectResolvedToolNames(first, ["cached_lifecycle_tool"]); + expect(tool?.name).toBe("cached_lifecycle_tool"); + expect(factory).toHaveBeenCalledTimes(1); + + const unrelatedEntry: MockRegistryToolEntry = { + pluginId: "unrelated-live", + optional: false, + source: "/tmp/unrelated-live.js", + names: ["unrelated_live_tool"], + factory: () => makeTool("unrelated_live_tool"), + }; + const replacementRegistry = createToolRegistry([unrelatedEntry]); + replacementRegistry.plugins.push({ id: "cache-lifecycle-test", status: "loaded" }); + setActivePluginRegistry?.(replacementRegistry as never, "provider-runtime", "default", "/tmp"); + resolveRuntimePluginRegistryMock.mockReturnValue(undefined); + loadOpenClawPluginsMock.mockReset(); + loadOpenClawPluginsMock + .mockReturnValueOnce(gatewayRegistry) + .mockReturnValue(createToolRegistry([])); + + await expect(tool?.execute("call-1", {}, undefined)).resolves.toEqual({ + content: [{ type: "text", text: "ok" }], + }); + await expect(tool?.execute("call-2", {}, undefined)).resolves.toEqual({ + content: [{ type: "text", text: "ok" }], + }); + expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(1); + expect(getActivePluginRegistry?.()).toBe(replacementRegistry); + expect(getActivePluginRegistry?.()?.tools.map((entry) => entry.pluginId)).toContain( + "unrelated-live", + ); + }); + it("does not reuse cached plugin tool descriptors across sandbox context changes", () => { const factory = vi.fn((rawCtx: unknown) => { const ctx = rawCtx as { sandboxed?: boolean }; diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index 9ebc29576766..b2dcd4a92718 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -31,16 +31,21 @@ import { capturePluginToolDescriptor, createPluginToolDescriptorConfigCacheKeyMemo, readCachedPluginToolDescriptors, + resetPluginToolDescriptorCache as resetCachedPluginToolDescriptors, type CachedPluginToolDescriptor, type PluginToolDescriptorConfigCacheKeyMemo, writeCachedPluginToolDescriptors, } from "./tool-descriptor-cache.js"; import type { OpenClawPluginToolContext } from "./types.js"; -export { - resetPluginToolDescriptorCache, - resetPluginToolDescriptorCache as resetPluginToolFactoryCache, -} from "./tool-descriptor-cache.js"; +let cachedDescriptorRuntimeRegistries = new WeakMap(); + +export function resetPluginToolDescriptorCache(): void { + resetCachedPluginToolDescriptors(); + cachedDescriptorRuntimeRegistries = new WeakMap(); +} + +export { resetPluginToolDescriptorCache as resetPluginToolFactoryCache }; /** MCP bridge metadata attached to plugin tools surfaced through agent tool lists. */ export type PluginToolMcpMeta = { @@ -692,6 +697,10 @@ function createCachedDescriptorPluginTool(params: { const registry = resolvePluginToolRegistry({ loadOptions, onlyPluginIds: [pluginId], + retainedRegistry: cachedDescriptorRuntimeRegistries.get(params.descriptor), + onRetainRegistry: (retainedRegistry) => { + cachedDescriptorRuntimeRegistries.set(params.descriptor, retainedRegistry); + }, }); const candidates = registry?.tools.filter((candidate) => candidate.pluginId === pluginId); if (!candidates || candidates.length === 0) { @@ -899,6 +908,8 @@ function resolveCachedPluginTools(params: { function resolvePluginToolRegistry(params: { loadOptions: PluginLoadOptions; onlyPluginIds?: readonly string[]; + retainedRegistry?: PluginRegistry; + onRetainRegistry?: (registry: PluginRegistry) => void; }) { const lookup = { env: params.loadOptions.env, @@ -924,7 +935,16 @@ function resolvePluginToolRegistry(params: { return activeRegistry; } + if (registryHasScopedPluginTools(params.retainedRegistry, params.onlyPluginIds)) { + return params.retainedRegistry; + } + const forceStandaloneLoad = Boolean(channelRegistry || activeRegistry); + const shouldRetainColdLoadedToolRegistry = + forceStandaloneLoad && + params.loadOptions.activate === false && + params.loadOptions.toolDiscovery === true && + params.onRetainRegistry !== undefined; const standaloneRegistry = ensureStandaloneRuntimePluginRegistryLoaded({ surface: "active", forceLoad: forceStandaloneLoad, @@ -933,6 +953,9 @@ function resolvePluginToolRegistry(params: { loadOptions: params.loadOptions, }); if (registryHasScopedPluginTools(standaloneRegistry, params.onlyPluginIds)) { + if (shouldRetainColdLoadedToolRegistry) { + params.onRetainRegistry?.(standaloneRegistry); + } return standaloneRegistry; } return standaloneRegistry ?? channelRegistry ?? activeRegistry; diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index cf7692e2a766..b86641a44c63 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -42,8 +42,10 @@ const tempDirs: string[] = []; vi.mock("./install.js", () => ({ installPluginFromNpmSpec: (...args: unknown[]) => installPluginFromNpmSpecMock(...args), - resolvePluginInstallDir: (pluginId: string, extensionsDir = "/tmp") => - `${extensionsDir}/${pluginId}`, + resolvePluginInstallDir: (pluginId: string, extensionsDir = "/tmp") => { + const separator = process.platform === "win32" ? "\\" : "/"; + return `${extensionsDir.replace(/[\\/]+$/, "")}${separator}${pluginId}`; + }, PLUGIN_INSTALL_ERROR_CODE: { NPM_PACKAGE_NOT_FOUND: "npm_package_not_found", }, @@ -64,6 +66,9 @@ vi.mock("./clawhub.js", () => ({ ARTIFACT_UNAVAILABLE: "artifact_unavailable", ARCHIVE_INTEGRITY_MISMATCH: "archive_integrity_mismatch", ARTIFACT_DOWNLOAD_UNAVAILABLE: "artifact_download_unavailable", + CLAWHUB_SECURITY_UNAVAILABLE: "clawhub_security_unavailable", + CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED: "clawhub_risk_acknowledgement_required", + CLAWHUB_DOWNLOAD_BLOCKED: "clawhub_download_blocked", }, installPluginFromClawHub: (...args: unknown[]) => installPluginFromClawHubMock(...args), })); @@ -215,6 +220,35 @@ function createClawHubInstallConfig(params: { }; } +function createEnabledDemoClawHubInstallConfig(): OpenClawConfig { + const installPath = createInstalledPackageDir({ + name: "demo", + version: "1.2.3", + }); + const config = createClawHubInstallConfig({ + pluginId: "demo", + installPath, + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + }); + config.plugins = { + ...config.plugins, + entries: { + demo: { + enabled: true, + config: { preserved: true }, + }, + }, + allow: ["demo"], + slots: { + memory: "demo", + }, + }; + return config; +} + function createGitInstallConfig(params: { pluginId: string; spec: string; @@ -1079,6 +1113,45 @@ describe("updateNpmInstalledPlugins", () => { ); }); + it("does not apply official beta-channel sync to third-party npm specs", async () => { + const installPath = createInstalledPackageDir({ + name: "@martian-engineering/lossless-claw", + version: "0.9.0", + }); + mockNpmViewMetadata({ + name: "@martian-engineering/lossless-claw", + version: "0.9.1", + }); + installPluginFromNpmSpecMock.mockResolvedValue( + createSuccessfulNpmUpdateResult({ + pluginId: "lossless-claw", + targetDir: installPath, + version: "0.9.1", + npmResolution: { + name: "@martian-engineering/lossless-claw", + version: "0.9.1", + resolvedSpec: "@martian-engineering/lossless-claw@0.9.1", + }, + }), + ); + + await updateNpmInstalledPlugins({ + config: createNpmInstallConfig({ + pluginId: "lossless-claw", + spec: "@martian-engineering/lossless-claw", + installPath, + resolvedName: "@martian-engineering/lossless-claw", + resolvedSpec: "@martian-engineering/lossless-claw@0.9.0", + resolvedVersion: "0.9.0", + }), + pluginIds: ["lossless-claw"], + syncOfficialPluginInstalls: true, + officialPluginUpdateChannel: "beta", + }); + + expect(npmInstallCall()?.spec).toBe("@martian-engineering/lossless-claw"); + }); + it("does not skip trusted official default updates when latest resolves to the installed prerelease", async () => { const installPath = createInstalledPackageDir({ name: "@openclaw/acpx", @@ -1861,7 +1934,7 @@ describe("updateNpmInstalledPlugins", () => { }); }); - it("falls through to npm reinstall when metadata probing fails", async () => { + it("falls through to npm reinstall when metadata probing fails for valid specs", async () => { const warn = vi.fn(); const installPath = createInstalledPackageDir({ name: "@martian-engineering/lossless-claw", @@ -1896,6 +1969,107 @@ describe("updateNpmInstalledPlugins", () => { expect(installPluginFromNpmSpecMock).toHaveBeenCalledTimes(1); }); + it("records range metadata probing failures without falling through to npm reinstall", async () => { + const warn = vi.fn(); + const installPath = createInstalledPackageDir({ + name: "@martian-engineering/lossless-claw", + version: "0.9.0", + }); + runCommandWithTimeoutMock.mockResolvedValueOnce({ + code: 1, + stdout: "", + stderr: "registry timeout", + }); + const result = await updateNpmInstalledPlugins({ + config: createNpmInstallConfig({ + pluginId: "lossless-claw", + spec: "@martian-engineering/lossless-claw@^0.9.0", + installPath, + }), + pluginIds: ["lossless-claw"], + logger: { warn }, + }); + + expect(warn).not.toHaveBeenCalled(); + expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); + expect(result.changed).toBe(false); + expect(result.outcomes).toEqual([ + { + pluginId: "lossless-claw", + status: "error", + message: "Failed to check lossless-claw: npm view failed: registry timeout", + }, + ]); + }); + + it("uses failure cleanup when metadata probing fails and disableOnFailure is enabled", async () => { + const warn = vi.fn(); + const installPath = createInstalledPackageDir({ + name: "@martian-engineering/lossless-claw", + version: "0.9.0", + }); + runCommandWithTimeoutMock.mockResolvedValueOnce({ + code: 1, + stdout: "", + stderr: "registry timeout", + }); + + const result = await updateNpmInstalledPlugins({ + config: { + plugins: { + allow: ["lossless-claw", "keep"], + deny: ["lossless-claw", "blocked"], + slots: { + memory: "lossless-claw", + contextEngine: "lossless-claw", + }, + entries: { + "lossless-claw": { + enabled: true, + config: { preserved: true }, + }, + }, + installs: { + "lossless-claw": { + source: "npm", + spec: "@martian-engineering/lossless-claw@^0.9.0", + installPath, + resolvedName: "@martian-engineering/lossless-claw", + resolvedVersion: "0.9.0", + resolvedSpec: "@martian-engineering/lossless-claw@0.9.0", + }, + }, + }, + }, + pluginIds: ["lossless-claw"], + disableOnFailure: true, + logger: { warn }, + }); + + const message = + 'Disabled "lossless-claw" after plugin update failure; OpenClaw will continue without it. Failed to check lossless-claw: npm view failed: registry timeout'; + expect(warn).toHaveBeenCalledWith(message); + expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); + expect(result.changed).toBe(true); + expect(result.config.plugins?.entries?.["lossless-claw"]).toEqual({ + enabled: false, + config: { preserved: true }, + }); + expect(result.config.plugins?.allow).toEqual(["keep"]); + expect(result.config.plugins?.deny).toEqual(["blocked"]); + expect(result.config.plugins?.slots).toEqual({ + memory: "memory-core", + contextEngine: "legacy", + }); + expect(result.outcomes).toEqual([ + { + pluginId: "lossless-claw", + status: "skipped", + message, + }, + ]); + }); + it.each([ { source: "npm", @@ -2436,6 +2610,12 @@ describe("updateNpmInstalledPlugins", () => { installPath: "/tmp/demo", }, }, + allow: ["demo", "other"], + deny: ["blocked"], + slots: { + memory: "demo", + contextEngine: "demo", + }, }, } satisfies OpenClawConfig; @@ -2456,6 +2636,12 @@ describe("updateNpmInstalledPlugins", () => { enabled: false, config: { preserved: true }, }); + expect(result.config.plugins?.allow).toEqual(["other"]); + expect(result.config.plugins?.deny).toEqual(["blocked"]); + expect(result.config.plugins?.slots).toEqual({ + memory: "memory-core", + contextEngine: "legacy", + }); expect(result.config.plugins?.installs?.demo).toEqual(config.plugins.installs.demo); expect(result.outcomes).toEqual([ { @@ -2466,55 +2652,249 @@ describe("updateNpmInstalledPlugins", () => { ]); }); - it("clears stale plugin policy and slot references when disabling failed updates", async () => { - const warn = vi.fn(); - installPluginFromNpmSpecMock.mockResolvedValue({ + it("keeps an existing ClawHub plugin enabled when a risky update is not acknowledged", async () => { + installPluginFromClawHubMock.mockResolvedValue({ ok: false, - error: "security scan blocked install", + code: "clawhub_risk_acknowledgement_required", + error: + "Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning.", + warning: + "╭─ WARNING - ClawHub found security risks in this release ─╮\n│ • Finding: suspicious payload strings │\n╰───────────────────────────────────────────────────────────────────────╯", }); - const config = { - plugins: { - allow: ["demo", "keep"], - deny: ["demo", "blocked"], - slots: { - memory: "demo", - contextEngine: "demo", - }, - entries: { - demo: { - enabled: true, - }, - }, - installs: { - demo: { - source: "npm" as const, - spec: "@acme/demo", - installPath: "/tmp/demo", - }, - }, - }, - } satisfies OpenClawConfig; + const config = createEnabledDemoClawHubInstallConfig(); const result = await updateNpmInstalledPlugins({ config, + pluginIds: ["demo"], + disableOnFailure: true, + }); + + expect(clawHubInstallCall()?.spec).toBe("clawhub:demo"); + expect(result.changed).toBe(false); + expect(result.config).toBe(config); + expect(result.config.plugins?.entries?.demo).toEqual({ + enabled: true, + config: { preserved: true }, + }); + expect(result.config.plugins?.allow).toEqual(["demo"]); + expect(result.config.plugins?.slots?.memory).toBe("demo"); + expect(result.outcomes).toEqual([ + { + pluginId: "demo", + status: "skipped", + code: "clawhub_risk_acknowledgement_required", + currentVersion: "1.2.3", + warning: + "╭─ WARNING - ClawHub found security risks in this release ─╮\n│ • Finding: suspicious payload strings │\n╰───────────────────────────────────────────────────────────────────────╯", + message: + "Skipped demo ClawHub update: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. Existing installed plugin left unchanged.", + }, + ]); + }); + + it("does not skip a risk-gated ClawHub update when the installed package is missing", async () => { + installPluginFromClawHubMock.mockResolvedValue({ + ok: false, + code: "clawhub_risk_acknowledgement_required", + error: + "Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning.", + warning: + "╭─ WARNING - ClawHub found security risks in this release ─╮\n│ • Finding: suspicious payload strings │\n╰───────────────────────────────────────────────────────────────────────╯", + }); + const installPath = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-update-missing-")); + tempDirs.push(installPath); + const config = createClawHubInstallConfig({ + pluginId: "demo", + installPath, + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + }); + config.plugins = { + ...config.plugins, + entries: { + demo: { + enabled: true, + config: { preserved: true }, + }, + }, + allow: ["demo"], + slots: { + memory: "demo", + }, + }; + + const result = await updateNpmInstalledPlugins({ + config, + pluginIds: ["demo"], + disableOnFailure: true, + }); + + expect(clawHubInstallCall()?.spec).toBe("clawhub:demo"); + const message = + 'Disabled "demo" after plugin update failure; OpenClaw will continue without it. Failed to update demo: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. (ClawHub clawhub:demo).'; + expect(result.changed).toBe(true); + expect(result.config.plugins?.entries?.demo).toEqual({ + enabled: false, + config: { preserved: true }, + }); + expect(result.config.plugins?.allow).toBeUndefined(); + expect(result.config.plugins?.slots?.memory).toBe("memory-core"); + expect(result.outcomes).toEqual([ + { + pluginId: "demo", + status: "skipped", + message, + }, + ]); + }); + + it("keeps an existing ClawHub plugin enabled when a newer target release is blocked", async () => { + installPluginFromClawHubMock.mockResolvedValue({ + ok: false, + code: "clawhub_download_blocked", + version: "1.2.4", + error: "ClawHub blocked this release; update was not started.", + warning: + "╭─ BLOCKED - ClawHub flagged this release as malicious ─╮\n│ • Security scan: malicious │\n╰────────────────────────────────────────────────────────╯", + }); + const config = createEnabledDemoClawHubInstallConfig(); + + const result = await updateNpmInstalledPlugins({ + config, + pluginIds: ["demo"], + disableOnFailure: true, + }); + + expect(clawHubInstallCall()?.spec).toBe("clawhub:demo"); + expect(result.changed).toBe(false); + expect(result.config).toBe(config); + expect(result.config.plugins?.entries?.demo).toEqual({ + enabled: true, + config: { preserved: true }, + }); + expect(result.config.plugins?.allow).toEqual(["demo"]); + expect(result.config.plugins?.slots?.memory).toBe("demo"); + expect(result.outcomes).toEqual([ + { + pluginId: "demo", + status: "skipped", + code: "clawhub_download_blocked", + currentVersion: "1.2.3", + warning: + "╭─ BLOCKED - ClawHub flagged this release as malicious ─╮\n│ • Security scan: malicious │\n╰────────────────────────────────────────────────────────╯", + message: + "Skipped demo ClawHub update: ClawHub blocked this release; update was not started. Existing installed plugin left unchanged.", + }, + ]); + }); + + it("keeps an existing ClawHub plugin enabled when newer target security data is unavailable", async () => { + installPluginFromClawHubMock.mockResolvedValue({ + ok: false, + code: "clawhub_security_unavailable", + version: "1.2.4", + error: + 'ClawHub release "demo@1.2.4" could not be checked because ClawHub security data is unavailable. Try again later or choose a different version.', + }); + const config = createEnabledDemoClawHubInstallConfig(); + + const result = await updateNpmInstalledPlugins({ + config, + pluginIds: ["demo"], + disableOnFailure: true, + }); + + expect(clawHubInstallCall()?.spec).toBe("clawhub:demo"); + expect(result.changed).toBe(false); + expect(result.config).toBe(config); + expect(result.config.plugins?.entries?.demo).toEqual({ + enabled: true, + config: { preserved: true }, + }); + expect(result.config.plugins?.allow).toEqual(["demo"]); + expect(result.config.plugins?.slots?.memory).toBe("demo"); + expect(result.outcomes).toEqual([ + { + pluginId: "demo", + status: "skipped", + code: "clawhub_security_unavailable", + currentVersion: "1.2.3", + message: + 'Skipped demo ClawHub update: ClawHub release "demo@1.2.4" could not be checked because ClawHub security data is unavailable. Try again later or choose a different version. Existing installed plugin left unchanged.', + }, + ]); + }); + + it("keeps an existing ClawHub plugin enabled when current target security data is unavailable", async () => { + installPluginFromClawHubMock.mockResolvedValue({ + ok: false, + code: "clawhub_security_unavailable", + version: "1.2.3", + error: + 'ClawHub release "demo@1.2.3" could not be checked because ClawHub security data is unavailable. Try again later or choose a different version.', + }); + const config = createEnabledDemoClawHubInstallConfig(); + + const result = await updateNpmInstalledPlugins({ + config, + pluginIds: ["demo"], + disableOnFailure: true, + }); + + expect(clawHubInstallCall()?.spec).toBe("clawhub:demo"); + expect(result.changed).toBe(false); + expect(result.config).toBe(config); + expect(result.config.plugins?.entries?.demo).toEqual({ + enabled: true, + config: { preserved: true }, + }); + expect(result.config.plugins?.allow).toEqual(["demo"]); + expect(result.config.plugins?.slots?.memory).toBe("demo"); + expect(result.outcomes).toEqual([ + { + pluginId: "demo", + status: "skipped", + code: "clawhub_security_unavailable", + currentVersion: "1.2.3", + message: + 'Skipped demo ClawHub update: ClawHub release "demo@1.2.3" could not be checked because ClawHub security data is unavailable. Try again later or choose a different version. Existing installed plugin left unchanged.', + }, + ]); + }); + + it("disables an existing ClawHub plugin when its current release is blocked", async () => { + const warn = vi.fn(); + installPluginFromClawHubMock.mockResolvedValue({ + ok: false, + code: "clawhub_download_blocked", + version: "1.2.3", + error: "ClawHub blocked this release; update was not started.", + warning: + "╭─ BLOCKED - ClawHub flagged this release as malicious ─╮\n│ • Security scan: malicious │\n╰────────────────────────────────────────────────────────╯", + }); + const config = createEnabledDemoClawHubInstallConfig(); + + const result = await updateNpmInstalledPlugins({ + config, + pluginIds: ["demo"], disableOnFailure: true, logger: { warn }, }); - const message = - 'Disabled "demo" after plugin update failure; OpenClaw will continue without it. Failed to update demo: security scan blocked install'; - expect(warn).toHaveBeenCalledWith(message); expect(result.changed).toBe(true); expect(result.config.plugins?.entries?.demo).toEqual({ enabled: false, + config: { preserved: true }, }); - expect(result.config.plugins?.installs?.demo).toEqual(config.plugins.installs.demo); - expect(result.config.plugins?.allow).toEqual(["keep"]); - expect(result.config.plugins?.deny).toEqual(["blocked"]); + expect(result.config.plugins?.allow).toBeUndefined(); expect(result.config.plugins?.slots).toEqual({ memory: "memory-core", - contextEngine: "legacy", }); + const message = + 'Disabled "demo" after plugin update failure; OpenClaw will continue without it. Failed to update demo: ClawHub blocked this release; update was not started. (ClawHub clawhub:demo).'; + expect(warn).toHaveBeenCalledWith(message); expect(result.outcomes).toEqual([ { pluginId: "demo", @@ -3179,7 +3559,7 @@ describe("updateNpmInstalledPlugins", () => { ); }); - it("falls back to npm for trusted official ClawHub artifact blocks", async () => { + it("does not fall back to npm for blocked official ClawHub artifact downloads", async () => { const warnMessages: string[] = []; const installPath = createInstalledPackageDir({ name: "@openclaw/discord", @@ -3187,22 +3567,11 @@ describe("updateNpmInstalledPlugins", () => { }); installPluginFromClawHubMock.mockResolvedValueOnce({ ok: false, - code: "artifact_unavailable", + code: "clawhub_download_blocked", error: - 'ClawHub artifact download for "@openclaw/discord@2026.5.16-beta.5" is not available yet (ClawHub /api/v1/packages/%40openclaw%2Fdiscord/versions/2026.5.16-beta.5/artifact/download failed (403): Blocked: this package release has been flagged as malicious and cannot be downloaded.). Use "npm:@openclaw/discord@2026.5.16-beta.5" for launch installs while ClawHub artifact routing is being rolled out.', + 'ClawHub blocked artifact download for "@openclaw/discord@2026.5.16-beta.5"; install was not started. ClawHub /api/v1/packages/%40openclaw%2Fdiscord/versions/2026.5.16-beta.5/artifact/download failed (403): Blocked: this package release has been flagged as malicious and cannot be downloaded.', + version: "2026.5.16-beta.5", }); - installPluginFromNpmSpecMock.mockResolvedValueOnce( - createSuccessfulNpmUpdateResult({ - pluginId: "discord", - targetDir: "/tmp/openclaw-plugins/discord", - version: "2026.5.16-beta.5", - npmResolution: { - name: "@openclaw/discord", - version: "2026.5.16-beta.5", - resolvedSpec: "@openclaw/discord@2026.5.16-beta.5", - }, - }), - ); const result = await updateNpmInstalledPlugins({ config: createClawHubInstallConfig({ @@ -3221,32 +3590,25 @@ describe("updateNpmInstalledPlugins", () => { }); expect(clawHubInstallCall()?.spec).toBe("clawhub:@openclaw/discord@beta"); - expect(npmInstallCall()?.spec).toBe("@openclaw/discord@beta"); - expect(npmInstallCall()?.expectedPluginId).toBe("discord"); - expect(npmInstallCall()?.trustedSourceLinkedOfficialInstall).toBe(true); + expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); expect(result.config.plugins?.entries?.discord?.enabled).toBeUndefined(); expectRecordFields(result.config.plugins?.installs?.discord, { - source: "npm", - spec: "@openclaw/discord@2026.5.16-beta.5", - installPath: "/tmp/openclaw-plugins/discord", - version: "2026.5.16-beta.5", + source: "clawhub", + spec: "clawhub:@openclaw/discord", + installPath, + clawhubPackage: "@openclaw/discord", }); - expect(result.config.plugins?.installs?.discord?.clawhubPackage).toBeUndefined(); - expect(result.config.plugins?.installs?.discord?.clawhubUrl).toBeUndefined(); - expect(result.config.plugins?.installs?.discord?.artifactKind).toBeUndefined(); expect(result.outcomes).toEqual([ { pluginId: "discord", - status: "updated", + status: "skipped", + code: "clawhub_download_blocked", currentVersion: "2026.5.12", - nextVersion: "2026.5.16-beta.5", message: - "Updated discord: 2026.5.12 -> 2026.5.16-beta.5. (warning: official ClawHub artifact fallback used @openclaw/discord@beta).", + 'Skipped discord ClawHub update: ClawHub blocked artifact download for "@openclaw/discord@2026.5.16-beta.5"; install was not started. ClawHub /api/v1/packages/%40openclaw%2Fdiscord/versions/2026.5.16-beta.5/artifact/download failed (403): Blocked: this package release has been flagged as malicious and cannot be downloaded. Existing installed plugin left unchanged.', }, ]); - expect(warnMessages).toEqual([ - 'Plugin "discord" could not download official ClawHub artifact for clawhub:@openclaw/discord@beta; using npm @openclaw/discord@beta instead. Core update can still complete.', - ]); + expect(warnMessages).toStrictEqual([]); }); it("uses the default npm spec when beta ClawHub falls back before an artifact block", async () => { @@ -3563,6 +3925,57 @@ describe("updateNpmInstalledPlugins", () => { }); }); + it("forwards ClawHub risk acknowledgement inputs without dry-run prompts", async () => { + const onClawHubRisk = vi.fn(async () => true); + const config = createClawHubInstallConfig({ + pluginId: "demo", + installPath: "/tmp/demo", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + }); + installPluginFromClawHubMock.mockResolvedValue({ + ok: true, + pluginId: "demo", + targetDir: "/tmp/demo", + version: "1.2.4", + clawhub: { + source: "clawhub", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + integrity: "sha256-next", + resolvedAt: "2026-03-22T00:00:00.000Z", + }, + }); + + for (const dryRun of [true, false]) { + installPluginFromClawHubMock.mockClear(); + + await updateNpmInstalledPlugins({ + config, + pluginIds: ["demo"], + acknowledgeClawHubRisk: true, + onClawHubRisk, + ...(dryRun ? { dryRun: true } : {}), + }); + + expect(installPluginFromClawHubMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "clawhub:demo", + acknowledgeClawHubRisk: true, + ...(dryRun ? { dryRun: true } : {}), + ...(!dryRun ? { onClawHubRisk } : {}), + }), + ); + if (dryRun) { + expect(clawHubInstallCall()?.onClawHubRisk).toBeUndefined(); + } + } + }); + it("migrates legacy unscoped install keys when a scoped npm package updates", async () => { installPluginFromNpmSpecMock.mockResolvedValue({ ok: true, @@ -3823,6 +4236,7 @@ describe("updateNpmInstalledPlugins", () => { it("reuses the recorded managed extensions root when updating external plugins", async () => { const installPath = "/var/openclaw/extensions/demo"; const extensionsDir = "/var/openclaw/extensions"; + const expectedExtensionsDir = path.resolve(extensionsDir); installPluginFromNpmSpecMock.mockResolvedValue( createSuccessfulNpmUpdateResult({ pluginId: "demo", @@ -3906,10 +4320,10 @@ describe("updateNpmInstalledPlugins", () => { pluginIds: ["demo"], }); - expect(npmInstallCall()?.extensionsDir).toBe(extensionsDir); - expect(clawHubInstallCall()?.extensionsDir).toBe(extensionsDir); - expect(marketplaceInstallCall()?.extensionsDir).toBe(extensionsDir); - expect(gitInstallCall()?.extensionsDir).toBe(extensionsDir); + expect(npmInstallCall()?.extensionsDir).toBe(expectedExtensionsDir); + expect(clawHubInstallCall()?.extensionsDir).toBe(expectedExtensionsDir); + expect(marketplaceInstallCall()?.extensionsDir).toBe(expectedExtensionsDir); + expect(gitInstallCall()?.extensionsDir).toBe(expectedExtensionsDir); }); }); @@ -4140,9 +4554,12 @@ describe("syncPluginsForUpdateChannel", () => { clawhubPackage: "legacy-chat", }), ); + const onClawHubRisk = vi.fn(async () => true); const result = await syncPluginsForUpdateChannel({ channel: "stable", + acknowledgeClawHubRisk: true, + onClawHubRisk, externalizedBundledPluginBridges: [ { bundledPluginId: "legacy-chat", @@ -4176,6 +4593,8 @@ describe("syncPluginsForUpdateChannel", () => { expect(clawHubInstallCall()?.baseUrl).toBe("https://clawhub.ai"); expect(clawHubInstallCall()?.mode).toBe("update"); expect(clawHubInstallCall()?.expectedPluginId).toBe("legacy-chat"); + expect(clawHubInstallCall()?.acknowledgeClawHubRisk).toBe(true); + expect(clawHubInstallCall()?.onClawHubRisk).toBe(onClawHubRisk); expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); expect(result.changed).toBe(true); expect(result.summary.switchedToClawHub).toEqual(["legacy-chat"]); @@ -4426,6 +4845,7 @@ describe("syncPluginsForUpdateChannel", () => { ok: false, code: "archive_integrity_mismatch", error: "ClawHub ClawPack integrity mismatch.", + warning: "WARNING\nSecurity scan: suspicious", }); const config: OpenClawConfig = { channels: { @@ -4462,6 +4882,7 @@ describe("syncPluginsForUpdateChannel", () => { expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); expect(result.changed).toBe(false); expect(result.config).toBe(config); + expect(result.summary.warnings).toEqual(["WARNING\nSecurity scan: suspicious"]); expect(result.summary.errors).toEqual([ "Failed to update legacy-chat: ClawHub ClawPack integrity mismatch. (ClawHub clawhub:legacy-chat@2026.5.1-beta.2).", ]); diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 9484950c4f66..26590eeccda8 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; +import type { ClawHubTrustErrorCode } from "../infra/clawhub-install-trust.js"; import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js"; import { satisfiesPluginApiRange } from "../infra/clawhub.js"; import { unscopedPackageName } from "../infra/install-safe-path.js"; @@ -28,8 +29,9 @@ import { runCommandWithTimeout } from "../process/exec.js"; import { resolveUserPath } from "../utils.js"; import { resolveCompatibilityHostVersion } from "../version.js"; import { resolveBundledPluginSources } from "./bundled-sources.js"; +import { CLAWHUB_INSTALL_ERROR_CODE } from "./clawhub-error-codes.js"; import { buildClawHubPluginInstallRecordFields } from "./clawhub-install-records.js"; -import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "./clawhub.js"; +import { installPluginFromClawHub, type ClawHubRiskAcknowledgementRequest } from "./clawhub.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { getExternalizedBundledPluginLegacyPathSuffix, @@ -74,6 +76,7 @@ export type PluginUpdateLogger = { info?: (message: string) => void; warn?: (message: string) => void; error?: (message: string) => void; + terminalLinks?: boolean; }; /** Outcome status for one plugin update attempt. */ @@ -88,15 +91,25 @@ export type PluginUpdateChannelFallback = { message: string; }; -export type PluginUpdateOutcome = { +type BasePluginUpdateOutcome = { pluginId: string; - status: PluginUpdateStatus; message: string; currentVersion?: string; nextVersion?: string; channelFallback?: PluginUpdateChannelFallback; + warning?: string; }; +export type PluginUpdateOutcome = + | (BasePluginUpdateOutcome & { + status: "skipped"; + code?: ClawHubTrustErrorCode; + }) + | (BasePluginUpdateOutcome & { + status: Exclude; + code?: string; + }); + export type PluginUpdateSummary = { config: OpenClawConfig; changed: boolean; @@ -198,6 +211,76 @@ function formatClawHubInstallFailure(params: { return `Failed to ${params.phase} ${params.pluginId}: ${params.error} (ClawHub ${params.spec}).`; } +function isClawHubRiskAcknowledgementRequired(result: { ok: false; code?: string }): boolean { + return result.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED; +} + +function isClawHubDownloadBlocked(result: { ok: false; code?: string }): boolean { + return result.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_DOWNLOAD_BLOCKED; +} + +function isClawHubSecurityUnavailable(result: { ok: false; code?: string }): boolean { + return result.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE; +} + +function readClawHubTrustErrorCode(result: { code?: string }): ClawHubTrustErrorCode | undefined { + if ( + result.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED || + result.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_DOWNLOAD_BLOCKED || + result.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE + ) { + return result.code; + } + return undefined; +} + +function shouldSkipClawHubTrustFailureForExistingInstall(params: { + result: { ok: false; code?: string; version?: string }; + currentVersion: string | undefined; +}): boolean { + if (isClawHubRiskAcknowledgementRequired(params.result)) { + return Boolean(params.currentVersion); + } + if (isClawHubSecurityUnavailable(params.result)) { + return Boolean(params.currentVersion); + } + if (!isClawHubDownloadBlocked(params.result)) { + return false; + } + return Boolean( + params.result.version && + params.currentVersion && + params.result.version !== params.currentVersion, + ); +} + +function buildClawHubTrustSkippedOutcome(params: { + pluginId: string; + phase: "check" | "update"; + error: string; + code: ClawHubTrustErrorCode; + warning?: string; + currentVersion?: string; +}): PluginUpdateOutcome { + return { + pluginId: params.pluginId, + status: "skipped", + ...(params.code ? { code: params.code } : {}), + ...(params.currentVersion ? { currentVersion: params.currentVersion } : {}), + ...(params.warning ? { warning: params.warning } : {}), + message: `Skipped ${params.pluginId} ClawHub ${params.phase}: ${params.error} Existing installed plugin left unchanged.`, + }; +} + +export function isClawHubTrustSkippedOutcome(outcome: { status: string; code?: string }): boolean { + return ( + outcome.status === "skipped" && + (outcome.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED || + outcome.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_DOWNLOAD_BLOCKED || + outcome.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE) + ); +} + function formatGitInstallFailure(params: { pluginId: string; spec: string; @@ -493,7 +576,7 @@ function resolveRecordedExtensionsDir(params: { const parentDir = path.dirname(params.installPath); try { const canonicalInstallPath = resolvePluginInstallDir(params.pluginId, parentDir); - return canonicalInstallPath === params.installPath ? parentDir : undefined; + return pathsEqual(canonicalInstallPath, params.installPath) ? parentDir : undefined; } catch { return undefined; } @@ -1238,9 +1321,12 @@ export async function updateNpmInstalledPlugins(params: { timeoutMs?: number; dryRun?: boolean; updateChannel?: UpdateChannel; + officialPluginUpdateChannel?: UpdateChannel; dangerouslyForceUnsafeInstall?: boolean; specOverrides?: Record; onIntegrityDrift?: (params: PluginUpdateIntegrityDriftParams) => boolean | Promise; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; }): Promise { const logger = params.logger ?? {}; const installs = params.config.plugins?.installs ?? {}; @@ -1259,6 +1345,10 @@ export async function updateNpmInstalledPlugins(params: { ranNpmInstaller = true; return await installPluginFromNpmSpec(installParams); }; + const clawHubRiskAcknowledgementOptions = { + ...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), + ...(!params.dryRun && params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}), + }; const recordFailure = ( pluginId: string, @@ -1314,6 +1404,7 @@ export async function updateNpmInstalledPlugins(params: { const officialClawHubSpec = params.syncOfficialPluginInstalls ? resolveTrustedSourceLinkedOfficialClawHubSpec({ pluginId, record }) : undefined; + const officialSyncUpdateChannel = params.officialPluginUpdateChannel ?? params.updateChannel; if (normalizedPluginConfig) { const enableState = resolveEffectiveEnableState({ @@ -1347,7 +1438,7 @@ export async function updateNpmInstalledPlugins(params: { record, specOverride: params.specOverrides?.[pluginId], officialSpecOverride: officialNpmSpec, - updateChannel: params.updateChannel, + updateChannel: officialNpmSpec ? officialSyncUpdateChannel : params.updateChannel, }) : undefined; const clawhubSpecs = @@ -1355,7 +1446,7 @@ export async function updateNpmInstalledPlugins(params: { ? resolveClawHubUpdateSpecs({ record, officialSpecOverride: officialClawHubSpec, - updateChannel: params.updateChannel, + updateChannel: officialClawHubSpec ? officialSyncUpdateChannel : params.updateChannel, }) : undefined; const effectiveSpec = @@ -1377,7 +1468,9 @@ export async function updateNpmInstalledPlugins(params: { record, effectiveClawHubSpec: effectiveSpec, recordClawHubSpec: recordSpec, - updateChannel: params.updateChannel, + updateChannel: params.syncOfficialPluginInstalls + ? officialSyncUpdateChannel + : params.updateChannel, }) : null; let officialNpmFallbackInstallSpec = officialNpmFallbackSpecs?.installSpec; @@ -1580,6 +1673,10 @@ export async function updateNpmInstalledPlugins(params: { continue; } } else { + if (!parseRegistryNpmSpec(effectiveSpec!)) { + recordFailure(pluginId, `Failed to check ${pluginId}: ${metadataResult.error}`); + continue; + } logger.warn?.( `Could not check ${pluginId} before update; falling back to installer path: ${metadataResult.error}`, ); @@ -1625,6 +1722,7 @@ export async function updateNpmInstalledPlugins(params: { dryRun: true, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, + ...clawHubRiskAcknowledgementOptions, logger, }) : record.source === "git" @@ -1725,6 +1823,7 @@ export async function updateNpmInstalledPlugins(params: { dryRun: true, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, + ...clawHubRiskAcknowledgementOptions, logger, }); activeClawHubInstallSpec = clawhubSpecs.fallbackSpec; @@ -1761,6 +1860,29 @@ export async function updateNpmInstalledPlugins(params: { }); } if (!probe.ok) { + if ( + record.source === "clawhub" && + shouldSkipClawHubTrustFailureForExistingInstall({ + result: probe, + currentVersion, + }) + ) { + const code = readClawHubTrustErrorCode(probe); + if (!code) { + continue; + } + outcomes.push( + buildClawHubTrustSkippedOutcome({ + pluginId, + phase: "check", + error: probe.error, + code, + ...("warning" in probe && probe.warning ? { warning: probe.warning } : {}), + ...(currentVersion ? { currentVersion } : {}), + }), + ); + continue; + } recordFailure( pluginId, record.source === "npm" || usedOfficialNpmFallback @@ -1886,6 +2008,7 @@ export async function updateNpmInstalledPlugins(params: { timeoutMs: params.timeoutMs, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, + ...clawHubRiskAcknowledgementOptions, logger, }) : record.source === "git" @@ -1983,6 +2106,7 @@ export async function updateNpmInstalledPlugins(params: { timeoutMs: params.timeoutMs, dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, expectedPluginId: pluginId, + ...clawHubRiskAcknowledgementOptions, logger, }); activeClawHubInstallSpec = clawhubSpecs.fallbackSpec; @@ -2020,6 +2144,29 @@ export async function updateNpmInstalledPlugins(params: { }); } if (!result.ok) { + if ( + record.source === "clawhub" && + shouldSkipClawHubTrustFailureForExistingInstall({ + result, + currentVersion, + }) + ) { + const code = readClawHubTrustErrorCode(result); + if (!code) { + continue; + } + outcomes.push( + buildClawHubTrustSkippedOutcome({ + pluginId, + phase: "update", + error: result.error, + code, + ...("warning" in result && result.warning ? { warning: result.warning } : {}), + ...(currentVersion ? { currentVersion } : {}), + }), + ); + continue; + } recordFailure( pluginId, resultSource === "npm" @@ -2175,6 +2322,8 @@ export async function syncPluginsForUpdateChannel(params: { env?: NodeJS.ProcessEnv; logger?: PluginUpdateLogger; externalizedBundledPluginBridges?: readonly ExternalizedBundledPluginBridge[]; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; }): Promise { const env = params.env ?? process.env; const logger = params.logger ?? {}; @@ -2194,6 +2343,10 @@ export async function syncPluginsForUpdateChannel(params: { const loadHelpers = buildLoadPathHelpers(next.plugins?.load?.paths ?? [], env); let installs = next.plugins?.installs ?? {}; let changed = false; + const clawHubRiskAcknowledgementOptions = { + ...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), + ...(params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}), + }; if (params.channel === "dev") { for (const [pluginId, record] of Object.entries(installs)) { @@ -2307,6 +2460,7 @@ export async function syncPluginsForUpdateChannel(params: { ...(bridge.clawhubUrl ? { baseUrl: bridge.clawhubUrl } : {}), mode: "update", expectedPluginId: targetPluginId, + ...clawHubRiskAcknowledgementOptions, logger, }); if (!result.ok && npmSpec && shouldFallbackClawHubBridgeToNpm({ result, npmSpec })) { @@ -2336,6 +2490,16 @@ export async function syncPluginsForUpdateChannel(params: { } if (!result.ok) { + const clawHubTrustWarning = + installSource === "clawhub" && + "warning" in result && + typeof result.warning === "string" && + result.warning.trim().length > 0 + ? result.warning + : null; + if (clawHubTrustWarning) { + summary.warnings.push(clawHubTrustWarning); + } const message = installSource === "clawhub" ? formatClawHubInstallFailure({ diff --git a/src/routing/account-lookup.test.ts b/src/routing/account-lookup.test.ts index 04b11a553011..2f5c4241f579 100644 --- a/src/routing/account-lookup.test.ts +++ b/src/routing/account-lookup.test.ts @@ -1,5 +1,6 @@ // Account lookup tests cover account matching by id, alias, and chat metadata. import { describe, expect, it } from "vitest"; +import { normalizeAccountId as normalizeRoutingAccountId } from "./account-id.js"; import { resolveAccountEntry, resolveNormalizedAccountEntry } from "./account-lookup.js"; function createAccountsWithPrototypePollution() { @@ -75,6 +76,33 @@ describe("resolveNormalizedAccountEntry", () => { id: "ops", }, }, + { + name: "does not resolve blocked raw keys as the default account", + accounts: JSON.parse('{"__proto__":{"id":"blocked"}}') as Record, + resolve: (accounts: Record) => + resolveNormalizedAccountEntry(accounts, "default", normalizeRoutingAccountId), + expected: undefined, + }, + { + name: "does not resolve keys that normalize to blocked object keys", + accounts: { + "constructor ": { id: "blocked" }, + } as Record, + resolve: (accounts: Record) => + resolveNormalizedAccountEntry(accounts, "constructor", (accountId) => + accountId.trim().toLowerCase(), + ), + expected: undefined, + }, + { + name: "does not resolve invalid raw keys through the default account fallback", + accounts: { + "constructor ": { id: "blocked" }, + } as Record, + resolve: (accounts: Record) => + resolveNormalizedAccountEntry(accounts, "default", normalizeRoutingAccountId), + expected: undefined, + }, { name: "ignores prototype-chain values", resolve: () => undefined, diff --git a/src/routing/account-lookup.ts b/src/routing/account-lookup.ts index c5088112b27a..6e498d318ca6 100644 --- a/src/routing/account-lookup.ts +++ b/src/routing/account-lookup.ts @@ -1,5 +1,7 @@ // Account lookup helpers resolve route accounts from normalized account ids. import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import { isBlockedObjectKey } from "../infra/prototype-keys.js"; +import { normalizeOptionalAccountId } from "./account-id.js"; // Case-insensitive account lookup for config maps that may preserve user // casing. Exact keys win so callers can still distinguish intentional entries. @@ -30,10 +32,20 @@ export function resolveNormalizedAccountEntry( if (!accounts || typeof accounts !== "object") { return undefined; } - if (Object.hasOwn(accounts, accountId)) { + if (Object.hasOwn(accounts, accountId) && !isBlockedObjectKey(accountId)) { return accounts[accountId]; } const normalized = normalizeAccountId(accountId); - const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized); + const matchKey = Object.keys(accounts).find((key) => { + if (isBlockedObjectKey(key)) { + return false; + } + const candidate = normalizeAccountId(key); + return ( + Boolean(normalizeOptionalAccountId(key)) && + !isBlockedObjectKey(candidate) && + candidate === normalized + ); + }); return matchKey ? accounts[matchKey] : undefined; } diff --git a/src/scripts/test-projects.test.ts b/src/scripts/test-projects.test.ts index f1388b9d88df..425e3f506040 100644 --- a/src/scripts/test-projects.test.ts +++ b/src/scripts/test-projects.test.ts @@ -927,6 +927,8 @@ describe("test-projects args", () => { forwardedArgs: [], includePatterns: [ "src/agents/agent-bundle-mcp-runtime.test.ts", + "src/agents/agent-tools-agent-config.exec.test.ts", + "src/agents/bash-tools.exec-foreground-failures.test.ts", "src/agents/models-config.file-mode.test.ts", "src/agents/sandbox/ssh.test.ts", ], diff --git a/src/shared/store-writer-queue.ts b/src/shared/store-writer-queue.ts index 3e1cb26dd00b..3cf2a675a6dc 100644 --- a/src/shared/store-writer-queue.ts +++ b/src/shared/store-writer-queue.ts @@ -1,3 +1,5 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + /** Pending exclusive store write plus the promise hooks for its caller. */ export type StoreWriterTask = { /** Write operation to run once earlier tasks for the same store path finish. */ @@ -21,6 +23,39 @@ export type StoreWriterQueue = { /** Store writer queues keyed by the canonical store path. */ type StoreWriterQueues = Map; +type ActiveStoreWriter = { + active: boolean; + parent: ActiveStoreWriter | undefined; + queues: StoreWriterQueues; + storePath: string; +}; + +const activeStoreWriters = new AsyncLocalStorage(); + +function isActiveStoreWriter(queues: StoreWriterQueues, storePath: string): boolean { + let active = activeStoreWriters.getStore(); + while (active) { + if (active.active && active.queues === queues && active.storePath === storePath) { + return true; + } + active = active.parent; + } + return false; +} + +async function runActiveStoreWriter( + queues: StoreWriterQueues, + storePath: string, + fn: () => Promise, +): Promise { + const writer = { active: true, parent: activeStoreWriters.getStore(), queues, storePath }; + try { + return await activeStoreWriters.run(writer, fn); + } finally { + writer.active = false; + } +} + function getOrCreateStoreWriterQueue( queues: StoreWriterQueues, storePath: string, @@ -89,6 +124,7 @@ export async function runQueuedStoreWrite(params: { storePath: string; label: string; fn: () => Promise; + reentrant?: boolean; }): Promise { if (!params.storePath || typeof params.storePath !== "string") { throw new Error( @@ -97,10 +133,15 @@ export async function runQueuedStoreWrite(params: { )}`, ); } + // Explicit reentrancy keeps one logical read/decide/write section on the + // active lane; ordinary async children must queue behind the current writer. + if (params.reentrant === true && isActiveStoreWriter(params.queues, params.storePath)) { + return await params.fn(); + } const queue = getOrCreateStoreWriterQueue(params.queues, params.storePath); return await new Promise((resolve, reject) => { const task: StoreWriterTask = { - fn: async () => await params.fn(), + fn: async () => await runActiveStoreWriter(params.queues, params.storePath, params.fn), resolve: (value) => resolve(value as T), reject, }; diff --git a/src/skills/lifecycle/clawhub.test.ts b/src/skills/lifecycle/clawhub.test.ts index ae029a404bde..a77ceb03f578 100644 --- a/src/skills/lifecycle/clawhub.test.ts +++ b/src/skills/lifecycle/clawhub.test.ts @@ -9,6 +9,7 @@ import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js"; const fetchClawHubSkillDetailMock = vi.fn(); const fetchClawHubSkillInstallResolutionMock = vi.fn(); const fetchClawHubSkillVerificationMock = vi.fn(); +const fetchClawHubSkillSecurityVerdictsMock = vi.fn(); const downloadClawHubSkillArchiveMock = vi.fn(); const downloadClawHubSkillArchiveUrlMock = vi.fn(); const downloadClawHubGitHubSkillArchiveMock = vi.fn(); @@ -27,6 +28,7 @@ vi.mock("../../infra/clawhub.js", () => ({ fetchClawHubSkillDetail: fetchClawHubSkillDetailMock, fetchClawHubSkillInstallResolution: fetchClawHubSkillInstallResolutionMock, fetchClawHubSkillVerification: fetchClawHubSkillVerificationMock, + fetchClawHubSkillSecurityVerdicts: fetchClawHubSkillSecurityVerdictsMock, downloadClawHubSkillArchive: downloadClawHubSkillArchiveMock, downloadClawHubSkillArchiveUrl: downloadClawHubSkillArchiveUrlMock, downloadClawHubGitHubSkillArchive: downloadClawHubGitHubSkillArchiveMock, @@ -179,6 +181,7 @@ describe("skills-clawhub", () => { fetchClawHubSkillDetailMock.mockReset(); fetchClawHubSkillInstallResolutionMock.mockReset(); fetchClawHubSkillVerificationMock.mockReset(); + fetchClawHubSkillSecurityVerdictsMock.mockReset(); downloadClawHubSkillArchiveMock.mockReset(); downloadClawHubSkillArchiveUrlMock.mockReset(); downloadClawHubGitHubSkillArchiveMock.mockReset(); @@ -195,7 +198,9 @@ describe("skills-clawhub", () => { resolveClawHubBaseUrlMock.mockImplementation((baseUrl?: string) => (baseUrl ?? "https://clawhub.ai").replace(/\/+$/, ""), ); - isDefaultClawHubBaseUrlMock.mockImplementation((baseUrl?: string) => !baseUrl); + isDefaultClawHubBaseUrlMock.mockImplementation( + (baseUrl?: string) => !baseUrl || baseUrl.replace(/\/+$/, "") === "https://clawhub.ai", + ); pathExistsMock.mockImplementation(async (input: string) => input.endsWith("SKILL.md")); fetchClawHubSkillDetailMock.mockResolvedValue({ skill: { @@ -229,6 +234,28 @@ describe("skills-clawhub", () => { security: { status: "clean", signals: { staticScan: { engineVersion: "v2.4.24" } } }, signature: { status: "unsigned" }, }); + fetchClawHubSkillSecurityVerdictsMock.mockImplementation( + async (params: { + items: Array<{ slug: string; ownerHandle?: string; version: string }>; + }) => ({ + schema: "clawhub.skill.security-verdicts.v1", + items: params.items.map((item) => ({ + ok: true, + decision: "pass", + reasons: [], + requestedSlug: item.slug, + requestedVersion: item.version, + slug: item.slug, + version: item.version, + displayName: "Agent Receipt", + ...(item.ownerHandle ? { publisherHandle: item.ownerHandle } : {}), + security: { + status: "clean", + passed: true, + }, + })), + }), + ); downloadClawHubSkillArchiveMock.mockResolvedValue({ archivePath: "/tmp/agentreceipt.zip", integrity: "sha256-test", @@ -310,8 +337,491 @@ describe("skills-clawhub", () => { ]); }); + it("bypasses ClawHub trust checks for official skill install resolutions", async () => { + fetchClawHubSkillInstallResolutionMock.mockResolvedValueOnce({ + ok: true, + slug: "agentreceipt", + channel: "official", + isOfficial: true, + installKind: "archive", + archive: { + version: "1.0.0", + downloadUrl: "https://clawhub.ai/api/v1/download?slug=agentreceipt&version=1.0.0", + }, + }); + fetchClawHubSkillSecurityVerdictsMock.mockRejectedValueOnce(new Error("should not be called")); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "agentreceipt", + }); + + expectInstalledSkill(result, { + slug: "agentreceipt", + version: "1.0.0", + targetDir: "/tmp/workspace/skills/agentreceipt", + }); + expect(fetchClawHubSkillSecurityVerdictsMock).not.toHaveBeenCalled(); + expect(installPolicyInput()).toMatchObject({ + origin: { registry: "https://clawhub.ai" }, + source: { kind: "clawhub", authority: "official", mutable: false, network: true }, + }); + }); + + it("bypasses ClawHub trust checks when skill detail has an official owner", async () => { + fetchClawHubSkillDetailMock.mockResolvedValueOnce({ + skill: { + slug: "tao-setup-nvidia-gpu-host", + displayName: "TAO Setup NVIDIA GPU Host", + createdAt: 1, + updatedAt: 2, + }, + owner: { + handle: "nvidia", + displayName: "NVIDIA", + official: true, + }, + latestVersion: { + version: "1.0.0", + createdAt: 3, + }, + }); + fetchClawHubSkillSecurityVerdictsMock.mockRejectedValueOnce(new Error("should not be called")); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "tao-setup-nvidia-gpu-host", + }); + + expectInstalledSkill(result, { + slug: "tao-setup-nvidia-gpu-host", + version: "1.0.0", + targetDir: "/tmp/workspace/skills/tao-setup-nvidia-gpu-host", + }); + expect(fetchClawHubSkillDetailMock).toHaveBeenCalledWith({ + slug: "tao-setup-nvidia-gpu-host", + baseUrl: undefined, + }); + expect(fetchClawHubSkillSecurityVerdictsMock).not.toHaveBeenCalled(); + expect(installPolicyInput()).toMatchObject({ + origin: { registry: "https://clawhub.ai" }, + source: { kind: "clawhub", authority: "official", mutable: false, network: true }, + }); + }); + + it("blocks ClawHub skill installs when release trust is malicious", async () => { + const warnings: string[] = []; + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [ + { + ok: false, + decision: "fail", + reasons: ["scan:malicious"], + requestedSlug: "agentreceipt", + requestedVersion: "1.0.0", + slug: "agentreceipt", + version: "1.0.0", + publisherHandle: "acme", + security: { + status: "malicious", + passed: false, + }, + }, + ], + }); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "@acme/agentreceipt", + logger: { + warn: (message) => warnings.push(message), + }, + }); + + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("expected malicious skill install failure"); + } + expect(result.error).toBe("ClawHub blocked this release; install was not started."); + expect(result.code).toBe("clawhub_download_blocked"); + expect(result.warning).toContain("BLOCKED - ClawHub flagged this release as malicious"); + expect(warnings.join("\n")).toContain("BLOCKED - ClawHub flagged this release as malicious"); + expect(warnings.join("\n")).toContain("OpenClaw will not install this skill release"); + expect(downloadClawHubSkillArchiveUrlMock).not.toHaveBeenCalled(); + expect(downloadClawHubSkillArchiveMock).not.toHaveBeenCalled(); + }); + + it("requires acknowledgement before installing suspicious ClawHub skill releases", async () => { + const warnings: string[] = []; + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [ + { + ok: false, + decision: "fail", + reasons: ["security.status_not_clean"], + requestedSlug: "agentreceipt", + requestedVersion: "1.0.0", + slug: "agentreceipt", + version: "1.0.0", + skillUrl: "https://clawhub.ai/acme/skills/agentreceipt", + securityAuditUrl: + "https://clawhub.ai/acme/skills/agentreceipt/security-audit?version=1.0.0", + security: { + status: "suspicious", + passed: false, + }, + }, + ], + }); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "agentreceipt", + logger: { + warn: (message) => warnings.push(message), + }, + }); + + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("expected suspicious skill install failure"); + } + expect(result.error).toContain("--acknowledge-clawhub-risk"); + expect(result.version).toBe("1.0.0"); + expect(result.warning).toContain("WARNING - ClawHub found security risks"); + expect(result.warning).toContain( + "https://clawhub.ai/acme/skills/agentreceipt/security-audit?version=1.0.0", + ); + expect(warnings.join("\n")).toContain("WARNING - ClawHub found security risks"); + expect(warnings.join("\n")).toContain( + "https://clawhub.ai/acme/skills/agentreceipt/security-audit?version=1.0.0", + ); + expect(warnings.join("\n")).toContain("large instruction/tool-use blast radius"); + expect(downloadClawHubSkillArchiveUrlMock).not.toHaveBeenCalled(); + }); + + it("returns review-recommended warnings with successful ClawHub skill installs", async () => { + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [ + { + ok: true, + decision: "pass", + reasons: ["scan:pending"], + requestedSlug: "agentreceipt", + requestedVersion: "1.0.0", + slug: "agentreceipt", + version: "1.0.0", + security: { + status: "pending", + passed: true, + }, + }, + ], + }); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "agentreceipt", + }); + + expectInstalledSkill(result, { + slug: "agentreceipt", + version: "1.0.0", + }); + if (!result.ok) { + throw new Error("expected review-recommended skill install success"); + } + expect(result.warning).toContain( + "REVIEW RECOMMENDED - ClawHub has not completed a fresh clean check", + ); + expect(result.warning).toContain("security scan is pending"); + }); + + it("fails closed when ClawHub skill trust checks are unavailable", async () => { + fetchClawHubSkillSecurityVerdictsMock.mockRejectedValueOnce( + new Error("security verdicts unavailable"), + ); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "agentreceipt", + }); + + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("expected unavailable skill trust failure"); + } + expect(result.code).toBe("clawhub_security_unavailable"); + expect(result.error).toContain("ClawHub release trust check failed"); + expect(result.error).toContain("security verdicts unavailable"); + expect(downloadClawHubSkillArchiveUrlMock).not.toHaveBeenCalled(); + expect(downloadClawHubSkillArchiveMock).not.toHaveBeenCalled(); + }); + + it("fails closed when ClawHub returns no skill trust verdict", async () => { + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [], + }); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "agentreceipt", + }); + + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("expected missing skill trust verdict failure"); + } + expect(result.code).toBe("clawhub_security_unavailable"); + expect(result.error).toContain("returned 0 verdicts"); + expect(downloadClawHubSkillArchiveUrlMock).not.toHaveBeenCalled(); + expect(downloadClawHubSkillArchiveMock).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: "omitted security", + verdict: {}, + }, + { + name: "null security", + verdict: { security: null }, + }, + ])("fails closed when a passing skill verdict has $name", async ({ verdict }) => { + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [ + { + ok: true, + decision: "pass", + reasons: [], + requestedSlug: "agentreceipt", + requestedVersion: "1.0.0", + slug: "agentreceipt", + version: "1.0.0", + ...verdict, + }, + ], + }); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "agentreceipt", + }); + + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("expected unusable skill trust verdict failure"); + } + expect(result.code).toBe("clawhub_security_unavailable"); + expect(result.error).toContain("did not return a usable security verdict"); + expect(downloadClawHubSkillArchiveUrlMock).not.toHaveBeenCalled(); + expect(downloadClawHubSkillArchiveMock).not.toHaveBeenCalled(); + }); + + it("blocks ClawHub skill installs when moderation marks the release as malware-blocked", async () => { + const warnings: string[] = []; + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [ + { + ok: false, + decision: "fail", + reasons: ["moderation.malware_blocked"], + requestedSlug: "agentreceipt", + requestedVersion: "1.0.0", + slug: "agentreceipt", + version: "1.0.0", + security: { + status: "clean", + passed: false, + }, + }, + ], + }); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "agentreceipt", + acknowledgeClawHubRisk: true, + logger: { + warn: (message) => warnings.push(message), + }, + }); + + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("expected moderation-blocked skill install failure"); + } + expect(result.error).toBe("ClawHub blocked this release; install was not started."); + expect(result.code).toBe("clawhub_download_blocked"); + expect(warnings.join("\n")).toContain("BLOCKED - ClawHub blocked this release"); + expect(downloadClawHubSkillArchiveUrlMock).not.toHaveBeenCalled(); + }); + + it("requires acknowledgement when ClawHub returns a failed skill verdict with clean nested scan status", async () => { + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [ + { + ok: false, + decision: "fail", + reasons: [], + requestedSlug: "agentreceipt", + requestedVersion: "1.0.0", + slug: "agentreceipt", + version: "1.0.0", + security: { + status: "clean", + passed: false, + }, + }, + ], + }); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "agentreceipt", + }); + + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("expected failed skill verdict install failure"); + } + expect(result.error).toContain("--acknowledge-clawhub-risk"); + expect(downloadClawHubSkillArchiveUrlMock).not.toHaveBeenCalled(); + }); + + it("uses the owner-qualified skill name for suspicious ClawHub acknowledgements", async () => { + const onClawHubRisk = vi.fn< + NonNullable[0]["onClawHubRisk"]> + >(async () => false); + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [ + { + ok: false, + decision: "fail", + reasons: ["security.status_not_clean"], + requestedSlug: "agentreceipt", + requestedVersion: "1.0.0", + slug: "agentreceipt", + version: "1.0.0", + publisherHandle: "acme", + security: { + status: "suspicious", + passed: false, + }, + }, + ], + }); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "@acme/agentreceipt", + onClawHubRisk, + }); + + expect(result.ok).toBe(false); + expect(onClawHubRisk).toHaveBeenCalledWith( + expect.objectContaining({ + packageName: "@acme/agentreceipt", + version: "1.0.0", + }), + ); + const acknowledgementRequest = onClawHubRisk.mock.calls[0]?.[0]; + expect(acknowledgementRequest?.warning).toContain( + "https://clawhub.ai/acme/skills/agentreceipt", + ); + expect(acknowledgementRequest?.warning).toContain( + "https://clawhub.ai/acme/skills/agentreceipt/security-audit?version=1.0.0", + ); + expect(downloadClawHubSkillArchiveUrlMock).not.toHaveBeenCalled(); + }); + + it("continues after explicit acknowledgement for suspicious ClawHub skill releases", async () => { + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [ + { + ok: false, + decision: "fail", + reasons: ["security.status_not_clean"], + requestedSlug: "agentreceipt", + requestedVersion: "1.0.0", + slug: "agentreceipt", + version: "1.0.0", + security: { + status: "suspicious", + passed: false, + }, + }, + ], + }); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "agentreceipt", + acknowledgeClawHubRisk: true, + }); + + expectInstalledSkill(result, { + slug: "agentreceipt", + version: "1.0.0", + }); + if (!result.ok) { + throw new Error("expected acknowledged suspicious skill install success"); + } + expect(result.warning).toContain("WARNING - ClawHub found security risks"); + expect(downloadClawHubSkillArchiveUrlMock).toHaveBeenCalledWith({ + url: "https://clawhub.ai/api/v1/download?slug=agentreceipt&version=1.0.0", + baseUrl: undefined, + }); + }); + it("installs owner-qualified ClawHub skills without using owner as a local path", async () => { const workspaceDir = await tempDirs.make("openclaw-owner-skill-"); + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [ + { + ok: false, + decision: "fail", + reasons: ["skill.not_found"], + requestedSlug: "weather", + requestedVersion: "1.0.0", + slug: "weather", + version: null, + security: null, + error: { + code: "skill_not_found", + message: "Skill not found", + }, + }, + ], + }); + fetchClawHubSkillVerificationMock.mockResolvedValueOnce({ + schema: "clawhub.skill.verify.v1", + ok: true, + decision: "pass", + reasons: [], + slug: "weather", + displayName: "Weather", + pageUrl: "https://clawhub.ai/demo-owner/skills/weather", + publisherHandle: "demo-owner", + publisherDisplayName: "Demo Owner", + version: "1.0.0", + createdAt: 123, + card: { available: true, sha256: "card-sha" }, + artifact: { sourceFingerprint: "source-fp" }, + provenance: { source: "unavailable" }, + security: { status: "clean" }, + signature: { status: "unsigned" }, + }); installPackageDirMock.mockImplementationOnce(async (params: { targetDir: string }) => { await fs.mkdir(params.targetDir, { recursive: true }); await fs.writeFile(path.join(params.targetDir, "SKILL.md"), "# Weather\n", "utf8"); @@ -328,6 +838,26 @@ describe("skills-clawhub", () => { ownerHandle: "demo-owner", baseUrl: undefined, }); + expect(fetchClawHubSkillSecurityVerdictsMock).toHaveBeenCalledWith({ + items: [ + { + slug: "weather", + ownerHandle: "demo-owner", + version: "1.0.0", + }, + ], + baseUrl: undefined, + token: undefined, + timeoutMs: undefined, + }); + expect(fetchClawHubSkillVerificationMock).toHaveBeenNthCalledWith(1, { + slug: "weather", + ownerHandle: "demo-owner", + version: "1.0.0", + baseUrl: undefined, + token: undefined, + timeoutMs: undefined, + }); expectInstallPackageSourceDir("/tmp/extracted-skill"); expect(installPolicyInput()).toMatchObject({ origin: { @@ -367,6 +897,122 @@ describe("skills-clawhub", () => { }); }); + it("does not require acknowledgement for owner-qualified clean skills missing only cards", async () => { + const workspaceDir = await tempDirs.make("openclaw-owner-card-missing-"); + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [ + { + ok: false, + decision: "fail", + reasons: ["skill.not_found"], + requestedSlug: "weather", + requestedVersion: "1.0.0", + slug: "weather", + version: null, + security: null, + error: { + code: "skill_not_found", + message: "Skill not found", + }, + }, + ], + }); + fetchClawHubSkillVerificationMock.mockResolvedValueOnce({ + schema: "clawhub.skill.verify.v1", + ok: false, + decision: "fail", + reasons: ["card.missing"], + slug: "weather", + displayName: "Weather", + pageUrl: "https://clawhub.ai/demo-owner/skills/weather", + publisherHandle: "demo-owner", + publisherDisplayName: "Demo Owner", + version: "1.0.0", + createdAt: 123, + card: { available: false }, + artifact: { sourceFingerprint: "source-fp" }, + provenance: { source: "unavailable" }, + security: { status: "clean" }, + signature: { status: "unsigned" }, + }); + const onClawHubRisk = vi.fn(async () => false); + installPackageDirMock.mockImplementationOnce(async (params: { targetDir: string }) => { + await fs.mkdir(params.targetDir, { recursive: true }); + await fs.writeFile(path.join(params.targetDir, "SKILL.md"), "# Weather\n", "utf8"); + return { ok: true, targetDir: params.targetDir }; + }); + + const result = await installSkillFromClawHub({ + workspaceDir, + slug: "@demo-owner/weather", + onClawHubRisk, + }); + + expectInstalledSkill(result, { + slug: "weather", + version: "1.0.0", + targetDir: path.join(workspaceDir, "skills", "weather"), + }); + expect(onClawHubRisk).not.toHaveBeenCalled(); + expect(downloadClawHubSkillArchiveUrlMock).toHaveBeenCalled(); + }); + + it("does not let owner-qualified fallback acknowledgement mask missing exact versions", async () => { + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [ + { + ok: false, + decision: "fail", + reasons: ["skill.not_found"], + requestedSlug: "weather", + requestedVersion: "1.0.0", + slug: "weather", + version: null, + security: null, + error: { + code: "skill_not_found", + message: "Skill not found", + }, + }, + ], + }); + fetchClawHubSkillVerificationMock.mockResolvedValueOnce({ + schema: "clawhub.skill.verify.v1", + ok: false, + decision: "fail", + reasons: ["version.not_found"], + slug: "weather", + displayName: "Weather", + pageUrl: "https://clawhub.ai/demo-owner/skills/weather", + publisherHandle: "demo-owner", + publisherDisplayName: "Demo Owner", + version: null, + createdAt: 123, + card: { available: true, sha256: "card-sha" }, + artifact: { sourceFingerprint: "source-fp" }, + provenance: { source: "unavailable" }, + security: { status: "clean" }, + signature: { status: "unsigned" }, + }); + + const result = await installSkillFromClawHub({ + workspaceDir: "/tmp/workspace", + slug: "@demo-owner/weather", + acknowledgeClawHubRisk: true, + }); + + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("expected owner-qualified missing version failure"); + } + expect(result.code).toBe("clawhub_security_unavailable"); + expect(result.error).toContain('returned version "unknown"'); + expect(downloadClawHubSkillArchiveUrlMock).not.toHaveBeenCalled(); + expect(downloadClawHubSkillArchiveMock).not.toHaveBeenCalled(); + }); + it("formats ambiguous ClawHub slug responses with owner-qualified guidance", async () => { fetchClawHubSkillInstallResolutionMock.mockResolvedValueOnce({ ok: false, @@ -755,6 +1401,9 @@ describe("skills-clawhub", () => { slug: "aiq-deploy", baseUrl: undefined, }); + // GitHub-backed skills are approved by the install resolver before it + // returns this pinned commit; they do not have a ClawHub release version. + expect(fetchClawHubSkillSecurityVerdictsMock).not.toHaveBeenCalled(); expect(downloadClawHubGitHubSkillArchiveMock).toHaveBeenCalledWith({ repo: "NVIDIA/skills", commit, @@ -809,6 +1458,8 @@ describe("skills-clawhub", () => { baseUrl: undefined, forceInstall: true, }); + // forceInstall is a resolver policy input for GitHub-backed skills. + expect(fetchClawHubSkillSecurityVerdictsMock).not.toHaveBeenCalled(); expectInstalledSkill(result, { slug: "aiq-deploy", version: commit, @@ -919,6 +1570,129 @@ describe("skills-clawhub", () => { expect(lock.skills.weather?.ownerHandle).toBe("demo-owner"); }); + it("updates official publisher ClawHub skills without fetching security verdicts", async () => { + const workspaceDir = await tempDirs.make("openclaw-official-owner-update-"); + await writeClawHubOriginFixture({ + workspaceDir, + slug: "tao-setup-nvidia-gpu-host", + ownerHandle: "nvidia", + registry: "https://clawhub.ai", + installedVersion: "0.9.0", + }); + fetchClawHubSkillDetailMock.mockResolvedValueOnce({ + skill: { + slug: "tao-setup-nvidia-gpu-host", + displayName: "TAO Setup NVIDIA GPU Host", + createdAt: 1, + updatedAt: 2, + }, + owner: { + handle: "nvidia", + displayName: "NVIDIA", + official: true, + }, + latestVersion: { + version: "1.0.0", + createdAt: 3, + }, + }); + fetchClawHubSkillInstallResolutionMock.mockResolvedValueOnce({ + ok: true, + slug: "tao-setup-nvidia-gpu-host", + installKind: "archive", + archive: { + version: "1.0.0", + downloadUrl: + "https://clawhub.ai/api/v1/download?slug=tao-setup-nvidia-gpu-host&ownerHandle=nvidia&version=1.0.0", + }, + }); + fetchClawHubSkillSecurityVerdictsMock.mockRejectedValueOnce(new Error("should not be called")); + installPackageDirMock.mockImplementationOnce(async (params: { targetDir: string }) => { + await fs.mkdir(params.targetDir, { recursive: true }); + await fs.writeFile(path.join(params.targetDir, "SKILL.md"), "# NVIDIA\n", "utf8"); + return { ok: true, targetDir: params.targetDir }; + }); + + const results = await updateSkillsFromClawHub({ + workspaceDir, + slug: "tao-setup-nvidia-gpu-host", + }); + + expect(fetchClawHubSkillDetailMock).toHaveBeenCalledWith({ + slug: "tao-setup-nvidia-gpu-host", + ownerHandle: "nvidia", + baseUrl: "https://clawhub.ai", + }); + expect(fetchClawHubSkillSecurityVerdictsMock).not.toHaveBeenCalled(); + expect(results).toEqual([ + { + ok: true, + slug: "tao-setup-nvidia-gpu-host", + previousVersion: "0.9.0", + version: "1.0.0", + changed: true, + targetDir: path.join(workspaceDir, "skills", "tao-setup-nvidia-gpu-host"), + }, + ]); + expect(installPolicyInput()).toMatchObject({ + origin: { registry: "https://clawhub.ai", ownerHandle: "nvidia" }, + source: { kind: "clawhub", authority: "official", mutable: false, network: true }, + }); + }); + + it("explains that a malicious skill update will not be downloaded", async () => { + const workspaceDir = await tempDirs.make("openclaw-skill-malicious-update-"); + const warnings: string[] = []; + await writeClawHubOriginFixture({ + workspaceDir, + slug: "agentreceipt", + installedVersion: "0.9.0", + }); + fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({ + schema: "clawhub.skill.security-verdicts.v1", + items: [ + { + ok: false, + decision: "fail", + reasons: ["scan:malicious"], + requestedSlug: "agentreceipt", + requestedVersion: "1.0.0", + slug: "agentreceipt", + version: "1.0.0", + security: { + status: "malicious", + passed: false, + }, + }, + ], + }); + + const results = await updateSkillsFromClawHub({ + workspaceDir, + slug: "agentreceipt", + logger: { + warn: (message) => warnings.push(message), + }, + }); + + expect(results).toEqual([ + expect.objectContaining({ + ok: false, + code: "clawhub_download_blocked", + error: "ClawHub blocked this release; update was not started.", + }), + ]); + expect(warnings.join("\n")).toContain( + "Latest skill version is marked malicious; OpenClaw will not download it.", + ); + expect(warnings.join("\n")).toContain( + "Uninstall the installed skill unless you have independently reviewed it.", + ); + expect(warnings.join("\n")).not.toContain("Choose a different version"); + expect(downloadClawHubSkillArchiveUrlMock).not.toHaveBeenCalled(); + expect(downloadClawHubSkillArchiveMock).not.toHaveBeenCalled(); + }); + it("updates owner-qualified ClawHub skills when the requested owner matches tracking", async () => { const workspaceDir = await tempDirs.make("openclaw-owner-update-request-"); await writeClawHubOriginFixture({ diff --git a/src/skills/lifecycle/clawhub.ts b/src/skills/lifecycle/clawhub.ts index cb8eadb0b29c..b94ddff1f1d6 100644 --- a/src/skills/lifecycle/clawhub.ts +++ b/src/skills/lifecycle/clawhub.ts @@ -4,6 +4,11 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + type ClawHubTrustErrorCode, + ensureClawHubPackageTrustAcknowledged, + type ClawHubRiskAcknowledgementRequest, +} from "../../infra/clawhub-install-trust.js"; import { downloadClawHubGitHubSkillArchive, downloadClawHubSkillArchive, @@ -139,8 +144,9 @@ type InstallClawHubSkillResult = version: string; targetDir: string; detail?: ClawHubSkillDetail; + warning?: string; } - | { ok: false; error: string }; + | { ok: false; error: string; code?: ClawHubTrustErrorCode; version?: string; warning?: string }; type UpdateClawHubSkillResult = | { @@ -150,11 +156,14 @@ type UpdateClawHubSkillResult = version: string; changed: boolean; targetDir: string; + warning?: string; } - | { ok: false; error: string }; + | { ok: false; error: string; code?: ClawHubTrustErrorCode; version?: string; warning?: string }; type Logger = { info?: (message: string) => void; + warn?: (message: string) => void; + terminalLinks?: boolean; }; type ClawHubSkillRef = { @@ -229,10 +238,61 @@ type ClawHubInstallParams = { baseUrl?: string; force?: boolean; forceInstall?: boolean; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; logger?: Logger; config?: OpenClawConfig; }; +type ClawHubOfficialFlagContainer = { + channel?: unknown; + official?: unknown; + isOfficial?: unknown; +}; + +function hasOfficialClawHubFlag(value: ClawHubOfficialFlagContainer | null | undefined): boolean { + return value?.channel === "official" || value?.official === true || value?.isOfficial === true; +} + +function isDefaultOfficialClawHubSkillSource(params: { + baseUrl?: string; + detail?: ClawHubSkillDetail; + resolution?: Extract; +}): boolean { + if (!isDefaultClawHubBaseUrl(params.baseUrl)) { + return false; + } + return ( + hasOfficialClawHubFlag(params.detail?.skill) || + hasOfficialClawHubFlag(params.detail?.owner) || + hasOfficialClawHubFlag(params.resolution) || + (params.resolution?.installKind === "archive" && + hasOfficialClawHubFlag(params.resolution.archive)) + ); +} + +async function fetchDefaultClawHubSkillDetailIfOfficial(params: { + baseUrl?: string; + slug: string; + ownerHandle?: string; +}): Promise { + if (!isDefaultClawHubBaseUrl(params.baseUrl)) { + return undefined; + } + try { + const detail = await fetchClawHubSkillDetail({ + slug: params.slug, + ...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}), + baseUrl: params.baseUrl, + }); + return isDefaultOfficialClawHubSkillSource({ baseUrl: params.baseUrl, detail }) + ? detail + : undefined; + } catch { + return undefined; + } +} + type TrackedUpdateTarget = | { ok: true; @@ -1107,7 +1167,7 @@ async function installArchiveResolution(params: { version: string; archivePath: string; registry: string; - authority: "openclaw" | "third-party"; + authority: "official" | "openclaw" | "third-party"; force?: boolean; logger?: Logger; config?: OpenClawConfig; @@ -1154,6 +1214,7 @@ async function installGitHubResolution(params: { sourcePath: string; archivePath: string; registry: string; + authority: "official" | "third-party"; repo: string; commit: string; force?: boolean; @@ -1186,7 +1247,7 @@ async function installGitHubResolution(params: { }, source: { kind: "git", - authority: "third-party", + authority: params.authority, mutable: false, network: true, }, @@ -1212,6 +1273,41 @@ function assertInstallResolutionAllowed( throw new Error(resolution.message || `Skill "${resolution.slug}" is not installable.`); } +async function ensureClawHubSkillTrustAcknowledged( + params: ClawHubInstallParams & { + version: string; + skipClawHubTrustCheck?: boolean; + }, +): Promise< + | { ok: true; warning?: string } + | { ok: false; error: string; code?: ClawHubTrustErrorCode; warning?: string } +> { + if (params.skipClawHubTrustCheck) { + return { ok: true }; + } + const result = await ensureClawHubPackageTrustAcknowledged({ + subject: { + kind: "skill", + packageName: params.slug, + ...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}), + }, + version: params.version, + baseUrl: params.baseUrl, + acknowledgeClawHubRisk: params.acknowledgeClawHubRisk, + onClawHubRisk: params.onClawHubRisk, + logger: params.logger, + mode: params.force ? "update" : "install", + }); + return result.ok + ? { ok: true, ...(result.warning ? { warning: result.warning } : {}) } + : { + ok: false, + error: result.error, + ...(result.code ? { code: result.code } : {}), + ...(result.warning ? { warning: result.warning } : {}), + }; +} + async function performClawHubSkillInstall( params: ClawHubInstallParams, ): Promise { @@ -1230,49 +1326,96 @@ async function performClawHubSkillInstall( let detail: ClawHubSkillDetail | undefined; let latestResolution: Extract | undefined; let install: Awaited>; + let trustWarning: string | undefined; + let officialClawHubSkill = false; - const archive = params.version - ? await (async () => { - const resolved = await resolveInstallVersion({ + let archive: ClawHubDownloadResult; + if (params.version) { + const resolved = await resolveInstallVersion({ + slug: params.slug, + ...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}), + version: params.version, + baseUrl: params.baseUrl, + }); + detail = resolved.detail; + version = resolved.version; + officialClawHubSkill = isDefaultOfficialClawHubSkillSource({ + baseUrl: params.baseUrl, + detail, + }); + const trust = await ensureClawHubSkillTrustAcknowledged({ + ...params, + version, + skipClawHubTrustCheck: officialClawHubSkill, + }); + if (!trust.ok) { + return { ...trust, version }; + } + trustWarning = trust.warning; + params.logger?.info?.(`Downloading ${params.slug}@${version} from ClawHub…`); + archive = await downloadClawHubSkillArchive({ + slug: params.slug, + ...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}), + version, + baseUrl: params.baseUrl, + }); + } else { + latestResolution = assertInstallResolutionAllowed( + await fetchClawHubSkillInstallResolution({ + slug: params.slug, + ...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}), + baseUrl: params.baseUrl, + ...(params.forceInstall ? { forceInstall: true } : {}), + }), + ); + const resolutionOfficialClawHubSkill = isDefaultOfficialClawHubSkillSource({ + baseUrl: params.baseUrl, + resolution: latestResolution, + }); + detail = resolutionOfficialClawHubSkill + ? undefined + : await fetchDefaultClawHubSkillDetailIfOfficial({ + baseUrl: params.baseUrl, slug: params.slug, ...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}), - version: params.version, - baseUrl: params.baseUrl, }); - detail = resolved.detail; - version = resolved.version; - params.logger?.info?.(`Downloading ${params.slug}@${version} from ClawHub…`); - return await downloadClawHubSkillArchive({ - slug: params.slug, - ...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}), - version, - baseUrl: params.baseUrl, - }); - })() - : await (async () => { - latestResolution = assertInstallResolutionAllowed( - await fetchClawHubSkillInstallResolution({ - slug: params.slug, - ...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}), - baseUrl: params.baseUrl, - ...(params.forceInstall ? { forceInstall: true } : {}), - }), - ); - if (latestResolution.installKind === "github") { - version = latestResolution.github.commit; - params.logger?.info?.(`Downloading ${params.slug}@${version} from GitHub…`); - return await downloadClawHubGitHubSkillArchive({ - repo: latestResolution.github.repo, - commit: latestResolution.github.commit, - }); - } - version = latestResolution.archive.version; - params.logger?.info?.(`Downloading ${params.slug}@${version} from ClawHub…`); - return await downloadClawHubSkillArchiveUrl({ - url: latestResolution.archive.downloadUrl, - baseUrl: params.baseUrl, - }); - })(); + if (latestResolution.installKind === "github") { + version = latestResolution.github.commit; + officialClawHubSkill = isDefaultOfficialClawHubSkillSource({ + baseUrl: params.baseUrl, + detail, + resolution: latestResolution, + }); + // GitHub-backed ClawHub skills are commit resolutions, not ClawHub skill + // release versions; the install resolver owns their scan/force policy. + params.logger?.info?.(`Downloading ${params.slug}@${version} from GitHub…`); + archive = await downloadClawHubGitHubSkillArchive({ + repo: latestResolution.github.repo, + commit: latestResolution.github.commit, + }); + } else { + version = latestResolution.archive.version; + officialClawHubSkill = isDefaultOfficialClawHubSkillSource({ + baseUrl: params.baseUrl, + detail, + resolution: latestResolution, + }); + const trust = await ensureClawHubSkillTrustAcknowledged({ + ...params, + version, + skipClawHubTrustCheck: officialClawHubSkill, + }); + if (!trust.ok) { + return { ...trust, version }; + } + trustWarning = trust.warning; + params.logger?.info?.(`Downloading ${params.slug}@${version} from ClawHub…`); + archive = await downloadClawHubSkillArchiveUrl({ + url: latestResolution.archive.downloadUrl, + baseUrl: params.baseUrl, + }); + } + } try { if (!params.version) { if (!latestResolution) { @@ -1287,6 +1430,7 @@ async function performClawHubSkillInstall( sourcePath: latestResolution.github.path, archivePath: archive.archivePath, registry, + authority: officialClawHubSkill ? "official" : "third-party", repo: latestResolution.github.repo, commit: latestResolution.github.commit, force: params.force, @@ -1300,7 +1444,7 @@ async function performClawHubSkillInstall( version, archivePath: archive.archivePath, registry, - authority: clawhubAuthority, + authority: officialClawHubSkill ? "official" : clawhubAuthority, force: params.force, logger: params.logger, config: params.config, @@ -1313,7 +1457,7 @@ async function performClawHubSkillInstall( version, archivePath: archive.archivePath, registry, - authority: clawhubAuthority, + authority: officialClawHubSkill ? "official" : clawhubAuthority, force: params.force, logger: params.logger, config: params.config, @@ -1374,6 +1518,7 @@ async function performClawHubSkillInstall( version, targetDir: install.targetDir, ...(detail ? { detail } : {}), + ...(trustWarning ? { warning: trustWarning } : {}), }; } finally { await archive.cleanup().catch(() => undefined); @@ -1453,6 +1598,8 @@ export async function installSkillFromClawHub(params: { baseUrl?: string; force?: boolean; forceInstall?: boolean; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; logger?: Logger; config?: OpenClawConfig; }): Promise { @@ -1464,6 +1611,8 @@ export async function updateSkillsFromClawHub(params: { slug?: string; baseUrl?: string; forceInstall?: boolean; + acknowledgeClawHubRisk?: boolean; + onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; logger?: Logger; config?: OpenClawConfig; }): Promise { @@ -1499,6 +1648,8 @@ export async function updateSkillsFromClawHub(params: { baseUrl: tracked.baseUrl, force: true, forceInstall: params.forceInstall, + acknowledgeClawHubRisk: params.acknowledgeClawHubRisk, + onClawHubRisk: params.onClawHubRisk, logger: params.logger, config: params.config, }); @@ -1513,6 +1664,7 @@ export async function updateSkillsFromClawHub(params: { version: install.version, changed: tracked.previousVersion !== install.version, targetDir: install.targetDir, + ...(install.warning ? { warning: install.warning } : {}), }); } return results; diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index edbd88bb98df..299e882dca20 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -288,6 +288,7 @@ export interface CronJobs { payload_model: string | null; payload_thinking: string | null; payload_timeout_seconds: number | null; + payload_tools_allow_is_default: number | null; payload_tools_allow_json: string | null; running_at_ms: number | null; runtime_updated_at_ms: number | null; diff --git a/src/state/openclaw-state-db.ts b/src/state/openclaw-state-db.ts index a89f05b526f0..b4877d56c02a 100644 --- a/src/state/openclaw-state-db.ts +++ b/src/state/openclaw-state-db.ts @@ -712,6 +712,7 @@ function ensureAdditiveStateColumns(db: DatabaseSync): void { ensureColumn(db, "cron_jobs", "payload_external_content_source_json TEXT"); ensureColumn(db, "cron_jobs", "payload_light_context INTEGER"); ensureColumn(db, "cron_jobs", "payload_tools_allow_json TEXT"); + ensureColumn(db, "cron_jobs", "payload_tools_allow_is_default INTEGER"); ensureColumn(db, "cron_jobs", "delivery_mode TEXT"); ensureColumn(db, "cron_jobs", "delivery_channel TEXT"); ensureColumn(db, "cron_jobs", "delivery_to TEXT"); diff --git a/src/state/openclaw-state-schema.generated.ts b/src/state/openclaw-state-schema.generated.ts index 629437dc7670..a11a1194e4fe 100644 --- a/src/state/openclaw-state-schema.generated.ts +++ b/src/state/openclaw-state-schema.generated.ts @@ -884,6 +884,7 @@ CREATE TABLE IF NOT EXISTS cron_jobs ( payload_external_content_source_json TEXT, payload_light_context INTEGER, payload_tools_allow_json TEXT, + payload_tools_allow_is_default INTEGER, delivery_mode TEXT, delivery_channel TEXT, delivery_to TEXT, diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index 0409b2c3cb71..3ad725f92a63 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -879,6 +879,7 @@ CREATE TABLE IF NOT EXISTS cron_jobs ( payload_external_content_source_json TEXT, payload_light_context INTEGER, payload_tools_allow_json TEXT, + payload_tools_allow_is_default INTEGER, delivery_mode TEXT, delivery_channel TEXT, delivery_to TEXT, diff --git a/src/status/codex-synthetic-usage.ts b/src/status/codex-synthetic-usage.ts new file mode 100644 index 000000000000..3efd134c03de --- /dev/null +++ b/src/status/codex-synthetic-usage.ts @@ -0,0 +1,63 @@ +import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { CODEX_APP_SERVER_AUTH_MARKER } from "../agents/model-auth-markers.js"; +import type { ProviderAuth } from "../infra/provider-usage.auth.js"; +import type { ProviderUsageSnapshot, UsageSummary } from "../infra/provider-usage.types.js"; + +export const CODEX_SYNTHETIC_USAGE_PROVIDER = "openai"; +export const CODEX_SYNTHETIC_USAGE_HOOK_PROVIDER = "codex"; + +export function buildCodexSyntheticUsageAuth( + params: { + authProfileId?: string; + } = {}, +): ProviderAuth { + return { + provider: CODEX_SYNTHETIC_USAGE_PROVIDER, + token: CODEX_APP_SERVER_AUTH_MARKER, + ...(params.authProfileId ? { authProfileId: params.authProfileId } : {}), + hookProvider: CODEX_SYNTHETIC_USAGE_HOOK_PROVIDER, + }; +} + +export function shouldUseCodexSyntheticUsageForRuntime(params: { + provider?: string; + effectiveHarness?: string; +}): boolean { + const harness = normalizeOptionalLowercaseString(params.effectiveHarness); + const provider = normalizeOptionalLowercaseString(params.provider); + return ( + harness === CODEX_SYNTHETIC_USAGE_HOOK_PROVIDER && + (provider === CODEX_SYNTHETIC_USAGE_PROVIDER || provider === "codex") + ); +} + +function hasDisplayableUsageSnapshot(snapshot: ProviderUsageSnapshot): boolean { + return snapshot.windows.length > 0 || Boolean(snapshot.summary?.trim()); +} + +function usageSnapshotRank(snapshot: ProviderUsageSnapshot): number { + if (hasDisplayableUsageSnapshot(snapshot)) { + return 2; + } + return snapshot.error ? 0 : 1; +} + +export function mergeUsageSummaries( + base: UsageSummary, + extra: UsageSummary | undefined, +): UsageSummary { + if (!extra || extra.providers.length === 0) { + return base; + } + const providersById = new Map(base.providers.map((provider) => [provider.provider, provider])); + for (const provider of extra.providers) { + const existing = providersById.get(provider.provider); + if (!existing || usageSnapshotRank(provider) >= usageSnapshotRank(existing)) { + providersById.set(provider.provider, provider); + } + } + return { + updatedAt: base.updatedAt, + providers: [...providersById.values()], + }; +} diff --git a/src/status/status-message.ts b/src/status/status-message.ts index 3b6e349a109f..cb729299b87c 100644 --- a/src/status/status-message.ts +++ b/src/status/status-message.ts @@ -593,11 +593,17 @@ export function buildStatusMessage(args: StatusArgs): string { }); const selectedProvider = entry?.providerOverride ?? resolved.provider ?? DEFAULT_PROVIDER; const selectedModel = entry?.modelOverride ?? resolved.model ?? DEFAULT_MODEL; + const parseSelectedProvider = Boolean( + entry?.modelOverride?.trim() && !entry?.providerOverride?.trim(), + ); const modelRefs = resolveSelectedAndActiveModel({ selectedProvider, selectedModel, sessionEntry: entry, + parseSelectedProvider, }); + const selectedLookupProvider = modelRefs.selected.provider || selectedProvider; + const selectedLookupModel = modelRefs.selected.model || selectedModel; const initialFallbackState = resolveActiveFallbackState({ selectedModelRef: modelRefs.selected.label || "unknown", activeModelRef: modelRefs.active.label || "unknown", @@ -718,8 +724,8 @@ export function buildStatusMessage(args: StatusArgs): string { const runtimeDiffersFromSelected = activeModelLabel !== (modelRefs.selected.label || "unknown"); const selectedContextTokens = resolveContextTokensForModel({ cfg: contextConfig, - provider: selectedProvider, - model: selectedModel, + provider: selectedLookupProvider, + model: selectedLookupModel, allowAsyncLoad: false, }); const explicitRuntimeContextTokens = @@ -740,8 +746,8 @@ export function buildStatusMessage(args: StatusArgs): string { const channelModelNote = resolveChannelModelNote({ config: args.config, entry, - selectedProvider, - selectedModel, + selectedProvider: selectedLookupProvider, + selectedModel: selectedLookupModel, parentSessionKey: args.parentSessionKey, }); const persistedContextTokens = @@ -1007,7 +1013,7 @@ export function buildStatusMessage(args: StatusArgs): string { { config: args.config }, ); const selectedAuthMode = - normalizeAuthMode(args.modelAuth) ?? resolveModelAuthMode(selectedProvider, args.config); + normalizeAuthMode(args.modelAuth) ?? resolveModelAuthMode(selectedLookupProvider, args.config); const rawSelectedAuthLabelValue = selectedAuthMode && selectedAuthMode !== "unknown" ? (args.modelAuth ?? selectedAuthMode) diff --git a/src/status/status-text.ts b/src/status/status-text.ts index 10a8498849b0..9ef131eb8ae7 100644 --- a/src/status/status-text.ts +++ b/src/status/status-text.ts @@ -13,7 +13,6 @@ import { ensureAuthProfileStore } from "../agents/auth-profiles/store.js"; import { resolveContextTokensForModel } from "../agents/context.js"; import { resolveFastModeState } from "../agents/fast-mode.js"; import { resolveModelAuthLabel } from "../agents/model-auth-label.js"; -import { CODEX_APP_SERVER_AUTH_MARKER } from "../agents/model-auth-markers.js"; import { areRuntimeModelRefsEquivalent, shouldPreferActiveRuntimeAliasAuthLabel, @@ -49,6 +48,11 @@ import { formatTaskStatusDetail, formatTaskStatusTitle, } from "../tasks/task-status.js"; +import { resolveActiveFallbackState } from "./fallback-notice-state.js"; +import { + buildCodexSyntheticUsageAuth, + shouldUseCodexSyntheticUsageForRuntime, +} from "./codex-synthetic-usage.js"; import { formatCompactPluginHealthLine } from "./status-plugin-health.js"; import type { BuildStatusTextParams } from "./status-text.types.js"; @@ -225,15 +229,6 @@ function resolveCodexSyntheticUsageAuthProfileId(params: { } } -function shouldUseCodexSyntheticUsage(params: { - provider?: string; - effectiveHarness?: string; -}): boolean { - const harness = normalizeOptionalLowercaseString(params.effectiveHarness); - const provider = normalizeOptionalLowercaseString(params.provider); - return harness === "codex" && (provider === "openai" || provider === "codex"); -} - function formatSessionTaskLine(sessionKey: string): string | undefined { const snapshot = buildTaskStatusSnapshot(listTasksForSessionKeyForStatus(sessionKey)); const task = snapshot.focus; @@ -352,27 +347,35 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise((_, reject) => { @@ -590,24 +601,21 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise 0 && - (runtimeSnapshotHasFallbackProvenance || + (activeRuntimeIsAuthoritative || contextTokens === configuredContextTokens || contextTokens === selectedContextTokens) ? contextTokens : undefined; - const statusRuntimeContextTokens = runtimeSnapshotHasFallbackProvenance - ? runtimeContextTokens + const statusRuntimeContextTokens = activeRuntimeIsAuthoritative + ? (runtimeContextTokens ?? + (fallbackState.active && typeof contextTokens === "number" && contextTokens > 0 + ? contextTokens + : undefined)) : undefined; return buildStatusMessage({ config: cfg, diff --git a/src/test-utils/env.test.ts b/src/test-utils/env.test.ts index 2b7fdfa1d77a..d0f7c5a067bf 100644 --- a/src/test-utils/env.test.ts +++ b/src/test-utils/env.test.ts @@ -5,6 +5,8 @@ import { captureEnv, captureFullEnv, createPathResolutionEnv, + deleteTestEnvValue, + setTestEnvValue, withEnv, withEnvAsync, withPathResolutionEnv, @@ -12,9 +14,9 @@ import { function restoreEnvKey(key: string, previous: string | undefined): void { if (previous === undefined) { - delete process.env[key]; + deleteTestEnvValue(key); } else { - process.env[key] = previous; + setTestEnvValue(key, previous); } } @@ -25,8 +27,8 @@ describe("env test utils", () => { const snapshot = captureEnv([keyA, keyB]); const prevA = process.env[keyA]; const prevB = process.env[keyB]; - process.env[keyA] = "mutated"; - delete process.env[keyB]; + setTestEnvValue(keyA, "mutated"); + deleteTestEnvValue(keyB); snapshot.restore(); @@ -38,8 +40,8 @@ describe("env test utils", () => { const key = "OPENCLAW_ENV_TEST_ADDED"; const prevHome = process.env.HOME; const snapshot = captureFullEnv(); - process.env[key] = "1"; - delete process.env.HOME; + setTestEnvValue(key, "1"); + deleteTestEnvValue("HOME"); snapshot.restore(); @@ -74,7 +76,7 @@ describe("env test utils", () => { it("withEnv can delete a key only inside callback", () => { const key = "OPENCLAW_ENV_TEST_SYNC_DELETE"; const prev = process.env[key]; - process.env[key] = "outer"; + setTestEnvValue(key, "outer"); const seen = withEnv({ [key]: undefined }, () => process.env[key]); @@ -110,7 +112,7 @@ describe("env test utils", () => { it("withEnvAsync can delete a key only inside callback", async () => { const key = "OPENCLAW_ENV_TEST_ASYNC_DELETE"; const prev = process.env[key]; - process.env[key] = "outer"; + setTestEnvValue(key, "outer"); const seen = await withEnvAsync({ [key]: undefined }, async () => process.env[key]); @@ -125,9 +127,9 @@ describe("env test utils", () => { const previousOpenClawHome = process.env.OPENCLAW_HOME; const previousStateDir = process.env.OPENCLAW_STATE_DIR; const previousBundledDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; - process.env.OPENCLAW_HOME = "/srv/openclaw-home"; - process.env.OPENCLAW_STATE_DIR = "/srv/openclaw-state"; - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/srv/openclaw-bundled"; + setTestEnvValue("OPENCLAW_HOME", "/srv/openclaw-home"); + setTestEnvValue("OPENCLAW_STATE_DIR", "/srv/openclaw-state"); + setTestEnvValue("OPENCLAW_BUNDLED_PLUGINS_DIR", "/srv/openclaw-bundled"); try { const env = createPathResolutionEnv(homeDir, { @@ -149,7 +151,7 @@ describe("env test utils", () => { const homeDir = path.join(path.sep, "tmp", "openclaw-home"); const resolvedHomeDir = path.resolve(homeDir); const previousOpenClawHome = process.env.OPENCLAW_HOME; - process.env.OPENCLAW_HOME = "/srv/openclaw-home"; + setTestEnvValue("OPENCLAW_HOME", "/srv/openclaw-home"); try { const seen = withPathResolutionEnv( diff --git a/src/test-utils/temp-home.ts b/src/test-utils/temp-home.ts index 72ea16bf1963..407c94fea5fd 100644 --- a/src/test-utils/temp-home.ts +++ b/src/test-utils/temp-home.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { captureEnv } from "./env.js"; +import { captureEnv, setTestEnvValue } from "./env.js"; import { cleanupSessionStateForTest } from "./session-state-cleanup.js"; const HOME_ENV_KEYS = [ @@ -52,15 +52,15 @@ export async function createTempHomeEnv(prefix: string): Promise { await fs.mkdir(path.join(home, ".openclaw"), { recursive: true }); const snapshot = captureEnv([...HOME_ENV_KEYS]); - process.env.HOME = home; - process.env.USERPROFILE = home; - process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw"); + setTestEnvValue("HOME", home); + setTestEnvValue("USERPROFILE", home); + setTestEnvValue("OPENCLAW_STATE_DIR", path.join(home, ".openclaw")); if (process.platform === "win32") { const match = home.match(/^([A-Za-z]:)(.*)$/); if (match) { - process.env.HOMEDRIVE = match[1]; - process.env.HOMEPATH = match[2] || "\\"; + setTestEnvValue("HOMEDRIVE", match[1]); + setTestEnvValue("HOMEPATH", match[2] || "\\"); } } diff --git a/src/trajectory/export.test.ts b/src/trajectory/export.test.ts index 5fd5de9c8370..2e854eae06f1 100644 --- a/src/trajectory/export.test.ts +++ b/src/trajectory/export.test.ts @@ -1467,4 +1467,39 @@ describe("exportTrajectoryBundle", () => { expect(tools).toContain("$WORKSPACE_DIR/docs"); expect(`${prompts}\n${artifacts}\n${systemPrompt}\n${tools}`).not.toContain(tmpDir); }); + + it("exports the transcript for a legacy v1 session without entry timestamps", async () => { + const tmpDir = makeTempDir(); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const outputDir = path.join(tmpDir, "bundle"); + const header = { + type: "session", + version: 1, + id: "session-1", + cwd: tmpDir, + }; + const userEntry = { + type: "message", + message: userMessage("hello"), + }; + const assistantEntry = { + type: "message", + message: assistantMessage([{ type: "text", text: "done" }]), + }; + fs.writeFileSync( + sessionFile, + `${[header, userEntry, assistantEntry].map((entry) => JSON.stringify(entry)).join("\n")}\n`, + "utf8", + ); + + const bundle = await exportTrajectoryBundle({ + outputDir, + sessionFile, + sessionId: "session-1", + workspaceDir: tmpDir, + }); + + expect(bundle.manifest.transcriptEventCount).toBe(2); + expect(eventTypes(bundle.events)).toEqual(["user.message", "assistant.message"]); + }); }); diff --git a/src/trajectory/export.ts b/src/trajectory/export.ts index 8d7adb323ba4..c51027dd3147 100644 --- a/src/trajectory/export.ts +++ b/src/trajectory/export.ts @@ -185,9 +185,7 @@ async function readSessionBranch(filePath: string): Promise<{ (entry): entry is SessionEntry => entry.type !== "session" && isCanonicalSessionTranscriptEntry(entry) && - typeof (entry as { id?: unknown }).id === "string" && - (typeof (entry as { timestamp?: unknown }).timestamp === "string" || - typeof (entry as { timestamp?: unknown }).timestamp === "number"), + typeof (entry as { id?: unknown }).id === "string", ); const tree = scanSessionTranscriptTree(fileEntries); if (!tree.hasLeafUpdate) { diff --git a/src/tui/commands.ts b/src/tui/commands.ts index 084dbbf41493..9dc96c4f7257 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -12,7 +12,7 @@ const FAST_LEVELS = ["status", "auto", "on", "off"]; const REASONING_LEVELS = ["on", "off"]; const ELEVATED_LEVELS = ["on", "off", "ask", "full"]; const ACTIVATION_LEVELS = ["mention", "always"]; -const USAGE_FOOTER_LEVELS = ["off", "tokens", "full"]; +const USAGE_FOOTER_LEVELS = ["off", "tokens", "full", "reset", "inherit", "clear", "default"]; export type ParsedCommand = { name: string; @@ -196,7 +196,7 @@ export function helpText(options: SlashCommandOptions = {}): string { "/verbose ", "/trace ", "/reasoning ", - "/usage ", + "/usage ", "/elevated ", "/elev ", "/activation ", diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index 9e7a1d38ba7a..5d346a5ae025 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -1442,4 +1442,59 @@ describe("tui command handlers", () => { expect(openOverlay).toHaveBeenCalledTimes(1); }); + + it("/usage reset clears the stale local responseUsage after the gateway patch", async () => { + // Regression: after /usage reset sends responseUsage: null and the gateway deletes + // the field, applySessionInfoFromPatch skips absent fields. The command handler must + // explicitly clear the stale local value so no-arg cycles and subsequent refreshes + // start from the correct effective mode. + const patchSession = vi.fn().mockResolvedValue({ + entry: { + // Gateway returns the updated entry without the responseUsage field (it was deleted). + sessionId: "sess-reset", + updatedAt: Date.now(), + }, + }); + const { handleCommand, addSystem, state } = createHarness({ patchSession }); + const sessionInfo = state.sessionInfo as { + responseUsage?: string; + effectiveResponseUsage?: string; + }; + sessionInfo.responseUsage = "tokens"; + sessionInfo.effectiveResponseUsage = "tokens"; + + await handleCommand("/usage reset"); + + expect(patchSession).toHaveBeenCalledWith( + expect.objectContaining({ responseUsage: null }), + ); + expect(addSystem).toHaveBeenCalledWith("usage footer: reset to default"); + // Both stale local values must be cleared so the toggle/display is not stale + // until refreshSessionInfo() repopulates the inherited default. + expect(sessionInfo.responseUsage).toBeUndefined(); + expect(sessionInfo.effectiveResponseUsage).toBeUndefined(); + }); + + it("/usage no-arg toggle cycles from effectiveResponseUsage when the session override is unset", async () => { + // Regression: when the session has no explicit responseUsage but the config default + // is "tokens", the toggle should cycle tokens→full, not off→tokens. + const patchSession = vi.fn().mockResolvedValue({ + entry: { sessionId: "sess-toggle", updatedAt: Date.now(), responseUsage: "full" }, + }); + const { handleCommand, addSystem, state } = createHarness({ patchSession }); + // No raw responseUsage on session, but effective (from config default) is "tokens". + const sessionInfo = state.sessionInfo as { + responseUsage?: string; + effectiveResponseUsage?: string; + }; + sessionInfo.responseUsage = undefined; + sessionInfo.effectiveResponseUsage = "tokens"; + + await handleCommand("/usage"); + + expect(patchSession).toHaveBeenCalledWith( + expect.objectContaining({ responseUsage: "full" }), + ); + expect(addSystem).toHaveBeenCalledWith("usage footer: full"); + }); }); diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 3245171957d8..9f5e16fabceb 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -11,6 +11,7 @@ import { } from "../auto-reply/reply/commands-goal.js"; import { formatThinkingLevels, + isSessionDefaultDirectiveValue, normalizeUsageDisplay, resolveResponseUsageMode, } from "../auto-reply/thinking.js"; @@ -604,19 +605,37 @@ export function createCommandHandlers(context: CommandHandlerContext) { } break; case "usage": { - const normalized = args ? normalizeUsageDisplay(args) : undefined; - if (args && !normalized) { - chatLog.addSystem("usage: /usage "); + const isReset = args ? isSessionDefaultDirectiveValue(args) : false; + const normalized = args && !isReset ? normalizeUsageDisplay(args) : undefined; + if (args && !normalized && !isReset) { + chatLog.addSystem("usage: /usage "); break; } - const currentRaw = state.sessionInfo.responseUsage; - const current = resolveResponseUsageMode(currentRaw); + if (isReset) { + try { + const result = await client.patchSession({ + ...currentSessionPatchTarget(), + responseUsage: null, + }); + chatLog.addSystem("usage footer: reset to default"); + applySessionInfoFromPatch(result); + delete state.sessionInfo.responseUsage; + delete state.sessionInfo.effectiveResponseUsage; + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`usage failed: ${String(err)}`); + } + break; + } + const current = + state.sessionInfo.effectiveResponseUsage ?? + resolveResponseUsageMode(state.sessionInfo.responseUsage); const next = normalized ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off"); try { const result = await client.patchSession({ ...currentSessionPatchTarget(), - responseUsage: next === "off" ? null : next, + responseUsage: next, }); chatLog.addSystem(`usage footer: ${next}`); applySessionInfoFromPatch(result); diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index fec710eff889..9d849de7fdd5 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -89,6 +89,7 @@ function sessionInfoUiEquals(left: SessionInfo, right: SessionInfo): boolean { left.outputTokens === right.outputTokens && left.totalTokens === right.totalTokens && left.responseUsage === right.responseUsage && + left.effectiveResponseUsage === right.effectiveResponseUsage && left.displayName === right.displayName && goalEquals(left.goal, right.goal) ); @@ -245,6 +246,9 @@ export function createSessionActions(context: SessionActionContext) { if (entry?.responseUsage !== undefined) { next.responseUsage = entry.responseUsage; } + if (entry?.effectiveResponseUsage !== undefined) { + next.effectiveResponseUsage = entry.effectiveResponseUsage; + } if (entry?.inputTokens !== undefined) { next.inputTokens = entry.inputTokens; } diff --git a/src/tui/tui-types.ts b/src/tui/tui-types.ts index 2f13cb6ae631..9b81c2fd522c 100644 --- a/src/tui/tui-types.ts +++ b/src/tui/tui-types.ts @@ -92,6 +92,8 @@ export type SessionInfo = { totalTokensFresh?: boolean; goal?: SessionGoal; responseUsage?: ResponseUsageMode; + /** Resolved effective usage mode (session override → channel config → default → off). Set by the gateway; the TUI uses this for no-arg toggle cycles so the cycle starts from the effective visible mode rather than the raw session value. */ + effectiveResponseUsage?: ResponseUsageMode; updatedAt?: number | null; displayName?: string; }; diff --git a/src/utils/cjk-chars.test.ts b/src/utils/cjk-chars.test.ts index 04bb77cc36ee..c041f5718539 100644 --- a/src/utils/cjk-chars.test.ts +++ b/src/utils/cjk-chars.test.ts @@ -46,6 +46,13 @@ describe("estimateStringChars", () => { expect(estimateStringChars("안녕하세요")).toBe(20); }); + it("handles East Asian fullwidth letters, numbers, and punctuation", () => { + expect(estimateStringChars("ABC123")).toBe(6 * CHARS_PER_TOKEN_ESTIMATE); + expect(estimateStringChars("hello,world")).toBe( + "helloworld".length + CHARS_PER_TOKEN_ESTIMATE, + ); + }); + it("handles CJK punctuation and symbols in the extended range", () => { // "⺀" (U+2E80) is in CJK Radicals Supplement range expect(estimateStringChars("⺀")).toBe(CHARS_PER_TOKEN_ESTIMATE); diff --git a/src/utils/cjk-chars.ts b/src/utils/cjk-chars.ts index 54efd58fae4b..6a4671b7c3f9 100644 --- a/src/utils/cjk-chars.ts +++ b/src/utils/cjk-chars.ts @@ -20,9 +20,10 @@ export const CHARS_PER_TOKEN_ESTIMATE = 4; /** * Matches CJK Unified Ideographs, CJK Extension A/B, CJK Compatibility * Ideographs, Hangul Syllables, Hiragana, Katakana, and other non-Latin - * scripts that typically use ~1 token per character. + * scripts and East Asian fullwidth forms that typically use ~1 token per character. */ -const NON_LATIN_RE = /[\u2E80-\u9FFF\uA000-\uA4FF\uAC00-\uD7AF\uF900-\uFAFF\u{20000}-\u{2FA1F}]/gu; +const NON_LATIN_RE = + /[\u2E80-\u9FFF\uA000-\uA4FF\uAC00-\uD7AF\uF900-\uFAFF\uFF01-\uFF60\uFFE0-\uFFE6\u{20000}-\u{2FA1F}]/gu; /** * Return an adjusted character length that accounts for non-Latin (CJK, etc.) diff --git a/test/git-hooks-pre-commit.test.ts b/test/git-hooks-pre-commit.test.ts index 541a840fbc8a..229ae8013f8f 100644 --- a/test/git-hooks-pre-commit.test.ts +++ b/test/git-hooks-pre-commit.test.ts @@ -1,6 +1,6 @@ // Git hook tests validate pre-commit hook behavior and scripts. import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { cleanupTempDirs, makeTempRepoRoot } from "./helpers/temp-repo.js"; @@ -39,8 +39,8 @@ const runFailure = ( const failure = error as Error & { status?: number; stderr?: string; stdout?: string }; return { status: failure.status ?? 1, - stderr: String(failure.stderr ?? ""), - stdout: String(failure.stdout ?? ""), + stderr: failure.stderr ?? "", + stdout: failure.stdout ?? "", }; } throw error; @@ -83,6 +83,34 @@ function installPreCommitFixture(dir: string): string { return fakeBinDir; } +function installFormattingRecorder(dir: string): string { + const logPath = path.join(dir, "hook-tool.log"); + writeFileSync( + path.join(dir, "scripts", "pre-commit", "filter-staged-files.mjs"), + `const files = process.argv.slice(3).filter((arg) => arg !== "--"); +for (const file of files) { + if (file.endsWith(".ts")) { + process.stdout.write(file); + process.stdout.write("\0"); + } +} +`, + "utf8", + ); + writeFileSync( + path.join(dir, "scripts", "pre-commit", "run-node-tool.sh"), + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> ${JSON.stringify(logPath)} +`, + { + encoding: "utf8", + mode: 0o755, + }, + ); + return logPath; +} + function installRunNodeToolFixture(dir: string): void { mkdirSync(path.join(dir, "scripts", "pre-commit"), { recursive: true }); symlinkSync( @@ -101,6 +129,13 @@ function splitNonEmptyLines(output: string): string[] { return lines; } +function readFormatterLog(logPath: string): string[] { + if (!existsSync(logPath)) { + return []; + } + return splitNonEmptyLines(readFileSync(logPath, "utf8")); +} + afterEach(() => { cleanupTempDirs(tempDirs); }); @@ -128,6 +163,100 @@ describe("git-hooks/pre-commit (integration)", () => { expect(staged).toEqual(["--all"]); }); + it("skips formatting staged files while a merge commit is in progress", () => { + const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-merge-"); + run(dir, "git", ["init", "-q", "--initial-branch=main"]); + installPreCommitFixture(dir); + const logPath = installFormattingRecorder(dir); + + writeFileSync(path.join(dir, "changed.ts"), "export const value = 1;\n", "utf8"); + run(dir, "git", ["add", "--", "changed.ts"]); + run(dir, "git", [ + "-c", + "user.name=Test User", + "-c", + "user.email=test@example.invalid", + "commit", + "-q", + "-m", + "initial", + ]); + run(dir, "git", ["checkout", "-q", "-b", "side"]); + writeFileSync(path.join(dir, "changed.ts"), "export const value = 2;\n", "utf8"); + run(dir, "git", ["add", "--", "changed.ts"]); + run(dir, "git", [ + "-c", + "user.name=Test User", + "-c", + "user.email=test@example.invalid", + "commit", + "-q", + "-m", + "side change", + ]); + run(dir, "git", ["checkout", "-q", "main"]); + run(dir, "git", [ + "-c", + "user.name=Test User", + "-c", + "user.email=test@example.invalid", + "merge", + "--no-commit", + "--no-ff", + "side", + ]); + + expect(existsSync(path.join(dir, ".git", "MERGE_HEAD"))).toBe(true); + expect(run(dir, "git", ["diff", "--cached", "--name-only"])).toBe("changed.ts"); + + run(dir, "bash", ["git-hooks/pre-commit"]); + + expect(readFormatterLog(logPath)).toEqual([]); + }); + + it.each([ + ["cherry-pick", "CHERRY_PICK_HEAD", "file"], + ["revert", "REVERT_HEAD", "file"], + ["rebase head", "REBASE_HEAD", "file"], + ["merge rebase state", "rebase-merge", "dir"], + ["apply rebase state", "rebase-apply", "dir"], + ])("skips formatting staged files while %s metadata is present", (_label, gitPath, kind) => { + const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-sequencer-"); + run(dir, "git", ["init", "-q", "--initial-branch=main"]); + installPreCommitFixture(dir); + const logPath = installFormattingRecorder(dir); + + writeFileSync(path.join(dir, "changed.ts"), "export const value = 1;\n", "utf8"); + run(dir, "git", ["add", "--", "changed.ts"]); + + const metadataPath = path.join(dir, ".git", gitPath); + if (kind === "dir") { + mkdirSync(metadataPath, { recursive: true }); + } else { + writeFileSync(metadataPath, "sequencer state\n", "utf8"); + } + + run(dir, "bash", ["git-hooks/pre-commit"]); + + expect(readFormatterLog(logPath)).toEqual([]); + }); + + it("still formats staged files during a normal commit", () => { + const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-normal-"); + run(dir, "git", ["init", "-q", "--initial-branch=main"]); + installPreCommitFixture(dir); + const logPath = installFormattingRecorder(dir); + + writeFileSync(path.join(dir, "changed.ts"), "export const value = 1;\n", "utf8"); + run(dir, "git", ["add", "--", "changed.ts"]); + + run(dir, "bash", ["git-hooks/pre-commit"]); + + expect(readFormatterLog(logPath)).toEqual([ + "oxfmt --write --no-error-on-unmatched-pattern changed.ts", + ]); + }); + it("does not run the changed-scope check for non-doc staged changes", () => { const dir = makeTempRepoRoot(tempDirs, "openclaw-pre-commit-no-check-changed-"); run(dir, "git", ["init", "-q", "--initial-branch=main"]); diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index 1336644189f0..b0405970efd3 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -18,6 +18,8 @@ import { createChangedCheckPlan, createPnpmManagedCommand, createTargetedCoreLintCommand, + createTargetedExtensionLintCommand, + createTargetedScriptLintCommand, shouldDelegateChangedCheckToCrabbox, shouldRunAppcastOwnerTest, shouldRunCanvasA2uiNativeResourceCheck, @@ -255,6 +257,24 @@ describe("scripts/changed-lanes", () => { ]); }); + it("prints changed check dry-run commands", () => { + const result = spawnSync( + process.execPath, + ["scripts/check-changed.mjs", "--dry-run", "--", "extensions/lmstudio/src/api.ts"], + { + cwd: repoRoot, + encoding: "utf8", + env: createNestedGitEnv(), + }, + ); + + expect(result.status).toBe(0); + expect(result.stderr).toContain("[check:changed:dry-run] lanes=extensions, extensionTests"); + expect(result.stderr).toContain( + "[check:changed:dry-run] would run: node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.extensions.json extensions/lmstudio/src/api.ts", + ); + }); + it("includes untracked worktree files in the default local diff", () => { const dir = makeTempRepoRoot(tempDirs, "openclaw-changed-lanes-"); git(dir, ["init", "-q", "--initial-branch=main"]); @@ -579,6 +599,16 @@ describe("scripts/changed-lanes", () => { expect(command).toBeNull(); }); + it("falls back to full extension lint for broad extension diffs", () => { + const targets = Array.from( + { length: 9 }, + (_, index) => `extensions/discord/src/file-${index}.ts`, + ); + const command = createTargetedExtensionLintCommand(targets, { PATH: "/usr/bin" }); + + expect(command).toBeNull(); + }); + it("falls back to full core lint when a changed core target was deleted", () => { expect( createTargetedCoreLintCommand( @@ -631,6 +661,50 @@ describe("scripts/changed-lanes", () => { }); }); + it("targets small extension lint diffs", () => { + expect( + createTargetedExtensionLintCommand( + ["extensions/lmstudio/src/api.ts", "docs/help/testing.md"], + { PATH: "/usr/bin" }, + { fileExists: () => true }, + ), + ).toEqual({ + name: "lint extension changed file", + bin: "node", + args: [ + "scripts/run-oxlint.mjs", + "--tsconfig", + "config/tsconfig/oxlint.extensions.json", + "extensions/lmstudio/src/api.ts", + ], + env: { + PATH: "/usr/bin", + }, + }); + }); + + it("targets small script lint diffs", () => { + expect( + createTargetedScriptLintCommand( + ["scripts/check-changed.mjs", "test/scripts/changed-lanes.test.ts"], + { PATH: "/usr/bin" }, + { fileExists: () => true }, + ), + ).toEqual({ + name: "lint script changed file", + bin: "node", + args: [ + "scripts/run-oxlint.mjs", + "--tsconfig", + "config/tsconfig/oxlint.scripts.json", + "scripts/check-changed.mjs", + ], + env: { + PATH: "/usr/bin", + }, + }); + }); + it("reenables local-check policy for changed typecheck commands", () => { const result = detectChangedLanes(["packages/normalization-core/src/string-normalization.ts"]); const plan = createChangedCheckPlan(result, { @@ -796,9 +870,11 @@ describe("scripts/changed-lanes", () => { }); it("runs changed-check lint lanes under the parent heavy-check lock", () => { - const result = detectChangedLanes(["extensions/discord/src/index.ts"]); + const result = detectChangedLanes(["extensions/lmstudio/src/api.ts"]); const plan = createChangedCheckPlan(result, { env: { PATH: "/usr/bin" } }); - const lintCommand = plan.commands.find((command) => command.args[0] === "lint:extensions"); + const lintCommand = plan.commands.find( + (command) => command.name === "lint extension changed file", + ); expect(lintCommand?.env).toEqual({ OPENCLAW_OXLINT_SKIP_LOCK: "1", @@ -840,7 +916,7 @@ describe("scripts/changed-lanes", () => { }); it("routes extension production changes to extension prod and extension test lanes", () => { - const result = detectChangedLanes(["extensions/discord/src/index.ts"]); + const result = detectChangedLanes(["extensions/lmstudio/src/api.ts"]); expectLanes(result.lanes, { extensions: true, diff --git a/test/scripts/check-session-accessor-boundary.test.ts b/test/scripts/check-session-accessor-boundary.test.ts index 2eba8e16279c..c47e49ccda73 100644 --- a/test/scripts/check-session-accessor-boundary.test.ts +++ b/test/scripts/check-session-accessor-boundary.test.ts @@ -3,6 +3,7 @@ import { allowedSessionStoreRuntimeFileBackedCompatExports, collectSessionStoreRuntimeFileBackedCompatExports, findGatewaySessionCreateLifecycleViolations, + findEmbeddedAgentSessionTargetViolations, findMemoryHostSessionCorpusBoundaryViolations, findSessionAccessorBoundaryViolations, findSessionCompactManualTrimBoundaryViolations, @@ -11,6 +12,7 @@ import { findSessionStoreRuntimeFileBackedCompatExportViolations, findTranscriptWriterBoundaryViolations, migratedBundledPluginSessionAccessorFiles, + migratedEmbeddedAgentSessionTargetFiles, migratedMemoryHostSessionCorpusFiles, migratedSessionLifecycleCleanupFiles, migratedSessionCompactManualTrimFiles, @@ -57,6 +59,7 @@ describe("session accessor boundary guard", () => { "src/gateway/sessions-history-http.ts", "src/gateway/session-utils.ts", "src/gateway/managed-image-attachments.ts", + "src/gateway/boot.ts", "src/gateway/server-methods/artifacts.ts", "src/gateway/server-methods/chat.ts", "src/gateway/sessions-resolve.ts", @@ -66,6 +69,7 @@ describe("session accessor boundary guard", () => { "src/gateway/session-reset-service.ts", "src/infra/outbound/message-action-tts.ts", "src/agents/tools/embedded-gateway-stub.ts", + "src/agents/tools/session-status-tool.ts", "src/agents/tools/sessions-list-tool.ts", "src/plugins/host-hook-state.ts", "src/status/status-message.ts", @@ -77,14 +81,34 @@ describe("session accessor boundary guard", () => { it("ratchets only the bundled plugin files migrated by this slice", () => { expect(migratedBundledPluginSessionAccessorFiles).toEqual( new Set([ + "extensions/codex/src/conversation-binding.ts", + "extensions/discord/src/monitor/native-command-model-picker-ui.ts", "extensions/discord/src/monitor/native-command-model-picker-apply.ts", "extensions/discord/src/monitor/thread-session-close.ts", + "extensions/feishu/src/reasoning-preview.ts", + "extensions/memory-core/src/dreaming-phases.ts", "extensions/memory-core/src/dreaming-narrative.ts", + "extensions/mattermost/src/mattermost/model-picker.ts", + "extensions/matrix/src/matrix/monitor/handler.ts", + "extensions/matrix/src/session-route.ts", + "extensions/slack/src/monitor/slash.ts", + "extensions/telegram/src/bot-core.ts", "extensions/telegram/src/bot-handlers.runtime.ts", + "extensions/telegram/src/bot.ts", + "extensions/telegram/src/bot-message-dispatch.ts", + "extensions/telegram/src/bot-native-commands.ts", + "extensions/voice-call/src/response-generator.ts", + "extensions/whatsapp/src/auto-reply/monitor/group-activation.ts", ]), ); }); + it("ratchets only files migrated to embedded-agent session targets", () => { + expect(migratedEmbeddedAgentSessionTargetFiles).toEqual( + new Set(["extensions/voice-call/src/response-generator.ts"]), + ); + }); + it("ratchets only files migrated to session accessor writes", () => { expect(migratedSessionAccessorWriteFiles).toEqual( new Set([ @@ -99,6 +123,7 @@ describe("session accessor boundary guard", () => { "src/auto-reply/reply/abort.ts", "src/agents/subagent-control.ts", "src/agents/subagent-registry-helpers.ts", + "src/agents/tools/session-status-tool.ts", "src/auto-reply/reply/abort-cutoff.runtime.ts", "src/auto-reply/reply/agent-runner-cli-dispatch.ts", "src/auto-reply/reply/agent-runner-execution.ts", @@ -121,7 +146,9 @@ describe("session accessor boundary guard", () => { "src/auto-reply/reply/session-usage.ts", "src/commands/tasks.ts", "src/config/sessions/cleanup-service.ts", + "src/gateway/boot.ts", "src/gateway/server-node-events.ts", + "src/gateway/session-compaction-checkpoints.ts", "src/plugins/host-hook-cleanup.ts", "src/plugins/host-hook-state.ts", "src/tui/embedded-backend.ts", @@ -502,4 +529,48 @@ describe("session accessor boundary guard", () => { `), ).toEqual([]); }); + + it("flags embedded-agent calls that pass deprecated sessionFile identity", () => { + expect( + findEmbeddedAgentSessionTargetViolations(` + const sessionFile = agentRuntime.session.resolveSessionFilePath(sessionId, entry); + agentRuntime.runEmbeddedAgent({ + sessionId, + sessionKey, + sessionFile, + }); + runEmbeddedAgent({ + sessionId, + sessionFile: transcriptPath, + }); + `), + ).toEqual([ + { + line: 2, + reason: 'references legacy embedded-agent session file resolver "resolveSessionFilePath"', + }, + { + line: 6, + reason: + 'passes deprecated embedded-agent runtime identity field "sessionFile"; use sessionTarget', + }, + { + line: 10, + reason: + 'passes deprecated embedded-agent runtime identity field "sessionFile"; use sessionTarget', + }, + ]); + }); + + it("allows embedded-agent calls that pass sessionTarget identity", () => { + expect( + findEmbeddedAgentSessionTargetViolations(` + agentRuntime.runEmbeddedAgent({ + sessionId, + sessionKey, + sessionTarget: { agentId, sessionId, sessionKey, storePath }, + }); + `), + ).toEqual([]); + }); }); diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 0d53bb9790cb..eaa09d2e872e 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -573,7 +573,11 @@ describe("ci workflow guards", () => { expect(runStep.run).toContain("childEnv[key] = value"); }); - it("uploads a CI timing summary after the run lanes finish", () => { + it("keeps the CI timing summary parked for timing optimization work", () => { + expect(readFileSync(".github/workflows/ci.yml", "utf8")).toContain( + "Re-enable this job when we want to collect CI timing data for timing optimization.", + ); + const workflow = readCiWorkflow(); const timingJob = workflow.jobs["ci-timings-summary"]; @@ -598,6 +602,7 @@ describe("ci workflow guards", () => { "ios-build", "android", ]); + expect(timingJob.if).toContain("false"); expect(timingJob.if).toContain("always()"); expect(timingJob.if).toContain("!cancelled()"); @@ -664,6 +669,7 @@ describe("ci workflow guards", () => { expect(qaEvidenceWorkflow.on.workflow_dispatch.inputs).not.toHaveProperty("fail_on_qa_failure"); expect(qaEvidenceWorkflow.on.workflow_call.inputs).not.toHaveProperty("fail_on_qa_failure"); expect(qaEvidenceWorkflow.on.workflow_dispatch.inputs.qa_profile).not.toHaveProperty("options"); + expect(qaEvidenceWorkflow.on.workflow_dispatch.inputs.qa_profile.default).toBe("all"); expect(qaEvidenceWorkflow.on.workflow_call.inputs.qa_profile.type).toBe("string"); const validateProfileStep = qaRunJob.steps.find( (step) => step.name === "Validate QA profile input", @@ -682,7 +688,7 @@ describe("ci workflow guards", () => { // Keep the caller's ref while the callee verifies it against expected_sha. ref: "${{ inputs.ref }}", expected_sha: "${{ needs.validate_selected_ref.outputs.selected_revision }}", - qa_profile: "release", + qa_profile: "all", }); expect(generateJob.with).not.toHaveProperty("fail_on_qa_failure"); @@ -713,6 +719,8 @@ describe("ci workflow guards", () => { (step) => step.name === "Validate QA evidence manifest", ); expect(validateManifestStep.run).toContain("qa-profile-evidence-manifest.json"); + expect(validateManifestStep.run).toContain("qa-evidence.json profile must be all"); + expect(validateManifestStep.run).toContain("QA evidence manifest profile must be all"); expect(validateManifestStep.run).toContain("manifest.targetSha !== targetSha"); expect(qaRunJob.outputs.artifact_name).toBe("${{ steps.evidence.outputs.artifact_name }}"); @@ -795,13 +803,55 @@ describe("ci workflow guards", () => { it("keeps workflow guards in fast CI-routing checks", () => { const workflow = readCiWorkflow(); + const preflightStep = workflow.jobs.preflight.steps.find( + (step) => step.name === "Build CI manifest", + ); + const taxonomy = parse(readFileSync("taxonomy.yaml", "utf8")) as { + profiles: Array<{ id: string; categoryIds: string[] }>; + }; + const smokeProfile = taxonomy.profiles.find((profile) => profile.id === "smoke-ci"); + if (!smokeProfile) { + throw new Error("taxonomy.yaml is missing the smoke-ci profile"); + } const fastCoreJob = workflow.jobs["checks-fast-core"]; const runStep = fastCoreJob.steps.find( (step) => step.name === "Run ${{ matrix.task }} (${{ matrix.runtime }})", ); + const uploadStep = fastCoreJob.steps.find( + (step) => step.name === "Upload QA smoke profile evidence", + ); + const ciWorkflowText = readFileSync(".github/workflows/ci.yml", "utf8"); + + expect(preflightStep.run).not.toContain("qa-smoke-profile"); + expect(preflightStep.run).not.toContain("qa_category"); + expect(smokeProfile.categoryIds).toHaveLength(30); + for (const categoryId of smokeProfile.categoryIds) { + expect(ciWorkflowText).not.toContain(`"${categoryId}"`); + } + expect(runStep.run).toContain("bundled-protocol)"); + expect(runStep.run).toContain("qa-smoke-ci)"); expect(runStep.run).toContain("contracts-plugins-ci-routing)"); expect(runStep.run).toContain("ci-routing)"); + expect(ciWorkflowText).toContain( + '{ check_name: "QA Smoke CI", runtime: "node", task: "qa-smoke-ci" }', + ); + expect(runStep.run).toContain("--qa-profile smoke-ci"); + expect(runStep.run).toContain("--concurrency 8"); + expect(runStep.run).not.toContain("--category"); + expect(runStep.run).not.toContain("--allow-failures"); + expect(runStep.run).toContain("qa_exit_code=0"); + expect(runStep.run).toContain('exit "$qa_exit_code"'); + expect(runStep.run).toContain("scripts/build-all.mjs qaRuntime"); + expect(runStep.run).not.toContain("OPENAI_API_KEY"); + expect(runStep.run).toMatch( + /bundled-protocol\)\s+pnpm test:bundled\s+pnpm protocol:check\s+;;\s+qa-smoke-ci\)/, + ); + expect(uploadStep.if).toBe("always() && matrix.task == 'qa-smoke-ci'"); + expect(uploadStep.with).toMatchObject({ + path: ".artifacts/qa-e2e/smoke-ci-profile/", + "if-no-files-found": "warn", + }); expect(runStep.run.match(/test\/scripts\/ci-workflow-guards\.test\.ts/g)?.length).toBe(2); }); diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index 5c9986df50ad..5c757607fe63 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -56,14 +56,12 @@ const QR_IMPORT_DOCKER_E2E_PATH = "scripts/e2e/qr-import-docker.sh"; const MULTI_NODE_UPDATE_DOCKER_E2E_PATH = "scripts/e2e/multi-node-update-docker.sh"; const BUNDLED_PLUGIN_INSTALL_UNINSTALL_E2E_PATH = "scripts/e2e/bundled-plugin-install-uninstall-docker.sh"; -const AGENT_BUNDLE_MCP_TOOLS_DOCKER_E2E_PATH = - "scripts/e2e/agent-bundle-mcp-tools-docker.sh"; +const AGENT_BUNDLE_MCP_TOOLS_DOCKER_E2E_PATH = "scripts/e2e/agent-bundle-mcp-tools-docker.sh"; const COMMITMENTS_SAFETY_DOCKER_E2E_PATH = "scripts/e2e/commitments-safety-docker.sh"; const CRESTODIAN_FIRST_RUN_DOCKER_E2E_PATH = "scripts/e2e/crestodian-first-run-docker.sh"; const CRESTODIAN_PLANNER_DOCKER_E2E_PATH = "scripts/e2e/crestodian-planner-docker.sh"; const CRESTODIAN_RESCUE_DOCKER_E2E_PATH = "scripts/e2e/crestodian-rescue-docker.sh"; -const SESSION_RUNTIME_CONTEXT_DOCKER_E2E_PATH = - "scripts/e2e/session-runtime-context-docker.sh"; +const SESSION_RUNTIME_CONTEXT_DOCKER_E2E_PATH = "scripts/e2e/session-runtime-context-docker.sh"; const BUNDLED_PLUGIN_INSTALL_UNINSTALL_SWEEP_PATH = "scripts/e2e/lib/bundled-plugin-install-uninstall/sweep.sh"; const BUNDLED_PLUGIN_INSTALL_UNINSTALL_PROBE_PATH = @@ -2804,6 +2802,14 @@ grep -Fxq preserved "$TMPDIR/caller-fd" } }); + it("gives Codex on-demand package installs enough time to reach Codex assertions", () => { + const runner = readFileSync(CODEX_ON_DEMAND_DOCKER_E2E_PATH, "utf8"); + + expect(runner).toContain( + 'export OPENCLAW_E2E_NPM_INSTALL_TIMEOUT="${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-1200s}"', + ); + }); + it("cleans package-backed onboarding and plugin Docker artifacts on every exit path", () => { for (const path of [ CODEX_ON_DEMAND_DOCKER_E2E_PATH, @@ -4188,7 +4194,7 @@ output="$(cat "$sampler_log")" const client = readFileSync(OPENAI_WEB_SEARCH_MINIMAL_CLIENT_PATH, "utf8"); expect(runner).toContain( - "PORT=\"$(docker_e2e_read_tcp_port_env OPENCLAW_OPENAI_WEB_SEARCH_MINIMAL_PORT 18789)\"", + 'PORT="$(docker_e2e_read_tcp_port_env OPENCLAW_OPENAI_WEB_SEARCH_MINIMAL_PORT 18789)"', ); expect(runner).toContain('MOCK_PORT="80"'); expect(runner).not.toContain("OPENCLAW_OPENAI_WEB_SEARCH_MINIMAL_MOCK_PORT"); diff --git a/test/scripts/lint-suppressions.test.ts b/test/scripts/lint-suppressions.test.ts index ff8d4857950d..005e9c0bcc64 100644 --- a/test/scripts/lint-suppressions.test.ts +++ b/test/scripts/lint-suppressions.test.ts @@ -226,6 +226,7 @@ describe("production lint suppressions", () => { "src/test-utils/vitest-mock-fn.ts|typescript/no-explicit-any|1", "src/utils.ts|typescript/no-unnecessary-type-parameters|1", "src/version.ts|eslint/no-underscore-dangle|1", + "ui/public/sw.js|unicorn/require-post-message-target-origin|1", ]), ); }); diff --git a/test/scripts/plain-gh.test.ts b/test/scripts/plain-gh.test.ts index e7520342c108..7e918ea81994 100644 --- a/test/scripts/plain-gh.test.ts +++ b/test/scripts/plain-gh.test.ts @@ -4,7 +4,12 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { execPlainGh, plainGhEnv, resolvePlainGhBin } from "../../scripts/lib/plain-gh.mjs"; +import { + execPlainGh, + plainGhEnv, + PLAIN_GH_SYSTEM_CANDIDATES, + resolvePlainGhBin, +} from "../../scripts/lib/plain-gh.mjs"; const tempDirs: string[] = []; @@ -64,6 +69,16 @@ describe("plain gh helpers", () => { ).toBe(ghPath); }); + it("prefers package-manager gh paths over bin shims", () => { + const realGh = makeFakeGh(); + const shimGh = makeFakeGh(); + + expect(resolvePlainGhBin({ PATH: shimGh }, [realGh, shimGh])).toBe(realGh); + expect(PLAIN_GH_SYSTEM_CANDIDATES.indexOf("/opt/homebrew/opt/gh/bin/gh")).toBeLessThan( + PLAIN_GH_SYSTEM_CANDIDATES.indexOf("/opt/homebrew/bin/gh"), + ); + }); + it("normalizes color environment for JSON-safe gh output", () => { expect( plainGhEnv({ @@ -135,5 +150,8 @@ describe("plain gh helpers", () => { expect(helper).toContain("type -P gh"); expect(helper).not.toContain("command -v gh"); + expect(helper.indexOf("/opt/homebrew/opt/gh/bin/gh")).toBeLessThan( + helper.indexOf("/opt/homebrew/bin/gh"), + ); }); }); diff --git a/test/scripts/plugin-sdk-surface-report.test.ts b/test/scripts/plugin-sdk-surface-report.test.ts index 31b5c5778dca..04144b34d308 100644 --- a/test/scripts/plugin-sdk-surface-report.test.ts +++ b/test/scripts/plugin-sdk-surface-report.test.ts @@ -14,16 +14,54 @@ function runSurfaceReport(env: Record) { }); } -function readDefaultPublicFunctionExportBudget() { +type PublicSurfaceCounts = { + callableExports: number; + exports: number; + wildcardReexports: number; +}; + +function readDefaultPublicSurfaceBudgets(): PublicSurfaceCounts { const source = readFileSync("scripts/plugin-sdk-surface-report.mjs", "utf8"); - const match = - /publicFunctionExports:\s*readBudgetEnv\("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS",\s*(\d+)\)/u.exec( + const readFallback = (budgetKey: string) => { + const match = new RegExp(`${budgetKey}:\\s*readBudgetEnv\\(\\s*"[^"]+",\\s*(\\d+)`, "u").exec( source, ); - if (match === null || match[1] === undefined) { - throw new Error("failed to read default public function export budget"); + if (match === null || match[1] === undefined) { + throw new Error(`failed to read default ${budgetKey} budget`); + } + return Number(match[1]); + }; + return { + exports: readFallback("publicExports"), + callableExports: readFallback("publicFunctionExports"), + wildcardReexports: readFallback("publicWildcardReexports"), + }; +} + +function readCurrentPublicSurfaceCounts(): PublicSurfaceCounts { + const result = runSurfaceReport({}); + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + + const totalsMatch = + /public package SDK entrypoints:[\s\S]*?\n exports: (\d+)\n callable exports: (\d+)/u.exec( + result.stdout, + ); + const wildcardsMatch = /public wildcard reexports: (\d+)/u.exec(result.stdout); + if ( + totalsMatch === null || + totalsMatch[1] === undefined || + totalsMatch[2] === undefined || + wildcardsMatch === null || + wildcardsMatch[1] === undefined + ) { + throw new Error("failed to read current public surface counts"); } - return Number(match[1]); + return { + exports: Number(totalsMatch[1]), + callableExports: Number(totalsMatch[2]), + wildcardReexports: Number(wildcardsMatch[1]), + }; } describe("plugin SDK surface report", () => { @@ -94,8 +132,12 @@ describe("plugin SDK surface report", () => { expect(result.stderr).toBe(""); }); + it("keeps default public surface budgets pinned to current source counts", () => { + expect(readDefaultPublicSurfaceBudgets()).toEqual(readCurrentPublicSurfaceCounts()); + }); + it("keeps generated package declarations out of source surface counts", () => { - const budget = readDefaultPublicFunctionExportBudget(); + const budget = readDefaultPublicSurfaceBudgets().callableExports; const result = runSurfaceReport({ OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS: String(budget - 1), }); diff --git a/test/scripts/render-maturity-docs.test.ts b/test/scripts/render-maturity-docs.test.ts index c42a090fd9fb..2d949f0c218a 100644 --- a/test/scripts/render-maturity-docs.test.ts +++ b/test/scripts/render-maturity-docs.test.ts @@ -3,11 +3,32 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { parse as parseYaml } from "yaml"; import { createTempDirTracker } from "../helpers/temp-dir.js"; const repoRoot = path.resolve(__dirname, "../.."); const tempDirs = createTempDirTracker(); +type TaxonomyFixture = { + surfaces?: TaxonomySurfaceFixture[]; +}; + +type TaxonomySurfaceFixture = { + id?: string; + status?: string; + categories?: TaxonomyCategoryFixture[]; +}; + +type TaxonomyCategoryFixture = { + id?: string; + name?: string; + features?: TaxonomyFeatureFixture[]; +}; + +type TaxonomyFeatureFixture = { + coverageIds?: string[]; +}; + afterEach(() => { tempDirs.cleanup(); }); @@ -26,7 +47,33 @@ function runCli(...args: string[]) { function writeQaEvidence(params: { dir: string; entries: Array<{ id: string; status: "pass" | "fail" | "blocked" | "skipped" }>; + scorecard?: unknown; }) { + const scorecard = params.scorecard ?? { + filters: { surface: null, category: null }, + run: { evidenceEntryCount: params.entries.length }, + categories: { + total: 0, + fulfilled: 0, + partial: 0, + missing: 0, + fulfillmentPercent: 0, + }, + features: { + total: 0, + fulfilled: 0, + partial: 0, + missing: 0, + fulfillmentPercent: 0, + }, + coverageIds: { + total: 0, + fulfilled: 0, + missing: 0, + fulfillmentPercent: 0, + }, + categoryReports: [], + }; fs.mkdirSync(params.dir, { recursive: true }); fs.writeFileSync( path.join(params.dir, "qa-evidence.json"), @@ -47,24 +94,7 @@ function writeQaEvidence(params: { coverage: [{ id: "tools.evidence", role: "primary" }], result: { status: entry.status }, })), - scorecard: { - filters: { surface: null, category: null }, - run: { evidenceEntryCount: params.entries.length }, - categories: { - total: 0, - fulfilled: 0, - partial: 0, - missing: 0, - fulfillmentPercent: 0, - }, - features: { - total: 0, - fulfilled: 0, - missing: 0, - fulfillmentPercent: 0, - }, - categoryReports: [], - }, + scorecard, }, null, 2, @@ -73,6 +103,73 @@ function writeQaEvidence(params: { ); } +function allProfileScorecardFixture() { + const taxonomy = parseYaml( + fs.readFileSync(path.join(repoRoot, "taxonomy.yaml"), "utf8"), + ) as TaxonomyFixture; + const activeSurfaces = (taxonomy.surfaces ?? []).filter( + (surface) => surface.status !== "retired", + ); + const categoryReports = activeSurfaces.flatMap((surface) => + (surface.categories ?? []).map((category) => { + const coverageIds = [ + ...new Set((category.features ?? []).flatMap((feature) => feature.coverageIds ?? [])), + ].sort(); + return { + id: `${surface.id}.${category.id}`, + surfaceId: surface.id, + name: category.name, + status: "missing", + features: { + total: category.features.length, + fulfilled: 0, + partial: 0, + missing: category.features.length, + fulfillmentPercent: 0, + }, + coverageIds: { + total: coverageIds.length, + fulfilled: 0, + missing: coverageIds.length, + fulfillmentPercent: 0, + secondaryOnly: 0, + }, + missingCoverageIds: coverageIds, + }; + }), + ); + const featureCount = categoryReports.reduce((count, report) => count + report.features.total, 0); + const coverageIdCount = categoryReports.reduce( + (count, report) => count + report.coverageIds.total, + 0, + ); + return { + filters: { surface: null, category: null }, + run: { evidenceEntryCount: 1 }, + categories: { + total: categoryReports.length, + fulfilled: 0, + partial: 0, + missing: categoryReports.length, + fulfillmentPercent: 0, + }, + features: { + total: featureCount, + fulfilled: 0, + partial: 0, + missing: featureCount, + fulfillmentPercent: 0, + }, + coverageIds: { + total: coverageIdCount, + fulfilled: 0, + missing: coverageIdCount, + fulfillmentPercent: 0, + }, + categoryReports, + }; +} + describe("maturity docs renderer CLI", () => { it("checks maturity inputs without requiring QA evidence artifacts", () => { const result = runCli("--check"); @@ -134,4 +231,23 @@ describe("maturity docs renderer CLI", () => { expect(scorecard).not.toContain("0 failed"); expect(scorecard).not.toContain("0 blocked"); }); + + it("renders the maturity score from quality and completeness without coverage", () => { + const outputDir = tempDirs.make("openclaw-maturity-docs-output-"); + const evidenceDir = tempDirs.make("openclaw-maturity-docs-evidence-"); + writeQaEvidence({ + dir: evidenceDir, + entries: [{ id: "passing-scenario", status: "pass" }], + scorecard: allProfileScorecardFixture(), + }); + + const result = runCli("--output-dir", outputDir, "--evidence-dir", evidenceDir); + + expect(result.status).toBe(0); + const scorecard = fs.readFileSync(path.join(outputDir, "maturity", "scorecard.md"), "utf8"); + expect(scorecard).toContain("Maturity score"); + expect(scorecard).toContain('67%'); + expect(scorecard).toContain("Coverage Experimental - 0%"); + expect(scorecard).toContain("end-to-end coverage above 90%"); + }); }); diff --git a/test/scripts/sandbox-common-smoke-workflow.test.ts b/test/scripts/sandbox-common-smoke-workflow.test.ts index 2d76213978d7..4916bbb98bba 100644 --- a/test/scripts/sandbox-common-smoke-workflow.test.ts +++ b/test/scripts/sandbox-common-smoke-workflow.test.ts @@ -14,6 +14,9 @@ describe("sandbox common smoke workflow", () => { expect(workflow).toContain( "timeout --kill-after=30s 2m docker run --rm openclaw-sandbox-common-smoke:bookworm-slim", ); + expect(workflow).toContain("node --version"); + expect(workflow).toContain("pnpm --version"); + expect(workflow).not.toContain("INSTALL_PNPM=0"); expect(workflow).not.toMatch(/(^|\n)\s+docker build -t openclaw-sandbox-smoke-base/u); expect(workflow).not.toContain( 'u="$(docker run --rm openclaw-sandbox-common-smoke:bookworm-slim', diff --git a/test/test-env.test.ts b/test/test-env.test.ts index 6207311f11fe..1e7bb416d8c4 100644 --- a/test/test-env.test.ts +++ b/test/test-env.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { deleteTestEnvValue, setTestEnvValue } from "../src/test-utils/env.js"; import { cleanupTempDirs, makeTempDir } from "./helpers/temp-dir.js"; import { installTestEnv } from "./test-env.js"; @@ -14,14 +15,14 @@ const cleanupFns: Array<() => void> = []; function restoreProcessEnv(): void { for (const key of Object.keys(process.env)) { if (!(key in ORIGINAL_ENV)) { - delete process.env[key]; + deleteTestEnvValue(key); } } for (const [key, value] of Object.entries(ORIGINAL_ENV)) { if (value === undefined) { - delete process.env[key]; + deleteTestEnvValue(key); } else { - process.env[key] = value; + setTestEnvValue(key, value); } } } @@ -133,13 +134,13 @@ describe("installTestEnv", () => { "session\n", ); - process.env.HOME = realHome; - process.env.USERPROFILE = realHome; - process.env.OPENCLAW_LIVE_TEST = "1"; - process.env.OPENCLAW_LIVE_TEST_QUIET = "1"; - process.env.OPENCLAW_CONFIG_PATH = "~/custom-openclaw.json5"; - process.env.OPENCLAW_TEST_HOME = priorIsolatedHome; - process.env.OPENCLAW_STATE_DIR = path.join(priorIsolatedHome, ".openclaw"); + setTestEnvValue("HOME", realHome); + setTestEnvValue("USERPROFILE", realHome); + setTestEnvValue("OPENCLAW_LIVE_TEST", "1"); + setTestEnvValue("OPENCLAW_LIVE_TEST_QUIET", "1"); + setTestEnvValue("OPENCLAW_CONFIG_PATH", "~/custom-openclaw.json5"); + setTestEnvValue("OPENCLAW_TEST_HOME", priorIsolatedHome); + setTestEnvValue("OPENCLAW_STATE_DIR", path.join(priorIsolatedHome, ".openclaw")); const testEnv = installTestEnv(); cleanupFns.push(testEnv.cleanup); @@ -219,11 +220,11 @@ describe("installTestEnv", () => { const realHome = createTempHome(); writeFile(path.join(realHome, ".profile"), "export TEST_PROFILE_ONLY=from-profile\n"); - process.env.HOME = realHome; - process.env.USERPROFILE = realHome; - process.env.OPENCLAW_LIVE_TEST = "1"; - process.env.OPENCLAW_LIVE_USE_REAL_HOME = "1"; - process.env.OPENCLAW_LIVE_TEST_QUIET = "1"; + setTestEnvValue("HOME", realHome); + setTestEnvValue("USERPROFILE", realHome); + setTestEnvValue("OPENCLAW_LIVE_TEST", "1"); + setTestEnvValue("OPENCLAW_LIVE_USE_REAL_HOME", "1"); + setTestEnvValue("OPENCLAW_LIVE_TEST_QUIET", "1"); const testEnv = installTestEnv(); @@ -236,13 +237,13 @@ describe("installTestEnv", () => { const realHome = createTempHome(); writeFile(path.join(realHome, ".profile"), "export TEST_PROFILE_ONLY=from-profile\n"); - process.env.HOME = realHome; - process.env.USERPROFILE = realHome; - delete process.env.LIVE; - delete process.env.OPENCLAW_LIVE_TEST; - delete process.env.OPENCLAW_LIVE_GATEWAY; - delete process.env.OPENCLAW_LIVE_USE_REAL_HOME; - delete process.env.OPENCLAW_LIVE_TEST_QUIET; + setTestEnvValue("HOME", realHome); + setTestEnvValue("USERPROFILE", realHome); + deleteTestEnvValue("LIVE"); + deleteTestEnvValue("OPENCLAW_LIVE_TEST"); + deleteTestEnvValue("OPENCLAW_LIVE_GATEWAY"); + deleteTestEnvValue("OPENCLAW_LIVE_USE_REAL_HOME"); + deleteTestEnvValue("OPENCLAW_LIVE_TEST_QUIET"); const testEnv = installTestEnv(); cleanupFns.push(testEnv.cleanup); @@ -255,11 +256,11 @@ describe("installTestEnv", () => { const realHome = createTempHome(); writeFile(path.join(realHome, ".profile"), "export TEST_PROFILE_ONLY=from-profile\n"); - process.env.HOME = realHome; - process.env.USERPROFILE = realHome; - process.env.OPENCLAW_LIVE_TEST = "1"; - process.env.OPENCLAW_LIVE_USE_REAL_HOME = "1"; - process.env.OPENCLAW_LIVE_TEST_QUIET = "1"; + setTestEnvValue("HOME", realHome); + setTestEnvValue("USERPROFILE", realHome); + setTestEnvValue("OPENCLAW_LIVE_TEST", "1"); + setTestEnvValue("OPENCLAW_LIVE_USE_REAL_HOME", "1"); + setTestEnvValue("OPENCLAW_LIVE_TEST_QUIET", "1"); vi.doMock("node:child_process", () => ({ execFileSync: () => { diff --git a/test/test-env.ts b/test/test-env.ts index a38a09aae20b..3bbed6385674 100644 --- a/test/test-env.ts +++ b/test/test-env.ts @@ -5,6 +5,7 @@ import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import JSON5 from "json5"; +import { deleteTestEnvValue, setTestEnvValue } from "../src/test-utils/env.js"; type RestoreEntry = { key: string; value: string | undefined }; @@ -44,9 +45,9 @@ function isTruthyEnvValue(value: string | undefined): boolean { function restoreEnv(entries: RestoreEntry[]): void { for (const { key, value } of entries) { if (value === undefined) { - delete process.env[key]; + deleteTestEnvValue(key); } else { - process.env[key] = value; + setTestEnvValue(key, value); } } } @@ -90,7 +91,7 @@ function loadProfileEnv(homeDir = os.homedir()): void { if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key) || (process.env[key] ?? "") !== "") { return false; } - process.env[key] = entry.slice(idx + 1); + setTestEnvValue(key, entry.slice(idx + 1)); return true; }; const countAppliedEntries = (entries: Iterable) => { @@ -193,45 +194,45 @@ function createIsolatedTestHome(restore: RestoreEntry[]): { } { const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-test-home-")); - process.env.HOME = tempHome; - process.env.USERPROFILE = tempHome; - process.env.OPENCLAW_TEST_HOME = tempHome; - process.env.OPENCLAW_TEST_FAST = "1"; - process.env.OPENCLAW_STRICT_FAST_REPLY_CONFIG = "1"; - delete process.env.OPENCLAW_ALLOW_SLOW_REPLY_TESTS; + setTestEnvValue("HOME", tempHome); + setTestEnvValue("USERPROFILE", tempHome); + setTestEnvValue("OPENCLAW_TEST_HOME", tempHome); + setTestEnvValue("OPENCLAW_TEST_FAST", "1"); + setTestEnvValue("OPENCLAW_STRICT_FAST_REPLY_CONFIG", "1"); + deleteTestEnvValue("OPENCLAW_ALLOW_SLOW_REPLY_TESTS"); // Ensure test runs never touch the developer's real config/state, even if they have overrides set. - delete process.env.OPENCLAW_CONFIG_PATH; + deleteTestEnvValue("OPENCLAW_CONFIG_PATH"); // Prefer deriving state dir from HOME so nested tests that change HOME also isolate correctly. - delete process.env.OPENCLAW_STATE_DIR; - delete process.env.OPENCLAW_AGENT_DIR; + deleteTestEnvValue("OPENCLAW_STATE_DIR"); + deleteTestEnvValue("OPENCLAW_AGENT_DIR"); // Prefer test-controlled ports over developer overrides (avoid port collisions across tests/workers). - delete process.env.OPENCLAW_GATEWAY_PORT; - delete process.env.OPENCLAW_BRIDGE_ENABLED; - delete process.env.OPENCLAW_BRIDGE_HOST; - delete process.env.OPENCLAW_BRIDGE_PORT; - delete process.env.OPENCLAW_CANVAS_HOST_PORT; + deleteTestEnvValue("OPENCLAW_GATEWAY_PORT"); + deleteTestEnvValue("OPENCLAW_BRIDGE_ENABLED"); + deleteTestEnvValue("OPENCLAW_BRIDGE_HOST"); + deleteTestEnvValue("OPENCLAW_BRIDGE_PORT"); + deleteTestEnvValue("OPENCLAW_CANVAS_HOST_PORT"); // Avoid leaking real GitHub/Copilot tokens into non-live test runs. - delete process.env.TELEGRAM_BOT_TOKEN; - delete process.env.DISCORD_BOT_TOKEN; - delete process.env.SLACK_BOT_TOKEN; - delete process.env.SLACK_APP_TOKEN; - delete process.env.SLACK_USER_TOKEN; - delete process.env.COPILOT_GITHUB_TOKEN; - delete process.env.GH_TOKEN; - delete process.env.GITHUB_TOKEN; + deleteTestEnvValue("TELEGRAM_BOT_TOKEN"); + deleteTestEnvValue("DISCORD_BOT_TOKEN"); + deleteTestEnvValue("SLACK_BOT_TOKEN"); + deleteTestEnvValue("SLACK_APP_TOKEN"); + deleteTestEnvValue("SLACK_USER_TOKEN"); + deleteTestEnvValue("COPILOT_GITHUB_TOKEN"); + deleteTestEnvValue("GH_TOKEN"); + deleteTestEnvValue("GITHUB_TOKEN"); // Avoid leaking local dev tooling flags into tests (e.g. --inspect). - delete process.env.NODE_OPTIONS; + deleteTestEnvValue("NODE_OPTIONS"); // Windows: prefer the default state dir so auth/profile tests match real paths. if (process.platform === "win32") { - process.env.OPENCLAW_STATE_DIR = path.join(tempHome, ".openclaw"); + setTestEnvValue("OPENCLAW_STATE_DIR", path.join(tempHome, ".openclaw")); } - process.env.XDG_CONFIG_HOME = path.join(tempHome, ".config"); - process.env.XDG_DATA_HOME = path.join(tempHome, ".local", "share"); - process.env.XDG_STATE_HOME = path.join(tempHome, ".local", "state"); - process.env.XDG_CACHE_HOME = path.join(tempHome, ".cache"); + setTestEnvValue("XDG_CONFIG_HOME", path.join(tempHome, ".config")); + setTestEnvValue("XDG_DATA_HOME", path.join(tempHome, ".local", "share")); + setTestEnvValue("XDG_STATE_HOME", path.join(tempHome, ".local", "state")); + setTestEnvValue("XDG_CACHE_HOME", path.join(tempHome, ".cache")); const cleanup = () => { restoreEnv(restore); diff --git a/ui/public/sw.js b/ui/public/sw.js index 43127a96e882..bf1051245a76 100644 --- a/ui/public/sw.js +++ b/ui/public/sw.js @@ -22,22 +22,32 @@ self.addEventListener("install", (event) => { }); self.addEventListener("activate", (event) => { - // Keep a small prior-build window so open tabs can still load old hashed chunks after updates. event.waitUntil( - Promise.all([ - self.clients.claim(), - caches.keys().then((keys) => { - const controlKeys = keys.filter((key) => key.startsWith(CACHE_PREFIX)); - const priorCacheLimit = Math.max(0, CONTROL_CACHE_LIMIT - 1); - const retained = new Set([ - ...controlKeys.filter((key) => key !== CACHE_NAME).slice(-priorCacheLimit), - CACHE_NAME, - ]); - return Promise.all( + (async () => { + const [cacheKeys, windowClients] = await Promise.all([ + caches.keys(), + self.clients.matchAll({ type: "window", includeUncontrolled: true }), + ]); + const controlKeys = cacheKeys.filter((key) => key.startsWith(CACHE_PREFIX)); + const priorCacheLimit = Math.max(0, CONTROL_CACHE_LIMIT - 1); + // Keep a small prior-build window so open tabs can still load old hashed chunks after updates. + const retained = new Set([ + ...controlKeys.filter((key) => key !== CACHE_NAME).slice(-priorCacheLimit), + CACHE_NAME, + ]); + + await Promise.all([ + self.clients.claim(), + Promise.all( controlKeys.filter((key) => !retained.has(key)).map((key) => caches.delete(key)), - ); - }), - ]), + ), + ]); + + for (const client of windowClients) { + // oxlint-disable-next-line unicorn/require-post-message-target-origin -- Service Worker Client.postMessage does not take targetOrigin. + client.postMessage({ type: "sw-updated", version: CACHE_VERSION }); + } + })(), ); }); diff --git a/ui/src/main.ts b/ui/src/main.ts index 0925ca0e08e9..24f4757273df 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -12,12 +12,18 @@ type ViteImportMeta = ImportMeta & { declare const OPENCLAW_CONTROL_UI_BUILD_ID: string | undefined; const isProd = (import.meta as ViteImportMeta).env?.PROD === true; +const currentControlUiBuildId = OPENCLAW_CONTROL_UI_BUILD_ID || "dev"; syncDocumentPublicAssetLinks(); if (isProd && "serviceWorker" in navigator) { const swUrl = new URL(inferControlUiPublicAssetPath("sw.js"), window.location.origin); - swUrl.searchParams.set("v", OPENCLAW_CONTROL_UI_BUILD_ID || "dev"); + swUrl.searchParams.set("v", currentControlUiBuildId); + navigator.serviceWorker.addEventListener("message", (event) => { + if (event.data?.type === "sw-updated" && event.data.version !== currentControlUiBuildId) { + window.location.reload(); + } + }); void navigator.serviceWorker.register(swUrl, { updateViaCache: "none" }); } else if (!isProd && "serviceWorker" in navigator) { // Unregister any leftover dev SW to avoid stale cache issues. diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 597886c9afcc..fd1581c1ab06 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -697,8 +697,9 @@ .chat-composer-model-control { display: inline-flex; - flex: 0 1 220px; + flex: 0 0 auto; min-width: 0; + max-width: 70vw; } .chat-composer-model-control .chat-controls__model { @@ -2188,7 +2189,7 @@ z-index: 80; display: grid; gap: 2px; - width: max(100%, min(260px, calc(100vw - 32px))); + width: max(300%, min(300px, calc(100vw - 32px))); max-height: min(280px, calc(100vh - 120px)); padding: 6px; border: 1px solid color-mix(in srgb, var(--border) 82%, transparent); diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 5e0b8113151d..54782b0573ff 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -3557,7 +3557,8 @@ export function renderApp(state: AppViewState) { }, onClawHubDetailOpen: (slug) => void loadClawHubDetail(state, slug), onClawHubDetailClose: () => closeClawHubDetail(state), - onClawHubInstall: (slug) => void installFromClawHub(state, slug), + onClawHubInstall: (slug, acknowledgeClawHubRisk, version) => + void installFromClawHub(state, slug, acknowledgeClawHubRisk, version), }), ) : nothing} diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 5051ba5c2701..2ecc245a850d 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -4,8 +4,8 @@ import type { ChatAbortOptions, ChatSendOptions } from "./app-chat.ts"; import type { EventLogEntry } from "./app-events.ts"; import type { CompactionStatus, FallbackStatus } from "./app-tool-stream.ts"; import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "./chat/input-history.ts"; -import type { RealtimeTalkConversationEntry } from "./chat/realtime-talk-conversation.ts"; import type { RealtimeTalkCatalogProvider } from "./chat/realtime-talk-catalog.ts"; +import type { RealtimeTalkConversationEntry } from "./chat/realtime-talk-conversation.ts"; import type { RealtimeTalkStatus } from "./chat/realtime-talk.ts"; import type { ChatRunUiStatus } from "./chat/run-lifecycle.ts"; import type { ChatMessageCache } from "./chat/session-message-cache.ts"; @@ -427,7 +427,13 @@ export type AppViewState = { clawhubDetailLoading: boolean; clawhubDetailError: string | null; clawhubInstallSlug: string | null; - clawhubInstallMessage: { kind: "success" | "error"; text: string } | null; + clawhubInstallMessage: { + kind: "success" | "error"; + text: string; + acknowledgeSlug?: string; + acknowledgeVersion?: string; + acknowledgeLabel?: string; + } | null; clawhubVerdicts: Record; clawhubVerdictsLoading: boolean; clawhubVerdictsError: string | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index ede73ca0ed5d..8512eb42034a 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -77,16 +77,16 @@ import type { AppViewState } from "./app-view-state.ts"; import { normalizeAssistantIdentity } from "./assistant-identity.ts"; import { restoreChatComposerState } from "./chat/composer-persistence.ts"; import { exportChatMarkdown } from "./chat/export.ts"; +import { + reconcileRealtimeTalkCatalogSelection, + type RealtimeTalkCatalogProvider, +} from "./chat/realtime-talk-catalog.ts"; import { createRealtimeTalkConversationState, updateRealtimeTalkConversation, type RealtimeTalkConversationEntry, type RealtimeTalkConversationState, } from "./chat/realtime-talk-conversation.ts"; -import { - reconcileRealtimeTalkCatalogSelection, - type RealtimeTalkCatalogProvider, -} from "./chat/realtime-talk-catalog.ts"; import { RealtimeTalkSession, type RealtimeTalkLaunchOptions, @@ -645,7 +645,12 @@ export class OpenClawApp extends LitElement { @state() clawhubDetailLoading = false; @state() clawhubDetailError: string | null = null; @state() clawhubInstallSlug: string | null = null; - @state() clawhubInstallMessage: { kind: "success" | "error"; text: string } | null = null; + @state() clawhubInstallMessage: { + kind: "success" | "error"; + text: string; + acknowledgeSlug?: string; + acknowledgeVersion?: string; + } | null = null; @state() clawhubVerdicts: Record = {}; @state() clawhubVerdictsLoading = false; @state() clawhubVerdictsError: string | null = null; diff --git a/ui/src/ui/controllers/sessions.ts b/ui/src/ui/controllers/sessions.ts index 236dbb0b158e..e322af854710 100644 --- a/ui/src/ui/controllers/sessions.ts +++ b/ui/src/ui/controllers/sessions.ts @@ -297,6 +297,7 @@ const SESSION_EVENT_ROW_FIELDS = [ "compactionCheckpointCount", "contextTokens", "displayName", + "effectiveResponseUsage", "endedAt", "elevatedLevel", "effectiveFastMode", diff --git a/ui/src/ui/controllers/skills.test.ts b/ui/src/ui/controllers/skills.test.ts index 0a3aa662d864..de9c74d9b7c2 100644 --- a/ui/src/ui/controllers/skills.test.ts +++ b/ui/src/ui/controllers/skills.test.ts @@ -766,6 +766,103 @@ describe("skill mutations", () => { }); }); + it("shows ClawHub trust warnings returned by successful skill installs", async () => { + const { state, request } = createState(); + request.mockImplementation(async (method: string) => { + if (method === "skills.install") { + return { + message: "Installed github@1.2.3", + warning: "REVIEW RECOMMENDED - ClawHub has not completed a fresh clean check", + }; + } + return {}; + }); + + await installFromClawHub(state, "github"); + + expect(state.clawhubInstallMessage).toEqual({ + kind: "success", + text: + "Installed github@1.2.3\n\n" + + "REVIEW RECOMMENDED - ClawHub has not completed a fresh clean check", + }); + }); + + it("shows ClawHub trust warnings from failed skill install error details", async () => { + const { state, request } = createState(); + const error = new Error("ClawHub blocked this release; install was not started.") as Error & { + details?: unknown; + }; + error.details = { + warning: "BLOCKED - ClawHub flagged this release as malicious", + }; + request.mockRejectedValue(error); + + await installFromClawHub(state, "github"); + + expect(state.clawhubInstallMessage).toEqual({ + kind: "error", + text: + "ClawHub blocked this release; install was not started.\n\n" + + "BLOCKED - ClawHub flagged this release as malicious", + }); + }); + + it("allows retrying acknowledgement-required ClawHub skill installs", async () => { + const { state, request } = createState(); + const error = new Error("ClawHub requires acknowledgement before installing.") as Error & { + details?: unknown; + }; + error.details = { + clawhubTrustCode: "clawhub_risk_acknowledgement_required", + version: "1.2.3", + warning: "REVIEW REQUIRED - ClawHub found suspicious behavior.", + }; + request.mockImplementation(async (method: string) => { + if (method === "skills.install" && request.mock.calls.length === 1) { + throw error; + } + if (method === "skills.install") { + return { message: "Installed github@1.2.3" }; + } + return { + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [], + }; + }); + + await installFromClawHub(state, "github"); + + expect(state.clawhubInstallMessage).toEqual({ + kind: "error", + text: + "Review the ClawHub warning before installing this skill.\n\n" + + "REVIEW REQUIRED - ClawHub found suspicious behavior.", + acknowledgeSlug: "github", + acknowledgeVersion: "1.2.3", + acknowledgeLabel: "Acknowledge risk and install", + }); + + await installFromClawHub( + state, + "github", + true, + state.clawhubInstallMessage!.acknowledgeVersion, + ); + + expect(request).toHaveBeenNthCalledWith(2, "skills.install", { + source: "clawhub", + slug: "github", + version: "1.2.3", + acknowledgeClawHubRisk: true, + }); + expect(state.clawhubInstallMessage).toEqual({ + kind: "success", + text: "Installed github@1.2.3", + }); + }); + it.each([ { name: "legacy install", diff --git a/ui/src/ui/controllers/skills.ts b/ui/src/ui/controllers/skills.ts index 88234499401d..60663eba3cda 100644 --- a/ui/src/ui/controllers/skills.ts +++ b/ui/src/ui/controllers/skills.ts @@ -1,4 +1,8 @@ // Control UI controller manages skills gateway state. +import { + ClawHubTrustErrorCodes, + readClawHubTrustErrorDetails, +} from "../../../../packages/gateway-protocol/src/clawhub-trust-error-details.js"; import type { GatewayBrowserClient } from "../gateway.ts"; import type { AgentsListResult, @@ -22,6 +26,8 @@ export type ClawHubSkillDetail = { displayName: string; summary?: string; tags?: Record; + channel?: string | null; + isOfficial?: boolean | null; createdAt: number; updatedAt: number; } | null; @@ -38,6 +44,9 @@ export type ClawHubSkillDetail = { handle?: string | null; displayName?: string | null; image?: string | null; + official?: boolean | null; + channel?: string | null; + isOfficial?: boolean | null; } | null; }; @@ -87,7 +96,13 @@ export type SkillsState = { clawhubDetailLoading: boolean; clawhubDetailError: string | null; clawhubInstallSlug: string | null; - clawhubInstallMessage: { kind: "success" | "error"; text: string } | null; + clawhubInstallMessage: { + kind: "success" | "error"; + text: string; + acknowledgeSlug?: string; + acknowledgeVersion?: string; + acknowledgeLabel?: string; + } | null; clawhubVerdicts: Record; clawhubVerdictsLoading: boolean; clawhubVerdictsError: string | null; @@ -113,6 +128,38 @@ function setSkillMessage(state: SkillsState, key: string, message: SkillMessage) const getErrorMessage = (err: unknown) => (err instanceof Error ? err.message : String(err)); +function getClawHubTrustWarningFromError(err: unknown): string | undefined { + if (!err || typeof err !== "object" || !("details" in err)) { + return undefined; + } + return readClawHubTrustErrorDetails((err as { details?: unknown }).details)?.warning; +} + +function getClawHubTrustCodeFromError(err: unknown) { + if (!err || typeof err !== "object" || !("details" in err)) { + return undefined; + } + return readClawHubTrustErrorDetails((err as { details?: unknown }).details)?.clawhubTrustCode; +} + +function getClawHubTrustVersionFromError(err: unknown): string | undefined { + if (!err || typeof err !== "object" || !("details" in err)) { + return undefined; + } + return readClawHubTrustErrorDetails((err as { details?: unknown }).details)?.version; +} + +function formatClawHubInstallMessage(message: string, warning?: string): string { + return warning ? `${message}\n\n${warning}` : message; +} + +function formatClawHubAcknowledgementMessage(warning?: string): string { + return formatClawHubInstallMessage( + "Review the ClawHub warning before installing this skill.", + warning, + ); +} + export function clawhubVerdictKey(target: { registry: string; slug: string; @@ -555,7 +602,12 @@ export function closeClawHubDetail(state: SkillsState) { state.clawhubDetailLoading = false; } -export async function installFromClawHub(state: SkillsState, slug: string) { +export async function installFromClawHub( + state: SkillsState, + slug: string, + acknowledgeClawHubRisk = false, + version?: string, +) { if (!state.client || !state.connected) { return; } @@ -563,11 +615,16 @@ export async function installFromClawHub(state: SkillsState, slug: string) { state.clawhubInstallSlug = slug; state.clawhubInstallMessage = null; try { - await state.client.request("skills.install", { - ...skillsAgentParams(state), - source: "clawhub", - slug, - }); + const result = await state.client.request<{ message?: string; warning?: string }>( + "skills.install", + { + ...skillsAgentParams(state), + source: "clawhub", + slug, + ...(version ? { version } : {}), + ...(acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), + }, + ); if (!isSkillsAgentScopeCurrent(state, agentScope)) { return; } @@ -575,10 +632,24 @@ export async function installFromClawHub(state: SkillsState, slug: string) { if (!isSkillsAgentScopeCurrent(state, agentScope)) { return; } - state.clawhubInstallMessage = { kind: "success", text: `Installed ${slug}` }; + state.clawhubInstallMessage = { + kind: "success", + text: formatClawHubInstallMessage(result?.message ?? `Installed ${slug}`, result?.warning), + }; } catch (err) { if (isSkillsAgentScopeCurrent(state, agentScope)) { - state.clawhubInstallMessage = { kind: "error", text: getErrorMessage(err) }; + const needsAcknowledgement = + getClawHubTrustCodeFromError(err) === ClawHubTrustErrorCodes.RISK_ACKNOWLEDGEMENT_REQUIRED; + const acknowledgeVersion = getClawHubTrustVersionFromError(err); + state.clawhubInstallMessage = { + kind: "error", + text: needsAcknowledgement + ? formatClawHubAcknowledgementMessage(getClawHubTrustWarningFromError(err)) + : formatClawHubInstallMessage(getErrorMessage(err), getClawHubTrustWarningFromError(err)), + ...(needsAcknowledgement ? { acknowledgeSlug: slug } : {}), + ...(needsAcknowledgement && acknowledgeVersion ? { acknowledgeVersion } : {}), + ...(needsAcknowledgement ? { acknowledgeLabel: "Acknowledge risk and install" } : {}), + }; } } finally { if (isSkillsAgentScopeCurrent(state, agentScope) && state.clawhubInstallSlug === slug) { diff --git a/ui/src/ui/service-worker-cache.test.ts b/ui/src/ui/service-worker-cache.test.ts index 1239a3f118e2..9b83792495da 100644 --- a/ui/src/ui/service-worker-cache.test.ts +++ b/ui/src/ui/service-worker-cache.test.ts @@ -2,18 +2,22 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import vm from "node:vm"; +import { describe, expect, it, vi } from "vitest"; const here = path.dirname(fileURLToPath(import.meta.url)); +const serviceWorkerPath = path.join(here, "../../public/sw.js"); describe("Control UI service worker cache versioning", () => { it("registers the service worker with a build id and bounds prior build caches", () => { const mainSource = fs.readFileSync(path.join(here, "../main.ts"), "utf8"); - const serviceWorkerSource = fs.readFileSync(path.join(here, "../../public/sw.js"), "utf8"); + const serviceWorkerSource = fs.readFileSync(serviceWorkerPath, "utf8"); const viteConfigSource = fs.readFileSync(path.join(here, "../../vite.config.ts"), "utf8"); expect(mainSource).toContain('swUrl.searchParams.set("v"'); expect(mainSource).toContain('updateViaCache: "none"'); + expect(mainSource).toContain('navigator.serviceWorker.addEventListener("message"'); + expect(mainSource).toContain("event.data.version !== currentControlUiBuildId"); expect(serviceWorkerSource).toContain( 'const EMBEDDED_CACHE_VERSION = "__OPENCLAW_CONTROL_UI_BUILD_ID__"', ); @@ -21,7 +25,93 @@ describe("Control UI service worker cache versioning", () => { expect(serviceWorkerSource).toContain("CONTROL_CACHE_LIMIT = 3"); expect(serviceWorkerSource).toContain("slice(-priorCacheLimit)"); expect(serviceWorkerSource).toContain("caches.delete"); + expect(serviceWorkerSource).toContain("includeUncontrolled: true"); + expect(serviceWorkerSource).not.toContain( + 'postMessage({ type: "sw-updated", version: CACHE_VERSION },', + ); expect(viteConfigSource).toContain("source.replace(placeholder, JSON.stringify(buildId))"); expect(serviceWorkerSource).not.toContain('const CACHE_NAME = "openclaw-control-v1"'); }); + + it("broadcasts updated versions to uncontrolled window clients during activation", async () => { + const serviceWorkerSource = fs.readFileSync(serviceWorkerPath, "utf8"); + const windowClient = { postMessage: vi.fn() }; + const matchedClients = createDeferred>(); + const listeners = new Map void>>(); + const cacheDelete = vi.fn(async () => true); + const clients = { + claim: vi.fn(async () => undefined), + matchAll: vi.fn(() => matchedClients.promise), + }; + const caches = { + delete: cacheDelete, + keys: vi.fn(async () => [ + "openclaw-control-oldest", + "openclaw-control-older", + "openclaw-control-previous", + "openclaw-control-new-build", + "other-cache", + ]), + open: vi.fn(), + }; + const serviceWorkerGlobal = { + addEventListener(type: string, listener: (event: ActivateEventStub) => void) { + listeners.set(type, [...(listeners.get(type) ?? []), listener]); + }, + clients, + location: { href: "https://control.example/sw.js?v=new-build" }, + registration: { showNotification: vi.fn() }, + skipWaiting: vi.fn(), + }; + const context = vm.createContext({ + URL, + caches, + fetch: vi.fn(), + self: serviceWorkerGlobal, + }); + + new vm.Script(serviceWorkerSource, { filename: "ui/public/sw.js" }).runInContext(context); + + const activateHandler = listeners.get("activate")?.[0]; + expect(activateHandler).toBeDefined(); + let activationPromise: Promise | undefined; + activateHandler?.({ + waitUntil(promise: Promise) { + activationPromise = promise; + }, + }); + + let activationSettled = false; + void activationPromise?.then(() => { + activationSettled = true; + }); + await Promise.resolve(); + + expect(activationSettled).toBe(false); + expect(windowClient.postMessage).not.toHaveBeenCalled(); + + matchedClients.resolve([windowClient]); + await activationPromise; + + expect(clients.matchAll).toHaveBeenCalledWith({ type: "window", includeUncontrolled: true }); + expect(clients.claim).toHaveBeenCalled(); + expect(cacheDelete).toHaveBeenCalledWith("openclaw-control-oldest"); + expect(windowClient.postMessage).toHaveBeenCalledWith({ + type: "sw-updated", + version: "new-build", + }); + expect(windowClient.postMessage.mock.calls[0]).toHaveLength(1); + }); }); + +type ActivateEventStub = { + waitUntil(promise: Promise): void; +}; + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index fd32ca33974a..bb839b711ad0 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -506,6 +506,8 @@ export type GatewaySessionRow = { childSessions?: string[]; model?: string; modelProvider?: string; + /** Resolved effective usage-footer mode (session override → per-channel config → default → off), carried from gateway session rows/events. */ + effectiveResponseUsage?: "on" | "off" | "tokens" | "full"; agentRuntime?: GatewayAgentRuntime; contextTokens?: number; compactionCheckpointCount?: number; diff --git a/ui/src/ui/views/skills.test.ts b/ui/src/ui/views/skills.test.ts index 9f6775058d43..bef55ad3201b 100644 --- a/ui/src/ui/views/skills.test.ts +++ b/ui/src/ui/views/skills.test.ts @@ -382,6 +382,38 @@ describe("renderSkills", () => { expect(onClawHubInstall).toHaveBeenCalledWith("github"); }); + it("renders ClawHub acknowledgement retry actions", async () => { + const container = document.createElement("div"); + document.body.append(container); + dialogRestores.push(() => container.remove()); + const onClawHubInstall = vi.fn(); + + render( + renderSkills( + createProps({ + clawhubInstallMessage: { + kind: "error", + text: "REVIEW REQUIRED - ClawHub found suspicious behavior.", + acknowledgeSlug: "github", + acknowledgeVersion: "1.2.3", + }, + onClawHubInstall, + }), + ), + container, + ); + + const retryButton = container.querySelector(".callout button"); + expect(normalizeText(container.querySelector(".callout")!)).toBe( + "REVIEW REQUIRED - ClawHub found suspicious behavior. Acknowledge risk and install", + ); + expect(retryButton).toBeInstanceOf(HTMLButtonElement); + retryButton!.click(); + + expect(onClawHubInstall).toHaveBeenCalledTimes(1); + expect(onClawHubInstall).toHaveBeenCalledWith("github", true, "1.2.3"); + }); + it("renders installed ClawHub verdicts and the local Skill Card tab", async () => { const container = document.createElement("div"); document.body.append(container); diff --git a/ui/src/ui/views/skills.ts b/ui/src/ui/views/skills.ts index cdacc6ea31ac..9f97b0b3c353 100644 --- a/ui/src/ui/views/skills.ts +++ b/ui/src/ui/views/skills.ts @@ -78,7 +78,13 @@ export type SkillsProps = { clawhubDetailLoading: boolean; clawhubDetailError: string | null; clawhubInstallSlug: string | null; - clawhubInstallMessage: { kind: "success" | "error"; text: string } | null; + clawhubInstallMessage: { + kind: "success" | "error"; + text: string; + acknowledgeSlug?: string; + acknowledgeVersion?: string; + acknowledgeLabel?: string; + } | null; onFilterChange: (next: string) => void; onAgentChange: (agentId: string) => void; onStatusFilterChange: (next: SkillsStatusFilter) => void; @@ -93,7 +99,7 @@ export type SkillsProps = { onClawHubQueryChange: (query: string) => void; onClawHubDetailOpen: (slug: string) => void; onClawHubDetailClose: () => void; - onClawHubInstall: (slug: string) => void; + onClawHubInstall: (slug: string, acknowledgeClawHubRisk?: boolean, version?: string) => void; }; type StatusTabDef = { id: SkillsStatusFilter; label: string }; @@ -318,7 +324,29 @@ export function renderSkills(props: SkillsProps) { class="callout ${props.clawhubInstallMessage.kind === "error" ? "danger" : "success"}" style="margin-top: 8px;" > - ${props.clawhubInstallMessage.text} +
+ ${props.clawhubInstallMessage.text} +
+ ${props.clawhubInstallMessage.acknowledgeSlug + ? html`` + : nothing} ` : nothing} ${renderClawHubResults(props)} diff --git a/ui/src/ui/views/usage-metrics.test.ts b/ui/src/ui/views/usage-metrics.test.ts index 0dc6685690ae..291fb61b1d97 100644 --- a/ui/src/ui/views/usage-metrics.test.ts +++ b/ui/src/ui/views/usage-metrics.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi, afterEach } from "vitest"; import { buildPeakErrorHours, buildUsageMosaicStats, + formatTokens, getHourAndWeekdayForUtcQuarterBucket, sessionTouchesSelectedHours, } from "./usage-metrics.ts"; @@ -411,3 +412,28 @@ describe("usage mosaic token buckets", () => { expect(sessionTouchesSelectedHours(session, [11], "utc")).toBe(true); }); }); + +describe("formatTokens", () => { + it("formats values below 1,000 verbatim", () => { + expect(formatTokens(0)).toBe("0"); + expect(formatTokens(999)).toBe("999"); + }); + + it("formats thousands with one decimal and a K suffix", () => { + expect(formatTokens(1_000)).toBe("1.0K"); + expect(formatTokens(12_500)).toBe("12.5K"); + expect(formatTokens(999_949)).toBe("999.9K"); + }); + + it("rolls 999,950-999,999 over to the M branch instead of '1000.0K'", () => { + // These values round up to "1000.0" at one-decimal thousands precision. + // Without the rollover guard they render the nonsensical "1000.0K". + expect(formatTokens(999_950)).toBe("1.0M"); + expect(formatTokens(999_999)).toBe("1.0M"); + }); + + it("formats millions with one decimal and an M suffix", () => { + expect(formatTokens(1_000_000)).toBe("1.0M"); + expect(formatTokens(2_500_000)).toBe("2.5M"); + }); +}); diff --git a/ui/src/ui/views/usage-metrics.ts b/ui/src/ui/views/usage-metrics.ts index 6618f046796a..4a440cdb3699 100644 --- a/ui/src/ui/views/usage-metrics.ts +++ b/ui/src/ui/views/usage-metrics.ts @@ -20,7 +20,16 @@ function formatTokens(n: number): string { return `${(n / 1_000_000).toFixed(1)}M`; } if (n >= 1_000) { - return `${(n / 1_000).toFixed(1)}K`; + // Values from 999,950-999,999 round to "1000.0" at one-decimal + // thousands precision, which would display the nonsensical "1000.0K" + // instead of rolling over to the M branch above. Re-check the + // rounded result before formatting. Mirrors the guard in + // formatCompactTokenCount (../chat/token-format.ts). + const thousands = (n / 1_000).toFixed(1); + if (Number(thousands) >= 1_000) { + return `${(n / 1_000_000).toFixed(1)}M`; + } + return `${thousands}K`; } return String(n); }