From 12d0fd2ef8afa92c5c9480de0f090e34488d565e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 24 Aug 2026 01:59:16 -0700 Subject: [PATCH] refactor(anthropic): replace handwritten Claude sessions with Agent SDK (#128131) * refactor(anthropic): explore official Claude Agent SDK runtime * refactor(anthropic): replace handwritten Claude sessions with SDK * refactor(anthropic): collapse SDK live-session ownership * refactor(anthropic): simplify SDK ownership and preserve live skills * fix(anthropic): fence cancelled SDK runs before process startup * fix(anthropic): harden SDK approvals, lifecycle, and packaging * refactor(anthropic): own SDK process trees and streamline runtime * fix(anthropic): repair rebased packaging and legacy test fixtures --- Dockerfile | 14 +- config/assertion-safety-baseline.txt | 2 - docs/gateway/cli-backends.md | 90 +- docs/plugins/cli-backend-plugins.md | 28 +- docs/providers/anthropic.md | 70 +- docs/tools/exec-approvals.md | 14 +- .../anthropic/agent-sdk.runtime.test.ts | 1076 +++++++++++++++++ extensions/anthropic/agent-sdk.runtime.ts | 706 +++++++++++ extensions/anthropic/cli-backend.ts | 64 +- extensions/anthropic/cli-shared.test.ts | 31 +- extensions/anthropic/cli-shared.ts | 95 +- extensions/anthropic/package.json | 3 + extensions/google/cli-backend-auth.test.ts | 25 +- .../google/cli-backend-isolated.test.ts | 20 +- extensions/google/setup-api.test.ts | 4 +- pnpm-lock.yaml | 4 + scripts/build-all.mts | 1 + scripts/lib/bundled-plugin-build-entries.mjs | 28 +- scripts/lib/docker-plugin-selection.mjs | 46 +- .../root-package-bundled-plugin-excludes.mjs | 23 + src/agents/cli-backend-version-support.ts | 37 - src/agents/cli-backends.test.ts | 39 +- src/agents/cli-backends.ts | 46 +- src/agents/cli-output-stream.ts | 13 +- ...cli-runner.before-agent-reply-cron.test.ts | 27 +- src/agents/cli-runner.context-engine.test.ts | 1 + .../cli-runner.fault-sequences.e2e.test.ts | 152 +-- src/agents/cli-runner.helpers.test.ts | 13 + src/agents/cli-runner.reliability.test.ts | 507 +------- src/agents/cli-runner.spawn.test.ts | 518 +------- src/agents/cli-runner.test-helpers.ts | 235 +--- src/agents/cli-runner.test-support.ts | 11 - src/agents/cli-runner.ts | 4 +- .../claude-live-background-tasks.test.ts | 576 --------- .../claude-live-process-approval.test.ts | 528 -------- .../claude-live-process-capture.test.ts | 391 ------ .../cli-runner/claude-live-process.test.ts | 820 ------------- src/agents/cli-runner/claude-live-process.ts | 660 ---------- .../cli-runner/claude-live-registry.test.ts | 1050 ---------------- src/agents/cli-runner/claude-live-registry.ts | 183 --- .../claude-live-session-policy.test.ts | 85 -- .../cli-runner/claude-live-session-policy.ts | 30 - ...-live-session.abort-partial-output.test.ts | 158 --- .../claude-live-session.test-support.ts | 12 - .../cli-runner/claude-live-session.test.ts | 573 --------- src/agents/cli-runner/claude-live-session.ts | 631 ---------- .../claude-live-turn-diagnostics.test.ts | 495 -------- .../cli-runner/claude-live-turn-timeouts.ts | 112 -- .../cli-runner/claude-live-turn.test.ts | 996 --------------- src/agents/cli-runner/claude-live-turn.ts | 638 ---------- .../cli-live-session-registry.test.ts | 366 ++++++ .../cli-runner/cli-live-session-registry.ts | 263 ++++ ...st.ts => cli-native-tool-approval.test.ts} | 71 +- ...pproval.ts => cli-native-tool-approval.ts} | 101 +- src/agents/cli-runner/cli-run-settlement.ts | 8 +- src/agents/cli-runner/cli-run-transcript.ts | 2 +- .../execute-events.tool-result-args.test.ts | 1 + src/agents/cli-runner/execute-plugin.test.ts | 913 ++++++++++++++ src/agents/cli-runner/execute-plugin.ts | 409 +++++++ src/agents/cli-runner/execute-process.ts | 155 ++- .../cli-runner/execute-tool-tracking.ts | 4 +- .../execute.pending-cancellation.test.ts | 26 + .../execute.supervisor-capture.test.ts | 1 + src/agents/cli-runner/execute.ts | 70 +- src/agents/cli-runner/helpers.ts | 11 +- .../cli-runner/live-session-fingerprint.ts | 100 ++ src/agents/cli-runner/prepare.test.ts | 99 +- src/agents/cli-runner/prepare.ts | 28 +- src/agents/cli-runner/tool-policy.test.ts | 18 +- src/agents/cli-runner/tool-policy.ts | 13 - src/agents/cli-runner/types.ts | 12 +- .../command/attempt-execution.cli.test.ts | 6 +- src/agents/command/attempt-execution.ts | 4 +- src/auto-reply/reply/reply-run-registry.ts | 1 - src/cli/program/register.agent-turn.ts | 2 +- src/cli/program/register.agent.test.ts | 5 +- src/commands/doctor-claude-cli.test.ts | 39 - src/commands/doctor-claude-cli.ts | 45 - src/commands/onboard-inference.test.ts | 30 - src/commands/onboard-inference.ts | 24 +- src/dockerfile.test.ts | 2 +- src/gateway/gateway-cli-backend.live.test.ts | 20 +- src/plugin-sdk/cli-backend.ts | 10 +- src/plugins/cli-backend.types.ts | 88 +- .../package-manifest.contract.test.ts | 4 + src/plugins/types.ts | 13 - src/system-agent/setup-inference.test.ts | 8 +- .../package-openclaw-for-docker.e2e.test.ts | 1 + test/openclaw-prepack.test.ts | 1 + test/scripts/docker-plugin-selection.test.ts | 72 +- 90 files changed, 4704 insertions(+), 10227 deletions(-) create mode 100644 extensions/anthropic/agent-sdk.runtime.test.ts create mode 100644 extensions/anthropic/agent-sdk.runtime.ts create mode 100644 scripts/lib/root-package-bundled-plugin-excludes.mjs delete mode 100644 src/agents/cli-backend-version-support.ts delete mode 100644 src/agents/cli-runner/claude-live-background-tasks.test.ts delete mode 100644 src/agents/cli-runner/claude-live-process-approval.test.ts delete mode 100644 src/agents/cli-runner/claude-live-process-capture.test.ts delete mode 100644 src/agents/cli-runner/claude-live-process.test.ts delete mode 100644 src/agents/cli-runner/claude-live-process.ts delete mode 100644 src/agents/cli-runner/claude-live-registry.test.ts delete mode 100644 src/agents/cli-runner/claude-live-registry.ts delete mode 100644 src/agents/cli-runner/claude-live-session-policy.test.ts delete mode 100644 src/agents/cli-runner/claude-live-session-policy.ts delete mode 100644 src/agents/cli-runner/claude-live-session.abort-partial-output.test.ts delete mode 100644 src/agents/cli-runner/claude-live-session.test-support.ts delete mode 100644 src/agents/cli-runner/claude-live-session.test.ts delete mode 100644 src/agents/cli-runner/claude-live-session.ts delete mode 100644 src/agents/cli-runner/claude-live-turn-diagnostics.test.ts delete mode 100644 src/agents/cli-runner/claude-live-turn-timeouts.ts delete mode 100644 src/agents/cli-runner/claude-live-turn.test.ts delete mode 100644 src/agents/cli-runner/claude-live-turn.ts create mode 100644 src/agents/cli-runner/cli-live-session-registry.test.ts create mode 100644 src/agents/cli-runner/cli-live-session-registry.ts rename src/agents/cli-runner/{claude-live-tool-approval.test.ts => cli-native-tool-approval.test.ts} (86%) rename src/agents/cli-runner/{claude-live-tool-approval.ts => cli-native-tool-approval.ts} (72%) create mode 100644 src/agents/cli-runner/execute-plugin.test.ts create mode 100644 src/agents/cli-runner/execute-plugin.ts create mode 100644 src/agents/cli-runner/live-session-fingerprint.ts diff --git a/Dockerfile b/Dockerfile index 34a2bf5984b6..885ac5ebbff7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,6 +36,8 @@ ARG OPENCLAW_BUNDLED_PLUGIN_DIR # Podman/Buildah hosts. Full trees stay in this disposable stage; later stages # receive only extracted manifests. COPY scripts/lib/docker-plugin-selection.mjs /tmp/docker-plugin-selection.mjs +COPY scripts/lib/root-package-bundled-plugin-excludes.mjs /tmp/root-package-bundled-plugin-excludes.mjs +COPY package.json /tmp/package.json COPY packages /tmp/packages COPY ${OPENCLAW_BUNDLED_PLUGIN_DIR} /tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR} RUN mkdir -p /out/packages "/out/${OPENCLAW_BUNDLED_PLUGIN_DIR}" && \ @@ -48,13 +50,17 @@ RUN mkdir -p /out/packages "/out/${OPENCLAW_BUNDLED_PLUGIN_DIR}" && \ done && \ node /tmp/docker-plugin-selection.mjs "/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}" "$OPENCLAW_EXTENSIONS" \ > /out/openclaw-selected-plugin-dirs && \ + node /tmp/docker-plugin-selection.mjs "/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}" "$OPENCLAW_EXTENSIONS" \ + --required-platform-packages > /out/openclaw-required-platform-packages && \ + node /tmp/docker-plugin-selection.mjs "/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}" "$OPENCLAW_EXTENSIONS" \ + --required-bundled /tmp/package.json > /tmp/openclaw-workspace-plugin-dirs && \ while IFS= read -r ext; do \ ext_dir="/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}/$ext"; \ if [ -f "$ext_dir/package.json" ]; then \ mkdir -p "/out/${OPENCLAW_BUNDLED_PLUGIN_DIR}/$ext" && \ cp "$ext_dir/package.json" "/out/${OPENCLAW_BUNDLED_PLUGIN_DIR}/$ext/package.json"; \ fi; \ - done < /out/openclaw-selected-plugin-dirs + done < /tmp/openclaw-workspace-plugin-dirs # ── Stage 2: Build ────────────────────────────────────────────── FROM ${OPENCLAW_BUN_IMAGE} AS bun-binary @@ -83,6 +89,7 @@ COPY scripts/lib/package-dist-imports.mjs ./scripts/lib/package-dist-imports.mjs COPY --from=workspace-deps /out/packages/ ./packages/ COPY --from=workspace-deps /out/${OPENCLAW_BUNDLED_PLUGIN_DIR}/ ./${OPENCLAW_BUNDLED_PLUGIN_DIR}/ COPY --from=workspace-deps /out/openclaw-selected-plugin-dirs /tmp/openclaw-selected-plugin-dirs +COPY --from=workspace-deps /out/openclaw-required-platform-packages /tmp/openclaw-required-platform-packages # Reduce OOM risk on low-memory hosts during dependency installation. # Docker builds on small VMs may otherwise fail with "Killed" (exit 137). @@ -170,6 +177,7 @@ FROM build AS runtime-assets ARG OPENCLAW_BUNDLED_PLUGIN_DIR # BuildKit cache mounts are not part of cached layers; seed tarballs for the # installed prod graph in the same step that runs offline prune. +# Keep SDK-native binaries only for selected plugins that explicitly require them. RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \ node scripts/list-prod-store-packages.mjs | xargs -r pnpm store add && \ CI=true pnpm prune --prod \ @@ -192,6 +200,10 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/sto /app/node_modules/openclaw \ /app/node_modules/.bin/openclaw \ /app/node_modules/.pnpm/openclaw@*/node_modules/openclaw && \ + if ! grep -q '^@anthropic-ai/claude-agent-sdk-' /tmp/openclaw-required-platform-packages; then \ + find /app/node_modules/@anthropic-ai -maxdepth 1 -type d \ + -name 'claude-agent-sdk-linux-*' -exec rm -rf {} +; \ + fi && \ node --input-type=module -e 'await import("grammy")' && \ node scripts/check-package-dist-imports.mjs /app diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index b808bb7b9b17..1e5586a28ffd 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -1747,8 +1747,6 @@ src/agents/cli-runner/bundle-mcp-codex.ts 5 src/agents/cli-runner/bundle-mcp-gemini.ts 4 src/agents/cli-runner/bundle-mcp-runtime.ts 1 src/agents/cli-runner/bundle-mcp.ts 6 -src/agents/cli-runner/claude-live-registry.ts 1 -src/agents/cli-runner/claude-live-session.ts 1 src/agents/cli-runner/cli-backend-auth-policy.ts 1 src/agents/cli-runner/cli-run-settlement.ts 1 src/agents/cli-runner/cli-run-transcript.ts 3 diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index d081a375bd86..46c75f02bf99 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -25,7 +25,7 @@ Use it as a safety net for "always works" text responses, not a primary path. Fo The bundled Anthropic plugin registers a default `claude-cli` backend, so it works with no config beyond having Claude Code installed and logged in: ```bash -openclaw agent --agent main --message "hi" --model claude-cli/claude-sonnet-4-6 +openclaw agent --agent main --message "hi" --model claude-cli/claude-sonnet-5 ``` `main` is the default agent id when no explicit agent list is configured; swap in your own agent id otherwise. @@ -48,11 +48,11 @@ Add the CLI backend to your fallback list so it only runs when primary models fa defaults: { model: { primary: "anthropic/claude-opus-4-6", - fallbacks: ["claude-cli/claude-sonnet-4-6"], + fallbacks: ["claude-cli/claude-sonnet-5"], }, models: { "anthropic/claude-opus-4-6": { alias: "Opus" }, - "claude-cli/claude-sonnet-4-6": {}, + "claude-cli/claude-sonnet-5": {}, }, }, }, @@ -89,7 +89,7 @@ plugin code registered with `api.registerCliBackend(...)`. 1. Selects a backend by provider prefix (`claude-cli/...`). 2. Builds a system prompt using the same OpenClaw prompt and workspace context. -3. Executes the CLI with a session id (if supported) so history stays consistent. The bundled `claude-cli` backend keeps a Claude stdio process alive per OpenClaw session and sends follow-up turns over stream-json stdin. +3. Executes the CLI with a session id (if supported) so history stays consistent. The bundled `claude-cli` backend uses Anthropic's official Agent SDK and keeps its authenticated Claude Code subprocess warm across compatible agent turns. 4. Parses output (JSON or plain text) and returns the final text. 5. Persists session ids per backend so follow-ups reuse the same CLI session. @@ -116,17 +116,16 @@ The `openclaw agent` command also has its own request deadline. Its 600-second f ### Claude CLI specifics -OpenClaw's managed Claude stdio sessions require the `msg_lifecycle_v1` -capability, first observed in the published Claude Code 2.1.206 build. At -runtime OpenClaw does not trust the version string alone: it waits for -Claude Code's `system/init` record to advertise `msg_lifecycle_v1`, then accepts -assistant, tool, and result records only after the matching input lifecycle has -started. Unknown capabilities are ignored. A CLI that omits the required -capability fails immediately with `claude update` and gateway-restart guidance -instead of waiting for the no-output watchdog. +The bundled Anthropic plugin runs the installed Claude Code executable through +Anthropic's official Agent SDK. Claude Code owns its existing local login and +subscription; OpenClaw does not extract that login or send synthesized +Anthropic API requests. Compatible agent turns share one warm SDK query and +Claude Code subprocess. A changed model, system prompt, authenticated identity, +or tool policy starts a new query; persisted Claude session IDs still provide +conversation continuity when the gateway or subprocess restarts. -Setup and Doctor treat 2.1.206 as advisory, so a lower-version compatible -backport or wrapper remains selectable and is verified by the runtime gate. +Keep Claude Code updated, especially if the SDK reports an incompatible +installed executable: ```bash claude --version @@ -134,16 +133,24 @@ claude update # Restart the OpenClaw gateway after updating. ``` -Claude Code's public CLI documentation covers stream-json mode and updates but -does not currently document the lifecycle event itself. OpenClaw therefore -feature-detects the advertised capability; 2.1.206 is the first published -Claude Code build observed to provide it. - The bundled `claude-cli` backend prefers Claude Code's native skill resolver. When the current skills snapshot has at least one selected skill with a materialized path, OpenClaw passes a temporary Claude Code plugin via `--plugin-dir` and omits the duplicate OpenClaw skills catalog from the appended system prompt. Without a materialized plugin skill, OpenClaw keeps the prompt catalog as a fallback. Skill env/API key overrides still apply to the child process environment for the run. -Claude CLI has its own noninteractive permission mode; OpenClaw maps that to the existing exec policy instead of adding Claude-specific config. For OpenClaw-managed Claude live sessions, the effective exec policy is authoritative: YOLO (`tools.exec.mode: "full"`) normally launches Claude with `--permission-mode bypassPermissions`, while a restrictive policy launches it with `--permission-mode default`. Root-run gateways also use `default` because Claude Code rejects bypass mode for root. Per-agent `agents.entries.*.tools.exec` settings override the global `tools.exec` for that agent. The Anthropic plugin normalizes Claude's permission flags to match the effective policy and host restriction. +The Agent SDK always runs with Claude Code's default permission mode. +OpenClaw's existing effective exec policy remains authoritative through SDK +permission callbacks and a `PreToolUse` hook, including when user settings +would otherwise preapprove a tool. Per-agent and session restrictions still +override broader global policy. OpenClaw-owned MCP tools remain authorized by +the Gateway rather than receiving a second Claude-native approval. -Under a restrictive policy, Claude asks OpenClaw over stdio before using one of its native or extension tools (its own Bash, WebFetch, or Claude in Chrome browser tools). When the effective exec ask setting is `on-miss` or `always`, OpenClaw relays each request as an interactive approval to the session's channel: **Allow once** permits the single call, **Allow always** permits that tool name for the rest of the live Claude session (in memory only, never persisted), and **Deny**, a timeout, or an unreachable approval route all deny the call. Policies that never prompt keep their old behavior: `security: "deny"` rejects every request, and ask `off` with less than full security (exec mode `allowlist`) denies without asking. +When the effective exec ask setting is `on-miss` or `always`, OpenClaw relays +native or extension tool requests as interactive approvals to the session's +channel: **Allow once** permits the single call, **Allow always** permits that +tool name for the same warm live session while each subsequent turn's policy +and available tools still allow it, and **Deny**, a timeout, an unreachable +approval route, or a closed turn all deny the call. Grants stay in memory, end +when that exact live session is replaced, and never apply to Bash. Policies +that never prompt keep their existing behavior: `security: "deny"` rejects +every request, and ask `off` with less than full security denies without asking. ### Claude browser tools and 1Password sign-in @@ -172,8 +179,7 @@ register a small wrapper backend plugin. - `always`: always send a session id (new UUID if none stored). - `existing`: only send a session id if one was stored before. - `none`: never send a session id. -- `claude-cli` defaults to `liveSession: "claude-stdio"`, `output: "jsonl"`, and `input: "stdin"`, so follow-up turns reuse the live Claude process while it is active, including for custom configs that omit transport fields. If the gateway restarts or the idle process exits, OpenClaw resumes from the stored Claude session id. Stored session ids are verified against a readable project transcript before resume; a missing transcript clears the binding (logged as `reason=transcript-missing`) instead of silently starting a fresh session under `--resume`. -- Claude live sessions keep bounded JSONL output guards: 8 MiB and 20,000 raw JSONL lines per turn. +- `claude-cli` defaults to `liveSession: "claude-stdio"`, `output: "jsonl"`, and `input: "stdin"`. The owning Anthropic plugin keeps one official Agent SDK query and Claude Code subprocess warm for compatible consecutive agent turns. If the gateway restarts or the idle process exits, OpenClaw resumes from the stored Claude session id. Stored session ids are verified against a readable project transcript before resume; a missing transcript clears the binding (logged as `reason=transcript-missing`) instead of silently starting a fresh session under `--resume`. - Stored CLI sessions are provider-owned continuity. Automatic reset is disabled by default; `/reset` and explicit daily or idle `session.reset` policies still cut them. - Fresh CLI sessions normally reseed only from OpenClaw's compaction summary plus the post-compaction tail. To recover short sessions invalidated before compaction, a backend can opt in with `reseedFromRawTranscriptWhenUncompacted: true`. Raw transcript reseed stays bounded and limited to safe invalidations, such as a missing CLI transcript, an orphaned tool-use tail, message-policy/system-prompt/cwd/MCP changes, or a session-expired retry; auth profile or credential-epoch changes never reseed raw transcript history. @@ -225,20 +231,20 @@ Anthropic owns `claude-cli` and Google owns `google-gemini-cli`. OpenAI Codex ag The bundled Anthropic plugin registers for `claude-cli`: -| Key | Value | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `command` | `claude` | -| `args` | `-p --output-format stream-json --include-partial-messages --verbose --setting-sources user --allowedTools mcp__openclaw__* --disallowedTools ScheduleWakeup,CronCreate,Bash(run_in_background:true),Monitor` | -| `output` | `jsonl` | -| `input` | `stdin` | -| `modelArg` | `--model` | -| `sessionArgs` | `["--session-id", "{sessionId}"]` | -| `sessionMode` | `always` | -| live-session requirement | `msg_lifecycle_v1` (first observed in Claude Code 2.1.206) | -| `imageArg` | `@` | -| `imagePathScope` | `workspace` | -| `systemPromptFileArg` | `--append-system-prompt-file` | -| `systemPromptMode` | `append` | +| Key | Value | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `command` | `claude` | +| `args` | `-p --output-format stream-json --include-partial-messages --verbose --setting-sources user --allowedTools mcp__openclaw__* --disallowedTools ScheduleWakeup,CronCreate,Bash(run_in_background:true),Monitor` | +| `output` | `jsonl` | +| `input` | `stdin` | +| `modelArg` | `--model` | +| `sessionArgs` | `["--session-id", "{sessionId}"]` | +| `sessionMode` | `always` | +| agent runtime | Anthropic Agent SDK with a warm, session-scoped Claude Code query | +| `imageArg` | `@` | +| `imagePathScope` | `workspace` | +| `systemPromptFileArg` | `--append-system-prompt-file` | +| `systemPromptMode` | `append` | On Claude Code 2.1.98 or newer, the bundled backend adds `--exclude-dynamic-system-prompt-sections` after its bounded Gateway-startup @@ -341,10 +347,12 @@ If no MCP servers are enabled, OpenClaw still injects a strict config when a bac Session-scoped bundled MCP runtimes are cached for reuse within a session, then reaped after 10 minutes of idle time. One-shot embedded runs such as auth probes, slug generation, and active-memory recall request cleanup at run end so stdio children and Streamable HTTP/SSE streams do not outlive the run. -For `claude-cli`, a compatible selected or ordered OpenClaw OAuth/token profile -is forwarded to that Claude child. This makes per-agent profiles authoritative -for the turn while preserving Claude's native host login when no compatible -profile exists. +For `claude-cli`, an imported native OAuth profile reuses the matching, +identity-verified Claude Code login without forwarding an extracted access +token. Explicit non-native API-key and token profiles continue to use the +protected, per-invocation credential-forwarding CLI path, keeping selected +per-agent profiles authoritative without placing credential values in command +arguments. ## Reseed history cap diff --git a/docs/plugins/cli-backend-plugins.md b/docs/plugins/cli-backend-plugins.md index 367a03a09079..ad6cf41b41e5 100644 --- a/docs/plugins/cli-backend-plugins.md +++ b/docs/plugins/cli-backend-plugins.md @@ -241,18 +241,10 @@ only for behavior that really belongs to the backend. | `manualCompaction` | Atomic command, transport, and positive-acknowledgement contract | | `subscriptionAuthDispatch` | Opted-in embedded runs on subscription credentials execute via this backend | | `runtimeArtifact` | Bound a script launcher to its complete bundled package tree | -| `liveSessionRequirement` | Require an init capability before trusting long-lived session output | Keep these hooks provider-owned. Do not add CLI-specific branches to core when a backend hook can express the behavior. -`liveSessionRequirement` declares one exact capability that the CLI must -advertise in its initialization record before OpenClaw trusts streamed output. -It also supplies the first known compatible version, version-probe arguments, -and update command used by setup and Doctor. Runtime support remains -capability-based, so a compatible backport or wrapper is not rejected only -because of its version string. - `prepareExecution(ctx)` receives `ctx.contextTokenBudget`, the effective token limit selected for the run. Backends that own native compaction can map that budget into their CLI-specific launch contract. It also receives the optional @@ -261,6 +253,16 @@ effective `ctx.thinkingLevel`: `off`, `minimal`, `low`, `medium`, `high`, applied through launch environment or staged configuration; the same field is available to `resolveExecutionArgs(ctx)` for native CLI flags. +`prepareExecution(ctx)` may also return an optional `execute` transport when a +backend owns a vendor-supported SDK for the installed CLI. The transport +receives the exact prepared command, arguments, environment, prompt, session, +and tool availability; it yields the backend's existing structured stream +records. Native tool actions must use the provided, run-bound +`requestToolPermission` callback rather than creating independent approval +authority. OpenClaw retains cancellation, watchdogs, session policy, and MCP +grant ownership. Explicit credential forwarding, paired-node execution, and +manual compaction continue through the existing host-managed process path. + `runtimeArtifact` is plugin-owned. It is consulted only when a live inference turn mints or revalidates verified setup authority; normal CLI runs do not require it. A backend without this declaration cannot @@ -307,16 +309,6 @@ Runtime caps such as cron `toolsAllow` are normalized and group-expanded by OpenClaw before this contract is built. Native tools are disabled, and a backend without a complete declared enforcement path fails before execution. -Plugins built against `v2026.7.2-beta.1` through `v2026.7.2-beta.3` may still -read the deprecated `ctx.toolAvailability.mcp` transport-name projection and -may omit `toolAvailabilityEnforcement` when a selectable backend implements -`resolveExecutionArgs`. OpenClaw recognizes that shipped beta path from the -plugin package's required `openclaw.build.openclawVersion` metadata and -preserves it through the `2026.8.x` line. New and updated plugins should use canonical -`ctx.toolAvailability.openClaw` names and declare -`toolAvailabilityEnforcement: "execution-args"` explicitly; the beta -compatibility path is scheduled for removal after that window. - ### `parseJsonlEvent`: provider-specific JSONL streams Set `parseJsonlEvent` when a backend emits line-delimited JSON that does not diff --git a/docs/providers/anthropic.md b/docs/providers/anthropic.md index 03e8e0502c4a..eb96089106ed 100644 --- a/docs/providers/anthropic.md +++ b/docs/providers/anthropic.md @@ -9,7 +9,7 @@ title: "Anthropic" Anthropic builds the **Claude** model family. OpenClaw supports two auth routes: - **API key** - direct Anthropic API access with usage-based billing (`anthropic/*` models) -- **Claude CLI** - reuse an existing Claude Code login on the same host +- **Claude CLI** - reuse an existing Claude Code login on the same host through Anthropic's official Agent SDK ## Usage and cost tracking @@ -22,18 +22,11 @@ OpenClaw detects the available Anthropic credential and selects the matching usa Admin API cost history comes from Anthropic's [Usage and Cost API](https://platform.claude.com/docs/en/manage-claude/usage-cost-api). It is actual provider billing, separate from OpenClaw's session-derived estimated cost. -OpenClaw's Claude CLI backend runs the installed Claude Code CLI in -non-interactive print mode (`claude -p`). Anthropic's current Claude Code docs -describe that mode as Agent SDK/programmatic usage. Anthropic's June 15, 2026 -support update paused the announced separate Agent SDK billing change: Claude -Agent SDK, `claude -p`, and third-party app usage still draw from a signed-in -subscription's usage limits, and the previously announced monthly Agent SDK -credit is not available while Anthropic revises that plan. - -Interactive Claude Code still draws from the signed-in Claude plan's limits. -API key auth is direct pay-as-you-go billing and does not depend on that plan. -For long-lived gateway hosts, shared automation, and predictable production -spend, use an Anthropic API key. +Claude Code owns its existing login and subscription; OpenClaw does not extract +that login or synthesize Anthropic API requests. Agent SDK and `claude -p` +usage currently draw from the signed-in subscription's limits. API-key auth +uses separate pay-as-you-go billing and is preferable for shared automation or +predictable production spend. Anthropic's current support articles can change this behavior without an OpenClaw release: @@ -91,18 +84,15 @@ OpenClaw release: - OpenClaw's streamed session correlation requires the - `msg_lifecycle_v1` capability. Claude Code 2.1.206 is the first - published build known to advertise it. Verify the installed version: + OpenClaw runs the installed Claude Code executable through Anthropic's + official Agent SDK. Verify that Claude Code is installed and up to date: ```bash claude --version ``` - A lower-version compatible backport or wrapper remains selectable; - OpenClaw verifies the capability at runtime. If the runtime rejects the - installed build, update Claude Code and restart OpenClaw so the gateway - launches the new binary: + If the installed build is incompatible, update Claude Code and restart + OpenClaw so the gateway launches the new binary: ```bash claude update @@ -114,7 +104,18 @@ OpenClaw release: # choose: Claude CLI ``` - OpenClaw detects and reuses the existing Claude CLI credentials. + OpenClaw detects the existing Claude CLI login. Normal agent turns use + the official Agent SDK with the installed, authenticated Claude Code + executable, including native-tool turns whose approvals remain under + OpenClaw control. Imported native OAuth profiles reuse the verified + Claude Code login; explicitly selected API-key or token credentials + still use protected file-descriptor forwarding. Isolated side-question + completions and paired-node execution retain the supervised CLI path. + + Consecutive agent turns reuse the same warm Agent SDK query and Claude + Code subprocess when their authenticated session and execution policy + match. If that process ends or the gateway restarts, the next turn + resumes the persisted Claude Code session. ```bash @@ -125,8 +126,6 @@ OpenClaw release: Setup and runtime details for the Claude CLI backend are in [CLI Backends](/gateway/cli-backends). - `openclaw doctor` also reports advisory guidance for an installed Claude - Code version below the first-known compatible release. @@ -177,8 +176,8 @@ OpenClaw release: ### Billing and `claude -p` - OpenClaw uses Claude Code's non-interactive `claude -p` path for Claude CLI - runs. Anthropic currently treats that path as Agent SDK/programmatic usage: + Anthropic currently treats Agent SDK and non-interactive CLI invocations as + programmatic usage: - Anthropic's June 15, 2026 support update paused the previously announced separate Agent SDK credit plan. @@ -189,14 +188,6 @@ OpenClaw release: - Console/API-key logins use pay-as-you-go API billing and do not receive the subscription Agent SDK credit. - See Anthropic's [Agent SDK plan - article](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan) - for the pause notice, and the Claude Code plan articles for - [Pro/Max](https://support.claude.com/en/articles/11145838-use-claude-code-with-your-pro-or-max-plan) - and - [Team/Enterprise](https://support.claude.com/en/articles/11845131-use-claude-code-with-your-team-or-enterprise-plan) - subscription behavior. - Anthropic can change Claude Code billing and rate-limit behavior without an OpenClaw release. Check `claude auth status`, `/status`, and Anthropic's linked docs when billing predictability matters. @@ -664,6 +655,19 @@ OpenClaw supports Anthropic's prompt caching feature for API-key auth. ## Troubleshooting + + Run these commands as the Gateway user on the Gateway host: + + ```bash + claude auth status --text + claude auth login + openclaw gateway restart + ``` + + Claude Code owns its login and refresh lifecycle; do not copy an OAuth token into OpenClaw. + + + Anthropic token auth expires and can be revoked. For new setups, use an Anthropic API key instead. diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index 460041ba9d54..2c8671d0010b 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -257,15 +257,11 @@ explicitly when a no-UI approval prompt should fall back to allow. -CLI-backed providers that expose their own noninteractive permission mode -can follow this policy. Claude CLI adds -`--permission-mode bypassPermissions` when OpenClaw's effective exec -policy is YOLO. For OpenClaw-managed Claude live sessions, OpenClaw's -effective exec policy is authoritative over Claude's native permission mode: -YOLO normalizes live launches to `--permission-mode bypassPermissions`, and -restrictive effective exec policy normalizes live launches to -`--permission-mode default`, even if raw Claude backend args specify another -mode. +For OpenClaw-managed Claude sessions, the Claude Agent SDK always uses its +`default` permission mode. OpenClaw's effective exec policy remains +authoritative through its native tool approval callback, including YOLO and +restrictive policies, even if raw Claude backend args request +`bypassPermissions`. If you want a more conservative setup, tighten OpenClaw exec policy back to `allowlist` / `on-miss` or `deny`. diff --git a/extensions/anthropic/agent-sdk.runtime.test.ts b/extensions/anthropic/agent-sdk.runtime.test.ts new file mode 100644 index 000000000000..9a261cd7afaa --- /dev/null +++ b/extensions/anthropic/agent-sdk.runtime.test.ts @@ -0,0 +1,1076 @@ +import { createHash } from "node:crypto"; +import { PassThrough } from "node:stream"; +import type { + SpawnOptions as ClaudeAgentSdkSpawnOptions, + SpawnedProcess as ClaudeAgentSdkSpawnedProcess, +} from "@anthropic-ai/claude-agent-sdk"; +import type { + CliBackendExecute, + CliBackendExecuteContext, + CliBackendLiveSessionCapability, + CliBackendLiveSessionHandle, +} from "openclaw/plugin-sdk/cli-backend"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { executeClaudeAgentSdk } from "./agent-sdk.runtime.js"; +import { buildAnthropicCliBackend } from "./cli-backend.js"; + +const { queryMock } = vi.hoisted(() => ({ + queryMock: vi.fn(), +})); + +vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ + query: queryMock, +})); + +const SESSION_ID = "a174e16f-b6e9-48da-ad5a-c437dfc2f9b4"; +const SUCCESS_RESULT = { + type: "result", + subtype: "success", + is_error: false, + result: "ok", + session_id: SESSION_ID, +}; +const liveCapabilities = new Set(); + +function createContext( + overrides: Partial = {}, +): CliBackendExecuteContext { + return { + command: "/usr/local/bin/claude", + args: [ + "-p", + "--output-format", + "stream-json", + "--include-partial-messages", + "--verbose", + "--setting-sources", + "user", + "--allowedTools", + "mcp__openclaw__*", + "--disallowedTools", + "ScheduleWakeup,CronCreate,Bash(run_in_background:true),Monitor", + ], + cwd: "/tmp/openclaw-workspace", + env: { + HOME: "/tmp/claude-login-home", + PATH: "/usr/local/bin:/usr/bin", + OPENCLAW_MCP_TOKEN: "test-grant-not-a-real-secret", + }, + prompt: "Remember the launch code.", + modelId: "claude-sonnet-4-6", + systemPrompt: "Follow the OpenClaw execution policy.", + sessionId: SESSION_ID, + useResume: false, + timeoutMs: 30_000, + executionMode: "agent", + requestToolPermission: vi.fn(async () => ({ + behavior: "deny" as const, + message: "OpenClaw denied this action.", + })), + ...overrides, + }; +} + +function useSdkMessages( + messages: ReadonlyArray> = [SUCCESS_RESULT], + onQuery?: (options: Record) => Promise, +) { + const close = vi.fn(); + queryMock.mockImplementation(({ options }: { options: Record }) => { + const stream = (async function* () { + await onQuery?.(options); + yield* messages; + })(); + return Object.assign(stream, { close }); + }); + return { close }; +} + +async function collect(context: CliBackendExecuteContext): Promise[]> { + const records: Record[] = []; + for await (const record of executeClaudeAgentSdk(context)) { + records.push(record); + } + return records; +} + +function sdkOptions(): Record { + const call = queryMock.mock.calls[0]?.[0] as { options?: Record } | undefined; + expect(call?.options).toBeDefined(); + return call?.options ?? {}; +} + +type SdkNativeToolCallback = ( + toolName: string, + input: Record, + details: { signal: AbortSignal; toolUseID: string; requestId?: string }, +) => Promise; + +function sdkNativeTool(options: Record): SdkNativeToolCallback { + const callback = options.canUseTool as SdkNativeToolCallback; + expect(callback).toEqual(expect.any(Function)); + return callback; +} + +type SdkPreToolUseCallback = ( + input: { + hook_event_name: "PreToolUse"; + tool_name: string; + tool_input: unknown; + tool_use_id: string; + }, + toolUseId: string | undefined, + options: { signal: AbortSignal }, +) => Promise; + +function sdkPreToolUse(options: Record): SdkPreToolUseCallback { + const hooks = options.hooks as { + PreToolUse?: Array<{ hooks?: SdkPreToolUseCallback[] }>; + }; + const callback = hooks.PreToolUse?.[0]?.hooks?.[0]; + if (!callback) { + throw new Error("Claude Agent SDK did not register its native permission hook."); + } + return callback; +} + +function createLiveCapability( + fingerprint = "matching-session-policy", + state: { current?: CliBackendLiveSessionHandle } = {}, +): CliBackendLiveSessionCapability { + const capability: CliBackendLiveSessionCapability = { + fingerprint, + current: () => state.current, + register: vi.fn((handle) => { + state.current = handle; + }), + activate: vi.fn(), + remove: vi.fn((handle) => { + if (state.current === handle) { + state.current = undefined; + } + }), + }; + liveCapabilities.add(capability); + return capability; +} + +function useLiveSdkStreams() { + const streams: PassThrough[] = []; + const prompts: Array[]> = []; + const closes: ReturnType[] = []; + queryMock.mockImplementation(({ prompt }: { prompt: PassThrough }) => { + const stream = new PassThrough({ objectMode: true }); + const messages: Record[] = []; + const close = vi.fn(() => stream.end()); + prompt.on("data", (message: Record) => messages.push(message)); + streams.push(stream); + prompts.push(messages); + closes.push(close); + return Object.assign(stream, { close }); + }); + return { streams, prompts, closes }; +} + +afterEach(async () => { + for (const capability of liveCapabilities) { + const session = capability.current(); + if (session) { + session.close("restart"); + await session.waitForExit(); + } + } + liveCapabilities.clear(); + queryMock.mockReset(); + vi.restoreAllMocks(); +}); + +describe("Anthropic Agent SDK runtime ownership", () => { + it("keeps selected SDK credentials on their private descriptor and isolates side questions", () => { + const backend = buildAnthropicCliBackend(); + const base = { + workspaceDir: "/tmp/openclaw-workspace", + provider: "claude-cli", + modelId: "claude-sonnet-4-6", + executionMode: "agent" as const, + }; + + const credential = backend.prepareExecution?.({ + ...base, + authCredential: { type: "token", token: "fixture-token" }, + } as Parameters>[0]); + const emptyCredential = backend.prepareExecution?.({ + ...base, + authCredential: { type: "token", token: " " }, + } as Parameters>[0]); + const sideQuestion = backend.prepareExecution?.({ + ...base, + executionMode: "side-question", + isolatedCompletionPrompt: "Return a JSON summary.", + } as Parameters>[0]); + + expect(credential).toEqual( + expect.objectContaining({ + env: { CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: "3" }, + secretInput: expect.objectContaining({ fd: 3 }), + execute: expect.any(Function), + }), + ); + expect(emptyCredential).toEqual( + expect.objectContaining({ env: {}, execute: expect.any(Function) }), + ); + expect(emptyCredential).not.toHaveProperty("secretInput"); + expect(sideQuestion).not.toHaveProperty("execute"); + }); + + it("owns the selected-credential process tree while keeping its descriptor private and zeroed", async () => { + const backend = buildAnthropicCliBackend(); + const token = "selected-private-descriptor-fixture"; + const prepared = (await backend.prepareExecution?.({ + workspaceDir: "/tmp/openclaw-workspace", + provider: "claude-cli", + modelId: "claude-sonnet-4-6", + executionMode: "agent", + authCredential: { type: "token", token }, + } as Parameters>[0])) as { + env: Record; + secretInput: { fd: 3; createData: () => Buffer }; + execute: CliBackendExecute; + cleanup?: () => Promise; + }; + let deliveredBuffer: Buffer | undefined; + const originalCreateData = prepared.secretInput.createData; + vi.spyOn(prepared.secretInput, "createData").mockImplementation(() => { + deliveredBuffer = originalCreateData(); + return deliveredBuffer; + }); + let descriptorOutput: { fd: number; digest: string } | undefined; + useSdkMessages([SUCCESS_RESULT], async (options) => { + const spawnProcess = options.spawnClaudeCodeProcess as + | ((input: ClaudeAgentSdkSpawnOptions) => ClaudeAgentSdkSpawnedProcess) + | undefined; + if (!spawnProcess) { + throw new Error("Selected Claude credentials require an SDK-private descriptor spawner."); + } + const args = [ + "-e", + [ + 'const data = require("node:fs").readFileSync(3);', + 'require("node:fs").writeSync(2, Buffer.alloc(1024 * 1024));', + 'const digest = require("node:crypto").createHash("sha256").update(data).digest("hex");', + 'const descendant = require("node:child_process").spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {stdio: "ignore"});', + "process.stdout.write(JSON.stringify({fd: Number(process.env.CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR), digest, descendantPid: descendant.pid}));", + "data.fill(0);", + "setInterval(() => {}, 1000);", + ].join(""), + ]; + const env = { PATH: process.env.PATH, ...prepared.env }; + expect(JSON.stringify(args)).not.toContain(token); + expect(Object.values(env)).not.toContain(token); + const child = spawnProcess({ + command: process.execPath, + args, + cwd: process.cwd(), + env, + signal: new AbortController().signal, + }); + const output = await new Promise((resolve, reject) => { + let stdout = ""; + child.stdout.on("data", (chunk: Buffer | string) => { + stdout += chunk.toString(); + child.kill("SIGTERM"); + }); + child.once("error", reject); + child.once("exit", (code, signal) => { + if (signal === "SIGTERM" || (process.platform === "win32" && code !== null)) { + resolve(stdout); + } else { + reject(new Error(`Credential descriptor proof exited ${String(code)}.`)); + } + }); + }); + const { descendantPid, ...descriptor } = JSON.parse(output) as { + fd: number; + digest: string; + descendantPid: number; + }; + descriptorOutput = descriptor; + try { + await vi.waitFor(() => expect(() => process.kill(descendantPid, 0)).toThrow()); + } finally { + try { + process.kill(descendantPid, "SIGKILL"); + } catch {} + } + }); + + const events: Record[] = []; + for await (const event of prepared.execute( + createContext({ env: { ...createContext().env, ...prepared.env } }), + )) { + events.push(event); + } + + expect(events).toContainEqual(SUCCESS_RESULT); + expect(descriptorOutput).toEqual({ + fd: 3, + digest: createHash("sha256").update(token).digest("hex"), + }); + expect(deliveredBuffer).toBeDefined(); + expect(deliveredBuffer?.every((byte) => byte === 0)).toBe(true); + await prepared.cleanup?.(); + expect(() => prepared.secretInput.createData()).toThrow("no longer available"); + }); + + it.each( + [ + { + name: "denies", + decision: { behavior: "deny" as const, message: "OpenClaw denied restricted Bash." }, + }, + { + name: "allows", + decision: { behavior: "allow" as const, updatedInput: { command: "printf approved" } }, + }, + ].flatMap(({ name, decision }) => [ + { name: `${name} ambient`, credential: undefined, decision }, + { + name: `${name} selected-credential`, + credential: { type: "token" as const, token: "selected-profile-fixture" }, + decision, + }, + ]), + )( + "$name restricted native Bash through the prepared SDK approval owner", + async ({ credential, decision }) => { + const backend = buildAnthropicCliBackend(); + const toolAvailability = { native: ["Bash"], openClaw: ["message"] }; + const prepareContext = { + workspaceDir: "/tmp/openclaw-workspace", + provider: "claude-cli", + modelId: "claude-sonnet-4-6", + executionMode: "agent" as const, + toolAvailability, + }; + const prepared = await backend.prepareExecution?.({ + ...prepareContext, + ...(credential ? { authCredential: credential } : {}), + } as Parameters>[0]); + if (!prepared?.execute) { + throw new Error("Restricted native Bash must use OpenClaw's SDK approval owner."); + } + + const args = backend.resolveExecutionArgs?.({ + ...prepareContext, + useResume: false, + baseArgs: backend.config.args ?? [], + }); + if (!args) { + throw new Error("Anthropic did not prepare restricted native execution arguments."); + } + const requestToolPermission = vi.fn(async () => decision); + const input = { command: "cat /tmp/openclaw-proof-private.txt" }; + let hookDecision: unknown; + let callbackDecision: unknown; + useSdkMessages([SUCCESS_RESULT], async (options) => { + const signal = new AbortController().signal; + hookDecision = await sdkPreToolUse(options)( + { + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: input, + tool_use_id: "restricted-native-bash", + }, + undefined, + { signal }, + ); + callbackDecision = await sdkNativeTool(options)("Bash", input, { + signal, + toolUseID: "restricted-native-bash", + }); + }); + + const events: Record[] = []; + const executionContext = createContext({ args, requestToolPermission, toolAvailability }); + Object.assign(executionContext.env, prepared.env); + for await (const event of prepared.execute(executionContext)) { + events.push(event); + } + + expect(events).toContainEqual(SUCCESS_RESULT); + expect(sdkOptions()).toEqual( + expect.objectContaining({ + tools: ["Bash"], + allowedTools: ["mcp__openclaw__message"], + settingSources: [], + permissionMode: "default", + }), + ); + if (credential) { + expect(sdkOptions().spawnClaudeCodeProcess).toEqual(expect.any(Function)); + expect(sdkOptions().env).toEqual( + expect.objectContaining({ CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: "3" }), + ); + expect(JSON.stringify(sdkOptions().env)).not.toContain(credential.token); + } + expect(hookDecision).toEqual({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: decision.behavior, + ...(decision.behavior === "allow" + ? { updatedInput: decision.updatedInput } + : { permissionDecisionReason: decision.message }), + }, + }); + expect(callbackDecision).toEqual(decision); + expect(requestToolPermission).toHaveBeenCalledTimes(2); + expect(requestToolPermission).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: "Bash", + toolInput: input, + toolCallId: "restricted-native-bash", + }), + ); + }, + ); + + it("runs the installed authenticated executable with the exact host-prepared environment", async () => { + const result = { ...SUCCESS_RESULT, result: "Launch code remembered." }; + useSdkMessages([result]); + const context = createContext(); + + expect(await collect(context)).toContainEqual(result); + + expect(queryMock).toHaveBeenCalledOnce(); + expect(sdkOptions()).toEqual( + expect.objectContaining({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + cwd: "/tmp/openclaw-workspace", + env: context.env, + model: "claude-sonnet-4-6", + includePartialMessages: true, + settingSources: ["user"], + }), + ); + expect(sdkOptions().env).not.toHaveProperty("ANTHROPIC_API_KEY"); + expect(sdkOptions().env).not.toHaveProperty("ANTHROPIC_OAUTH_TOKEN"); + expect(sdkOptions().env).not.toHaveProperty("CLAUDE_CODE_OAUTH_TOKEN"); + + expect(queryMock.mock.calls[0]?.[0]?.prompt).toBe("Remember the launch code."); + }); + + it.each([ + { + name: "the caller's cancellation reason", + reason: new Error("OpenClaw cancelled the run before SDK startup."), + }, + { name: "the default AbortError", reason: undefined }, + ])("preserves $name without starting an already-aborted SDK run", async ({ reason }) => { + const controller = new AbortController(); + controller.abort(reason); + + await expect(collect(createContext({ abortSignal: controller.signal }))).rejects.toBe( + controller.signal.reason, + ); + expect(queryMock).not.toHaveBeenCalled(); + }); + + it("rejects cancellation that races the SDK's asynchronous module load", async () => { + const controller = new AbortController(); + const reason = new Error("OpenClaw cancelled the run while the SDK was loading."); + const running = collect(createContext({ abortSignal: controller.signal })); + + controller.abort(reason); + + await expect(running).rejects.toBe(reason); + expect(queryMock).not.toHaveBeenCalled(); + }); + + it("preserves native session identity across fresh and resumed turns", async () => { + useSdkMessages(); + + await collect(createContext()); + expect(sdkOptions()).toEqual(expect.objectContaining({ sessionId: SESSION_ID })); + expect(sdkOptions()).not.toHaveProperty("resume"); + + queryMock.mockClear(); + await collect(createContext({ useResume: true })); + expect(sdkOptions()).toEqual(expect.objectContaining({ resume: SESSION_ID })); + expect(sdkOptions()).not.toHaveProperty("sessionId"); + }); + + it("reuses one official SDK query and Claude process across compatible agent turns", async () => { + const live = useLiveSdkStreams(); + const capability = createLiveCapability(); + const activate = vi.spyOn(capability, "activate"); + const first = collect(createContext({ prompt: "Remember orange.", liveSession: capability })); + await vi.waitFor(() => expect(queryMock).toHaveBeenCalledOnce()); + live.streams[0]?.write({ ...SUCCESS_RESULT, result: "Remembered orange." }); + + await expect(first).resolves.toContainEqual( + expect.objectContaining({ result: "Remembered orange." }), + ); + const firstHandle = capability.current(); + expect(firstHandle?.isIdle()).toBe(true); + + const second = collect( + createContext({ + prompt: "Which color did I mention?", + useResume: true, + liveSession: capability, + }), + ); + await vi.waitFor(() => expect(live.prompts[0]).toHaveLength(2)); + live.streams[0]?.write({ ...SUCCESS_RESULT, result: "Orange." }); + + await expect(second).resolves.toContainEqual(expect.objectContaining({ result: "Orange." })); + expect(queryMock).toHaveBeenCalledOnce(); + expect(capability.current()).toBe(firstHandle); + expect(activate).toHaveBeenCalledTimes(2); + expect(live.prompts[0]?.map((message) => message.message)).toEqual([ + { role: "user", content: "Remember orange." }, + { role: "user", content: "Which color did I mention?" }, + ]); + }); + + it("restarts the warm SDK query when its system prompt or execution fingerprint changes", async () => { + const live = useLiveSdkStreams(); + const shared: { current?: CliBackendLiveSessionHandle } = {}; + const originalCapability = createLiveCapability("original-system-prompt", shared); + const original = collect(createContext({ liveSession: originalCapability })); + await vi.waitFor(() => expect(queryMock).toHaveBeenCalledOnce()); + live.streams[0]?.write(SUCCESS_RESULT); + await original; + const originalSession = originalCapability.current(); + + const changedCapability = createLiveCapability("changed-system-prompt", shared); + const changed = collect( + createContext({ + systemPrompt: "A changed authoritative OpenClaw system prompt.", + useResume: true, + liveSession: changedCapability, + }), + ); + await vi.waitFor(() => expect(queryMock).toHaveBeenCalledTimes(2)); + live.streams[1]?.write({ ...SUCCESS_RESULT, result: "new system prompt" }); + + await expect(changed).resolves.toContainEqual( + expect.objectContaining({ result: "new system prompt" }), + ); + expect(live.closes[0]).toHaveBeenCalledOnce(); + expect(changedCapability.current()?.generation).not.toBe(originalSession?.generation); + expect(queryMock.mock.calls[1]?.[0]?.options).toEqual( + expect.objectContaining({ + resume: SESSION_ID, + systemPrompt: expect.objectContaining({ + append: "A changed authoritative OpenClaw system prompt.", + }), + }), + ); + }); + + it("refuses to start a live process when its owner will not activate the admitted turn", async () => { + const capability = createLiveCapability(); + const reason = new Error("OpenClaw rejected a stale execution owner."); + vi.spyOn(capability, "activate").mockImplementation(() => { + throw reason; + }); + const remove = vi.spyOn(capability, "remove"); + + await expect(collect(createContext({ liveSession: capability }))).rejects.toBe(reason); + + expect(queryMock).not.toHaveBeenCalled(); + expect(capability.current()).toBeUndefined(); + expect(remove).toHaveBeenCalledOnce(); + }); + + it("closes the warm process and fences retained permissions when its active turn is aborted", async () => { + const live = useLiveSdkStreams(); + const capability = createLiveCapability(); + const controller = new AbortController(); + const running = collect( + createContext({ abortSignal: controller.signal, liveSession: capability }), + ); + await vi.waitFor(() => expect(queryMock).toHaveBeenCalledOnce()); + const canUseTool = sdkNativeTool(sdkOptions()); + const reason = new Error("OpenClaw cancelled the active warm turn."); + + controller.abort(reason); + + await expect(running).rejects.toBe(reason); + expect(live.closes[0]).toHaveBeenCalledOnce(); + expect(capability.current()).toBeUndefined(); + await expect( + canUseTool( + "Bash", + { command: "echo stale" }, + { + signal: new AbortController().signal, + toolUseID: "cancelled-native-tool", + }, + ), + ).resolves.toEqual({ behavior: "deny", message: "The OpenClaw run is no longer active." }); + }); + + it("rebinds a persistent SDK approval callback to only the active admitted turn", async () => { + const live = useLiveSdkStreams(); + const capability = createLiveCapability(); + const firstApproval = vi.fn(async () => ({ + behavior: "allow" as const, + updatedInput: { command: "echo first" }, + })); + const secondApproval = vi.fn(async () => ({ + behavior: "deny" as const, + message: "The second admitted turn denied native execution.", + })); + const first = collect( + createContext({ requestToolPermission: firstApproval, liveSession: capability }), + ); + await vi.waitFor(() => expect(queryMock).toHaveBeenCalledOnce()); + const canUseTool = sdkNativeTool(sdkOptions()); + const firstRequest = { + signal: new AbortController().signal, + toolUseID: "native-turn-first", + }; + + await expect(canUseTool("Bash", { command: "echo first" }, firstRequest)).resolves.toEqual({ + behavior: "allow", + updatedInput: { command: "echo first" }, + }); + live.streams[0]?.write(SUCCESS_RESULT); + await first; + + await expect(canUseTool("Bash", { command: "echo stale" }, firstRequest)).resolves.toEqual({ + behavior: "deny", + message: "The OpenClaw run is no longer active.", + }); + + const second = collect( + createContext({ + prompt: "second", + requestToolPermission: secondApproval, + liveSession: capability, + }), + ); + await vi.waitFor(() => expect(live.prompts[0]).toHaveLength(2)); + await expect( + canUseTool( + "Bash", + { command: "echo second" }, + { + signal: new AbortController().signal, + toolUseID: "native-turn-second", + }, + ), + ).resolves.toEqual({ + behavior: "deny", + message: "The second admitted turn denied native execution.", + }); + live.streams[0]?.write(SUCCESS_RESULT); + await second; + + expect(firstApproval).toHaveBeenCalledOnce(); + expect(secondApproval).toHaveBeenCalledOnce(); + expect(queryMock).toHaveBeenCalledOnce(); + }); + + it("rejects an approval that resolves after its exact admitted turn has already ended", async () => { + const live = useLiveSdkStreams(); + const capability = createLiveCapability(); + let resolveApproval: + | ((decision: { behavior: "allow"; updatedInput: Record }) => void) + | undefined; + const requestToolPermission = vi.fn( + () => + new Promise<{ behavior: "allow"; updatedInput: Record }>((resolve) => { + resolveApproval = resolve; + }), + ); + const running = collect(createContext({ requestToolPermission, liveSession: capability })); + await vi.waitFor(() => expect(queryMock).toHaveBeenCalledOnce()); + const approval = sdkNativeTool(sdkOptions())( + "Bash", + { command: "echo late" }, + { signal: new AbortController().signal, toolUseID: "late-native-approval" }, + ); + await vi.waitFor(() => expect(requestToolPermission).toHaveBeenCalledOnce()); + + live.streams[0]?.write(SUCCESS_RESULT); + await running; + resolveApproval?.({ behavior: "allow", updatedInput: { command: "echo late" } }); + + await expect(approval).resolves.toEqual({ + behavior: "deny", + message: "The OpenClaw run is no longer active.", + }); + }); + + it("holds provisional synthetic results until the real background-agent answer arrives", async () => { + const live = useLiveSdkStreams(); + const capability = createLiveCapability(); + const observed: Record[] = []; + let settled = false; + const result = (async () => { + for await (const event of executeClaudeAgentSdk(createContext({ liveSession: capability }))) { + observed.push(event); + } + settled = true; + return observed; + })(); + await vi.waitFor(() => expect(queryMock).toHaveBeenCalledOnce()); + const stream = live.streams[0]; + expect(stream).toBeDefined(); + + stream?.write({ + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "background-agent", task_type: "local_agent" }], + }); + stream?.write({ + type: "assistant", + message: { + model: "", + content: [{ type: "text", text: "No response requested." }], + }, + }); + stream?.write({ ...SUCCESS_RESULT, result: "" }); + await vi.waitFor(() => expect(observed).toHaveLength(3)); + expect(settled).toBe(false); + + stream?.write({ type: "system", subtype: "background_tasks_changed", tasks: [] }); + stream?.write({ ...SUCCESS_RESULT, result: "background answer" }); + + await expect(result).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "result", result: "background answer" }), + ]), + ); + expect(observed.at(-1)).toEqual(expect.objectContaining({ result: "background answer" })); + expect(live.closes[0]).not.toHaveBeenCalled(); + }); + + it("keeps restricted native tools and MCP grants inside the exact host-owned surface", async () => { + useSdkMessages(); + const context = createContext({ + args: [ + "-p", + "--setting-sources", + "", + "--strict-mcp-config", + "--mcp-config", + "/tmp/openclaw-restricted-mcp.json", + "--tools", + "", + "--allowedTools", + "mcp__openclaw__message", + "--disallowedTools", + "Bash", + "Edit", + "Write", + ], + toolAvailability: { + native: [], + openClaw: ["message"], + }, + }); + + await collect(context); + + expect(sdkOptions()).toEqual( + expect.objectContaining({ + tools: [], + allowedTools: ["mcp__openclaw__message"], + disallowedTools: ["Bash", "Edit", "Write"], + settingSources: [], + strictMcpConfig: true, + }), + ); + expect(sdkOptions().allowedTools).not.toContain("Bash"); + expect(sdkOptions()).not.toHaveProperty("mcpServers"); + expect(sdkOptions().extraArgs).toEqual( + expect.objectContaining({ "mcp-config": "/tmp/openclaw-restricted-mcp.json" }), + ); + expect( + JSON.stringify({ mcpServers: sdkOptions().mcpServers, extraArgs: sdkOptions().extraArgs }), + ).not.toContain(context.env.OPENCLAW_MCP_TOKEN); + }); + + it("preserves variadic directories, tools, and managed plugin MCP isolation", async () => { + useSdkMessages(); + + await collect( + createContext({ + args: [ + "-p", + "--add-dir", + "/tmp/a", + "/tmp/b", + "--add-dir=/tmp/c", + "--tools", + "Read", + "Grep", + "--plugin-dir", + "/tmp/openclaw-native-skills", + "--plugin-dir-no-mcp", + "/tmp/openclaw-isolated-skills", + ], + }), + ); + + expect(sdkOptions().plugins).toEqual([ + { type: "local", path: "/tmp/openclaw-native-skills" }, + { type: "local", path: "/tmp/openclaw-isolated-skills", skipMcpDiscovery: true }, + ]); + expect(sdkOptions().additionalDirectories).toEqual(["/tmp/a", "/tmp/b", "/tmp/c"]); + expect(sdkOptions().tools).toEqual(["Read", "Grep"]); + }); + + it.each([ + { + name: "unsafe project settings", + args: ["-p", "--setting-sources", "project"], + error: "Claude Agent SDK settings must be limited to user settings.", + }, + { + name: "a missing private MCP configuration path", + args: ["-p", "--mcp-config"], + error: "Claude Agent SDK cannot preserve --mcp-config without its value", + }, + ])("rejects $name before starting the Claude subprocess", async ({ args, error }) => { + await expect(collect(createContext({ args }))).rejects.toThrow(error); + expect(queryMock).not.toHaveBeenCalled(); + }); + + it("expands wildcard MCP grants into only the exact tools admitted by OpenClaw", async () => { + useSdkMessages(); + + await collect( + createContext({ + args: ["-p", "--allowedTools", "Bash", "mcp__openclaw__*", "Edit"], + toolAvailability: { + native: [], + openClaw: ["message", "memory_search"], + }, + }), + ); + + expect(sdkOptions()).toEqual( + expect.objectContaining({ + tools: [], + allowedTools: ["mcp__openclaw__message", "mcp__openclaw__memory_search"], + }), + ); + expect(sdkOptions().allowedTools).not.toContain("mcp__openclaw__*"); + expect(sdkOptions().allowedTools).not.toContain("Bash"); + expect(sdkOptions().allowedTools).not.toContain("Edit"); + }); + + it("enforces native tool policy before user settings can shadow the permission callback", async () => { + const requestToolPermission = vi.fn(async () => ({ + behavior: "deny" as const, + message: "The session policy denied native execution.", + })); + let nativeDecision: unknown; + let gatewayDecision: unknown; + let malformedDecision: unknown; + useSdkMessages([SUCCESS_RESULT], async (options) => { + const hook = sdkPreToolUse(options); + const signal = new AbortController().signal; + + nativeDecision = await hook( + { + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command: "cat private.txt" }, + tool_use_id: "native-tool-shadowed", + }, + "native-tool-shadowed", + { signal }, + ); + gatewayDecision = await hook( + { + hook_event_name: "PreToolUse", + tool_name: "mcp__openclaw__message", + tool_input: { action: "send" }, + tool_use_id: "gateway-tool-owned", + }, + "gateway-tool-owned", + { signal }, + ); + malformedDecision = await hook( + { + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: "not-an-object", + tool_use_id: "malformed-native-tool", + }, + "malformed-native-tool", + { signal }, + ); + }); + + await collect(createContext({ requestToolPermission })); + + expect(nativeDecision).toEqual({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "The session policy denied native execution.", + }, + }); + expect(gatewayDecision).toEqual({ continue: true }); + expect(malformedDecision).toEqual({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "OpenClaw rejected malformed native tool input.", + }, + }); + expect(requestToolPermission).toHaveBeenCalledOnce(); + expect(requestToolPermission).toHaveBeenCalledWith({ + toolName: "Bash", + toolInput: { command: "cat private.txt" }, + toolCallId: "native-tool-shadowed", + abortSignal: expect.any(AbortSignal), + }); + }); + + it("keeps bypass-shaped backend arguments behind the host permission callback", async () => { + const requestToolPermission = vi.fn(async () => ({ + behavior: "deny" as const, + message: "The session policy denied native execution.", + })); + let decision: unknown; + useSdkMessages([SUCCESS_RESULT], async (options) => { + decision = await sdkNativeTool(options)( + "Bash", + { command: "cat private.txt" }, + { + signal: new AbortController().signal, + toolUseID: "native-tool-bypass", + requestId: "approval-bypass", + }, + ); + }); + + await collect( + createContext({ + args: ["-p", "--permission-mode", "bypassPermissions"], + requestToolPermission, + }), + ); + + expect(sdkOptions().permissionMode).toBe("default"); + expect(sdkOptions()).not.toHaveProperty("allowDangerouslySkipPermissions"); + expect(decision).toEqual({ + behavior: "deny", + message: "The session policy denied native execution.", + }); + expect(requestToolPermission).toHaveBeenCalledOnce(); + }); + + it.each([ + { + name: "forwards allowed decisions and exact host inputs", + resolve: async () => ({ + behavior: "allow" as const, + updatedInput: { command: "echo approved" }, + }), + expected: { behavior: "allow", updatedInput: { command: "echo approved" } }, + }, + { + name: "preserves a denied host decision", + resolve: async () => ({ + behavior: "deny" as const, + message: "OpenClaw exec policy denied this action.", + }), + expected: { behavior: "deny", message: "OpenClaw exec policy denied this action." }, + }, + { + name: "fails closed when the host approval owner is unavailable", + resolve: async () => { + throw new Error("The Gateway approval owner is unavailable."); + }, + expected: { behavior: "deny", message: "OpenClaw could not authorize this tool call." }, + }, + ])("$name and fences the retained callback after closure", async ({ resolve, expected }) => { + const requestToolPermission = vi.fn(resolve); + const signal = new AbortController().signal; + const input = { command: "echo approved" }; + let decision: unknown; + let callback: SdkNativeToolCallback | undefined; + useSdkMessages([SUCCESS_RESULT], async (options) => { + callback = sdkNativeTool(options); + decision = await callback("Bash", input, { + signal, + toolUseID: "native-tool-1", + requestId: "approval-1", + }); + }); + + await collect(createContext({ requestToolPermission })); + + expect(decision).toEqual(expected); + expect(requestToolPermission).toHaveBeenCalledWith({ + toolName: "Bash", + toolInput: input, + toolCallId: "native-tool-1", + abortSignal: signal, + }); + await expect( + callback?.( + "Bash", + { command: "echo stale" }, + { + signal, + toolUseID: "native-tool-stale", + }, + ), + ).resolves.toEqual({ + behavior: "deny", + message: "The OpenClaw run is no longer active.", + }); + expect(requestToolPermission).toHaveBeenCalledOnce(); + }); + + it.each([429, 529])( + "yields an HTTP %i error-marked success before surfacing the SDK's later exit error", + async (apiErrorStatus) => { + const result = { + type: "result", + subtype: "success", + is_error: true, + api_error_status: apiErrorStatus, + result: "Claude subscription returned an upstream error.", + session_id: SESSION_ID, + }; + const exitError = new Error("Claude Code returned an error result."); + queryMock.mockImplementation(() => + Object.assign( + (async function* () { + yield result; + throw exitError; + })(), + { close: vi.fn() }, + ), + ); + const observed: Record[] = []; + const running = (async () => { + for await (const event of executeClaudeAgentSdk(createContext())) { + observed.push(event); + } + })(); + + await expect(running).rejects.toBe(exitError); + expect(observed).toContainEqual(result); + }, + ); + + it("fails closed when the official SDK exits without a terminal result", async () => { + useSdkMessages([]); + + await expect(collect(createContext())).rejects.toThrow( + "Claude Agent SDK exited without a terminal result.", + ); + }); +}); diff --git a/extensions/anthropic/agent-sdk.runtime.ts b/extensions/anthropic/agent-sdk.runtime.ts new file mode 100644 index 000000000000..61e86f8f8766 --- /dev/null +++ b/extensions/anthropic/agent-sdk.runtime.ts @@ -0,0 +1,706 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { PassThrough, Writable } from "node:stream"; +import type { + Options as ClaudeAgentSdkOptions, + PermissionResult as ClaudeAgentSdkPermissionResult, + Query as ClaudeAgentSdkQuery, + SDKUserMessage as ClaudeAgentSdkUserMessage, + SpawnOptions as ClaudeAgentSdkSpawnOptions, + SpawnedProcess as ClaudeAgentSdkSpawnedProcess, +} from "@anthropic-ai/claude-agent-sdk"; +import type { + CliBackendExecuteContext, + CliBackendLiveSessionCapability, + CliBackendLiveSessionCloseReason, + CliBackendLiveSessionHandle, +} from "openclaw/plugin-sdk/cli-backend"; +import { killProcessTree } from "openclaw/plugin-sdk/process-runtime"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; + +const CLAUDE_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"] satisfies NonNullable< + ClaudeAgentSdkOptions["effort"] +>[]; +const CLAUDE_STREAM_PROTOCOL_FLAGS = new Set([ + "-p", + "--print", + "--verbose", + "--include-partial-messages", +]); +const CLAUDE_STREAM_PROTOCOL_VALUE_FLAGS = new Set([ + "--output-format", + "--input-format", + "--model", + "--session-id", + "--resume", + "-r", + "--append-system-prompt-file", + "--append-system-prompt", + "--system-prompt-file", + "--system-prompt", +]); +const CLAUDE_VALUE_FLAGS = new Set([ + ...CLAUDE_STREAM_PROTOCOL_VALUE_FLAGS, + "--setting-sources", + "--allowedTools", + "--allowed-tools", + "--disallowedTools", + "--disallowed-tools", + "--tools", + "--add-dir", + "--permission-mode", + "--effort", + "--mcp-config", + "--resume-session-at", + "--max-turns", + "--plugin-dir", + "--plugin-dir-no-mcp", +]); +const CLAUDE_VARIADIC_VALUE_FLAGS = new Set([ + "--allowedTools", + "--allowed-tools", + "--disallowedTools", + "--disallowed-tools", + "--tools", + "--add-dir", +]); +const CLAUDE_LIVE_IDLE_TIMEOUT_MS = 10 * 60 * 1_000; +const RESULT_HOLDING_BACKGROUND_TASK_TYPES = new Set(["local_agent", "local_workflow"]); + +type ClaudeAgentSdkSecretInput = { + fd: 3; + createData: () => Buffer; +}; + +type ClaudeAgentSdkTurn = { + context: CliBackendExecuteContext; + controller: AbortController; +}; + +type ClaudeAgentSdkLiveTurn = ClaudeAgentSdkTurn & { + events: PassThrough; + sawTerminalResult: boolean; + error?: Error; +}; + +type ClaudeAgentSdkSession = { + handle: CliBackendLiveSessionHandle; + capability: CliBackendLiveSessionCapability; + controller: AbortController; + prompts: PassThrough; + currentTurn?: ClaudeAgentSdkLiveTurn; + query?: ClaudeAgentSdkQuery; + idleTimer?: ReturnType; + hasResultHoldingBackgroundTasks: boolean; + closed: boolean; + resolveExit: () => void; + exited: Promise; +}; + +const claudeAgentSdkSessions = new WeakMap(); + +function splitClaudeToolNames(value: string): string[] { + return value + .split(",") + .map((name) => name.trim()) + .filter(Boolean); +} + +function spawnClaudeAgentSdkProcess( + options: ClaudeAgentSdkSpawnOptions, + secretInput?: ClaudeAgentSdkSecretInput, +): ClaudeAgentSdkSpawnedProcess { + const child = spawn(options.command, options.args, { + cwd: options.cwd, + detached: process.platform !== "win32", + env: options.env, + signal: options.signal, + stdio: secretInput + ? ["pipe", "pipe", "pipe", process.platform === "win32" ? "overlapped" : "pipe"] + : ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + // The SDK only drains stderr for its built-in spawner; unread custom pipes + // fill at 64 KiB and deadlock credential-backed Claude processes. + child.stderr.resume(); + const killChild = child.kill.bind(child); + child.kill = (signal?: NodeJS.Signals | number) => { + if (!child.pid || (signal !== undefined && signal !== "SIGTERM" && signal !== "SIGKILL")) { + return killChild(signal); + } + // Windows must enumerate descendants before the root disappears; POSIX + // children own a detached group so cancellation never reaches the host. + killProcessTree(child.pid, { + detached: process.platform !== "win32", + ...(signal === "SIGKILL" ? { force: true } : {}), + }); + return true; + }; + if (!secretInput) { + return child; + } + let credential: Buffer | undefined; + try { + const descriptor = child.stdio[secretInput.fd]; + if (!(descriptor instanceof Writable)) { + throw new Error(`Claude Agent SDK secret descriptor ${secretInput.fd} is unavailable.`); + } + credential = secretInput.createData(); + const rejectDelivery = () => { + credential?.fill(0); + child.kill(); + }; + descriptor.on("error", rejectDelivery); + descriptor.once("close", () => descriptor.off("error", rejectDelivery)); + descriptor.end(credential, (error?: Error | null) => { + credential?.fill(0); + if (error) { + child.kill(); + } + }); + return child; + } catch (error) { + credential?.fill(0); + child.kill(); + throw error; + } +} + +async function authorizeClaudeAgentSdkTool(params: { + currentTurn: () => ClaudeAgentSdkTurn | undefined; + toolName: string; + input: Record; + signal: AbortSignal; + toolUseId?: string; +}): Promise { + const turn = params.currentTurn(); + if (!turn || params.signal.aborted || turn.controller.signal.aborted) { + return { behavior: "deny", message: "The OpenClaw run is no longer active." }; + } + try { + const decision = await turn.context.requestToolPermission({ + toolName: params.toolName, + toolInput: params.input, + ...(params.toolUseId ? { toolCallId: params.toolUseId } : {}), + abortSignal: params.signal, + }); + if (params.currentTurn() !== turn || params.signal.aborted || turn.controller.signal.aborted) { + return { behavior: "deny", message: "The OpenClaw run is no longer active." }; + } + return decision.behavior === "allow" + ? { behavior: "allow", updatedInput: decision.updatedInput } + : decision; + } catch { + return { behavior: "deny", message: "OpenClaw could not authorize this tool call." }; + } +} + +function resolveClaudeAgentSdkOptions( + context: CliBackendExecuteContext, + abortController: AbortController, + currentTurn: () => ClaudeAgentSdkTurn | undefined, + secretInput?: ClaudeAgentSdkSecretInput, +): ClaudeAgentSdkOptions { + const options: ClaudeAgentSdkOptions = { + abortController, + cwd: context.cwd, + env: context.env, + includePartialMessages: true, + model: context.modelId, + pathToClaudeCodeExecutable: context.command, + permissionMode: "default", + settingSources: ["user"], + spawnClaudeCodeProcess: (spawnOptions) => spawnClaudeAgentSdkProcess(spawnOptions, secretInput), + systemPrompt: { + type: "preset", + preset: "claude_code", + append: context.systemPrompt, + }, + canUseTool: (toolName, input, request) => + authorizeClaudeAgentSdkTool({ + currentTurn, + toolName, + input, + signal: request.signal, + toolUseId: request.toolUseID, + }), + hooks: { + PreToolUse: [ + { + hooks: [ + async (input, toolUseId, request) => { + if (input.hook_event_name !== "PreToolUse") { + return {}; + } + if (input.tool_name.startsWith("mcp__openclaw__")) { + return { continue: true }; + } + if (!isRecord(input.tool_input)) { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "OpenClaw rejected malformed native tool input.", + }, + }; + } + // Settings-level allow rules run before canUseTool. A native + // pre-tool hook keeps every action under its admitted run owner. + const decision = await authorizeClaudeAgentSdkTool({ + currentTurn, + toolName: input.tool_name, + input: input.tool_input, + signal: request.signal, + toolUseId: toolUseId ?? input.tool_use_id, + }); + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: decision.behavior, + ...(decision.behavior === "allow" + ? { updatedInput: decision.updatedInput } + : { permissionDecisionReason: decision.message }), + }, + }; + }, + ], + }, + ], + }, + }; + + if (context.useResume && context.sessionId) { + options.resume = context.sessionId; + } else if (context.sessionId) { + options.sessionId = context.sessionId; + } + + const allowedTools: string[] = []; + const disallowedTools: string[] = []; + const extraArgs: NonNullable = {}; + let excludeDynamicSystemPromptSections = false; + + for (let index = 0; index < context.args.length; index += 1) { + const rawArgument = context.args[index] ?? ""; + const equalsIndex = rawArgument.indexOf("="); + const argument = equalsIndex === -1 ? rawArgument : rawArgument.slice(0, equalsIndex); + const inlineValue = equalsIndex === -1 ? undefined : rawArgument.slice(equalsIndex + 1); + + if (CLAUDE_STREAM_PROTOCOL_FLAGS.has(argument)) { + continue; + } + let value = inlineValue ?? ""; + if (CLAUDE_VALUE_FLAGS.has(argument) && inlineValue === undefined) { + const next = context.args[index + 1]; + if (next === undefined) { + throw new Error(`Claude Agent SDK cannot preserve ${argument} without its value`); + } + value = next; + index += 1; + } + const values = [value]; + if (CLAUDE_VARIADIC_VALUE_FLAGS.has(argument) && inlineValue === undefined) { + while (index + 1 < context.args.length && !context.args[index + 1]?.startsWith("-")) { + values.push(context.args[index + 1] ?? ""); + index += 1; + } + } + if (CLAUDE_STREAM_PROTOCOL_VALUE_FLAGS.has(argument)) { + continue; + } + + switch (argument) { + case "--setting-sources": { + if (value !== "" && value !== "user") { + throw new Error("Claude Agent SDK settings must be limited to user settings."); + } + options.settingSources = value === "" ? [] : ["user"]; + break; + } + case "--allowedTools": + case "--allowed-tools": { + // SDK allowedTools grants automatic approval; native tools must always + // remain behind the closure-bound OpenClaw permission callback. + allowedTools.push( + ...values + .flatMap(splitClaudeToolNames) + .filter((toolName) => toolName.startsWith("mcp__openclaw__")), + ); + break; + } + case "--disallowedTools": + case "--disallowed-tools": { + disallowedTools.push(...values.flatMap(splitClaudeToolNames)); + break; + } + case "--tools": { + options.tools = values.flatMap(splitClaudeToolNames); + break; + } + case "--add-dir": { + options.additionalDirectories ??= []; + options.additionalDirectories.push(...values); + break; + } + case "--permission-mode": { + // Global argv can request bypass, auto, or accepted edits while the + // admitted session narrows authority. Only the host callback decides. + break; + } + case "--effort": { + const effort = CLAUDE_EFFORT_LEVELS.find((level) => level === value); + if (!effort) { + throw new Error(`Unsupported Claude Agent SDK effort: ${value}`); + } + options.effort = effort; + break; + } + case "--mcp-config": { + // The generated config contains a private gateway bearer. Keep its + // existing file boundary; SDK mcpServers would expose it in argv. + extraArgs["mcp-config"] = value; + break; + } + case "--strict-mcp-config": + options.strictMcpConfig = true; + break; + case "--fork-session": + options.forkSession = true; + break; + case "--resume-session-at": { + options.resumeSessionAt = value; + break; + } + case "--no-session-persistence": + options.persistSession = false; + break; + case "--max-turns": { + const maxTurns = Number(value); + if (!Number.isSafeInteger(maxTurns) || maxTurns < 1) { + throw new Error(`Unsupported Claude Agent SDK max-turns value: ${value}`); + } + options.maxTurns = maxTurns; + break; + } + case "--plugin-dir": + case "--plugin-dir-no-mcp": { + options.plugins ??= []; + options.plugins.push({ + type: "local", + path: value, + ...(argument === "--plugin-dir-no-mcp" ? { skipMcpDiscovery: true } : {}), + }); + break; + } + case "--exclude-dynamic-system-prompt-sections": + excludeDynamicSystemPromptSections = true; + break; + default: { + if (!argument.startsWith("--")) { + throw new Error(`Claude Agent SDK cannot preserve positional argument: ${argument}`); + } + const name = argument.slice(2); + if (inlineValue !== undefined) { + extraArgs[name] = inlineValue; + break; + } + const next = context.args[index + 1]; + if (next !== undefined && !next.startsWith("-")) { + extraArgs[name] = next; + index += 1; + } else { + extraArgs[name] = null; + } + } + } + } + + if (context.toolAvailability) { + options.tools = [...context.toolAvailability.native]; + const approvedOpenClawTools = context.toolAvailability.openClaw.map( + (toolName) => `mcp__openclaw__${toolName}`, + ); + const authorizedOpenClawTools = new Set(allowedTools); + options.allowedTools = approvedOpenClawTools.filter( + (toolName) => + authorizedOpenClawTools.has(toolName) || authorizedOpenClawTools.has("mcp__openclaw__*"), + ); + } else if (allowedTools.length > 0) { + options.allowedTools = [...new Set(allowedTools)]; + } + if (disallowedTools.length > 0) { + options.disallowedTools = [...new Set(disallowedTools)]; + } + if (Object.keys(extraArgs).length > 0) { + options.extraArgs = extraArgs; + } + if (excludeDynamicSystemPromptSections) { + options.systemPrompt = { + type: "preset", + preset: "claude_code", + append: context.systemPrompt, + excludeDynamicSections: true, + }; + } + return options; +} + +function createClaudeAgentSdkUserMessage( + context: CliBackendExecuteContext, +): ClaudeAgentSdkUserMessage { + return { + type: "user", + message: { role: "user", content: context.prompt }, + parent_tool_use_id: null, + uuid: randomUUID(), + ...(context.sessionId ? { session_id: context.sessionId } : {}), + }; +} + +function closeClaudeAgentSdkSession( + session: ClaudeAgentSdkSession, + _reason: CliBackendLiveSessionCloseReason, + error?: unknown, +): void { + if (session.closed) { + return; + } + session.closed = true; + clearTimeout(session.idleTimer); + session.capability.remove(session.handle); + + const turn = session.currentTurn; + session.currentTurn = undefined; + if (turn) { + turn.error = + error instanceof Error ? error : new Error("Claude Agent SDK live session closed."); + turn.controller.abort(); + turn.events.end(); + } + session.controller.abort(); + session.prompts.end(); + session.query?.close(); + if (!session.query) { + session.resolveExit(); + } +} + +function completeClaudeAgentSdkTurn(session: ClaudeAgentSdkSession): void { + const turn = session.currentTurn; + if (!turn) { + return; + } + session.currentTurn = undefined; + turn.controller.abort(); + turn.events.end(); + session.idleTimer = setTimeout(() => { + session.handle.close("idle"); + }, CLAUDE_LIVE_IDLE_TIMEOUT_MS); + session.idleTimer.unref(); +} + +function acceptClaudeAgentSdkMessage( + session: ClaudeAgentSdkSession, + message: Record, +): void { + const turn = session.currentTurn; + if (!turn) { + return; + } + if (message.type === "system" && message.subtype === "background_tasks_changed") { + session.hasResultHoldingBackgroundTasks = ( + Array.isArray(message.tasks) ? message.tasks : [] + ).some( + (task) => + isRecord(task) && + typeof task.task_type === "string" && + RESULT_HOLDING_BACKGROUND_TASK_TYPES.has(task.task_type) && + typeof task.task_id === "string" && + task.task_id.length > 0, + ); + } + turn.events.write(message); + if (message.type === "result") { + turn.sawTerminalResult = true; + // Local agents/workflows emit an interim result before their final + // answer; keep the turn and capture grant alive until its final result. + if (!session.hasResultHoldingBackgroundTasks) { + completeClaudeAgentSdkTurn(session); + } + } +} + +async function consumeClaudeAgentSdkSession( + session: ClaudeAgentSdkSession, + query: ClaudeAgentSdkQuery, +): Promise { + try { + for await (const message of query) { + acceptClaudeAgentSdkMessage(session, { ...message }); + } + if (!session.closed) { + const error = new Error("Claude Agent SDK live session exited unexpectedly."); + session.handle.close("abort", error); + } + } catch (error) { + if (!session.closed) { + session.handle.close("abort", error); + } + } finally { + session.resolveExit(); + } +} + +function createClaudeAgentSdkSession( + capability: CliBackendLiveSessionCapability, +): ClaudeAgentSdkSession { + let resolveExit: () => void = () => {}; + const exited = new Promise((resolve) => { + resolveExit = resolve; + }); + const session: ClaudeAgentSdkSession = { + capability, + controller: new AbortController(), + prompts: new PassThrough({ objectMode: true }), + hasResultHoldingBackgroundTasks: false, + closed: false, + resolveExit, + exited, + handle: { + generation: randomUUID(), + fingerprint: capability.fingerprint, + isIdle: () => !session.closed && !session.currentTurn, + close: (reason, error) => closeClaudeAgentSdkSession(session, reason, error), + waitForExit: () => session.exited, + }, + }; + claudeAgentSdkSessions.set(session.handle, session); + capability.register(session.handle); + return session; +} + +async function* executeClaudeAgentSdkLiveTurn( + context: CliBackendExecuteContext, + capability: CliBackendLiveSessionCapability, + secretInput?: ClaudeAgentSdkSecretInput, +): AsyncIterable> { + const { query } = await import("@anthropic-ai/claude-agent-sdk"); + let existingHandle = capability.current(); + if (existingHandle && existingHandle.fingerprint !== capability.fingerprint) { + existingHandle.close("restart"); + await existingHandle.waitForExit(); + existingHandle = capability.current(); + } + + let session = existingHandle ? claudeAgentSdkSessions.get(existingHandle) : undefined; + if (existingHandle && (!session || session.closed)) { + existingHandle.close("restart"); + await existingHandle.waitForExit(); + session = undefined; + } + session ??= createClaudeAgentSdkSession(capability); + session.capability = capability; + if (session.currentTurn) { + throw new Error("Claude Agent SDK live session is already handling another turn."); + } + clearTimeout(session.idleTimer); + + const turn: ClaudeAgentSdkLiveTurn = { + context, + controller: new AbortController(), + events: new PassThrough({ objectMode: true }), + sawTerminalResult: false, + }; + session.currentTurn = turn; + const abort = () => session.handle.close("abort", context.abortSignal?.reason); + context.abortSignal?.addEventListener("abort", abort, { once: true }); + + try { + if (context.abortSignal?.aborted) { + abort(); + throw context.abortSignal.reason ?? new Error("Claude Agent SDK live turn was aborted."); + } + // Capture activation adopts this admitted turn onto the exact registered + // process bearer before either its prompt or any tool call can execute. + capability.activate(session.handle); + + if (!session.query) { + const options = resolveClaudeAgentSdkOptions( + context, + session.controller, + () => session.currentTurn, + secretInput, + ); + session.query = query({ prompt: session.prompts, options }); + void consumeClaudeAgentSdkSession(session, session.query); + } + if (session.closed || session.currentTurn !== turn) { + throw new Error("Claude Agent SDK live session closed before its prompt was accepted."); + } + session.prompts.write(createClaudeAgentSdkUserMessage(context)); + + for await (const record of turn.events) { + yield record; + } + if (turn.error) { + throw turn.error; + } + if (!turn.sawTerminalResult) { + throw new Error("Claude Agent SDK live turn exited without a terminal result."); + } + } catch (error) { + if (!session.closed) { + session.handle.close("abort", error); + } + throw error; + } finally { + turn.controller.abort(); + context.abortSignal?.removeEventListener("abort", abort); + } +} + +/** Execute Claude Code through Anthropic's maintained SDK transport and private auth boundary. */ +export async function* executeClaudeAgentSdk( + context: CliBackendExecuteContext, + secretInput?: ClaudeAgentSdkSecretInput, +): AsyncIterable> { + if (context.liveSession) { + yield* executeClaudeAgentSdkLiveTurn(context, context.liveSession, secretInput); + return; + } + + const { query } = await import("@anthropic-ai/claude-agent-sdk"); + const controller = new AbortController(); + let activeTurn: ClaudeAgentSdkTurn | undefined = { + context, + controller, + }; + let sawTerminalResult = false; + const abort = () => controller.abort(); + context.abortSignal?.addEventListener("abort", abort, { once: true }); + + try { + context.abortSignal?.throwIfAborted(); + const options = resolveClaudeAgentSdkOptions( + context, + controller, + () => activeTurn, + secretInput, + ); + for await (const message of query({ prompt: context.prompt, options })) { + if (message.type === "result") { + sawTerminalResult = true; + } + yield { ...message }; + } + if (!sawTerminalResult && !controller.signal.aborted) { + throw new Error("Claude Agent SDK exited without a terminal result."); + } + } finally { + activeTurn = undefined; + if (!controller.signal.aborted) { + controller.abort(); + } + context.abortSignal?.removeEventListener("abort", abort); + } +} diff --git a/extensions/anthropic/cli-backend.ts b/extensions/anthropic/cli-backend.ts index 9e5213932a59..2493ef986c08 100644 --- a/extensions/anthropic/cli-backend.ts +++ b/extensions/anthropic/cli-backend.ts @@ -4,6 +4,7 @@ */ import { createHmac, randomBytes } from "node:crypto"; import type { + CliBackendExecuteContext, CliBackendPlugin, CliBackendPreparedExecution, } from "openclaw/plugin-sdk/cli-backend"; @@ -41,6 +42,19 @@ type ClaudeCliPreparedExecution = CliBackendPreparedExecution & { }; const CLAUDE_CLI_CREDENTIAL_FINGERPRINT_KEY = randomBytes(32); +const CLAUDE_CLI_DEFAULT_ARGS = [ + "-p", + "--output-format", + "stream-json", + "--include-partial-messages", + "--verbose", + "--setting-sources", + "user", + "--allowedTools", + "mcp__openclaw__*", + "--disallowedTools", + "ScheduleWakeup,CronCreate,Bash(run_in_background:true),Monitor", +] as const; function createClaudeCliAuthInput(params: { envName: "CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR" | "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR"; @@ -143,14 +157,6 @@ export function buildAnthropicCliBackend( entrypoint: "command", nativeExecutableNames: ["claude", "claude.exe"], }, - // Claude Code 2.1.206 first shipped per-input lifecycle correlation. The - // runtime checks the advertised capability so backports and wrappers work. - liveSessionRequirement: { - capability: "msg_lifecycle_v1", - minimumVersion: "2.1.206", - versionArgs: ["--version"], - updateCommand: "claude update", - }, bundleMcp: true, bundleMcpMode: "claude-config-file", nativeToolMode: "selectable", @@ -196,34 +202,8 @@ export function buildAnthropicCliBackend( subscriptionAuthDispatch: true, config: { command: "claude", - args: [ - "-p", - "--output-format", - "stream-json", - "--include-partial-messages", - "--verbose", - "--setting-sources", - "user", - "--allowedTools", - "mcp__openclaw__*", - "--disallowedTools", - "ScheduleWakeup,CronCreate,Bash(run_in_background:true),Monitor", - ], - resumeArgs: [ - "-p", - "--output-format", - "stream-json", - "--include-partial-messages", - "--verbose", - "--setting-sources", - "user", - "--allowedTools", - "mcp__openclaw__*", - "--disallowedTools", - "ScheduleWakeup,CronCreate,Bash(run_in_background:true),Monitor", - "--resume", - "{sessionId}", - ], + args: [...CLAUDE_CLI_DEFAULT_ARGS], + resumeArgs: [...CLAUDE_CLI_DEFAULT_ARGS, "--resume", "{sessionId}"], forkArg: "--fork-session", // Claude Code 2.1.209+ exposes this hidden print-mode flag, and stream-json // emits the matching transcript UUID on assistant records. @@ -270,7 +250,16 @@ export function buildAnthropicCliBackend( ...resolveClaudeCliThinkingEnv(context.thinkingLevel, context.modelId), ...authInput?.env, }; - return Object.keys(env).length > 0 || isolatedCompletion + const agentSdkExecution = + !isolatedCompletion && context.executionMode === "agent" + ? { + async *execute(executionContext: CliBackendExecuteContext) { + const { executeClaudeAgentSdk } = await import("./agent-sdk.runtime.js"); + yield* executeClaudeAgentSdk(executionContext, authInput?.secretInput); + }, + } + : undefined; + return Object.keys(env).length > 0 || isolatedCompletion || agentSdkExecution ? { env, // The paired side-question argv projection disables settings, memory, @@ -279,6 +268,7 @@ export function buildAnthropicCliBackend( ...(authInput?.clearEnv ? { clearEnv: authInput.clearEnv } : {}), ...(authInput?.secretInput ? { secretInput: authInput.secretInput } : {}), ...(authInput?.cleanup ? { cleanup: authInput.cleanup } : {}), + ...agentSdkExecution, } : undefined; }; diff --git a/extensions/anthropic/cli-shared.test.ts b/extensions/anthropic/cli-shared.test.ts index d04bafb5bfd6..1b430aec28a2 100644 --- a/extensions/anthropic/cli-shared.test.ts +++ b/extensions/anthropic/cli-shared.test.ts @@ -311,7 +311,6 @@ describe("resolveClaudeCliExecutionArgs", () => { toolAvailability: { native: [], openClaw: ["openclaw"], - mcp: ["mcp__openclaw__openclaw"], }, }), ).toEqual([ @@ -387,7 +386,6 @@ describe("resolveClaudeCliExecutionArgs", () => { toolAvailability: { native: [], openClaw: ["message"], - mcp: ["mcp__openclaw__message"], }, }), ).toEqual([ @@ -451,7 +449,7 @@ describe("resolveClaudeCliExecutionArgs", () => { "--disallowedTools", "mcp__other__*", ], - toolAvailability: { native: [], openClaw: [], mcp: [] }, + toolAvailability: { native: [], openClaw: [] }, }), ).toEqual([ "-p", @@ -721,13 +719,6 @@ describe("normalizeClaudeBackendConfig", () => { entrypoint: "command", nativeExecutableNames: ["claude", "claude.exe"], }); - expect(backend.liveSessionRequirement).toEqual({ - capability: "msg_lifecycle_v1", - minimumVersion: "2.1.206", - versionArgs: ["--version"], - updateCommand: "claude update", - }); - const normalized = normalizeConfig?.({ ...backend.config, args: ["-p", "--output-format", "stream-json", "--verbose"], @@ -1016,16 +1007,20 @@ describe("normalizeClaudeBackendConfig", () => { ).toThrow("Selected Claude CLI OAuth credential is expired or invalid"); }); - it("keeps native Claude login when no compatible profile is selected", () => { + it("runs native Claude login through the official Agent SDK without forwarding credentials", () => { const backend = buildAnthropicCliBackend(); - expect( - backend.prepareExecution?.({ - workspaceDir: "/tmp/openclaw-claude-cli", - provider: "claude-cli", - modelId: "claude-opus-4-7", - }), - ).toBeUndefined(); + const prepared = backend.prepareExecution?.({ + workspaceDir: "/tmp/openclaw-claude-cli", + provider: "claude-cli", + modelId: "claude-opus-4-7", + executionMode: "agent", + }); + + expect(prepared).toEqual(expect.objectContaining({ execute: expect.any(Function) })); + expect(prepared).not.toHaveProperty("secretInput"); + expect(prepared).not.toHaveProperty("env.CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR"); + expect(prepared).not.toHaveProperty("env.CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR"); }); it("forwards a selected API-key profile through Claude's private descriptor", async () => { diff --git a/extensions/anthropic/cli-shared.ts b/extensions/anthropic/cli-shared.ts index 38bf97564472..5b697cbaef69 100644 --- a/extensions/anthropic/cli-shared.ts +++ b/extensions/anthropic/cli-shared.ts @@ -221,48 +221,37 @@ function isOpenClawRequestedYolo(context?: CliBackendNormalizeConfigContext): bo ); } -/** Resolve Claude permission mode from OpenClaw exec security settings. */ -function resolveClaudePermissionMode(context?: CliBackendNormalizeConfigContext): { - mode?: string; - overrideExisting: boolean; -} { - return isOpenClawRequestedYolo(context) - ? { mode: CLAUDE_BYPASS_PERMISSION_MODE, overrideExisting: false } - : { overrideExisting: false }; -} - -/** Normalize Claude permission arguments, removing legacy skip-permissions flags. */ -function normalizeClaudePermissionArgs( +/** Keep filesystem settings user-scoped and normalize native permission flags together. */ +function normalizeClaudeBackendArgs( args?: string[], - options?: { mode?: string; overrideExisting?: boolean }, + permissionMode?: string, ): string[] | undefined { if (!args) { - return options?.mode ? [CLAUDE_PERMISSION_MODE_ARG, options.mode] : args; + return permissionMode ? [CLAUDE_PERMISSION_MODE_ARG, permissionMode] : args; } const normalized: string[] = []; let hasPermissionMode = false; - let skipNext = false; - for (const [index, arg] of args.entries()) { - if (skipNext) { - skipNext = false; - continue; - } + let hasSettingSources = false; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] ?? ""; if (arg === CLAUDE_LEGACY_SKIP_PERMISSIONS_ARG) { continue; } - if (arg === CLAUDE_PERMISSION_MODE_ARG) { - const maybeValue = args.at(index + 1); + if (arg === CLAUDE_PERMISSION_MODE_ARG || arg === CLAUDE_SETTING_SOURCES_ARG) { + const maybeValue = args[index + 1]; if ( typeof maybeValue === "string" && maybeValue.trim().length > 0 && !maybeValue.startsWith("-") ) { - hasPermissionMode = true; - if (!options?.overrideExisting) { - normalized.push(arg); - normalized.push(maybeValue); + if (arg === CLAUDE_PERMISSION_MODE_ARG) { + hasPermissionMode = true; + normalized.push(arg, maybeValue); + } else { + hasSettingSources = true; + normalized.push(arg, CLAUDE_SAFE_SETTING_SOURCES); } - skipNext = true; + index += 1; } continue; } @@ -270,43 +259,7 @@ function normalizeClaudePermissionArgs( const maybeValue = arg.slice(`${CLAUDE_PERMISSION_MODE_ARG}=`.length).trim(); if (maybeValue.length > 0 && !maybeValue.startsWith("-")) { hasPermissionMode = true; - if (!options?.overrideExisting) { - normalized.push(`${CLAUDE_PERMISSION_MODE_ARG}=${maybeValue}`); - } - } - continue; - } - normalized.push(arg); - } - if (options?.mode && (!hasPermissionMode || options.overrideExisting)) { - normalized.push(CLAUDE_PERMISSION_MODE_ARG, options.mode); - } - return normalized; -} - -/** Ensure Claude CLI setting sources stay restricted to user settings. */ -function normalizeClaudeSettingSourcesArgs(args?: string[]): string[] | undefined { - if (!args) { - return args; - } - const normalized: string[] = []; - let hasSettingSources = false; - let skipNext = false; - for (const [index, arg] of args.entries()) { - if (skipNext) { - skipNext = false; - continue; - } - if (arg === CLAUDE_SETTING_SOURCES_ARG) { - const maybeValue = args.at(index + 1); - if ( - typeof maybeValue === "string" && - maybeValue.trim().length > 0 && - !maybeValue.startsWith("-") - ) { - hasSettingSources = true; - normalized.push(arg, CLAUDE_SAFE_SETTING_SOURCES); - skipNext = true; + normalized.push(`${CLAUDE_PERMISSION_MODE_ARG}=${maybeValue}`); } continue; } @@ -320,6 +273,9 @@ function normalizeClaudeSettingSourcesArgs(args?: string[]): string[] | undefine if (!hasSettingSources) { normalized.push(CLAUDE_SETTING_SOURCES_ARG, CLAUDE_SAFE_SETTING_SOURCES); } + if (permissionMode && !hasPermissionMode) { + normalized.push(CLAUDE_PERMISSION_MODE_ARG, permissionMode); + } return normalized; } @@ -578,14 +534,13 @@ export function normalizeClaudeBackendConfig( ): CliBackendConfig { const output = config.output ?? "jsonl"; const input = config.input ?? "stdin"; - const permission = resolveClaudePermissionMode(context); + const permissionMode = isOpenClawRequestedYolo(context) + ? CLAUDE_BYPASS_PERMISSION_MODE + : undefined; return { ...config, - args: normalizeClaudePermissionArgs(normalizeClaudeSettingSourcesArgs(config.args), permission), - resumeArgs: normalizeClaudePermissionArgs( - normalizeClaudeSettingSourcesArgs(config.resumeArgs), - permission, - ), + args: normalizeClaudeBackendArgs(config.args, permissionMode), + resumeArgs: normalizeClaudeBackendArgs(config.resumeArgs, permissionMode), output, liveSession: config.liveSession ?? (output === "jsonl" && input === "stdin" ? "claude-stdio" : undefined), diff --git a/extensions/anthropic/package.json b/extensions/anthropic/package.json index 52a4e1a6116f..2d6fabcc93e0 100644 --- a/extensions/anthropic/package.json +++ b/extensions/anthropic/package.json @@ -4,6 +4,9 @@ "private": true, "description": "OpenClaw Anthropic provider, Claude CLI, and native session catalog plugin", "type": "module", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "0.3.219" + }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" }, diff --git a/extensions/google/cli-backend-auth.test.ts b/extensions/google/cli-backend-auth.test.ts index 88c500c7135f..eda8265bcacf 100644 --- a/extensions/google/cli-backend-auth.test.ts +++ b/extensions/google/cli-backend-auth.test.ts @@ -91,7 +91,7 @@ describe("google gemini cli backend auth bridge", () => { await expect( buildGoogleGeminiCliBackend().prepareExecution?.({ ...buildGeminiOAuthPrepareContext(workspaceDir), - toolAvailability: { native: [], openClaw: [], mcp: [] }, + toolAvailability: { native: [], openClaw: [] }, isolatedCompletionModelId: "gemini-3.1-flash-preview", isolatedCompletionSystemPrompt: "Return only JSON.", } as GeminiPrepareContext), @@ -109,7 +109,7 @@ describe("google gemini cli backend auth bridge", () => { await expect( buildGoogleGeminiCliBackend().prepareExecution?.({ ...buildGeminiOAuthPrepareContext(workspaceDir), - toolAvailability: { native: [], openClaw: [], mcp: [] }, + toolAvailability: { native: [], openClaw: [] }, } as GeminiPrepareContext), ).rejects.toThrow("Code Assist auth can inject administrator-required tools"); }); @@ -130,7 +130,7 @@ describe("google gemini cli backend auth bridge", () => { provider: "google-gemini-cli", modelId: "gemini-3.1-flash-preview", env: { GEMINI_CLI_HOME: ambientHome }, - toolAvailability: { native: [], openClaw: [], mcp: [] }, + toolAvailability: { native: [], openClaw: [] }, } as GeminiPrepareContext), ).rejects.toThrow("Code Assist auth can inject administrator-required tools"); }); @@ -147,7 +147,7 @@ describe("google gemini cli backend auth bridge", () => { provider: "google-gemini-cli", modelId: "gemini-3.1-pro-preview", env: { GEMINI_API_KEY: "prepared-key" }, - toolAvailability: { native: [], openClaw: [], mcp: [] }, + toolAvailability: { native: [], openClaw: [] }, }); expect(prepared?.env?.GEMINI_API_KEY).toBe("prepared-key"); expect(prepared?.env?.GOOGLE_GENAI_USE_GCA).toBe("false"); @@ -185,7 +185,7 @@ describe("google gemini cli backend auth bridge", () => { workspaceDir, provider: "google-gemini-cli", modelId: "gemini-3.1-flash-preview", - toolAvailability: { native: [], openClaw: [], mcp: [] }, + toolAvailability: { native: [], openClaw: [] }, isolatedCompletionModelId: "gemini-3.1-flash-preview", isolatedCompletionSystemPrompt: "Return only JSON.", } as GeminiPrepareContext); @@ -229,7 +229,7 @@ describe("google gemini cli backend auth bridge", () => { workspaceDir: projectDir, provider: "google-gemini-cli", modelId: "gemini-3.1-flash-preview", - toolAvailability: { native: [], openClaw: [], mcp: [] }, + toolAvailability: { native: [], openClaw: [] }, isolatedCompletionModelId: "gemini-3.1-flash-preview", isolatedCompletionSystemPrompt: "Return only JSON.", } as GeminiPrepareContext); @@ -262,7 +262,7 @@ describe("google gemini cli backend auth bridge", () => { workspaceDir, provider: "google-gemini-cli", modelId: "gemini-3.1-flash-preview", - toolAvailability: { native: [], openClaw: [], mcp: [] }, + toolAvailability: { native: [], openClaw: [] }, isolatedCompletionModelId: "gemini-3.1-flash-preview", isolatedCompletionSystemPrompt: "Return only JSON.", } as GeminiPrepareContext); @@ -297,7 +297,7 @@ describe("google gemini cli backend auth bridge", () => { workspaceDir, provider: "google-gemini-cli", modelId: "gemini-3.1-flash-preview", - toolAvailability: { native: [], openClaw: [], mcp: [] }, + toolAvailability: { native: [], openClaw: [] }, isolatedCompletionModelId: "gemini-3.1-flash-preview", isolatedCompletionSystemPrompt: "Return only JSON.", } as GeminiPrepareContext); @@ -319,7 +319,7 @@ describe("google gemini cli backend auth bridge", () => { buildGoogleGeminiCliBackend().prepareExecution?.({ ...buildGeminiApiKeyPrepareContext(workspaceDir), modelId: "auto", - toolAvailability: { native: [], openClaw: [], mcp: [] }, + toolAvailability: { native: [], openClaw: [] }, isolatedCompletionModelId: "auto", isolatedCompletionSystemPrompt: "Return only JSON.", } as GeminiPrepareContext), @@ -388,7 +388,6 @@ describe("google gemini cli backend auth bridge", () => { context.toolAvailability = { native: [], openClaw: [...allowed], - mcp: allowed.map((toolName) => `mcp__openclaw__${toolName}`), }; const prepared = await backend.prepareExecution?.(context); const preparedHome = prepared?.env?.GEMINI_CLI_HOME ?? ""; @@ -475,7 +474,7 @@ describe("google gemini cli backend auth bridge", () => { GEMINI_API_KEY: "ambient-key", GEMINI_CLI_SYSTEM_SETTINGS_PATH: inheritedSettingsPath, }, - toolAvailability: { native: ["run_shell_command"], openClaw: [], mcp: [] }, + toolAvailability: { native: ["run_shell_command"], openClaw: [] }, }), ).rejects.toThrow("cannot expose backend-native tools"); }); @@ -507,7 +506,7 @@ describe("google gemini cli backend auth bridge", () => { GEMINI_API_KEY: "ambient-key", GEMINI_CLI_SYSTEM_SETTINGS_PATH: inheritedSettingsPath, }, - toolAvailability: { native: [], openClaw: [], mcp: [] }, + toolAvailability: { native: [], openClaw: [] }, }); try { expect(prepared?.toolAvailabilityEnforced).toBe(true); @@ -1021,7 +1020,7 @@ describe("google gemini cli backend auth bridge", () => { try { const context = buildGeminiApiKeyPrepareContext(workspaceDir); context.env = { GEMINI_CLI_HOME: ambientHome }; - context.toolAvailability = { native: [], openClaw: [], mcp: [] }; + context.toolAvailability = { native: [], openClaw: [] }; mkdtempSpy.mockClear(); const preparation = backend.prepareExecution?.(context); await expect(preparation).rejects.not.toBeInstanceOf(CliBackendAuthProfilePreparationError); diff --git a/extensions/google/cli-backend-isolated.test.ts b/extensions/google/cli-backend-isolated.test.ts index f8353d99cc54..fcb1b9095923 100644 --- a/extensions/google/cli-backend-isolated.test.ts +++ b/extensions/google/cli-backend-isolated.test.ts @@ -66,7 +66,7 @@ describe("Gemini CLI isolated completion", () => { provider: "vercel-ai-gateway", key: "vercel-key", }, - toolAvailability: { native: [], openClaw: [], mcp: [] }, + toolAvailability: { native: [], openClaw: [] }, isolatedCompletionCwd: workspaceDir, isolatedCompletionModelId: "gemini-3.1-flash-lite", isolatedCompletionPrompt: "Return JSON.", @@ -132,7 +132,7 @@ describe("Gemini CLI isolated completion", () => { GEMINI_CLI_SYSTEM_SETTINGS_PATH: inheritedSettingsPath, GEMINI_WRITE_SYSTEM_MD: inheritedSystemPromptWritePath, }; - context.toolAvailability = { native: [], openClaw: [], mcp: [] }; + context.toolAvailability = { native: [], openClaw: [] }; context.isolatedCompletionCwd = isolatedCompletionCwd; context.isolatedCompletionModelId = "gemini-3.1-flash-preview"; context.isolatedCompletionPrompt = "TASK:\nReturn one JSON object."; @@ -229,7 +229,7 @@ describe("Gemini CLI isolated completion", () => { await withTempDir("openclaw-test-workspace-", async (workspaceDir) => { const context: GeminiPrepareContext = { ...buildGeminiApiKeyPrepareContext(workspaceDir), - toolAvailability: { native: [], openClaw: [], mcp: [] }, + toolAvailability: { native: [], openClaw: [] }, isolatedCompletionModelId: "gemini-3.1-flash-preview", isolatedCompletionSystemPrompt: systemPrompt, }; @@ -255,7 +255,7 @@ describe("Gemini CLI isolated completion", () => { await expect( buildGoogleGeminiCliBackend().prepareExecution?.({ ...buildGeminiApiKeyPrepareContext(workspaceDir), - toolAvailability: { native: [], openClaw: [], mcp: [] }, + toolAvailability: { native: [], openClaw: [] }, isolatedCompletionModelId: "gemini-3.1-flash-preview", isolatedCompletionPrompt: prompt, isolatedCompletionSystemPrompt: "Return only JSON.", @@ -271,7 +271,7 @@ describe("Gemini CLI isolated completion", () => { await withTempDir("openclaw-test-workspace-", async (workspaceDir) => { const prepared = await buildGoogleGeminiCliBackend().prepareExecution?.({ ...buildGeminiApiKeyPrepareContext(workspaceDir), - toolAvailability: { native: [], openClaw: [], mcp: [] }, + toolAvailability: { native: [], openClaw: [] }, isolatedCompletionModelId: "gemini-3.1-flash-preview", isolatedCompletionPrompt: `Read ${"\\".repeat(backslashes)}@secret.txt`, isolatedCompletionSystemPrompt: "Return only JSON.", @@ -286,7 +286,7 @@ describe("Gemini CLI isolated completion", () => { await withTempDir("openclaw-test-workspace-", async (workspaceDir) => { const prepared = await buildGoogleGeminiCliBackend().prepareExecution?.({ ...buildGeminiApiKeyPrepareContext(workspaceDir), - toolAvailability: { native: [], openClaw: [], mcp: [] }, + toolAvailability: { native: [], openClaw: [] }, isolatedCompletionModelId: "gemini-3.1-flash-preview", isolatedCompletionPrompt: prompt, isolatedCompletionSystemPrompt: "Return only JSON.", @@ -300,7 +300,7 @@ describe("Gemini CLI isolated completion", () => { await withTempDir("openclaw-test-workspace-", async (workspaceDir) => { const prepared = await buildGoogleGeminiCliBackend().prepareExecution?.({ ...buildGeminiApiKeyPrepareContext(workspaceDir), - toolAvailability: { native: [], openClaw: [], mcp: [] }, + toolAvailability: { native: [], openClaw: [] }, isolatedCompletionModelId: "gemini-3.1-flash-preview", isolatedCompletionPrompt: " \n/memory show", isolatedCompletionSystemPrompt: "Return only JSON.", @@ -327,7 +327,7 @@ describe("Gemini CLI isolated completion", () => { workspaceDir, provider: "google-gemini-cli", modelId: "gemini-3.1-flash-preview", - toolAvailability: { native: [], openClaw: [], mcp: [] }, + toolAvailability: { native: [], openClaw: [] }, isolatedCompletionModelId: "gemini-3.1-flash-preview", isolatedCompletionSystemPrompt: "Return only JSON.", } as GeminiPrepareContext), @@ -363,7 +363,7 @@ describe("Gemini CLI isolated completion", () => { GEMINI_CLI_HOME: ambientHome, GEMINI_CLI_SYSTEM_SETTINGS_PATH: systemSettingsPath, }, - toolAvailability: { native: [], openClaw: [], mcp: [] }, + toolAvailability: { native: [], openClaw: [] }, isolatedCompletionModelId: "gemini-3.1-flash-preview", isolatedCompletionSystemPrompt: "Return only JSON.", } as GeminiPrepareContext), @@ -404,7 +404,7 @@ describe("Gemini CLI isolated completion", () => { provider: "google-gemini-cli", modelId: "gemini-3.1-flash-preview", env: { GEMINI_CLI_HOME: preparedHome }, - toolAvailability: { native: [], openClaw: [], mcp: [] }, + toolAvailability: { native: [], openClaw: [] }, isolatedCompletionModelId: "gemini-3.1-flash-preview", isolatedCompletionSystemPrompt: "Return only JSON.", } as GeminiPrepareContext); diff --git a/extensions/google/setup-api.test.ts b/extensions/google/setup-api.test.ts index 447f9ab188ef..5b4e6c000902 100644 --- a/extensions/google/setup-api.test.ts +++ b/extensions/google/setup-api.test.ts @@ -104,7 +104,7 @@ describe("google gemini cli backend config", () => { const restrictedArgs = backend.resolveExecutionArgs?.({ ...baseContext, - toolAvailability: { native: [], openClaw: ["read"], mcp: ["read"] }, + toolAvailability: { native: [], openClaw: ["read"] }, }); expect(restrictedArgs).toEqual([ "--prompt", @@ -118,7 +118,7 @@ describe("google gemini cli backend config", () => { const emptyArgs = backend.resolveExecutionArgs?.({ ...baseContext, - toolAvailability: { native: [], openClaw: [], mcp: [] }, + toolAvailability: { native: [], openClaw: [] }, }); expect(emptyArgs?.slice(0, -4)).toEqual(["--prompt", "{prompt}", "--allowed-mcp-server-names"]); expect(emptyArgs?.at(-4)).toMatch( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6b29153c26a..cd74a556c34e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -466,6 +466,10 @@ importers: version: link:../../packages/plugin-sdk extensions/anthropic: + dependencies: + '@anthropic-ai/claude-agent-sdk': + specifier: 0.3.219 + version: 0.3.219(@anthropic-ai/sdk@0.115.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(zod@4.4.3) devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* diff --git a/scripts/build-all.mts b/scripts/build-all.mts index 31480e3c13f5..0644d7f8713b 100644 --- a/scripts/build-all.mts +++ b/scripts/build-all.mts @@ -101,6 +101,7 @@ const TSDOWN_DECLARATION_TOOL_INPUTS = [ "scripts/lib/plugin-sdk-private-local-only-subpaths.json", "scripts/lib/plugin-sdk-deprecated-public-subpaths.json", "scripts/lib/plugin-sdk-deprecated-barrel-subpaths.json", + "scripts/lib/root-package-bundled-plugin-excludes.mjs", "scripts/lib/tsdown-config-groups.mts", "scripts/lib/tsdown-output-roots.mts", ]; diff --git a/scripts/lib/bundled-plugin-build-entries.mjs b/scripts/lib/bundled-plugin-build-entries.mjs index 02bef6121f2a..a03c696f0fe8 100644 --- a/scripts/lib/bundled-plugin-build-entries.mjs +++ b/scripts/lib/bundled-plugin-build-entries.mjs @@ -8,6 +8,9 @@ import { bundledPluginFile, } from "./bundled-plugin-paths.mjs"; import { shouldBuildBundledCluster } from "./optional-bundled-clusters.mjs"; +import { collectRootPackageExcludedExtensionDirs } from "./root-package-bundled-plugin-excludes.mjs"; + +export { collectRootPackageExcludedExtensionDirs }; const TOP_LEVEL_PUBLIC_SURFACE_EXTENSIONS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]); /** Bundled plugin directories built with core but not packaged as standalone npm plugins. */ @@ -311,31 +314,6 @@ export function listBundledPluginBuildEntries(params = {}) { ); } -/** - * Collect bundled extension dirs that root package builds should exclude. - * @internal Shared repository-script contract. - */ -export function collectRootPackageExcludedExtensionDirs(params = {}) { - const cwd = params.cwd ?? process.cwd(); - const packageJsonPath = path.join(cwd, "package.json"); - const excluded = new Set(); - if (!fs.existsSync(packageJsonPath)) { - return excluded; - } - - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); - for (const entry of packageJson.files ?? []) { - if (typeof entry !== "string") { - continue; - } - const match = /^!dist\/extensions\/([^/]+)\/\*\*$/u.exec(entry); - if (match?.[1]) { - excluded.add(match[1]); - } - } - return excluded; -} - /** * List package artifact files generated for bundled plugins. * @internal Shared repository-script contract. diff --git a/scripts/lib/docker-plugin-selection.mjs b/scripts/lib/docker-plugin-selection.mjs index 04f95930e3e3..4c868f7c7ac1 100644 --- a/scripts/lib/docker-plugin-selection.mjs +++ b/scripts/lib/docker-plugin-selection.mjs @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { collectRootPackageExcludedExtensionDirs } from "./root-package-bundled-plugin-excludes.mjs"; const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*$/u; @@ -66,12 +67,53 @@ function resolveDockerPluginSelection(params) { return [...resolvedDirs].toSorted((left, right) => left.localeCompare(right)); } +function collectRequiredBundledPluginDirs(params) { + const excluded = collectRootPackageExcludedExtensionDirs({ + cwd: path.dirname(params.rootPackagePath), + }); + return collectPluginIdentities(params.extensionsRoot) + .filter(({ dirName }) => { + if (excluded.has(dirName)) { + return false; + } + const packageJsonPath = path.join(params.extensionsRoot, dirName, "package.json"); + if (!fs.existsSync(packageJsonPath)) { + return false; + } + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + return Object.keys(packageJson.dependencies ?? {}).length > 0; + }) + .map(({ dirName }) => dirName); +} + if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { try { - const resolved = resolveDockerPluginSelection({ + const params = { extensionsRoot: process.argv[2], selection: process.argv[3] ?? "", - }); + }; + let resolved = resolveDockerPluginSelection(params); + if (process.argv[4] === "--required-bundled") { + resolved = [ + ...new Set([ + ...resolved, + ...collectRequiredBundledPluginDirs({ ...params, rootPackagePath: process.argv[5] }), + ]), + ].toSorted((left, right) => left.localeCompare(right)); + } else if (process.argv[4] === "--required-platform-packages") { + resolved = [ + ...new Set( + resolved.flatMap((dirName) => { + const packageJsonPath = path.join(params.extensionsRoot, dirName, "package.json"); + if (!fs.existsSync(packageJsonPath)) { + return []; + } + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + return packageJson.openclaw?.install?.requiredPlatformPackages ?? []; + }), + ), + ].toSorted((left, right) => left.localeCompare(right)); + } if (resolved.length > 0) { process.stdout.write(`${resolved.join("\n")}\n`); } diff --git a/scripts/lib/root-package-bundled-plugin-excludes.mjs b/scripts/lib/root-package-bundled-plugin-excludes.mjs new file mode 100644 index 000000000000..0df032bbc21b --- /dev/null +++ b/scripts/lib/root-package-bundled-plugin-excludes.mjs @@ -0,0 +1,23 @@ +import fs from "node:fs"; +import path from "node:path"; + +/** Collect bundled plugin directories excluded from the root package artifact. */ +export function collectRootPackageExcludedExtensionDirs(params = {}) { + const packageJsonPath = path.join(params.cwd ?? process.cwd(), "package.json"); + const excluded = new Set(); + if (!fs.existsSync(packageJsonPath)) { + return excluded; + } + + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + for (const entry of packageJson.files ?? []) { + if (typeof entry !== "string") { + continue; + } + const match = /^!dist\/extensions\/([^/]+)\/\*\*$/u.exec(entry); + if (match?.[1]) { + excluded.add(match[1]); + } + } + return excluded; +} diff --git a/src/agents/cli-backend-version-support.ts b/src/agents/cli-backend-version-support.ts deleted file mode 100644 index c3c06e732c9c..000000000000 --- a/src/agents/cli-backend-version-support.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** Shared version guidance for provider-owned CLI backend protocol requirements. */ -import { coerce as coerceSemver } from "semver"; -import { compareValidSemver } from "../infra/semver.js"; -import type { CliBackendLiveSessionRequirement } from "../plugins/cli-backend.types.js"; - -type CliBackendVersionGuidance = - | { status: "at-or-above-known-floor"; version: string } - | { status: "below-known-floor"; version: string } - | { status: "unknown" }; - -/** Compare human CLI version output with the provider's first-known compatible release. */ -export function resolveCliBackendVersionGuidance( - versionOutput: string | undefined, - requirement: CliBackendLiveSessionRequirement, -): CliBackendVersionGuidance { - const parsed = versionOutput ? coerceSemver(versionOutput)?.version : undefined; - if (!parsed) { - return { status: "unknown" }; - } - const comparison = compareValidSemver(parsed, requirement.minimumVersion); - if (comparison === null) { - return { status: "unknown" }; - } - return { - status: comparison < 0 ? "below-known-floor" : "at-or-above-known-floor", - version: parsed, - }; -} - -/** Advisory guidance; runtime capability negotiation remains authoritative. */ -export function formatCliBackendVersionAdvisory(params: { - label: string; - requirement: CliBackendLiveSessionRequirement; - version: string; -}): string { - return `${params.label} ${params.requirement.minimumVersion} is the first published build known to advertise ${params.requirement.capability}; found ${params.version}. OpenClaw verifies this capability at runtime. If this build is rejected, run \`${params.requirement.updateCommand}\`, restart OpenClaw, and retry.`; -} diff --git a/src/agents/cli-backends.test.ts b/src/agents/cli-backends.test.ts index 0ed65d4d6923..b63eecaaa037 100644 --- a/src/agents/cli-backends.test.ts +++ b/src/agents/cli-backends.test.ts @@ -11,7 +11,6 @@ import { listCliRuntimeModelBackendBindings, listCliRuntimeProviderIds, resolveCliBackendConfig, - resolveCliBackendLiveSessionRequirement, resolveCliBackendLiveTest, resolveCliRuntimeCanonicalProvider, resolveCliRuntimeModelBackendBinding, @@ -43,13 +42,6 @@ const runtimeArtifact: CliBackendRuntimeArtifactPolicy = { packageName: "@fixture/acme-cli", entrypoint: "command", }; -const liveSessionRequirement = { - capability: "acme_lifecycle_v1", - minimumVersion: "1.2.3", - versionArgs: ["--version"], - updateCommand: "acme update", -} as const; - function createBackend(overrides: CliBackendOverrides = {}): CliBackendPlugin { const base = { id: "acme-cli", @@ -66,7 +58,6 @@ function createBackend(overrides: CliBackendOverrides = {}): CliBackendPlugin { bundleMcp: true, bundleMcpMode: "claude-config-file", runtimeArtifact, - liveSessionRequirement, liveTest: { defaultModelRef: "acme/acme-large", defaultImageProbe: true, @@ -95,9 +86,8 @@ function createBooleanOwnershipBackend(ownsNativeCompaction: boolean): CliBacken function runtimeEntry( overrides: CliBackendOverrides = {}, pluginId = "acme-plugin", - metadata: { builtWithOpenClawVersion?: string } = {}, ): RuntimeBackendEntry { - return { ...createBackend(overrides), pluginId, ...metadata } as RuntimeBackendEntry; + return { ...createBackend(overrides), pluginId } as RuntimeBackendEntry; } function setupEntry( @@ -147,7 +137,6 @@ describe("resolveCliBackendConfig", () => { bundleMcp: true, bundleMcpMode: "claude-config-file", runtimeArtifact, - liveSessionRequirement, config: { command: "acme", args: ["chat", "--json"], @@ -230,9 +219,7 @@ describe("resolveCliBackendConfig", () => { expect(resolved.pluginId).toBeUndefined(); expect(resolved.config).toEqual({ command: "setup-acme", args: ["run"] }); expect(resolved.runtimeArtifact).toEqual(runtimeArtifact); - expect(resolved.liveSessionRequirement).toEqual(liveSessionRequirement); expect(resolved.parseJsonlEvent).toBe(parseJsonlEvent); - expect(resolveCliBackendLiveSessionRequirement("acme-cli")).toEqual(liveSessionRequirement); }); it("returns null when no plugin owns the backend", () => { @@ -282,29 +269,7 @@ describe("resolveCliBackendConfig", () => { expect(resolved.sideQuestionToolMode).toBe("disabled"); }); - it("normalizes the shipped beta selectable-hook contract to execution-args enforcement", () => { - const resolveExecutionArgs = vi.fn(({ baseArgs }: { baseArgs: readonly string[] }) => baseArgs); - cliBackendsTesting.setDepsForTest({ - resolveRuntimeCliBackends: () => [ - runtimeEntry( - { - nativeToolMode: "selectable", - resolveExecutionArgs: resolveExecutionArgs as never, - }, - "acme-plugin", - { builtWithOpenClawVersion: "2026.7.2-beta.3" }, - ), - ], - resolvePluginSetupCliBackend: () => undefined, - }); - - const resolved = requireBackend(); - - expect(resolved.resolveExecutionArgs).toBe(resolveExecutionArgs); - expect(resolved.toolAvailabilityEnforcement).toBe("execution-args"); - }); - - it("does not infer enforcement for an unversioned selectable hook", () => { + it("requires explicit enforcement for a selectable hook", () => { const resolveExecutionArgs = vi.fn(({ baseArgs }: { baseArgs: readonly string[] }) => baseArgs); cliBackendsTesting.setDepsForTest({ resolveRuntimeCliBackends: () => [ diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts index 3ffdb05ebce6..e2f2b1075942 100644 --- a/src/agents/cli-backends.ts +++ b/src/agents/cli-backends.ts @@ -6,7 +6,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { ContextEngineHostCapability } from "../context-engine/types.js"; import type { CliBackendConfig, - CliBackendLiveSessionRequirement, CliBackendRuntimeArtifactPolicy, } from "../plugins/cli-backend.types.js"; import { resolveRuntimeCliBackends } from "../plugins/cli-backends.runtime.js"; @@ -65,7 +64,6 @@ export type ResolvedCliBackend = { nativeToolMode?: CliBackendNativeToolMode; sideQuestionToolMode?: CliBackendSideQuestionToolMode; runtimeArtifact?: CliBackendRuntimeArtifactPolicy; - liveSessionRequirement?: CliBackendLiveSessionRequirement; }; type ResolvedCliBackendLiveTest = { @@ -108,7 +106,6 @@ type FallbackCliBackendPolicy = { nativeToolMode?: CliBackendNativeToolMode; sideQuestionToolMode?: CliBackendSideQuestionToolMode; runtimeArtifact?: CliBackendRuntimeArtifactPolicy; - liveSessionRequirement?: CliBackendLiveSessionRequirement; }; const FALLBACK_CLI_BACKEND_POLICIES: Record = {}; @@ -123,27 +120,6 @@ function normalizeBundleMcpMode( return mode ?? "claude-config-file"; } -function resolveToolAvailabilityEnforcement( - backend: Pick< - CliBackendPlugin, - "nativeToolMode" | "resolveExecutionArgs" | "toolAvailabilityEnforcement" - > & { builtWithOpenClawVersion?: string }, -): CliBackendToolAvailabilityEnforcement | undefined { - if (backend.toolAvailabilityEnforcement) { - return backend.toolAvailabilityEnforcement; - } - // v2026.7.2-beta.1 through .3 made selectable + resolveExecutionArgs the - // public enforcement contract. Require matching package build provenance so - // a new no-op hook cannot be mistaken for that shipped SDK path. - const builtWith = backend.builtWithOpenClawVersion?.replace(/^v/u, ""); - const isShippedBetaContract = /^2026\.7\.2-beta\.[123]$/u.test(builtWith ?? ""); - return isShippedBetaContract && - backend.nativeToolMode === "selectable" && - backend.resolveExecutionArgs - ? "execution-args" - : undefined; -} - function resolveSetupCliBackendPolicy(provider: string): FallbackCliBackendPolicy | undefined { const entry = cliBackendsDeps.resolvePluginSetupCliBackend({ backend: provider, @@ -177,7 +153,6 @@ function resolveSetupCliBackendPolicy(provider: string): FallbackCliBackendPolic nativeToolMode: entry.backend.nativeToolMode, sideQuestionToolMode: entry.backend.sideQuestionToolMode, runtimeArtifact: entry.backend.runtimeArtifact, - liveSessionRequirement: entry.backend.liveSessionRequirement, }; } @@ -371,23 +346,6 @@ export function resolveCliBackendLiveTest(provider: string): ResolvedCliBackendL }; } -/** Resolves setup-safe live-session protocol metadata without normalizing runtime config. */ -export function resolveCliBackendLiveSessionRequirement( - provider: string, -): CliBackendLiveSessionRequirement | null { - const normalized = normalizeBackendKey(provider); - const entry = - cliBackendsDeps.resolvePluginSetupCliBackend({ backend: normalized }) ?? - cliBackendsDeps - .resolveRuntimeCliBackends() - .find((backend) => normalizeBackendKey(backend.id) === normalized); - if (!entry) { - return null; - } - const backend = "backend" in entry ? entry.backend : entry; - return backend.liveSessionRequirement ?? null; -} - /** Resolves the executable CLI backend registered by its owning plugin. */ export function resolveCliBackendConfig( provider: string, @@ -435,11 +393,10 @@ export function resolveCliBackendConfig( resolveExecutionArgs: registered.resolveExecutionArgs, resolveModelId: registered.resolveModelId, parseJsonlEvent: registered.parseJsonlEvent, - toolAvailabilityEnforcement: resolveToolAvailabilityEnforcement(registered), + toolAvailabilityEnforcement: registered.toolAvailabilityEnforcement, nativeToolMode: registered.nativeToolMode, sideQuestionToolMode: registered.sideQuestionToolMode, runtimeArtifact: registered.runtimeArtifact, - liveSessionRequirement: registered.liveSessionRequirement, }; } @@ -476,7 +433,6 @@ export function resolveCliBackendConfig( nativeToolMode: fallbackPolicy.nativeToolMode, sideQuestionToolMode: fallbackPolicy.sideQuestionToolMode, runtimeArtifact: fallbackPolicy.runtimeArtifact, - liveSessionRequirement: fallbackPolicy.liveSessionRequirement, }; } diff --git a/src/agents/cli-output-stream.ts b/src/agents/cli-output-stream.ts index d8382879f2ca..87cc1d993c17 100644 --- a/src/agents/cli-output-stream.ts +++ b/src/agents/cli-output-stream.ts @@ -45,13 +45,13 @@ import { supportsCliJsonlToolEvents, } from "./cli-output-records.js"; -export const CLI_STREAM_JSON_DEFAULT_MAX_TURN_RAW_CHARS = 8 * 1024 * 1024; +const CLI_STREAM_JSON_DEFAULT_MAX_TURN_RAW_CHARS = 8 * 1024 * 1024; const CLI_STREAM_JSON_DEFAULT_MAX_TURN_LINES = 20_000; export const CLI_STREAM_JSON_MISSING_RESULT_ERROR = "CLI stream-json output ended without a result event."; const CLAUDE_SYNTHETIC_NO_RESPONSE_ERROR = "Claude CLI returned a synthetic no-response result."; -export const CLI_STREAM_JSON_OUTPUT_LIMITS = Object.freeze({ +const CLI_STREAM_JSON_OUTPUT_LIMITS = Object.freeze({ maxTurnRawChars: CLI_STREAM_JSON_DEFAULT_MAX_TURN_RAW_CHARS, maxPendingLineChars: CLI_STREAM_JSON_DEFAULT_MAX_TURN_RAW_CHARS, maxTurnLines: CLI_STREAM_JSON_DEFAULT_MAX_TURN_LINES, @@ -74,7 +74,7 @@ function isClaudeSyntheticNoResponse(parsed: Record): boolean { } /** Frames arbitrary stdout chunks while bounding each individual raw JSONL line. */ -export function frameBoundedCliJsonlChunk( +function frameBoundedCliJsonlChunk( state: { pending: string }, chunk: string, maxLineChars: number, @@ -103,7 +103,7 @@ export function frameBoundedCliJsonlChunk( } /** Drops Claude's echoed binary bytes before they enter retained tool/transcript state. */ -export function normalizeClaudeCliStreamJsonRecord( +function normalizeClaudeCliStreamJsonRecord( parsed: Record, ): { line: string; omittedRawChars: number } | undefined { if (parsed.type !== "user" || !isRecord(parsed.message)) { @@ -140,10 +140,7 @@ export function normalizeClaudeCliStreamJsonRecord( return normalized ? { line: JSON.stringify(parsed), omittedRawChars } : undefined; } -export function streamJsonOutputLimitErrorText( - kind: "raw" | "line" | "lines", - limit: number, -): string { +function streamJsonOutputLimitErrorText(kind: "raw" | "line" | "lines", limit: number): string { if (kind === "line") { return `CLI JSONL line exceeded ${limit} characters; refusing to parse output.`; } diff --git a/src/agents/cli-runner.before-agent-reply-cron.test.ts b/src/agents/cli-runner.before-agent-reply-cron.test.ts index 7e773b9f3017..c5dc13abd8b0 100644 --- a/src/agents/cli-runner.before-agent-reply-cron.test.ts +++ b/src/agents/cli-runner.before-agent-reply-cron.test.ts @@ -39,7 +39,7 @@ const { runBeforeAgentRunMock, executePreparedCliRunMock, prepareCliRunContextMock, - closeClaudeSessionMock, + closeCliSessionMock, closeMcpLoopbackServerMock, retireSessionMcpRuntimeForSessionKeyMock, retireSessionMcpRuntimeMock, @@ -56,7 +56,7 @@ const { (_context: unknown, _cliSessionIdToUse?: string) => Promise >(async () => ({ text: "" })), prepareCliRunContextMock: vi.fn(), - closeClaudeSessionMock: vi.fn(), + closeCliSessionMock: vi.fn(), closeMcpLoopbackServerMock: vi.fn(), retireSessionMcpRuntimeForSessionKeyMock: vi.fn(), retireSessionMcpRuntimeMock: vi.fn(), @@ -81,14 +81,11 @@ vi.mock("./cli-runner/execute.runtime.js", () => ({ executePreparedCliRun: executePreparedCliRunMock, })); -vi.mock("./cli-runner/claude-live-registry.js", () => ({ - closeClaudeSession: closeClaudeSessionMock, - getClaudeGeneration: vi.fn(() => undefined), - hasClaudeSession: vi.fn(() => false), -})); - -vi.mock("./cli-runner/claude-live-session-policy.js", () => ({ - acceptsClaudeLive: vi.fn(() => false), +vi.mock("./cli-runner/cli-live-session-registry.js", () => ({ + closeCliLiveSession: closeCliSessionMock, + getCliLiveSessionGeneration: vi.fn(() => undefined), + hasCliLiveSession: vi.fn(() => false), + acceptsCliLiveSession: vi.fn(() => false), })); vi.mock("../gateway/mcp-http.js", () => ({ @@ -175,7 +172,7 @@ beforeEach(() => { prepareCliRunContextMock.mockImplementation(async (params) => makeStubContext(params as typeof baseRunParams & { trigger?: string }), ); - closeClaudeSessionMock.mockReset(); + closeCliSessionMock.mockReset(); closeMcpLoopbackServerMock.mockReset(); retireSessionMcpRuntimeForSessionKeyMock.mockReset(); retireSessionMcpRuntimeForSessionKeyMock.mockResolvedValue(true); @@ -502,7 +499,7 @@ describe("runCliAgent before_agent_reply seam", () => { }); expect(error).toMatchObject({ message: "CLI process failed" }); - expect(closeClaudeSessionMock).toHaveBeenCalledTimes(1); + expect(closeCliSessionMock).toHaveBeenCalledTimes(1); expect(events.find((event) => event.type === "harness.run.error")).toMatchObject({ type: "harness.run.error", phase: "send", @@ -529,7 +526,7 @@ describe("runCliAgent before_agent_reply seam", () => { it("classifies a surfaced outer cleanup failure as cleanup", async () => { executePreparedCliRunMock.mockResolvedValueOnce({ text: "real Claude reply" }); - closeClaudeSessionMock.mockRejectedValueOnce(new Error("managed session cleanup failed")); + closeCliSessionMock.mockRejectedValueOnce(new Error("managed session cleanup failed")); const { error, events } = await captureRejectedClaudeRun({ ...baseRunParams, @@ -829,8 +826,8 @@ describe("runCliAgent before_agent_reply seam", () => { await runCliAgent({ ...baseRunParams, cleanupCliLiveSessionOnRunEnd: true }); expect(executePreparedCliRunMock).toHaveBeenCalledTimes(1); - expect(closeClaudeSessionMock).toHaveBeenCalledTimes(1); - expect(closeClaudeSessionMock).toHaveBeenCalledWith( + expect(closeCliSessionMock).toHaveBeenCalledTimes(1); + expect(closeCliSessionMock).toHaveBeenCalledWith( await expectDefined( prepareCliRunContextMock.mock.results[0], "prepareCliRunContextMock.mock.results[0] test invariant", diff --git a/src/agents/cli-runner.context-engine.test.ts b/src/agents/cli-runner.context-engine.test.ts index 82d66a1286f0..7a36357afe85 100644 --- a/src/agents/cli-runner.context-engine.test.ts +++ b/src/agents/cli-runner.context-engine.test.ts @@ -130,6 +130,7 @@ function buildPreparedContext(contextEngine: ContextEngine): PreparedCliRunConte normalizedModel: "sonnet-4.6", systemPrompt: "You are a helpful assistant.", systemPromptReport: {} as PreparedCliRunContext["systemPromptReport"], + claudeSkillsPluginArgs: [], bootstrapPromptWarningLines: [], authEpochVersion: 2, }; diff --git a/src/agents/cli-runner.fault-sequences.e2e.test.ts b/src/agents/cli-runner.fault-sequences.e2e.test.ts index e1a542800777..36a8db742b2d 100644 --- a/src/agents/cli-runner.fault-sequences.e2e.test.ts +++ b/src/agents/cli-runner.fault-sequences.e2e.test.ts @@ -8,12 +8,7 @@ import { onAgentEvent } from "../infra/agent-events.js"; import type { RunExit } from "../process/supervisor/types.js"; import { createTestAdmittedRunContext } from "./admitted-run-context.test-support.js"; import { runPreparedCliAgent } from "./cli-runner.js"; -import { - buildClaudeLiveRunContext, - buildPreparedCliRunContext, - mockClaudeLiveRun, -} from "./cli-runner.test-helpers.js"; -import { resetClaudeLiveSessionsForTest } from "./cli-runner/claude-live-session.test-support.js"; +import { buildPreparedCliRunContext } from "./cli-runner.test-helpers.js"; import { createManagedRun, supervisorSpawnMock } from "./cli-runner/execute.test-support.js"; import type { PreparedCliRunContext, RunCliAgentParams } from "./cli-runner/types.js"; import type { EmbeddedAgentRunResult } from "./embedded-agent-runner/types.js"; @@ -123,8 +118,6 @@ beforeEach(async () => { }); afterEach(async () => { - resetClaudeLiveSessionsForTest(); - vi.useRealTimers(); await fs.rm(scenarioRoot, { recursive: true, force: true }); }); @@ -219,41 +212,6 @@ function buildReusableProcessContext(params: CliBoundaryParams): PreparedCliRunC return context; } -function buildReusableLiveContext(params: CliBoundaryParams): PreparedCliRunContext { - const context = applyBoundaryParams( - buildClaudeLiveRunContext({ - model: params.model ?? PRIMARY_MODEL, - runId: params.runId, - workspaceDir: scenarioRoot, - timeoutMs: 5_000, - backend: { - resumeArgs: ["-p", "--resume", "{sessionId}", "--output-format", "stream-json"], - forkArg: "--fork-session", - resumeAtArg: "--resume-session-at", - reliability: { - watchdog: { - fresh: { noOutputTimeoutRatio: 0.2, minMs: 1_000, maxMs: 1_000 }, - resume: { noOutputTimeoutRatio: 0.2, minMs: 1_000, maxMs: 1_000 }, - }, - }, - }, - }), - params, - ); - context.reusableCliSession = { mode: "reuse", sessionId: "source-cli-session" }; - context.openClawHistoryPrompt = RESEED_PROMPT; - context.params.cliSessionBinding = { - sessionId: "source-cli-session", - resumeCheckpointId: "assistant-before-stall", - }; - context.params.onBeforeForkedCliSessionRetry = vi.fn(async () => true); - context.params.claimCliSessionFork = vi.fn(async () => true); - context.params.persistCliSessionForkSuccessor = vi.fn(async () => undefined); - context.params.restoreCliSessionFork = vi.fn(async () => undefined); - context.params.onBeforeFreshCliSessionRetry = vi.fn(async () => true); - return context; -} - async function runOuter(options: OuterRunOptions = {}) { harness.wholeTurnRuns += 1; return runWithModelFallback({ @@ -300,40 +258,6 @@ function expectCounts(expected: ScenarioCounts): void { expect(currentCounts()).toEqual(expected); } -function installLiveStall(sessionId: string): void { - mockClaudeLiveRun(supervisorSpawnMock, { - cancelable: true, - events: [{ type: "system", subtype: "init", session_id: sessionId }], - }); - harness.managedChildren += 1; -} - -function installLiveSuccess(sessionId: string, text: string): void { - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: sessionId }, - { type: "result", subtype: "success", session_id: sessionId, result: text }, - ], - }); - harness.managedChildren += 1; -} - -function installLiveFailure(sessionId: string, text: string): void { - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: sessionId }, - { - type: "result", - subtype: "error_during_execution", - is_error: true, - session_id: sessionId, - result: text, - }, - ], - }); - harness.managedChildren += 1; -} - describe("CLI runner fault sequences", () => { it("dispatches bridge mode through one fresh CLI child and bypasses the native run budget", async () => { supervisorSpawnMock.mockResolvedValueOnce(managedRun(successExit("bridge ok"))); @@ -552,80 +476,6 @@ describe("CLI runner fault sequences", () => { }); }); - it("recovers resume to fork inside one outer candidate", async () => { - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); - harness.contextFor = buildReusableLiveContext; - installLiveStall("source-cli-session"); - installLiveSuccess("forked-cli-session", "fork recovered"); - - const outcomePromise = runOuter(); - await vi.advanceTimersByTimeAsync(0); - await vi.advanceTimersByTimeAsync(1_000); - const outcome = await outcomePromise; - - expect(outcome.result.payloads).toEqual([{ text: "fork recovered" }]); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); - expectCounts({ - childProcesses: 2, - nativeRunBudgetAttempts: 0, - outerCandidates: 1, - runCliAgentCalls: 1, - wholeTurnRetries: 0, - }); - }); - - it("recovers a failed fork with one fresh transcript reseed inside one outer candidate", async () => { - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); - harness.contextFor = buildReusableLiveContext; - installLiveStall("source-cli-session"); - installLiveStall("forked-before-stall"); - installLiveSuccess("fresh-after-fork", "fresh recovered"); - - const outcomePromise = runOuter(); - await vi.advanceTimersByTimeAsync(0); - await vi.advanceTimersByTimeAsync(1_000); - await vi.advanceTimersByTimeAsync(1_000); - const outcome = await outcomePromise; - - expect(outcome.result.payloads).toEqual([{ text: "fresh recovered" }]); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(3); - expectCounts({ - childProcesses: 3, - nativeRunBudgetAttempts: 0, - outerCandidates: 1, - runCliAgentCalls: 1, - wholeTurnRetries: 0, - }); - }); - - it("delivers an exhausted three-child recovery ladder to the outer candidate exactly once", async () => { - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); - harness.contextFor = buildReusableLiveContext; - installLiveStall("source-cli-session"); - installLiveStall("forked-before-failure"); - installLiveFailure("fresh-terminal", "worker exploded after recovery"); - const outerErrors = vi.fn(); - - const outcomePromise = runOuter({ onError: outerErrors }); - const rejection = outcomePromise.catch((error: unknown) => error); - await vi.advanceTimersByTimeAsync(0); - await vi.advanceTimersByTimeAsync(1_000); - await vi.advanceTimersByTimeAsync(1_000); - const error = await rejection; - - expect(error).toBeInstanceOf(Error); - expect(String(error)).toContain("worker exploded after recovery"); - expect(outerErrors).toHaveBeenCalledTimes(1); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(3); - expectCounts({ - childProcesses: 3, - nativeRunBudgetAttempts: 0, - outerCandidates: 1, - runCliAgentCalls: 1, - wholeTurnRetries: 0, - }); - }); - it("fresh-retries reused-session context overflow once and never crosses models", async () => { harness.contextFor = buildReusableProcessContext; supervisorSpawnMock diff --git a/src/agents/cli-runner.helpers.test.ts b/src/agents/cli-runner.helpers.test.ts index 2644f3ed36a3..5b3056e3ffc4 100644 --- a/src/agents/cli-runner.helpers.test.ts +++ b/src/agents/cli-runner.helpers.test.ts @@ -830,6 +830,19 @@ describe("resolveCliRunQueueKey", () => { ).toBe("claude-cli:owner:abcd1234"); }); + it("keeps third-party live sessions serialized on their exact owner even when serialize=false", () => { + expect( + resolveCliRunQueueKey({ + backendId: "acme-cli", + liveSession: "claude-stdio", + serialize: false, + runId: "run-third-party-live", + workspaceDir: "/tmp/project-a", + ownerKey: "third-party-owner", + }), + ).toBe("acme-cli:owner:third-party-owner"); + }); + it("keeps resumed Claude live sessions on the owner lane", () => { expect( resolveCliRunQueueKey({ diff --git a/src/agents/cli-runner.reliability.test.ts b/src/agents/cli-runner.reliability.test.ts index 197eaf57d6df..06684b2f7bd2 100644 --- a/src/agents/cli-runner.reliability.test.ts +++ b/src/agents/cli-runner.reliability.test.ts @@ -50,14 +50,12 @@ import { runPreparedCliAgent, setCliRunnerTestDeps, } from "./cli-runner.js"; -import { createClaudeInputStartedEvent } from "./cli-runner.test-helpers.js"; import { createManagedRun, enqueueSystemEventMock, requestHeartbeatMock, supervisorSpawnMock, } from "./cli-runner.test-support.js"; -import { resetClaudeLiveSessionsForTest } from "./cli-runner/claude-live-session.test-support.js"; import { executePreparedCliRun } from "./cli-runner/execute.js"; import { resolveCliNoOutputTimeoutMs, @@ -245,6 +243,7 @@ function buildPreparedContext(params: PreparedContextOverrides = {}): PreparedCl systemPrompt: "You are a helpful assistant.", systemPromptReport: {} as PreparedCliRunContext["systemPromptReport"], bootstrapPromptWarningLines: [], + claudeSkillsPluginArgs: [], ...(params?.openClawHistoryPrompt ? { openClawHistoryPrompt: params.openClawHistoryPrompt } : {}), @@ -276,51 +275,8 @@ function makeManagedRun(overrides: Partial = {}) { return createManagedRun(makeRunExit(overrides)); } -type CliBackend = PreparedCliRunContext["preparedBackend"]["backend"]; - -type LiveClaudeBackend = CliBackend & { - reliability: { - watchdog: { - resume: { noOutputTimeoutMs: number; minMs: number; maxMs: number }; - fresh: { noOutputTimeoutMs: number; minMs: number; maxMs: number }; - }; - }; -}; - -function makeLiveClaudeBackend(overrides: Partial = {}): LiveClaudeBackend { - return { - command: "claude", - args: ["-p", "--output-format", "stream-json"], - resumeArgs: ["-p", "--resume", "{sessionId}", "--output-format", "stream-json"], - forkArg: "--fork-session", - resumeAtArg: "--resume-session-at", - output: "jsonl", - input: "stdin", - modelArg: "--model", - sessionArgs: ["--session-id", "{sessionId}"], - sessionMode: "always", - liveSession: "claude-stdio", - reliability: { - watchdog: { - resume: { noOutputTimeoutMs: 1_000, minMs: 1_000, maxMs: 1_000 }, - fresh: { noOutputTimeoutMs: 1_000, minMs: 1_000, maxMs: 1_000 }, - }, - }, - serialize: true, - ...overrides, - }; -} - const requireRecord = createRequireRecord("object", "expected-label"); -function claudeInputStartedJson(data: string): string { - const event = createClaudeInputStartedEvent(data); - if (!event) { - throw new Error("expected Claude user input UUID"); - } - return JSON.stringify(event); -} - function requireArray(value: unknown, label: string): Array { expect(Array.isArray(value), label).toBe(true); return value as Array; @@ -441,7 +397,6 @@ describe("runCliAgent reliability", () => { vi.unstubAllEnvs(); sessionFileEnvSnapshot?.restore(); sessionFileEnvSnapshot = undefined; - resetClaudeLiveSessionsForTest(); resetDiagnosticEventsForTest(); cliBackendsTesting.resetDepsForTest(); vi.useRealTimers(); @@ -2070,466 +2025,6 @@ describe("runCliAgent reliability", () => { expect(clearBeforeRetry).not.toHaveBeenCalled(); }); - it("forks a lifecycle-started resume stall without rebuilding its cached conversation", async () => { - vi.useFakeTimers(); - supervisorSpawnMock.mockClear(); - const transcriptProbe = vi.fn(async () => false); - setCliRunnerTestDeps({ claudeCliSessionTranscriptHasContent: transcriptProbe }); - const artifactDir = autoCleanupTempDirs.make("openclaw-live-retry-artifacts-"); - const mcpConfigPath = path.join(artifactDir, "mcp.json"); - const skillsDir = path.join(artifactDir, "skills-plugin"); - fs.writeFileSync(mcpConfigPath, "{}\n", "utf-8"); - fs.mkdirSync(skillsDir); - - const resolveArg = (argv: string[] | undefined, flag: string) => { - const index = argv?.indexOf(flag) ?? -1; - if (index < 0) { - throw new Error(`expected ${flag}`); - } - const value = argv?.[index + 1]; - if (!value) { - throw new Error(`expected value after ${flag}`); - } - return value; - }; - - let notifyFirstSpawn: (() => void) | undefined; - const firstSpawned = new Promise((resolve) => { - notifyFirstSpawn = resolve; - }); - let spawnCount = 0; - const spawnedArgv: string[][] = []; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - spawnCount += 1; - const input = args[0] as { - argv?: string[]; - onStdout?: (chunk: string) => void; - }; - spawnedArgv.push(input.argv ?? []); - expect(resolveArg(input.argv, "--mcp-config")).toBe(mcpConfigPath); - expect(resolveArg(input.argv, "--skills-plugin-dir")).toBe(skillsDir); - expect(fs.existsSync(mcpConfigPath)).toBe(true); - expect(fs.existsSync(skillsDir)).toBe(true); - - if (spawnCount === 1) { - notifyFirstSpawn?.(); - const stdoutListener = input.onStdout; - let resolveExit: ((value: RunExit) => void) | undefined; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - return { - runId: "live-retry-timeout", - pid: 3301, - startedAtMs: Date.now(), - stdin: { - write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { - stdoutListener?.( - [ - JSON.stringify({ - type: "system", - subtype: "init", - session_id: "stale-live", - }), - claudeInputStartedJson(dataValue), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }, - wait: vi.fn(() => exited), - cancel: vi.fn(() => - resolveExit?.( - makeRunExit({ - reason: "manual-cancel", - exitCode: null, - durationMs: 1, - }), - ), - ), - }; - } - - const stdoutListener = input.onStdout; - return { - runId: "live-retry-fork", - pid: 3302, - startedAtMs: Date.now(), - stdin: { - write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { - stdoutListener?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "forked-live" }), - claudeInputStartedJson(dataValue), - JSON.stringify({ - type: "assistant", - uuid: "assistant-after-recovery", - session_id: "forked-live", - message: { - model: "claude-fable-5", - role: "assistant", - content: [{ type: "text", text: "fork ok" }], - }, - }), - JSON.stringify({ type: "result", session_id: "forked-live", result: "fork ok" }), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; - }); - - const liveBackend = makeLiveClaudeBackend({ - args: [ - "-p", - "--output-format", - "stream-json", - "--mcp-config", - mcpConfigPath, - "--skills-plugin-dir", - skillsDir, - ], - resumeArgs: [ - "-p", - "--resume", - "{sessionId}", - "--output-format", - "stream-json", - "--mcp-config", - mcpConfigPath, - "--skills-plugin-dir", - skillsDir, - ], - }); - const cleanup = vi.fn(async () => { - fs.rmSync(artifactDir, { recursive: true, force: true }); - }); - const prepareForkRetry = vi.fn(async () => true); - const claimFork = vi.fn(async () => true); - const persistForkSuccessor = vi.fn(async () => {}); - const restoreFork = vi.fn(async () => {}); - const clearBeforeRetry = vi.fn(async () => true); - const context = makeClaudePreparedContext({ - sessionKey: "agent:main:live-artifacts", - runId: "run-live-artifact-retry", - cliSessionId: "stale-live", - openClawHistoryPrompt: CLI_RESEED_PROMPT, - }); - context.preparedBackend.backend = liveBackend; - context.preparedBackend.cleanup = cleanup; - context.backendResolved.config = liveBackend; - context.params.cliSessionBinding = { - sessionId: "stale-live", - resumeCheckpointId: "assistant-before-stall", - }; - - const resultPromise = runPreparedCliAgent({ - ...context, - params: { - ...context.params, - timeoutMs: 5_000, - onBeforeForkedCliSessionRetry: prepareForkRetry, - claimCliSessionFork: claimFork, - persistCliSessionForkSuccessor: persistForkSuccessor, - restoreCliSessionFork: restoreFork, - onBeforeFreshCliSessionRetry: clearBeforeRetry, - }, - }); - await firstSpawned; - await vi.advanceTimersByTimeAsync(1_000); - const result = await resultPromise; - - expect(result.payloads).toEqual([{ text: "fork ok" }]); - expect(result.meta.finalPromptText).not.toContain("User: earlier context"); - expect(result.meta.agentMeta?.cliSessionBinding?.sessionId).toBe("forked-live"); - expect(result.meta.agentMeta?.cliSessionBinding?.resumeCheckpointId).toBe( - "assistant-after-recovery", - ); - expect(transcriptProbe).not.toHaveBeenCalled(); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); - expect(spawnedArgv[0]).not.toContain("--fork-session"); - expect(spawnedArgv[1]).toEqual( - expect.arrayContaining([ - "--resume", - "stale-live", - "--fork-session", - "--resume-session-at", - "assistant-before-stall", - ]), - ); - expect(prepareForkRetry).toHaveBeenCalledWith({ - provider: "claude-cli", - reason: "timeout", - sessionId: "stale-live", - }); - expect(claimFork).toHaveBeenCalledOnce(); - expect(persistForkSuccessor).toHaveBeenCalledWith("forked-live"); - expect(restoreFork).not.toHaveBeenCalled(); - expect(clearBeforeRetry).not.toHaveBeenCalled(); - expect(cleanup).toHaveBeenCalledOnce(); - expect(fs.existsSync(artifactDir)).toBe(false); - }); - - it("falls back to transcript reseeding when the lifecycle-started fork also stalls", async () => { - vi.useFakeTimers(); - supervisorSpawnMock.mockClear(); - const spawnedArgv: string[][] = []; - let notifyFirstSpawn: (() => void) | undefined; - const firstSpawned = new Promise((resolve) => { - notifyFirstSpawn = resolve; - }); - let notifySecondSpawn: (() => void) | undefined; - const secondSpawned = new Promise((resolve) => { - notifySecondSpawn = resolve; - }); - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = args[0] as { - argv?: string[]; - onStdout?: (chunk: string) => void; - }; - spawnedArgv.push(input.argv ?? []); - const spawnIndex = spawnedArgv.length; - if (spawnIndex === 1) { - notifyFirstSpawn?.(); - } else if (spawnIndex === 2) { - notifySecondSpawn?.(); - } - let resolveExit: ((value: RunExit) => void) | undefined; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - return { - runId: `live-fork-fallback-${spawnIndex}`, - pid: 3400 + spawnIndex, - startedAtMs: Date.now(), - stdin: { - write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { - const sessionId = - spawnIndex === 1 - ? "stalled-source" - : spawnIndex === 2 - ? "forked-before-stall" - : "fresh-after-fork-stall"; - input.onStdout?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: sessionId }), - claudeInputStartedJson(dataValue), - ...(spawnIndex < 3 - ? [] - : [ - JSON.stringify({ - type: "result", - session_id: sessionId, - result: "fresh fallback ok", - }), - ]), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }, - wait: vi.fn(() => exited), - cancel: vi.fn(() => - resolveExit?.( - makeRunExit({ - reason: "manual-cancel", - exitCode: null, - durationMs: 1, - }), - ), - ), - }; - }); - - const backend = makeLiveClaudeBackend(); - const context = makeClaudePreparedContext({ - sessionKey: "agent:main:fork-then-fresh", - runId: "run-fork-then-fresh", - cliSessionId: "stalled-source", - openClawHistoryPrompt: CLI_RESEED_PROMPT, - }); - context.preparedBackend.backend = backend; - context.backendResolved.config = backend; - context.params.cliSessionBinding = { - sessionId: "stalled-source", - resumeCheckpointId: "assistant-before-stall", - }; - const prepareForkRetry = vi.fn(async () => true); - const claimFork = vi.fn(async () => true); - const persistForkSuccessor = vi.fn(async () => {}); - const restoreFork = vi.fn(async () => {}); - const clearBeforeRetry = vi.fn(async () => true); - - const resultPromise = runPreparedCliAgent({ - ...context, - params: { - ...context.params, - timeoutMs: 5_000, - onBeforeForkedCliSessionRetry: prepareForkRetry, - claimCliSessionFork: claimFork, - persistCliSessionForkSuccessor: persistForkSuccessor, - restoreCliSessionFork: restoreFork, - onBeforeFreshCliSessionRetry: clearBeforeRetry, - }, - }); - await firstSpawned; - await vi.advanceTimersByTimeAsync(1_000); - await secondSpawned; - await vi.advanceTimersByTimeAsync(1_000); - const result = await resultPromise; - - expect(result.payloads).toEqual([{ text: "fresh fallback ok" }]); - expect(result.meta.finalPromptText).toContain("User: earlier context"); - expect(spawnedArgv).toHaveLength(3); - expect(spawnedArgv[1]).toEqual( - expect.arrayContaining([ - "--resume", - "stalled-source", - "--fork-session", - "--resume-session-at", - "assistant-before-stall", - ]), - ); - expect(spawnedArgv[2]).not.toContain("--resume"); - expect(spawnedArgv[2]).not.toContain("--fork-session"); - expect(prepareForkRetry).toHaveBeenCalledOnce(); - expect(claimFork).toHaveBeenCalledOnce(); - expect(persistForkSuccessor).toHaveBeenCalledWith("forked-before-stall"); - expect(restoreFork).not.toHaveBeenCalled(); - expect(clearBeforeRetry).toHaveBeenCalledWith({ - provider: "claude-cli", - reason: "timeout", - sessionId: "forked-before-stall", - }); - }); - - it("tracks and clears a successor when the initial attempt is already a fork", async () => { - vi.useFakeTimers(); - supervisorSpawnMock.mockClear(); - const spawnedArgv: string[][] = []; - let notifyFirstSpawn: (() => void) | undefined; - const firstSpawned = new Promise((resolve) => { - notifyFirstSpawn = resolve; - }); - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = args[0] as { - argv?: string[]; - onStdout?: (chunk: string) => void; - }; - spawnedArgv.push(input.argv ?? []); - const spawnIndex = spawnedArgv.length; - if (spawnIndex === 1) { - notifyFirstSpawn?.(); - } - let resolveExit: ((value: RunExit) => void) | undefined; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - return { - runId: `initial-fork-fallback-${spawnIndex}`, - pid: 3500 + spawnIndex, - startedAtMs: Date.now(), - stdin: { - write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { - const sessionId = - spawnIndex === 1 ? "initial-fork-successor" : "fresh-after-initial-fork-stall"; - input.onStdout?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: sessionId }), - claudeInputStartedJson(dataValue), - ...(spawnIndex === 1 - ? [] - : [ - JSON.stringify({ - type: "result", - session_id: sessionId, - result: "initial fork fallback ok", - }), - ]), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }, - wait: vi.fn(() => exited), - cancel: vi.fn(() => - resolveExit?.( - makeRunExit({ - reason: "manual-cancel", - exitCode: null, - durationMs: 1, - }), - ), - ), - }; - }); - - const backend = makeLiveClaudeBackend(); - const context = makeClaudePreparedContext({ - sessionKey: "agent:main:initial-fork-fallback", - runId: "run-initial-fork-fallback", - cliSessionId: "initial-fork-parent", - openClawHistoryPrompt: CLI_RESEED_PROMPT, - }); - context.preparedBackend.backend = backend; - context.backendResolved.config = backend; - context.params.cliSessionBinding = { - sessionId: "initial-fork-parent", - resumeCheckpointId: "assistant-before-initial-fork", - forkNextResume: true, - }; - const claimFork = vi.fn(async () => true); - const persistForkSuccessor = vi.fn(async () => {}); - const restoreFork = vi.fn(async () => {}); - const clearBeforeRetry = vi.fn(async () => true); - - const resultPromise = runPreparedCliAgent({ - ...context, - params: { - ...context.params, - timeoutMs: 5_000, - forkCliSessionOnResume: true, - claimCliSessionFork: claimFork, - persistCliSessionForkSuccessor: persistForkSuccessor, - restoreCliSessionFork: restoreFork, - onBeforeFreshCliSessionRetry: clearBeforeRetry, - }, - }); - await firstSpawned; - await vi.advanceTimersByTimeAsync(1_000); - const result = await resultPromise; - - expect(result.payloads).toEqual([{ text: "initial fork fallback ok" }]); - expect(result.meta.finalPromptText).toContain("User: earlier context"); - expect(spawnedArgv).toHaveLength(2); - expect(spawnedArgv[0]).toEqual( - expect.arrayContaining([ - "--resume", - "initial-fork-parent", - "--fork-session", - "--resume-session-at", - "assistant-before-initial-fork", - ]), - ); - expect(spawnedArgv[1]).not.toContain("--resume"); - expect(spawnedArgv[1]).not.toContain("--fork-session"); - expect(claimFork).toHaveBeenCalledOnce(); - expect(persistForkSuccessor).toHaveBeenCalledWith("initial-fork-successor"); - expect(restoreFork).not.toHaveBeenCalled(); - expect(clearBeforeRetry).toHaveBeenCalledWith({ - provider: "claude-cli", - reason: "timeout", - sessionId: "initial-fork-successor", - }); - }); - it("does not fresh retry a no-output timeout after CLI diagnostic output", async () => { supervisorSpawnMock.mockClear(); enqueueSystemEventMock.mockClear(); diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index 20bd1388c8d7..4970f03e94ef 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -15,7 +15,6 @@ import { import { invokeNodeClaudeCliRun } from "../gateway/node-agent-cli-runtime.js"; import { onAgentEvent, resetAgentEventsForTest } from "../infra/agent-events.js"; import { - onTrustedToolExecutionEvent, setDiagnosticsEnabledForProcess, waitForDiagnosticEventsDrained, } from "../infra/diagnostic-events.js"; @@ -24,22 +23,17 @@ import { startDiagnosticRunActivityTracking, } from "../logging/diagnostic-run-activity.js"; import type { getProcessSupervisor } from "../process/supervisor/index.js"; -import { createTestAdmittedRunContext } from "./admitted-run-context.test-support.js"; import { - buildClaudeLiveRunContext, buildPreparedCliRunContext, captureModelCallDiagnostics, - createClaudeInputStartedEvent, expectPathMissing, expectRejectsWithFields, expectModelCallTypes, mockCallArg, - mockClaudeLiveRun, requireArgAfter, requireRecord, requireRegexMatch, } from "./cli-runner.test-helpers.js"; -import { resetClaudeLiveSessionsForTest } from "./cli-runner/claude-live-session.test-support.js"; import { attachCliMessagingDeliveryEvidence, getCliMessagingDeliveryEvidence, @@ -79,19 +73,11 @@ vi.mock("../gateway/mcp-http.loopback-runtime.js", async (importOriginal) => { }; }); -function emitClaudeInputStarted(stdout: ((chunk: string) => void) | undefined, data: string): void { - const event = createClaudeInputStartedEvent(data); - if (event) { - stdout?.(`${JSON.stringify(event)}\n`); - } -} - beforeEach(() => { setDiagnosticsEnabledForProcess(true); resetAgentEventsForTest(); resetDiagnosticRunActivityForTest(); startDiagnosticRunActivityTracking(); - resetClaudeLiveSessionsForTest(); setCliRunnerExecuteTestDeps({ writeCliSystemPromptFile, invokeNodeClaudeCliRun, @@ -109,7 +95,6 @@ afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); resetDiagnosticRunActivityForTest(); - resetClaudeLiveSessionsForTest(); }); const CLAUDE_OK_JSONL = `${JSON.stringify({ type: "result", result: "ok" })}\n`; @@ -248,7 +233,7 @@ describe("runCliAgent spawn path", () => { writeCliSystemPromptFile: writeSystemPrompt, invokeNodeClaudeCliRun: invokeNode, }); - const context = buildClaudeLiveRunContext({ + const context = buildPreparedCliRunContext({ model: "claude-opus-4-8", runId: "run-node-claude", prompt: "current turn", @@ -316,7 +301,7 @@ describe("runCliAgent spawn path", () => { expect(output).toMatchObject({ text: "node answer", sessionId: "forked-node-session" }); // Node runs keep the gateway's native tool policy; loopback MCP tools do // not exist on the node so the OpenClaw list is projected empty. - expect(toolAvailability).toEqual({ native: [], openClaw: [], mcp: [] }); + expect(toolAvailability).toEqual({ native: [], openClaw: [] }); expect(writeSystemPrompt).not.toHaveBeenCalled(); expect(supervisorSpawnMock).not.toHaveBeenCalled(); expect(invokeNode).toHaveBeenCalledWith( @@ -374,7 +359,7 @@ describe("runCliAgent spawn path", () => { }; }); setCliRunnerExecuteTestDeps({ invokeNodeClaudeCliRun: invokeNode }); - const context = buildClaudeLiveRunContext({ + const context = buildPreparedCliRunContext({ model: "claude-fable-5", runId: `run-node-context-${testCase.selection}`, sessionEntry: { @@ -423,7 +408,7 @@ describe("runCliAgent spawn path", () => { }; }); setCliRunnerExecuteTestDeps({ invokeNodeClaudeCliRun: invokeNode }); - const context = buildClaudeLiveRunContext({ + const context = buildPreparedCliRunContext({ model: "claude-opus-4-8", runId: "run-node-synthetic-empty", prompt: "current turn", @@ -456,7 +441,7 @@ describe("runCliAgent spawn path", () => { }; }); setCliRunnerExecuteTestDeps({ invokeNodeClaudeCliRun: invokeNode }); - const context = buildClaudeLiveRunContext({ + const context = buildPreparedCliRunContext({ model: "claude-opus-4-8", prompt: "current turn", sessionEntry: { @@ -773,52 +758,15 @@ describe("runCliAgent spawn path", () => { }), ); - const backendConfig = { - command: "claude", - args: ["-p", "--output-format", "stream-json"], - output: "jsonl" as const, - input: "stdin" as const, - modelArg: "--model", - sessionArgs: ["--session-id", "{sessionId}"], - systemPromptArg: "--append-system-prompt", - systemPromptWhen: "first" as const, - serialize: true, - }; - const context: PreparedCliRunContext = { - params: { - admittedRunContext: createTestAdmittedRunContext("run-no-tools-disabled"), - sessionId: "s1", - sessionFile: "/tmp/session.jsonl", - workspaceDir: "/tmp", - prompt: "Run: node script.mjs", - provider: "claude-cli", - model: "sonnet", - timeoutMs: 1_000, - runId: "run-no-tools-disabled", - extraSystemPrompt: "You are a helpful assistant.", + const context = buildPreparedCliRunContext({ + runId: "run-no-tools-disabled", + prompt: "Run: node script.mjs", + backend: { + systemPromptArg: "--append-system-prompt", + systemPromptFileArg: undefined, }, - started: Date.now(), - workspaceDir: "/tmp", - backendResolved: { - id: "claude-cli", - config: backendConfig, - bundleMcp: true, - pluginId: "anthropic", - }, - preparedBackend: { - backend: backendConfig, - env: {}, - }, - reusableCliSession: { mode: "none" }, - hadSessionFile: false, - contextEngineConfig: {}, - modelId: "sonnet", - normalizedModel: "sonnet", - systemPrompt: "You are a helpful assistant.", - systemPromptReport: {} as PreparedCliRunContext["systemPromptReport"], - bootstrapPromptWarningLines: [], - authEpochVersion: 2, - }; + }); + context.params.extraSystemPrompt = "You are a helpful assistant."; await executePreparedCliRun(context); const input = mockCallArg(supervisorSpawnMock) as { argv?: string[] }; @@ -1307,10 +1255,7 @@ describe("runCliAgent spawn path", () => { expect(resolveExecutionArgs).toHaveBeenCalledWith( expect.objectContaining({ - toolAvailability: { - ...toolAvailability, - mcp: ["mcp__openclaw__openclaw"], - }, + toolAvailability, }), ); }); @@ -1632,86 +1577,6 @@ describe("runCliAgent spawn path", () => { } }); - it("passes OpenClaw skills to Claude as a session plugin", async () => { - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-skills-")); - const skillDir = path.join(workspaceDir, "skills", "weather"); - await fs.mkdir(skillDir, { recursive: true }); - await fs.writeFile( - path.join(skillDir, "SKILL.md"), - [ - "---", - "name: weather", - "description: Use weather tools for forecasts.", - "---", - "", - "Read forecast data before replying.", - ].join("\n"), - "utf-8", - ); - - let pluginDir = ""; - supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { argv?: string[] }; - pluginDir = requireArgAfter(input.argv, "--plugin-dir"); - const manifest = JSON.parse( - await fs.readFile(path.join(pluginDir, ".claude-plugin", "plugin.json"), "utf-8"), - ) as { name?: string; skills?: string }; - expect(manifest.name).toBe("openclaw-skills"); - expect(manifest.skills).toBe("./skills"); - await expect( - fs.readFile(path.join(pluginDir, "skills", "weather", "SKILL.md"), "utf-8"), - ).resolves.toContain("Read forecast data before replying."); - return createManagedRun({ - reason: "exit", - exitCode: 0, - exitSignal: null, - durationMs: 50, - stdout: CLAUDE_OK_JSONL, - stderr: "", - timedOut: false, - noOutputTimedOut: false, - }); - }); - - try { - await executePreparedCliRun( - buildPreparedCliRunContext({ - workspaceDir, - skillsSnapshot: { - prompt: "", - skills: [{ name: "weather" }], - resolvedSkills: [ - { - name: "weather", - description: "Use weather tools for forecasts.", - filePath: path.join(skillDir, "SKILL.md"), - baseDir: skillDir, - source: "test", - sourceInfo: { - path: skillDir, - source: "test", - scope: "project", - origin: "top-level", - baseDir: skillDir, - }, - disableModelInvocation: false, - }, - ], - }, - }), - ); - let accessError: unknown; - try { - await fs.access(pluginDir); - } catch (error) { - accessError = error; - } - expect((accessError as NodeJS.ErrnoException | undefined)?.code).toBe("ENOENT"); - } finally { - await fs.rm(workspaceDir, { recursive: true, force: true }); - } - }); - it("injects skill env overrides into CLI child env and restores host env", async () => { const previousEnvValue = process.env.CLI_SKILL_API_KEY; delete process.env.CLI_SKILL_API_KEY; @@ -2152,361 +2017,6 @@ describe("runCliAgent spawn path", () => { } }); - it("keeps one managed Claude model call open until background task results drain", async () => { - let stdoutListener: ((chunk: string) => void) | undefined; - const writes: string[] = []; - const cancel = vi.fn(); - const interimChunk = - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-trace" }), - JSON.stringify({ - type: "assistant", - session_id: "live-trace", - message: { - role: "assistant", - content: [{ type: "text", text: "working" }], - usage: { input_tokens: 4, output_tokens: 1, cache_read_input_tokens: 20 }, - }, - }), - JSON.stringify({ - type: "system", - subtype: "background_tasks_changed", - tasks: [{ task_id: "task-1", task_type: "local_agent", description: "research" }], - }), - JSON.stringify({ - type: "result", - subtype: "success", - session_id: "live-trace", - result: "working", - usage: { input_tokens: 5, output_tokens: 1, cache_read_input_tokens: 25 }, - }), - ].join("\n") + "\n"; - const finalChunk = - [ - JSON.stringify({ type: "system", subtype: "background_tasks_changed", tasks: [] }), - JSON.stringify({ - type: "assistant", - session_id: "live-trace", - message: { - role: "assistant", - content: [{ type: "text", text: "finished" }], - usage: { input_tokens: 6, output_tokens: 2, cache_read_input_tokens: 30 }, - }, - }), - JSON.stringify({ - type: "result", - subtype: "success", - session_id: "live-trace", - result: "finished", - usage: { - input_tokens: 10, - output_tokens: 3, - cache_read_input_tokens: 50, - cache_creation_input_tokens: 2, - }, - }), - ].join("\n") + "\n"; - const stdin = { - write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { - writes.push(data); - emitClaudeInputStarted(stdoutListener, data); - stdoutListener?.(interimChunk); - cb?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "live-model-call", - pid: 2345, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel, - }; - }); - const diagnostics = captureModelCallDiagnostics("run-live-model-call-background"); - - try { - const run = executePreparedCliRun( - buildClaudeLiveRunContext({ - model: "claude-sonnet-4-6", - runId: "run-live-model-call-background", - prompt: "research this", - config: { - diagnostics: { - enabled: true, - otel: { - enabled: true, - traces: true, - captureContent: true, - }, - }, - }, - }), - ); - await vi.waitFor(() => expect(writes).toHaveLength(1)); - await waitForDiagnosticEventsDrained(); - expect(diagnostics.events.map(({ event }) => event.type)).toEqual(["model.call.started"]); - - stdoutListener?.(finalChunk); - const output = await run; - await waitForDiagnosticEventsDrained(); - - expect(output.text).toContain("working"); - expect(output.text).toContain("finished"); - expect(output.usage).toEqual({ - input: 6, - output: 2, - cacheRead: 30, - cacheWrite: undefined, - total: undefined, - }); - expectModelCallTypes(diagnostics, ["model.call.started", "model.call.completed"]); - const completed = diagnostics.events[1]; - const inputUuid = (JSON.parse(writes[0] ?? "{}") as { uuid?: string }).uuid; - const lifecycleChunk = `${JSON.stringify({ - type: "command_lifecycle", - command_uuid: inputUuid, - state: "started", - })}\n`; - expect(completed?.event).toMatchObject({ - api: "claude-code", - transport: "stdio-live", - observationUnit: "turn", - requestPayloadBytes: Buffer.byteLength(writes[0] ?? ""), - responseStreamBytes: - Buffer.byteLength(lifecycleChunk) + - Buffer.byteLength(interimChunk) + - Buffer.byteLength(finalChunk), - usage: { - input: 10, - output: 3, - cacheRead: 50, - cacheWrite: 2, - }, - }); - expect(completed?.privateData.modelContent?.outputMessages).toEqual([ - { role: "assistant", content: [{ type: "text", text: "working" }] }, - { role: "assistant", content: [{ type: "text", text: "finished" }] }, - ]); - expect(cancel).not.toHaveBeenCalled(); - } finally { - diagnostics.stop(); - } - }); - - it("emits one terminal model-call error for a managed Claude result failure", async () => { - mockClaudeLiveRun(supervisorSpawnMock, { - runId: "live-model-call-error", - pid: 2346, - events: [ - { - type: "result", - subtype: "error_during_execution", - is_error: true, - session_id: "live-error", - result: "managed turn failed", - usage: { input_tokens: 8, output_tokens: 2, cache_read_input_tokens: 40 }, - }, - ], - }); - const diagnostics = captureModelCallDiagnostics("run-live-model-call-error"); - - try { - await expect( - executePreparedCliRun( - buildClaudeLiveRunContext({ - model: "claude-sonnet-4-6", - runId: "run-live-model-call-error", - }), - ), - ).rejects.toThrow(/managed turn failed/i); - await waitForDiagnosticEventsDrained(); - - expectModelCallTypes(diagnostics, ["model.call.started", "model.call.error"]); - expect(diagnostics.events[1]?.event).toMatchObject({ - transport: "stdio-live", - usage: { input: 8, output: 2, cacheRead: 40 }, - }); - } finally { - diagnostics.stop(); - } - }); - - it("extends the live no-output watchdog to the blocked-tool floor while a tool is outstanding", async () => { - const toolErrorEvents: Array> = []; - const stopDiagnostics = onTrustedToolExecutionEvent((event) => { - if (event.type === "tool.execution.error") { - toolErrorEvents.push(event as unknown as Record); - } - }); - let stdoutListener: ((chunk: string) => void) | undefined; - const cancel = vi.fn(); - const stdin = { - write: vi.fn((data: string, callback?: (error?: Error | null) => void) => { - emitClaudeInputStarted(stdoutListener, data); - stdoutListener?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-quiet-tool" }), - JSON.stringify({ - type: "assistant", - message: { - content: [{ type: "tool_use", id: "tool-quiet-1", name: "Bash", input: {} }], - }, - }), - ].join("\n") + "\n", - ); - callback?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - pid: 2345, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel, - }; - }); - - const run = executePreparedCliRun( - buildClaudeLiveRunContext({ - timeoutMs: 3_600_000, - }), - ); - const rejection = run.then( - () => undefined, - (error: unknown) => error, - ); - await vi.waitFor(() => { - expect(stdin.write).toHaveBeenCalledOnce(); - }); - - // Fake the clock only after the spawn path settled, then emit one more - // stdout line so the watchdog re-arms on the faked setTimeout/Date. - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); - stdoutListener?.( - `${JSON.stringify({ - type: "stream_event", - event: { type: "content_block_delta", delta: { type: "text_delta", text: "running" } }, - })}\n`, - ); - - // Base watchdog (600s cap for a 1h budget) must not kill the quiet tool. - vi.advanceTimersByTime(650_000); - expect(cancel).not.toHaveBeenCalled(); - - // The blocked-tool floor (15min of quiet) still terminates a wedged tool. - try { - vi.advanceTimersByTime(300_000); - expect(cancel).toHaveBeenCalledWith("manual-cancel"); - const error = await rejection; - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toMatch(/produced no output for 900s/); - // Watchdog-killed turns must keep timeout provenance for active tools. - expect(toolErrorEvents).toContainEqual( - expect.objectContaining({ - toolCallId: "tool-quiet-1", - terminalReason: "timed_out", - }), - ); - } finally { - stopDiagnostics(); - } - }); - - it("keeps non-capture live prepared backend cleanup with the whole-run owner", async () => { - mockClaudeLiveRun(supervisorSpawnMock, { - runId: "live-cleanup-run", - pid: 2346, - events: [ - { type: "system", subtype: "init", session_id: "live-session-cleanup" }, - { type: "result", session_id: "live-session-cleanup", result: "ok" }, - ], - }); - const preparedBackendCleanup = vi.fn(async () => {}); - const context = buildClaudeLiveRunContext({ - prompt: "first", - backend: { - args: ["-p", "--strict-mcp-config", "--mcp-config", "/tmp/mcp-cleanup.json"], - }, - mcpConfigHash: "cleanup-mcp-config", - }); - context.preparedBackend.cleanup = preparedBackendCleanup; - - const result = await executePreparedCliRun(context); - - expect(result.text).toBe("ok"); - expect(context.preparedBackend.cleanup).toBe(preparedBackendCleanup); - expect(preparedBackendCleanup).not.toHaveBeenCalled(); - - resetClaudeLiveSessionsForTest(); - expect(preparedBackendCleanup).not.toHaveBeenCalled(); - await context.preparedBackend.cleanup?.(); - expect(preparedBackendCleanup).toHaveBeenCalledOnce(); - }); - - it("keeps captured live prepared backend cleanup with the whole-run owner", async () => { - const mcpConfigDir = await fs.mkdtemp( - path.join(os.tmpdir(), "openclaw-cli-captured-mcp-config-"), - ); - const mcpConfigPath = path.join(mcpConfigDir, "mcp.json"); - await fs.writeFile( - mcpConfigPath, - `${JSON.stringify( - { - mcpServers: { - openclaw: { - type: "http", - url: "http://127.0.0.1:23119/mcp", - headers: {}, - }, - }, - }, - null, - 2, - )}\n`, - "utf-8", - ); - try { - mockClaudeLiveRun(supervisorSpawnMock, { - cancelable: true, - pid: 2347, - events: [ - { type: "system", subtype: "init", session_id: "captured-live-cleanup" }, - { type: "result", session_id: "captured-live-cleanup", result: "ok" }, - ], - }); - const preparedBackendCleanup = vi.fn(async () => {}); - const context = buildClaudeLiveRunContext({ - prompt: "first", - backend: { - args: ["-p", "--strict-mcp-config", "--mcp-config", mcpConfigPath], - }, - mcpConfigHash: "captured-cleanup-mcp-config", - mcpDeliveryCapture: true, - }); - context.preparedBackend.cleanup = preparedBackendCleanup; - - const result = await executePreparedCliRun(context); - - expect(result.text).toBe("ok"); - expect(context.preparedBackend.cleanup).toBe(preparedBackendCleanup); - expect(preparedBackendCleanup).not.toHaveBeenCalled(); - - await context.preparedBackend.cleanup?.(); - expect(preparedBackendCleanup).toHaveBeenCalledOnce(); - } finally { - await fs.rm(mcpConfigDir, { recursive: true, force: true }); - } - }); - it("preserves completed output when system prompt cleanup fails after delivery", async () => { const cleanupError = new Error("system prompt cleanup failed"); const logWarnSpy = vi.spyOn(cliBackendLog, "warn").mockImplementation(() => undefined); diff --git a/src/agents/cli-runner.test-helpers.ts b/src/agents/cli-runner.test-helpers.ts index e7d94d8a3499..c094932b5d4e 100644 --- a/src/agents/cli-runner.test-helpers.ts +++ b/src/agents/cli-runner.test-helpers.ts @@ -9,13 +9,7 @@ import { type DiagnosticEventPayload, type DiagnosticEventPrivateData, } from "../infra/diagnostic-events.js"; -import type { ExecApprovalsFile } from "../infra/exec-approvals-core.js"; -import { saveExecApprovals } from "../infra/exec-approvals-store.js"; -import { testing as execApprovalsStoreTesting } from "../infra/exec-approvals-store.test-support.js"; import type { CliBackendPlugin } from "../plugins/cli-backend.types.js"; -import type { RunExit } from "../process/supervisor/types.js"; -import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; -import { withEnvAsync } from "../test-utils/env.js"; import { createTestAdmittedRunContext } from "./admitted-run-context.test-support.js"; import type { PreparedCliRunContext, RunCliAgentParams } from "./cli-runner/types.js"; @@ -79,13 +73,6 @@ export function createTestMcpLoopbackServerConfig(port: number) { }; } -export function createClaudeInputStartedEvent(data: string) { - const input = JSON.parse(data) as { type?: string; uuid?: string }; - return input.type === "user" && typeof input.uuid === "string" - ? { type: "command_lifecycle" as const, command_uuid: input.uuid, state: "started" as const } - : undefined; -} - export function createTestMcpLoopbackClientGrant(params: { context: McpLoopbackRequestContext; }): McpLoopbackClientGrant { @@ -119,7 +106,7 @@ export function buildDefaultTestCliBackend( }; } -export type PreparedCliRunContextOverrides = { +type PreparedCliRunContextOverrides = { provider?: CliProvider; model?: string; runId?: string; @@ -146,7 +133,6 @@ export type PreparedCliRunContextOverrides = { timeoutMs?: number; onSuccessfulAuthBinding?: PreparedCliRunContext["params"]["onSuccessfulAuthBinding"]; runtimeArtifact?: PreparedCliRunContext["backendResolved"]["runtimeArtifact"]; - liveSessionRequirement?: PreparedCliRunContext["backendResolved"]["liveSessionRequirement"]; }; export function buildPreparedCliRunContext( @@ -242,7 +228,6 @@ export function buildPreparedCliRunContext( overrides.toolAvailabilityEnforcement ?? (provider === "google-gemini-cli" ? "prepare-execution" : "execution-args"), runtimeArtifact: overrides.runtimeArtifact, - liveSessionRequirement: overrides.liveSessionRequirement, }, preparedBackend: { backend, @@ -259,39 +244,11 @@ export function buildPreparedCliRunContext( systemPromptReport: {} as PreparedCliRunContext["systemPromptReport"], bootstrapPromptWarningLines: [], authEpochVersion: 2, + claudeSkillsPluginArgs: [], ...(overrides.mcpDeliveryCapture ? { mcpDeliveryCapture: true } : {}), }; } -export function buildClaudeLiveRunContext(overrides: PreparedCliRunContextOverrides = {}) { - return buildPreparedCliRunContext({ - ...overrides, - backend: { ...overrides.backend, liveSession: "claude-stdio" }, - }); -} - -export function createCancelableLiveRunLifecycle() { - let resolveExit!: (exit: RunExit) => void; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - return { - wait: vi.fn(() => exited), - cancel: vi.fn((_reason?: string) => { - resolveExit({ - reason: "manual-cancel", - exitCode: null, - exitSignal: null, - durationMs: 1, - stdout: "", - stderr: "", - timedOut: false, - noOutputTimedOut: false, - }); - }), - }; -} - export function requireArgAfter(argv: string[] | undefined, flag: string): string { const index = argv?.indexOf(flag) ?? -1; if (index < 0) { @@ -353,34 +310,6 @@ export async function expectPathMissing(targetPath: string) { throw new Error(`expected ${targetPath} to be missing`); } -export async function withTempExecApprovalsState( - file: Record, - run: () => Promise, -) { - const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-exec-approvals-")); - const stateDir = path.join(home, ".openclaw"); - try { - await withEnvAsync({ HOME: home, OPENCLAW_STATE_DIR: stateDir }, async () => { - execApprovalsStoreTesting.reset(); - saveExecApprovals(file as ExecApprovalsFile); - await run(); - }); - } finally { - closeOpenClawStateDatabaseForTest(); - execApprovalsStoreTesting.reset(); - await fs.promises.rm(home, { recursive: true, force: true }); - } -} - -export async function withTempOpenClawHome(run: (home: string) => Promise) { - const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-home-")); - try { - await withEnvAsync({ OPENCLAW_HOME: home }, async () => run(home)); - } finally { - await fs.promises.rm(home, { recursive: true, force: true }); - } -} - type PrepareCliRun = (params: RunCliAgentParams) => Promise; export function createCliRunnerPrepareFixture(prepareCliRun: PrepareCliRun) { @@ -513,163 +442,3 @@ export function createWeatherSkillFixture(root: string, materialized: boolean) { } satisfies NonNullable, }; } - -type SupervisorSpawnMock = (typeof import("./cli-runner.test-support.js"))["supervisorSpawnMock"]; - -type ClaudeLiveRunFixture = ReturnType; - -export function mockClaudeLiveRun( - spawnMock: SupervisorSpawnMock, - options: { - cancelable?: boolean; - beforeSpawn?: () => Promise; - events?: Array | string>; - inputLifecycle?: boolean; - exitImmediately?: RunExit; - exitOnWrite?: RunExit; - onWrite?: (params: { - data: string; - emit: (events: Array | string>) => void; - writeIndex: number; - }) => void; - runId?: string; - pid?: number; - } = {}, -) { - let stdoutListener: ((chunk: string) => void) | undefined; - let resolveExit: ((exit: RunExit) => void) | undefined; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - let spawnInput: { - argv?: string[]; - env?: Record; - onStdout?: (chunk: string) => void; - } = {}; - const writes: string[] = []; - const emit = (events: Array | string>) => { - stdoutListener?.( - `${events.map((event) => (typeof event === "string" ? event : JSON.stringify(event))).join("\n")}\n`, - ); - }; - const stdin = { - write: vi.fn((data: string, callback?: (error?: Error | null) => void) => { - writes.push(data); - const writeIndex = writes.length - 1; - const inputStartedEvent = createClaudeInputStartedEvent(data); - if (options.inputLifecycle !== false && inputStartedEvent) { - emit([inputStartedEvent]); - } - if (options.onWrite) { - options.onWrite({ data, emit, writeIndex }); - } else if (writeIndex === 0 && options.events) { - emit(options.events); - } - callback?.(); - if (options.exitOnWrite) { - resolveExit?.(options.exitOnWrite); - } - }), - end: vi.fn(), - }; - const lifecycle = options.cancelable - ? createCancelableLiveRunLifecycle() - : { - wait: vi.fn(() => - options.exitImmediately - ? Promise.resolve(options.exitImmediately) - : options.exitOnWrite - ? exited - : new Promise(() => {}), - ), - cancel: vi.fn(), - }; - spawnMock.mockImplementationOnce(async (...args: unknown[]) => { - spawnInput = (args[0] ?? {}) as typeof spawnInput; - stdoutListener = spawnInput.onStdout; - await options.beforeSpawn?.(); - return { - runId: options.runId ?? "live-run", - pid: options.pid ?? 2345, - startedAtMs: Date.now(), - stdin, - ...lifecycle, - }; - }); - return { - emit, - get spawnInput() { - return spawnInput; - }, - stdin, - lifecycle, - writes, - }; -} - -export function buildClaudeControlRequestEvents(params: { - requestId: string; - toolUseId: string; - input: Record; - sessionId?: string; - toolName?: string; -}) { - const sessionId = params.sessionId ?? "live-control"; - return [ - { - type: "control_request", - request_id: params.requestId, - request: { - subtype: "can_use_tool", - tool_name: params.toolName ?? "Bash", - tool_use_id: params.toolUseId, - input: params.input, - }, - }, - { type: "system", subtype: "init", session_id: sessionId }, - { type: "result", session_id: sessionId, result: "ok" }, - ]; -} - -export function expectClaudeControlDecision( - fixture: ClaudeLiveRunFixture, - expected: { - behavior: "allow" | "deny"; - requestId: string; - toolUseId?: string; - updatedInput?: Record; - messageIncludes?: string; - }, -) { - const encoded = fixture.writes.find((entry) => entry.includes('"control_response"')); - expect(encoded, "control_response written to stdin").toBeDefined(); - const parsed = JSON.parse((encoded ?? "").trim()) as { - type: string; - response: { - subtype: string; - request_id: string; - response: { - behavior: string; - decisionClassification?: string; - message?: string; - toolUseID?: string; - updatedInput?: unknown; - }; - }; - }; - expect(parsed.type).toBe("control_response"); - expect(parsed.response.subtype).toBe("success"); - expect(parsed.response.request_id).toBe(expected.requestId); - expect(parsed.response.response.behavior).toBe(expected.behavior); - if (expected.toolUseId) { - expect(parsed.response.response.toolUseID).toBe(expected.toolUseId); - } - if (expected.updatedInput) { - expect(parsed.response.response.updatedInput).toEqual(expected.updatedInput); - } - if (expected.messageIncludes) { - expect(parsed.response.response.decisionClassification).toBe("user_reject"); - expect(parsed.response.response.message).toContain(expected.messageIncludes); - } - return parsed; -} diff --git a/src/agents/cli-runner.test-support.ts b/src/agents/cli-runner.test-support.ts index a8295d6aec73..9d92c22e0d90 100644 --- a/src/agents/cli-runner.test-support.ts +++ b/src/agents/cli-runner.test-support.ts @@ -1,7 +1,6 @@ /** Shared CLI runner test doubles for supervisor, bootstrap, and heartbeat seams. */ import type { Mock } from "vitest"; import { beforeEach, vi } from "vitest"; -import { getClaudeGeneration } from "./cli-runner/claude-live-registry.js"; import { setCliRunnerPrepareTestDeps } from "./cli-runner/prepare.test-support.js"; import type { EmbeddedContextFile } from "./embedded-agent-helpers.js"; import type { WorkspaceBootstrapFile } from "./workspace.js"; @@ -40,16 +39,6 @@ setCliRunnerPrepareTestDeps({ resolveOpenClawReferencePaths: async () => ({ docsPath: null, sourcePath: null }), }); -/** Restore prepare-time CLI runner test dependencies after a test overrides them. */ -export function restoreCliRunnerPrepareTestDeps() { - setCliRunnerPrepareTestDeps({ - makeBootstrapWarn: () => () => {}, - resolveBootstrapContextForRun: hoisted.resolveBootstrapContextForRunMock, - resolveOpenClawReferencePaths: async () => ({ docsPath: null, sourcePath: null }), - getClaudeGeneration, - }); -} - beforeEach(() => { vi.unstubAllEnvs(); }); diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 2382975eb29e..416c173ccf97 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -28,7 +28,7 @@ import { markAuthProfileSuccess, } from "./auth-profiles.js"; import { resolveCliBackendConfig } from "./cli-backends.js"; -import { acceptsClaudeLive } from "./cli-runner/claude-live-session-policy.js"; +import { acceptsCliLiveSession } from "./cli-runner/cli-live-session-registry.js"; import { resolveCliSessionId, runCliRecovery, @@ -568,7 +568,7 @@ export async function runPreparedCliAgent( effectiveCliSessionId, params.provider, context.cwd ?? context.workspaceDir, - { skipTranscriptProbe: acceptsClaudeLive(context) }, + { skipTranscriptProbe: acceptsCliLiveSession(context) }, ); const interruptionError = terminalInterruption ? formatCliTerminalInterruption(terminalInterruption) diff --git a/src/agents/cli-runner/claude-live-background-tasks.test.ts b/src/agents/cli-runner/claude-live-background-tasks.test.ts deleted file mode 100644 index b8800c6ee799..000000000000 --- a/src/agents/cli-runner/claude-live-background-tasks.test.ts +++ /dev/null @@ -1,576 +0,0 @@ -/** Claude live turns: provisional results while native or queued work continues. */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - setDiagnosticsEnabledForProcess, - waitForDiagnosticEventsDrained, -} from "../../infra/diagnostic-events.js"; -import { - BLOCKED_TOOL_CALL_ABORT_FLOOR_MS, - getDiagnosticSessionActivitySnapshot, - resetDiagnosticRunActivityForTest, - startDiagnosticRunActivityTracking, -} from "../../logging/diagnostic-run-activity.js"; -import type { getProcessSupervisor } from "../../process/supervisor/index.js"; -import { createTestAdmittedRunContext } from "../admitted-run-context.test-support.js"; -import { - restoreCliRunnerPrepareTestDeps, - supervisorSpawnMock, -} from "../cli-runner.test-support.js"; -import { runClaudeTurn } from "./claude-live-session.js"; -import { resetClaudeLiveSessionsForTest } from "./claude-live-session.test-support.js"; -import { setCliRunnerExecuteTestDeps } from "./execute.test-support.js"; -import { writeCliSystemPromptFile } from "./helpers.js"; -import type { PreparedCliRunContext } from "./types.js"; - -type ProcessSupervisor = ReturnType; -type SupervisorSpawnFn = ProcessSupervisor["spawn"]; - -beforeEach(() => { - setDiagnosticsEnabledForProcess(true); - resetDiagnosticRunActivityForTest(); - startDiagnosticRunActivityTracking(); - resetClaudeLiveSessionsForTest(); - restoreCliRunnerPrepareTestDeps(); - setCliRunnerExecuteTestDeps({ writeCliSystemPromptFile }); - supervisorSpawnMock.mockClear(); -}); - -afterEach(() => { - vi.restoreAllMocks(); - vi.useRealTimers(); - resetDiagnosticRunActivityForTest(); - resetClaudeLiveSessionsForTest(); -}); - -function buildPreparedCliRunContext(params: { - runId: string; - timeoutMs?: number; - sessionId?: string; - sessionKey?: string; - credentialFingerprint?: string; -}): PreparedCliRunContext { - const backend = { - command: "claude", - args: ["-p", "--output-format", "stream-json"], - output: "jsonl" as const, - input: "stdin" as const, - modelArg: "--model", - sessionArgs: ["--session-id", "{sessionId}"], - sessionMode: "always" as const, - systemPromptFileArg: "--append-system-prompt-file", - systemPromptWhen: "first" as const, - serialize: true, - liveSession: "claude-stdio" as const, - }; - return { - params: { - admittedRunContext: createTestAdmittedRunContext(params.runId), - sessionId: params.sessionId ?? "s-bg", - sessionKey: params.sessionKey ?? "agent:main:bg", - sessionFile: "/tmp/session.jsonl", - workspaceDir: "/tmp", - prompt: "hi", - provider: "claude-cli", - model: "sonnet", - timeoutMs: params.timeoutMs ?? 60_000, - runId: params.runId, - }, - started: Date.now(), - workspaceDir: "/tmp", - backendResolved: { - id: "claude-cli", - config: backend, - bundleMcp: true, - pluginId: "anthropic", - }, - preparedBackend: { - backend, - env: {}, - ...(params.credentialFingerprint - ? { - secretInput: { - fd: 3, - fingerprint: params.credentialFingerprint, - createData: () => Buffer.from("secret"), - }, - } - : {}), - }, - reusableCliSession: { mode: "none" }, - hadSessionFile: false, - contextEngineConfig: {}, - modelId: "sonnet", - normalizedModel: "sonnet", - systemPrompt: "You are a helpful assistant.", - systemPromptReport: {} as PreparedCliRunContext["systemPromptReport"], - bootstrapPromptWarningLines: [], - authEpochVersion: 2, - }; -} - -function getProcessSupervisorForTest() { - return { - spawn: (params: Parameters[0]) => - supervisorSpawnMock(params) as ReturnType, - cancel: vi.fn(), - cancelScope: vi.fn(), - getRecord: vi.fn(), - }; -} - -function installLiveStdoutDriver(params?: { - onWrite?: (stdout: (chunk: string) => void) => void; - autoStart?: boolean; -}): { - cancel: ReturnType; - userInputUuids: string[]; - stdout: { - emit: (chunk: string) => void; - startCurrentInput: () => void; - waitReady: () => Promise; - }; -} { - let stdoutListener: ((chunk: string) => void) | undefined; - const cancel = vi.fn(); - const userInputUuids: string[] = []; - let markReady: (() => void) | undefined; - const ready = new Promise((resolve) => { - markReady = resolve; - }); - const stdin = { - write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { - const parsed = JSON.parse(data) as { type?: string; uuid?: string }; - if (parsed.type === "user" && typeof parsed.uuid === "string") { - userInputUuids.push(parsed.uuid); - if (params?.autoStart !== false) { - stdoutListener?.( - jsonl([{ type: "command_lifecycle", command_uuid: parsed.uuid, state: "started" }]), - ); - } - } - if (stdoutListener && params?.onWrite) { - params.onWrite(stdoutListener); - } - cb?.(); - markReady?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "live-bg-run", - pid: 4242, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel, - }; - }); - return { - cancel, - userInputUuids, - stdout: { - emit: (chunk: string) => { - stdoutListener?.(chunk); - }, - startCurrentInput: () => { - const inputUuid = userInputUuids.at(-1); - if (!inputUuid) { - throw new Error("Claude input UUID was not written"); - } - stdoutListener?.( - jsonl([{ type: "command_lifecycle", command_uuid: inputUuid, state: "started" }]), - ); - }, - waitReady: () => ready, - }, - }; -} - -function jsonl(lines: unknown[]): string { - return lines.map((line) => JSON.stringify(line)).join("\n") + "\n"; -} - -function startLiveTurn(params: { - runId: string; - timeoutMs?: number; - noOutputTimeoutMs?: number; - useResume?: boolean; - onPhase?: (phase: "send" | "resolve") => void; - credentialFingerprint?: string; -}) { - const context = buildPreparedCliRunContext({ - runId: params.runId, - timeoutMs: params.timeoutMs, - credentialFingerprint: params.credentialFingerprint, - }); - return runClaudeTurn({ - context, - args: context.preparedBackend.backend.args ?? [], - env: {}, - prompt: "hi", - useResume: params.useResume ?? false, - noOutputTimeoutMs: params.noOutputTimeoutMs ?? 5_000, - getProcessSupervisor: getProcessSupervisorForTest, - onAssistantDelta: () => {}, - onPhase: params.onPhase, - cleanup: async () => {}, - }); -} - -describe("claude live session provisional results", () => { - it.each([ - { taskType: "local_agent", label: "subagent" }, - { taskType: "local_workflow", label: "workflow" }, - ] as const)( - "defers the interim success result until $taskType ($label) tasks drain", - async ({ taskType }) => { - const driver = installLiveStdoutDriver(); - const phases: Array<"send" | "resolve"> = []; - const resultPromise = startLiveTurn({ - runId: `run-bg-interim-${taskType}`, - onPhase: (phase) => phases.push(phase), - }); - await driver.stdout.waitReady(); - - // Tool spawn + authoritative outstanding-task list + immediate tool_result. - driver.stdout.emit( - jsonl([ - { type: "system", subtype: "init", session_id: "live-bg" }, - { - type: "assistant", - session_id: "live-bg", - message: { - role: "assistant", - content: [ - { - type: "tool_use", - id: "tool-agent-1", - name: "Agent", - input: { description: "research topic", prompt: "do work" }, - }, - ], - }, - }, - { - type: "system", - subtype: "background_tasks_changed", - tasks: [{ task_id: "task-1", task_type: taskType, description: "research topic" }], - }, - { - type: "system", - subtype: "task_started", - task_id: "task-1", - tool_use_id: "tool-agent-1", - subagent_type: "general-purpose", - }, - { - type: "user", - session_id: "live-bg", - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-agent-1", - content: "Background agent started", - }, - ], - }, - }, - { - type: "assistant", - session_id: "live-bg", - message: { - role: "assistant", - content: [{ type: "text", text: "Working on it in the background." }], - }, - }, - { - type: "result", - subtype: "success", - session_id: "live-bg", - result: "Working on it in the background.", - stop_reason: "end_turn", - }, - ]), - ); - - // Interim result must not resolve while a result-holding task is outstanding. - let settled = false; - void resultPromise.then( - () => { - settled = true; - }, - () => { - settled = true; - }, - ); - await Promise.resolve(); - expect(settled).toBe(false); - expect(phases).toEqual(["resolve", "send"]); - expect(driver.cancel).not.toHaveBeenCalled(); - await waitForDiagnosticEventsDrained(); - expect( - getDiagnosticSessionActivitySnapshot({ sessionKey: "agent:main:bg" }).lastProgressReason, - ).toBe("cli_live:result_deferred_background_tasks"); - - driver.stdout.emit( - jsonl([ - { - type: "system", - subtype: "task_notification", - task_id: "task-1", - status: "completed", - summary: "subagent final output", - }, - { type: "system", subtype: "background_tasks_changed", tasks: [] }, - { - type: "system", - subtype: "task_updated", - patch: { status: "completed" }, - }, - { type: "system", subtype: "init", session_id: "live-bg" }, - { - type: "assistant", - session_id: "live-bg", - message: { - role: "assistant", - content: [{ type: "text", text: "Subagent finished: subagent final output" }], - }, - }, - { - type: "result", - subtype: "success", - session_id: "live-bg", - result: "Subagent finished: subagent final output", - origin: { kind: "task-notification" }, - }, - ]), - ); - - const result = await resultPromise; - expect(phases).toEqual(["resolve", "send", "resolve"]); - expect(result.output.text).toContain("Working on it in the background."); - expect(result.output.text).toContain("Subagent finished: subagent final output"); - expect(driver.cancel).not.toHaveBeenCalled(); - }, - ); - - it("does not defer a success result for local_bash background tasks", async () => { - const driver = installLiveStdoutDriver({ - onWrite: (stdout) => { - stdout( - jsonl([ - { type: "system", subtype: "init", session_id: "live-bg-bash" }, - { - type: "system", - subtype: "background_tasks_changed", - tasks: [ - { - task_id: "bash-1", - task_type: "local_bash", - description: "background shell", - }, - ], - }, - { - type: "result", - subtype: "success", - session_id: "live-bg-bash", - result: "started bash in background", - stop_reason: "end_turn", - }, - ]), - ); - }, - }); - const result = await startLiveTurn({ runId: "run-bg-bash" }); - expect(result.output.text).toBe("started bash in background"); - expect(driver.cancel).not.toHaveBeenCalled(); - }); - - it("resolves a single success result immediately when no background tasks are outstanding", async () => { - const driver = installLiveStdoutDriver({ - onWrite: (stdout) => { - stdout( - jsonl([ - { type: "system", subtype: "init", session_id: "live-bg-none" }, - { - type: "assistant", - session_id: "live-bg-none", - message: { - role: "assistant", - content: [{ type: "text", text: "plain answer" }], - }, - }, - { - type: "result", - subtype: "success", - session_id: "live-bg-none", - result: "plain answer", - }, - ]), - ); - }, - }); - const result = await startLiveTurn({ runId: "run-bg-none" }); - expect(result.output.text).toBe("plain answer"); - expect(driver.cancel).not.toHaveBeenCalled(); - }); - - it("keeps a synthetic result provisional while background work continues", async () => { - const driver = installLiveStdoutDriver({ autoStart: false }); - const resultPromise = startLiveTurn({ runId: "run-synthetic-background" }); - await driver.stdout.waitReady(); - driver.stdout.startCurrentInput(); - driver.stdout.emit( - jsonl([ - { type: "system", subtype: "init", session_id: "live-synthetic-background" }, - { - type: "system", - subtype: "background_tasks_changed", - tasks: [{ task_id: "task-1", task_type: "local_agent" }], - }, - { - type: "assistant", - message: { - model: "", - content: [{ type: "text", text: "No response requested." }], - }, - }, - { type: "result", subtype: "success", result: "" }, - ]), - ); - let settled = false; - void resultPromise.then( - () => (settled = true), - () => (settled = true), - ); - await Promise.resolve(); - expect(settled).toBe(false); - - driver.stdout.emit( - jsonl([ - { type: "system", subtype: "background_tasks_changed", tasks: [] }, - { type: "result", subtype: "success", result: "background answer" }, - ]), - ); - - await expect(resultPromise).resolves.toMatchObject({ output: { text: "background answer" } }); - expect(driver.cancel).not.toHaveBeenCalled(); - }); - - it("does not no-output-abort while a background task is outstanding within the blocked-tool floor", async () => { - const driver = installLiveStdoutDriver(); - // Spawn with real timers so async supervisor setup settles, then fake the - // watchdog clock the same way as the blocked-tool live-session tests. - const resultPromise = startLiveTurn({ - runId: "run-bg-quiet", - timeoutMs: 3_600_000, - noOutputTimeoutMs: 1_000, - }); - await driver.stdout.waitReady(); - - driver.stdout.emit( - jsonl([ - { type: "system", subtype: "init", session_id: "live-bg-quiet" }, - { - type: "system", - subtype: "background_tasks_changed", - tasks: [{ task_id: "task-quiet", task_type: "local_agent", description: "long work" }], - }, - { - type: "result", - subtype: "success", - session_id: "live-bg-quiet", - result: "started", - stop_reason: "end_turn", - }, - ]), - ); - - await Promise.resolve(); - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); - // Re-arm the no-output watchdog against the faked clock after the interim result. - driver.stdout.emit( - `${JSON.stringify({ - type: "system", - subtype: "task_progress", - task_id: "task-quiet", - description: "still working", - })}\n`, - ); - - // Past the base no-output window (1s) but inside the blocked-tool floor (15m). - await vi.advanceTimersByTimeAsync(BLOCKED_TOOL_CALL_ABORT_FLOOR_MS - 1_000); - expect(driver.cancel).not.toHaveBeenCalled(); - - // Drain tasks and finish while still inside the floor window. - driver.stdout.emit( - jsonl([ - { type: "system", subtype: "background_tasks_changed", tasks: [] }, - { - type: "result", - subtype: "success", - session_id: "live-bg-quiet", - result: "done after wait", - origin: { kind: "task-notification" }, - }, - ]), - ); - - const result = await resultPromise; - expect(result.output.text).toContain("done after wait"); - expect(driver.cancel).not.toHaveBeenCalled(); - }); - - it("still aborts on overall turn timeout while waiting for a never-finishing background task", async () => { - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); - const driver = installLiveStdoutDriver(); - const resultPromise = startLiveTurn({ - runId: "run-bg-turn-timeout", - timeoutMs: 5_000, - noOutputTimeoutMs: 60_000, - }); - // Flush microtasks so the mocked supervisor spawn resolves under fake timers. - await vi.advanceTimersByTimeAsync(0); - await driver.stdout.waitReady(); - - driver.stdout.emit( - jsonl([ - { type: "system", subtype: "init", session_id: "live-bg-timeout" }, - { - type: "system", - subtype: "background_tasks_changed", - tasks: [{ task_id: "task-hang", task_type: "local_agent", description: "never ends" }], - }, - { - type: "result", - subtype: "success", - session_id: "live-bg-timeout", - result: "started hang", - stop_reason: "end_turn", - }, - ]), - ); - - const rejection = expect(resultPromise).rejects.toMatchObject({ - name: "FailoverError", - message: expect.stringMatching(/exceeded timeout/i), - code: "cli_overall_timeout", - cliTimeout: { - mode: "overall", - timeoutSeconds: 5, - observedActivity: true, - activeToolCount: 0, - backgroundTaskCount: 1, - }, - }); - await vi.advanceTimersByTimeAsync(5_000); - await rejection; - expect(driver.cancel).toHaveBeenCalledWith("manual-cancel"); - }); -}); diff --git a/src/agents/cli-runner/claude-live-process-approval.test.ts b/src/agents/cli-runner/claude-live-process-approval.test.ts deleted file mode 100644 index 8cd52db8f5be..000000000000 --- a/src/agents/cli-runner/claude-live-process-approval.test.ts +++ /dev/null @@ -1,528 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - markMcpLoopbackToolCallFinished, - markMcpLoopbackToolCallStarted, - recordMcpLoopbackToolCallResult, -} from "../../gateway/mcp-http.loopback-runtime.js"; -import { - onInternalDiagnosticEvent, - waitForDiagnosticEventsDrained, -} from "../../infra/diagnostic-events.js"; -import { PLUGIN_APPROVAL_DETAIL_MAX_LENGTH } from "../../infra/plugin-approvals.js"; -import { - buildClaudeControlRequestEvents, - buildClaudeLiveRunContext, - createCancelableLiveRunLifecycle, - createClaudeInputStartedEvent, - expectClaudeControlDecision, - mockClaudeLiveRun, -} from "../cli-runner.test-helpers.js"; -import { - restoreCliRunnerPrepareTestDeps, - supervisorSpawnMock, -} from "../cli-runner.test-support.js"; -import { callGatewayTool } from "../tools/gateway.js"; -import { resetClaudeLiveSessionsForTest } from "./claude-live-session.test-support.js"; -import { executePreparedCliRun } from "./execute.js"; - -vi.mock("../tools/gateway.js", () => ({ - callGatewayTool: vi.fn(), -})); - -const mockCallGatewayTool = vi.mocked(callGatewayTool); - -function emitClaudeInputStarted(stdout: ((chunk: string) => void) | undefined, data: string): void { - const event = createClaudeInputStartedEvent(data); - if (event) { - stdout?.(`${JSON.stringify(event)}\n`); - } -} - -beforeEach(() => { - resetClaudeLiveSessionsForTest(); - restoreCliRunnerPrepareTestDeps(); - supervisorSpawnMock.mockClear(); - mockCallGatewayTool.mockReset(); - mockCallGatewayTool.mockResolvedValue({ id: "claude-native-approval", decision: "deny" }); -}); - -afterEach(() => { - vi.restoreAllMocks(); - vi.useRealTimers(); - resetClaudeLiveSessionsForTest(); -}); - -describe("Claude live process approvals", () => { - it("answers Claude live control_request can_use_tool with allow when exec policy is full/no-ask", async () => { - const live = mockClaudeLiveRun(supervisorSpawnMock, { - events: buildClaudeControlRequestEvents({ - requestId: "req-allow", - toolUseId: "tool-allow-1", - input: { command: "ls" }, - sessionId: "live-control-allow", - }), - pid: 3001, - }); - - const result = await executePreparedCliRun( - buildClaudeLiveRunContext({ - prompt: "hello", - config: { tools: { exec: { security: "full", ask: "off" } } }, - }), - ); - expect(result.text).toBe("ok"); - expectClaudeControlDecision(live, { - behavior: "allow", - requestId: "req-allow", - toolUseId: "tool-allow-1", - updatedInput: { command: "ls" }, - }); - }); - - it.each([ - { - name: "session deny overrides broader global and agent full policy", - requestId: "req-session-security-deny", - toolUseId: "tool-session-security-deny-1", - context: () => - buildClaudeLiveRunContext({ - sessionKey: "agent:main:main", - sessionEntry: { - sessionId: "session-policy-test", - updatedAt: 1, - execSecurity: "deny", - }, - config: { - tools: { exec: { security: "full", ask: "off" } }, - agents: { - list: [ - { - id: "main", - default: true, - tools: { exec: { security: "full", ask: "off" } }, - }, - ], - }, - }, - }), - }, - { - name: "partial agent policy inherits restrictive global security", - requestId: "req-partial-agent-global-deny", - toolUseId: "tool-partial-agent-global-deny-1", - context: () => - buildClaudeLiveRunContext({ - sessionKey: "agent:main:main", - config: { - tools: { exec: { security: "deny", ask: "off" } }, - agents: { - list: [ - { - id: "main", - default: true, - tools: { exec: { ask: "off" } }, - }, - ], - }, - }, - }), - }, - ])("denies Claude live native tools when $name", async ({ requestId, toolUseId, context }) => { - const live = mockClaudeLiveRun(supervisorSpawnMock, { - events: buildClaudeControlRequestEvents({ - requestId, - toolUseId, - input: { command: "ls" }, - sessionId: requestId, - }), - }); - - const result = await executePreparedCliRun(context()); - - expect(result.text).toBe("ok"); - expectClaudeControlDecision(live, { - behavior: "deny", - requestId, - messageIncludes: "security=deny", - }); - expect(mockCallGatewayTool).not.toHaveBeenCalled(); - }); - - it("preserves image and PDF bytes inside approved Claude live control inputs", async () => { - const input = { - command: "process media", - image: { - type: "image", - source: { type: "base64", media_type: "image/png", data: "aGVsbG8=" }, - }, - document: { - type: "document", - source: { type: "base64", media_type: "application/pdf", data: "JVBERi0=" }, - }, - }; - const live = mockClaudeLiveRun(supervisorSpawnMock, { - events: buildClaudeControlRequestEvents({ - requestId: "req-allow-media", - toolUseId: "tool-allow-media", - input, - sessionId: "live-control-allow-media", - }), - }); - - const result = await executePreparedCliRun( - buildClaudeLiveRunContext({ - prompt: "hello", - config: { tools: { exec: { security: "full", ask: "off" } } }, - }), - ); - - expect(result.text).toBe("ok"); - const response = expectClaudeControlDecision(live, { - behavior: "allow", - requestId: "req-allow-media", - toolUseId: "tool-allow-media", - updatedInput: input, - }); - expect(JSON.stringify(response.response.response.updatedInput)).toBe(JSON.stringify(input)); - }); - - it("honors allow-once from a Claude native tool Gateway approval", async () => { - mockCallGatewayTool.mockResolvedValueOnce({ - id: "claude-native-allow-once", - decision: "allow-once", - }); - const live = mockClaudeLiveRun(supervisorSpawnMock, { - events: buildClaudeControlRequestEvents({ - requestId: "req-allow-once", - toolUseId: "tool-allow-once-1", - input: { command: "ls" }, - sessionId: "live-control-allow-once", - }), - pid: 3011, - }); - - const result = await executePreparedCliRun( - buildClaudeLiveRunContext({ - prompt: "hello", - config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, - }), - ); - - expect(result.text).toBe("ok"); - await vi.waitFor(() => - expect(live.writes.some((entry) => entry.includes('"control_response"'))).toBe(true), - ); - expectClaudeControlDecision(live, { - behavior: "allow", - requestId: "req-allow-once", - toolUseId: "tool-allow-once-1", - updatedInput: { command: "ls" }, - }); - expect(mockCallGatewayTool).toHaveBeenCalledWith( - "plugin.approval.request", - expect.any(Object), - expect.objectContaining({ - pluginId: "claude-cli", - toolName: "Bash", - toolCallId: "tool-allow-once-1", - }), - { expectFinal: false }, - ); - }); - - it("denies Claude Bash when an approved script operand changes before release", async () => { - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-claude-drift-")); - const scriptPath = path.join(workspaceDir, "script.sh"); - try { - await fs.writeFile(scriptPath, "#!/bin/sh\necho approved\n"); - mockCallGatewayTool - .mockResolvedValueOnce({ id: "claude-native-script-drift" }) - .mockImplementationOnce(async () => { - await fs.writeFile(scriptPath, "#!/bin/sh\necho mutated\n"); - return { id: "claude-native-script-drift", decision: "allow-once" }; - }); - const live = mockClaudeLiveRun(supervisorSpawnMock, { - events: buildClaudeControlRequestEvents({ - requestId: "req-script-drift", - toolUseId: "tool-script-drift", - input: { command: "sh script.sh" }, - sessionId: "live-script-drift", - }), - }); - - await executePreparedCliRun( - buildClaudeLiveRunContext({ - workspaceDir, - config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, - }), - ); - - await vi.waitFor(() => - expect(live.writes.some((entry) => entry.includes('"control_response"'))).toBe(true), - ); - expectClaudeControlDecision(live, { - behavior: "deny", - requestId: "req-script-drift", - messageIncludes: "approval script operand changed before execution", - }); - } finally { - await fs.rm(workspaceDir, { recursive: true, force: true }); - } - }); - - it("releases Claude Bash when the approved script operand is unchanged", async () => { - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-claude-stable-")); - const scriptPath = path.join(workspaceDir, "script.sh"); - try { - await fs.writeFile(scriptPath, "#!/bin/sh\necho approved\n"); - mockCallGatewayTool - .mockResolvedValueOnce({ id: "claude-native-script-stable" }) - .mockResolvedValueOnce({ - id: "claude-native-script-stable", - decision: "allow-once", - }); - const live = mockClaudeLiveRun(supervisorSpawnMock, { - events: buildClaudeControlRequestEvents({ - requestId: "req-script-stable", - toolUseId: "tool-script-stable", - input: { command: "sh script.sh" }, - sessionId: "live-script-stable", - }), - }); - - await executePreparedCliRun( - buildClaudeLiveRunContext({ - workspaceDir, - config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, - }), - ); - - await vi.waitFor(() => - expect(live.writes.some((entry) => entry.includes('"control_response"'))).toBe(true), - ); - expectClaudeControlDecision(live, { - behavior: "allow", - requestId: "req-script-stable", - toolUseId: "tool-script-stable", - updatedInput: { command: "sh script.sh" }, - }); - } finally { - await fs.rm(workspaceDir, { recursive: true, force: true }); - } - }); - - it("sends full reviewer detail for oversized non-Bash tool input", async () => { - mockCallGatewayTool.mockResolvedValueOnce({ - id: "claude-native-bounded-detail", - decision: "allow-once", - }); - const content = `line one ${"x".repeat(500)} line end`; - const live = mockClaudeLiveRun(supervisorSpawnMock, { - events: buildClaudeControlRequestEvents({ - requestId: "req-write-bounded-detail", - toolUseId: "tool-write-bounded-detail-1", - toolName: "Write", - input: { file_path: "/tmp/out.txt", content }, - sessionId: "live-control-write-bounded-detail", - }), - pid: 3012, - }); - - const result = await executePreparedCliRun( - buildClaudeLiveRunContext({ - prompt: "hello", - config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, - }), - ); - - expect(result.text).toBe("ok"); - await vi.waitFor(() => - expect(live.writes.some((entry) => entry.includes('"control_response"'))).toBe(true), - ); - expectClaudeControlDecision(live, { - behavior: "allow", - requestId: "req-write-bounded-detail", - toolUseId: "tool-write-bounded-detail-1", - updatedInput: { file_path: "/tmp/out.txt", content }, - }); - expect(mockCallGatewayTool).toHaveBeenCalledWith( - "plugin.approval.request", - expect.any(Object), - expect.objectContaining({ - detail: JSON.stringify({ file_path: "/tmp/out.txt", content }), - allowedDecisions: ["allow-once", "deny"], - }), - { expectFinal: false }, - ); - }); - - it("fails closed when a Claude native tool Gateway approval is unavailable", async () => { - mockCallGatewayTool.mockRejectedValueOnce(new Error("gateway unavailable")); - const live = mockClaudeLiveRun(supervisorSpawnMock, { - events: buildClaudeControlRequestEvents({ - requestId: "req-approval-unavailable", - toolUseId: "tool-approval-unavailable-1", - input: { command: "ls" }, - sessionId: "live-control-approval-unavailable", - }), - pid: 3013, - }); - - const result = await executePreparedCliRun( - buildClaudeLiveRunContext({ - prompt: "hello", - config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, - }), - ); - - expect(result.text).toBe("ok"); - await vi.waitFor(() => - expect(live.writes.some((entry) => entry.includes('"control_response"'))).toBe(true), - ); - expectClaudeControlDecision(live, { - behavior: "deny", - requestId: "req-approval-unavailable", - messageIncludes: "OpenClaw approval was not granted", - }); - }); - - it("denies oversized Claude Bash approval requests before calling the Gateway", async () => { - const live = mockClaudeLiveRun(supervisorSpawnMock, { - events: buildClaudeControlRequestEvents({ - requestId: "req-bash-oversized", - toolUseId: "tool-bash-oversized-1", - input: { command: "x".repeat(PLUGIN_APPROVAL_DETAIL_MAX_LENGTH) }, - sessionId: "live-control-bash-oversized", - }), - pid: 3014, - }); - - const result = await executePreparedCliRun( - buildClaudeLiveRunContext({ - prompt: "hello", - config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, - }), - ); - - expect(result.text).toBe("ok"); - await vi.waitFor(() => - expect(live.writes.some((entry) => entry.includes('"control_response"'))).toBe(true), - ); - expectClaudeControlDecision(live, { - behavior: "deny", - requestId: "req-bash-oversized", - messageIncludes: "too large to display", - }); - expect(mockCallGatewayTool).not.toHaveBeenCalled(); - }); - - it("preserves loopback policy blocks for Claude live tools", async () => { - const diagnosticEvents: Array> = []; - const stopDiagnostics = onInternalDiagnosticEvent((event) => { - if ( - event.type.startsWith("tool.execution.") && - "toolCallId" in event && - event.toolCallId === "tool-live-blocked" - ) { - diagnosticEvents.push(event as unknown as Record); - } - }); - let stdoutListener: ((chunk: string) => void) | undefined; - let captureKey = ""; - const stdin = { - write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { - emitClaudeInputStarted(stdoutListener, data); - const captureHandle = markMcpLoopbackToolCallStarted({ - captureKey, - toolName: "message", - args: { action: "react" }, - }); - if (!captureHandle) { - throw new Error("Expected live tool capture"); - } - recordMcpLoopbackToolCallResult({ - captureHandle, - toolName: "message", - args: { action: "react" }, - outcome: "blocked", - deniedReason: "plugin-approval", - }); - markMcpLoopbackToolCallFinished(captureHandle); - stdoutListener?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-blocked" }), - JSON.stringify({ - type: "assistant", - session_id: "live-blocked", - message: { - role: "assistant", - content: [ - { - type: "mcp_tool_use", - id: "tool-live-blocked", - name: "mcp__openclaw__message", - input: { action: "react" }, - }, - ], - }, - }), - JSON.stringify({ - type: "user", - session_id: "live-blocked", - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-live-blocked", - content: "blocked", - is_error: true, - }, - ], - }, - }), - JSON.stringify({ type: "result", session_id: "live-blocked", result: "ok" }), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }; - const liveRunLifecycle = createCancelableLiveRunLifecycle(); - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { - env?: Record; - onStdout?: (chunk: string) => void; - }; - stdoutListener = input.onStdout; - captureKey = input.env?.OPENCLAW_MCP_CLI_CAPTURE_KEY ?? ""; - return { pid: 3061, startedAtMs: Date.now(), stdin, ...liveRunLifecycle }; - }); - const context = buildClaudeLiveRunContext({ - sessionId: "session-live-blocked", - sessionKey: "agent:main:blocked", - prompt: "hello", - }); - context.mcpDeliveryCapture = true; - - try { - await expect(executePreparedCliRun(context)).resolves.toMatchObject({ text: "ok" }); - await waitForDiagnosticEventsDrained(); - } finally { - stopDiagnostics(); - } - - expect(diagnosticEvents).toMatchObject([ - { type: "tool.execution.started", toolCallId: "tool-live-blocked" }, - { - type: "tool.execution.blocked", - toolCallId: "tool-live-blocked", - deniedReason: "plugin-approval", - }, - ]); - expect(liveRunLifecycle.cancel).not.toHaveBeenCalled(); - }); -}); diff --git a/src/agents/cli-runner/claude-live-process-capture.test.ts b/src/agents/cli-runner/claude-live-process-capture.test.ts deleted file mode 100644 index 78a1f607e246..000000000000 --- a/src/agents/cli-runner/claude-live-process-capture.test.ts +++ /dev/null @@ -1,391 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createDeferred } from "../../../test/helpers/promise.js"; -import { markMcpLoopbackRequestStarted } from "../../gateway/mcp-http.loopback-runtime.js"; -import type { getProcessSupervisor } from "../../process/supervisor/index.js"; -import { - buildClaudeLiveRunContext, - buildPreparedCliRunContext, - createCancelableLiveRunLifecycle, - createClaudeInputStartedEvent, - mockClaudeLiveRun, -} from "../cli-runner.test-helpers.js"; -import { - restoreCliRunnerPrepareTestDeps, - supervisorSpawnMock, -} from "../cli-runner.test-support.js"; -import { runClaudeTurn } from "./claude-live-session.js"; -import { resetClaudeLiveSessionsForTest } from "./claude-live-session.test-support.js"; -import { executePreparedCliRun } from "./execute.js"; -import { cliBackendLog } from "./log.js"; - -// Gateway coverage owns quiet-admission timing; these cases preserve real capture draining. -vi.mock("../../gateway/mcp-http.loopback-runtime.js", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - waitForMcpLoopbackToolCallCaptureIdle: ( - captureKey: string, - options: Parameters[1], - ) => - actual.waitForMcpLoopbackToolCallCaptureIdle(captureKey, { - ...options, - admissionGraceMs: 0, - }), - }; -}); - -type ProcessSupervisor = ReturnType; -type SupervisorSpawnFn = ProcessSupervisor["spawn"]; - -function emitClaudeInputStarted(stdout: ((chunk: string) => void) | undefined, data: string): void { - const event = createClaudeInputStartedEvent(data); - if (event) { - stdout?.(`${JSON.stringify(event)}\n`); - } -} - -function createCapturedLiveTurnRunner(options: { - results: string[]; - cleanup?: (runId: string) => Promise; -}) { - const cancels: Array> = []; - const captureKeys: string[] = []; - let turnIndex = 0; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const spawnIndex = supervisorSpawnMock.mock.calls.length; - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - const lifecycle = createCancelableLiveRunLifecycle(); - cancels.push(lifecycle.cancel); - return { - runId: `live-run-${spawnIndex}`, - pid: 2345 + spawnIndex, - startedAtMs: Date.now(), - stdin: { - write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { - emitClaudeInputStarted(input.onStdout, dataValue); - const result = options.results[turnIndex] ?? "ok"; - turnIndex += 1; - input.onStdout?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-session" }), - JSON.stringify({ type: "result", session_id: "live-session", result }), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }, - ...lifecycle, - }; - }); - const runTurn = async ( - runId: string, - args: string[], - env: Record, - mcpHashes?: { config: string; resume: string }, - ) => { - const context = buildClaudeLiveRunContext({ - runId, - backend: { - resumeArgs: ["-p", "--output-format", "stream-json", "--resume", "{sessionId}"], - }, - mcpDeliveryCapture: true, - mcpConfigHash: mcpHashes?.config, - mcpResumeHash: mcpHashes?.resume, - }); - const result = await runClaudeTurn({ - context, - args, - env, - prompt: "hi", - useResume: args.some((entry) => entry.startsWith("--resume")), - noOutputTimeoutMs: 1_000, - getProcessSupervisor: () => ({ - spawn: (spawnArgs: Parameters[0]) => - supervisorSpawnMock(spawnArgs) as ReturnType, - cancel: vi.fn(), - cancelScope: vi.fn(), - getRecord: vi.fn(), - }), - onAssistantDelta: () => {}, - onMcpCaptureReady: (captureKey) => captureKeys.push(captureKey), - cleanup: async () => { - await options.cleanup?.(runId); - }, - }); - return result.output.text; - }; - return { cancels, captureKeys, runTurn }; -} - -beforeEach(() => { - resetClaudeLiveSessionsForTest(); - restoreCliRunnerPrepareTestDeps(); - supervisorSpawnMock.mockClear(); -}); - -afterEach(() => { - vi.restoreAllMocks(); - vi.useRealTimers(); - resetClaudeLiveSessionsForTest(); -}); - -describe("Claude live MCP capture lifetime", () => { - it("reuses a captured Claude live process and capture key across resume turns", async () => { - const logInfoSpy = vi.spyOn(cliBackendLog, "info").mockImplementation(() => undefined); - const { cancels, captureKeys, runTurn } = createCapturedLiveTurnRunner({ - results: ["first-ok", "resume-ok"], - }); - const env = { ANTHROPIC_BASE_URL: "https://one.example" }; - const freshArgs = ["-p", "--output-format", "stream-json"]; - const resumeArgs = ["-p", "--output-format", "stream-json", "--resume", "live-session"]; - - await expect(runTurn("run-live-fresh", freshArgs, env)).resolves.toBe("first-ok"); - await expect(runTurn("run-live-resume", resumeArgs, env)).resolves.toBe("resume-ok"); - - expect(supervisorSpawnMock).toHaveBeenCalledOnce(); - expect(cancels[0]).not.toHaveBeenCalled(); - expect(captureKeys[0]).toEqual(expect.any(String)); - expect(captureKeys).toEqual([captureKeys[0], captureKeys[0]]); - expect( - logInfoSpy.mock.calls - .map(([message]) => message) - .filter((message) => typeof message === "string" && message.includes("reason=restart")), - ).toEqual([]); - }); - - it("reuses a captured process when only turn-local MCP config changes", async () => { - const { cancels, runTurn } = createCapturedLiveTurnRunner({ - results: ["first-ok", "resume-ok"], - }); - const env = { ANTHROPIC_BASE_URL: "https://one.example" }; - const freshArgs = ["-p", "--output-format", "stream-json"]; - const resumeArgs = ["-p", "--output-format", "stream-json", "--resume", "live-session"]; - - await expect( - runTurn("run-live-fresh", freshArgs, env, { - config: "turn-config-one", - resume: "stable-resume-config", - }), - ).resolves.toBe("first-ok"); - await expect( - runTurn("run-live-resume", resumeArgs, env, { - config: "turn-config-two", - resume: "stable-resume-config", - }), - ).resolves.toBe("resume-ok"); - - expect(supervisorSpawnMock).toHaveBeenCalledOnce(); - expect(cancels[0]).not.toHaveBeenCalled(); - }); - - it("still restarts a captured Claude live process when resume identity changes", async () => { - const logWarnSpy = vi.spyOn(cliBackendLog, "warn").mockImplementation(() => undefined); - const { cancels, captureKeys, runTurn } = createCapturedLiveTurnRunner({ - results: ["first-ok", "env-ok", "fresh-ok"], - cleanup: async (runId) => { - if (runId === "run-live-fresh") { - throw new Error("captured cleanup failed"); - } - }, - }); - const freshArgs = ["-p", "--output-format", "stream-json"]; - const resumeArgs = ["-p", "--output-format", "stream-json", "--resume", "live-session"]; - - await expect( - runTurn("run-live-fresh", freshArgs, { ANTHROPIC_BASE_URL: "https://one.example" }), - ).resolves.toBe("first-ok"); - await expect( - runTurn("run-live-env-change", resumeArgs, { ANTHROPIC_BASE_URL: "https://two.example" }), - ).resolves.toBe("env-ok"); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); - expect(cancels[0]).toHaveBeenCalledWith("manual-cancel"); - expect(captureKeys[1]).not.toBe(captureKeys[0]); - - await expect( - runTurn("run-live-fresh-retry", freshArgs, { ANTHROPIC_BASE_URL: "https://two.example" }), - ).resolves.toBe("fresh-ok"); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(3); - expect(cancels[1]).toHaveBeenCalledWith("manual-cancel"); - expect(captureKeys[2]).not.toBe(captureKeys[1]); - expect(logWarnSpy).toHaveBeenCalledWith( - expect.stringContaining("Claude live session cleanup failed: captured cleanup failed"), - ); - }); - - it("fences a reused Claude live capture key between execute turns", async () => { - const live = mockClaudeLiveRun(supervisorSpawnMock, { - cancelable: true, - onWrite: ({ data, emit, writeIndex }) => { - if ((JSON.parse(data) as { type?: string }).type !== "user") { - return; - } - emit([ - { type: "system", subtype: "init", session_id: "captured-live" }, - { - type: "result", - session_id: "captured-live", - result: writeIndex === 0 ? "one" : "two", - }, - ]); - }, - }); - const activateCapture = vi.fn<(captureKey: string) => void>(); - const deactivateCapture = vi.fn<(captureKey: string) => void>(); - const revokeProcessToken = vi.fn<() => void>(); - const adoptedProcessTokens: string[] = []; - const backend = { - resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], - liveSession: "claude-stdio" as const, - }; - const buildContext = (prompt: string, transportToken: string) => { - const context = buildPreparedCliRunContext({ - backend, - prompt, - mcpDeliveryCapture: true, - preparedEnv: { OPENCLAW_MCP_TOKEN: transportToken }, - }); - context.preparedBackend.mcpClientGrantCapture = { - transportToken, - adoptProcessToken: (processToken) => adoptedProcessTokens.push(processToken), - revokeProcessToken, - activate: activateCapture, - deactivate: deactivateCapture, - }; - return context; - }; - - const first = await executePreparedCliRun(buildContext("first", "turn-token-one")); - const second = await executePreparedCliRun( - buildContext("second", "turn-token-two"), - "captured-live", - ); - - expect(first.text).toBe("one"); - expect(second.text).toBe("two"); - expect(supervisorSpawnMock).toHaveBeenCalledOnce(); - expect(adoptedProcessTokens).toEqual(["turn-token-one"]); - expect(live.lifecycle.cancel).not.toHaveBeenCalled(); - const captureKey = activateCapture.mock.calls[0]?.[0]; - expect(typeof captureKey).toBe("string"); - expect(captureKey?.length).toBeGreaterThan(0); - expect(activateCapture.mock.calls.map(([key]) => key)).toEqual([captureKey, captureKey]); - expect(deactivateCapture.mock.calls.map(([key]) => key)).toEqual([captureKey, captureKey]); - expect(deactivateCapture.mock.invocationCallOrder[0]).toBeLessThan( - activateCapture.mock.invocationCallOrder[1]!, - ); - expect(revokeProcessToken).not.toHaveBeenCalled(); - resetClaudeLiveSessionsForTest(); - expect(revokeProcessToken).toHaveBeenCalledOnce(); - }); - - it("reuses a captured process only while its thinking launch environment matches", async () => { - const firstLive = mockClaudeLiveRun(supervisorSpawnMock, { - cancelable: true, - onWrite: ({ data, emit, writeIndex }) => { - if ((JSON.parse(data) as { type?: string }).type !== "user") { - return; - } - emit([ - { type: "system", subtype: "init", session_id: "captured-thinking" }, - { - type: "result", - session_id: "captured-thinking", - result: writeIndex === 0 ? "one" : "two", - }, - ]); - }, - }); - mockClaudeLiveRun(supervisorSpawnMock, { - onWrite: ({ emit, writeIndex }) => { - emit([ - { type: "system", subtype: "init", session_id: "captured-thinking" }, - { - type: "result", - session_id: "captured-thinking", - result: writeIndex === 0 ? "three" : "four", - }, - ]); - }, - }); - const backend = { - resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], - liveSession: "claude-stdio" as const, - }; - const buildContext = (prompt: string, maxThinkingTokens: string) => - buildPreparedCliRunContext({ - backend, - prompt, - mcpDeliveryCapture: true, - preparedEnv: { MAX_THINKING_TOKENS: maxThinkingTokens }, - }); - - const first = await executePreparedCliRun(buildContext("first", "2048")); - const sameLevel = await executePreparedCliRun( - buildContext("second", "2048"), - "captured-thinking", - ); - const changedLevel = await executePreparedCliRun( - buildContext("third", "16384"), - "captured-thinking", - ); - const sameChangedLevel = await executePreparedCliRun( - buildContext("fourth", "16384"), - "captured-thinking", - ); - - expect([first.text, sameLevel.text, changedLevel.text, sameChangedLevel.text]).toEqual([ - "one", - "two", - "three", - "four", - ]); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); - expect(firstLive.lifecycle.cancel).toHaveBeenCalledWith("manual-cancel"); - }); - - it("closes a captured Claude live process when MCP delivery capture cannot drain", async () => { - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); - const logInfoSpy = vi.spyOn(cliBackendLog, "info").mockImplementation(() => undefined); - const requestStarted = createDeferred(); - const live = mockClaudeLiveRun(supervisorSpawnMock, { - cancelable: true, - onWrite: ({ data, emit }) => { - if ((JSON.parse(data) as { type?: string }).type !== "user") { - return; - } - markMcpLoopbackRequestStarted(live.spawnInput.env?.OPENCLAW_MCP_CLI_CAPTURE_KEY); - requestStarted.resolve(); - emit([ - { type: "system", subtype: "init", session_id: "captured-drain" }, - { type: "result", session_id: "captured-drain", result: "ok" }, - ]); - }, - }); - const context = buildClaudeLiveRunContext({ - backend: { - resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], - }, - mcpDeliveryCapture: true, - }); - - const pending = executePreparedCliRun(context); - await requestStarted.promise; - await vi.advanceTimersByTimeAsync(0); - const rejection = expect(pending).rejects.toThrow( - "CLI message tool call remained in flight after exit", - ); - await vi.advanceTimersByTimeAsync(5_000); - await rejection; - expect(live.lifecycle.cancel).toHaveBeenCalledWith("manual-cancel"); - expect( - logInfoSpy.mock.calls - .map(([message]) => message) - .some( - (message) => - typeof message === "string" && message.includes("reason=mcp-capture-rotation"), - ), - ).toBe(true); - }, 15_000); -}); diff --git a/src/agents/cli-runner/claude-live-process.test.ts b/src/agents/cli-runner/claude-live-process.test.ts deleted file mode 100644 index 80c10da73cdd..000000000000 --- a/src/agents/cli-runner/claude-live-process.test.ts +++ /dev/null @@ -1,820 +0,0 @@ -import path from "node:path"; -import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "@openclaw/ai/internal/shared"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - onInternalDiagnosticEvent, - waitForDiagnosticEventsDrained, -} from "../../infra/diagnostic-events.js"; -import type { getProcessSupervisor } from "../../process/supervisor/index.js"; -import { - buildClaudeControlRequestEvents, - buildClaudeLiveRunContext, - buildPreparedCliRunContext, - createClaudeInputStartedEvent, - expectClaudeControlDecision, - expectPathMissing, - expectRejectsWithFields, - mockClaudeLiveRun, - requireArgAfter, - withTempExecApprovalsState, - withTempOpenClawHome, - type PreparedCliRunContextOverrides, -} from "../cli-runner.test-helpers.js"; -import { - restoreCliRunnerPrepareTestDeps, - supervisorSpawnMock, -} from "../cli-runner.test-support.js"; -import { callGatewayTool } from "../tools/gateway.js"; -import { runClaudeTurn } from "./claude-live-session.js"; -import { resetClaudeLiveSessionsForTest } from "./claude-live-session.test-support.js"; -import { executePreparedCliRun } from "./execute.js"; -import type { PreparedCliRunContext } from "./types.js"; - -vi.mock("../tools/gateway.js", () => ({ - callGatewayTool: vi.fn(), -})); - -const mockCallGatewayTool = vi.mocked(callGatewayTool); - -type ProcessSupervisor = ReturnType; -type SupervisorSpawnFn = ProcessSupervisor["spawn"]; - -function emitClaudeInputStarted(stdout: ((chunk: string) => void) | undefined, data: string): void { - const event = createClaudeInputStartedEvent(data); - if (event) { - stdout?.(`${JSON.stringify(event)}\n`); - } -} - -type ClaudeControlPolicyTestCase = { - name: string; - requestId: string; - toolUseId: string; - input: Record; - expected: { - behavior: "allow" | "deny"; - messageIncludes?: string; - updatedInput?: Record; - }; - context?: PreparedCliRunContextOverrides; - approvals?: Record; - expectedPermissionMode?: string; -}; - -beforeEach(() => { - resetClaudeLiveSessionsForTest(); - restoreCliRunnerPrepareTestDeps(); - supervisorSpawnMock.mockClear(); - mockCallGatewayTool.mockReset(); - mockCallGatewayTool.mockResolvedValue({ id: "claude-native-approval", decision: "deny" }); -}); - -afterEach(() => { - vi.restoreAllMocks(); - vi.useRealTimers(); - resetClaudeLiveSessionsForTest(); -}); - -describe("Claude live configured exec policy", () => { - it("uses the configured default agent for an unscoped legacy session key", async () => { - const live = mockClaudeLiveRun(supervisorSpawnMock, { - onWrite: ({ data, emit }) => { - if (data.includes('"control_response"')) { - return; - } - emit( - buildClaudeControlRequestEvents({ - requestId: "req-default-agent", - toolUseId: "tool-default-agent", - toolName: "Bash", - input: { command: "pwd" }, - sessionId: "live-default-agent", - }), - ); - }, - }); - const context = buildClaudeLiveRunContext({ - sessionKey: "main", - config: { - tools: { exec: { security: "full", ask: "off" } }, - agents: { - entries: { - main: {}, - ops: { default: true, tools: { exec: { security: "deny", ask: "always" } } }, - }, - }, - } as unknown as PreparedCliRunContext["params"]["config"], - }); - - await expect(executePreparedCliRun(context)).resolves.toMatchObject({ text: "ok" }); - expectClaudeControlDecision(live, { - behavior: "deny", - requestId: "req-default-agent", - messageIncludes: "security=deny", - }); - expect(mockCallGatewayTool).not.toHaveBeenCalled(); - }); -}); - -describe("Claude live process", () => { - it("refreshes a reused Claude live session when only dynamic prompt context changes", async () => { - let userTurn = 0; - let controlRequest = 0; - const live = mockClaudeLiveRun(supervisorSpawnMock, { - onWrite: ({ data, emit }) => { - const parsed = JSON.parse(data) as { - type: string; - request_id?: string; - request?: { subtype?: string; model?: string; system_prompt?: string }; - }; - if (parsed.type === "control_request") { - controlRequest += 1; - if (controlRequest === 1) { - expect(parsed.request).toEqual({ - subtype: "set_model", - model: "sonnet", - system_prompt: "", - }); - emit([ - { - type: "control_response", - response: { - subtype: "error", - request_id: parsed.request_id, - error: "set_model: system_prompt must be a non-empty string when present", - }, - }, - ]); - return; - } - expect(parsed.request).toEqual({ - subtype: "set_model", - model: "sonnet", - system_prompt: - "# OpenClaw\n\n## Stable Instructions\nKeep the operator informed.\nSecond-turn metadata", - }); - emit([ - { - type: "control_response", - response: { subtype: "success", request_id: parsed.request_id }, - }, - ]); - return; - } - if (parsed.type !== "user") { - throw new Error(`unexpected live stdin ${parsed.type}`); - } - userTurn += 1; - emit([ - { type: "system", subtype: "init", session_id: "live-dynamic-prompt" }, - { - type: "result", - session_id: "live-dynamic-prompt", - result: userTurn === 1 ? "one" : "two", - }, - ]); - }, - }); - const backend = { - resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], - liveSession: "claude-stdio" as const, - systemPromptWhen: "always" as const, - }; - - const first = await executePreparedCliRun( - buildPreparedCliRunContext({ - backend, - prompt: "first", - systemPrompt: `# OpenClaw\n\n## Stable Instructions\nKeep the operator informed.${SYSTEM_PROMPT_CACHE_BOUNDARY}First-turn metadata`, - }), - ); - const second = await executePreparedCliRun( - buildPreparedCliRunContext({ - backend, - prompt: "second", - systemPrompt: `# OpenClaw\n\n## Stable Instructions\nKeep the operator informed.${SYSTEM_PROMPT_CACHE_BOUNDARY}Second-turn metadata`, - }), - "live-dynamic-prompt", - ); - - expect(first.text).toBe("one"); - expect(second.text).toBe("two"); - expect(supervisorSpawnMock).toHaveBeenCalledOnce(); - expect(live.writes.map((entry) => JSON.parse(entry).type)).toEqual([ - "user", - "control_request", - "control_request", - "user", - ]); - const userMessages = live.writes - .map((entry) => JSON.parse(entry) as { type: string; message?: { content?: string } }) - .filter((entry) => entry.type === "user") - .map((entry) => entry.message?.content); - expect(userMessages).toEqual(["first", "second"]); - }); - - it("answers Claude live control_request can_use_tool with deny when the user rejects approval", async () => { - const diagnosticEvents: Array> = []; - const stopDiagnostics = onInternalDiagnosticEvent((event) => { - if ( - event.type.startsWith("tool.execution.") && - "toolCallId" in event && - event.toolCallId === "tool-deny-1" - ) { - diagnosticEvents.push(event as unknown as Record); - } - }); - const controlEvents = buildClaudeControlRequestEvents({ - requestId: "req-deny", - toolUseId: "tool-deny-1", - input: { command: "rm -rf /" }, - sessionId: "live-control-deny", - }); - const live = mockClaudeLiveRun(supervisorSpawnMock, { - onWrite: ({ data, emit, writeIndex }) => { - if (writeIndex === 0) { - emit(controlEvents.slice(0, 2)); - return; - } - if (!data.includes('"control_response"')) { - return; - } - emit([ - { - type: "assistant", - session_id: "live-control-deny", - message: { - role: "assistant", - content: [ - { - type: "tool_use", - id: "tool-deny-1", - name: "Bash", - input: { command: "rm -rf /" }, - }, - ], - }, - }, - { - type: "user", - session_id: "live-control-deny", - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-deny-1", - content: "denied", - is_error: true, - }, - ], - }, - }, - { type: "result", session_id: "live-control-deny", result: "ok" }, - ]); - }, - pid: 3002, - }); - - let result; - try { - result = await executePreparedCliRun( - buildClaudeLiveRunContext({ - prompt: "hello", - config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, - }), - ); - await vi.waitFor(() => - expect(live.writes.some((entry) => entry.includes('"control_response"'))).toBe(true), - ); - await waitForDiagnosticEventsDrained(); - } finally { - stopDiagnostics(); - } - expect(result.text).toBe("ok"); - expectClaudeControlDecision(live, { - behavior: "deny", - requestId: "req-deny", - messageIncludes: "OpenClaw user denied Claude native tool use (Bash).", - }); - expect(diagnosticEvents).toMatchObject([ - { - type: "tool.execution.started", - toolCallId: "tool-deny-1", - toolName: "Bash", - paramsSummary: { kind: "object" }, - }, - { - type: "tool.execution.blocked", - toolCallId: "tool-deny-1", - toolName: "Bash", - deniedReason: "cli_live_exec_policy", - }, - ]); - expect(diagnosticEvents).toHaveLength(2); - expect(JSON.stringify(diagnosticEvents)).not.toContain("rm -rf"); - expect(requireArgAfter(live.spawnInput.argv, "--permission-mode")).toBe("default"); - }); - - it("reuses a Claude native tool allow-always grant within the live process", async () => { - mockCallGatewayTool.mockResolvedValueOnce({ - id: "claude-native-allow-always", - decision: "allow-always", - }); - let promptCount = 0; - const live = mockClaudeLiveRun(supervisorSpawnMock, { - onWrite: ({ data, emit }) => { - if (data.includes('"control_response"')) { - return; - } - promptCount += 1; - emit( - buildClaudeControlRequestEvents({ - requestId: `req-grant-${promptCount}`, - toolUseId: `tool-grant-${promptCount}`, - toolName: "Write", - input: { - file_path: `/tmp/grant-${promptCount}.txt`, - content: `content ${promptCount}`, - }, - sessionId: "live-control-allow-always", - }), - ); - }, - pid: 3012, - }); - const buildContext = (runId: string, prompt: string) => - buildClaudeLiveRunContext({ - runId, - prompt, - sessionId: "session-allow-always", - sessionKey: "agent:main:allow-always", - config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, - }); - - await expect( - executePreparedCliRun(buildContext("run-grant-1", "first")), - ).resolves.toMatchObject({ text: "ok" }); - await vi.waitFor(() => - expect(live.writes.filter((entry) => entry.includes('"control_response"'))).toHaveLength(1), - ); - await expect( - executePreparedCliRun(buildContext("run-grant-2", "second")), - ).resolves.toMatchObject({ text: "ok" }); - await vi.waitFor(() => - expect(live.writes.filter((entry) => entry.includes('"control_response"'))).toHaveLength(2), - ); - - expect(mockCallGatewayTool).toHaveBeenCalledTimes(1); - expectClaudeControlDecision(live, { - behavior: "allow", - requestId: "req-grant-1", - toolUseId: "tool-grant-1", - updatedInput: { file_path: "/tmp/grant-1.txt", content: "content 1" }, - }); - const secondResponse = live.writes.find( - (entry) => entry.includes('"control_response"') && entry.includes("req-grant-2"), - ); - expect(secondResponse).toContain('"behavior":"allow"'); - }); - - it("prompts on every Claude native tool request when exec ask is always", async () => { - mockCallGatewayTool.mockResolvedValueOnce({ - id: "claude-native-always-seed", - decision: "allow-always", - }); - let promptCount = 0; - const live = mockClaudeLiveRun(supervisorSpawnMock, { - onWrite: ({ data, emit }) => { - if (data.includes('"control_response"')) { - return; - } - promptCount += 1; - emit( - buildClaudeControlRequestEvents({ - requestId: `req-always-${promptCount}`, - toolUseId: `tool-always-${promptCount}`, - toolName: "Write", - input: { - file_path: `/tmp/always-${promptCount}.txt`, - content: `content ${promptCount}`, - }, - sessionId: "live-control-ask-always", - }), - ); - }, - pid: 3015, - }); - const buildContext = (runId: string, prompt: string, ask: "always" | "on-miss") => - buildClaudeLiveRunContext({ - runId, - prompt, - sessionId: "session-ask-always", - sessionKey: "agent:main:ask-always", - sessionEntry: { execAsk: ask } as PreparedCliRunContext["params"]["sessionEntry"], - config: { tools: { exec: { security: "full", ask: "on-miss" } } }, - }); - - await expect( - executePreparedCliRun(buildContext("run-always-seed", "seed", "on-miss")), - ).resolves.toMatchObject({ text: "ok" }); - await vi.waitFor(() => - expect(live.writes.filter((entry) => entry.includes('"control_response"'))).toHaveLength(1), - ); - mockCallGatewayTool.mockClear(); - mockCallGatewayTool - .mockResolvedValueOnce({ id: "claude-native-always-1", decision: "allow-once" }) - .mockResolvedValueOnce({ id: "claude-native-always-2", decision: "allow-once" }); - - await expect( - executePreparedCliRun(buildContext("run-always-1", "first", "always")), - ).resolves.toMatchObject({ text: "ok" }); - await vi.waitFor(() => - expect(live.writes.filter((entry) => entry.includes('"control_response"'))).toHaveLength(2), - ); - await expect( - executePreparedCliRun(buildContext("run-always-2", "second", "always")), - ).resolves.toMatchObject({ text: "ok" }); - await vi.waitFor(() => - expect(live.writes.filter((entry) => entry.includes('"control_response"'))).toHaveLength(3), - ); - - expect(mockCallGatewayTool).toHaveBeenCalledTimes(2); - for (const call of mockCallGatewayTool.mock.calls) { - expect(call[2]).toMatchObject({ allowedDecisions: ["allow-once", "deny"] }); - } - const firstResponse = live.writes.find( - (entry) => entry.includes('"control_response"') && entry.includes("req-always-2"), - ); - const secondResponse = live.writes.find( - (entry) => entry.includes('"control_response"') && entry.includes("req-always-3"), - ); - expect(firstResponse).toContain('"behavior":"allow"'); - expect(secondResponse).toContain('"behavior":"allow"'); - }); - - it("does not create exec approvals file while resolving Claude live policy", async () => { - await withTempOpenClawHome(async (home) => { - const approvalsPath = path.join(home, ".openclaw", "exec-approvals.json"); - const live = mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: "live-no-approvals-file" }, - { type: "result", session_id: "live-no-approvals-file", result: "ok" }, - ], - pid: 3009, - }); - - const result = await executePreparedCliRun( - buildClaudeLiveRunContext({ - prompt: "hello", - config: { - tools: { exec: { security: "allowlist", ask: "on-miss" } }, - } as PreparedCliRunContext["params"]["config"], - }), - ); - - expect(result.text).toBe("ok"); - expect(requireArgAfter(live.spawnInput.argv, "--permission-mode")).toBe("default"); - await expectPathMissing(approvalsPath); - }); - }); - - it.each([ - { - name: "allows tools when no exec policy is configured (default deployment)", - requestId: "req-default-allow", - toolUseId: "tool-default-allow-1", - input: { command: "echo hi" }, - expected: { behavior: "allow", updatedInput: { command: "echo hi" } }, - }, - { - name: "denies tools when approval defaults are restrictive", - requestId: "req-approval-default-deny", - toolUseId: "tool-approval-default-deny-1", - input: { command: "ls" }, - expected: { behavior: "deny", messageIncludes: "OpenClaw user denied" }, - approvals: { - version: 1, - defaults: { security: "allowlist", ask: "on-miss" }, - agents: {}, - }, - context: { - backend: { - liveSession: "claude-stdio", - args: ["-p", "--output-format", "stream-json", "--permission-mode", "bypassPermissions"], - }, - }, - expectedPermissionMode: "default", - }, - { - name: "denies tools when session exec ask is restrictive", - requestId: "req-session-ask-deny", - toolUseId: "tool-session-ask-deny-1", - input: { command: "ls" }, - expected: { behavior: "deny", messageIncludes: "OpenClaw user denied" }, - context: { - backend: { - liveSession: "claude-stdio", - args: ["-p", "--output-format", "stream-json", "--permission-mode", "bypassPermissions"], - }, - sessionEntry: { execAsk: "always" } as PreparedCliRunContext["params"]["sessionEntry"], - config: { tools: { exec: { security: "full", ask: "off" } } }, - }, - expectedPermissionMode: "default", - }, - { - name: "denies tools when agent approvals are restrictive", - requestId: "req-agent-approval-deny", - toolUseId: "tool-agent-approval-deny-1", - input: { command: "ls" }, - expected: { behavior: "deny", messageIncludes: "security=deny" }, - approvals: { version: 1, agents: { reviewer: { security: "deny" } } }, - context: { - agentId: "reviewer", - backend: { - liveSession: "claude-stdio", - args: ["-p", "--output-format", "stream-json", "--permission-mode", "bypassPermissions"], - }, - config: { tools: { exec: { security: "full", ask: "off" } } }, - }, - expectedPermissionMode: "default", - }, - { - name: "denies tools when session-key agent approvals are restrictive", - requestId: "req-session-key-approval-deny", - toolUseId: "tool-session-key-approval-deny-1", - input: { command: "ls" }, - expected: { behavior: "deny", messageIncludes: "security=deny" }, - approvals: { version: 1, agents: { reviewer: { security: "deny" } } }, - context: { - sessionKey: "agent:reviewer:main", - backend: { - liveSession: "claude-stdio", - args: ["-p", "--output-format", "stream-json", "--permission-mode", "bypassPermissions"], - }, - config: { tools: { exec: { security: "full", ask: "off" } } }, - }, - expectedPermissionMode: "default", - }, - { - name: "allows tools when OpenClaw exec is YOLO despite raw --permission-mode default", - requestId: "req-permmode-allow", - toolUseId: "tool-permmode-allow-1", - input: { command: "ls" }, - expected: { behavior: "allow" }, - context: { - backend: { - liveSession: "claude-stdio", - args: ["-p", "--output-format", "stream-json", "--permission-mode", "default"], - }, - config: { tools: { exec: { security: "full", ask: "off" } } }, - }, - }, - ])("answers Claude live control_request can_use_tool: $name", async (testCase) => { - const run = async () => { - const live = mockClaudeLiveRun(supervisorSpawnMock, { - events: buildClaudeControlRequestEvents({ - requestId: testCase.requestId, - toolUseId: testCase.toolUseId, - input: testCase.input, - sessionId: `live-control-${testCase.requestId}`, - }), - }); - const result = await executePreparedCliRun( - buildClaudeLiveRunContext({ ...testCase.context }), - ); - - expect(result.text).toBe("ok"); - await vi.waitFor(() => - expect(live.writes.some((entry) => entry.includes('"control_response"'))).toBe(true), - ); - expectClaudeControlDecision(live, { - ...testCase.expected, - requestId: testCase.requestId, - ...(testCase.expected.behavior === "allow" ? { toolUseId: testCase.toolUseId } : {}), - }); - if (testCase.expectedPermissionMode) { - expect(requireArgAfter(live.spawnInput.argv, "--permission-mode")).toBe( - testCase.expectedPermissionMode, - ); - } - }; - - if (testCase.approvals) { - await withTempExecApprovalsState(testCase.approvals, run); - } else { - await run(); - } - }); - - it("cleans live-turn resources when capture activation fails before spawn", async () => { - const cleanup = vi.fn(async () => undefined); - const context = buildPreparedCliRunContext({ mcpDeliveryCapture: true }); - - await expect( - runClaudeTurn({ - context, - args: [], - env: {}, - prompt: "hi", - useResume: false, - noOutputTimeoutMs: 1_000, - getProcessSupervisor: () => ({ - spawn: (params: Parameters[0]) => - supervisorSpawnMock(params) as ReturnType, - cancel: vi.fn(), - cancelScope: vi.fn(), - getRecord: vi.fn(), - }), - onAssistantDelta: () => {}, - onMcpCaptureReady: () => { - throw new Error("grant activation failed"); - }, - cleanup, - }), - ).rejects.toThrow("grant activation failed"); - - expect(cleanup).toHaveBeenCalledOnce(); - expect(supervisorSpawnMock).not.toHaveBeenCalled(); - }); - - it.each([ - { - name: "marks Claude live stderr context overflows as retryable", - exitCode: 1, - stderr: "Prompt is too long", - events: [{ type: "system", subtype: "init", session_id: "live-overflow" }], - expected: { - name: "FailoverError", - reason: "context_overflow", - code: "cli_context_overflow", - status: 413, - }, - }, - { - name: "marks quiet Claude live exit-zero turns as retryable empty responses", - exitCode: 0, - stderr: "", - events: [], - expected: { - name: "FailoverError", - reason: "empty_response", - code: "cli_unknown_empty_failure", - }, - }, - { - name: "marks quiet Claude live nonzero exits as retryable unknown failures", - exitCode: 1, - stderr: "", - events: [], - expected: { - name: "FailoverError", - reason: "unknown", - code: "cli_unknown_empty_failure", - }, - }, - { - name: "preserves Claude live stderr classification on exit-zero failures", - exitCode: 0, - stderr: "Prompt is too long", - events: [], - expected: { - name: "FailoverError", - reason: "context_overflow", - code: "cli_context_overflow", - }, - }, - ])("$name", async (testCase) => { - mockClaudeLiveRun(supervisorSpawnMock, { - events: testCase.events, - inputLifecycle: testCase.events.length > 0, - exitOnWrite: { - reason: "exit", - exitCode: testCase.exitCode, - exitSignal: null, - durationMs: 1, - stdout: "", - stderr: testCase.stderr, - timedOut: false, - noOutputTimedOut: false, - }, - }); - - await expectRejectsWithFields( - executePreparedCliRun( - buildPreparedCliRunContext({ backend: { liveSession: "claude-stdio" } }), - ), - testCase.expected, - ); - }); - - it("fails when Claude exits before a live turn starts", async () => { - mockClaudeLiveRun(supervisorSpawnMock, { - exitImmediately: { - reason: "exit", - exitCode: 1, - exitSignal: null, - durationMs: 1, - stdout: "", - stderr: "startup failed", - timedOut: false, - noOutputTimedOut: false, - }, - }); - - await expect(executePreparedCliRun(buildClaudeLiveRunContext())).rejects.toThrow( - "Claude CLI live session closed before handling the turn", - ); - }); - - it("does not surface stale stderr after a later Claude live exit", async () => { - let stdoutListener: ((chunk: string) => void) | undefined; - let stderrListener: ((chunk: string) => void) | undefined; - let resolveExit: - | ((value: { - reason: "exit"; - exitCode: number; - exitSignal: null; - durationMs: number; - stdout: string; - stderr: string; - timedOut: false; - noOutputTimedOut: false; - }) => void) - | undefined; - const wait = new Promise<{ - reason: "exit"; - exitCode: number; - exitSignal: null; - durationMs: number; - stdout: string; - stderr: string; - timedOut: false; - noOutputTimedOut: false; - }>((resolve) => { - resolveExit = resolve; - }); - let writeCount = 0; - const stdin = { - write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { - emitClaudeInputStarted(stdoutListener, dataValue); - writeCount += 1; - if (writeCount === 1) { - stderrListener?.("stale stderr from first turn"); - stdoutListener?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-stderr" }), - JSON.stringify({ - type: "result", - session_id: "live-stderr", - result: "first-ok", - }), - ].join("\n") + "\n", - ); - cb?.(); - return; - } - cb?.(); - if (!resolveExit) { - throw new Error("Expected Claude live exit resolver to be initialized"); - } - resolveExit({ - reason: "exit", - exitCode: 1, - exitSignal: null, - durationMs: 50, - stdout: "", - stderr: "", - timedOut: false, - noOutputTimedOut: false, - }); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { - onStdout?: (chunk: string) => void; - onStderr?: (chunk: string) => void; - }; - stdoutListener = input.onStdout; - stderrListener = input.onStderr; - return { - runId: "live-run", - pid: 2345, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => wait), - cancel: vi.fn(), - }; - }); - - const first = await executePreparedCliRun(buildClaudeLiveRunContext({ prompt: "first" })); - const second = executePreparedCliRun(buildClaudeLiveRunContext({ prompt: "second" })); - - expect(first.text).toBe("first-ok"); - await expectRejectsWithFields(second, { - name: "FailoverError", - message: "Claude CLI failed.", - }); - }); -}); diff --git a/src/agents/cli-runner/claude-live-process.ts b/src/agents/cli-runner/claude-live-process.ts deleted file mode 100644 index d56186972b66..000000000000 --- a/src/agents/cli-runner/claude-live-process.ts +++ /dev/null @@ -1,660 +0,0 @@ -import crypto from "node:crypto"; -import { stripSystemPromptCacheBoundary } from "@openclaw/ai/internal/shared"; -import { isRecord } from "@openclaw/normalization-core/record-coerce"; -import { formatErrorMessage } from "../../infra/errors.js"; -import type { - CliOutput, - CliStreamingDelta, - CliThinkingDelta, - CliThinkingProgress, - CliToolResultDelta, - CliToolUseStartDelta, - CliUsage, -} from "../cli-output-contracts.js"; -import { resolveExecDefaults } from "../exec-defaults.js"; -import { FailoverError, resolveFailoverStatus } from "../failover-error.js"; -import { prepareCliBundleMcpCaptureAttempt } from "./bundle-mcp.js"; -import { LIVE_SESSION_LIMITS, resolveClaudeLiveMode } from "./claude-live-session-policy.js"; -import { - requestClaudeNativeToolApproval, - resolveClaudeNativeToolApprovalPlan, -} from "./claude-live-tool-approval.js"; -import { resetClaudeNoOutputTimer } from "./claude-live-turn-timeouts.js"; -import { - acceptClaudeExit, - acceptClaudeStdout, - createClaudeOutputLimitError, - createClaudeTurn, - failClaudeTurn, - markClaudeLiveToolDenied, - type ClaudeLiveExecPermission, - type ClaudeLiveToolTerminalOutcome, - type ClaudeLiveTurn, - type ClaudeLiveTurnHost, -} from "./claude-live-turn.js"; -import { cliBackendLog } from "./log.js"; -import type { PreparedCliRunContext } from "./types.js"; - -type ProcessSupervisor = ReturnType< - typeof import("../../process/supervisor/index.js").getProcessSupervisor ->; -type ManagedRun = Awaited>; - -type ClaudeLivePendingControlRequest = { - requestId: string; - timer: NodeJS.Timeout; - resolve: (response: ClaudeLiveControlResponse | null) => void; -}; -type ClaudeLiveControlResponse = { subtype: string; error?: string }; - -const CLAUDE_LIVE_IDLE_TIMEOUT_MS = 10 * 60 * 1_000; -const CLAUDE_LIVE_CONTROL_TIMEOUT_MS = 3_000; -const CLAUDE_LIVE_CLOSE_WAIT_TIMEOUT_MS = 5_000; -const CLAUDE_LIVE_SYSTEM_PROMPT_PROBE_ERROR = - "set_model: system_prompt must be a non-empty string when present"; - -export type ClaudeLiveProcess = ClaudeLiveTurnHost & { - key: string; - generation: string; - fingerprint: string; - systemPromptHash: string; - systemPromptSwitchCapability: "unknown" | "supported" | "unsupported"; - liveSessionRequirement?: import("../../plugins/cli-backend.types.js").CliBackendLiveSessionRequirement; - managedRun: ManagedRun; - sessionId?: string; - idleTimer: NodeJS.Timeout | null; - cleanup: () => Promise; - cleanupPromise: Promise | null; - pendingControlRequest: ClaudeLivePendingControlRequest | null; - mcpCaptureKey?: string; - /** Process-stable bearer whose server-side authority rotates per turn. */ - mcpGrantToken?: string; - nativeToolApprovalGrants: Set; - isIdle(): boolean; - waitForExit(): Promise; - cleanupResources(): Promise; -}; - -type BeginClaudeTurnParams = { - context: PreparedCliRunContext; - inputUuid: string; - useResume: boolean; - execPermission: ClaudeLiveExecPermission; - onAssistantDelta: (delta: CliStreamingDelta) => void; - onThinkingDelta?: (delta: CliThinkingDelta) => void; - onThinkingProgress?: (progress: CliThinkingProgress) => void; - onToolUseStart?: (delta: CliToolUseStartDelta) => void; - onToolResult?: (delta: CliToolResultDelta) => void; - resolveToolResultTerminalOutcome?: ( - delta: CliToolResultDelta, - ) => ClaudeLiveToolTerminalOutcome | undefined; - onCommentaryText?: (text: string) => void; - onSessionId?: (sessionId: string) => void; - onAssistantMessage?: (message: unknown) => void; - onUsage?: (usage: CliUsage, terminal: boolean) => void; - onCliOutput?: (chunk: string, stream: "stderr" | "stdout") => void; - onPhase?: (phase: "send" | "resolve") => void; -}; - -function settlePendingControlRequest( - session: ClaudeLiveProcess, - response: ClaudeLiveControlResponse | null, -): void { - const pending = session.pendingControlRequest; - if (!pending) { - return; - } - clearTimeout(pending.timer); - session.pendingControlRequest = null; - pending.resolve(response); -} - -function cleanupProcess(session: ClaudeLiveProcess): Promise { - if (!session.cleanupPromise) { - session.cleanupPromise = session.cleanup().catch((error: unknown) => { - cliBackendLog.warn(`Claude live session cleanup failed: ${formatErrorMessage(error)}`); - }); - } - return session.cleanupPromise; -} - -async function waitForManagedRunExit(managedRun: ManagedRun): Promise { - let timeout: NodeJS.Timeout | null = null; - try { - await Promise.race([ - managedRun.wait().then( - () => undefined, - () => undefined, - ), - new Promise((resolve) => { - timeout = setTimeout(resolve, CLAUDE_LIVE_CLOSE_WAIT_TIMEOUT_MS); - timeout.unref?.(); - }), - ]); - } finally { - if (timeout) { - clearTimeout(timeout); - } - } -} - -function writeControlResponse(session: ClaudeLiveProcess, response: unknown): void { - const stdin = session.managedRun.stdin; - if (!stdin) { - throw new Error("Claude CLI live session stdin is unavailable"); - } - stdin.write(`${JSON.stringify(response)}\n`); -} - -function acceptControlResponse( - session: ClaudeLiveProcess, - parsed: Record, -): boolean { - const pending = session.pendingControlRequest; - if (!pending || parsed.type !== "control_response" || !isRecord(parsed.response)) { - return false; - } - const response = parsed.response; - if (response.request_id !== pending.requestId) { - return false; - } - settlePendingControlRequest(session, { - subtype: typeof response.subtype === "string" ? response.subtype : "", - ...(typeof response.error === "string" ? { error: response.error } : {}), - }); - return true; -} - -function writeToolControlResponse(params: { - session: ClaudeLiveProcess; - requestId: string; - toolUseId?: string; - toolInput: Record; - decision: { behavior: "allow" } | { behavior: "deny"; message: string }; -}): void { - writeControlResponse(params.session, { - type: "control_response", - response: { - subtype: "success", - request_id: params.requestId, - response: - params.decision.behavior === "allow" - ? { - behavior: "allow", - updatedInput: params.toolInput, - ...(params.toolUseId ? { toolUseID: params.toolUseId } : {}), - } - : { - behavior: "deny", - decisionClassification: "user_reject", - message: params.decision.message, - }, - }, - }); -} - -function markControlToolDenied(params: { - turn: ClaudeLiveTurn; - toolUseId?: string; - toolName: string; - toolInput: Record; -}): void { - if (!params.toolUseId || !params.toolName) { - return; - } - markClaudeLiveToolDenied(params.turn, { - toolCallId: params.toolUseId, - name: params.toolName, - kind: "tool_use", - args: params.toolInput, - }); -} - -function acceptControlRequest( - session: ClaudeLiveProcess, - turn: ClaudeLiveTurn, - parsed: Record, -): void { - if (parsed.type !== "control_request" || !isRecord(parsed.request)) { - return; - } - const request = parsed.request; - if (request.subtype !== "can_use_tool") { - return; - } - const requestId = typeof parsed.request_id === "string" ? parsed.request_id : ""; - if (!requestId) { - return; - } - const toolUseId = typeof request.tool_use_id === "string" ? request.tool_use_id : undefined; - const toolName = typeof request.tool_name === "string" ? request.tool_name.trim() : ""; - const toolInput = isRecord(request.input) ? request.input : {}; - const plan = resolveClaudeNativeToolApprovalPlan(turn.execPermission); - if ( - plan === "allow" || - (plan === "prompt" && - turn.execPermission.ask !== "always" && - session.nativeToolApprovalGrants.has(toolName)) - ) { - writeToolControlResponse({ - session, - requestId, - toolUseId, - toolInput, - decision: { behavior: "allow" }, - }); - return; - } - if (plan === "deny") { - markControlToolDenied({ turn, toolUseId, toolName, toolInput }); - writeToolControlResponse({ - session, - requestId, - toolUseId, - toolInput, - decision: { - behavior: "deny", - message: `OpenClaw exec policy denied Claude native tool use (security=${turn.execPermission.security}, ask=${turn.execPermission.ask}).`, - }, - }); - return; - } - void (async () => { - const outcome = await requestClaudeNativeToolApproval({ - toolName, - toolInput, - pluginId: session.providerId, - sessionKey: turn.diagnosticRefs.sessionKey, - agentId: turn.diagnosticRefs.agentId, - toolCallId: toolUseId, - cwd: turn.cwd, - abortSignal: turn.abortSignal, - ask: turn.execPermission.ask, - }); - const runAborted = turn.abortSignal?.aborted === true; - const allowed = !runAborted && outcome.kind === "allow"; - if (!runAborted && outcome.kind === "allow" && outcome.grantAlways) { - session.nativeToolApprovalGrants.add(toolName); - } - if (!allowed) { - markControlToolDenied({ turn, toolUseId, toolName, toolInput }); - } - if (session.closing || !session.managedRun.stdin) { - return; - } - try { - writeToolControlResponse({ - session, - requestId, - toolUseId, - toolInput, - decision: allowed - ? { behavior: "allow" } - : { - behavior: "deny", - message: - outcome.kind === "deny" && outcome.reason === "policy-oversized" - ? "OpenClaw denied Claude native tool use (Bash): the command is too large to display for out-of-band approval. Split it into smaller commands and retry." - : outcome.kind === "deny" && outcome.reason === "operand-binding" - ? (outcome.message ?? - "OpenClaw denied Claude native tool use (Bash): the command could not be bound to stable script bytes.") - : outcome.kind === "deny" && outcome.reason === "user" && !runAborted - ? `OpenClaw user denied Claude native tool use (${toolName}).` - : `OpenClaw approval was not granted for Claude native tool use (${toolName}).`, - }, - }); - } catch { - // The live process may close while an out-of-band approval is pending. - } - })(); -} - -function acceptSessionRequirement( - session: ClaudeLiveProcess, - parsed: Record, -): boolean { - const requirement = session.liveSessionRequirement; - if (!requirement || parsed.type !== "system" || parsed.subtype !== "init") { - return true; - } - const capabilities = Array.isArray(parsed.capabilities) - ? parsed.capabilities.filter((value): value is string => typeof value === "string") - : []; - if (capabilities.includes(requirement.capability)) { - session.liveSessionCapabilityReady = true; - return true; - } - const version = - typeof parsed.claude_code_version === "string" - ? parsed.claude_code_version.trim() || undefined - : undefined; - const versionDetail = version ? ` (version ${version})` : ""; - session.close( - "abort", - new FailoverError( - `The running Claude Code build${versionDetail} did not advertise the required ${requirement.capability} capability. Claude Code ${requirement.minimumVersion} is the first known compatible release. Run \`${requirement.updateCommand}\`, restart OpenClaw, and retry.`, - { - reason: "format", - provider: session.providerId, - model: session.modelId, - status: resolveFailoverStatus("format"), - code: "cli_live_session_unsupported", - }, - ), - ); - return false; -} - -function requestModelUpdate(params: { - session: ClaudeLiveProcess; - model: string; - systemPrompt: string; -}): Promise { - if (params.session.pendingControlRequest) { - return Promise.resolve(null); - } - const requestId = crypto.randomUUID(); - const response = new Promise((resolve) => { - params.session.pendingControlRequest = { - requestId, - timer: setTimeout( - () => settlePendingControlRequest(params.session, null), - CLAUDE_LIVE_CONTROL_TIMEOUT_MS, - ), - resolve, - }; - }); - return writeClaudeInput( - params.session, - `${JSON.stringify({ - type: "control_request", - request_id: requestId, - request: { subtype: "set_model", model: params.model, system_prompt: params.systemPrompt }, - })}\n`, - ) - .catch(() => settlePendingControlRequest(params.session, null)) - .then(() => response); -} - -async function supportsSystemPromptSwitch( - session: ClaudeLiveProcess, - model: string, -): Promise { - if (session.systemPromptSwitchCapability !== "unknown") { - return session.systemPromptSwitchCapability === "supported"; - } - const response = await requestModelUpdate({ session, model, systemPrompt: "" }); - const supported = - response?.subtype === "error" && response.error === CLAUDE_LIVE_SYSTEM_PROMPT_PROBE_ERROR; - session.systemPromptSwitchCapability = supported ? "supported" : "unsupported"; - return supported; -} - -export async function refreshClaudePrompt(params: { - session: ClaudeLiveProcess; - context: PreparedCliRunContext; - systemPromptHash: string; -}): Promise { - if (params.session.systemPromptHash === params.systemPromptHash) { - return true; - } - const systemPrompt = stripSystemPromptCacheBoundary(params.context.systemPrompt); - if ( - !systemPrompt.trim() || - !(await supportsSystemPromptSwitch(params.session, params.context.normalizedModel)) - ) { - params.session.close("restart"); - return false; - } - const response = await requestModelUpdate({ - session: params.session, - model: params.context.normalizedModel, - systemPrompt, - }); - if (response?.subtype === "success") { - params.session.systemPromptHash = params.systemPromptHash; - return true; - } - params.session.close("restart"); - return false; -} - -export function resolveClaudeLiveExecPermission( - context: PreparedCliRunContext, -): ClaudeLiveExecPermission { - const { security, ask } = resolveExecDefaults({ - cfg: context.params.config, - sessionEntry: context.params.sessionEntry, - execOverrides: context.params.execOverrides, - agentId: context.params.agentId, - sessionKey: context.params.runtimePolicySessionKey ?? context.params.sessionKey, - }); - return { - security, - ask, - permissionMode: resolveClaudeLiveMode(security, ask, process.getuid?.()), - }; -} - -export async function spawnClaudeProcess(params: { - context: PreparedCliRunContext; - argv: string[]; - env: Record; - generation: string; - fingerprint: string; - systemPromptHash: string; - key: string; - mcpCaptureKey?: string; - noOutputTimeoutMs: number; - supervisor: ProcessSupervisor; - cleanup: () => Promise; - onSpawned: (session: ClaudeLiveProcess) => void; - onClosed: (session: ClaudeLiveProcess) => void; -}): Promise { - let session: ClaudeLiveProcess | null = null; - const mcpCaptureAttempt = await prepareCliBundleMcpCaptureAttempt({ - mode: params.context.backendResolved.bundleMcpMode, - backend: params.context.preparedBackend.backend, - env: params.env, - captureKey: params.mcpCaptureKey, - }); - let managedRun: ManagedRun; - try { - managedRun = await params.supervisor.spawn({ - sessionId: params.context.params.sessionId, - backendId: params.context.backendResolved.id, - scopeKey: `claude-live:${params.key}`, - replaceExistingScope: true, - mode: "child", - argv: params.argv, - cwd: params.context.cwd ?? params.context.workspaceDir, - env: mcpCaptureAttempt.env ?? params.env, - stdinMode: "pipe-open", - secretInput: params.context.preparedBackend.secretInput, - captureOutput: false, - onStdout: (chunk) => { - if (session) { - acceptClaudeStdout(session, chunk); - } - }, - onStderr: (chunk) => { - if (!session) { - return; - } - session.currentTurn?.onCliOutput?.(chunk, "stderr"); - if (session.currentTurn && chunk.trim()) { - session.currentTurn.hasReplayUnsafeActivity = true; - } - session.stderr += chunk; - if (session.stderr.length > LIVE_SESSION_LIMITS.maxStderrChars) { - session.close( - "abort", - createClaudeOutputLimitError(session, "Claude CLI stderr exceeded limit."), - ); - return; - } - resetClaudeNoOutputTimer(session, session.currentTurn); - }, - }); - } catch (error) { - await mcpCaptureAttempt.cleanup?.(); - throw error; - } - const revokeMcpProcessGrant = - params.context.preparedBackend.mcpClientGrantCapture?.revokeProcessToken; - session = { - backend: params.context.preparedBackend.backend, - key: params.key, - generation: params.generation, - fingerprint: params.fingerprint, - systemPromptHash: params.systemPromptHash, - systemPromptSwitchCapability: "unknown", - liveSessionRequirement: params.context.backendResolved.liveSessionRequirement, - liveSessionCapabilityReady: !params.context.backendResolved.liveSessionRequirement, - managedRun, - providerId: params.context.params.provider, - modelId: params.context.modelId, - noOutputTimeoutMs: params.noOutputTimeoutMs, - stderr: "", - stdoutBuffer: { pending: "" }, - currentTurn: null, - idleTimer: null, - cleanup: async () => { - try { - revokeMcpProcessGrant?.(); - } finally { - try { - await mcpCaptureAttempt.cleanup?.(); - } finally { - await params.cleanup(); - } - } - }, - cleanupPromise: null, - closing: false, - pendingControlRequest: null, - mcpCaptureKey: params.mcpCaptureKey, - mcpGrantToken: params.context.preparedBackend.mcpClientGrantCapture?.transportToken, - nativeToolApprovalGrants: new Set(), - outstandingBackgroundTaskIds: new Set(), - isIdle() { - return this.currentTurn === null; - }, - close(reason, error) { - if (session?.closing) { - return; - } - cliBackendLog.info( - `claude live session close: provider=${this.providerId} model=${this.modelId} reason=${reason}`, - ); - this.closing = true; - if (this.idleTimer) { - clearTimeout(this.idleTimer); - this.idleTimer = null; - } - params.onClosed(this); - settlePendingControlRequest(this, null); - if (error) { - failClaudeTurn(this, error); - } else { - this.outstandingBackgroundTaskIds.clear(); - } - this.managedRun.cancel("manual-cancel"); - void cleanupProcess(this); - }, - scheduleIdleClose() { - if (this.idleTimer) { - clearTimeout(this.idleTimer); - } - this.idleTimer = setTimeout(() => { - if (!this.currentTurn) { - this.close("idle"); - } - }, CLAUDE_LIVE_IDLE_TIMEOUT_MS); - }, - acceptControlResponse(parsed) { - return acceptControlResponse(this, parsed); - }, - acceptControlRequest(turn, parsed) { - acceptControlRequest(this, turn, parsed); - }, - acceptSessionRequirement(parsed) { - return acceptSessionRequirement(this, parsed); - }, - acceptSessionId(sessionId) { - this.sessionId = sessionId; - }, - settleControlRequest() { - settlePendingControlRequest(this, null); - }, - cleanupAfterExit() { - if (this.idleTimer) { - clearTimeout(this.idleTimer); - this.idleTimer = null; - } - params.onClosed(this); - void cleanupProcess(this); - }, - waitForExit() { - return waitForManagedRunExit(this.managedRun); - }, - cleanupResources() { - return cleanupProcess(this); - }, - }; - params.onSpawned(session); - void managedRun.wait().then( - (exit) => { - if (session) { - acceptClaudeExit(session, exit.exitCode); - } - }, - (error: unknown) => { - if (session) { - session.close("abort", error); - } - }, - ); - return session; -} - -export function beginClaudeTurn( - session: ClaudeLiveProcess, - params: BeginClaudeTurnParams, -): Promise { - return new Promise((resolve, reject) => { - session.currentTurn = createClaudeTurn({ ...params, host: session, resolve, reject }); - }); -} - -export function abortClaudeTurn(session: ClaudeLiveProcess, error: Error): void { - if (session.currentTurn) { - session.close("abort", error); - } -} - -export function createClaudeUserInputMessage(content: string, uuid: string): string { - return `${JSON.stringify({ - type: "user", - uuid, - session_id: "", - parent_tool_use_id: null, - message: { role: "user", content }, - })}\n`; -} - -export async function writeClaudeInput(session: ClaudeLiveProcess, payload: string): Promise { - const stdin = session.managedRun.stdin; - if (!stdin) { - throw new Error("Claude CLI live session stdin is unavailable"); - } - await new Promise((resolve, reject) => { - stdin.write(payload, (error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }); - }); -} diff --git a/src/agents/cli-runner/claude-live-registry.test.ts b/src/agents/cli-runner/claude-live-registry.test.ts deleted file mode 100644 index 197b286c2cd1..000000000000 --- a/src/agents/cli-runner/claude-live-registry.test.ts +++ /dev/null @@ -1,1050 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "@openclaw/ai/internal/shared"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; -import { onAgentEvent, resetAgentEventsForTest } from "../../infra/agent-events.js"; -import { setDiagnosticsEnabledForProcess } from "../../infra/diagnostic-events.js"; -import { - resetDiagnosticRunActivityForTest, - startDiagnosticRunActivityTracking, -} from "../../logging/diagnostic-run-activity.js"; -import type { getProcessSupervisor } from "../../process/supervisor/index.js"; -import type { RunExit } from "../../process/supervisor/types.js"; -import { - buildClaudeLiveRunContext, - buildPreparedCliRunContext, - createClaudeInputStartedEvent, - mockCallArg, - mockClaudeLiveRun, -} from "../cli-runner.test-helpers.js"; -import { - restoreCliRunnerPrepareTestDeps, - supervisorSpawnMock, -} from "../cli-runner.test-support.js"; -import { - buildClaudeOwnerKey, - closeClaudeSession, - getClaudeGeneration, -} from "./claude-live-registry.js"; -import { runClaudeTurn } from "./claude-live-session.js"; -import { resetClaudeLiveSessionsForTest } from "./claude-live-session.test-support.js"; -import { executePreparedCliRun } from "./execute.js"; -import { setCliRunnerExecuteTestDeps } from "./execute.test-support.js"; -import { writeCliSystemPromptFile } from "./helpers.js"; -import { cliBackendLog } from "./log.js"; - -type ProcessSupervisor = ReturnType; -type SupervisorSpawnFn = ProcessSupervisor["spawn"]; -const tempDirs = useAutoCleanupTempDirTracker(afterEach); - -beforeEach(() => { - setDiagnosticsEnabledForProcess(true); - resetAgentEventsForTest(); - resetDiagnosticRunActivityForTest(); - startDiagnosticRunActivityTracking(); - resetClaudeLiveSessionsForTest(); - restoreCliRunnerPrepareTestDeps(); - setCliRunnerExecuteTestDeps({ writeCliSystemPromptFile }); - supervisorSpawnMock.mockClear(); -}); - -afterEach(() => { - vi.restoreAllMocks(); - vi.useRealTimers(); - resetDiagnosticRunActivityForTest(); - resetClaudeLiveSessionsForTest(); -}); - -function emitClaudeInputStarted(stdout: ((chunk: string) => void) | undefined, data: string): void { - const event = createClaudeInputStartedEvent(data); - if (event) { - stdout?.(`${JSON.stringify(event)}\n`); - } -} - -function getProcessSupervisorForTest() { - return { - spawn: (params: Parameters[0]) => - supervisorSpawnMock(params) as ReturnType, - cancel: vi.fn(), - cancelScope: vi.fn(), - getRecord: vi.fn(), - }; -} - -describe("buildClaudeOwnerKey", () => { - it("is deterministic and distinguishes session keys", () => { - const base = { - agentAccountId: "acct-1", - agentId: "agent-main", - authProfileId: "profile-a", - sessionId: "sess-1", - sessionKey: "key-a", - }; - const a1 = buildClaudeOwnerKey(base); - const a2 = buildClaudeOwnerKey(base); - expect(a1).toBe(a2); - expect(buildClaudeOwnerKey({ ...base, sessionKey: "key-b" })).not.toBe(a1); - }); - - it("keeps queue and live-session owner hashes byte-identical", () => { - expect( - buildClaudeOwnerKey({ - agentAccountId: "acct-1", - agentId: "agent-main", - authProfileId: "profile-a", - sessionId: "sess-1", - sessionKey: "key-a", - }), - ).toBe("718b9a6cf473526c3c357883dfc8f1da1cf90b709d9ed38d675b52314abe6800"); - }); -}); - -describe("Claude live registry lifecycle", () => { - it("reuses a Claude live session process across turns", async () => { - const logInfoSpy = vi.spyOn(cliBackendLog, "info").mockImplementation(() => undefined); - const agentEvents: unknown[] = []; - const stop = onAgentEvent((evt) => { - if (evt.stream === "assistant") { - agentEvents.push(evt.data); - } - }); - const live = mockClaudeLiveRun(supervisorSpawnMock, { - onWrite: ({ data, emit }) => { - const prompt = (JSON.parse(data) as { message: { content: string } }).message.content; - const text = prompt === "first" ? "one" : "two"; - emit([ - { type: "system", subtype: "init", session_id: "live-session-1" }, - { - type: "stream_event", - event: { - type: "content_block_delta", - delta: { type: "text_delta", text }, - }, - }, - { type: "result", session_id: "live-session-1", result: text }, - ]); - }, - }); - - try { - const firstContext = buildClaudeLiveRunContext({ - prompt: "first", - backend: { - args: ["-p", "--strict-mcp-config", "--mcp-config", "/tmp/mcp-one.json"], - resumeArgs: [ - "-p", - "--resume", - "{sessionId}", - "--strict-mcp-config", - "--mcp-config", - "/tmp/mcp-one.json", - ], - }, - mcpConfigHash: "same-mcp-config", - }); - const first = await executePreparedCliRun(firstContext); - const liveGeneration = getClaudeGeneration({ - backendId: "claude-cli", - sessionId: "s1", - }); - expect(liveGeneration).toBeDefined(); - const secondContext = buildClaudeLiveRunContext({ - prompt: "second", - backend: { - args: ["-p", "--strict-mcp-config", "--mcp-config", "/tmp/mcp-two.json"], - resumeArgs: [ - "-p", - "--resume", - "{sessionId}", - "--strict-mcp-config", - "--mcp-config", - "/tmp/mcp-two.json", - ], - }, - mcpConfigHash: "same-mcp-config", - }); - secondContext.requiredClaudeLiveSessionGeneration = liveGeneration; - const second = await executePreparedCliRun(secondContext, "live-session-1"); - - const changedContext = buildClaudeLiveRunContext({ - model: "opus", - prompt: "changed", - backend: { - args: ["-p"], - resumeArgs: ["-p", "--resume", "{sessionId}"], - }, - mcpConfigHash: "same-mcp-config", - }); - changedContext.requiredClaudeLiveSessionGeneration = liveGeneration; - await expect(executePreparedCliRun(changedContext, "live-session-1")).rejects.toMatchObject({ - reason: "session_expired", - code: "cli_live_session_changed", - }); - - const spawnInput = mockCallArg(supervisorSpawnMock) as { - argv?: string[]; - stdinMode?: string; - }; - expect(first.text).toBe("one"); - expect(second.text).toBe("two"); - expect(supervisorSpawnMock).toHaveBeenCalledOnce(); - expect(spawnInput.stdinMode).toBe("pipe-open"); - expect(spawnInput.argv).toContain("--input-format"); - expect(spawnInput.argv).toContain("--output-format"); - expect(spawnInput.argv).toContain("stream-json"); - expect(spawnInput.argv).toContain("--replay-user-messages"); - expect(spawnInput.argv).not.toContain("--session-id"); - expect(spawnInput.argv).toContain("/tmp/mcp-one.json"); - expect( - live.writes.map( - (entry) => (JSON.parse(entry) as { message: { content: string } }).message.content, - ), - ).toEqual(["first", "second"]); - expect(agentEvents).toEqual([ - { text: "one", delta: "one" }, - { text: "two", delta: "two" }, - ]); - const turnLogs = logInfoSpy.mock.calls - .map(([message]) => message) - .filter((message) => message.startsWith("claude live session turn:")); - expect(turnLogs).toHaveLength(2); - expect(turnLogs[0]).toContain("outBytes=3 outHash=7692c3ad3540"); - expect(turnLogs[1]).toContain("outBytes=3 outHash=3fc4ccfe7458"); - expect(turnLogs.join("\n")).not.toContain("one"); - expect(turnLogs.join("\n")).not.toContain("two"); - } finally { - logInfoSpy.mockRestore(); - stop(); - } - }); - - it("requires the exact warm Claude process even without native resume args", async () => { - const liveRuns = Array.from({ length: 3 }, () => - mockClaudeLiveRun(supervisorSpawnMock, { - pid: 2346, - events: [ - { type: "system", subtype: "init", session_id: "live-session-1" }, - { type: "result", session_id: "live-session-1", result: "one" }, - ], - }), - ); - - const firstContext = buildPreparedCliRunContext({ - prompt: "first", - backend: { args: ["-p"], resumeArgs: [], liveSession: "claude-stdio" }, - }); - expect((await executePreparedCliRun(firstContext)).text).toBe("one"); - const liveGeneration = getClaudeGeneration({ - backendId: "claude-cli", - sessionId: "s1", - }); - expect(liveGeneration).toBeDefined(); - - resetClaudeLiveSessionsForTest(); - const missingContext = buildPreparedCliRunContext({ - prompt: "second", - backend: { args: ["-p"], resumeArgs: [], liveSession: "claude-stdio" }, - }); - missingContext.requiredClaudeLiveSessionGeneration = liveGeneration; - - await expect(executePreparedCliRun(missingContext, "live-session-1")).rejects.toMatchObject({ - reason: "session_expired", - code: "cli_live_session_missing", - }); - - const replacementContext = buildPreparedCliRunContext({ - prompt: "replacement", - backend: { args: ["-p"], resumeArgs: [], liveSession: "claude-stdio" }, - }); - expect((await executePreparedCliRun(replacementContext)).text).toBe("one"); - await expect(executePreparedCliRun(missingContext, "live-session-1")).rejects.toMatchObject({ - reason: "session_expired", - code: "cli_live_session_changed", - }); - missingContext.openClawHistoryPrompt = "bounded OpenClaw history\n\nsecond"; - expect((await executePreparedCliRun(missingContext)).text).toBe("one"); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(3); - expect( - (JSON.parse(liveRuns[2]?.writes.at(-1) ?? "") as { message: { content: string } }).message - .content, - ).toBe("bounded OpenClaw history\n\nsecond"); - }); - - it("serializes concurrent Claude live session creation for the same key", async () => { - let releaseSpawn: (() => void) | undefined; - let turn = 0; - const spawnReady = new Promise((resolve) => { - releaseSpawn = resolve; - }); - const live = mockClaudeLiveRun(supervisorSpawnMock, { - beforeSpawn: () => spawnReady, - onWrite: ({ emit }) => { - turn += 1; - emit([ - { type: "system", subtype: "init", session_id: "live-concurrent" }, - { - type: "result", - session_id: "live-concurrent", - result: turn === 1 ? "one" : "two", - }, - ]); - }, - }); - - const backend = { - liveSession: "claude-stdio" as const, - }; - const first = executePreparedCliRun( - buildPreparedCliRunContext({ - prompt: "first", - backend, - }), - ); - const second = executePreparedCliRun( - buildPreparedCliRunContext({ - prompt: "second", - backend, - }), - ); - await vi.waitFor(() => expect(supervisorSpawnMock).toHaveBeenCalledOnce()); - releaseSpawn?.(); - - const results = await Promise.all([first, second]); - expect(results.map((result) => result.text).toSorted()).toEqual(["one", "two"]); - expect(live.stdin.write).toHaveBeenCalledTimes(2); - expect(supervisorSpawnMock).toHaveBeenCalledOnce(); - }); - - it("does not register a process whose pending spawn was closed", async () => { - let releaseSpawn: (() => void) | undefined; - const spawnBlocked = new Promise((resolve) => { - releaseSpawn = resolve; - }); - let stdoutListener: ((chunk: string) => void) | undefined; - const cancel = vi.fn(); - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - await spawnBlocked; - return { - pid: 2349, - startedAtMs: Date.now(), - stdin: { - write: vi.fn((data: string, callback?: (error?: Error | null) => void) => { - emitClaudeInputStarted(stdoutListener, data); - stdoutListener?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "closed-spawn" }), - JSON.stringify({ type: "result", session_id: "closed-spawn", result: "late" }), - ].join("\n") + "\n", - ); - callback?.(); - }), - end: vi.fn(), - }, - wait: vi.fn(() => new Promise(() => {})), - cancel, - }; - }); - - const context = buildPreparedCliRunContext({ - runId: "run-close-pending-spawn", - sessionId: "session-close-pending-spawn", - backend: { liveSession: "claude-stdio" }, - }); - const run = runClaudeTurn({ - context, - args: context.preparedBackend.backend.args ?? [], - env: {}, - prompt: "hello", - useResume: false, - noOutputTimeoutMs: 1_000, - getProcessSupervisor: getProcessSupervisorForTest, - onAssistantDelta: () => {}, - cleanup: async () => {}, - }); - - await vi.waitFor(() => expect(supervisorSpawnMock).toHaveBeenCalledOnce()); - expect( - getClaudeGeneration({ backendId: "claude-cli", sessionId: "session-close-pending-spawn" }), - ).toBeDefined(); - await closeClaudeSession(context, "restart"); - releaseSpawn?.(); - - await expect(run).rejects.toThrow("closed before handling the turn"); - expect( - getClaudeGeneration({ backendId: "claude-cli", sessionId: "session-close-pending-spawn" }), - ).toBeUndefined(); - expect(cancel).toHaveBeenCalledWith("manual-cancel"); - }); - - it("does not close a replacement spawned while the previous process exits", async () => { - let resolveOldExit: ((exit: RunExit) => void) | undefined; - const oldExit = new Promise((resolve) => { - resolveOldExit = resolve; - }); - const old = mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: "old-session" }, - { type: "result", session_id: "old-session", result: "old" }, - ], - }); - old.lifecycle.wait.mockImplementation(() => oldExit); - - const context = buildPreparedCliRunContext({ - prompt: "old", - backend: { liveSession: "claude-stdio" }, - }); - await expect(executePreparedCliRun(context)).resolves.toMatchObject({ text: "old" }); - - let releaseReplacementSpawn: (() => void) | undefined; - const replacementSpawnBlocked = new Promise((resolve) => { - releaseReplacementSpawn = resolve; - }); - const replacement = mockClaudeLiveRun(supervisorSpawnMock, { - beforeSpawn: () => replacementSpawnBlocked, - events: [ - { type: "system", subtype: "init", session_id: "replacement-session" }, - { type: "result", session_id: "replacement-session", result: "replacement" }, - ], - }); - - const closing = closeClaudeSession(context, "restart"); - await vi.waitFor(() => expect(old.lifecycle.cancel).toHaveBeenCalledWith("manual-cancel")); - const replacementRun = executePreparedCliRun( - buildPreparedCliRunContext({ - prompt: "replacement", - backend: { liveSession: "claude-stdio" }, - }), - ); - await vi.waitFor(() => expect(supervisorSpawnMock).toHaveBeenCalledTimes(2)); - - resolveOldExit?.({ - reason: "manual-cancel", - exitCode: null, - exitSignal: null, - durationMs: 1, - stdout: "", - stderr: "", - timedOut: false, - noOutputTimedOut: false, - }); - await closing; - releaseReplacementSpawn?.(); - - await expect(replacementRun).resolves.toMatchObject({ text: "replacement" }); - expect(replacement.lifecycle.cancel).not.toHaveBeenCalled(); - }); - - it("recovers when a required warm Claude process exits during reuse cleanup", async () => { - let stdoutListener: ((chunk: string) => void) | undefined; - let resolveExit: ((exit: RunExit) => void) | undefined; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - let turn = 0; - const stdin = { - write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { - emitClaudeInputStarted(stdoutListener, data); - turn += 1; - stdoutListener?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-race" }), - JSON.stringify({ type: "result", session_id: "live-race", result: `turn-${turn}` }), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - pid: 2350, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => exited), - cancel: vi.fn(), - }; - }); - const context = buildPreparedCliRunContext({ - prompt: "first", - backend: { args: ["-p"], resumeArgs: [], liveSession: "claude-stdio" }, - }); - const first = await runClaudeTurn({ - context, - args: context.preparedBackend.backend.args ?? [], - env: {}, - prompt: "first", - useResume: false, - noOutputTimeoutMs: 1_000, - getProcessSupervisor: getProcessSupervisorForTest, - onAssistantDelta: () => {}, - cleanup: async () => {}, - }); - expect(first.output.text).toBe("turn-1"); - const generation = getClaudeGeneration({ - backendId: "claude-cli", - sessionId: "s1", - }); - expect(generation).toBeDefined(); - - let markCleanupStarted: (() => void) | undefined; - const cleanupStarted = new Promise((resolve) => { - markCleanupStarted = resolve; - }); - let releaseCleanup: (() => void) | undefined; - const cleanupReleased = new Promise((resolve) => { - releaseCleanup = resolve; - }); - const reuse = runClaudeTurn({ - context, - args: context.preparedBackend.backend.args ?? [], - env: {}, - prompt: "second", - useResume: false, - requiredSessionGeneration: generation, - noOutputTimeoutMs: 1_000, - getProcessSupervisor: getProcessSupervisorForTest, - onAssistantDelta: () => {}, - cleanup: async () => { - markCleanupStarted?.(); - await cleanupReleased; - }, - }); - await cleanupStarted; - resolveExit?.({ - reason: "exit", - exitCode: 0, - exitSignal: null, - durationMs: 1, - stdout: "", - stderr: "", - timedOut: false, - noOutputTimedOut: false, - }); - await vi.waitFor(() => - expect(getClaudeGeneration({ backendId: "claude-cli", sessionId: "s1" })).toBeUndefined(), - ); - releaseCleanup?.(); - - await expect(reuse).rejects.toMatchObject({ - reason: "session_expired", - code: "cli_live_session_missing", - }); - expect(stdin.write).toHaveBeenCalledOnce(); - }); - - it("counts pending Claude live session creates against the session cap", async () => { - let releaseSpawn: (() => void) | undefined; - const spawnReady = new Promise((resolve) => { - releaseSpawn = resolve; - }); - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - const spawnIndex = supervisorSpawnMock.mock.calls.length; - await spawnReady; - const stdin = { - write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { - emitClaudeInputStarted(input.onStdout, dataValue); - input.onStdout?.( - [ - JSON.stringify({ - type: "system", - subtype: "init", - session_id: `live-cap-${spawnIndex}`, - }), - JSON.stringify({ - type: "result", - session_id: `live-cap-${spawnIndex}`, - result: `ok-${spawnIndex}`, - }), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }; - return { - runId: `live-run-${spawnIndex}`, - pid: 2300 + spawnIndex, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; - }); - - const backend = { - liveSession: "claude-stdio" as const, - }; - const runs = Array.from({ length: 17 }, (_, index) => - (() => { - const context = buildPreparedCliRunContext({ - runId: `run-live-cap-${index}`, - prompt: `prompt ${index}`, - sessionId: `session-${index}`, - backend, - }); - return runClaudeTurn({ - context, - args: context.preparedBackend.backend.args ?? [], - env: {}, - prompt: `prompt ${index}`, - useResume: false, - noOutputTimeoutMs: 1_000, - getProcessSupervisor: getProcessSupervisorForTest, - onAssistantDelta: () => {}, - cleanup: async () => {}, - }); - })(), - ); - const rejectedRun = runs[16]; - const rejectedRunExpectation = expect(rejectedRun).rejects.toThrow( - "Too many Claude CLI live sessions are active.", - ); - - await vi.waitFor(() => expect(supervisorSpawnMock).toHaveBeenCalledTimes(16)); - await rejectedRunExpectation; - releaseSpawn?.(); - await expect(Promise.all(runs.slice(0, 16))).resolves.toHaveLength(16); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(16); - }); - - it("reuses the same credential generation and restarts when it rotates", async () => { - let stdoutListener: ((chunk: string) => void) | undefined; - const cancel = vi.fn(); - const userInputUuids: string[] = []; - const stdin = { - write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { - const parsed = JSON.parse(data) as { type?: string; uuid?: string }; - if (parsed.type === "user" && typeof parsed.uuid === "string") { - userInputUuids.push(parsed.uuid); - stdoutListener?.( - `${JSON.stringify({ - type: "command_lifecycle", - command_uuid: parsed.uuid, - state: "started", - })}\n`, - ); - } - stdoutListener?.( - [ - JSON.stringify({ - type: "system", - subtype: "init", - session_id: "live-credential-rotation", - }), - JSON.stringify({ - type: "result", - subtype: "success", - session_id: "live-credential-rotation", - result: "done", - }), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: `live-credential-${supervisorSpawnMock.mock.calls.length}`, - pid: 4242, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel, - }; - }); - const runTurn = (runId: string, credentialFingerprint: string) => { - const context = buildPreparedCliRunContext({ - runId, - backend: { liveSession: "claude-stdio" }, - }); - context.preparedBackend.secretInput = { - fd: 3, - fingerprint: credentialFingerprint, - createData: () => Buffer.from("secret"), - }; - return runClaudeTurn({ - context, - args: context.preparedBackend.backend.args ?? [], - env: {}, - prompt: "hi", - useResume: false, - noOutputTimeoutMs: 5_000, - getProcessSupervisor: getProcessSupervisorForTest, - onAssistantDelta: () => {}, - cleanup: async () => {}, - }); - }; - - await runTurn("run-credential-a-first", "credential-a"); - await runTurn("run-credential-a-second", "credential-a"); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(1); - - await runTurn("run-credential-b", "credential-b"); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); - expect(new Set(userInputUuids).size).toBe(3); - expect(cancel).toHaveBeenCalledOnce(); - }); - - it("restarts Claude live sessions when selected skills change", async () => { - const workspaceDir = tempDirs.make("openclaw-live-skills-"); - const weatherDir = path.join(workspaceDir, "skills", "weather"); - const gitDir = path.join(workspaceDir, "skills", "git"); - await fs.mkdir(weatherDir, { recursive: true }); - await fs.mkdir(gitDir, { recursive: true }); - await fs.writeFile(path.join(weatherDir, "SKILL.md"), "weather instructions\n", "utf-8"); - await fs.writeFile(path.join(gitDir, "SKILL.md"), "git instructions\n", "utf-8"); - - const cancels: Array> = []; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const spawnIndex = supervisorSpawnMock.mock.calls.length; - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - const cancel = vi.fn(); - cancels.push(cancel); - const stdin = { - write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { - emitClaudeInputStarted(input.onStdout, dataValue); - const text = spawnIndex === 1 ? "weather-ok" : "git-ok"; - input.onStdout?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: `live-${spawnIndex}` }), - JSON.stringify({ - type: "result", - session_id: `live-${spawnIndex}`, - result: text, - }), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }; - return { - runId: `live-run-${spawnIndex}`, - pid: 2345 + spawnIndex, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel, - }; - }); - - try { - const first = await executePreparedCliRun( - buildClaudeLiveRunContext({ - prompt: "first", - workspaceDir, - skillsSnapshot: { - prompt: "weather", - skills: [{ name: "weather" }], - resolvedSkills: [ - { - name: "weather", - description: "Weather instructions.", - filePath: path.join(weatherDir, "SKILL.md"), - baseDir: weatherDir, - source: "test", - sourceInfo: { - path: weatherDir, - source: "test", - scope: "project", - origin: "top-level", - baseDir: weatherDir, - }, - disableModelInvocation: false, - }, - ], - }, - }), - ); - const second = await executePreparedCliRun( - buildClaudeLiveRunContext({ - prompt: "second", - workspaceDir, - skillsSnapshot: { - prompt: "git", - skills: [{ name: "git" }], - resolvedSkills: [ - { - name: "git", - description: "Git instructions.", - filePath: path.join(gitDir, "SKILL.md"), - baseDir: gitDir, - source: "test", - sourceInfo: { - path: gitDir, - source: "test", - scope: "project", - origin: "top-level", - baseDir: gitDir, - }, - disableModelInvocation: false, - }, - ], - }, - }), - ); - - expect(first.text).toBe("weather-ok"); - expect(second.text).toBe("git-ok"); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); - expect(cancels[0]).toHaveBeenCalledWith("manual-cancel"); - expect(cancels[1]).not.toHaveBeenCalled(); - } finally { - await fs.rm(workspaceDir, { recursive: true, force: true }); - } - }); - - it("closes idle Claude live sessions after ten minutes", async () => { - vi.useFakeTimers(); - const live = mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: "live-session-idle" }, - { type: "result", session_id: "live-session-idle", result: "idle-ok" }, - ], - }); - - try { - const result = await executePreparedCliRun( - buildClaudeLiveRunContext({ - prompt: "idle", - }), - ); - - expect(result.text).toBe("idle-ok"); - expect(live.lifecycle.cancel).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(10 * 60 * 1_000 - 1); - expect(live.lifecycle.cancel).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(1); - expect(live.lifecycle.cancel).toHaveBeenCalledWith("manual-cancel"); - expect( - live.writes.map( - (entry) => (JSON.parse(entry) as { message: { content: string } }).message.content, - ), - ).toEqual(["idle"]); - } finally { - vi.useRealTimers(); - } - }); - it("serializes direct live turns and drops an aborted queued turn", async () => { - let userTurn = 0; - let releaseSecondTurn: (() => void) | undefined; - const live = mockClaudeLiveRun(supervisorSpawnMock, { - onWrite: ({ data, emit }) => { - const parsed = JSON.parse(data) as { type: string }; - if (parsed.type !== "user") { - throw new Error(`unexpected live stdin ${parsed.type}`); - } - userTurn += 1; - const emitTurn = () => { - emit([ - { type: "system", subtype: "init", session_id: "live-serialized-turns" }, - { - type: "result", - session_id: "live-serialized-turns", - result: `turn-${userTurn}`, - }, - ]); - }; - if (userTurn === 2) { - releaseSecondTurn = emitTurn; - return; - } - emitTurn(); - }, - }); - const backend = { - args: ["-p", "--output-format", "stream-json"], - resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], - liveSession: "claude-stdio" as const, - systemPromptWhen: "always" as const, - }; - const getSerializedProcessSupervisor = () => ({ - spawn: (params: Parameters[0]) => - supervisorSpawnMock(params) as ReturnType, - cancel: vi.fn(), - cancelScope: vi.fn(), - getRecord: vi.fn(), - }); - const runTurn = ( - prompt: string, - useResume: boolean, - abortSignal?: AbortSignal, - cleanup: () => Promise = async () => {}, - ) => { - const context = buildPreparedCliRunContext({ backend, prompt }); - context.params.abortSignal = abortSignal; - return runClaudeTurn({ - context, - args: context.preparedBackend.backend.args ?? [], - env: {}, - prompt, - useResume, - noOutputTimeoutMs: 1_000, - getProcessSupervisor: getSerializedProcessSupervisor, - onAssistantDelta: () => {}, - cleanup, - }); - }; - - await expect(runTurn("first", false)).resolves.toMatchObject({ output: { text: "turn-1" } }); - - const second = runTurn("second", true); - await vi.waitFor(() => expect(releaseSecondTurn).toBeTypeOf("function")); - const queuedAbort = new AbortController(); - const abortedCleanup = vi.fn(async () => {}); - const third = runTurn("third", true, queuedAbort.signal, abortedCleanup); - await new Promise((resolve) => { - setImmediate(resolve); - }); - - expect(live.writes.map((entry) => JSON.parse(entry).type)).toEqual(["user", "user"]); - queuedAbort.abort(); - await expect(third).rejects.toMatchObject({ name: "AbortError" }); - expect(abortedCleanup).toHaveBeenCalledOnce(); - expect(live.writes.map((entry) => JSON.parse(entry).type)).toEqual(["user", "user"]); - releaseSecondTurn?.(); - - await expect(second).resolves.toMatchObject({ output: { text: "turn-2" } }); - await expect(runTurn("fourth", true)).resolves.toMatchObject({ output: { text: "turn-3" } }); - expect(live.writes.map((entry) => JSON.parse(entry).type)).toEqual(["user", "user", "user"]); - expect(supervisorSpawnMock).toHaveBeenCalledOnce(); - }); - - it("serializes direct live turns before refreshing their system prompts", async () => { - let userTurn = 0; - let releaseCapabilityProbe: (() => void) | undefined; - const live = mockClaudeLiveRun(supervisorSpawnMock, { - onWrite: ({ data, emit }) => { - const parsed = JSON.parse(data) as { - type: string; - request_id?: string; - request?: { system_prompt?: string }; - }; - if (parsed.type === "control_request") { - if (parsed.request?.system_prompt === "") { - releaseCapabilityProbe = () => { - emit([ - { - type: "control_response", - response: { - subtype: "error", - request_id: parsed.request_id, - error: "set_model: system_prompt must be a non-empty string when present", - }, - }, - ]); - }; - return; - } - emit([ - { - type: "control_response", - response: { - subtype: "success", - request_id: parsed.request_id, - }, - }, - ]); - return; - } - userTurn += 1; - emit([ - { type: "system", subtype: "init", session_id: "live-serialized-refresh" }, - { - type: "result", - session_id: "live-serialized-refresh", - result: `turn-${userTurn}`, - }, - ]); - }, - }); - const backend = { - args: ["-p", "--output-format", "stream-json"], - resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], - liveSession: "claude-stdio" as const, - systemPromptWhen: "always" as const, - }; - const getSerializedProcessSupervisor = () => ({ - spawn: (params: Parameters[0]) => - supervisorSpawnMock(params) as ReturnType, - cancel: vi.fn(), - cancelScope: vi.fn(), - getRecord: vi.fn(), - }); - const runTurn = ( - systemPrompt: string, - prompt: string, - useResume: boolean, - abortSignal?: AbortSignal, - cleanup: () => Promise = async () => {}, - ) => { - const context = buildPreparedCliRunContext({ backend, prompt, systemPrompt }); - context.params.abortSignal = abortSignal; - return runClaudeTurn({ - context, - args: context.preparedBackend.backend.args ?? [], - env: {}, - prompt, - useResume, - noOutputTimeoutMs: 1_000, - getProcessSupervisor: getSerializedProcessSupervisor, - onAssistantDelta: () => {}, - cleanup, - }); - }; - - await expect( - runTurn(`Stable instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}First metadata`, "first", false), - ).resolves.toMatchObject({ output: { text: "turn-1" } }); - - const second = runTurn( - `Stable instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}Second metadata`, - "second", - true, - ); - await vi.waitFor(() => expect(releaseCapabilityProbe).toBeTypeOf("function")); - const queuedAbort = new AbortController(); - const abortedCleanup = vi.fn(async () => {}); - const third = runTurn( - `Stable instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}Third metadata`, - "third", - true, - queuedAbort.signal, - abortedCleanup, - ); - await new Promise((resolve) => { - setImmediate(resolve); - }); - - expect(live.writes.map((entry) => JSON.parse(entry).type)).toEqual(["user", "control_request"]); - queuedAbort.abort(); - await expect(third).rejects.toMatchObject({ name: "AbortError" }); - expect(abortedCleanup).toHaveBeenCalledOnce(); - expect(live.writes.map((entry) => JSON.parse(entry).type)).toEqual(["user", "control_request"]); - releaseCapabilityProbe?.(); - - await expect(second).resolves.toMatchObject({ output: { text: "turn-2" } }); - await expect( - runTurn(`Stable instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}Fourth metadata`, "fourth", true), - ).resolves.toMatchObject({ output: { text: "turn-3" } }); - expect(live.writes.map((entry) => JSON.parse(entry).type)).toEqual([ - "user", - "control_request", - "control_request", - "user", - "control_request", - "user", - ]); - expect(supervisorSpawnMock).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/agents/cli-runner/claude-live-registry.ts b/src/agents/cli-runner/claude-live-registry.ts deleted file mode 100644 index 38e462598597..000000000000 --- a/src/agents/cli-runner/claude-live-registry.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { sha256Hex } from "../../infra/crypto-digest.js"; -import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js"; -import { FailoverError, resolveFailoverStatus } from "../failover-error.js"; -import { LIVE_SESSION_LIMITS } from "./claude-live-session-policy.js"; -import { cliBackendLog } from "./log.js"; -import type { PreparedCliRunContext } from "./types.js"; - -type ClaudeLiveSessionOwner = { - backendId: string; - agentAccountId?: string; - agentId?: string; - authProfileId?: string; - sessionId?: string; - sessionKey?: string; -}; - -type ClaudeLiveCloseReason = "idle" | "restart" | "abort" | "mcp-capture-rotation"; - -/** Structural process handle kept by the registry without importing its implementation. */ -type ClaudeLiveProcessHandle = { - key: string; - generation: string; - providerId: string; - modelId: string; - isIdle(): boolean; - close(reason: ClaudeLiveCloseReason, error?: unknown): void; - waitForExit(): Promise; - cleanupResources(): Promise; -}; - -type ClaudeLiveSessionCreate = { - generation: string; - closeReason?: ClaudeLiveCloseReason; -}; - -const liveSessions = new Map(); -const liveSessionCreates = new Map(); -const liveSessionTurns = new KeyedAsyncQueue(); - -function buildClaudeLiveOwnerKey(owner: ClaudeLiveSessionOwner): string { - return `${owner.backendId}:${buildClaudeOwnerKey(owner)}`; -} - -/** Hashes the account/agent/auth/session tuple shared by queue and registry ownership. */ -export function buildClaudeOwnerKey(input: Omit): string { - return sha256Hex( - JSON.stringify({ - agentAccountId: input.agentAccountId, - agentId: input.agentId, - authProfileId: input.authProfileId, - sessionId: input.sessionId, - sessionKey: input.sessionKey, - }), - ); -} - -export function buildClaudeLiveKey(context: PreparedCliRunContext): string { - return buildClaudeLiveOwnerKey({ - backendId: context.backendResolved.id, - agentAccountId: context.params.agentAccountId, - agentId: context.params.agentId, - authProfileId: context.effectiveAuthProfileId, - sessionId: context.params.sessionId, - sessionKey: context.params.sessionKey, - }); -} - -/** Returns whether this owner still has an in-process Claude stdio session. */ -export function hasClaudeSession(owner: ClaudeLiveSessionOwner): boolean { - return getClaudeGeneration(owner) !== undefined; -} - -/** Returns the opaque generation of this owner's current or pending Claude stdio session. */ -export function getClaudeGeneration(owner: ClaudeLiveSessionOwner): string | undefined { - const key = buildClaudeLiveOwnerKey(owner); - return liveSessions.get(key)?.generation ?? liveSessionCreates.get(key)?.generation; -} - -export function getClaudeSession(key: string): ClaudeLiveProcessHandle | undefined { - return liveSessions.get(key); -} - -export function registerClaudeSession( - session: ClaudeLiveProcessHandle, - pending: ClaudeLiveSessionCreate, -): void { - if (liveSessionCreates.get(session.key) !== pending || pending.closeReason) { - session.close(pending.closeReason ?? "restart"); - return; - } - liveSessions.set(session.key, session); - cliBackendLog.info( - `claude live session start: provider=${session.providerId} model=${session.modelId} activeSessions=${liveSessions.size}`, - ); -} - -export function removeClaudeSession(session: ClaudeLiveProcessHandle): void { - if (liveSessions.get(session.key) === session) { - liveSessions.delete(session.key); - } -} - -export function beginClaudeSessionCreate(key: string, generation: string): ClaudeLiveSessionCreate { - const create = { generation }; - liveSessionCreates.set(key, create); - return create; -} - -export function finishClaudeSessionCreate(key: string, create: ClaudeLiveSessionCreate): void { - if (liveSessionCreates.get(key) === create) { - liveSessionCreates.delete(key); - } -} - -export function enqueueClaudeTurn(key: string, task: () => Promise): Promise { - return liveSessionTurns.enqueue(key, task); -} - -/** Closes the live Claude session associated with a prepared run context, if one exists. */ -export async function closeClaudeSession( - context: PreparedCliRunContext, - reason: ClaudeLiveCloseReason, -): Promise { - const key = buildClaudeLiveKey(context); - const session = liveSessions.get(key); - const pending = liveSessionCreates.get(key); - if (session) { - session.close(reason); - } - if (pending) { - pending.closeReason = reason; - liveSessionCreates.delete(key); - } - if (session) { - await session.waitForExit(); - } -} - -function closeOldestIdleSession(): boolean { - for (const session of liveSessions.values()) { - if (session.isIdle()) { - session.close("idle"); - return true; - } - } - return false; -} - -export function ensureClaudeSessionCapacity(key: string, context: PreparedCliRunContext): void { - if ( - liveSessions.has(key) || - liveSessionCreates.has(key) || - liveSessions.size + liveSessionCreates.size < LIVE_SESSION_LIMITS.maxSessions - ) { - return; - } - if (closeOldestIdleSession()) { - return; - } - throw new FailoverError("Too many Claude CLI live sessions are active.", { - reason: "rate_limit", - provider: context.params.provider, - model: context.modelId, - status: resolveFailoverStatus("rate_limit"), - }); -} - -/** Closes all live Claude CLI sessions and clears creation promises for tests. */ -function resetClaudeLiveSessionsForTest(): void { - for (const session of liveSessions.values()) { - session.close("restart"); - } - liveSessions.clear(); - for (const pending of liveSessionCreates.values()) { - pending.closeReason = "restart"; - } - liveSessionCreates.clear(); -} - -if (process.env.VITEST || process.env.NODE_ENV === "test") { - (globalThis as Record)[Symbol.for("openclaw.claudeLiveRegistryReset")] = - resetClaudeLiveSessionsForTest; -} diff --git a/src/agents/cli-runner/claude-live-session-policy.test.ts b/src/agents/cli-runner/claude-live-session-policy.test.ts deleted file mode 100644 index 2035b5df104c..000000000000 --- a/src/agents/cli-runner/claude-live-session-policy.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveClaudeLiveExecPermission } from "./claude-live-process.js"; -import { acceptsClaudeLive, resolveClaudeLiveMode } from "./claude-live-session-policy.js"; -import type { PreparedCliRunContext } from "./types.js"; - -describe("resolveClaudeLiveMode", () => { - it("keeps root on Claude default permissions while preserving YOLO elsewhere", () => { - expect(resolveClaudeLiveMode("full", "off", 0)).toBe("default"); - expect(resolveClaudeLiveMode("full", "off", 1000)).toBe("bypassPermissions"); - }); - - it("keeps restrictive OpenClaw policies on Claude default permissions", () => { - expect(resolveClaudeLiveMode("allowlist", "on-miss", 1000)).toBe("default"); - }); -}); - -describe("acceptsClaudeLive", () => { - it("accepts only local Claude stdin/jsonl stdio contexts", () => { - const context = { - params: { sessionEntry: {} }, - backendResolved: { id: "claude-cli" }, - preparedBackend: { - backend: { liveSession: "claude-stdio", output: "jsonl", input: "stdin" }, - }, - } as unknown as PreparedCliRunContext; - - expect(acceptsClaudeLive(context)).toBe(true); - expect( - acceptsClaudeLive({ - ...context, - params: { ...context.params, sessionEntry: { execHost: "node" } }, - } as unknown as PreparedCliRunContext), - ).toBe(false); - expect( - acceptsClaudeLive({ - ...context, - preparedBackend: { - ...context.preparedBackend, - backend: { ...context.preparedBackend.backend, output: "json" }, - }, - }), - ).toBe(false); - }); - - it("uses the configured fixed-store owner for an unscoped session key", () => { - const context = { - params: { - sessionKey: "global", - config: { - session: { store: "/stores/shared.sqlite" }, - tools: { exec: { security: "full", ask: "off" } }, - agents: { - ownership: "explicit", - defaults: { sessionStore: { agentId: "research" } }, - entries: { - ops: {}, - research: { tools: { exec: { security: "deny", ask: "always" } } }, - }, - }, - }, - }, - } as unknown as PreparedCliRunContext; - - expect(resolveClaudeLiveExecPermission(context)).toEqual({ - security: "deny", - ask: "always", - permissionMode: "default", - }); - }); - - it("uses bypass permissions for an explicit full session despite restrictive config", () => { - const context = { - params: { - config: { tools: { exec: { mode: "ask" } } }, - sessionEntry: { permissionMode: "full" }, - }, - } as unknown as PreparedCliRunContext; - - expect(resolveClaudeLiveExecPermission(context)).toEqual({ - security: "full", - ask: "off", - permissionMode: "bypassPermissions", - }); - }); -}); diff --git a/src/agents/cli-runner/claude-live-session-policy.ts b/src/agents/cli-runner/claude-live-session-policy.ts deleted file mode 100644 index 71f73a15c3f5..000000000000 --- a/src/agents/cli-runner/claude-live-session-policy.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { ExecAsk, ExecSecurity } from "../../infra/exec-approvals.js"; -import type { PreparedCliRunContext } from "./types.js"; - -export const LIVE_SESSION_LIMITS = { - maxSessions: 16, - maxStderrChars: 64 * 1024, -} as const; - -/** Returns whether a prepared backend context is eligible for Claude live stdio reuse. */ -export function acceptsClaudeLive(context: PreparedCliRunContext): boolean { - return ( - context.params.sessionEntry?.execHost !== "node" && - context.backendResolved.id === "claude-cli" && - context.preparedBackend.backend.liveSession === "claude-stdio" && - context.preparedBackend.backend.output === "jsonl" && - context.preparedBackend.backend.input === "stdin" - ); -} - -/** Resolve Claude's live permission mode without asking root to use an unsupported bypass. */ -export function resolveClaudeLiveMode( - security: ExecSecurity, - ask: ExecAsk, - uid?: number, -): "bypassPermissions" | "default" { - // Claude Code rejects bypassPermissions before stdio control requests when - // running as root. Default mode still lets OpenClaw answer those requests - // from the authoritative exec policy in handleClaudeLiveControlRequest. - return security === "full" && ask === "off" && uid !== 0 ? "bypassPermissions" : "default"; -} diff --git a/src/agents/cli-runner/claude-live-session.abort-partial-output.test.ts b/src/agents/cli-runner/claude-live-session.abort-partial-output.test.ts deleted file mode 100644 index 558fcc1f7d5c..000000000000 --- a/src/agents/cli-runner/claude-live-session.abort-partial-output.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** Claude live session: aborted turns must surface already-streamed assistant text. */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { getProcessSupervisor } from "../../process/supervisor/index.js"; -import { buildClaudeLiveRunContext, mockClaudeLiveRun } from "../cli-runner.test-helpers.js"; -import { supervisorSpawnMock } from "../cli-runner.test-support.js"; -import { runClaudeTurn } from "./claude-live-session.js"; -import { resetClaudeLiveSessionsForTest } from "./claude-live-session.test-support.js"; - -type ProcessSupervisor = ReturnType; -type SupervisorSpawnFn = ProcessSupervisor["spawn"]; - -beforeEach(() => { - resetClaudeLiveSessionsForTest(); - supervisorSpawnMock.mockClear(); -}); - -afterEach(() => { - vi.restoreAllMocks(); - resetClaudeLiveSessionsForTest(); -}); - -function getProcessSupervisorForTest() { - return { - spawn: (params: Parameters[0]) => - supervisorSpawnMock(params) as ReturnType, - cancel: vi.fn(), - cancelScope: vi.fn(), - getRecord: vi.fn(), - }; -} - -function startLiveTurnWithAbortSignal(runId: string, signal: AbortSignal) { - const context = buildClaudeLiveRunContext({ runId, timeoutMs: 60_000 }); - context.params.abortSignal = signal; - return runClaudeTurn({ - context, - args: context.preparedBackend.backend.args ?? [], - env: {}, - prompt: "hi", - useResume: false, - noOutputTimeoutMs: 5_000, - getProcessSupervisor: getProcessSupervisorForTest, - onAssistantDelta: () => {}, - cleanup: async () => {}, - }); -} - -describe("claude live session aborted-turn partial output", () => { - it.each([ - { - name: "AbortError", - reason: "aborted", - abort: (controller: AbortController) => controller.abort(), - }, - { - name: "externally supplied TimeoutError", - reason: "timeout", - abort: (controller: AbortController) => { - const error = new Error("caller deadline exceeded"); - error.name = "TimeoutError"; - controller.abort(error); - }, - }, - { - name: "AbortError wrapping a TimeoutError (an abort, not a deadline)", - reason: "aborted", - abort: (controller: AbortController) => { - const timeout = new Error("caller deadline exceeded"); - timeout.name = "TimeoutError"; - const error = new Error("caller cancelled", { cause: timeout }); - error.name = "AbortError"; - controller.abort(error); - }, - }, - ])("resolves streamed assistant text for $name", async ({ abort, reason }) => { - const controller = new AbortController(); - let textEmitted = false; - const fixture = mockClaudeLiveRun(supervisorSpawnMock, { - cancelable: true, - onWrite: ({ emit }) => { - emit([ - { type: "system", subtype: "init", session_id: "live-abort-partial" }, - { - type: "stream_event", - session_id: "live-abort-partial", - event: { - type: "content_block_delta", - delta: { type: "text_delta", text: "Here is the answer so far" }, - }, - }, - ]); - textEmitted = true; - }, - }); - - const turnPromise = startLiveTurnWithAbortSignal("run-abort-partial", controller.signal); - await vi.waitFor(() => { - expect(textEmitted).toBe(true); - }); - abort(controller); - - await expect(turnPromise).resolves.toMatchObject({ - output: { - text: expect.stringContaining("Here is the answer so far"), - terminalInterruption: { reason }, - }, - }); - expect(fixture.lifecycle.cancel).toHaveBeenCalledWith("manual-cancel"); - }); - - it("still rejects genuine CLI failures after streamed text instead of masking them", async () => { - const controller = new AbortController(); - mockClaudeLiveRun(supervisorSpawnMock, { - onWrite: ({ emit }) => { - emit([ - { type: "system", subtype: "init", session_id: "live-abort-failover" }, - { - type: "assistant", - session_id: "live-abort-failover", - message: { - role: "assistant", - content: [{ type: "text", text: "Partial answer before failure" }], - }, - }, - { - type: "result", - subtype: "error_during_execution", - is_error: true, - session_id: "live-abort-failover", - result: "tool failed", - }, - ]); - }, - }); - - const turnPromise = startLiveTurnWithAbortSignal("run-abort-failover", controller.signal); - await expect(turnPromise).rejects.toMatchObject({ name: "FailoverError" }); - expect(controller.signal.aborted).toBe(false); - }); - - it("still rejects an abort when no assistant text was streamed yet", async () => { - const controller = new AbortController(); - mockClaudeLiveRun(supervisorSpawnMock, { - cancelable: true, - onWrite: ({ emit }) => { - emit([{ type: "system", subtype: "init", session_id: "live-abort-empty" }]); - }, - }); - - const turnPromise = startLiveTurnWithAbortSignal("run-abort-empty", controller.signal); - await vi.waitFor(() => { - expect(supervisorSpawnMock).toHaveBeenCalledOnce(); - }); - controller.abort(); - - await expect(turnPromise).rejects.toMatchObject({ name: "AbortError" }); - }); -}); diff --git a/src/agents/cli-runner/claude-live-session.test-support.ts b/src/agents/cli-runner/claude-live-session.test-support.ts deleted file mode 100644 index 4ce5b7859d83..000000000000 --- a/src/agents/cli-runner/claude-live-session.test-support.ts +++ /dev/null @@ -1,12 +0,0 @@ -import "./claude-live-registry.js"; - -/** Resets the process registry between live-session tests. */ -export function resetClaudeLiveSessionsForTest(): void { - const reset = (globalThis as Record)[ - Symbol.for("openclaw.claudeLiveRegistryReset") - ]; - if (typeof reset !== "function") { - throw new Error("Claude live registry reset seam is unavailable"); - } - reset(); -} diff --git a/src/agents/cli-runner/claude-live-session.test.ts b/src/agents/cli-runner/claude-live-session.test.ts deleted file mode 100644 index 81c82320d11e..000000000000 --- a/src/agents/cli-runner/claude-live-session.test.ts +++ /dev/null @@ -1,573 +0,0 @@ -import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "@openclaw/ai/internal/shared"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createReplyOperation } from "../../auto-reply/reply/reply-run-registry.js"; -import { testing as replyRunTesting } from "../../auto-reply/reply/reply-run-registry.test-support.js"; -import { onAgentEvent, resetAgentEventsForTest } from "../../infra/agent-events.js"; -import type { CliBackendConfig } from "../../plugins/cli-backend.types.js"; -import type { getProcessSupervisor } from "../../process/supervisor/index.js"; -import { - buildClaudeLiveRunContext, - buildPreparedCliRunContext, - createClaudeInputStartedEvent, - expectRejectsWithFields, - mockCallArg, - mockClaudeLiveRun, -} from "../cli-runner.test-helpers.js"; -import { - restoreCliRunnerPrepareTestDeps, - supervisorSpawnMock, -} from "../cli-runner.test-support.js"; -import { runClaudeTurn } from "./claude-live-session.js"; -import { resetClaudeLiveSessionsForTest } from "./claude-live-session.test-support.js"; -import { executePreparedCliRun } from "./execute.js"; - -function emitClaudeInputStarted(stdout: ((chunk: string) => void) | undefined, data: string): void { - const event = createClaudeInputStartedEvent(data); - if (event) { - stdout?.(`${JSON.stringify(event)}\n`); - } -} - -beforeEach(() => { - resetAgentEventsForTest(); - resetClaudeLiveSessionsForTest(); - replyRunTesting.resetReplyRunRegistry(); - restoreCliRunnerPrepareTestDeps(); - supervisorSpawnMock.mockClear(); -}); - -afterEach(() => { - vi.restoreAllMocks(); - resetClaudeLiveSessionsForTest(); - replyRunTesting.resetReplyRunRegistry(); -}); - -const promptFile = "/tmp/system-prompt.md"; -const baseBackend = { - command: "claude", - args: ["-p"], - output: "jsonl", - input: "stdin", - modelArg: "--model", - sessionArgs: ["--session-id", "{sessionId}"], - sessionMode: "always", - systemPromptArg: "--append-system-prompt", - systemPromptFileArg: "--append-system-prompt-file", - systemPromptWhen: "first", - liveSession: "claude-stdio", -} as CliBackendConfig; - -type ProcessSupervisor = ReturnType; -type SupervisorSpawnFn = ProcessSupervisor["spawn"]; - -async function captureClaudeLiveArgs(params: { - args: string[]; - backend: CliBackendConfig; - useResume: boolean; -}): Promise { - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: "live-args" }, - { type: "result", session_id: "live-args", result: "ok" }, - ], - }); - const context = buildPreparedCliRunContext({ backend: params.backend }); - await runClaudeTurn({ - context, - args: params.args, - env: {}, - prompt: "hello", - useResume: params.useResume, - noOutputTimeoutMs: 1_000, - getProcessSupervisor: () => ({ - spawn: (input: Parameters[0]) => - supervisorSpawnMock(input) as ReturnType, - cancel: vi.fn(), - cancelScope: vi.fn(), - getRecord: vi.fn(), - }), - onAssistantDelta: () => {}, - cleanup: async () => {}, - }); - return (mockCallArg(supervisorSpawnMock) as { argv: string[] }).argv; -} - -describe("Claude live process arguments", () => { - it("normalizes the live protocol while retaining resume state", async () => { - const args = await captureClaudeLiveArgs({ - args: ["-p", "--resume", "claude-session", "--session-id", "openclaw-session"], - backend: baseBackend, - useResume: true, - }); - - expect(args).toContain("--resume"); - expect(args).toContain("claude-session"); - expect(args).not.toContain("openclaw-session"); - expect(args).toEqual( - expect.arrayContaining([ - "--input-format", - "stream-json", - "--output-format", - "stream-json", - "--permission-prompt-tool", - "stdio", - ]), - ); - }); - - it.each([ - { systemPromptWhen: "first", useResume: true, retained: false }, - { systemPromptWhen: "always", useResume: true, retained: true }, - { systemPromptWhen: "first", useResume: false, retained: true }, - { systemPromptWhen: "always", useResume: false, retained: true }, - ] as const)( - "retains=$retained the prompt file for systemPromptWhen=$systemPromptWhen resume=$useResume", - async ({ systemPromptWhen, useResume, retained }) => { - const args = await captureClaudeLiveArgs({ - args: ["-p", "--append-system-prompt-file", promptFile], - backend: { ...baseBackend, systemPromptWhen }, - useResume, - }); - expect(args.includes("--append-system-prompt-file")).toBe(retained); - expect(args.includes(promptFile)).toBe(retained); - }, - ); -}); - -describe("runClaudeTurn", () => { - it("keeps pre-tool commentary out of an empty-result Claude live reply", async () => { - const agentEvents: Array<{ stream: string; data: unknown }> = []; - const stop = onAgentEvent((event) => { - agentEvents.push({ stream: event.stream, data: event.data }); - }); - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: "live-empty-result" }, - { - type: "stream_event", - event: { - type: "content_block_delta", - delta: { type: "text_delta", text: "Let me check." }, - }, - }, - { - type: "stream_event", - event: { - type: "content_block_start", - index: 1, - content_block: { type: "tool_use", id: "tool-1", name: "Read", input: {} }, - }, - }, - { - type: "stream_event", - event: { - type: "content_block_delta", - delta: { type: "text_delta", text: "Final answer." }, - }, - }, - { type: "result", session_id: "live-empty-result", result: "" }, - ], - }); - - try { - const result = await executePreparedCliRun( - buildClaudeLiveRunContext({ - emitCommentaryText: true, - }), - ); - - expect(result.text).toBe("Final answer."); - expect(agentEvents).toContainEqual({ - stream: "item", - data: expect.objectContaining({ - kind: "preamble", - progressText: "Let me check.", - }), - }); - expect(agentEvents).toContainEqual({ - stream: "assistant", - data: { text: "Final answer.", delta: "Final answer." }, - }); - } finally { - stop(); - } - }); - - it("reports Claude live session reply backends as streaming until the turn finishes", async () => { - let markWriteReady: (() => void) | undefined; - const writeReady = new Promise((resolve) => { - markWriteReady = resolve; - }); - const live = mockClaudeLiveRun(supervisorSpawnMock, { - onWrite: () => { - markWriteReady?.(); - }, - }); - const operation = createReplyOperation({ - sessionKey: "agent:main:main", - sessionId: "live-session-reply", - resetTriggered: false, - }); - operation.setPhase("running"); - const context = buildClaudeLiveRunContext({ - sessionId: "live-session-reply", - sessionKey: "agent:main:main", - prompt: "hello", - }); - - const run = executePreparedCliRun({ - ...context, - params: { - ...context.params, - replyOperation: operation, - }, - }); - - await writeReady; - live.emit([ - { type: "system", subtype: "init", session_id: "live-session-reply" }, - { type: "result", session_id: "live-session-reply", result: "done" }, - ]); - - const result = await run; - expect(result.text).toBe("done"); - operation.complete(); - }); - - it("reuses a Claude live session when resumed turns omit the system prompt arg", async () => { - let turn = 0; - mockClaudeLiveRun(supervisorSpawnMock, { - onWrite: ({ emit }) => { - turn += 1; - emit([ - { type: "system", subtype: "init", session_id: "live-system" }, - { type: "result", session_id: "live-system", result: turn === 1 ? "one" : "two" }, - ]); - }, - }); - - const backend = { - resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], - liveSession: "claude-stdio" as const, - }; - const first = await executePreparedCliRun( - buildPreparedCliRunContext({ - prompt: "first", - backend, - }), - ); - const second = await executePreparedCliRun( - buildPreparedCliRunContext({ - prompt: "second", - backend, - }), - "live-system", - ); - - expect(first.text).toBe("one"); - expect(second.text).toBe("two"); - expect(supervisorSpawnMock).toHaveBeenCalledOnce(); - }); - - it("restarts a warm Claude process when its thinking budget changes", async () => { - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: "live-thinking-budget" }, - { type: "result", session_id: "live-thinking-budget", result: "one" }, - ], - cancelable: true, - }); - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: "live-thinking-budget" }, - { type: "result", session_id: "live-thinking-budget", result: "two" }, - ], - }); - const backend = { - resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], - liveSession: "claude-stdio" as const, - }; - - await executePreparedCliRun( - buildPreparedCliRunContext({ - backend, - preparedEnv: { MAX_THINKING_TOKENS: "2048" }, - }), - ); - const second = await executePreparedCliRun( - buildPreparedCliRunContext({ - backend, - preparedEnv: { MAX_THINKING_TOKENS: "16384" }, - }), - "live-thinking-budget", - ); - - expect(second.text).toBe("two"); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); - }); - - it("restarts Claude live sessions when a multi-section stable prompt changes", async () => { - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: "live-stable-prompt" }, - { type: "result", session_id: "live-stable-prompt", result: "one" }, - ], - cancelable: true, - }); - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: "live-stable-prompt" }, - { type: "result", session_id: "live-stable-prompt", result: "two" }, - ], - }); - const backend = { - resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], - liveSession: "claude-stdio" as const, - systemPromptWhen: "always" as const, - }; - - await executePreparedCliRun( - buildPreparedCliRunContext({ - backend, - systemPrompt: `# OpenClaw\n\n## Stable Instructions\nFirst instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}Metadata`, - }), - ); - const second = await executePreparedCliRun( - buildPreparedCliRunContext({ - backend, - systemPrompt: `# OpenClaw\n\n## Stable Instructions\nSecond instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}Metadata`, - }), - "live-stable-prompt", - ); - - expect(second.text).toBe("two"); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); - }); - - it.each([ - { - name: "ignores the system_prompt field", - responses: [{ subtype: "success" }], - }, - { - name: "rejects the live refresh", - responses: [ - { - subtype: "error", - error: "set_model: system_prompt must be a non-empty string when present", - }, - { subtype: "error", error: "unsupported" }, - ], - }, - ])("restarts when Claude $name", async ({ responses }) => { - let controlRequest = 0; - mockClaudeLiveRun(supervisorSpawnMock, { - cancelable: true, - onWrite: ({ data, emit }) => { - const parsed = JSON.parse(data) as { type: string; request_id?: string }; - if (parsed.type === "control_request") { - const response = responses[controlRequest]; - controlRequest += 1; - emit([ - { - type: "control_response", - response: { - request_id: parsed.request_id, - ...response, - }, - }, - ]); - return; - } - emit([ - { type: "system", subtype: "init", session_id: "live-rejected-prompt" }, - { type: "result", session_id: "live-rejected-prompt", result: "one" }, - ]); - }, - }); - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: "live-rejected-prompt" }, - { type: "result", session_id: "live-rejected-prompt", result: "two" }, - ], - }); - const backend = { - resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], - liveSession: "claude-stdio" as const, - systemPromptWhen: "always" as const, - }; - - await executePreparedCliRun( - buildPreparedCliRunContext({ - backend, - systemPrompt: `Stable instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}First metadata`, - }), - ); - const second = await executePreparedCliRun( - buildPreparedCliRunContext({ - backend, - systemPrompt: `Stable instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}Second metadata`, - }), - "live-rejected-prompt", - ); - - expect(second.text).toBe("two"); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); - expect(controlRequest).toBe(responses.length); - }); - - it("restarts on marker-free prompt changes instead of weakening prompt identity", async () => { - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: "live-marker-free" }, - { type: "result", session_id: "live-marker-free", result: "one" }, - ], - cancelable: true, - }); - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: "live-marker-free" }, - { type: "result", session_id: "live-marker-free", result: "two" }, - ], - }); - const backend = { - resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], - liveSession: "claude-stdio" as const, - systemPromptWhen: "always" as const, - }; - - await executePreparedCliRun( - buildPreparedCliRunContext({ backend, systemPrompt: "First complete prompt" }), - ); - const second = await executePreparedCliRun( - buildPreparedCliRunContext({ backend, systemPrompt: "Second complete prompt" }), - "live-marker-free", - ); - - expect(second.text).toBe("two"); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); - }); - - it("keeps legacy first-only system prompts on full-prompt restart identity", async () => { - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: "live-first-only-prompt" }, - { type: "result", session_id: "live-first-only-prompt", result: "one" }, - ], - cancelable: true, - }); - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: "live-first-only-prompt" }, - { type: "result", session_id: "live-first-only-prompt", result: "two" }, - ], - }); - const backend = { - resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], - liveSession: "claude-stdio" as const, - systemPromptWhen: "first" as const, - }; - - await executePreparedCliRun( - buildPreparedCliRunContext({ - backend, - systemPrompt: `Stable instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}First metadata`, - }), - ); - const second = await executePreparedCliRun( - buildPreparedCliRunContext({ - backend, - systemPrompt: `Stable instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}Second metadata`, - }), - "live-first-only-prompt", - ); - - expect(second.text).toBe("two"); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); - }); - - it("restarts the Claude live process after request abort", async () => { - const abortController = new AbortController(); - let stdoutListener: ((chunk: string) => void) | undefined; - const cancels: Array> = []; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - const spawnIndex = supervisorSpawnMock.mock.calls.length; - const cancel = vi.fn(); - cancels.push(cancel); - const stdin = { - write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { - emitClaudeInputStarted(stdoutListener, dataValue); - if (spawnIndex === 2) { - stdoutListener?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-abort-2" }), - JSON.stringify({ - type: "result", - session_id: "live-abort-2", - result: "second-ok", - }), - ].join("\n") + "\n", - ); - } - cb?.(); - }), - end: vi.fn(), - }; - return { - runId: `live-run-${spawnIndex}`, - pid: 2345 + spawnIndex, - startedAtMs: Date.now(), - stdin, - wait: vi.fn( - () => - new Promise((resolve) => { - if (spawnIndex === 1) { - cancel.mockImplementationOnce(() => { - resolve({ - reason: "manual-cancel", - exitCode: null, - exitSignal: null, - durationMs: 50, - stdout: "", - stderr: "", - timedOut: false, - noOutputTimedOut: false, - }); - }); - } - }), - ), - cancel, - }; - }); - - const firstContext = buildClaudeLiveRunContext({}); - firstContext.params.abortSignal = abortController.signal; - const first = executePreparedCliRun(firstContext); - - await vi.waitFor(() => { - expect(supervisorSpawnMock).toHaveBeenCalledTimes(1); - }); - abortController.abort(); - - await expectRejectsWithFields(first, { name: "AbortError" }); - expect(cancels[0]).toHaveBeenCalledWith("manual-cancel"); - stdoutListener?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-abort" }), - JSON.stringify({ - type: "result", - session_id: "live-abort", - result: "discarded", - }), - ].join("\n") + "\n", - ); - - const second = await executePreparedCliRun(buildClaudeLiveRunContext({})); - - expect(second.text).toBe("second-ok"); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); - }); -}); diff --git a/src/agents/cli-runner/claude-live-session.ts b/src/agents/cli-runner/claude-live-session.ts deleted file mode 100644 index 61f2f67738c4..000000000000 --- a/src/agents/cli-runner/claude-live-session.ts +++ /dev/null @@ -1,631 +0,0 @@ -/** Coordinates admission and reuse for Claude CLI live processes. */ -import crypto from "node:crypto"; -import { - splitSystemPromptCacheBoundary, - stripSystemPromptCacheBoundary, -} from "@openclaw/ai/internal/shared"; -import type { ReplyBackendHandle } from "../../auto-reply/reply/reply-run-registry.js"; -import { createAbortError as createNamedAbortError } from "../../infra/abort-signal.js"; -import { sha256Hex } from "../../infra/crypto-digest.js"; -import { formatErrorMessage } from "../../infra/errors.js"; -import type { CliBackendConfig } from "../../plugins/cli-backend.types.js"; -import type { - CliStreamingDelta, - CliThinkingDelta, - CliThinkingProgress, - CliToolResultDelta, - CliToolUseStartDelta, - CliUsage, -} from "../cli-output-contracts.js"; -import { isTimeoutError, FailoverError, resolveFailoverStatus } from "../failover-error.js"; -import { - abortClaudeTurn, - beginClaudeTurn, - createClaudeUserInputMessage, - refreshClaudePrompt, - resolveClaudeLiveExecPermission, - spawnClaudeProcess, - writeClaudeInput, - type ClaudeLiveProcess, -} from "./claude-live-process.js"; -import { - buildClaudeLiveKey, - beginClaudeSessionCreate, - enqueueClaudeTurn, - ensureClaudeSessionCapacity, - finishClaudeSessionCreate, - getClaudeSession, - registerClaudeSession, - removeClaudeSession, -} from "./claude-live-registry.js"; -import type { ClaudeLiveToolTerminalOutcome } from "./claude-live-turn.js"; -import { cliBackendLog } from "./log.js"; -import type { PreparedCliRunContext } from "./types.js"; - -type ProcessSupervisor = ReturnType< - typeof import("../../process/supervisor/index.js").getProcessSupervisor ->; - -type ClaudeLiveRunResult = { - output: import("../cli-output-contracts.js").CliOutput; -}; - -type RunClaudeTurnParams = { - context: PreparedCliRunContext; - args: string[]; - executableCommand?: string; - executableLeadingArgv?: readonly string[]; - env: Record; - prompt: string; - useResume: boolean; - forceNewSession?: boolean; - requiredSessionGeneration?: string; - noOutputTimeoutMs: number; - getProcessSupervisor: () => ProcessSupervisor; - onAssistantDelta: (delta: CliStreamingDelta) => void; - onThinkingDelta?: (delta: CliThinkingDelta) => void; - onThinkingProgress?: (progress: CliThinkingProgress) => void; - onToolUseStart?: (delta: CliToolUseStartDelta) => void; - onToolResult?: (delta: CliToolResultDelta) => void; - resolveToolResultTerminalOutcome?: ( - delta: CliToolResultDelta, - ) => ClaudeLiveToolTerminalOutcome | undefined; - onCommentaryText?: (text: string) => void; - onMcpCaptureReady?: (captureKey: string) => void; - onSessionId?: (sessionId: string) => void; - onAssistantMessage?: (message: unknown) => void; - onUsage?: (usage: CliUsage, terminal: boolean) => void; - onCliOutput?: (chunk: string, stream: "stderr" | "stdout") => void; - onRequestPayload?: (payload: string) => void; - onPhase?: (phase: "send" | "resolve") => void; - cleanup: () => Promise; -}; - -function upsertArgValue(args: string[], flag: string, value: string): string[] { - const normalized: string[] = []; - for (let i = 0; i < args.length; i += 1) { - const arg = args[i] ?? ""; - if (arg === flag) { - i += 1; - continue; - } - if (arg.startsWith(`${flag}=`)) { - continue; - } - normalized.push(arg); - } - normalized.push(flag, value); - return normalized; -} - -function appendArg(args: string[], flag: string): string[] { - return args.includes(flag) ? args : [...args, flag]; -} - -function stripLiveProcessArgs( - args: string[], - backend: CliBackendConfig, - stripSystemPrompt: boolean, -): string[] { - const liveProcessFlags = new Set( - [ - "--session-id", - stripSystemPrompt ? backend.systemPromptArg : undefined, - stripSystemPrompt ? backend.systemPromptFileArg : undefined, - ].filter((entry): entry is string => typeof entry === "string" && entry.length > 0), - ); - const stripped: string[] = []; - for (let i = 0; i < args.length; i += 1) { - const arg = args[i] ?? ""; - if (liveProcessFlags.has(arg)) { - i += 1; - continue; - } - if ([...liveProcessFlags].some((flag) => arg.startsWith(`${flag}=`))) { - continue; - } - stripped.push(arg); - } - return stripped; -} - -function buildClaudeLiveArgs(params: { - args: string[]; - backend: CliBackendConfig; - systemPrompt: string; - useResume: boolean; - permissionMode?: string; -}): string[] { - const liveArgs = appendArg( - upsertArgValue( - upsertArgValue( - upsertArgValue( - stripLiveProcessArgs( - params.args, - params.backend, - params.useResume && params.backend.systemPromptWhen !== "always", - ), - "--input-format", - "stream-json", - ), - "--output-format", - "stream-json", - ), - "--permission-prompt-tool", - "stdio", - ), - "--replay-user-messages", - ); - return params.permissionMode - ? upsertArgValue(liveArgs, "--permission-mode", params.permissionMode) - : liveArgs; -} - -function buildClaudeLiveFingerprint(params: { - context: PreparedCliRunContext; - argv: string[]; - env: Record; -}): string { - const managedMcpGrant = params.context.preparedBackend.mcpClientGrantCapture; - const normalizeMcpGrantToken = - managedMcpGrant !== undefined && - params.env.OPENCLAW_MCP_TOKEN === managedMcpGrant.transportToken; - const stableSystemPrompt = - (params.context.preparedBackend.backend.systemPromptWhen === "always" - ? splitSystemPromptCacheBoundary(params.context.systemPrompt)?.stablePrefix - : undefined) ?? params.context.systemPrompt; - const normalizeMcpConfigPath = Boolean(params.context.preparedBackend.mcpConfigHash); - const skillSnapshot = params.context.params.skillsSnapshot; - const skillsFingerprint = skillSnapshot - ? sha256Hex( - JSON.stringify({ - promptHash: sha256Hex(skillSnapshot.prompt), - skillFilter: skillSnapshot.skillFilter, - skills: skillSnapshot.skills, - resolvedSkills: (skillSnapshot.resolvedSkills ?? []).map((skill) => ({ - name: skill.name, - description: skill.description, - filePath: skill.filePath, - sourceInfo: skill.sourceInfo, - })), - version: skillSnapshot.version, - }), - ) - : undefined; - const omittedValueFlags = new Set( - [ - params.context.preparedBackend.backend.systemPromptArg, - params.context.preparedBackend.backend.systemPromptFileArg, - "--resume", - "-r", - ].filter((entry): entry is string => typeof entry === "string" && entry.length > 0), - ); - const unstableValueFlags = new Set( - [ - "--session-id", - normalizeMcpConfigPath ? "--mcp-config" : undefined, - skillsFingerprint ? "--plugin-dir" : undefined, - ].filter((entry): entry is string => typeof entry === "string" && entry.length > 0), - ); - const stableArgv: string[] = []; - for (let i = 0; i < params.argv.length; i += 1) { - const entry = params.argv[i] ?? ""; - if (omittedValueFlags.has(entry)) { - i += 1; - continue; - } - if ([...omittedValueFlags].some((flag) => entry.startsWith(`${flag}=`))) { - continue; - } - if (unstableValueFlags.has(entry)) { - stableArgv.push(""); - i += 1; - continue; - } - if ([...unstableValueFlags].some((flag) => entry.startsWith(`${flag}=`))) { - stableArgv.push(""); - continue; - } - stableArgv.push(entry); - } - return JSON.stringify({ - command: params.argv[0], - workspaceDirHash: sha256Hex(params.context.workspaceDir), - cwdHash: params.context.cwdHash ?? sha256Hex(params.context.cwd ?? params.context.workspaceDir), - provider: params.context.params.provider, - model: params.context.normalizedModel, - systemPromptHash: sha256Hex(stableSystemPrompt), - authProfileIdHash: params.context.effectiveAuthProfileId - ? sha256Hex(params.context.effectiveAuthProfileId) - : undefined, - authEpochHash: params.context.authEpoch ? sha256Hex(params.context.authEpoch) : undefined, - extraSystemPromptHash: params.context.extraSystemPromptHash, - promptToolNamesHash: params.context.promptToolNamesHash, - // A warm child carries the canonical MCP topology across turns. Per-turn - // authority rotates through the capture grant without restarting it. - mcpResumeHash: - params.context.preparedBackend.mcpResumeHash ?? params.context.preparedBackend.mcpConfigHash, - credentialFingerprint: params.context.preparedBackend.secretInput?.fingerprint, - skillsFingerprint, - argv: stableArgv, - // This is the canonical compatibility check for all spawn-time inputs. - // Claude reads MAX_THINKING_TOKENS only when the child starts, so a changed - // thinking environment invalidates a warm process without a second reuse gate. - env: Object.keys(params.env) - .toSorted() - .map((key) => [ - key, - key === "OPENCLAW_MCP_TOKEN" && normalizeMcpGrantToken - ? "" - : params.env[key] - ? sha256Hex(params.env[key]) - : "", - ]), - }); -} - -function adoptClaudeLiveProcessMcpGrant(params: { - session: ClaudeLiveProcess; - context: PreparedCliRunContext; -}): boolean { - const turnGrant = params.context.preparedBackend.mcpClientGrantCapture; - if (!turnGrant && !params.session.mcpGrantToken) { - return true; - } - if (!turnGrant || !params.session.mcpGrantToken) { - return false; - } - turnGrant.adoptProcessToken(params.session.mcpGrantToken); - return true; -} - -function createAbortError(reason?: unknown): Error { - if (reason instanceof Error && isTimeoutError(reason)) { - return reason; - } - if (reason === undefined) { - return createNamedAbortError("CLI run aborted"); - } - const error = new Error( - reason instanceof Error - ? reason.message - : typeof reason === "string" - ? reason - : "CLI run aborted", - reason instanceof Error ? { cause: reason } : undefined, - ); - error.name = "AbortError"; - return error; -} - -function createRequiredLiveSessionError(params: { - context: PreparedCliRunContext; - code: "cli_live_session_changed" | "cli_live_session_missing"; - cause?: unknown; -}): FailoverError { - return new FailoverError("Managed Claude live session is no longer reusable.", { - reason: "session_expired", - provider: params.context.params.provider, - model: params.context.modelId, - status: resolveFailoverStatus("session_expired"), - code: params.code, - cause: params.cause, - }); -} - -async function abortTurnBeforeStart( - cleanup: () => Promise, - abortError: Error, -): Promise { - try { - await cleanup(); - } catch (cleanupError) { - throw new Error("Claude live turn aborted before start and cleanup failed", { - cause: cleanupError, - }); - } - throw abortError; -} - -/** Runs one prompt through a reusable Claude CLI live session. */ -export function runClaudeTurn(params: RunClaudeTurnParams): Promise { - const key = buildClaudeLiveKey(params.context); - let cleanupPromise: Promise | undefined; - const cleanup = () => (cleanupPromise ??= Promise.resolve().then(params.cleanup)); - const abortSignal = params.context.params.abortSignal; - if (!abortSignal) { - return enqueueClaudeTurn(key, () => runSerializedClaudeTurn(params, key, cleanup)); - } - if (abortSignal.aborted) { - return abortTurnBeforeStart(cleanup, createAbortError(abortSignal.reason)); - } - return new Promise((resolve, reject) => { - let started = false; - let settled = false; - const settle = ( - outcome: { kind: "resolve"; value: ClaudeLiveRunResult } | { kind: "reject"; error: unknown }, - ) => { - if (settled) { - return; - } - settled = true; - abortSignal.removeEventListener("abort", onAbort); - if (outcome.kind === "resolve") { - resolve(outcome.value); - } else { - reject( - outcome.error instanceof Error - ? outcome.error - : new Error(formatErrorMessage(outcome.error)), - ); - } - }; - const onAbort = () => { - if (!started) { - void abortTurnBeforeStart(cleanup, createAbortError(abortSignal.reason)).catch( - (error: unknown) => settle({ kind: "reject", error }), - ); - } - }; - abortSignal.addEventListener("abort", onAbort, { once: true }); - const queued = enqueueClaudeTurn(key, async () => { - started = true; - abortSignal.removeEventListener("abort", onAbort); - if (abortSignal.aborted) { - return await abortTurnBeforeStart(cleanup, createAbortError(abortSignal.reason)); - } - return await runSerializedClaudeTurn(params, key, cleanup); - }); - void queued.then( - (value) => settle({ kind: "resolve", value }), - (error: unknown) => settle({ kind: "reject", error }), - ); - }); -} - -async function runSerializedClaudeTurn( - params: RunClaudeTurnParams, - key: string, - cleanup: () => Promise, -): Promise { - const resumeCapable = Boolean(params.context.preparedBackend.backend.resumeArgs?.length); - const execPermission = resolveClaudeLiveExecPermission(params.context); - const argv = [ - params.executableCommand ?? params.context.preparedBackend.backend.command, - ...(params.executableLeadingArgv ?? []), - ...buildClaudeLiveArgs({ - args: params.args, - backend: params.context.preparedBackend.backend, - systemPrompt: params.context.systemPrompt, - useResume: params.useResume, - permissionMode: execPermission.permissionMode, - }), - ]; - const fingerprint = buildClaudeLiveFingerprint({ - context: params.context, - argv, - env: params.env, - }); - const systemPromptHash = sha256Hex(stripSystemPromptCacheBoundary(params.context.systemPrompt)); - let session = getClaudeSession(key) as ClaudeLiveProcess | undefined; - if ( - session && - params.requiredSessionGeneration && - session.generation !== params.requiredSessionGeneration - ) { - await cleanup(); - throw createRequiredLiveSessionError({ - context: params.context, - code: "cli_live_session_changed", - }); - } - if (session && params.forceNewSession) { - session.close("restart"); - session = undefined; - } - if (session && resumeCapable && !params.useResume) { - session.close("restart"); - session = undefined; - } - if (session && session.fingerprint !== fingerprint) { - if (params.requiredSessionGeneration) { - await cleanup(); - throw createRequiredLiveSessionError({ - context: params.context, - code: "cli_live_session_changed", - }); - } - session.close("restart"); - session = undefined; - } - if ( - session && - !(await refreshClaudePrompt({ session, context: params.context, systemPromptHash })) - ) { - if (params.requiredSessionGeneration) { - await cleanup(); - throw createRequiredLiveSessionError({ - context: params.context, - code: "cli_live_session_changed", - }); - } - session = undefined; - } - if (!session && params.requiredSessionGeneration) { - await cleanup(); - throw createRequiredLiveSessionError({ - context: params.context, - code: "cli_live_session_missing", - }); - } - if (session) { - const reusableSession = session; - try { - if (!adoptClaudeLiveProcessMcpGrant({ session: reusableSession, context: params.context })) { - reusableSession.close("restart"); - session = undefined; - } - } catch (error) { - reusableSession.close("restart", error); - session = undefined; - if (params.requiredSessionGeneration) { - await cleanup(); - throw createRequiredLiveSessionError({ - context: params.context, - code: "cli_live_session_changed", - cause: error, - }); - } - } - } - if (!session && params.requiredSessionGeneration) { - await cleanup(); - throw createRequiredLiveSessionError({ - context: params.context, - code: "cli_live_session_changed", - }); - } - const cleanupTurnArtifacts = Boolean(session); - let notifiedMcpCaptureKey: string | undefined; - const notifyMcpCaptureReady = (captureKey: string | undefined) => { - if (!captureKey || notifiedMcpCaptureKey === captureKey) { - return; - } - params.onMcpCaptureReady?.(captureKey); - notifiedMcpCaptureKey = captureKey; - }; - try { - ensureClaudeSessionCapacity(key, params.context); - } catch (error) { - await cleanup(); - throw error; - } - if (!session) { - // The owner queue stays held until creation completes, so a same-key turn - // cannot observe the pending promise. Pending records serve generation queries and capacity. - if (params.requiredSessionGeneration) { - await cleanup(); - throw createRequiredLiveSessionError({ - context: params.context, - code: "cli_live_session_missing", - }); - } - const generation = crypto.randomUUID(); - // Capture keys are child env/MCP-header state and cannot rotate without a - // new process. Bind one key to this process; grant activate/deactivate is - // the per-turn admission fence. Drain timeout still kills the child. - const mcpCaptureKey = params.context.mcpDeliveryCapture ? crypto.randomUUID() : undefined; - if (mcpCaptureKey) { - try { - notifyMcpCaptureReady(mcpCaptureKey); - } catch (error) { - await cleanup(); - throw error; - } - } - const pendingCreate = beginClaudeSessionCreate(key, generation); - const createSession: Promise = spawnClaudeProcess({ - context: params.context, - argv, - env: params.env, - generation, - fingerprint, - systemPromptHash, - key, - mcpCaptureKey, - noOutputTimeoutMs: params.noOutputTimeoutMs, - supervisor: params.getProcessSupervisor(), - cleanup, - onSpawned: (spawned) => registerClaudeSession(spawned, pendingCreate), - onClosed: removeClaudeSession, - }).finally(() => finishClaudeSessionCreate(key, pendingCreate)); - try { - session = await createSession; - } catch (error) { - await cleanup(); - throw error; - } - } - if (cleanupTurnArtifacts) { - if (session.idleTimer) { - clearTimeout(session.idleTimer); - session.idleTimer = null; - } - await cleanup(); - cliBackendLog.info( - `claude live session reuse: provider=${session.providerId} model=${session.modelId}`, - ); - } - if (session.closing || getClaudeSession(key) !== session) { - await cleanup(); - if (params.requiredSessionGeneration) { - throw createRequiredLiveSessionError({ - context: params.context, - code: "cli_live_session_missing", - }); - } - throw new Error("Claude CLI live session closed before handling the turn"); - } - if (session.currentTurn) { - throw new Error("Claude CLI live session is already handling a turn"); - } - if (session.sessionId) { - params.onSessionId?.(session.sessionId); - } - notifyMcpCaptureReady(session.mcpCaptureKey); - session.noOutputTimeoutMs = params.noOutputTimeoutMs; - session.stderr = ""; - const inputUuid = crypto.randomUUID(); - const outputPromise = beginClaudeTurn(session, { - context: params.context, - inputUuid, - useResume: params.useResume, - execPermission, - onAssistantDelta: params.onAssistantDelta, - onThinkingDelta: params.onThinkingDelta, - onThinkingProgress: params.onThinkingProgress, - onToolUseStart: params.onToolUseStart, - onToolResult: params.onToolResult, - resolveToolResultTerminalOutcome: params.resolveToolResultTerminalOutcome, - onCommentaryText: params.onCommentaryText, - onSessionId: params.onSessionId, - onAssistantMessage: params.onAssistantMessage, - onUsage: params.onUsage, - onCliOutput: params.onCliOutput, - onPhase: params.onPhase, - }); - void outputPromise.catch(() => undefined); - const abort = () => - abortClaudeTurn(session, createAbortError(params.context.params.abortSignal?.reason)); - const replyBackendHandle: ReplyBackendHandle | undefined = params.context.params.replyOperation - ? { - kind: "cli", - runId: params.context.params.runId, - toolAuthorityFingerprint: params.context.params.toolAuthorityFingerprint, - cancel: abort, - } - : undefined; - params.context.params.abortSignal?.addEventListener("abort", abort, { once: true }); - if (replyBackendHandle) { - params.context.params.replyOperation?.attachBackend(replyBackendHandle); - } - try { - if (params.context.params.abortSignal?.aborted) { - abort(); - } else { - try { - const requestPayload = createClaudeUserInputMessage(params.prompt, inputUuid); - params.onRequestPayload?.(requestPayload); - await Promise.race([writeClaudeInput(session, requestPayload), outputPromise]); - } catch (error) { - session.close("abort", error); - } - } - return { output: await outputPromise }; - } finally { - params.context.params.abortSignal?.removeEventListener("abort", abort); - if (replyBackendHandle) { - params.context.params.replyOperation?.detachBackend(replyBackendHandle); - } - } -} diff --git a/src/agents/cli-runner/claude-live-turn-diagnostics.test.ts b/src/agents/cli-runner/claude-live-turn-diagnostics.test.ts deleted file mode 100644 index fa2279abbd5e..000000000000 --- a/src/agents/cli-runner/claude-live-turn-diagnostics.test.ts +++ /dev/null @@ -1,495 +0,0 @@ -/** Claude live turn progress reporting and diagnostic correlation tests. */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - onInternalDiagnosticEvent, - setDiagnosticsEnabledForProcess, - waitForDiagnosticEventsDrained, -} from "../../infra/diagnostic-events.js"; -import { - getDiagnosticSessionActivitySnapshot, - resetDiagnosticRunActivityForTest, - startDiagnosticRunActivityTracking, -} from "../../logging/diagnostic-run-activity.js"; -import type { getProcessSupervisor } from "../../process/supervisor/index.js"; -import { - buildClaudeLiveRunContext, - createClaudeInputStartedEvent, - expectRejectsWithFields, - mockClaudeLiveRun, - type PreparedCliRunContextOverrides, -} from "../cli-runner.test-helpers.js"; -import { supervisorSpawnMock } from "../cli-runner.test-support.js"; -import { runClaudeTurn } from "./claude-live-session.js"; -import { resetClaudeLiveSessionsForTest } from "./claude-live-session.test-support.js"; - -type ProcessSupervisor = ReturnType; -type SupervisorSpawnFn = ProcessSupervisor["spawn"]; - -beforeEach(() => { - setDiagnosticsEnabledForProcess(true); - resetDiagnosticRunActivityForTest(); - startDiagnosticRunActivityTracking(); - resetClaudeLiveSessionsForTest(); - supervisorSpawnMock.mockClear(); -}); - -afterEach(() => { - vi.restoreAllMocks(); - vi.useRealTimers(); - resetDiagnosticRunActivityForTest(); - resetClaudeLiveSessionsForTest(); -}); - -function getProcessSupervisorForTest() { - return { - spawn: (params: Parameters[0]) => - supervisorSpawnMock(params) as ReturnType, - cancel: vi.fn(), - cancelScope: vi.fn(), - getRecord: vi.fn(), - }; -} - -function startLiveTurn( - runId: string, - useResume: boolean, - options: { - context?: PreparedCliRunContextOverrides; - abortSignal?: AbortSignal; - noOutputTimeoutMs?: number; - resolveToolResultTerminalOutcome?: ( - delta: import("../cli-output-contracts.js").CliToolResultDelta, - ) => import("./claude-live-turn.js").ClaudeLiveToolTerminalOutcome | undefined; - } = {}, -) { - const context = buildClaudeLiveRunContext({ - ...options.context, - runId, - timeoutMs: options.context?.timeoutMs ?? 60_000, - backend: { resumeArgs: ["-p", "--resume", "{sessionId}"] }, - }); - context.params.abortSignal = options.abortSignal; - return runClaudeTurn({ - context, - args: context.preparedBackend.backend.args ?? [], - env: {}, - prompt: "hi", - useResume, - noOutputTimeoutMs: options.noOutputTimeoutMs ?? 5_000, - getProcessSupervisor: getProcessSupervisorForTest, - onAssistantDelta: () => {}, - resolveToolResultTerminalOutcome: options.resolveToolResultTerminalOutcome, - cleanup: async () => {}, - }); -} - -function emitClaudeInputStarted(stdout: ((chunk: string) => void) | undefined, data: string): void { - const event = createClaudeInputStartedEvent(data); - if (event) { - stdout?.(`${JSON.stringify(event)}\n`); - } -} - -describe("Claude live turn progress and diagnostic correlation", () => { - it("reports Claude live stream progress without timer heartbeats", async () => { - vi.useFakeTimers({ - toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"], - }); - vi.setSystemTime(new Date("2026-05-28T00:00:00.000Z")); - const diagnosticEvents: string[] = []; - const stopDiagnostics = onInternalDiagnosticEvent((event) => { - if (event.type === "run.progress" || event.type.startsWith("tool.execution.")) { - diagnosticEvents.push(event.type); - } - }); - let stdoutListener: ((chunk: string) => void) | undefined; - const stdin = { - write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { - emitClaudeInputStarted(stdoutListener, data); - stdoutListener?.( - [ - JSON.stringify({ - type: "system", - subtype: "init", - session_id: "live-diagnostics", - }), - JSON.stringify({ - type: "assistant", - session_id: "live-diagnostics", - message: { - role: "assistant", - content: [ - { - type: "mcp_tool_use", - id: "tool-live-1", - name: "mcp__team__lookup", - input: { query: "status" }, - }, - { - type: "server_tool_use", - id: "tool-live-2", - name: "web_search", - input: { query: "release status" }, - }, - ], - }, - }), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - pid: 3060, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; - }); - - try { - const resultPromise = startLiveTurn("run-live-diagnostics", false, { - context: { - sessionId: "session-live-diagnostics", - sessionKey: "agent:main:diagnostics", - prompt: "hello", - timeoutMs: 120_000, - }, - noOutputTimeoutMs: 120_000, - }); - - await waitForDiagnosticEventsDrained(); - await vi.waitFor(() => - expect( - getDiagnosticSessionActivitySnapshot({ - sessionKey: "agent:main:diagnostics", - }).activeToolName, - ).toBe("mcp__team__lookup"), - ); - expect( - getDiagnosticSessionActivitySnapshot({ sessionKey: "agent:main:diagnostics" }) - .lastProgressReason, - ).toBe("cli_live:tool_started"); - - await vi.advanceTimersByTimeAsync(10_000); - await waitForDiagnosticEventsDrained(); - expect( - getDiagnosticSessionActivitySnapshot({ sessionKey: "agent:main:diagnostics" }) - .lastProgressReason, - ).toBe("cli_live:tool_started"); - expect( - getDiagnosticSessionActivitySnapshot({ sessionKey: "agent:main:diagnostics" }) - .lastProgressAgeMs, - ).toBeGreaterThanOrEqual(10_000); - - stdoutListener?.( - [ - JSON.stringify({ - type: "user", - session_id: "live-diagnostics", - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-live-1", - content: "lookup failed", - is_error: true, - }, - { - type: "tool_result", - tool_use_id: "tool-live-2", - content: "done", - }, - ], - }, - }), - JSON.stringify({ - type: "assistant", - session_id: "live-diagnostics", - message: { - role: "assistant", - content: [{ type: "text", text: "ok" }], - }, - }), - JSON.stringify({ - type: "result", - session_id: "live-diagnostics", - result: "ok", - }), - ].join("\n") + "\n", - ); - - await expect(resultPromise).resolves.toMatchObject({ output: { text: "ok" } }); - await waitForDiagnosticEventsDrained(); - expect( - getDiagnosticSessionActivitySnapshot({ sessionKey: "agent:main:diagnostics" }) - .activeToolName, - ).toBeUndefined(); - expect( - getDiagnosticSessionActivitySnapshot({ sessionKey: "agent:main:diagnostics" }) - .lastProgressReason, - ).toBe("cli_live:result"); - expect(diagnosticEvents.filter((event) => event === "tool.execution.started")).toHaveLength( - 2, - ); - expect(diagnosticEvents).toContain("tool.execution.completed"); - expect(diagnosticEvents).toContain("tool.execution.error"); - } finally { - stopDiagnostics(); - } - }); - - it("keeps identical parallel Claude live tool outcomes explicitly unknown", async () => { - const diagnosticEvents: Array> = []; - const stopDiagnostics = onInternalDiagnosticEvent((event) => { - if ( - event.type.startsWith("tool.execution.") && - "toolCallId" in event && - typeof event.toolCallId === "string" && - event.toolCallId.startsWith("tool-live-identical-") - ) { - diagnosticEvents.push(event as unknown as Record); - } - }); - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: "live-identical" }, - { - type: "assistant", - session_id: "live-identical", - message: { - role: "assistant", - content: [ - { - type: "mcp_tool_use", - id: "tool-live-identical-a", - name: "mcp__openclaw__message", - input: { action: "react", emoji: "same" }, - }, - { - type: "mcp_tool_use", - id: "tool-live-identical-b", - name: "mcp__openclaw__message", - input: { action: "react", emoji: "same" }, - }, - ], - }, - }, - { - type: "user", - session_id: "live-identical", - message: { - role: "user", - content: [ - { type: "tool_result", tool_use_id: "tool-live-identical-a", content: "ok" }, - { type: "tool_result", tool_use_id: "tool-live-identical-b", content: "ok" }, - ], - }, - }, - { type: "result", session_id: "live-identical", result: "ok" }, - ], - }); - - try { - await expect( - startLiveTurn("run-live-identical", false, { - context: { - sessionId: "session-live-identical", - sessionKey: "agent:main:live-identical", - prompt: "hello", - }, - resolveToolResultTerminalOutcome: () => ({ outcome: "unknown" }), - }), - ).resolves.toMatchObject({ output: { text: "ok" } }); - await waitForDiagnosticEventsDrained(); - } finally { - stopDiagnostics(); - } - - expect(diagnosticEvents).toMatchObject([ - { type: "tool.execution.started", toolCallId: "tool-live-identical-a" }, - { type: "tool.execution.started", toolCallId: "tool-live-identical-b" }, - { - type: "tool.execution.error", - toolCallId: "tool-live-identical-a", - errorCode: "tool_outcome_unknown", - }, - { - type: "tool.execution.error", - toolCallId: "tool-live-identical-b", - errorCode: "tool_outcome_unknown", - }, - ]); - }); - - it.each([ - [ - "client timeout", - "tool_use", - "Bash", - Object.assign(new Error("gateway timeout"), { name: "TimeoutError" }), - "TimeoutError", - { terminalReason: "timed_out" }, - ], - [ - "client cancellation", - "tool_use", - "Bash", - new Error("operator cancelled"), - "AbortError", - { terminalReason: "cancelled" }, - ], - [ - "server-native timeout", - "server_tool_use", - "web_search", - Object.assign(new Error("gateway timeout"), { name: "TimeoutError" }), - "TimeoutError", - { errorCode: "tool_outcome_unknown" }, - ], - [ - "server-native cancellation", - "server_tool_use", - "web_search", - new Error("operator cancelled"), - "AbortError", - { errorCode: "tool_outcome_unknown" }, - ], - ] as const)( - "classifies active Claude live tools on %s", - async (_, toolType, toolName, abortReason, expectedErrorName, expectedOutcome) => { - const abortController = new AbortController(); - const diagnosticEvents: Array> = []; - const stopDiagnostics = onInternalDiagnosticEvent((event) => { - if (event.type === "tool.execution.error") { - diagnosticEvents.push(event as unknown as Record); - } - }); - let stdoutListener: ((chunk: string) => void) | undefined; - const stdin = { - write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { - emitClaudeInputStarted(stdoutListener, data); - stdoutListener?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-timeout" }), - JSON.stringify({ - type: "assistant", - session_id: "live-timeout", - message: { - role: "assistant", - content: [ - { - type: toolType, - id: "tool-live-timeout", - name: toolName, - input: { query: "status" }, - }, - ], - }, - }), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - pid: 3061, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; - }); - - try { - const resultPromise = startLiveTurn("run-live-timeout", false, { - context: { - sessionId: "session-live-timeout", - sessionKey: "agent:main:timeout", - }, - abortSignal: abortController.signal, - noOutputTimeoutMs: 120_000, - }); - - await vi.waitFor(() => expect(stdoutListener).toBeDefined()); - abortController.abort(abortReason); - await expectRejectsWithFields(resultPromise, { name: expectedErrorName }); - await waitForDiagnosticEventsDrained(); - expect(diagnosticEvents).toContainEqual( - expect.objectContaining({ - toolCallId: "tool-live-timeout", - ...expectedOutcome, - }), - ); - if (toolType === "server_tool_use") { - const terminal = diagnosticEvents.find( - (event) => event.toolCallId === "tool-live-timeout", - ); - expect(terminal).not.toHaveProperty("terminalReason"); - } - } finally { - stopDiagnostics(); - } - }, - ); -}); - -describe("Claude live turn progress timeout cleanup", () => { - it("fails Claude live turns without unhandled rejection when stdin write is stuck", async () => { - vi.useFakeTimers(); - const unhandledRejections: unknown[] = []; - const onUnhandledRejection = (reason: unknown) => { - unhandledRejections.push(reason); - }; - process.on("unhandledRejection", onUnhandledRejection); - const cancel = vi.fn(); - let pendingWriteCallback: ((err?: Error | null) => void) | undefined; - const stdin = { - write: vi.fn((_dataValue: string, cb?: (err?: Error | null) => void) => { - pendingWriteCallback = cb; - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementationOnce(async () => ({ - pid: 2345, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn((reason: string) => { - cancel(reason); - pendingWriteCallback?.(new Error("stdin closed")); - }), - })); - - try { - const run = startLiveTurn("run-live-stuck-write", false, { - context: { timeoutMs: 10_000 }, - noOutputTimeoutMs: 1_000, - }); - const runExpectation = expectRejectsWithFields(run, { - name: "FailoverError", - message: "CLI produced no output for 1s and was terminated.", - }); - - await vi.advanceTimersByTimeAsync(1_000); - - await runExpectation; - await Promise.resolve(); - expect(unhandledRejections).toEqual([]); - expect(cancel).toHaveBeenCalledWith("manual-cancel"); - expect(stdin.write).toHaveBeenCalledOnce(); - } finally { - process.off("unhandledRejection", onUnhandledRejection); - } - }); -}); diff --git a/src/agents/cli-runner/claude-live-turn-timeouts.ts b/src/agents/cli-runner/claude-live-turn-timeouts.ts deleted file mode 100644 index aaa885755821..000000000000 --- a/src/agents/cli-runner/claude-live-turn-timeouts.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { BLOCKED_TOOL_CALL_ABORT_FLOOR_MS } from "../../logging/diagnostic-run-activity.js"; -import { - createCliTimeoutError, - resolveCliNoOutputTimeoutDecision, -} from "./no-output-timeout-policy.js"; - -type ClaudeLiveTimeoutTurn = { - startedAtMs: number; - rawLines: { length: number }; - noOutputTimer: NodeJS.Timeout | null; - lastOutputAtMs: number | null; - timeoutTimer: NodeJS.Timeout | null; - activeTools: { size: number }; - observedStdout: boolean; - useResume: boolean; - hasReplayUnsafeActivity: boolean; - toolEventCount: number; -}; - -type ClaudeLiveTimeoutHost = { - providerId: string; - modelId: string; - noOutputTimeoutMs: number; - stdoutBuffer: { pending: string }; - outstandingBackgroundTaskIds: { size: number }; - close(reason: "idle" | "restart" | "abort" | "mcp-capture-rotation", error?: unknown): void; -}; - -function armNoOutputTimer( - host: ClaudeLiveTimeoutHost, - turn: ClaudeLiveTimeoutTurn, - delayMs: number, -): void { - if (turn.noOutputTimer) { - clearTimeout(turn.noOutputTimer); - } - turn.noOutputTimer = setTimeout(() => { - const quietSinceMs = turn.lastOutputAtMs ?? turn.startedAtMs; - const quietDurationMs = Date.now() - quietSinceMs; - const decision = resolveCliNoOutputTimeoutDecision({ - context: { provider: host.providerId, model: host.modelId }, - timeoutMs: host.noOutputTimeoutMs, - quietDurationMs, - cliTimeout: { - mode: "no-output", - timeoutSeconds: Math.round(quietDurationMs / 1000), - observedActivity: - turn.lastOutputAtMs !== null || turn.toolEventCount > 0 || turn.rawLines.length > 0, - activeToolCount: turn.activeTools.size, - backgroundTaskCount: host.outstandingBackgroundTaskIds.size, - }, - hasOutputText: host.stdoutBuffer.pending.trim().length > 0, - useResume: turn.useResume, - hasReplayUnsafeActivity: turn.hasReplayUnsafeActivity, - allowResumeControlOnlyRetry: true, - outstandingWorkGraceMs: BLOCKED_TOOL_CALL_ABORT_FLOOR_MS, - }); - if (decision.deferMs !== undefined) { - armNoOutputTimer(host, turn, decision.deferMs); - return; - } - host.close("abort", decision.error); - }, delayMs); -} - -export function clearClaudeTurnTimers(turn: ClaudeLiveTimeoutTurn): void { - if (turn.noOutputTimer) { - clearTimeout(turn.noOutputTimer); - turn.noOutputTimer = null; - } - if (turn.timeoutTimer) { - clearTimeout(turn.timeoutTimer); - turn.timeoutTimer = null; - } -} - -export function resetClaudeNoOutputTimer( - host: ClaudeLiveTimeoutHost, - turn: ClaudeLiveTimeoutTurn | null, -): void { - if (!turn) { - return; - } - turn.lastOutputAtMs = Date.now(); - armNoOutputTimer(host, turn, host.noOutputTimeoutMs); -} - -export function armClaudeTurnTimers( - host: ClaudeLiveTimeoutHost, - turn: ClaudeLiveTimeoutTurn, - overallTimeoutMs: number, -): void { - armNoOutputTimer(host, turn, host.noOutputTimeoutMs); - turn.timeoutTimer = setTimeout(() => { - const timeoutSeconds = Math.round(overallTimeoutMs / 1000); - host.close( - "abort", - createCliTimeoutError( - { provider: host.providerId, model: host.modelId }, - { - mode: "overall", - timeoutSeconds, - observedActivity: - turn.observedStdout || turn.rawLines.length > 0 || turn.toolEventCount > 0, - activeToolCount: turn.activeTools.size, - backgroundTaskCount: host.outstandingBackgroundTaskIds.size, - }, - "cli_overall_timeout", - ), - ); - }, overallTimeoutMs); -} diff --git a/src/agents/cli-runner/claude-live-turn.test.ts b/src/agents/cli-runner/claude-live-turn.test.ts deleted file mode 100644 index ad9e2890fc16..000000000000 --- a/src/agents/cli-runner/claude-live-turn.test.ts +++ /dev/null @@ -1,996 +0,0 @@ -/** Claude live turn parsing, capability negotiation, and input ownership tests. */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { CliBackendParseJsonlEvent } from "../../plugins/cli-backend.types.js"; -import type { getProcessSupervisor } from "../../process/supervisor/index.js"; -import { - buildClaudeLiveRunContext, - expectRejectsWithFields, - mockClaudeLiveRun, - type PreparedCliRunContextOverrides, -} from "../cli-runner.test-helpers.js"; -import { supervisorSpawnMock } from "../cli-runner.test-support.js"; -import { createClaudeApiErrorFixture } from "../test-helpers/claude-api-error-fixture.js"; -import { runClaudeTurn } from "./claude-live-session.js"; -import { resetClaudeLiveSessionsForTest } from "./claude-live-session.test-support.js"; - -type ProcessSupervisor = ReturnType; -type SupervisorSpawnFn = ProcessSupervisor["spawn"]; - -const liveSessionRequirement = { - capability: "msg_lifecycle_v1", - minimumVersion: "2.1.206", - versionArgs: ["--version"], - updateCommand: "claude update", -} as const; - -beforeEach(() => { - resetClaudeLiveSessionsForTest(); - supervisorSpawnMock.mockClear(); -}); - -afterEach(() => { - vi.restoreAllMocks(); - vi.useRealTimers(); - resetClaudeLiveSessionsForTest(); -}); - -function getProcessSupervisorForTest() { - return { - spawn: (params: Parameters[0]) => - supervisorSpawnMock(params) as ReturnType, - cancel: vi.fn(), - cancelScope: vi.fn(), - getRecord: vi.fn(), - }; -} - -function startLiveTurn( - runId: string, - useResume: boolean, - options: { - context?: PreparedCliRunContextOverrides; - abortSignal?: AbortSignal; - noOutputTimeoutMs?: number; - requireCapability?: boolean; - onPhase?: (phase: "send" | "resolve") => void; - parseJsonlEvent?: CliBackendParseJsonlEvent; - onToolResult?: (delta: import("../cli-output-contracts.js").CliToolResultDelta) => void; - resolveToolResultTerminalOutcome?: ( - delta: import("../cli-output-contracts.js").CliToolResultDelta, - ) => import("./claude-live-turn.js").ClaudeLiveToolTerminalOutcome | undefined; - } = {}, -) { - const context = buildClaudeLiveRunContext({ - ...options.context, - runId, - timeoutMs: options.context?.timeoutMs ?? 60_000, - ...(options.requireCapability ? { liveSessionRequirement } : {}), - backend: { resumeArgs: ["-p", "--resume", "{sessionId}"] }, - }); - context.params.abortSignal = options.abortSignal; - context.backendResolved.parseJsonlEvent = options.parseJsonlEvent; - return runClaudeTurn({ - context, - args: context.preparedBackend.backend.args ?? [], - env: {}, - prompt: "hi", - useResume, - noOutputTimeoutMs: options.noOutputTimeoutMs ?? 5_000, - getProcessSupervisor: getProcessSupervisorForTest, - onAssistantDelta: () => {}, - onToolResult: options.onToolResult, - resolveToolResultTerminalOutcome: options.resolveToolResultTerminalOutcome, - onPhase: options.onPhase, - cleanup: async () => {}, - }); -} - -function installLiveStdoutDriver(params: { autoStart?: boolean } = {}) { - let stdoutListener: ((chunk: string) => void) | undefined; - const cancel = vi.fn(); - const userInputUuids: string[] = []; - let markReady: (() => void) | undefined; - const ready = new Promise((resolve) => { - markReady = resolve; - }); - const stdin = { - write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { - const parsed = JSON.parse(data) as { type?: string; uuid?: string }; - if (parsed.type === "user" && typeof parsed.uuid === "string") { - userInputUuids.push(parsed.uuid); - if (params.autoStart !== false) { - stdoutListener?.( - jsonl([{ type: "command_lifecycle", command_uuid: parsed.uuid, state: "started" }]), - ); - } - } - cb?.(); - markReady?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "live-turn-run", - pid: 4242, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel, - }; - }); - return { - cancel, - stdout: { - emit: (chunk: string) => stdoutListener?.(chunk), - startCurrentInput: () => { - const inputUuid = userInputUuids.at(-1); - if (!inputUuid) { - throw new Error("Claude input UUID was not written"); - } - stdoutListener?.( - jsonl([{ type: "command_lifecycle", command_uuid: inputUuid, state: "started" }]), - ); - }, - waitReady: () => ready, - }, - }; -} - -function jsonl(lines: unknown[]): string { - return lines.map((line) => JSON.stringify(line)).join("\n") + "\n"; -} - -describe("Claude live-session capability negotiation", () => { - it("rejects a malformed terminal result before background-task deferral", async () => { - const parseJsonlEvent = vi.fn((line) => { - const parsed = JSON.parse(line) as { type?: string; result?: string }; - if (parsed.type !== "result" || !parsed.result?.includes('')) { - return null; - } - return { - kind: "result", - errorText: - "Claude CLI returned malformed tool output (invalid request format): raw tool protocol appeared as assistant text.", - }; - }); - const phases: Array<"send" | "resolve"> = []; - const fixture = mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { - type: "system", - subtype: "init", - session_id: "live-malformed", - capabilities: ["msg_lifecycle_v1"], - }, - { - type: "system", - subtype: "background_tasks_changed", - tasks: [{ task_id: "task-1", task_type: "local_agent", description: "still running" }], - }, - { - type: "result", - subtype: "success", - session_id: "live-malformed", - result: [ - '', - 'pwd', - "", - ].join("\n"), - }, - ], - }); - - await expect( - startLiveTurn("run-malformed-result", false, { - parseJsonlEvent, - onPhase: (phase) => phases.push(phase), - }), - ).rejects.toMatchObject({ - name: "FailoverError", - reason: "format", - status: 400, - rawError: expect.stringContaining("raw tool protocol appeared as assistant text"), - }); - expect(phases).toEqual(["resolve"]); - expect(fixture.writes.filter((line) => line.includes('"type":"user"'))).toHaveLength(1); - expect( - parseJsonlEvent.mock.calls.filter(([line]) => line.includes('"type":"result"')), - ).toHaveLength(1); - }); - - it.each([ - { label: "fresh", useResume: false }, - { label: "resumed", useResume: true }, - ])( - "retains a matching start before $label init and trusts capability over version", - async (testCase) => { - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { - type: "system", - subtype: "init", - session_id: "live-capable", - claude_code_version: "2.1.100-custom", - capabilities: ["interrupt_receipt_v1", "msg_lifecycle_v1", "future_v2"], - }, - { - type: "result", - subtype: "success", - session_id: "live-capable", - result: "done", - }, - ], - }); - - await expect( - startLiveTurn(`run-capable-${testCase.label}`, testCase.useResume, { - requireCapability: true, - }), - ).resolves.toMatchObject({ - output: { text: "done" }, - }); - }, - ); - - it.each([ - { label: "fresh", useResume: false }, - { label: "resumed", useResume: true }, - ])( - "fails immediately when $label init omits the required lifecycle capability", - async (testCase) => { - const fixture = mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { - type: "system", - subtype: "init", - session_id: "live-legacy", - claude_code_version: "2.1.205", - capabilities: ["interrupt_receipt_v1"], - }, - ], - }); - - await expect( - startLiveTurn(`run-legacy-${testCase.label}`, testCase.useResume, { - requireCapability: true, - }), - ).rejects.toMatchObject({ - code: "cli_live_session_unsupported", - message: expect.stringContaining( - "Claude Code build (version 2.1.205) did not advertise the required msg_lifecycle_v1 capability", - ), - }); - expect(fixture.lifecycle.cancel).toHaveBeenCalledOnce(); - }, - ); -}); - -describe("Claude live turn input ownership and replay safety", () => { - it("ignores exact synthetic replay until the matching input starts", async () => { - const driver = installLiveStdoutDriver({ autoStart: false }); - const resultPromise = startLiveTurn("run-synthetic-placeholder", true); - await driver.stdout.waitReady(); - - driver.stdout.emit( - jsonl([ - { type: "system", subtype: "init", session_id: "live-synthetic" }, - { - type: "assistant", - session_id: "live-synthetic", - message: { - model: "", - role: "assistant", - content: [{ type: "text", text: "No response requested." }], - }, - }, - { - type: "result", - subtype: "success", - session_id: "live-synthetic", - result: "", - }, - { - type: "command_lifecycle", - command_uuid: "prior-synthetic-input", - state: "completed", - }, - ]), - ); - - let settled = false; - void resultPromise.then( - () => { - settled = true; - }, - () => { - settled = true; - }, - ); - await Promise.resolve(); - expect(settled).toBe(false); - expect(driver.cancel).not.toHaveBeenCalled(); - - driver.stdout.startCurrentInput(); - driver.stdout.emit( - jsonl([ - { - type: "assistant", - session_id: "live-synthetic", - message: { - model: "claude-fable-5", - role: "assistant", - content: [{ type: "text", text: "The background work is complete." }], - }, - }, - { - type: "result", - subtype: "success", - session_id: "live-synthetic", - result: "The background work is complete.", - }, - ]), - ); - - const result = await resultPromise; - expect(result.output.text).toBe("The background work is complete."); - expect(driver.cancel).not.toHaveBeenCalled(); - }); - - it("ignores markerless prior results until the matching input starts", async () => { - const driver = installLiveStdoutDriver({ autoStart: false }); - const resultPromise = startLiveTurn("run-markerless-prior-result", true); - await driver.stdout.waitReady(); - - driver.stdout.emit( - jsonl([ - { - type: "result", - subtype: "success", - session_id: "live-markerless", - result: "", - origin: { kind: "task-notification" }, - }, - { - type: "result", - subtype: "error_during_execution", - is_error: true, - session_id: "live-markerless", - result: "prior task failed", - }, - { - type: "command_lifecycle", - command_uuid: "prior-markerless-input", - state: "completed", - }, - ]), - ); - let settled = false; - void resultPromise.then( - () => { - settled = true; - }, - () => { - settled = true; - }, - ); - await Promise.resolve(); - expect(settled).toBe(false); - - driver.stdout.startCurrentInput(); - driver.stdout.emit( - jsonl([ - { - type: "assistant", - session_id: "live-markerless", - message: { - role: "assistant", - content: [{ type: "text", text: "current answer" }], - }, - }, - { - type: "result", - subtype: "success", - session_id: "live-markerless", - result: "current answer", - }, - ]), - ); - - await expect(resultPromise).resolves.toMatchObject({ output: { text: "current answer" } }); - expect(driver.cancel).not.toHaveBeenCalled(); - }); - - it("does not defer ordinary or non-empty results that resemble a synthetic placeholder", async () => { - const ordinaryDriver = installLiveStdoutDriver(); - const ordinaryPromise = startLiveTurn("run-ordinary-placeholder", false); - await ordinaryDriver.stdout.waitReady(); - ordinaryDriver.stdout.emit( - jsonl([ - { type: "system", subtype: "init", session_id: "live-ordinary-placeholder" }, - { - type: "assistant", - session_id: "live-ordinary-placeholder", - message: { - model: "claude-fable-5", - role: "assistant", - content: [{ type: "text", text: "No response requested." }], - }, - }, - { - type: "result", - subtype: "success", - session_id: "live-ordinary-placeholder", - result: "", - }, - ]), - ); - const ordinary = await ordinaryPromise; - expect(ordinary.output.text).toBe(""); - expect(ordinaryDriver.cancel).not.toHaveBeenCalled(); - - resetClaudeLiveSessionsForTest(); - const nonEmptyDriver = installLiveStdoutDriver(); - const nonEmptyPromise = startLiveTurn("run-synthetic-nonempty", true); - await nonEmptyDriver.stdout.waitReady(); - nonEmptyDriver.stdout.emit( - jsonl([ - { type: "system", subtype: "init", session_id: "live-synthetic-nonempty" }, - { - type: "assistant", - session_id: "live-synthetic-nonempty", - message: { - model: "", - role: "assistant", - content: [{ type: "text", text: "No response requested." }], - }, - }, - { - type: "result", - subtype: "success", - session_id: "live-synthetic-nonempty", - result: "real answer", - }, - ]), - ); - const nonEmpty = await nonEmptyPromise; - expect(nonEmpty.output.text).toBe("real answer"); - expect(nonEmptyDriver.cancel).not.toHaveBeenCalled(); - }); - - it("fails a current-input synthetic placeholder on a fresh live process", async () => { - const driver = installLiveStdoutDriver(); - const resultPromise = startLiveTurn("run-synthetic-fresh", false); - await driver.stdout.waitReady(); - driver.stdout.emit( - jsonl([ - { type: "system", subtype: "init", session_id: "live-synthetic-fresh" }, - { - type: "assistant", - session_id: "live-synthetic-fresh", - message: { - model: "", - role: "assistant", - content: [{ type: "text", text: "No response requested." }], - }, - }, - { - type: "result", - subtype: "success", - session_id: "live-synthetic-fresh", - result: "", - }, - ]), - ); - - await expect(resultPromise).rejects.toMatchObject({ - name: "FailoverError", - reason: "format", - code: "cli_synthetic_no_response", - }); - expect(driver.cancel).toHaveBeenCalledWith("manual-cancel"); - }); - - it("times out and cleans up when lifecycle records never start the current input", async () => { - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); - const driver = installLiveStdoutDriver({ autoStart: false }); - const resultPromise = startLiveTurn("run-missing-input-lifecycle", true, { - context: { timeoutMs: 60_000 }, - noOutputTimeoutMs: 1_000, - }); - await vi.advanceTimersByTimeAsync(0); - await driver.stdout.waitReady(); - - driver.stdout.emit( - jsonl([ - { - type: "command_lifecycle", - command_uuid: "unrelated-input", - state: "started", - }, - { - type: "result", - subtype: "error_during_execution", - is_error: true, - session_id: "live-missing-lifecycle", - result: "unrelated failure", - }, - ]), - ); - - const rejection = expect(resultPromise).rejects.toMatchObject({ - name: "FailoverError", - code: undefined, - cliTimeout: { - mode: "no-output", - timeoutSeconds: 1, - observedActivity: true, - activeToolCount: 0, - backgroundTaskCount: 0, - }, - }); - await vi.advanceTimersByTimeAsync(1_000); - await rejection; - expect(driver.cancel).toHaveBeenCalledWith("manual-cancel"); - }); - - it.each([ - { - label: "does not replay after current-turn synthetic output", - useResume: true, - expectedCode: undefined, - chunk: jsonl([ - { type: "system", subtype: "init", session_id: "live-synthetic-no-result" }, - { - type: "assistant", - session_id: "live-synthetic-no-result", - message: { - model: "", - role: "assistant", - content: [{ type: "text", text: "No response requested." }], - }, - }, - ]), - }, - { - label: "marks a resumed init-only stall as safe for recovery", - useResume: true, - expectedCode: "cli_no_output_timeout", - chunk: jsonl([{ type: "system", subtype: "init", session_id: "live-init-no-result" }]), - }, - { - label: "does not mark a fresh init-only stall as safe to replay", - useResume: false, - expectedCode: undefined, - chunk: jsonl([{ type: "system", subtype: "init", session_id: "live-fresh-init-no-result" }]), - }, - { - label: "does not mark a stall as retryable after substantive assistant output", - useResume: true, - expectedCode: undefined, - chunk: jsonl([ - { type: "system", subtype: "init", session_id: "live-synthetic-substantive" }, - { - type: "assistant", - session_id: "live-synthetic-substantive", - message: { - model: "", - role: "assistant", - content: [{ type: "text", text: "No response requested." }], - }, - }, - { - type: "assistant", - session_id: "live-synthetic-substantive", - message: { - model: "claude-fable-5", - role: "assistant", - content: [{ type: "text", text: "Partial real answer" }], - }, - }, - ]), - }, - { - label: "does not mark an incomplete stdout record as safe to replay", - useResume: true, - expectedCode: undefined, - chunk: '{"type":"assistant","message":{"model":"claude-fable-5"', - }, - ])("$label", async ({ useResume, expectedCode, chunk }) => { - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); - const driver = installLiveStdoutDriver(); - const resultPromise = startLiveTurn( - `run-replay-safe-stall-${useResume ? "resume" : "fresh"}`, - useResume, - { - context: { timeoutMs: 60_000 }, - noOutputTimeoutMs: 1_000, - }, - ); - await vi.advanceTimersByTimeAsync(0); - await driver.stdout.waitReady(); - driver.stdout.emit(chunk); - - const errorPromise = resultPromise.catch((error: unknown) => error); - await vi.advanceTimersByTimeAsync(1_000); - const error = (await errorPromise) as { code?: string; cliTimeout?: unknown }; - expect(error).toMatchObject({ - name: "FailoverError", - cliTimeout: { - mode: "no-output", - timeoutSeconds: 1, - observedActivity: true, - activeToolCount: 0, - backgroundTaskCount: 0, - }, - }); - expect(error.code).toBe(expectedCode); - expect(driver.cancel).toHaveBeenCalledWith("manual-cancel"); - }); - - it("still aborts on the turn timeout after input starts but never returns a result", async () => { - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); - const driver = installLiveStdoutDriver(); - const resultPromise = startLiveTurn("run-synthetic-timeout", true, { - context: { timeoutMs: 5_000 }, - noOutputTimeoutMs: 60_000, - }); - await vi.advanceTimersByTimeAsync(0); - await driver.stdout.waitReady(); - - driver.stdout.emit( - jsonl([ - { type: "system", subtype: "init", session_id: "live-synthetic-timeout" }, - { - type: "assistant", - session_id: "live-synthetic-timeout", - message: { - model: "", - role: "assistant", - content: [{ type: "text", text: "Continue from where you left off." }], - }, - }, - ]), - ); - - const rejection = expect(resultPromise).rejects.toMatchObject({ - name: "FailoverError", - message: expect.stringMatching(/exceeded timeout/i), - code: "cli_overall_timeout", - cliTimeout: { - mode: "overall", - timeoutSeconds: 5, - observedActivity: true, - activeToolCount: 0, - backgroundTaskCount: 0, - }, - }); - await vi.advanceTimersByTimeAsync(5_000); - await rejection; - expect(driver.cancel).toHaveBeenCalledWith("manual-cancel"); - }); - - it("fails immediately when an error result follows a synthetic placeholder", async () => { - const driver = installLiveStdoutDriver(); - const resultPromise = startLiveTurn("run-synthetic-error", true); - await driver.stdout.waitReady(); - - driver.stdout.emit( - jsonl([ - { type: "system", subtype: "init", session_id: "live-synthetic-error" }, - { - type: "assistant", - session_id: "live-synthetic-error", - message: { - model: "", - role: "assistant", - content: [{ type: "text", text: "No response requested." }], - }, - }, - { - type: "result", - subtype: "error_during_execution", - is_error: true, - session_id: "live-synthetic-error", - result: "provider failed", - }, - ]), - ); - - await expect(resultPromise).rejects.toMatchObject({ - name: "FailoverError", - rawError: expect.stringMatching(/provider failed/i), - }); - }); - - it("fails the turn on an error result even when background tasks are outstanding", async () => { - const driver = installLiveStdoutDriver(); - const phases: Array<"send" | "resolve"> = []; - const resultPromise = startLiveTurn("run-bg-error", false, { - onPhase: (phase) => phases.push(phase), - }); - await driver.stdout.waitReady(); - - driver.stdout.emit( - jsonl([ - { type: "system", subtype: "init", session_id: "live-bg-err" }, - { - type: "system", - subtype: "background_tasks_changed", - tasks: [{ task_id: "task-err", task_type: "local_agent", description: "stuck" }], - }, - { - type: "result", - subtype: "error_during_execution", - is_error: true, - session_id: "live-bg-err", - result: "agent crashed", - }, - ]), - ); - - await expect(resultPromise).rejects.toMatchObject({ - name: "FailoverError", - rawError: expect.stringMatching(/agent crashed/i), - }); - expect(phases).toEqual(["resolve"]); - }); -}); - -describe("Claude live turn output bounds and result projection", () => { - it("accepts Claude live stream-json lines larger than 256 KiB", async () => { - const largeText = "x".repeat(270 * 1024); - mockClaudeLiveRun(supervisorSpawnMock, { - events: [{ type: "result", session_id: "live-session-large", result: largeText }], - }); - - const result = await startLiveTurn("run-live-large", false); - - expect(result.output.text).toHaveLength(largeText.length); - expect(result.output.text).toBe(largeText); - }); - - it("frames coalesced Claude live image and PDF records before omitting retained bytes", async () => { - const toolResults: unknown[] = []; - const base64 = "a".repeat(4_300_000); - mockClaudeLiveRun(supervisorSpawnMock, { - onWrite: ({ emit }) => { - const events: Record[] = [ - { type: "system", subtype: "init", session_id: "live-binary-results" }, - ]; - for (const [type, mediaType] of [ - ["image", "image/png"], - ["document", "application/pdf"], - ] as const) { - events.push( - { - type: "assistant", - session_id: "live-binary-results", - message: { - role: "assistant", - content: [{ type: "tool_use", id: `read-${type}`, name: "Read", input: {} }], - }, - }, - { - type: "user", - session_id: "live-binary-results", - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: `read-${type}`, - content: [ - { type: "text", text: `Read ${type}` }, - { type, source: { type: "base64", media_type: mediaType, data: base64 } }, - ], - }, - ], - }, - }, - ); - } - events.push({ - type: "result", - session_id: "live-binary-results", - result: "both files read", - }); - emit(events); - }, - }); - - const result = await startLiveTurn("run-live-binary-results", false, { - onToolResult: (delta) => toolResults.push(delta.result), - }); - - expect(result.output.text).toBe("both files read"); - expect(toolResults).toEqual([ - [ - { type: "text", text: "Read image" }, - { - type: "image", - source: { type: "base64", media_type: "image/png" }, - omitted: true, - bytes: 3_225_000, - }, - ], - [ - { type: "text", text: "Read document" }, - { - type: "document", - source: { type: "base64", media_type: "application/pdf" }, - omitted: true, - bytes: 3_225_000, - }, - ], - ]); - }); - - it.each([ - { - name: "an oversized complete line", - chunks: () => [`${"a".repeat(8 * 1024 * 1024 + 1)}\n`], - }, - { - name: "an oversized growing unterminated line", - chunks: () => ["a".repeat(4_300_000), "a".repeat(4_300_000)], - }, - ])("rejects $name from Claude live stdout", async ({ chunks }) => { - const live: ReturnType = mockClaudeLiveRun(supervisorSpawnMock, { - onWrite: () => { - for (const chunk of chunks()) { - live.spawnInput.onStdout?.(chunk); - } - }, - }); - - await expect(startLiveTurn("run-live-oversized-line", false)).rejects.toThrow( - "CLI JSONL line exceeded 8388608 characters; refusing to parse output.", - ); - }); - - it.each([ - { - name: "a coalesced blank-frame flood", - createChunk: () => "\n".repeat(20_001), - expectedError: "CLI JSONL output exceeded 20000 lines; refusing to parse output.", - }, - { - name: "whitespace-only records exceeding the raw budget", - createChunk: () => `${" ".repeat(4_300_000)}\n${" ".repeat(4_300_000)}\n`, - expectedError: "CLI JSONL output exceeded 8388608 characters; refusing to parse output.", - }, - { - name: "valid JSON padded beyond the raw budget", - createChunk: () => `${" ".repeat(4_300_000)}{}\n${" ".repeat(4_300_000)}{}\n`, - expectedError: "CLI JSONL output exceeded 8388608 characters; refusing to parse output.", - }, - { - name: "internal formatting around compacted Claude media", - createChunk: () => { - const line = JSON.stringify({ - type: "user", - message: { - content: [ - { - type: "tool_result", - tool_use_id: "padded-live-image", - content: [ - { - type: "image", - source: { type: "base64", media_type: "image/png", data: "YQ==" }, - }, - ], - }, - ], - }, - }).replace('"message":', `"message":${" ".repeat(4_300_000)}`); - return `${line}\n${line}\n`; - }, - expectedError: "CLI JSONL output exceeded 8388608 characters; refusing to parse output.", - }, - ])("reports the exact limit for $name", async ({ createChunk, expectedError }) => { - const live: ReturnType = mockClaudeLiveRun(supervisorSpawnMock, { - onWrite: () => live.spawnInput.onStdout?.(createChunk()), - }); - - await expect(startLiveTurn("run-live-output-budget", false)).rejects.toThrow(expectedError); - }); - - it("reports backend JSONL parser failures without relabeling them as output limits", async () => { - mockClaudeLiveRun(supervisorSpawnMock, { - events: [{ type: "system", subtype: "init", session_id: "live-parser-error" }], - }); - - await expectRejectsWithFields( - startLiveTurn("run-live-parser-error", false, { - parseJsonlEvent: () => { - throw new Error("invalid custom event"); - }, - }), - { - name: "FailoverError", - reason: "format", - message: "CLI backend claude-cli JSONL parser failed: invalid custom event", - }, - ); - }); - - it("ignores non-JSON stdout lines from Claude live sessions", async () => { - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - "Claude CLI warning", - { type: "system", subtype: "init", session_id: "live-mixed" }, - { type: "result", session_id: "live-mixed", result: "mixed-ok" }, - ], - }); - - const result = await startLiveTurn("run-live-mixed", false); - expect(result.output.text).toBe("mixed-ok"); - }); - - it("fails Claude live turns on is_error results", async () => { - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: "live-error" }, - { - type: "result", - session_id: "live-error", - is_error: true, - result: "Credit balance is too low", - }, - ], - }); - - await expectRejectsWithFields(startLiveTurn("run-live-error", false), { - name: "FailoverError", - message: "Credit balance is too low", - }); - }); - - it("surfaces Claude live max-turn results with run and session recovery context", async () => { - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: "live-max-turns" }, - { - type: "result", - subtype: "error_max_turns", - session_id: "live-max-turns", - num_turns: 2, - stop_reason: "tool_use", - terminal_reason: "max_turns", - errors: ["Reached maximum number of turns (1)"], - }, - ], - }); - - await expectRejectsWithFields(startLiveTurn("run-live-max-turns", false), { - name: "FailoverError", - message: - "Claude CLI stopped after reaching the maximum number of turns (limit: 1). " + - "OpenClaw run: run-live-max-turns. OpenClaw session: s1. " + - "Claude session: live-max-turns. Tool actions may already have run; verify their effects before retrying. " + - "Retry with a higher --max-turns value or a narrower task.", - sessionId: "s1", - reason: "unknown", - code: "cli_max_turns", - rawError: "Reached maximum number of turns (1)", - }); - }); - - it("surfaces nested Claude stream-json API errors instead of raw event output", async () => { - const { message, jsonl: apiErrorJsonl } = createClaudeApiErrorFixture(); - mockClaudeLiveRun(supervisorSpawnMock, { - events: apiErrorJsonl.split("\n"), - }); - - await expectRejectsWithFields(startLiveTurn("run-live-api-error", false), { - name: "FailoverError", - message, - reason: "billing", - status: 402, - }); - }); -}); diff --git a/src/agents/cli-runner/claude-live-turn.ts b/src/agents/cli-runner/claude-live-turn.ts deleted file mode 100644 index a874f4916dbe..000000000000 --- a/src/agents/cli-runner/claude-live-turn.ts +++ /dev/null @@ -1,638 +0,0 @@ -import { isRecord } from "@openclaw/normalization-core/record-coerce"; -import { isAbortError } from "../../infra/abort-signal.js"; -import { - emitTrustedDiagnosticEvent, - type DiagnosticToolExecutionErrorEvent, - type DiagnosticToolParamsSummary, - type DiagnosticToolSource, -} from "../../infra/diagnostic-events.js"; -import type { - CliBackendConfig, - CliBackendParseJsonlEvent, -} from "../../plugins/cli-backend.types.js"; -import type { - CliOutput, - CliStreamingDelta, - CliStreamJsonOutputLimits, - CliThinkingDelta, - CliThinkingProgress, - CliToolResultDelta, - CliToolUseStartDelta, - CliUsage, -} from "../cli-output-contracts.js"; -import { pickCliSessionId } from "../cli-output-records.js"; -import { - CLI_STREAM_JSON_DEFAULT_MAX_TURN_RAW_CHARS, - CLI_STREAM_JSON_OUTPUT_LIMITS, - createCliJsonlStreamingParser, - frameBoundedCliJsonlChunk, - normalizeClaudeCliStreamJsonRecord, - streamJsonOutputLimitErrorText, -} from "../cli-output-stream.js"; -import { parseCliOutput } from "../cli-output.js"; -import { isFailoverError, isSignalTimeoutReason, type FailoverError } from "../failover-error.js"; -import { resolveCliToolTerminalReason } from "../run-termination.js"; -import { - armClaudeTurnTimers, - clearClaudeTurnTimers, - resetClaudeNoOutputTimer, -} from "./claude-live-turn-timeouts.js"; -import { createCliExitFailoverError, createCliFailoverError } from "./exit-error.js"; -import { cliBackendLog, formatCliBackendOutputDigest } from "./log.js"; -import { createCliOutputFailoverError } from "./output-error.js"; -import type { PreparedCliRunContext } from "./types.js"; - -export type ClaudeLiveExecPermission = { - security: import("../../infra/exec-approvals.js").ExecSecurity; - ask: import("../../infra/exec-approvals.js").ExecAsk; - permissionMode: "bypassPermissions" | "default"; -}; - -export type ClaudeLiveToolTerminalOutcome = - | { outcome: "blocked"; deniedReason: string; reason?: string } - | { outcome: "cancelled" | "failed" | "timed_out" | "unknown" }; - -type ClaudeLiveActiveTool = { - toolName: string; - toolCallId: string; - kind: CliToolUseStartDelta["kind"]; - startedAt: number; -}; - -export type ClaudeLiveTurn = { - backend: CliBackendConfig; - cwd: string; - parseJsonlEvent?: CliBackendParseJsonlEvent; - diagnosticRefs: { - runId: string; - sessionId: string; - sessionKey?: string; - agentId?: string; - }; - abortSignal?: AbortSignal; - outputLimits: CliStreamJsonOutputLimits; - startedAtMs: number; - rawLines: string[]; - sessionId?: string; - noOutputTimer: NodeJS.Timeout | null; - lastOutputAtMs: number | null; - timeoutTimer: NodeJS.Timeout | null; - activeTools: Map; - observedStdout: boolean; - inputUuid: string; - inputStarted: boolean; - onSessionId?: (sessionId: string) => void; - useResume: boolean; - hasReplayUnsafeActivity: boolean; - completedToolCallIds: Set; - toolEventCount: number; - streamingParser: ReturnType; - onCliOutput?: (chunk: string, stream: "stderr" | "stdout") => void; - onPhase?: (phase: "send" | "resolve") => void; - execPermission: ClaudeLiveExecPermission; - resolve: (output: CliOutput) => void; - reject: (error: unknown) => void; -}; - -export type ClaudeLiveTurnHost = { - backend: CliBackendConfig; - providerId: string; - modelId: string; - noOutputTimeoutMs: number; - stderr: string; - stdoutBuffer: { pending: string }; - currentTurn: ClaudeLiveTurn | null; - outstandingBackgroundTaskIds: Set; - liveSessionCapabilityReady: boolean; - closing: boolean; - close(reason: "idle" | "restart" | "abort" | "mcp-capture-rotation", error?: unknown): void; - scheduleIdleClose(): void; - acceptControlResponse(parsed: Record): boolean; - acceptControlRequest(turn: ClaudeLiveTurn, parsed: Record): void; - acceptSessionRequirement(parsed: Record): boolean; - acceptSessionId(sessionId: string): void; - settleControlRequest(): void; - cleanupAfterExit(): void; -}; - -function finishClaudeTurn(host: ClaudeLiveTurnHost, output: CliOutput): void { - const turn = host.currentTurn; - if (!turn) { - return; - } - cliBackendLog.info( - `claude live session turn: provider=${host.providerId} model=${host.modelId} durationMs=${Date.now() - turn.startedAtMs} rawLines=${turn.rawLines.length} ${formatCliBackendOutputDigest(output.text)}`, - ); - turn.streamingParser.finish(); - failActiveClaudeLiveTools(turn, new Error("Tool result missing before turn completed")); - clearClaudeTurnTimers(turn); - host.outstandingBackgroundTaskIds.clear(); - host.currentTurn = null; - turn.resolve(output); - host.scheduleIdleClose(); -} - -export function failClaudeTurn(host: ClaudeLiveTurnHost, error: unknown): void { - const turn = host.currentTurn; - if (!turn) { - return; - } - const errorKind = error instanceof Error ? error.name : typeof error; - cliBackendLog.warn( - `claude live session turn failed: provider=${host.providerId} model=${host.modelId} durationMs=${Date.now() - turn.startedAtMs} error=${errorKind}`, - ); - turn.streamingParser.finish(); - // Caller interruptions (abort signal, caller deadline) keep already-streamed text. - // Structured CLI failures still reject so failover and empty-output handling are unchanged. - // Deadline vs abort follows the signal-reason rule run-diagnostics uses: only a TimeoutError - // reason is a deadline; failover message patterns would read a plain abort as a timeout. - const interrupted = - !isFailoverError(error) && (isAbortError(error) || isSignalTimeoutReason(error)); - const partialOutput = interrupted ? turn.streamingParser.getOutput() : undefined; - failActiveClaudeLiveTools(turn, error); - clearClaudeTurnTimers(turn); - host.outstandingBackgroundTaskIds.clear(); - host.currentTurn = null; - if (!partialOutput?.text.trim() || partialOutput.errorText) { - turn.reject(error); - return; - } - cliBackendLog.info( - `claude live session aborted turn preserved partial output: provider=${host.providerId} model=${host.modelId} durationMs=${Date.now() - turn.startedAtMs} ${formatCliBackendOutputDigest(partialOutput.text)}`, - ); - turn.resolve({ - ...partialOutput, - terminalInterruption: { reason: isSignalTimeoutReason(error) ? "timeout" : "aborted" }, - }); -} - -export function createClaudeOutputLimitError( - host: ClaudeLiveTurnHost, - message: string, -): FailoverError { - return createCliFailoverError(message, "format", { - provider: host.providerId, - model: host.modelId, - }); -} - -function diagnosticBase(turn: ClaudeLiveTurn) { - return { - runId: turn.diagnosticRefs.runId, - sessionId: turn.diagnosticRefs.sessionId, - ...(turn.diagnosticRefs.sessionKey ? { sessionKey: turn.diagnosticRefs.sessionKey } : {}), - ...(turn.diagnosticRefs.agentId ? { agentId: turn.diagnosticRefs.agentId } : {}), - }; -} - -function emitProgress(turn: ClaudeLiveTurn, reason: string): void { - emitTrustedDiagnosticEvent({ type: "run.progress", ...diagnosticBase(turn), reason }); -} - -function toolSource(toolName: string): DiagnosticToolSource { - return toolName.startsWith("mcp__") ? "mcp" : "core"; -} - -function summarizeToolInput(input: unknown): DiagnosticToolParamsSummary | undefined { - if (input === undefined) { - return undefined; - } - if (input === null) { - return { kind: "null" }; - } - if (Array.isArray(input)) { - return { kind: "array", length: input.length }; - } - switch (typeof input) { - case "object": - return { kind: "object" }; - case "string": - return { kind: "string", length: input.length }; - case "number": - return { kind: "number" }; - case "boolean": - return { kind: "boolean" }; - case "undefined": - return { kind: "undefined" }; - default: - return { kind: "other" }; - } -} - -function markClaudeLiveToolStarted(turn: ClaudeLiveTurn, tool: CliToolUseStartDelta): void { - if (turn.completedToolCallIds.has(tool.toolCallId) || turn.activeTools.has(tool.toolCallId)) { - return; - } - const now = Date.now(); - turn.activeTools.set(tool.toolCallId, { - toolName: tool.name, - toolCallId: tool.toolCallId, - kind: tool.kind, - startedAt: now, - }); - turn.toolEventCount += 1; - emitTrustedDiagnosticEvent({ - type: "tool.execution.started", - ...diagnosticBase(turn), - toolName: tool.name, - toolSource: toolSource(tool.name), - toolOwner: "claude-cli", - toolCallId: tool.toolCallId, - paramsSummary: summarizeToolInput(tool.args), - }); - emitProgress(turn, "cli_live:tool_started"); -} - -function markClaudeLiveToolCompleted( - turn: ClaudeLiveTurn, - result: CliToolResultDelta, - terminalOutcome?: ClaudeLiveToolTerminalOutcome, -): void { - if (turn.completedToolCallIds.has(result.toolCallId)) { - return; - } - turn.toolEventCount += 1; - const activeTool = turn.activeTools.get(result.toolCallId); - if (!activeTool) { - emitProgress(turn, "cli_live:tool_result"); - return; - } - turn.activeTools.delete(result.toolCallId); - turn.completedToolCallIds.add(result.toolCallId); - const event = { - ...diagnosticBase(turn), - toolName: activeTool.toolName, - toolSource: toolSource(activeTool.toolName), - toolOwner: "claude-cli" as const, - toolCallId: activeTool.toolCallId, - durationMs: Math.max(0, Date.now() - activeTool.startedAt), - }; - if (terminalOutcome?.outcome === "blocked") { - emitTrustedDiagnosticEvent({ - type: "tool.execution.blocked", - ...event, - deniedReason: terminalOutcome.deniedReason, - reason: terminalOutcome.reason ?? "blocked by before-tool policy", - }); - } else if (terminalOutcome?.outcome === "unknown") { - emitTrustedDiagnosticEvent({ - type: "tool.execution.error", - ...event, - errorCategory: "cli_tool_ambiguous", - errorCode: "tool_outcome_unknown", - }); - } else if (terminalOutcome || result.isError) { - const terminalReason = terminalOutcome?.outcome ?? "failed"; - emitTrustedDiagnosticEvent({ - type: "tool.execution.error", - ...event, - errorCategory: terminalReason === "cancelled" ? "aborted" : "tool_failed", - terminalReason, - }); - } else { - emitTrustedDiagnosticEvent({ type: "tool.execution.completed", ...event }); - } - emitProgress(turn, "cli_live:tool_result"); -} - -export function markClaudeLiveToolDenied(turn: ClaudeLiveTurn, tool: CliToolUseStartDelta): void { - markClaudeLiveToolStarted(turn, tool); - markClaudeLiveToolCompleted( - turn, - { toolCallId: tool.toolCallId, name: tool.name, isError: true }, - { - outcome: "blocked", - deniedReason: "cli_live_exec_policy", - reason: "blocked by CLI live execution policy", - }, - ); -} - -function failActiveClaudeLiveTools(turn: ClaudeLiveTurn, error: unknown): void { - const terminalReason = resolveCliToolTerminalReason({ error, abortSignal: turn.abortSignal }); - const errorCategory = - terminalReason === "timed_out" - ? "timeout" - : terminalReason === "cancelled" - ? "aborted" - : "error"; - for (const activeTool of turn.activeTools.values()) { - const event: Omit = - { - ...diagnosticBase(turn), - toolName: activeTool.toolName, - toolSource: toolSource(activeTool.toolName), - toolOwner: "claude-cli", - toolCallId: activeTool.toolCallId, - durationMs: Math.max(0, Date.now() - activeTool.startedAt), - }; - emitTrustedDiagnosticEvent( - activeTool.kind === "server_tool_use" - ? { - type: "tool.execution.error", - ...event, - errorCategory: "cli_tool_ambiguous", - errorCode: "tool_outcome_unknown", - } - : { type: "tool.execution.error", ...event, errorCategory, terminalReason }, - ); - } - turn.activeTools.clear(); -} - -function noteClaudeLiveProgress( - turn: ClaudeLiveTurn, - parsed: Record, - sawToolEvent: boolean, -): void { - if (parsed.type === "result") { - emitProgress(turn, "cli_live:result"); - return; - } - if (sawToolEvent) { - return; - } - emitProgress(turn, "cli_live:stream_progress"); -} - -const RESULT_HOLDING_BACKGROUND_TASK_TYPES = new Set(["local_agent", "local_workflow"]); - -function applyBackgroundTasksChanged( - host: ClaudeLiveTurnHost, - parsed: Record, -): void { - if (parsed.type !== "system" || parsed.subtype !== "background_tasks_changed") { - return; - } - host.outstandingBackgroundTaskIds.clear(); - for (const task of Array.isArray(parsed.tasks) ? parsed.tasks : []) { - if (!isRecord(task)) { - continue; - } - const taskType = typeof task.task_type === "string" ? task.task_type.trim() : ""; - const taskId = typeof task.task_id === "string" ? task.task_id.trim() : ""; - if (RESULT_HOLDING_BACKGROUND_TASK_TYPES.has(taskType) && taskId) { - host.outstandingBackgroundTaskIds.add(taskId); - } - } -} - -function pushTurnLine(host: ClaudeLiveTurnHost, turn: ClaudeLiveTurn, line: string): boolean { - turn.streamingParser.push(`${line}\n`); - const errorText = turn.streamingParser.getErrorText(); - if (!errorText) { - return true; - } - host.close("abort", createClaudeOutputLimitError(host, errorText)); - return false; -} - -function acceptClaudeLine(host: ClaudeLiveTurnHost, line: string): void { - const turn = host.currentTurn; - const trimmed = line.trim(); - if (!trimmed) { - if (turn) { - pushTurnLine(host, turn, line); - } - return; - } - let parsed: Record | null = null; - try { - const candidate: unknown = JSON.parse(trimmed); - parsed = isRecord(candidate) ? candidate : null; - } catch {} - if (turn) { - turn.observedStdout = true; - } - if (!parsed) { - if (turn) { - turn.hasReplayUnsafeActivity = true; - } - return; - } - const parsedSessionId = pickCliSessionId(parsed, host.backend); - if (parsedSessionId) { - host.acceptSessionId(parsedSessionId); - if (parsed.type === "system" && parsed.subtype === "init") { - turn?.onSessionId?.(parsedSessionId); - } - } - if (host.acceptControlResponse(parsed) || !turn) { - return; - } - if ( - parsed.type === "command_lifecycle" && - parsed.command_uuid === turn.inputUuid && - parsed.state === "started" && - !turn.inputStarted - ) { - turn.inputStarted = true; - emitProgress(turn, "cli_live:input_started"); - } - if (!host.acceptSessionRequirement(parsed)) { - return; - } - if (!host.liveSessionCapabilityReady) { - return; - } - if (!turn.inputStarted) { - if (!(parsed.type === "system" && parsed.subtype === "init")) { - turn.hasReplayUnsafeActivity = true; - } - return; - } - if ( - !(parsed.type === "system" && parsed.subtype === "init") && - parsed.type !== "command_lifecycle" - ) { - turn.hasReplayUnsafeActivity = true; - } - const normalizedLine = normalizeClaudeCliStreamJsonRecord(parsed)?.line ?? trimmed; - turn.rawLines.push(normalizedLine); - applyBackgroundTasksChanged(host, parsed); - const toolEventCountBefore = turn.toolEventCount; - if (!pushTurnLine(host, turn, line)) { - return; - } - turn.sessionId = parsedSessionId ?? turn.sessionId; - noteClaudeLiveProgress(turn, parsed, turn.toolEventCount !== toolEventCountBefore); - host.acceptControlRequest(turn, parsed); - if (parsed.type !== "result") { - return; - } - turn.onPhase?.("resolve"); - const raw = turn.rawLines.join("\n"); - const output = - turn.streamingParser.getOutput() ?? - parseCliOutput({ - raw, - backend: turn.backend, - providerId: host.providerId, - parseJsonlEvent: turn.parseJsonlEvent, - outputMode: "jsonl", - fallbackSessionId: turn.sessionId, - }); - const syntheticNoResponsePendingContinuation = - output.terminalFailure?.reason === "synthetic_no_response" && - host.outstandingBackgroundTaskIds.size > 0; - if (output.errorText && !syntheticNoResponsePendingContinuation) { - const error = createCliOutputFailoverError({ - output, - provider: host.providerId, - model: host.modelId, - runId: turn.diagnosticRefs.runId, - sessionId: turn.diagnosticRefs.sessionId, - }); - if (error) { - failClaudeTurn(host, error); - } - host.scheduleIdleClose(); - return; - } - if (host.outstandingBackgroundTaskIds.size > 0) { - turn.onPhase?.("send"); - emitProgress(turn, "cli_live:result_deferred_background_tasks"); - return; - } - finishClaudeTurn(host, output); -} - -export function acceptClaudeStdout(host: ClaudeLiveTurnHost, chunk: string): void { - host.currentTurn?.onCliOutput?.(chunk, "stdout"); - resetClaudeNoOutputTimer(host, host.currentTurn); - const maxPendingLineChars = - host.currentTurn?.outputLimits.maxPendingLineChars ?? - CLI_STREAM_JSON_DEFAULT_MAX_TURN_RAW_CHARS; - try { - if ( - !frameBoundedCliJsonlChunk(host.stdoutBuffer, chunk, maxPendingLineChars, (line) => { - acceptClaudeLine(host, line); - return !host.closing; - }) - ) { - host.close( - "abort", - createClaudeOutputLimitError( - host, - streamJsonOutputLimitErrorText("line", maxPendingLineChars), - ), - ); - } - } catch (error) { - host.close("abort", error); - } -} - -export function acceptClaudeExit(host: ClaudeLiveTurnHost, exitCode: number | null): void { - host.closing = true; - host.settleControlRequest(); - host.cleanupAfterExit(); - if (!host.currentTurn) { - return; - } - if (host.stdoutBuffer.pending.trim()) { - const pendingLine = host.stdoutBuffer.pending; - host.stdoutBuffer.pending = ""; - try { - acceptClaudeLine(host, pendingLine); - } catch (error) { - failClaudeTurn(host, error); - return; - } - } - if (!host.currentTurn) { - return; - } - const stderr = host.stderr.trim(); - const turn = host.currentTurn; - failClaudeTurn( - host, - createCliExitFailoverError({ - context: { provider: host.providerId, model: host.modelId }, - candidates: [stderr], - fallbackMessage: - exitCode === 0 ? "Claude CLI exited before completing the turn." : "Claude CLI failed.", - emptyReason: exitCode === 0 ? "empty_response" : undefined, - retryEmptyFailure: !turn.observedStdout && turn.rawLines.length === 0, - }), - ); -} - -export function createClaudeTurn(params: { - context: PreparedCliRunContext; - inputUuid: string; - useResume: boolean; - host: ClaudeLiveTurnHost; - execPermission: ClaudeLiveExecPermission; - onAssistantDelta: (delta: CliStreamingDelta) => void; - onThinkingDelta?: (delta: CliThinkingDelta) => void; - onThinkingProgress?: (progress: CliThinkingProgress) => void; - onToolUseStart?: (delta: CliToolUseStartDelta) => void; - onToolResult?: (delta: CliToolResultDelta) => void; - resolveToolResultTerminalOutcome?: ( - delta: CliToolResultDelta, - ) => ClaudeLiveToolTerminalOutcome | undefined; - onCommentaryText?: (text: string) => void; - onSessionId?: (sessionId: string) => void; - onAssistantMessage?: (message: unknown) => void; - onUsage?: (usage: CliUsage, terminal: boolean) => void; - onCliOutput?: (chunk: string, stream: "stderr" | "stdout") => void; - onPhase?: (phase: "send" | "resolve") => void; - resolve: (output: CliOutput) => void; - reject: (error: unknown) => void; -}): ClaudeLiveTurn { - const turn: ClaudeLiveTurn = { - backend: params.context.preparedBackend.backend, - cwd: params.context.cwd ?? params.context.workspaceDir, - parseJsonlEvent: params.context.backendResolved.parseJsonlEvent, - diagnosticRefs: { - runId: params.context.params.runId, - sessionId: params.context.params.sessionId, - ...(params.context.params.sessionKey ? { sessionKey: params.context.params.sessionKey } : {}), - ...(params.context.params.agentId ? { agentId: params.context.params.agentId } : {}), - }, - abortSignal: params.context.params.abortSignal, - outputLimits: CLI_STREAM_JSON_OUTPUT_LIMITS, - startedAtMs: Date.now(), - rawLines: [], - noOutputTimer: null, - lastOutputAtMs: null, - timeoutTimer: null, - activeTools: new Map(), - observedStdout: false, - inputUuid: params.inputUuid, - inputStarted: false, - onSessionId: params.onSessionId, - useResume: params.useResume, - hasReplayUnsafeActivity: false, - completedToolCallIds: new Set(), - toolEventCount: 0, - streamingParser: createCliJsonlStreamingParser({ - backend: params.context.preparedBackend.backend, - providerId: params.context.backendResolved.id, - parseJsonlEvent: params.context.backendResolved.parseJsonlEvent, - onAssistantDelta: params.onAssistantDelta, - onThinkingDelta: params.onThinkingDelta, - onThinkingProgress: params.onThinkingProgress, - onToolUseStart: (delta) => { - markClaudeLiveToolStarted(turn, delta); - params.onToolUseStart?.(delta); - }, - onToolResult: (delta) => { - markClaudeLiveToolCompleted(turn, delta, params.resolveToolResultTerminalOutcome?.(delta)); - params.onToolResult?.(delta); - }, - onCommentaryText: params.onCommentaryText, - onSessionId: params.onSessionId, - onAssistantMessage: params.onAssistantMessage, - onUsage: params.onUsage, - }), - onCliOutput: params.onCliOutput, - onPhase: params.onPhase, - execPermission: params.execPermission, - resolve: params.resolve, - reject: params.reject, - }; - armClaudeTurnTimers(params.host, turn, params.context.params.timeoutMs); - return turn; -} diff --git a/src/agents/cli-runner/cli-live-session-registry.test.ts b/src/agents/cli-runner/cli-live-session-registry.test.ts new file mode 100644 index 000000000000..894cb6b83677 --- /dev/null +++ b/src/agents/cli-runner/cli-live-session-registry.test.ts @@ -0,0 +1,366 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createDeferred } from "../../../test/helpers/promise.js"; +import type { + CliBackendLiveSessionCapability, + CliBackendLiveSessionHandle, +} from "../../plugins/cli-backend.types.js"; +import { prepareSystemAgentRunAdmission } from "../admitted-run-context.js"; +import { buildPreparedCliRunContext } from "../cli-runner.test-helpers.js"; +import { + acceptsCliLiveSession, + buildCliLiveOwnerKey, + closeCliLiveSession, + createCliLiveSessionCapability, + getCliLiveSessionGeneration, + hasCliLiveSession, +} from "./cli-live-session-registry.js"; +import { buildCliLiveSessionFingerprint } from "./live-session-fingerprint.js"; + +const admissions: Array> = []; +const sessions = new Set(); +let nextOwnerId = 0; + +async function createOwner( + options: { + sessionId?: string; + generation?: string; + idle?: boolean; + deferExit?: boolean; + cleanup?: () => Promise; + systemPrompt?: string; + capture?: { token: string; key: string }; + requiredGeneration?: string; + } = {}, +) { + const index = ++nextOwnerId; + const sessionId = options.sessionId ?? `registry-session-${index}`; + const sessionKey = `agent:main:${sessionId}`; + const context = buildPreparedCliRunContext({ + provider: "claude-cli", + agentId: "main", + runId: `registry-run-${index}`, + sessionId, + sessionKey, + ...(options.systemPrompt ? { systemPrompt: options.systemPrompt } : {}), + }); + const admission = prepareSystemAgentRunAdmission( + {}, + context.params.runId, + "main", + "registry-test", + ); + admissions.push(admission); + context.params.admittedRunContext = await admission.admit("plugin-harness"); + const grant = options.capture + ? { + transportToken: options.capture.token, + adoptProcessToken: vi.fn(), + revokeProcessToken: vi.fn(), + activate: vi.fn(), + deactivate: vi.fn(), + } + : undefined; + if (grant) { + context.preparedBackend.mcpClientGrantCapture = grant; + } + const beginCapture = vi.fn(); + const capability: CliBackendLiveSessionCapability = createCliLiveSessionCapability({ + context, + argv: ["claude", "-p"], + env: { PATH: "/usr/bin:/bin" }, + beginCapture, + abortSignal: new AbortController().signal, + ...(options.cleanup ? { claimResources: () => options.cleanup } : {}), + ...(options.capture ? { captureKey: options.capture.key } : {}), + ...(options.requiredGeneration ? { requiredGeneration: options.requiredGeneration } : {}), + }); + const exited = createDeferred(); + const close = vi.fn(() => { + capability.remove(session); + if (!options.deferExit) { + exited.resolve(); + } + }); + const waitForExit = vi.fn(() => exited.promise); + const session: CliBackendLiveSessionHandle = { + generation: options.generation ?? `generation-${index}`, + fingerprint: capability.fingerprint, + isIdle: vi.fn(() => options.idle ?? false), + close, + waitForExit, + }; + const register = () => { + capability.register(session); + sessions.add(session); + return session; + }; + return { + admission, + beginCapture, + capability, + close, + context, + exited, + grant, + register, + session, + sessionId, + sessionKey, + waitForExit, + }; +} + +afterEach(() => { + for (const session of sessions) { + session.close("restart"); + } + sessions.clear(); + for (const admission of admissions.splice(0)) { + admission.close(); + } + vi.restoreAllMocks(); +}); + +describe("generic plugin-owned live session registry", () => { + it("keeps owner identity deterministic and isolated across sessions", () => { + const owner = { + agentAccountId: "acct-1", + agentId: "agent-main", + authProfileId: "profile-a", + sessionId: "sess-1", + sessionKey: "key-a", + }; + + expect(buildCliLiveOwnerKey({ ...owner })).toBe(buildCliLiveOwnerKey(owner)); + expect(buildCliLiveOwnerKey({ ...owner, sessionKey: "key-b" })).not.toBe( + buildCliLiveOwnerKey(owner), + ); + }); + + it("keeps fresh and resumed process fingerprints identical without hiding prompt changes", () => { + const fresh = buildPreparedCliRunContext({ systemPrompt: "Original system policy." }); + const resumed = buildPreparedCliRunContext({ systemPrompt: "Original system policy." }); + const changed = buildPreparedCliRunContext({ systemPrompt: "Changed system policy." }); + const env = { PATH: "/usr/bin:/bin" }; + const freshFingerprint = buildCliLiveSessionFingerprint({ + context: fresh, + argv: ["claude", "-p", "--session-id", "native-session"], + env, + }); + + expect( + buildCliLiveSessionFingerprint({ + context: resumed, + argv: ["claude", "-p", "--resume", "native-session"], + env, + }), + ).toBe(freshFingerprint); + expect( + buildCliLiveSessionFingerprint({ + context: changed, + argv: ["claude", "-p", "--resume", "native-session"], + env, + }), + ).not.toBe(freshFingerprint); + }); + + it("exposes only an active registered generation and never revives a removed owner", async () => { + const owner = await createOwner({ generation: "generation-exact" }); + const identity = { + backendId: "claude-cli", + agentId: "main", + sessionId: owner.sessionId, + sessionKey: owner.sessionKey, + }; + + expect(hasCliLiveSession(identity)).toBe(false); + owner.register(); + expect(hasCliLiveSession(identity)).toBe(true); + expect(getCliLiveSessionGeneration(identity)).toBe("generation-exact"); + + owner.capability.remove(owner.session); + expect(owner.capability.current()).toBeUndefined(); + expect(hasCliLiveSession(identity)).toBe(false); + }); + + it("rejects registration once its exact admitted run has closed", async () => { + const owner = await createOwner(); + owner.admission.close(); + + expect(() => owner.register()).toThrow("no longer active"); + expect( + hasCliLiveSession({ + backendId: "claude-cli", + agentId: "main", + sessionId: owner.sessionId, + sessionKey: owner.sessionKey, + }), + ).toBe(false); + }); + + it("rejects the same process handle under a different owner despite a matching fingerprint", async () => { + const original = await createOwner({ sessionId: "original-owner" }); + const other = await createOwner({ sessionId: "different-owner" }); + original.register(); + + expect(other.capability.fingerprint).toBe(original.capability.fingerprint); + expect(() => other.capability.register(original.session)).toThrow(); + expect(other.capability.current()).toBeUndefined(); + expect(original.capability.current()).toBe(original.session); + }); + + it("rejects required generation reuse after prompt changes without closing its only process", async () => { + const original = await createOwner({ + sessionId: "required-prompt-owner", + generation: "required-generation", + systemPrompt: "Original system policy.", + }); + original.register(); + const changed = await createOwner({ + sessionId: "required-prompt-owner", + requiredGeneration: "required-generation", + systemPrompt: "Changed system policy.", + }); + + expect(changed.capability.fingerprint).not.toBe(original.capability.fingerprint); + expect(() => changed.capability.current()).toThrow( + expect.objectContaining({ reason: "session_expired", code: "cli_live_session_changed" }), + ); + expect(original.close).not.toHaveBeenCalled(); + expect(original.capability.current()).toBe(original.session); + }); + + it("transfers admitted MCP authority to the original private process before capture", async () => { + const original = await createOwner({ + sessionId: "captured-owner", + capture: { token: "process-token-a", key: "capture-a" }, + }); + original.register(); + const resumed = await createOwner({ + sessionId: "captured-owner", + capture: { token: "turn-token-b", key: "capture-b" }, + }); + + resumed.capability.activate(original.session); + + expect(resumed.grant?.adoptProcessToken).toHaveBeenCalledExactlyOnceWith("process-token-a"); + expect(resumed.beginCapture).toHaveBeenCalledExactlyOnceWith("capture-a"); + expect(resumed.grant?.adoptProcessToken.mock.invocationCallOrder[0]).toBeLessThan( + resumed.beginCapture.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); + expect(Object.keys(resumed.capability)).not.toEqual( + expect.arrayContaining(["ownerKey", "transportToken", "captureKey"]), + ); + + resumed.capability.remove(original.session); + resumed.capability.remove(original.session); + expect(original.grant?.revokeProcessToken).toHaveBeenCalledOnce(); + expect(resumed.grant?.revokeProcessToken).not.toHaveBeenCalled(); + expect(original.capability.current()).toBeUndefined(); + }); + + it("fences MCP capture when its admitted authority closes during process transfer", async () => { + const original = await createOwner({ + sessionId: "transfer-closed-owner", + capture: { token: "original-process-token", key: "original-capture" }, + }); + original.register(); + const resumed = await createOwner({ + sessionId: "transfer-closed-owner", + capture: { token: "replacement-turn-token", key: "replacement-capture" }, + }); + resumed.grant?.adoptProcessToken.mockImplementation(() => resumed.admission.close()); + + expect(() => resumed.capability.activate(original.session)).toThrow("no longer active"); + + expect(resumed.grant?.adoptProcessToken).toHaveBeenCalledExactlyOnceWith( + "original-process-token", + ); + expect(resumed.beginCapture).not.toHaveBeenCalled(); + expect(original.capability.current()).toBe(original.session); + }); + + it.each([ + { + name: "a captured process cannot resume without an admitted turn grant", + originalCapture: { token: "captured-process-token", key: "captured-process-key" }, + resumedCapture: undefined, + }, + { + name: "an uncaptured process cannot inherit a newly admitted turn grant", + originalCapture: undefined, + resumedCapture: { token: "new-turn-token", key: "new-turn-key" }, + }, + ])("$name", async ({ originalCapture, resumedCapture }) => { + const sessionId = "changed-capture-topology-owner"; + const original = await createOwner({ + sessionId, + ...(originalCapture ? { capture: originalCapture } : {}), + }); + original.register(); + const resumed = await createOwner({ + sessionId, + ...(resumedCapture ? { capture: resumedCapture } : {}), + }); + + expect(() => resumed.capability.activate(original.session)).toThrow("MCP topology changed"); + expect(resumed.beginCapture).not.toHaveBeenCalled(); + if (resumed.grant) { + expect(resumed.grant.adoptProcessToken).not.toHaveBeenCalled(); + } + expect(original.capability.current()).toBe(original.session); + }); + + it("keeps claimed native skill resources until subprocess exit and cleans exactly once", async () => { + const cleanup = vi.fn(async () => {}); + const owner = await createOwner({ deferExit: true, cleanup }); + owner.register(); + + const closing = closeCliLiveSession(owner.context, "restart"); + owner.capability.remove(owner.session); + await Promise.resolve(); + expect(cleanup).not.toHaveBeenCalled(); + + owner.exited.resolve(); + await closing; + + expect(owner.close).toHaveBeenCalledWith("restart"); + expect(owner.waitForExit).toHaveBeenCalledOnce(); + expect(cleanup).toHaveBeenCalledOnce(); + }); + + it("evicts an idle owner at capacity and fails closed when every owner is active", async () => { + const owners = []; + for (let index = 0; index < 16; index += 1) { + const owner = await createOwner({ idle: index === 0 }); + owner.register(); + owners.push(owner); + } + + const replacement = await createOwner(); + expect(() => replacement.register()).not.toThrow(); + expect(owners[0]?.close).toHaveBeenCalledWith("idle"); + + const overflow = await createOwner(); + expect(() => overflow.register()).toThrow("Too many CLI live sessions are active."); + }); + + it("admits only local plugin-owned structured execution to reusable sessions", () => { + const eligible = buildPreparedCliRunContext({ backend: { liveSession: "claude-stdio" } }); + eligible.preparedBackend.execute = async function* () { + yield { type: "result" }; + }; + + expect(acceptsCliLiveSession(eligible)).toBe(true); + + const node = buildPreparedCliRunContext({ + backend: { liveSession: "claude-stdio" }, + sessionEntry: { sessionId: "node-session", updatedAt: 1, execHost: "node" }, + }); + node.preparedBackend.execute = eligible.preparedBackend.execute; + expect(acceptsCliLiveSession(node)).toBe(false); + + delete eligible.preparedBackend.execute; + expect(acceptsCliLiveSession(eligible)).toBe(false); + }); +}); diff --git a/src/agents/cli-runner/cli-live-session-registry.ts b/src/agents/cli-runner/cli-live-session-registry.ts new file mode 100644 index 000000000000..5703c940cd8c --- /dev/null +++ b/src/agents/cli-runner/cli-live-session-registry.ts @@ -0,0 +1,263 @@ +import { sha256Hex } from "../../infra/crypto-digest.js"; +import type { + CliBackendLiveSessionCapability, + CliBackendLiveSessionCloseReason, + CliBackendLiveSessionHandle, +} from "../../plugins/cli-backend.types.js"; +import { resolveAdmittedRunActiveAssertion } from "../admitted-run-context.js"; +import { createCliFailoverError } from "./exit-error.js"; +import { buildCliLiveSessionFingerprint } from "./live-session-fingerprint.js"; +import { cliBackendLog } from "./log.js"; +import type { PreparedCliRunContext } from "./types.js"; + +const MAX_LIVE_SESSIONS = 16; + +type CliLiveSessionOwner = { + backendId: string; + agentAccountId?: string; + agentId?: string; + authProfileId?: string; + sessionId?: string; + sessionKey?: string; +}; + +type CliLiveSessionRecord = { + handle: CliBackendLiveSessionHandle; + approvalGrants: Set; + cleanup?: () => Promise; + cleanupPromise?: Promise; + capture?: { + token: string; + key: string; + revoke: () => void; + }; +}; + +const liveSessions = new Map(); + +function buildCliLiveRegistryKey(owner: CliLiveSessionOwner): string { + return `${owner.backendId}:${buildCliLiveOwnerKey(owner)}`; +} + +/** Hashes the account/agent/auth/session tuple shared by queue and registry ownership. */ +export function buildCliLiveOwnerKey(input: Omit): string { + return sha256Hex( + JSON.stringify({ + agentAccountId: input.agentAccountId, + agentId: input.agentId, + authProfileId: input.authProfileId, + sessionId: input.sessionId, + sessionKey: input.sessionKey, + }), + ); +} + +function buildCliLiveSessionKey(context: PreparedCliRunContext): string { + return buildCliLiveRegistryKey({ + backendId: context.backendResolved.id, + agentAccountId: context.params.agentAccountId, + agentId: context.params.agentId, + authProfileId: context.effectiveAuthProfileId, + sessionId: context.params.sessionId, + sessionKey: context.params.sessionKey, + }); +} + +/** Returns whether this owner still has an in-process plugin-owned session. */ +export function hasCliLiveSession(owner: CliLiveSessionOwner): boolean { + return getCliLiveSessionGeneration(owner) !== undefined; +} + +/** Returns the opaque generation of this owner's registered execution session. */ +export function getCliLiveSessionGeneration(owner: CliLiveSessionOwner): string | undefined { + return liveSessions.get(buildCliLiveRegistryKey(owner))?.handle.generation; +} + +/** Reads owner-private standing approvals only from this exact current live process. */ +export function getCliLiveSessionApprovalGrants( + context: PreparedCliRunContext, +): Set | undefined { + return liveSessions.get(buildCliLiveSessionKey(context))?.approvalGrants; +} + +/** Closes the live execution session associated with a prepared run context, if one exists. */ +export async function closeCliLiveSession( + context: PreparedCliRunContext, + reason: CliBackendLiveSessionCloseReason, +): Promise { + const record = liveSessions.get(buildCliLiveSessionKey(context)); + if (!record) { + return; + } + // close removes its registry record synchronously; retain the private record + // until its original child exits and process-owned artifacts finish cleanup. + record.handle.close(reason); + await (record.cleanupPromise ?? record.handle.waitForExit()); +} + +function ensureCliLiveSessionCapacity(context: PreparedCliRunContext): void { + if (liveSessions.size < MAX_LIVE_SESSIONS) { + return; + } + for (const { handle } of liveSessions.values()) { + if (handle.isIdle()) { + handle.close("idle"); + return; + } + } + throw createCliFailoverError("Too many CLI live sessions are active.", "rate_limit", { + provider: context.params.provider, + model: context.modelId, + sessionId: context.params.sessionId, + lane: context.params.lane, + }); +} + +/** Returns whether this prepared local plugin transport may retain its execution process. */ +export function acceptsCliLiveSession(context: PreparedCliRunContext): boolean { + return ( + context.params.sessionEntry?.execHost !== "node" && + Boolean(context.preparedBackend.execute) && + context.preparedBackend.backend.liveSession !== undefined && + context.preparedBackend.backend.output === "jsonl" && + context.preparedBackend.backend.input === "stdin" + ); +} + +/** Creates host-owned lifecycle authority without exposing owner keys or bearer material. */ +export function createCliLiveSessionCapability(params: { + context: PreparedCliRunContext; + argv: readonly string[]; + env: Record; + captureKey?: string; + beginCapture: (captureKey: string | undefined) => void; + abortSignal: AbortSignal; + requiredGeneration?: string; + claimResources?: () => (() => Promise) | undefined; +}): CliBackendLiveSessionCapability { + const ownerKey = buildCliLiveSessionKey(params.context); + const fingerprint = buildCliLiveSessionFingerprint({ + context: params.context, + argv: params.argv, + env: params.env, + }); + const grant = params.context.preparedBackend.mcpClientGrantCapture; + if (Boolean(grant) !== Boolean(params.captureKey)) { + throw new Error("CLI live process and current turn disagree about MCP capture ownership."); + } + + const requiredSessionError = (code: "cli_live_session_changed" | "cli_live_session_missing") => + createCliFailoverError( + "Managed CLI live session is no longer reusable.", + "session_expired", + { + provider: params.context.params.provider, + model: params.context.modelId, + sessionId: params.context.params.sessionId, + lane: params.context.params.lane, + }, + { code }, + ); + const assertActive = () => { + const assertion = resolveAdmittedRunActiveAssertion( + params.context.params.admittedRunContext, + params.abortSignal, + ); + if (!assertion) { + throw new Error("CLI live session turn is no longer active."); + } + assertion(); + }; + const requireRegisteredRecord = (handle: CliBackendLiveSessionHandle) => { + assertActive(); + const record = liveSessions.get(ownerKey); + if (handle.fingerprint !== fingerprint || record?.handle !== handle) { + throw new Error("CLI live session no longer belongs to this admitted run."); + } + if (params.requiredGeneration && params.requiredGeneration !== handle.generation) { + throw requiredSessionError("cli_live_session_changed"); + } + return record; + }; + + return Object.freeze({ + fingerprint, + current: () => { + assertActive(); + const handle = liveSessions.get(ownerKey)?.handle; + if (params.requiredGeneration && handle?.generation !== params.requiredGeneration) { + throw requiredSessionError( + handle ? "cli_live_session_changed" : "cli_live_session_missing", + ); + } + if (params.requiredGeneration && handle?.fingerprint !== fingerprint) { + throw requiredSessionError("cli_live_session_changed"); + } + return handle; + }, + register: (handle) => { + assertActive(); + if (params.requiredGeneration) { + throw requiredSessionError("cli_live_session_changed"); + } + if ( + handle.fingerprint !== fingerprint || + !handle.generation.trim() || + liveSessions.has(ownerKey) || + // Owner keys stay private; one process handle must never cross owners. + Array.from(liveSessions.values()).some((record) => record.handle === handle) + ) { + throw new Error("CLI live session registration does not match its admitted owner."); + } + ensureCliLiveSessionCapacity(params.context); + const cleanup = params.claimResources?.(); + const record: CliLiveSessionRecord = { + handle, + approvalGrants: new Set(), + ...(cleanup ? { cleanup } : {}), + ...(grant && params.captureKey + ? { + capture: { + token: grant.transportToken, + key: params.captureKey, + revoke: grant.revokeProcessToken, + }, + } + : {}), + }; + liveSessions.set(ownerKey, record); + cliBackendLog.info( + `cli live session start: provider=${params.context.backendResolved.id} model=${params.context.normalizedModel} activeSessions=${liveSessions.size}`, + ); + }, + activate: (handle) => { + const record = requireRegisteredRecord(handle); + if (Boolean(record.capture) !== Boolean(grant)) { + throw new Error("CLI live session MCP topology changed across admitted turns."); + } + if (record.capture && grant) { + // Transfer the exact current admission before activating the original + // child capture header; copied bearers never carry authority alone. + grant.adoptProcessToken(record.capture.token); + requireRegisteredRecord(handle); + params.beginCapture(record.capture.key); + } + }, + remove: (handle) => { + const record = liveSessions.get(ownerKey); + if (record?.handle !== handle) { + return; + } + record.capture?.revoke(); + liveSessions.delete(ownerKey); + record.approvalGrants.clear(); + if (record.cleanup) { + // Native runtime artifacts remain process-owned until its child exits. + record.cleanupPromise = handle.waitForExit().then(record.cleanup); + void record.cleanupPromise.catch((error: unknown) => { + cliBackendLog.warn(`cli live session cleanup failed: ${String(error)}`); + }); + } + }, + }); +} diff --git a/src/agents/cli-runner/claude-live-tool-approval.test.ts b/src/agents/cli-runner/cli-native-tool-approval.test.ts similarity index 86% rename from src/agents/cli-runner/claude-live-tool-approval.test.ts rename to src/agents/cli-runner/cli-native-tool-approval.test.ts index 7077d8168ea1..8c8b239d9b66 100644 --- a/src/agents/cli-runner/claude-live-tool-approval.test.ts +++ b/src/agents/cli-runner/cli-native-tool-approval.test.ts @@ -9,9 +9,9 @@ import { import { APPROVAL_SCRIPT_OPERAND_DRIFT_DENIED_MESSAGE } from "../../infra/system-run-approval-binding.js"; import { callGatewayTool } from "../tools/gateway.js"; import { - requestClaudeNativeToolApproval, - resolveClaudeNativeToolApprovalPlan, -} from "./claude-live-tool-approval.js"; + requestCliNativeToolApproval, + resolveCliNativeToolApprovalPlan, +} from "./cli-native-tool-approval.js"; vi.mock("../tools/gateway.js", () => ({ callGatewayTool: vi.fn(), @@ -25,7 +25,7 @@ afterEach(() => { vi.useRealTimers(); }); -describe("resolveClaudeNativeToolApprovalPlan", () => { +describe("resolveCliNativeToolApprovalPlan", () => { it.each([ ["deny", "off", "deny"], ["deny", "on-miss", "deny"], @@ -38,18 +38,18 @@ describe("resolveClaudeNativeToolApprovalPlan", () => { ["full", "on-miss", "prompt"], ["full", "always", "prompt"], ] as const)("resolves security=%s ask=%s to %s", (security, ask, expected) => { - expect(resolveClaudeNativeToolApprovalPlan({ security, ask })).toBe(expected); + expect(resolveCliNativeToolApprovalPlan({ security, ask })).toBe(expected); }); }); -describe("requestClaudeNativeToolApproval", () => { +describe("requestCliNativeToolApproval", () => { it("registers and waits for a matching approval decision", async () => { mockCallGatewayTool .mockResolvedValueOnce({ id: "approval-1", status: "pending" }) .mockResolvedValueOnce({ id: "approval-1", decision: "allow-once" }); await expect( - requestClaudeNativeToolApproval({ + requestCliNativeToolApproval({ toolName: "Bash", toolInput: { command: "ls" }, pluginId: "claude-cli", @@ -71,7 +71,7 @@ describe("requestClaudeNativeToolApproval", () => { toolCallId: "tool-1", agentId: "main", sessionKey: "agent:main:main", - title: "Claude native tool: Bash", + title: "claude-cli native tool: Bash", description: '{"command":"ls"}', detail: '{"command":"ls"}', severity: "warning", @@ -97,7 +97,7 @@ describe("requestClaudeNativeToolApproval", () => { }); await expect( - requestClaudeNativeToolApproval({ + requestCliNativeToolApproval({ toolName: "WebFetch", toolInput: { url: "https://example.com" }, pluginId: "claude-cli", @@ -107,13 +107,34 @@ describe("requestClaudeNativeToolApproval", () => { expect(mockCallGatewayTool).toHaveBeenCalledOnce(); }); + it("identifies the owning backend when another provider requests native approval", async () => { + mockCallGatewayTool.mockResolvedValueOnce({ + id: "approval-other-provider", + decision: "allow-once", + }); + + await expect( + requestCliNativeToolApproval({ + toolName: "Read", + toolInput: { file_path: "/tmp/example.txt" }, + pluginId: "gemini-cli", + ask: "on-miss", + }), + ).resolves.toEqual({ kind: "allow", grantAlways: false }); + + expect(mockCallGatewayTool.mock.calls[0]?.[2]).toMatchObject({ + pluginId: "gemini-cli", + title: "gemini-cli native tool: Read", + }); + }); + it("fails closed when the approval wait times out", async () => { mockCallGatewayTool .mockResolvedValueOnce({ id: "approval-3" }) .mockRejectedValueOnce(new Error("gateway timeout")); await expect( - requestClaudeNativeToolApproval({ + requestCliNativeToolApproval({ toolName: "Bash", toolInput: { command: "ls" }, pluginId: "claude-cli", @@ -126,7 +147,7 @@ describe("requestClaudeNativeToolApproval", () => { mockCallGatewayTool.mockRejectedValueOnce(new Error("gateway unavailable")); await expect( - requestClaudeNativeToolApproval({ + requestCliNativeToolApproval({ toolName: "Bash", toolInput: { command: "ls" }, pluginId: "claude-cli", @@ -140,7 +161,7 @@ describe("requestClaudeNativeToolApproval", () => { mockCallGatewayTool .mockResolvedValueOnce({ id: "approval-4" }) .mockImplementationOnce(() => new Promise(() => {})); - const approval = requestClaudeNativeToolApproval({ + const approval = requestCliNativeToolApproval({ toolName: "Bash", toolInput: { command: "ls" }, pluginId: "claude-cli", @@ -156,7 +177,7 @@ describe("requestClaudeNativeToolApproval", () => { it("fails closed when the run aborts while registering the approval", async () => { const abortController = new AbortController(); mockCallGatewayTool.mockImplementationOnce(() => new Promise(() => {})); - const approval = requestClaudeNativeToolApproval({ + const approval = requestCliNativeToolApproval({ toolName: "Bash", toolInput: { command: "ls" }, pluginId: "claude-cli", @@ -175,7 +196,7 @@ describe("requestClaudeNativeToolApproval", () => { const content = `safe-prefix ${"x".repeat(500)} destructive-tail`; await expect( - requestClaudeNativeToolApproval({ + requestCliNativeToolApproval({ toolName: "Write", toolInput: { file_path: "/tmp/output.txt", content }, pluginId: "claude-cli", @@ -200,7 +221,7 @@ describe("requestClaudeNativeToolApproval", () => { mockCallGatewayTool.mockResolvedValueOnce({ id: "approval-5b", decision: "allow-always" }); await expect( - requestClaudeNativeToolApproval({ + requestCliNativeToolApproval({ toolName: "Bash", toolInput: { command: "ls" }, pluginId: "claude-cli", @@ -216,7 +237,7 @@ describe("requestClaudeNativeToolApproval", () => { }); it("checks Bash script drift before rejecting an unexpected allow-always", async () => { - const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-claude-always-drift-")); + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-always-drift-")); const script = path.join(cwd, "script.sh"); try { fs.writeFileSync(script, "#!/bin/sh\necho approved\n"); @@ -226,7 +247,7 @@ describe("requestClaudeNativeToolApproval", () => { }); await expect( - requestClaudeNativeToolApproval({ + requestCliNativeToolApproval({ toolName: "Bash", toolInput: { command: "sh script.sh" }, pluginId: "claude-cli", @@ -247,7 +268,7 @@ describe("requestClaudeNativeToolApproval", () => { // Channel/push approvers never see the reviewer detail, so a Bash command // hidden by description truncation must not be approvable from anywhere. await expect( - requestClaudeNativeToolApproval({ + requestCliNativeToolApproval({ toolName: "Bash", toolInput: { command: `echo ${"x".repeat(500)}; rm -rf /tmp/example` }, pluginId: "claude-cli", @@ -259,7 +280,7 @@ describe("requestClaudeNativeToolApproval", () => { it("denies Bash input beyond the reviewer detail limit without calling the gateway", async () => { await expect( - requestClaudeNativeToolApproval({ + requestCliNativeToolApproval({ toolName: "Bash", toolInput: { command: "x".repeat(PLUGIN_APPROVAL_DETAIL_MAX_LENGTH) }, pluginId: "claude-cli", @@ -273,7 +294,7 @@ describe("requestClaudeNativeToolApproval", () => { // ~70 bidi override chars stay under the raw description budget but escape // to \u{202E} sequences that overflow the 512-char channel summary. await expect( - requestClaudeNativeToolApproval({ + requestCliNativeToolApproval({ toolName: "Bash", toolInput: { command: `echo ${"‮".repeat(70)}; rm -rf /tmp/example` }, pluginId: "claude-cli", @@ -285,7 +306,7 @@ describe("requestClaudeNativeToolApproval", () => { it("denies Bash when reviewer sanitization would hide the command tail", async () => { await expect( - requestClaudeNativeToolApproval({ + requestCliNativeToolApproval({ toolName: "Bash", toolInput: { command: `# ${"\u202e".repeat(3_000)}\necho destructive-tail` }, pluginId: "claude-cli", @@ -299,7 +320,7 @@ describe("requestClaudeNativeToolApproval", () => { mockCallGatewayTool.mockResolvedValueOnce({ id: "approval-5c", decision: "deny" }); await expect( - requestClaudeNativeToolApproval({ + requestCliNativeToolApproval({ toolName: "WebFetch", toolInput: { url: "https://example.com" }, pluginId: "claude-cli", @@ -315,7 +336,7 @@ describe("requestClaudeNativeToolApproval", () => { mockCallGatewayTool.mockResolvedValueOnce({ id: "approval-6", decision: "deny" }); const toolName = `mcp__claude-in-chrome__${"long-tool-segment-".repeat(6)}`; - await requestClaudeNativeToolApproval({ + await requestCliNativeToolApproval({ toolName, toolInput: {}, pluginId: "claude-cli", @@ -326,14 +347,14 @@ describe("requestClaudeNativeToolApproval", () => { | { title?: unknown; toolName?: unknown } | undefined; expect(requestPayload?.title).toHaveLength(80); - expect(requestPayload?.title).toMatch(/^Claude native tool: /u); + expect(requestPayload?.title).toMatch(/^claude-cli native tool: /u); expect(requestPayload?.toolName).toBe(toolName); }); it("uses an object fallback when JSON serialization returns undefined", async () => { mockCallGatewayTool.mockResolvedValueOnce({ id: "approval-7", decision: "deny" }); - await requestClaudeNativeToolApproval({ + await requestCliNativeToolApproval({ toolName: "WebFetch", toolInput: { toJSON: () => undefined }, pluginId: "claude-cli", diff --git a/src/agents/cli-runner/claude-live-tool-approval.ts b/src/agents/cli-runner/cli-native-tool-approval.ts similarity index 72% rename from src/agents/cli-runner/claude-live-tool-approval.ts rename to src/agents/cli-runner/cli-native-tool-approval.ts index 91b25f9c9aa3..06e6aecf064c 100644 --- a/src/agents/cli-runner/claude-live-tool-approval.ts +++ b/src/agents/cli-runner/cli-native-tool-approval.ts @@ -15,9 +15,9 @@ import { import { sliceUtf16Safe, truncateUtf16Safe } from "../../utils.js"; import { callGatewayTool } from "../tools/gateway.js"; -type ClaudeNativeToolApprovalPlan = "allow" | "deny" | "prompt"; -type ClaudeNativeToolApprovalDecision = "allow-once" | "allow-always" | "deny"; -type ClaudeNativeToolApprovalOutcome = +type CliNativeToolApprovalPlan = "allow" | "deny" | "prompt"; +type CliNativeToolApprovalDecision = "allow-once" | "allow-always" | "deny"; +type CliNativeToolApprovalOutcome = | { kind: "allow"; grantAlways: boolean } | { kind: "deny"; @@ -25,30 +25,30 @@ type ClaudeNativeToolApprovalOutcome = message?: string; }; -const CLAUDE_NATIVE_TOOL_DESCRIPTION_HEAD_CHARS = 300; -const CLAUDE_NATIVE_TOOL_DESCRIPTION_TAIL_CHARS = 80; -const CLAUDE_NATIVE_TOOL_DESCRIPTION_MAX_CHARS = - CLAUDE_NATIVE_TOOL_DESCRIPTION_HEAD_CHARS + CLAUDE_NATIVE_TOOL_DESCRIPTION_TAIL_CHARS; -const CLAUDE_NATIVE_TOOL_APPROVAL_GATEWAY_GRACE_MS = 10_000; -const CLAUDE_NATIVE_TOOL_ALLOWED_DECISIONS = [ +const CLI_NATIVE_TOOL_DESCRIPTION_HEAD_CHARS = 300; +const CLI_NATIVE_TOOL_DESCRIPTION_TAIL_CHARS = 80; +const CLI_NATIVE_TOOL_DESCRIPTION_MAX_CHARS = + CLI_NATIVE_TOOL_DESCRIPTION_HEAD_CHARS + CLI_NATIVE_TOOL_DESCRIPTION_TAIL_CHARS; +const CLI_NATIVE_TOOL_APPROVAL_GATEWAY_GRACE_MS = 10_000; +const CLI_NATIVE_TOOL_ALLOWED_DECISIONS = [ "allow-once", "allow-always", "deny", -] as const satisfies readonly ClaudeNativeToolApprovalDecision[]; +] as const satisfies readonly CliNativeToolApprovalDecision[]; // A standing grant must never be minted from a partially displayed input, so // oversized inputs offer one-shot decisions only. -const CLAUDE_NATIVE_TOOL_TRUNCATED_DECISIONS = [ +const CLI_NATIVE_TOOL_TRUNCATED_DECISIONS = [ "allow-once", "deny", -] as const satisfies readonly ClaudeNativeToolApprovalDecision[]; -// Claude Code's Bash tool is arbitrary shell execution, so a name-wide grant is unrestricted. +] as const satisfies readonly CliNativeToolApprovalDecision[]; +// Bash is arbitrary shell execution, so a name-wide grant is unrestricted. // Bash fails closed when even the reviewer-only detail cannot show the complete input. -const CLAUDE_NATIVE_TOOL_ARBITRARY_EXECUTION_TOOL = "Bash"; +const CLI_NATIVE_TOOL_ARBITRARY_EXECUTION_TOOL = "Bash"; -export function resolveClaudeNativeToolApprovalPlan(execPermission: { +export function resolveCliNativeToolApprovalPlan(execPermission: { security: ExecSecurity; ask: ExecAsk; -}): ClaudeNativeToolApprovalPlan { +}): CliNativeToolApprovalPlan { if (execPermission.security === "deny") { return "deny"; } @@ -60,7 +60,7 @@ export function resolveClaudeNativeToolApprovalPlan(execPermission: { return "prompt"; } -type ClaudeNativeToolDescription = { compact: string; text: string; truncated: boolean }; +type CliNativeToolDescription = { compact: string; text: string; truncated: boolean }; /** * The gateway caps approval descriptions (PLUGIN_APPROVAL_DESCRIPTION_MAX_LENGTH), @@ -69,15 +69,15 @@ type ClaudeNativeToolDescription = { compact: string; text: string; truncated: b * view an explicit operator decision. Accepted tradeoff: the middle stays * unreviewable; oversized inputs therefore never earn allow-always. */ -function formatClaudeNativeToolDescription( +function formatCliNativeToolDescription( toolInput: Record, -): ClaudeNativeToolDescription { +): CliNativeToolDescription { const compact = JSON.stringify(toolInput) ?? "{}"; - if (compact.length <= CLAUDE_NATIVE_TOOL_DESCRIPTION_MAX_CHARS) { + if (compact.length <= CLI_NATIVE_TOOL_DESCRIPTION_MAX_CHARS) { return { compact, text: compact, truncated: false }; } - const head = truncateUtf16Safe(compact, CLAUDE_NATIVE_TOOL_DESCRIPTION_HEAD_CHARS); - const tail = sliceUtf16Safe(compact, compact.length - CLAUDE_NATIVE_TOOL_DESCRIPTION_TAIL_CHARS); + const head = truncateUtf16Safe(compact, CLI_NATIVE_TOOL_DESCRIPTION_HEAD_CHARS); + const tail = sliceUtf16Safe(compact, compact.length - CLI_NATIVE_TOOL_DESCRIPTION_TAIL_CHARS); const hiddenChars = compact.length - head.length - tail.length; return { compact, @@ -86,27 +86,30 @@ function formatClaudeNativeToolDescription( }; } -function formatClaudeNativeToolTitle(toolName: string): string { - return truncateUtf16Safe(`Claude native tool: ${toolName}`, PLUGIN_APPROVAL_TITLE_MAX_LENGTH); +function formatCliNativeToolTitle(pluginId: string, toolName: string): string { + return truncateUtf16Safe( + `${pluginId} native tool: ${toolName}`, + PLUGIN_APPROVAL_TITLE_MAX_LENGTH, + ); } -function resolveClaudeNativeToolAllowedDecisions(params: { +function resolveCliNativeToolAllowedDecisions(params: { ask: ExecAsk; toolName: string; descriptionTruncated: boolean; -}): readonly ClaudeNativeToolApprovalDecision[] { +}): readonly CliNativeToolApprovalDecision[] { return params.ask === "always" || - params.toolName === CLAUDE_NATIVE_TOOL_ARBITRARY_EXECUTION_TOOL || + params.toolName === CLI_NATIVE_TOOL_ARBITRARY_EXECUTION_TOOL || params.descriptionTruncated - ? CLAUDE_NATIVE_TOOL_TRUNCATED_DECISIONS - : CLAUDE_NATIVE_TOOL_ALLOWED_DECISIONS; + ? CLI_NATIVE_TOOL_TRUNCATED_DECISIONS + : CLI_NATIVE_TOOL_ALLOWED_DECISIONS; } function toAbortError(reason: unknown): Error { - return reason instanceof Error ? reason : new Error("Claude native tool approval aborted"); + return reason instanceof Error ? reason : new Error("CLI native tool approval aborted"); } -async function raceClaudeNativeToolApprovalAbort( +async function raceCliNativeToolApprovalAbort( promise: Promise, abortSignal: AbortSignal | undefined, ): Promise { @@ -131,25 +134,25 @@ async function raceClaudeNativeToolApprovalAbort( } } -function waitForClaudeNativeToolApproval(params: { +function waitForCliNativeToolApproval(params: { id: string; gatewayTimeoutMs: number; abortSignal?: AbortSignal; }): Promise<{ id?: string; decision?: unknown }> { - return raceClaudeNativeToolApprovalAbort( + return raceCliNativeToolApprovalAbort( callGatewayTool( "plugin.approval.waitDecision", { timeoutMs: params.gatewayTimeoutMs }, { id: params.id }, // Abort must reach the RPC too, or the gateway keeps the approval prompt - // live for its full timeout after the Claude run already ended. + // live for its full timeout after the admitted CLI run already ended. { signal: params.abortSignal }, ), params.abortSignal, ); } -export async function requestClaudeNativeToolApproval(params: { +export async function requestCliNativeToolApproval(params: { toolName: string; toolInput: Record; pluginId: string; @@ -159,23 +162,23 @@ export async function requestClaudeNativeToolApproval(params: { cwd?: string; abortSignal?: AbortSignal; ask: ExecAsk; -}): Promise { +}): Promise { try { const timeoutMs = DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS; const gatewayTimeoutMs = - addTimerTimeoutGraceMs(timeoutMs, CLAUDE_NATIVE_TOOL_APPROVAL_GATEWAY_GRACE_MS) ?? - timeoutMs + CLAUDE_NATIVE_TOOL_APPROVAL_GATEWAY_GRACE_MS; - const description = formatClaudeNativeToolDescription(params.toolInput); + addTimerTimeoutGraceMs(timeoutMs, CLI_NATIVE_TOOL_APPROVAL_GATEWAY_GRACE_MS) ?? + timeoutMs + CLI_NATIVE_TOOL_APPROVAL_GATEWAY_GRACE_MS; + const description = formatCliNativeToolDescription(params.toolInput); const detail = truncatePluginApprovalDetail(description.compact); const detailSanitization = - params.toolName === CLAUDE_NATIVE_TOOL_ARBITRARY_EXECUTION_TOOL + params.toolName === CLI_NATIVE_TOOL_ARBITRARY_EXECUTION_TOOL ? sanitizeExecApprovalWarningTextWithStatus(description.compact) : null; // Sanitization escapes control/bidi characters into longer visible // sequences, so a short raw command can still overflow the 512-char // description bound after sanitization and get truncated at render time. const summarySanitization = - params.toolName === CLAUDE_NATIVE_TOOL_ARBITRARY_EXECUTION_TOOL + params.toolName === CLI_NATIVE_TOOL_ARBITRARY_EXECUTION_TOOL ? sanitizeExecApprovalWarningTextWithStatus(description.text) : null; // Approvals resolve from summary-only surfaces (channel text, push), which @@ -183,7 +186,7 @@ export async function requestClaudeNativeToolApproval(params: { // resolving surface could see less than the complete command: a truncated // description, sanitization-altered display, or post-sanitization overflow. if ( - params.toolName === CLAUDE_NATIVE_TOOL_ARBITRARY_EXECUTION_TOOL && + params.toolName === CLI_NATIVE_TOOL_ARBITRARY_EXECUTION_TOOL && (description.truncated || detailSanitization?.truncated === true || detailSanitization?.oversized === true || @@ -195,12 +198,12 @@ export async function requestClaudeNativeToolApproval(params: { return { kind: "deny", reason: "policy-oversized" }; } const bashCommand = - params.toolName === CLAUDE_NATIVE_TOOL_ARBITRARY_EXECUTION_TOOL && + params.toolName === CLI_NATIVE_TOOL_ARBITRARY_EXECUTION_TOOL && typeof params.toolInput.command === "string" ? params.toolInput.command : undefined; let mutableFileBinding: SystemRunMutableFileBinding | undefined; - if (params.toolName === CLAUDE_NATIVE_TOOL_ARBITRARY_EXECUTION_TOOL) { + if (params.toolName === CLI_NATIVE_TOOL_ARBITRARY_EXECUTION_TOOL) { // Bind script bytes before the out-of-band approval wait. Text-identical // Bash input can otherwise execute a rewritten file after approval. const prepared = await prepareSystemRunMutableFileBinding({ @@ -212,7 +215,7 @@ export async function requestClaudeNativeToolApproval(params: { } mutableFileBinding = prepared.binding.operands.length > 0 ? prepared.binding : undefined; } - const allowedDecisions = resolveClaudeNativeToolAllowedDecisions({ + const allowedDecisions = resolveCliNativeToolAllowedDecisions({ ask: params.ask, toolName: params.toolName, descriptionTruncated: description.truncated, @@ -220,7 +223,7 @@ export async function requestClaudeNativeToolApproval(params: { const requestResult: { id?: string; decision?: unknown; - } = await raceClaudeNativeToolApprovalAbort( + } = await raceCliNativeToolApprovalAbort( callGatewayTool( "plugin.approval.request", { timeoutMs: gatewayTimeoutMs }, @@ -230,7 +233,7 @@ export async function requestClaudeNativeToolApproval(params: { toolCallId: params.toolCallId, agentId: params.agentId, sessionKey: params.sessionKey, - title: formatClaudeNativeToolTitle(params.toolName), + title: formatCliNativeToolTitle(params.pluginId, params.toolName), description: description.text, detail, severity: "warning", @@ -250,7 +253,7 @@ export async function requestClaudeNativeToolApproval(params: { if (Object.hasOwn(requestResult ?? {}, "decision")) { decision = requestResult.decision; } else { - const waitResult = await waitForClaudeNativeToolApproval({ + const waitResult = await waitForCliNativeToolApproval({ id, gatewayTimeoutMs, abortSignal: params.abortSignal, @@ -261,7 +264,7 @@ export async function requestClaudeNativeToolApproval(params: { return { kind: "deny", reason: "unavailable" }; } if ((decision === "allow-once" || decision === "allow-always") && mutableFileBinding) { - // This control response is OpenClaw's last boundary before Claude owns + // This control response is OpenClaw's last boundary before the CLI owns // spawn, so reject bytes that changed during the approval wait. const binding = await revalidateSystemRunMutableFileBinding({ binding: mutableFileBinding, diff --git a/src/agents/cli-runner/cli-run-settlement.ts b/src/agents/cli-runner/cli-run-settlement.ts index a5e6f3d84f7c..04e0acdb8078 100644 --- a/src/agents/cli-runner/cli-run-settlement.ts +++ b/src/agents/cli-runner/cli-run-settlement.ts @@ -28,7 +28,7 @@ import type { PreparedCliRunContext, RunCliAgentParams } from "./types.js"; const log = createSubsystemLogger("agents/cli-runner"); -/** Operator-visible reason recorded on trace attempts and agent_end when a live turn kept partial output. */ +/** Formats the visible terminal reason for an interrupted turn that retained partial output. */ export function formatCliTerminalInterruption(interruption: CliTerminalInterruption): string { return `CLI turn ${interruption.reason} after partial output`; } @@ -181,8 +181,8 @@ export async function settlePreparedCliRun(params: { }; if (runParams.cleanupCliLiveSessionOnRunEnd === true) { try { - const { closeClaudeSession } = await import("./claude-live-registry.js"); - await closeClaudeSession(context, "restart"); + const { closeCliLiveSession } = await import("./cli-live-session-registry.js"); + await closeCliLiveSession(context, "restart"); } catch (error) { recordCleanupError(error); } @@ -494,7 +494,7 @@ export function buildCliRunResult(params: { ? effectiveCliSessionId : undefined; const terminalInterruption = output.terminalInterruption; - // An interrupted live turn closed its process, so its native continuity is dead. + // An interrupted process cannot preserve its now-invalid native session binding. const cliSessionBindingCleared = terminalInterruption !== undefined || sessionBindingDisabled || diff --git a/src/agents/cli-runner/cli-run-transcript.ts b/src/agents/cli-runner/cli-run-transcript.ts index 303909bb88f4..e18655409f96 100644 --- a/src/agents/cli-runner/cli-run-transcript.ts +++ b/src/agents/cli-runner/cli-run-transcript.ts @@ -34,7 +34,7 @@ export function buildCliHookUserMessage(prompt: string): unknown { }; } -/** Interrupted turns persist as aborted so replayed history never reads partial text as a finished reply. */ +/** Interrupted turns persist as aborted so replayed history never treats partial text as complete. */ export function resolveCliAssistantStopReason(output: CliOutput): StopReason { return output.terminalInterruption ? "aborted" : "stop"; } diff --git a/src/agents/cli-runner/execute-events.tool-result-args.test.ts b/src/agents/cli-runner/execute-events.tool-result-args.test.ts index 1dac3519eebd..ad81c764e457 100644 --- a/src/agents/cli-runner/execute-events.tool-result-args.test.ts +++ b/src/agents/cli-runner/execute-events.tool-result-args.test.ts @@ -41,6 +41,7 @@ function buildContext(runId: string): PreparedCliRunContext { systemPrompt: "system", systemPromptReport: {} as PreparedCliRunContext["systemPromptReport"], bootstrapPromptWarningLines: [], + claudeSkillsPluginArgs: [], authEpochVersion: 2, } as PreparedCliRunContext; } diff --git a/src/agents/cli-runner/execute-plugin.test.ts b/src/agents/cli-runner/execute-plugin.test.ts new file mode 100644 index 000000000000..dca0f5f2e2f6 --- /dev/null +++ b/src/agents/cli-runner/execute-plugin.test.ts @@ -0,0 +1,913 @@ +import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "@openclaw/ai/internal/shared"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createDeferred } from "../../../test/helpers/promise.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { + CliBackendExecute, + CliBackendExecuteContext, + CliBackendLiveSessionHandle, + CliBackendToolPermissionResult, +} from "../../plugins/cli-backend.types.js"; +import { prepareSystemAgentRunAdmission } from "../admitted-run-context.js"; +import { buildPreparedCliRunContext } from "../cli-runner.test-helpers.js"; +import { callGatewayTool } from "../tools/gateway.js"; +import { + closeCliLiveSession, + createCliLiveSessionCapability, +} from "./cli-live-session-registry.js"; +import { executePluginOwnedProcess } from "./execute-plugin.js"; +import type { PreparedCliRunContext, RunCliAgentParams } from "./types.js"; + +vi.mock("../tools/gateway.js", () => ({ + callGatewayTool: vi.fn(), +})); + +const mockCallGatewayTool = vi.mocked(callGatewayTool); +const activeAdmissions: Array> = []; +const activeSessions = new Set(); +let nextRunId = 0; + +const SUCCESS_RESULT = { + type: "result", + subtype: "success", + is_error: false, + result: "completed", + session_id: "sdk-session", +}; + +async function createExecution( + options: { + config?: OpenClawConfig; + sessionEntry?: RunCliAgentParams["sessionEntry"]; + nativeTools?: string[]; + abortSignal?: AbortSignal; + timeoutMs?: number; + runId?: string; + resumeArgs?: string[]; + } = {}, +) { + const runId = options.runId ?? `plugin-owner-${++nextRunId}`; + const config = options.config ?? { tools: { exec: { security: "full", ask: "off" } } }; + const admission = prepareSystemAgentRunAdmission(config, runId, "main", "plugin-test"); + activeAdmissions.push(admission); + const context = buildPreparedCliRunContext({ + provider: "claude-cli", + model: "claude-sonnet-4-6", + agentId: "main", + runId, + sessionId: "sdk-session", + sessionKey: "agent:main:main", + prompt: "hello", + config, + executionMode: "agent", + timeoutMs: options.timeoutMs ?? 5_000, + sessionEntry: options.sessionEntry, + ...(options.nativeTools + ? { cliToolAvailability: { native: options.nativeTools, openClaw: [] } } + : {}), + systemPrompt: " Follow host policy. ", + backend: { + command: "/bin/sh", + args: [], + ...(options.resumeArgs ? { resumeArgs: options.resumeArgs } : {}), + }, + }); + context.params.admittedRunContext = await admission.admit("plugin-harness"); + if (options.abortSignal) { + context.params.abortSignal = options.abortSignal; + } + + return { admission, context }; +} + +function runPlugin( + context: PreparedCliRunContext, + execute: CliBackendExecute, + options: { + noOutputTimeoutMs?: number; + consumeStdout?: (chunk: string) => void; + sessionId?: string; + useResume?: boolean; + forceNewSession?: boolean; + liveSession?: boolean; + requiredGeneration?: string; + onNoOutputTimeout?: NonNullable< + Parameters[0]["onNoOutputTimeout"] + >; + onInterrupted?: (reason: "aborted" | "timeout") => boolean; + } = {}, +) { + return executePluginOwnedProcess({ + context, + execute, + executionCommand: "/bin/sh", + executionArgs: ["-p", "--permission-mode", "bypassPermissions"], + env: { PATH: "/bin:/usr/bin", OPENCLAW_TEST_MARKER: "host-owned" }, + prompt: context.params.prompt, + useResume: options.useResume ?? false, + sessionId: options.sessionId ?? "sdk-session", + ...(options.forceNewSession ? { forceNewSession: true } : {}), + ...(options.liveSession + ? { + liveSession: { + beginCapture: () => {}, + ...(options.requiredGeneration + ? { requiredGeneration: options.requiredGeneration } + : {}), + }, + } + : {}), + ...(options.onNoOutputTimeout ? { onNoOutputTimeout: options.onNoOutputTimeout } : {}), + ...(options.onInterrupted ? { onInterrupted: options.onInterrupted } : {}), + noOutputTimeoutMs: options.noOutputTimeoutMs ?? 2_000, + consumeStdout: options.consumeStdout ?? (() => {}), + }); +} + +function registerOwnerSession(context: PreparedCliRunContext, generation: string) { + const capability = createCliLiveSessionCapability({ + context, + argv: ["/bin/sh", "-p", "--permission-mode", "bypassPermissions"], + env: { PATH: "/bin:/usr/bin", OPENCLAW_TEST_MARKER: "host-owned" }, + beginCapture: () => {}, + abortSignal: new AbortController().signal, + }); + const close = vi.fn(() => capability.remove(session)); + const session: CliBackendLiveSessionHandle = { + generation, + fingerprint: capability.fingerprint, + isIdle: () => true, + close, + waitForExit: vi.fn(async () => {}), + }; + capability.register(session); + activeSessions.add(session); + return { handle: session, close }; +} + +function waitUntilAborted(execution: CliBackendExecuteContext): Promise { + const signal = execution.abortSignal; + if (!signal) { + throw new Error("Host execution did not expose its abort signal."); + } + return new Promise((_, reject) => { + signal.addEventListener( + "abort", + () => + reject( + signal.reason instanceof Error ? signal.reason : new Error("CLI test run was aborted."), + ), + { once: true }, + ); + }); +} + +function requestNativeTool( + execution: CliBackendExecuteContext, + toolName = "Bash", + toolInput: Record = { command: "echo approved" }, +) { + return execution.requestToolPermission({ + toolName, + toolInput, + toolCallId: `native-${toolName}`, + ...(execution.abortSignal ? { abortSignal: execution.abortSignal } : {}), + }); +} + +afterEach(() => { + for (const session of activeSessions) { + session.close("restart"); + } + activeSessions.clear(); + for (const admission of activeAdmissions.splice(0)) { + admission.close(); + } + mockCallGatewayTool.mockReset(); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe("plugin-owned CLI execution host boundary", () => { + it("streams plugin events through the canonical host output boundary", async () => { + const { context } = await createExecution(); + context.systemPrompt = ` Follow host policy.${SYSTEM_PROMPT_CACHE_BOUNDARY}Keep credentials private. `; + const output: string[] = []; + let observedExecution: CliBackendExecuteContext | undefined; + const execute: CliBackendExecute = async function* (execution) { + observedExecution = execution; + yield { type: "system", subtype: "init", session_id: "sdk-session" }; + yield SUCCESS_RESULT; + }; + + await expect( + runPlugin(context, execute, { consumeStdout: output.push.bind(output) }), + ).resolves.toMatchObject({ reason: "exit", exitCode: 0, timedOut: false }); + + expect(output.map((line) => JSON.parse(line))).toEqual([ + { type: "system", subtype: "init", session_id: "sdk-session" }, + SUCCESS_RESULT, + ]); + expect(observedExecution).toEqual( + expect.objectContaining({ + command: "/bin/sh", + cwd: "/tmp", + prompt: "hello", + modelId: "claude-sonnet-4-6", + systemPrompt: "Follow host policy.\nKeep credentials private.", + sessionId: "sdk-session", + useResume: false, + env: { PATH: "/bin:/usr/bin", OPENCLAW_TEST_MARKER: "host-owned" }, + requestToolPermission: expect.any(Function), + }), + ); + }); + + it("restarts true fresh sessions while preserving legitimate no-resume warm reuse", async () => { + const reseed = await createExecution({ runId: "plugin-fresh-reseed" }); + reseed.context.openClawHistoryPrompt = "Previously recorded bounded conversation."; + const reseededSession = registerOwnerSession(reseed.context, "old-reseed-session"); + + await runPlugin( + reseed.context, + async function* (execution) { + expect(execution.liveSession?.current()).toBeUndefined(); + yield SUCCESS_RESULT; + }, + { liveSession: true, forceNewSession: true }, + ); + expect(reseededSession.close).toHaveBeenCalledWith("restart"); + + const resumeCapable = await createExecution({ + runId: "plugin-resume-capable-fresh", + resumeArgs: ["--resume", "{sessionId}"], + }); + const resumeSession = registerOwnerSession(resumeCapable.context, "resume-capable-session"); + await runPlugin( + resumeCapable.context, + async function* () { + yield SUCCESS_RESULT; + }, + { liveSession: true, useResume: false }, + ); + expect(resumeSession.close).toHaveBeenCalledWith("restart"); + + const noResume = await createExecution({ runId: "plugin-no-resume-warm", resumeArgs: [] }); + const reusableSession = registerOwnerSession(noResume.context, "no-resume-session"); + await runPlugin( + noResume.context, + async function* (execution) { + expect(execution.liveSession?.current()).toBe(reusableSession.handle); + yield SUCCESS_RESULT; + }, + { liveSession: true, useResume: false }, + ); + expect(reusableSession.close).not.toHaveBeenCalled(); + }); + + it("rejects missing or replaced required generations but permits a deliberate cold recovery", async () => { + const { context } = await createExecution({ runId: "plugin-required-generation" }); + context.requiredClaudeLiveSessionGeneration = "original-generation"; + const requireCurrentSession: CliBackendExecute = async function* (execution) { + execution.liveSession?.current(); + yield SUCCESS_RESULT; + }; + const resumedOptions = { + liveSession: true, + useResume: true, + requiredGeneration: "original-generation", + }; + + await expect(runPlugin(context, requireCurrentSession, resumedOptions)).rejects.toMatchObject({ + reason: "session_expired", + code: "cli_live_session_missing", + }); + + const replacement = registerOwnerSession(context, "replacement-generation"); + await expect(runPlugin(context, requireCurrentSession, resumedOptions)).rejects.toMatchObject({ + reason: "session_expired", + code: "cli_live_session_changed", + }); + expect(replacement.close).not.toHaveBeenCalled(); + + context.openClawHistoryPrompt = "Recovered conversation history."; + await expect( + runPlugin(context, requireCurrentSession, { + liveSession: true, + useResume: false, + forceNewSession: true, + }), + ).resolves.toMatchObject({ reason: "exit" }); + expect(replacement.close).toHaveBeenCalledWith("restart"); + }); + + it("claims prepared resources only for the original process and cleans after its exit", async () => { + const first = await createExecution({ runId: "plugin-prepared-resource-owner" }); + const cleanup = vi.fn(async () => {}); + first.context.preparedBackend.claimLiveSessionResources = vi.fn(() => cleanup); + const exited = createDeferred(); + let handle: CliBackendLiveSessionHandle | undefined; + + await runPlugin( + first.context, + async function* (execution) { + const capability = execution.liveSession; + if (!capability) { + throw new Error("Expected a reusable plugin execution capability."); + } + const session: CliBackendLiveSessionHandle = { + generation: "prepared-resource-process", + fingerprint: capability.fingerprint, + isIdle: () => true, + close: vi.fn(() => capability.remove(session)), + waitForExit: () => exited.promise, + }; + handle = session; + capability.register(session); + activeSessions.add(session); + yield SUCCESS_RESULT; + }, + { liveSession: true }, + ); + + expect(first.context.preparedBackend.claimLiveSessionResources).toHaveBeenCalledOnce(); + expect(cleanup).not.toHaveBeenCalled(); + + const resumed = await createExecution({ runId: "plugin-prepared-resource-reuse" }); + const unusedResourceClaim = vi.fn(() => vi.fn(async () => {})); + resumed.context.preparedBackend.claimLiveSessionResources = unusedResourceClaim; + + await runPlugin( + resumed.context, + async function* (execution) { + expect(execution.liveSession?.current()).toBe(handle); + yield SUCCESS_RESULT; + }, + { liveSession: true }, + ); + + expect(unusedResourceClaim).not.toHaveBeenCalled(); + const closing = closeCliLiveSession(first.context, "restart"); + await Promise.resolve(); + expect(cleanup).not.toHaveBeenCalled(); + + exited.resolve(); + await closing; + expect(cleanup).toHaveBeenCalledOnce(); + }); + + it("applies restrictive session policy even when global policy permits execution", async () => { + const { context } = await createExecution({ + config: { tools: { exec: { security: "full", ask: "off" } } }, + sessionEntry: { sessionId: "sdk-session", updatedAt: 1, execSecurity: "deny" }, + }); + let decision: CliBackendToolPermissionResult | undefined; + + await runPlugin(context, async function* (execution) { + decision = await requestNativeTool(execution); + yield SUCCESS_RESULT; + }); + + expect(decision).toEqual( + expect.objectContaining({ + behavior: "deny", + message: expect.stringContaining("security=deny"), + }), + ); + expect(mockCallGatewayTool).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: "full policy releases the exact original input", + security: "full" as const, + ask: "off" as const, + behavior: "allow" as const, + }, + { + name: "allowlist policy never silently prompts or grants", + security: "allowlist" as const, + ask: "off" as const, + behavior: "deny" as const, + }, + ])("$name", async ({ security, ask, behavior }) => { + const { context } = await createExecution({ + config: { tools: { exec: { security, ask } } }, + nativeTools: ["Read"], + }); + const input = { file_path: "/tmp/example.png", nested: { source: "exact" } }; + let decision: CliBackendToolPermissionResult | undefined; + + await runPlugin(context, async function* (execution) { + decision = await requestNativeTool(execution, "Read", input); + yield SUCCESS_RESULT; + }); + + expect(decision?.behavior).toBe(behavior); + if (decision?.behavior === "allow") { + expect(decision.updatedInput).toBe(input); + } + expect(mockCallGatewayTool).not.toHaveBeenCalled(); + }); + + it("fails closed for unnamed and unavailable native tools before requesting approval", async () => { + const { context } = await createExecution({ nativeTools: ["Read"] }); + const decisions: CliBackendToolPermissionResult[] = []; + + await runPlugin(context, async function* (execution) { + decisions.push(await requestNativeTool(execution, " ")); + decisions.push(await requestNativeTool(execution, "Bash")); + yield SUCCESS_RESULT; + }); + + expect(decisions).toEqual([ + expect.objectContaining({ behavior: "deny", message: expect.stringContaining("unnamed") }), + expect.objectContaining({ + behavior: "deny", + message: expect.stringContaining("unavailable"), + }), + ]); + expect(mockCallGatewayTool).not.toHaveBeenCalled(); + }); + + it("retains safe standing approvals only for the exact live process and current turn policy", async () => { + const config: OpenClawConfig = { tools: { exec: { security: "allowlist", ask: "on-miss" } } }; + mockCallGatewayTool + .mockResolvedValueOnce({ id: "approval-first", decision: "allow-always" }) + .mockResolvedValueOnce({ id: "approval-second", decision: "allow-always" }); + + const first = await createExecution({ + config, + nativeTools: ["WebFetch"], + runId: "plugin-approval-first", + }); + const originalHandle = registerOwnerSession(first.context, "original-live-process"); + + const runApprovedTurn = async (context: PreparedCliRunContext, repeat: boolean) => { + await runPlugin(context, async function* (execution) { + await expect( + requestNativeTool(execution, "WebFetch", { url: "https://example.com" }), + ).resolves.toMatchObject({ behavior: "allow" }); + if (repeat) { + await expect( + requestNativeTool(execution, "WebFetch", { url: "https://example.com/next" }), + ).resolves.toMatchObject({ behavior: "allow" }); + } + yield SUCCESS_RESULT; + }); + }; + + await runApprovedTurn(first.context, true); + const sameProcess = await createExecution({ + config, + nativeTools: ["WebFetch"], + runId: "plugin-approval-second", + }); + await runApprovedTurn(sameProcess.context, false); + expect(mockCallGatewayTool).toHaveBeenCalledOnce(); + + const restricted = await createExecution({ + config, + nativeTools: ["WebFetch"], + runId: "plugin-approval-restricted", + sessionEntry: { sessionId: "sdk-session", updatedAt: 1, execSecurity: "deny" }, + }); + await runPlugin(restricted.context, async function* (execution) { + await expect( + requestNativeTool(execution, "WebFetch", { url: "https://example.com/restricted" }), + ).resolves.toMatchObject({ behavior: "deny" }); + yield SUCCESS_RESULT; + }); + expect(mockCallGatewayTool).toHaveBeenCalledOnce(); + + originalHandle.handle.close("restart"); + registerOwnerSession(first.context, "replacement-live-process"); + const replacement = await createExecution({ + config, + nativeTools: ["WebFetch"], + runId: "plugin-approval-replacement", + }); + await runApprovedTurn(replacement.context, false); + + expect(mockCallGatewayTool).toHaveBeenCalledTimes(2); + }); + + it("denies approval when its exact admitted authority closes during the awaited decision", async () => { + const { admission, context } = await createExecution({ + config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, + nativeTools: ["WebFetch"], + }); + mockCallGatewayTool.mockImplementationOnce(async () => { + admission.close(); + return { id: "approval-closed", decision: "allow-once" }; + }); + let decision: CliBackendToolPermissionResult | undefined; + + await runPlugin(context, async function* (execution) { + decision = await requestNativeTool(execution, "WebFetch", { url: "https://example.com" }); + yield SUCCESS_RESULT; + }); + + expect(decision).toEqual( + expect.objectContaining({ behavior: "deny", message: expect.stringContaining("closed") }), + ); + }); + + it("cancels an in-flight native approval and never releases its late decision", async () => { + const controller = new AbortController(); + const { context } = await createExecution({ + abortSignal: controller.signal, + config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, + nativeTools: ["WebFetch"], + }); + const approval = createDeferred<{ id: string; decision: "allow-always" }>(); + mockCallGatewayTool.mockReturnValueOnce(approval.promise); + const granted = vi.fn(); + const closed = vi.fn(); + const run = runPlugin(context, async function* (execution) { + try { + const decision = await requestNativeTool(execution, "WebFetch", { + url: "https://example.com/canceled-approval", + }); + if (decision.behavior === "allow") { + granted(); + } + yield SUCCESS_RESULT; + } finally { + closed(); + } + }); + await vi.waitFor(() => expect(mockCallGatewayTool).toHaveBeenCalledOnce()); + const approvalSignal = mockCallGatewayTool.mock.calls[0]?.[3]?.signal; + + controller.abort(); + + await expect(run).rejects.toMatchObject({ name: "AbortError" }); + expect(approvalSignal?.aborted).toBe(true); + expect(closed).toHaveBeenCalledOnce(); + expect(granted).not.toHaveBeenCalled(); + + approval.resolve({ id: "canceled-approval", decision: "allow-always" }); + await Promise.resolve(); + expect(granted).not.toHaveBeenCalled(); + }); + + it("fences a retained permission callback as soon as its turn finishes", async () => { + const { context } = await createExecution(); + let requestToolPermission: CliBackendExecuteContext["requestToolPermission"] | undefined; + + await runPlugin(context, async function* (execution) { + requestToolPermission = execution.requestToolPermission; + yield SUCCESS_RESULT; + }); + + await expect( + requestToolPermission?.({ toolName: "Bash", toolInput: { command: "echo stale" } }), + ).resolves.toEqual( + expect.objectContaining({ + behavior: "deny", + message: expect.stringContaining("no longer active"), + }), + ); + expect(mockCallGatewayTool).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: "a 429 error-marked success", + terminal: { + type: "result", + subtype: "success", + is_error: true, + api_error_status: 429, + result: "Claude subscription rate limit reached.", + }, + }, + { + name: "a 529 provider-error subtype despite an unset error flag", + terminal: { + type: "result", + subtype: "error_during_execution", + is_error: false, + api_error_status: 529, + errors: ["Anthropic API overloaded (529)."], + }, + }, + ])("preserves $name if the plugin throws while draining", async ({ terminal }) => { + const { context } = await createExecution(); + const output: string[] = []; + + await expect( + runPlugin( + context, + async function* () { + yield terminal; + throw new Error("SDK stream closed after the provider error"); + }, + { consumeStdout: output.push.bind(output) }, + ), + ).resolves.toMatchObject({ reason: "exit", exitCode: 0 }); + + expect(output.map((line) => JSON.parse(line))).toEqual([terminal]); + }); + + it.each([ + { + name: "a stream without a terminal result", + async *execute() { + yield { type: "system", subtype: "init" }; + }, + error: "without a terminal result", + }, + { + name: "a plugin failure after an otherwise successful result", + async *execute() { + yield SUCCESS_RESULT; + throw new Error("SDK stream failed after the result"); + }, + error: "SDK stream failed after the result", + }, + ])("rejects $name", async (testCase) => { + const { context } = await createExecution(); + + await expect(runPlugin(context, () => testCase.execute())).rejects.toThrow(testCase.error); + }); + + it("aborts a silent plugin stream through the host no-output watchdog", async () => { + vi.useFakeTimers(); + const { context } = await createExecution({ timeoutMs: 5_000 }); + const streamStarted = createDeferred(); + const run = runPlugin( + context, + async function* (execution) { + streamStarted.resolve(); + await waitUntilAborted(execution); + yield SUCCESS_RESULT; + }, + { noOutputTimeoutMs: 100 }, + ); + await streamStarted.promise; + + await vi.advanceTimersByTimeAsync(100); + + await expect(run).resolves.toMatchObject({ + reason: "no-output-timeout", + exitCode: null, + timedOut: true, + noOutputTimedOut: true, + }); + }); + + it.each([ + { + name: "init-only resumed traffic remains safely retryable", + event: { type: "system", subtype: "init", session_id: "sdk-session" }, + code: "cli_no_output_timeout", + }, + { + name: "actual SDK command lifecycle traffic remains safely retryable", + event: { + type: "command_lifecycle", + subtype: "started", + command: "resume", + session_id: "sdk-session", + }, + code: "cli_no_output_timeout", + }, + { + name: "substantive assistant output never becomes replay-safe", + event: { type: "assistant", message: { content: [{ type: "text", text: "started" }] } }, + code: undefined, + }, + ])("$name", async ({ event, code }) => { + vi.useFakeTimers(); + const { context } = await createExecution({ timeoutMs: 5_000 }); + const output: string[] = []; + const timeout = vi.fn(); + const run = runPlugin( + context, + async function* (execution) { + yield event; + await waitUntilAborted(execution); + yield SUCCESS_RESULT; + }, + { + useResume: true, + noOutputTimeoutMs: 100, + consumeStdout: output.push.bind(output), + onNoOutputTimeout: timeout, + }, + ); + await vi.waitFor(() => expect(output).toHaveLength(1)); + + await vi.advanceTimersByTimeAsync(100); + + await expect(run).resolves.toMatchObject({ reason: "no-output-timeout" }); + expect(timeout).toHaveBeenCalledOnce(); + expect(timeout.mock.calls[0]?.[0]).toMatchObject({ reason: "timeout" }); + expect(timeout.mock.calls[0]?.[0]?.code).toBe(code); + }); + + it("keeps an active native approval alive beyond the ordinary no-output watchdog", async () => { + vi.useFakeTimers(); + const { context } = await createExecution({ + config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, + nativeTools: ["WebFetch"], + }); + const approval = createDeferred<{ id: string; decision: "allow-once" }>(); + mockCallGatewayTool.mockReturnValueOnce(approval.promise); + let completed = false; + const run = runPlugin( + context, + async function* (execution) { + const decision = await requestNativeTool(execution, "WebFetch", { + url: "https://example.com/approval", + }); + expect(decision.behavior).toBe("allow"); + yield SUCCESS_RESULT; + }, + { noOutputTimeoutMs: 100 }, + ).then((result) => { + completed = true; + return result; + }); + await vi.waitFor(() => expect(mockCallGatewayTool).toHaveBeenCalledOnce()); + + await vi.advanceTimersByTimeAsync(150); + expect(completed).toBe(false); + + approval.resolve({ id: "approval-pending", decision: "allow-once" }); + await expect(run).resolves.toMatchObject({ reason: "exit", timedOut: false }); + }); + + it("keeps the overall deadline authoritative while a native approval is outstanding", async () => { + vi.useFakeTimers(); + const { context } = await createExecution({ + config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, + nativeTools: ["WebFetch"], + timeoutMs: 150, + }); + const approval = createDeferred<{ id: string; decision: "allow-once" }>(); + mockCallGatewayTool.mockReturnValueOnce(approval.promise); + const run = runPlugin( + context, + async function* (execution) { + await requestNativeTool(execution, "WebFetch", { url: "https://example.com/slow" }); + yield SUCCESS_RESULT; + }, + { noOutputTimeoutMs: 100 }, + ); + await vi.waitFor(() => expect(mockCallGatewayTool).toHaveBeenCalledOnce()); + const approvalSignal = mockCallGatewayTool.mock.calls[0]?.[3]?.signal; + + await vi.advanceTimersByTimeAsync(150); + + await expect(run).resolves.toMatchObject({ + reason: "overall-timeout", + timedOut: true, + noOutputTimedOut: false, + }); + expect(approvalSignal?.aborted).toBe(true); + approval.resolve({ id: "late-approval", decision: "allow-once" }); + }); + + it("keeps tracked background work alive beyond the ordinary no-output watchdog", async () => { + vi.useFakeTimers(); + const { context } = await createExecution(); + const backgroundFinished = createDeferred(); + const received: string[] = []; + let completed = false; + const run = runPlugin( + context, + async function* () { + yield { + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "background-agent", task_type: "local_agent" }], + }; + await backgroundFinished.promise; + yield { type: "system", subtype: "background_tasks_changed", tasks: [] }; + yield SUCCESS_RESULT; + }, + { noOutputTimeoutMs: 100, consumeStdout: received.push.bind(received) }, + ).then((result) => { + completed = true; + return result; + }); + await vi.waitFor(() => expect(received).toHaveLength(1)); + + await vi.advanceTimersByTimeAsync(150); + expect(completed).toBe(false); + + backgroundFinished.resolve(); + await expect(run).resolves.toMatchObject({ reason: "exit", timedOut: false }); + expect(received.map((event) => JSON.parse(event))).toHaveLength(3); + }); + + it("keeps the overall deadline authoritative while background work remains active", async () => { + vi.useFakeTimers(); + const { context } = await createExecution({ timeoutMs: 150 }); + const received: string[] = []; + const run = runPlugin( + context, + async function* (execution) { + yield { + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "background-agent", task_type: "local_agent" }], + }; + await waitUntilAborted(execution); + yield SUCCESS_RESULT; + }, + { noOutputTimeoutMs: 100, consumeStdout: received.push.bind(received) }, + ); + await vi.waitFor(() => expect(received).toHaveLength(1)); + + await vi.advanceTimersByTimeAsync(150); + + await expect(run).resolves.toMatchObject({ + reason: "overall-timeout", + timedOut: true, + noOutputTimedOut: false, + }); + }); + + it("propagates caller cancellation and closes the active plugin iterator", async () => { + const controller = new AbortController(); + const { context } = await createExecution({ abortSignal: controller.signal }); + const streamStarted = createDeferred(); + const streamClosed = vi.fn(); + const run = runPlugin(context, async function* (execution) { + try { + streamStarted.resolve(); + await waitUntilAborted(execution); + yield SUCCESS_RESULT; + } finally { + streamClosed(); + } + }); + await streamStarted.promise; + + controller.abort(); + + await expect(run).rejects.toMatchObject({ name: "AbortError" }); + expect(streamClosed).toHaveBeenCalledOnce(); + }); + + it.each([ + { + name: "AbortError", + reason: "aborted" as const, + abort: (controller: AbortController) => controller.abort(), + }, + { + name: "the caller's TimeoutError", + reason: "timeout" as const, + abort: (controller: AbortController) => { + const timeout = new Error("caller deadline exceeded"); + timeout.name = "TimeoutError"; + controller.abort(timeout); + }, + }, + { + name: "AbortError wrapping a TimeoutError", + reason: "aborted" as const, + abort: (controller: AbortController) => { + const timeout = new Error("caller deadline exceeded"); + timeout.name = "TimeoutError"; + const cancellation = new Error("caller cancelled", { cause: timeout }); + cancellation.name = "AbortError"; + controller.abort(cancellation); + }, + }, + ])("preserves streamed assistant output after $name", async ({ abort, reason }) => { + const controller = new AbortController(); + const { context } = await createExecution({ abortSignal: controller.signal }); + const output: string[] = []; + const preserveOutput = vi.fn(() => output.length > 0); + const run = runPlugin( + context, + async function* (execution) { + yield { + type: "assistant", + message: { content: [{ type: "text", text: "Here is the answer so far" }] }, + }; + await waitUntilAborted(execution); + yield SUCCESS_RESULT; + }, + { + consumeStdout: output.push.bind(output), + onInterrupted: preserveOutput, + }, + ); + await vi.waitFor(() => expect(output).toHaveLength(1)); + + abort(controller); + + await expect(run).resolves.toMatchObject({ reason: "manual-cancel", exitCode: null }); + expect(preserveOutput).toHaveBeenCalledExactlyOnceWith(reason); + expect(JSON.parse(output[0] ?? "{}")).toMatchObject({ + message: { content: [{ text: "Here is the answer so far" }] }, + }); + }); +}); diff --git a/src/agents/cli-runner/execute-plugin.ts b/src/agents/cli-runner/execute-plugin.ts new file mode 100644 index 000000000000..6b277a33b6a0 --- /dev/null +++ b/src/agents/cli-runner/execute-plugin.ts @@ -0,0 +1,409 @@ +import { stripSystemPromptCacheBoundary } from "@openclaw/ai/internal/shared"; +import { clampPositiveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { toErrorObject } from "../../infra/errors.js"; +import { resolveExecutablePath } from "../../infra/executable-path.js"; +import { BLOCKED_TOOL_CALL_ABORT_FLOOR_MS } from "../../logging/diagnostic-run-activity.js"; +import type { + CliBackendExecute, + CliBackendToolPermissionRequest, + CliBackendToolPermissionResult, +} from "../../plugins/cli-backend.types.js"; +import type { RunExit, TerminationReason } from "../../process/supervisor/types.js"; +import { resolveAdmittedRunActiveAssertion } from "../admitted-run-context.js"; +import type { CliTerminalInterruption } from "../cli-output-contracts.js"; +import { resolveExecDefaults } from "../exec-defaults.js"; +import { isSignalTimeoutReason, type FailoverError } from "../failover-error.js"; +import { + closeCliLiveSession, + createCliLiveSessionCapability, + getCliLiveSessionApprovalGrants, +} from "./cli-live-session-registry.js"; +import { + requestCliNativeToolApproval, + resolveCliNativeToolApprovalPlan, +} from "./cli-native-tool-approval.js"; +import { createCliAbortError } from "./execute-node-claude.js"; +import { resolveCliNoOutputTimeoutDecision } from "./no-output-timeout-policy.js"; +import type { PreparedCliRunContext } from "./types.js"; + +const PLUGIN_ITERATOR_CLOSE_TIMEOUT_MS = 5_000; + +function denyTool(message: string): CliBackendToolPermissionResult { + return { behavior: "deny", message }; +} + +function createPluginToolPermissionHandler(params: { + context: PreparedCliRunContext; + abortSignal: AbortSignal; + onPendingApproval: (delta: 1 | -1) => void; +}): (request: CliBackendToolPermissionRequest) => Promise { + const run = params.context.params; + const permission = resolveExecDefaults({ + cfg: run.config, + sessionEntry: run.sessionEntry, + execOverrides: run.execOverrides, + agentId: run.agentId, + sessionKey: run.runtimePolicySessionKey ?? run.sessionKey, + }); + const grants = new Set(); + + return async (request) => { + const signal = request.abortSignal + ? AbortSignal.any([params.abortSignal, request.abortSignal]) + : params.abortSignal; + const assertActive = resolveAdmittedRunActiveAssertion(run.admittedRunContext, signal); + if (!assertActive) { + return denyTool("OpenClaw denied native tool use: the admitted run is no longer active."); + } + try { + assertActive(); + } catch { + return denyTool("OpenClaw denied native tool use: the admitted run is no longer active."); + } + + const toolName = request.toolName.trim(); + if (!toolName) { + return denyTool("OpenClaw denied an unnamed native tool."); + } + if (run.cliToolAvailability && !run.cliToolAvailability.native.includes(toolName)) { + return denyTool(`OpenClaw denied native tool ${toolName}: it is unavailable to this run.`); + } + + const plan = resolveCliNativeToolApprovalPlan(permission); + if (plan === "deny") { + return denyTool( + `OpenClaw exec policy denied native tool use (security=${permission.security}, ask=${permission.ask}).`, + ); + } + const currentGrants = getCliLiveSessionApprovalGrants(params.context) ?? grants; + if (plan === "allow" || (permission.ask !== "always" && currentGrants.has(toolName))) { + assertActive(); + return { behavior: "allow", updatedInput: request.toolInput }; + } + + params.onPendingApproval(1); + let outcome: Awaited>; + try { + outcome = await requestCliNativeToolApproval({ + toolName, + toolInput: request.toolInput, + pluginId: params.context.backendResolved.id, + sessionKey: run.sessionKey, + agentId: run.agentId, + toolCallId: request.toolCallId, + cwd: params.context.cwd ?? params.context.workspaceDir, + abortSignal: signal, + ask: permission.ask, + }); + } finally { + params.onPendingApproval(-1); + } + // Approval itself may outlive, replace, or close the exact admitted turn. + // The host rechecks authority immediately before returning any capability. + try { + assertActive(); + } catch { + return denyTool("OpenClaw denied native tool use: the admitted run closed during approval."); + } + if (outcome.kind !== "allow") { + return denyTool( + outcome.message ?? + (outcome.reason === "user" + ? `OpenClaw user denied native tool use (${toolName}).` + : `OpenClaw approval was not granted for native tool use (${toolName}).`), + ); + } + if (outcome.grantAlways) { + currentGrants.add(toolName); + } + return { behavior: "allow", updatedInput: request.toolInput }; + }; +} + +function waitForIteratorValue( + iterator: AsyncIterator, + signal: AbortSignal, +): Promise> { + if (signal.aborted) { + return Promise.reject(toErrorObject(signal.reason, "CLI plugin execution was aborted.")); + } + return new Promise((resolve, reject) => { + const rejectAborted = () => + reject(toErrorObject(signal.reason, "CLI plugin execution was aborted.")); + signal.addEventListener("abort", rejectAborted, { once: true }); + void iterator.next().then( + (value) => { + signal.removeEventListener("abort", rejectAborted); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener("abort", rejectAborted); + reject(toErrorObject(error, "CLI plugin execution stream failed.")); + }, + ); + }); +} + +async function closePluginIterator( + iterator: AsyncIterator> | undefined, +): Promise { + if (!iterator?.return) { + return; + } + let timeout: ReturnType | undefined; + try { + await Promise.race([ + iterator.return(), + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error("CLI plugin runtime did not close after its run ended.")), + PLUGIN_ITERATOR_CLOSE_TIMEOUT_MS, + ); + timeout.unref(); + }), + ]); + } finally { + clearTimeout(timeout); + } +} + +/** Runs a prepared plugin transport while keeping cancellation and approvals host-owned. */ +export async function executePluginOwnedProcess(params: { + context: PreparedCliRunContext; + execute: CliBackendExecute; + executionCommand: string; + executionArgs: readonly string[]; + env: Record; + prompt: string; + useResume: boolean; + forceNewSession?: boolean; + sessionId?: string; + noOutputTimeoutMs: number; + consumeStdout: (chunk: string) => void; + activeToolCount?: () => number; + onNoOutputTimeout?: (error: FailoverError) => void; + onInterrupted?: (reason: CliTerminalInterruption["reason"]) => boolean; + liveSession?: { + captureKey?: string; + beginCapture: (captureKey: string | undefined) => void; + requiredGeneration?: string; + }; +}): Promise { + const run = params.context.params; + const cwd = params.context.cwd ?? params.context.workspaceDir; + const command = resolveExecutablePath(params.executionCommand, { cwd, env: params.env }); + if (!command) { + throw new Error(`CLI backend executable could not be resolved: ${params.executionCommand}`); + } + + const startedAt = Date.now(); + const controller = new AbortController(); + const signal = run.abortSignal + ? AbortSignal.any([controller.signal, run.abortSignal]) + : controller.signal; + const termination: { reason: TerminationReason } = { reason: "exit" }; + const outstanding = { + approvals: 0, + background: 0, + lastOutputAt: startedAt, + observed: false, + replayUnsafe: false, + }; + let noOutputTimer: ReturnType | undefined; + const overallTimeoutMs = clampPositiveTimerTimeoutMs(run.timeoutMs); + const noOutputTimeoutMs = clampPositiveTimerTimeoutMs(params.noOutputTimeoutMs); + const overallTimer = + overallTimeoutMs === undefined + ? undefined + : setTimeout(() => { + termination.reason = "overall-timeout"; + controller.abort(new Error("CLI plugin runtime exceeded its execution timeout.")); + }, overallTimeoutMs); + const resetNoOutputTimer = (delayMs = noOutputTimeoutMs) => { + clearTimeout(noOutputTimer); + if (delayMs === undefined || noOutputTimeoutMs === undefined) { + return; + } + noOutputTimer = setTimeout(() => { + const quietDurationMs = Date.now() - outstanding.lastOutputAt; + const decision = resolveCliNoOutputTimeoutDecision({ + context: { + provider: run.provider, + model: params.context.modelId, + sessionId: run.sessionId, + lane: run.lane, + }, + timeoutMs: noOutputTimeoutMs, + quietDurationMs, + cliTimeout: { + mode: "no-output", + timeoutSeconds: Math.round(quietDurationMs / 1000), + observedActivity: outstanding.observed, + activeToolCount: Math.max(params.activeToolCount?.() ?? 0, outstanding.approvals), + backgroundTaskCount: outstanding.background, + }, + hasOutputText: false, + useResume: params.useResume, + hasReplayUnsafeActivity: outstanding.replayUnsafe, + allowResumeControlOnlyRetry: true, + outstandingWorkGraceMs: BLOCKED_TOOL_CALL_ABORT_FLOOR_MS, + }); + if (decision.deferMs !== undefined) { + resetNoOutputTimer(decision.deferMs); + return; + } + termination.reason = "no-output-timeout"; + params.onNoOutputTimeout?.(decision.error); + controller.abort(decision.error); + }, delayMs); + }; + + const replyBackendHandle = run.replyOperation + ? { + kind: "cli" as const, + runId: run.runId, + toolAuthorityFingerprint: run.toolAuthorityFingerprint, + cancel: () => { + termination.reason = "manual-cancel"; + controller.abort(createCliAbortError()); + }, + } + : undefined; + if (replyBackendHandle) { + run.replyOperation?.attachBackend(replyBackendHandle); + } + + let iterator: AsyncIterator> | undefined; + let terminalResultSeen = false; + let terminalErrorSeen = false; + try { + resetNoOutputTimer(); + if ( + params.liveSession && + (params.forceNewSession || + (Boolean(params.context.preparedBackend.backend.resumeArgs?.length) && !params.useResume)) + ) { + if (params.liveSession.requiredGeneration) { + throw new Error("The required CLI live session cannot be replaced by a fresh process."); + } + await closeCliLiveSession(params.context, "restart"); + const assertActive = resolveAdmittedRunActiveAssertion(run.admittedRunContext, signal); + if (!assertActive) { + throw new Error("CLI live session turn closed while restarting its process."); + } + assertActive(); + } + const execution = params.execute({ + command, + args: params.executionArgs, + cwd, + env: params.env, + prompt: params.prompt, + modelId: params.context.normalizedModel, + systemPrompt: stripSystemPromptCacheBoundary(params.context.systemPrompt).trim(), + ...(params.sessionId ? { sessionId: params.sessionId } : {}), + useResume: params.useResume, + abortSignal: signal, + timeoutMs: run.timeoutMs, + ...(run.executionMode ? { executionMode: run.executionMode } : {}), + ...(run.cliToolAvailability ? { toolAvailability: run.cliToolAvailability } : {}), + ...(params.liveSession + ? { + liveSession: createCliLiveSessionCapability({ + context: params.context, + argv: [command, ...params.executionArgs], + env: params.env, + captureKey: params.liveSession.captureKey, + beginCapture: params.liveSession.beginCapture, + abortSignal: signal, + requiredGeneration: params.liveSession.requiredGeneration, + claimResources: params.context.preparedBackend.claimLiveSessionResources, + }), + } + : {}), + requestToolPermission: createPluginToolPermissionHandler({ + context: params.context, + abortSignal: signal, + onPendingApproval: (delta) => { + outstanding.approvals = Math.max(0, outstanding.approvals + delta); + }, + }), + }); + iterator = execution[Symbol.asyncIterator](); + + for (;;) { + const next = await waitForIteratorValue(iterator, signal); + if (next.done) { + break; + } + if (!isRecord(next.value)) { + throw new Error("CLI plugin runtime emitted an invalid structured stream event."); + } + if (next.value.type === "result") { + terminalResultSeen = true; + terminalErrorSeen ||= + next.value.is_error === true || + (typeof next.value.subtype === "string" && next.value.subtype.startsWith("error_")); + } + if ( + next.value.type === "system" && + next.value.subtype === "background_tasks_changed" && + Array.isArray(next.value.tasks) + ) { + outstanding.background = next.value.tasks.filter(isRecord).length; + } + params.consumeStdout(`${JSON.stringify(next.value)}\n`); + outstanding.observed = true; + if ( + !(next.value.type === "system" && next.value.subtype === "init") && + next.value.type !== "command_lifecycle" + ) { + outstanding.replayUnsafe = true; + } + outstanding.lastOutputAt = Date.now(); + resetNoOutputTimer(); + } + + if (!terminalResultSeen) { + throw new Error("CLI plugin runtime completed without a terminal result."); + } + } catch (error) { + if (run.abortSignal?.aborted || termination.reason === "manual-cancel") { + const reason = isSignalTimeoutReason(run.abortSignal?.reason) ? "timeout" : "aborted"; + if (!params.onInterrupted?.(reason)) { + throw createCliAbortError(); + } + termination.reason = "manual-cancel"; + } + // SDKs can throw after emitting an authoritative failed terminal record. + // Preserve that record so the existing parser owns auth/rate-limit failover. + if (termination.reason === "exit" && !terminalErrorSeen) { + throw error; + } + } finally { + clearTimeout(overallTimer); + clearTimeout(noOutputTimer); + // Permission callbacks can be retained by the plugin or its subprocess. + // Closing the turn fences those capabilities before any outer cleanup runs. + if (!controller.signal.aborted) { + controller.abort(new Error("CLI plugin runtime turn is no longer active.")); + } + if (replyBackendHandle) { + run.replyOperation?.detachBackend(replyBackendHandle); + } + await closePluginIterator(iterator); + } + + return { + reason: termination.reason, + exitCode: termination.reason === "exit" ? 0 : null, + exitSignal: null, + durationMs: Date.now() - startedAt, + stdout: "", + stderr: "", + timedOut: + termination.reason === "overall-timeout" || termination.reason === "no-output-timeout", + noOutputTimedOut: termination.reason === "no-output-timeout", + }; +} diff --git a/src/agents/cli-runner/execute-process.ts b/src/agents/cli-runner/execute-process.ts index 2ddb88a4e94a..07f1e7cc775a 100644 --- a/src/agents/cli-runner/execute-process.ts +++ b/src/agents/cli-runner/execute-process.ts @@ -7,11 +7,11 @@ import { } from "../../infra/event-session-routing.js"; import type { CliBackendConfig } from "../../plugins/cli-backend.types.js"; import type { RunExit } from "../../process/supervisor/types.js"; -import type { CliOutput } from "../cli-output-contracts.js"; +import type { CliOutput, CliTerminalInterruption } from "../cli-output-contracts.js"; import { createCliJsonlStreamingParser } from "../cli-output-stream.js"; import { parseCliOutput } from "../cli-output.js"; +import type { FailoverError } from "../failover-error.js"; import { applyPluginTextReplacements } from "../plugin-text-transforms.js"; -import { runClaudeTurn } from "./claude-live-session.js"; import type { CliExecuteDeps } from "./execute-deps.js"; import type { CliEventHandlers } from "./execute-events.js"; import { @@ -20,6 +20,7 @@ import { type resolveNodeClaudeTarget, } from "./execute-node-claude.js"; import { appendCliOutputTail } from "./execute-output-buffer.js"; +import { executePluginOwnedProcess } from "./execute-plugin.js"; import type { CliToolTracking } from "./execute-tool-tracking.js"; import { createCliExitFailoverError, createCliFailoverError } from "./exit-error.js"; import { buildCliSupervisorScopeKey } from "./helpers.js"; @@ -74,6 +75,8 @@ export async function executeCliProcess(params: { nodeEnv?: Record; nodeClearEnv?: string[]; useManagedClaudeLiveSession: boolean; + usePluginOwnedExecution: boolean; + initialGatewayCaptureKey?: string; useResume: boolean; cliSessionIdToUse?: string; resolvedSessionId?: string; @@ -88,8 +91,6 @@ export async function executeCliProcess(params: { outputMode: CliBackendConfig["output"]; logOutputText: boolean; cliTurnStartedAt: number; - fallbackCleanup?: () => Promise; - claimFallbackCleanup: () => void; observeForkSuccessor: (sessionId: string) => void; options?: ExecuteCliProcessOptions; }): Promise { @@ -103,65 +104,6 @@ export async function executeCliProcess(params: { }; const outputErrorContext = { ...failoverContext, runId: runParams.runId }; const hasJsonlOutput = params.outputMode === "jsonl"; - if (params.useManagedClaudeLiveSession) { - if (!hasJsonlOutput) { - throw new Error("Claude live session requires JSONL streaming parser"); - } - runParams.onExecutionPhase?.({ - phase: "process_spawned", - provider: runParams.provider, - model: context.modelId, - backend: context.backendResolved.id, - }); - params.claimFallbackCleanup(); - const liveResult = await runClaudeTurn({ - context, - args: params.executionArgs, - executableCommand: params.executionCommand, - executableLeadingArgv: params.executionLeadingArgv, - env: params.env, - prompt: params.prompt, - useResume: params.useResume, - forceNewSession: - params.cliSessionIdToUse === undefined && context.openClawHistoryPrompt !== undefined, - requiredSessionGeneration: params.cliSessionIdToUse - ? context.requiredClaudeLiveSessionGeneration - : undefined, - noOutputTimeoutMs: params.noOutputTimeoutMs, - getProcessSupervisor: params.deps.getProcessSupervisor, - onAssistantDelta: params.events.emitCliAssistantDelta, - onThinkingDelta: params.events.emitCliThinkingDelta, - onThinkingProgress: params.events.emitCliThinkingProgress, - onToolUseStart: params.events.emitCliToolUseStart, - onToolResult: params.events.emitCliToolResult, - resolveToolResultTerminalOutcome: (event) => { - const outcome = params.toolTracking.resolveCliLoopbackTerminalOutcome(event.toolCallId); - return outcome?.outcome === "completed" ? undefined : outcome; - }, - onCommentaryText: - params.events.emitLiveEvents && runParams.emitCommentaryText - ? params.events.emitCliCommentaryText - : undefined, - onMcpCaptureReady: params.toolTracking.beginGatewayCapture, - cleanup: async () => { - await params.fallbackCleanup?.(); - }, - onSessionId: params.observeForkSuccessor, - onAssistantMessage: params.diagnostics?.observeAssistantMessage, - onUsage: params.diagnostics?.observeUsage, - onCliOutput: params.diagnostics?.observeCliOutput, - onRequestPayload: params.diagnostics?.observeRequestPayload, - onPhase: params.options?.onPhase, - }); - params.options?.onPhase?.("resolve"); - const rawText = liveResult.output.text; - return { - ...liveResult.output, - rawText, - finalPromptText: params.prompt, - text: applyPluginTextReplacements(rawText, context.backendResolved.textTransforms?.output), - }; - } const streamingParser = hasJsonlOutput ? createCliJsonlStreamingParser({ @@ -228,6 +170,8 @@ export async function executeCliProcess(params: { let managedRunPid: number | undefined; let nodeRunAbortSignal: AbortSignal | undefined; let nodeRunTruncated = false; + const pluginTimeout: { error?: FailoverError } = {}; + let terminalInterruption: CliTerminalInterruption | undefined; let result: RunExit; params.diagnostics?.observeRequestPayload(params.stdin ?? params.argsPrompt ?? ""); if (params.nodePlacement) { @@ -249,6 +193,49 @@ export async function executeCliProcess(params: { result = nodeRun.result; nodeRunAbortSignal = nodeRun.nodeRunAbortSignal; nodeRunTruncated = nodeRun.nodeRunTruncated; + } else if (params.usePluginOwnedExecution && context.preparedBackend.execute) { + result = await executePluginOwnedProcess({ + context, + execute: context.preparedBackend.execute, + executionCommand: params.executionCommand, + executionArgs: params.executionArgs, + env: params.env, + prompt: params.prompt, + useResume: params.useResume, + forceNewSession: + params.cliSessionIdToUse === undefined && context.openClawHistoryPrompt !== undefined, + sessionId: params.resolvedSessionId, + noOutputTimeoutMs: params.noOutputTimeoutMs, + consumeStdout, + activeToolCount: params.events.activeParsedToolCount, + onNoOutputTimeout: (error) => { + pluginTimeout.error = error; + }, + onInterrupted: (reason) => { + streamingParser?.finish(); + const partialOutput = streamingParser?.getOutput(); + if ( + !partialOutput?.text.trim() || + partialOutput.errorText || + partialOutput.terminalFailure + ) { + return false; + } + terminalInterruption = { reason }; + return true; + }, + ...(params.useManagedClaudeLiveSession + ? { + liveSession: { + captureKey: params.initialGatewayCaptureKey, + beginCapture: params.toolTracking.beginGatewayCapture, + requiredGeneration: params.cliSessionIdToUse + ? context.requiredClaudeLiveSessionGeneration + : undefined, + }, + } + : {}), + }); } else { const supervisor = params.deps.getProcessSupervisor(); const scopeKey = buildCliSupervisorScopeKey({ @@ -307,7 +294,8 @@ export async function executeCliProcess(params: { } if ( (runParams.abortSignal?.aborted || nodeRunAbortSignal?.aborted) && - result.reason === "manual-cancel" + result.reason === "manual-cancel" && + !terminalInterruption ) { throw createCliAbortError(); } @@ -391,7 +379,7 @@ export async function executeCliProcess(params: { } } - if (result.exitCode !== 0 || result.reason !== "exit") { + if (!terminalInterruption && (result.exitCode !== 0 || result.reason !== "exit")) { params.options?.onPhase?.("send"); if (result.reason === "no-output-timeout" || result.noOutputTimedOut) { const timeoutSeconds = Math.round(params.noOutputTimeoutMs / 1000); @@ -399,21 +387,23 @@ export async function executeCliProcess(params: { `cli watchdog timeout: provider=${runParams.provider} model=${context.modelId} session=${params.resolvedSessionId ?? runParams.sessionId} noOutputTimeoutMs=${params.noOutputTimeoutMs} pid=${managedRunPid ?? "node"}`, ); const observedActivity = params.events.hasObservedCliActivity(); - const timeoutDecision = resolveCliNoOutputTimeoutDecision({ - context: failoverContext, - timeoutMs: params.noOutputTimeoutMs, - quietDurationMs: params.noOutputTimeoutMs, - cliTimeout: { - mode: "no-output", - timeoutSeconds, - observedActivity, - activeToolCount: params.events.activeParsedToolCount(), - backgroundTaskCount: 0, - }, - hasOutputText: Boolean(stdoutDiagnostic || stderrDiagnostic), - useResume: params.useResume, - hasReplayUnsafeActivity: observedActivity, - }); + const timeoutDecision = pluginTimeout.error + ? { error: pluginTimeout.error } + : resolveCliNoOutputTimeoutDecision({ + context: failoverContext, + timeoutMs: params.noOutputTimeoutMs, + quietDurationMs: params.noOutputTimeoutMs, + cliTimeout: { + mode: "no-output", + timeoutSeconds, + observedActivity, + activeToolCount: params.events.activeParsedToolCount(), + backgroundTaskCount: 0, + }, + hasOutputText: Boolean(stdoutDiagnostic || stderrDiagnostic), + useResume: params.useResume, + hasReplayUnsafeActivity: observedActivity, + }); const retryable = timeoutDecision.error.code === "cli_no_output_timeout"; const deferNotice = retryable && @@ -426,9 +416,7 @@ export async function executeCliProcess(params: { const stallNotice = [ `CLI agent (${runParams.provider}) produced no output for ${timeoutSeconds}s and was terminated.`, "It may have been waiting for interactive input or an approval prompt.", - ...(params.nodePlacement - ? ["Check the node's Claude permission settings for pending prompts."] - : ["For Claude Code, prefer --permission-mode bypassPermissions --print."]), + "Check CLI permission settings and OpenClaw approval prompts.", ].join(" "); const routing = resolveEventSessionRoutingPolicy({ cfg: runParams.config, @@ -518,6 +506,7 @@ export async function executeCliProcess(params: { ); return { ...parsed, + ...(terminalInterruption ? { terminalInterruption } : {}), diagnostics: { ...parsed.diagnostics, process: processDiagnostics }, rawText, finalPromptText: params.prompt, diff --git a/src/agents/cli-runner/execute-tool-tracking.ts b/src/agents/cli-runner/execute-tool-tracking.ts index f68497b04325..029f9cce1221 100644 --- a/src/agents/cli-runner/execute-tool-tracking.ts +++ b/src/agents/cli-runner/execute-tool-tracking.ts @@ -34,7 +34,7 @@ import { filterToolResultMediaUrls, } from "../embedded-agent-tool-media.js"; import { readToolResultDetails } from "../tool-result-error.js"; -import { closeClaudeSession } from "./claude-live-registry.js"; +import { closeCliLiveSession } from "./cli-live-session-registry.js"; import { attachCliMessagingDeliveryEvidence } from "./delivery-evidence.js"; import { appendUniqueCliMessagingEvidence, @@ -588,7 +588,7 @@ export function createCliToolTracking(context: PreparedCliRunContext) { if (params.useManagedClaudeLiveSession) { // The child still holds the process-env capture key. If drain cannot // prove idle, kill it so a stale key cannot admit later sends. - await closeClaudeSession(context, "mcp-capture-rotation"); + await closeCliLiveSession(context, "mcp-capture-rotation"); } const internalStates = await Promise.all( Array.from(inFlightPreparedMessagingCalls).map(isPreparedInternalSourceReply), diff --git a/src/agents/cli-runner/execute.pending-cancellation.test.ts b/src/agents/cli-runner/execute.pending-cancellation.test.ts index f80280e28368..5bd3c7d9a62f 100644 --- a/src/agents/cli-runner/execute.pending-cancellation.test.ts +++ b/src/agents/cli-runner/execute.pending-cancellation.test.ts @@ -94,6 +94,7 @@ function createRunContext(params: { normalizedModel: "test-model", systemPrompt: "system", systemPromptReport: {} as PreparedCliRunContext["systemPromptReport"], + claudeSkillsPluginArgs: [], bootstrapPromptWarningLines: [], authEpochVersion: 2, }; @@ -234,6 +235,31 @@ describe("local CLI pending process cancellation", () => { expect(createChildAdapterMock).not.toHaveBeenCalled(); }); + it("passes plugin-owned system prompts without writing temporary files or exposing prompt argv", async () => { + const writeCliSystemPromptFile = vi.spyOn(executeDeps, "writeCliSystemPromptFile"); + const context = createRunContext({ runId: "plugin-native-system-prompt" }); + context.preparedBackend.backend.command = "/bin/sh"; + context.preparedBackend.backend.output = "jsonl"; + context.preparedBackend.backend.jsonlDialect = "claude-stream-json"; + context.preparedBackend.backend.systemPromptFileArg = "--append-system-prompt-file"; + context.preparedBackend.backend.systemPromptArg = "--append-system-prompt"; + let executionArgs: readonly string[] | undefined; + let executionPrompt: string | undefined; + context.preparedBackend.execute = async function* (execution) { + executionArgs = execution.args; + executionPrompt = execution.systemPrompt; + yield { type: "result", subtype: "success", result: "completed" }; + }; + + await expect(executePreparedCliRun(context)).resolves.toMatchObject({ text: "completed" }); + + expect(executionPrompt).toBe("system"); + expect(executionArgs).not.toContain("--append-system-prompt-file"); + expect(executionArgs).not.toContain("--append-system-prompt"); + expect(writeCliSystemPromptFile).not.toHaveBeenCalled(); + expect(createChildAdapterMock).not.toHaveBeenCalled(); + }); + it("does not spawn after cancellation during asynchronous backend preparation", async () => { const controller = new AbortController(); const preparation = createDeferred(); diff --git a/src/agents/cli-runner/execute.supervisor-capture.test.ts b/src/agents/cli-runner/execute.supervisor-capture.test.ts index 832d1f849196..06608bb2b02c 100644 --- a/src/agents/cli-runner/execute.supervisor-capture.test.ts +++ b/src/agents/cli-runner/execute.supervisor-capture.test.ts @@ -129,6 +129,7 @@ function buildPreparedCliRunContext(params: { normalizedModel: "model", systemPrompt: "system", systemPromptReport: {} as PreparedCliRunContext["systemPromptReport"], + claudeSkillsPluginArgs: [], bootstrapPromptWarningLines: [], authEpochVersion: 2, }; diff --git a/src/agents/cli-runner/execute.ts b/src/agents/cli-runner/execute.ts index 2adf0060e931..f3b061471dbb 100644 --- a/src/agents/cli-runner/execute.ts +++ b/src/agents/cli-runner/execute.ts @@ -23,9 +23,11 @@ import { import type { MediaImageLayout } from "../embedded-agent-runner/run/prompt-image-metadata.js"; import { applyPluginTextReplacements } from "../plugin-text-transforms.js"; import { prepareCliBundleMcpCaptureAttempt } from "./bundle-mcp.js"; -import { buildClaudeOwnerKey, closeClaudeSession } from "./claude-live-registry.js"; -import { acceptsClaudeLive } from "./claude-live-session-policy.js"; -import { prepareClaudeCliSkillsPlugin } from "./claude-skills-plugin.js"; +import { + acceptsCliLiveSession, + buildCliLiveOwnerKey, + closeCliLiveSession, +} from "./cli-live-session-registry.js"; import { executeDeps } from "./execute-deps.js"; import { createCliEventHandlers } from "./execute-events.js"; import { @@ -61,7 +63,6 @@ import { LEGACY_CLAUDE_CLI_LOG_OUTPUT_ENV, } from "./log.js"; import { createClaudeCliModelCallDiagnostics } from "./model-call-diagnostics.js"; -import { buildCliBackendToolAvailability } from "./tool-policy.js"; import type { PreparedCliRunContext } from "./types.js"; function normalizeCliBackendThinkingLevel( @@ -134,6 +135,9 @@ export async function executePreparedCliRun( } const backend = context.preparedBackend.backend; const nodePlacement = resolveNodeClaudeTarget(context); + const usePluginOwnedExecution = Boolean( + context.preparedBackend.execute && !nodePlacement && params.controlOperation !== "compact", + ); const { sessionId: resolvedSessionId, isNew } = resolveSessionIdToSend({ backend, cliSessionId: cliSessionIdToUse, @@ -151,7 +155,7 @@ export async function executePreparedCliRun( systemPromptArg && (!useResume || backend.systemPromptWhen === "always" || resendSystemPromptForSoftResume); const systemPromptFile = - !nodePlacement && shouldSendSystemPrompt + !nodePlacement && !usePluginOwnedExecution && shouldSendSystemPrompt ? await executeDeps.writeCliSystemPromptFile({ backend, systemPrompt: systemPromptArg }) : undefined; const nodeSystemPrompt = nodePlacement && shouldSendSystemPrompt ? systemPromptArg : undefined; @@ -201,19 +205,10 @@ export async function executePreparedCliRun( const resolvedArgs = useResume ? baseArgs.map((entry) => entry.replaceAll("{sessionId}", resolvedSessionId ?? "")) : baseArgs; - const fallbackClaudeSkillsPlugin = - !nodePlacement && context.claudeSkillsPluginArgs === undefined - ? await prepareClaudeCliSkillsPlugin({ - backendId: context.backendResolved.id, - skillsSnapshot: params.skillsSnapshot, - }) - : undefined; - let fallbackClaudeSkillsPluginCleanupOwned = false; - const claudeSkillsPluginArgs = nodePlacement - ? [] - : (context.claudeSkillsPluginArgs ?? fallbackClaudeSkillsPlugin?.args ?? []); const baseArgsWithSkills = - claudeSkillsPluginArgs.length > 0 ? [...resolvedArgs, ...claudeSkillsPluginArgs] : resolvedArgs; + !nodePlacement && context.claudeSkillsPluginArgs.length > 0 + ? [...resolvedArgs, ...context.claudeSkillsPluginArgs] + : resolvedArgs; const resolvedExecutionArgs = context.backendResolved.resolveExecutionArgs?.({ config: params.config, workspaceDir: context.workspaceDir, @@ -224,13 +219,10 @@ export async function executePreparedCliRun( executionMode: params.executionMode ?? "agent", // Node runs project the native subset only: gateway-loopback MCP tools do // not exist on the node, and auto-approval must not cross that boundary. - toolAvailability: params.cliToolAvailability - ? buildCliBackendToolAvailability( - nodePlacement - ? { native: params.cliToolAvailability.native, openClaw: [] } - : params.cliToolAvailability, - ) - : undefined, + toolAvailability: + params.cliToolAvailability && nodePlacement + ? { native: params.cliToolAvailability.native, openClaw: [] } + : params.cliToolAvailability, useResume, baseArgs: baseArgsWithSkills, }); @@ -253,7 +245,7 @@ export async function executePreparedCliRun( baseArgs: Array.from(executionBaseArgs), modelId: context.normalizedModel, sessionId: resolvedSessionId, - systemPrompt: nodePlacement ? undefined : systemPromptArg, + systemPrompt: nodePlacement || usePluginOwnedExecution ? undefined : systemPromptArg, systemPromptFilePath: systemPromptFile?.filePath, imagePaths: imagePayload.imagePaths, promptArg: argsPrompt, @@ -263,7 +255,7 @@ export async function executePreparedCliRun( sendSystemPromptOnResume: resendSystemPromptForSoftResume, }); - const claudeOwnerKey = buildClaudeOwnerKey({ + const cliLiveOwnerKey = buildCliLiveOwnerKey({ agentAccountId: params.agentAccountId, agentId: params.agentId, authProfileId: context.effectiveAuthProfileId, @@ -277,9 +269,12 @@ export async function executePreparedCliRun( runId: params.runId, workspaceDir: context.workspaceDir, cliSessionId: useResume ? resolvedSessionId : undefined, - ownerKey: claudeOwnerKey, + ownerKey: cliLiveOwnerKey, }); - const useManagedClaudeLiveSession = acceptsClaudeLive(context) && !params.onSuccessfulAuthBinding; + // Plugin-owned transports own their child/session lifecycle; their MCP grant + // still needs the per-turn capture key used by other non-live executions. + const useManagedClaudeLiveSession = + usePluginOwnedExecution && acceptsCliLiveSession(context) && !params.onSuccessfulAuthBinding; // Fresh-session retries invoke this function again. Keep one helper per // observable CLI attempt so every started call retains its own terminal event. const diagnostics = createClaudeCliModelCallDiagnostics({ @@ -386,9 +381,7 @@ export async function executePreparedCliRun( isTruthyEnvValue(process.env[LEGACY_CLAUDE_CLI_LOG_OUTPUT_ENV]); const outputMode = useResume ? (backend.resumeOutput ?? backend.output) : backend.output; const initialGatewayCaptureKey = - useManagedClaudeLiveSession || nodePlacement || !context.mcpDeliveryCapture - ? undefined - : crypto.randomUUID(); + nodePlacement || !context.mcpDeliveryCapture ? undefined : crypto.randomUUID(); const mcpCaptureAttempt = nodePlacement ? { env: {}, cleanup: undefined } : await prepareCliBundleMcpCaptureAttempt({ @@ -537,7 +530,9 @@ export async function executePreparedCliRun( useResume, trigger: params.trigger, }); - toolTracking.beginGatewayCapture(initialGatewayCaptureKey); + if (!useManagedClaudeLiveSession) { + toolTracking.beginGatewayCapture(initialGatewayCaptureKey); + } runOutput = await executeCliProcess({ context, backend, @@ -550,6 +545,8 @@ export async function executePreparedCliRun( nodeEnv: nodeEnv && Object.keys(nodeEnv).length > 0 ? nodeEnv : undefined, nodeClearEnv: nodeClearEnv.length > 0 ? nodeClearEnv : undefined, useManagedClaudeLiveSession, + usePluginOwnedExecution, + initialGatewayCaptureKey, useResume, cliSessionIdToUse, resolvedSessionId, @@ -564,10 +561,6 @@ export async function executePreparedCliRun( outputMode, logOutputText, cliTurnStartedAt, - fallbackCleanup: fallbackClaudeSkillsPlugin?.cleanup, - claimFallbackCleanup: () => { - fallbackClaudeSkillsPluginCleanupOwned = fallbackClaudeSkillsPlugin !== undefined; - }, observeForkSuccessor, options, }); @@ -620,7 +613,7 @@ export async function executePreparedCliRun( } // The fork argument only applies at process startup; a cached warm child // would run inside the source session. Force a fresh spawn. - await closeClaudeSession(context, "restart"); + await closeCliLiveSession(context, "restart"); } return await executeAttempt(); }); @@ -651,9 +644,6 @@ export async function executePreparedCliRun( throw failure; } finally { try { - if (!fallbackClaudeSkillsPluginCleanupOwned) { - await cleanupOuterResource(fallbackClaudeSkillsPlugin?.cleanup); - } await cleanupOuterResource(systemPromptFile?.cleanup); await cleanupOuterResource(imagePayload.cleanupImages); } catch (error) { diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index 084a8bb2bf70..8ba3fe25046b 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -73,16 +73,15 @@ export function resolveCliRunQueueKey(params: { cliSessionId?: string; ownerKey?: string; }): string { - const requiresLiveSessionSerialization = - isClaudeCliBackendId(params.backendId) && params.liveSession === "claude-stdio"; + const requiresLiveSessionSerialization = params.liveSession !== undefined; if (params.serialize === false && !requiresLiveSessionSerialization) { return `${params.backendId}:${params.runId}`; } + const ownerKey = params.ownerKey?.trim(); + if (requiresLiveSessionSerialization && ownerKey) { + return `${params.backendId}:owner:${ownerKey}`; + } if (isClaudeCliBackendId(params.backendId)) { - const ownerKey = params.ownerKey?.trim(); - if (requiresLiveSessionSerialization && ownerKey) { - return `${params.backendId}:owner:${ownerKey}`; - } const sessionId = params.cliSessionId?.trim(); if (sessionId) { return `${params.backendId}:session:${sessionId}`; diff --git a/src/agents/cli-runner/live-session-fingerprint.ts b/src/agents/cli-runner/live-session-fingerprint.ts new file mode 100644 index 000000000000..b098a6012438 --- /dev/null +++ b/src/agents/cli-runner/live-session-fingerprint.ts @@ -0,0 +1,100 @@ +import { sha256Hex } from "../../infra/crypto-digest.js"; +import type { PreparedCliRunContext } from "./types.js"; + +/** Fingerprints every process-stable input without retaining secrets or volatile artifact paths. */ +export function buildCliLiveSessionFingerprint(params: { + context: PreparedCliRunContext; + argv: readonly string[]; + env: Readonly>; +}): string { + const context = params.context; + const managedGrant = context.preparedBackend.mcpClientGrantCapture; + const normalizeGrantToken = params.env.OPENCLAW_MCP_TOKEN === managedGrant?.transportToken; + const normalizeMcpConfigPath = Boolean(context.preparedBackend.mcpConfigHash); + const skillSnapshot = context.params.skillsSnapshot; + const skillsFingerprint = skillSnapshot + ? sha256Hex( + JSON.stringify({ + promptHash: sha256Hex(skillSnapshot.prompt), + skillFilter: skillSnapshot.skillFilter, + skills: skillSnapshot.skills, + resolvedSkills: (skillSnapshot.resolvedSkills ?? []).map((skill) => ({ + name: skill.name, + description: skill.description, + filePath: skill.filePath, + sourceInfo: skill.sourceInfo, + })), + version: skillSnapshot.version, + }), + ) + : undefined; + const omittedValueFlags = new Set( + [ + context.preparedBackend.backend.systemPromptArg, + context.preparedBackend.backend.systemPromptFileArg, + "--session-id", + "--resume", + "-r", + ].filter((entry): entry is string => typeof entry === "string" && entry.length > 0), + ); + const unstableValueFlags = new Set( + [ + normalizeMcpConfigPath ? "--mcp-config" : undefined, + skillsFingerprint ? "--plugin-dir" : undefined, + skillsFingerprint ? "--plugin-dir-no-mcp" : undefined, + ].filter((entry): entry is string => typeof entry === "string" && entry.length > 0), + ); + const argv: string[] = []; + for (let index = 0; index < params.argv.length; index += 1) { + const value = params.argv[index] ?? ""; + if (omittedValueFlags.has(value)) { + index += 1; + continue; + } + if ([...omittedValueFlags].some((flag) => value.startsWith(`${flag}=`))) { + continue; + } + if (unstableValueFlags.has(value)) { + argv.push(""); + index += 1; + continue; + } + if ([...unstableValueFlags].some((flag) => value.startsWith(`${flag}=`))) { + argv.push(""); + continue; + } + argv.push(value); + } + + return sha256Hex( + JSON.stringify({ + argv, + workspaceDirHash: sha256Hex(context.workspaceDir), + cwdHash: context.cwdHash ?? sha256Hex(context.cwd ?? context.workspaceDir), + provider: context.params.provider, + model: context.normalizedModel, + // Official SDK sessions cannot update prompts in place: any changed byte requires restart. + systemPromptHash: sha256Hex(context.systemPrompt), + authProfileIdHash: context.effectiveAuthProfileId + ? sha256Hex(context.effectiveAuthProfileId) + : undefined, + authEpochHash: context.authEpoch ? sha256Hex(context.authEpoch) : undefined, + extraSystemPromptHash: context.extraSystemPromptHash, + promptToolNamesHash: context.promptToolNamesHash, + mcpResumeHash: context.preparedBackend.mcpResumeHash ?? context.preparedBackend.mcpConfigHash, + credentialFingerprint: context.preparedBackend.secretInput?.fingerprint, + skillsFingerprint, + env: Object.keys(params.env) + .toSorted() + .filter((key) => key !== "OPENCLAW_MCP_CLI_CAPTURE_KEY") + .map((key) => [ + key, + key === "OPENCLAW_MCP_TOKEN" && normalizeGrantToken + ? "" + : params.env[key] + ? sha256Hex(params.env[key]) + : "", + ]), + }), + ); +} diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index 71a665701568..479883a64f66 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -67,6 +67,7 @@ import { } from "../media-generation-task-status.js"; import type { SandboxWorkspaceInfo } from "../sandbox/types.js"; import type { SystemAgentToolOptions } from "../tools/system-agent-tool.js"; +import { prepareClaudeCliSkillsPlugin } from "./claude-skills-plugin.js"; import { prepareCliRunContext } from "./prepare.js"; import { resetCliRunnerPrepareTestDeps, @@ -455,7 +456,7 @@ describe("prepareCliRunContext", () => { args: [], cleanup: vi.fn(async () => undefined), })), - getClaudeGeneration: vi.fn(() => undefined), + getCliLiveSessionGeneration: vi.fn(() => undefined), readExternalCliBootstrapCredential: readExternalCliBootstrapCredentialImpl, resolveApiKeyForProfile: resolveApiKeyForProfileImpl, // Keep preparation off the real plugin-metadata snapshot; catalog-driven @@ -1570,6 +1571,7 @@ describe("prepareCliRunContext", () => { const skillsCleanup = vi.fn(async () => { fs.rmSync(skillsPluginDir, { recursive: true, force: true }); }); + const revokeMcpLoopbackClientGrant = vi.fn(() => true); fs.mkdirSync(tempRoot, { recursive: true }); fs.mkdirSync(skillsPluginDir, { recursive: true }); setTestEnvValue("TMPDIR", tempRoot); @@ -1585,6 +1587,7 @@ describe("prepareCliRunContext", () => { ensureMcpLoopbackServer: vi.fn(createTestMcpLoopbackServer), createMcpLoopbackServerConfig: vi.fn(createTestMcpLoopbackServerConfig), mintMcpLoopbackClientGrant: vi.fn(createTestMcpLoopbackClientGrant), + revokeMcpLoopbackClientGrant, resolveMcpLoopbackScopedTools: vi.fn(() => ({ agentId: "main", tools: [] })), prepareClaudeCliSkillsPlugin: vi.fn(async () => ({ args: ["--plugin-dir", skillsPluginDir], @@ -1604,6 +1607,7 @@ describe("prepareCliRunContext", () => { ).rejects.toThrow("reference path lookup failed"); expect(skillsCleanup).toHaveBeenCalledOnce(); + expect(revokeMcpLoopbackClientGrant).toHaveBeenCalledExactlyOnceWith("loopback-token"); expect(fs.existsSync(skillsPluginDir)).toBe(false); expect( fs.readdirSync(tempRoot).filter((entry) => entry.startsWith("openclaw-cli-mcp-")), @@ -1627,7 +1631,7 @@ describe("prepareCliRunContext", () => { contextFiles: [{ path: "context.md", content: "context" }], })); const ensureMcpLoopbackServer = vi.fn(createTestMcpLoopbackServer); - const prepareClaudeCliSkillsPlugin = vi.fn(async () => ({ + const prepareClaudeCliSkillsPluginMock = vi.fn(async () => ({ args: ["--plugin-dir", "/tmp/claude-skills"], cleanup: vi.fn(async () => undefined), })); @@ -1652,7 +1656,7 @@ describe("prepareCliRunContext", () => { setCliRunnerPrepareTestDeps({ resolveBootstrapContextForRun, ensureMcpLoopbackServer, - prepareClaudeCliSkillsPlugin, + prepareClaudeCliSkillsPlugin: prepareClaudeCliSkillsPluginMock, makeBootstrapWarn: vi.fn(() => () => undefined), getActiveMcpLoopbackRuntime: vi.fn(() => undefined), createMcpLoopbackServerConfig: vi.fn(createTestMcpLoopbackServerConfig), @@ -1685,7 +1689,7 @@ describe("prepareCliRunContext", () => { expect(resolveBootstrapContextForRun).not.toHaveBeenCalled(); expect(ensureMcpLoopbackServer).not.toHaveBeenCalled(); - expect(prepareClaudeCliSkillsPlugin).not.toHaveBeenCalled(); + expect(prepareClaudeCliSkillsPluginMock).not.toHaveBeenCalled(); expect(mockGetGlobalHookRunner).not.toHaveBeenCalled(); expect(prepareExecution).toHaveBeenCalledWith( expect.objectContaining({ executionMode: "side-question" }), @@ -3578,7 +3582,7 @@ describe("prepareCliRunContext", () => { "did not enforce exact per-run tool availability during execution preparation", ); expect(prepareExecution).toHaveBeenCalledWith( - expect.objectContaining({ toolAvailability: { native: [], openClaw: [], mcp: [] } }), + expect.objectContaining({ toolAvailability: { native: [], openClaw: [] } }), ); expect(cleanup).toHaveBeenCalledOnce(); }); @@ -3736,7 +3740,7 @@ describe("prepareCliRunContext", () => { expect(prepareExecution).toHaveBeenCalledWith( expect.objectContaining({ - toolAvailability: { native: ["Read"], openClaw: [], mcp: [] }, + toolAvailability: { native: ["Read"], openClaw: [] }, }), ); expect(context.params.cliToolAvailability).toEqual({ native: ["Read"], openClaw: [] }); @@ -3853,7 +3857,7 @@ describe("prepareCliRunContext", () => { }); expect(prepareExecution).toHaveBeenCalledWith( expect.objectContaining({ - toolAvailability: { native: [], openClaw: ["read"], mcp: ["mcp__openclaw__read"] }, + toolAvailability: { native: [], openClaw: ["read"] }, }), ); expect(mintMcpLoopbackClientGrant.mock.calls[0]?.[0]?.context.toolsAllow).toEqual(["read"]); @@ -4363,7 +4367,7 @@ describe("prepareCliRunContext", () => { reseedFromRawTranscriptWhenUncompacted: true, }); const ensureMcpLoopbackServer = vi.fn(createTestMcpLoopbackServer); - const prepareClaudeCliSkillsPlugin = vi.fn(async () => ({ + const prepareClaudeCliSkillsPluginMock = vi.fn(async () => ({ args: ["--plugin-dir", "/tmp/gateway-skills"], cleanup: vi.fn(async () => undefined), })); @@ -4371,7 +4375,7 @@ describe("prepareCliRunContext", () => { const orphanCheck = vi.fn(async () => false); setCliRunnerPrepareTestDeps({ ensureMcpLoopbackServer, - prepareClaudeCliSkillsPlugin, + prepareClaudeCliSkillsPlugin: prepareClaudeCliSkillsPluginMock, claudeCliSessionTranscriptHasContent: transcriptCheck, claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck, }); @@ -4418,7 +4422,7 @@ describe("prepareCliRunContext", () => { expect(context.systemPrompt).not.toContain("GATEWAY_ONLY_SKILL_PATH"); expect(context.mcpDeliveryCapture).toBeUndefined(); expect(ensureMcpLoopbackServer).not.toHaveBeenCalled(); - expect(prepareClaudeCliSkillsPlugin).not.toHaveBeenCalled(); + expect(prepareClaudeCliSkillsPluginMock).not.toHaveBeenCalled(); expect(transcriptCheck).not.toHaveBeenCalled(); expect(orphanCheck).not.toHaveBeenCalled(); expect(prepareExecution).toHaveBeenCalledOnce(); @@ -4455,7 +4459,7 @@ describe("prepareCliRunContext", () => { setCliRunnerPrepareTestDeps({ claudeCliSessionTranscriptHasContent: transcriptCheck, claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck, - getClaudeGeneration: getLiveSessionGeneration, + getCliLiveSessionGeneration: getLiveSessionGeneration, }); const context = await fixture.prepare({ @@ -4742,6 +4746,7 @@ describe("prepareCliRunContext", () => { expect(context.systemPrompt).toContain("weather"); expect(context.systemPromptReport.skills.promptChars).toBeGreaterThan(0); expect(context.claudeSkillsPluginArgs).toEqual([]); + expect(context.preparedBackend.claimLiveSessionResources).toBeUndefined(); } else { expect(context.systemPrompt).not.toContain(""); expect(context.systemPrompt).not.toContain("weather"); @@ -4750,9 +4755,81 @@ describe("prepareCliRunContext", () => { "--plugin-dir", path.join(dir, "openclaw-skills"), ]); + expect(context.preparedBackend.claimLiveSessionResources).toEqual(expect.any(Function)); } }); + it("isolates claimed native skills from later turns while cleaning each turn's MCP and auth", async () => { + const { dir } = fixture.session; + const skill = createWeatherSkillFixture(dir, true); + const preparedExecutionCleanup = vi.fn(async () => undefined); + const revokeMcpLoopbackClientGrant = vi.fn(() => true); + setCliBackendForPrepareTest({ + id: "claude-cli", + pluginId: "anthropic", + bundleMcp: true, + prepareExecution: async () => ({ cleanup: preparedExecutionCleanup }), + }); + setCliRunnerPrepareTestDeps({ + prepareClaudeCliSkillsPlugin, + getActiveMcpLoopbackRuntime: vi.fn(() => ({ + port: 31783, + ownerToken: "loopback-owner-token", + nonOwnerToken: "loopback-non-owner-token", + })), + revokeMcpLoopbackClientGrant, + }); + + const context = await fixture.prepare({ + provider: "claude-cli", + model: "opus", + skillsSnapshot: skill.snapshot, + sessionKey: "agent:main:main", + runId: "native-skill-turn-one", + }); + const pluginDir = context.claudeSkillsPluginArgs[1]; + if (!pluginDir) { + throw new Error("Expected materialized skill plugin"); + } + const manifest = JSON.parse( + fs.readFileSync(path.join(pluginDir, ".claude-plugin", "plugin.json"), "utf8"), + ); + expect(manifest).toMatchObject({ name: "openclaw-skills", skills: "./skills" }); + const skillPath = path.join(pluginDir, "skills", "weather", "SKILL.md"); + expect(fs.readFileSync(skillPath, "utf8")).toContain("Read forecast data before replying."); + + const releaseSkills = context.preparedBackend.claimLiveSessionResources?.(); + expect(releaseSkills).toEqual(expect.any(Function)); + expect(context.preparedBackend.claimLiveSessionResources?.()).toBeUndefined(); + try { + await context.preparedBackend.cleanup?.(); + expect(fs.existsSync(skillPath)).toBe(true); + expect(preparedExecutionCleanup).toHaveBeenCalledOnce(); + expect(revokeMcpLoopbackClientGrant).toHaveBeenCalledExactlyOnceWith("loopback-token"); + + const nextTurn = await fixture.prepare({ + provider: "claude-cli", + model: "opus", + skillsSnapshot: skill.snapshot, + sessionKey: "agent:main:main", + runId: "native-skill-turn-two", + }); + const unusedPluginDir = nextTurn.claudeSkillsPluginArgs[1]; + expect(unusedPluginDir).toEqual(expect.any(String)); + expect(unusedPluginDir).not.toBe(pluginDir); + expect(fs.existsSync(unusedPluginDir ?? "")).toBe(true); + + await nextTurn.preparedBackend.cleanup?.(); + expect(fs.existsSync(unusedPluginDir ?? "")).toBe(false); + expect(fs.existsSync(skillPath)).toBe(true); + expect(preparedExecutionCleanup).toHaveBeenCalledTimes(2); + expect(revokeMcpLoopbackClientGrant).toHaveBeenCalledTimes(2); + } finally { + await releaseSkills?.(); + } + expect(fs.existsSync(pluginDir)).toBe(false); + }); + it("does not probe the transcript for non-claude-cli providers", async () => { const { dir } = fixture.session; const transcriptCheck = vi.fn(async () => false); diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 876f6a186cdc..58ce5c27a8a6 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -129,12 +129,12 @@ import { } from "../workspace.js"; import { CliAuthProfilePreparationError } from "./auth-profile-preparation-error.js"; import { prepareCliBundleMcpConfig } from "./bundle-mcp.js"; -import { getClaudeGeneration } from "./claude-live-registry.js"; import { prepareClaudeCliSkillsPlugin } from "./claude-skills-plugin.js"; import { resolveBundledCliBackendAuthPolicy, type BundledCliBackendAuthPolicy, } from "./cli-backend-auth-policy.js"; +import { getCliLiveSessionGeneration } from "./cli-live-session-registry.js"; import { buildCliAgentSystemPrompt, isClaudeCliBackendId, normalizeCliModel } from "./helpers.js"; import { cliBackendLog } from "./log.js"; import { buildCliMcpGrantContext, normalizeOptionalMcpContextValue } from "./mcp-grant-context.js"; @@ -146,7 +146,6 @@ import { loadCliSessionReseedMessages, resolveAutoCliSessionReseedHistoryChars, } from "./session-history.js"; -import { buildCliBackendToolAvailability } from "./tool-policy.js"; import type { CliReusableSession, CliSecretInput, @@ -199,7 +198,7 @@ const defaultPrepareDeps = { prepareClaudeCliSkillsPlugin, claudeCliSessionTranscriptHasContent, claudeCliSessionTranscriptHasOrphanedToolUse, - getClaudeGeneration, + getCliLiveSessionGeneration, readExternalCliBootstrapCredential, resolveApiKeyForProfile, loadManifestModelCatalog, @@ -1340,9 +1339,7 @@ export async function prepareCliRunContext( thinkingLevel: params.thinkLevel === "ultra" ? "max" : params.thinkLevel, authProfileId: effectiveAuthProfileId, executionMode, - toolAvailability: params.cliToolAvailability - ? buildCliBackendToolAvailability(params.cliToolAvailability) - : undefined, + toolAvailability: params.cliToolAvailability, env: preparedBackend.env, } satisfies Parameters>[0]; const privatePrepareExecutionContext = params.isolatedCompletion @@ -1451,11 +1448,24 @@ export async function prepareCliRunContext( backendId: backendResolved.id, skillsSnapshot: params.skillsSnapshot, }); + let claudeSkillsPluginClaimed = false; + const claimLiveSessionResources = + claudeSkillsPlugin.args.length > 0 + ? () => { + if (claudeSkillsPluginClaimed) { + return undefined; + } + claudeSkillsPluginClaimed = true; + return claudeSkillsPlugin.cleanup; + } + : undefined; const preparedCleanup = preparedBackendCleanup || claudeSkillsPlugin.args.length > 0 ? async () => { try { - await claudeSkillsPlugin.cleanup(); + if (!claudeSkillsPluginClaimed) { + await claudeSkillsPlugin.cleanup(); + } } finally { await preparedBackendCleanup?.(); } @@ -1493,6 +1503,8 @@ export async function prepareCliRunContext( ...(preparedBackendBeforeExecution ? { beforeExecution: preparedBackendBeforeExecution } : {}), + ...(claimLiveSessionResources ? { claimLiveSessionResources } : {}), + ...(preparedExecution?.execute ? { execute: preparedExecution.execute } : {}), ...(preparedExecution?.secretInput ? { secretInput: preparedExecution.secretInput } : {}), ...(mcpClientGrantCapture ? { mcpClientGrantCapture } : {}), ...(preparedCleanup ? { cleanup: preparedCleanup } : {}), @@ -1557,7 +1569,7 @@ export async function prepareCliRunContext( preparedBackendFinal.backend.liveSession === "claude-stdio" && preparedBackendFinal.backend.output === "jsonl" && preparedBackendFinal.backend.input === "stdin" && - prepareDeps.getClaudeGeneration({ + prepareDeps.getCliLiveSessionGeneration({ backendId: backendResolved.id, agentAccountId: params.agentAccountId, agentId: workspaceResolution.agentId, diff --git a/src/agents/cli-runner/tool-policy.test.ts b/src/agents/cli-runner/tool-policy.test.ts index 3a11f5670b57..14de71354d95 100644 --- a/src/agents/cli-runner/tool-policy.test.ts +++ b/src/agents/cli-runner/tool-policy.test.ts @@ -1,21 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - buildCliBackendToolAvailability, - resolveCliRuntimeToolsAllow, - stripOpenClawMcpToolPrefix, -} from "./tool-policy.js"; - -describe("buildCliBackendToolAvailability", () => { - it("keeps canonical names and projects the shipped beta MCP transport names", () => { - expect( - buildCliBackendToolAvailability({ native: ["Read"], openClaw: ["message", "write"] }), - ).toEqual({ - native: ["Read"], - openClaw: ["message", "write"], - mcp: ["mcp__openclaw__message", "mcp__openclaw__write"], - }); - }); -}); +import { resolveCliRuntimeToolsAllow, stripOpenClawMcpToolPrefix } from "./tool-policy.js"; describe("stripOpenClawMcpToolPrefix", () => { it("strips only the loopback transport prefix", () => { diff --git a/src/agents/cli-runner/tool-policy.ts b/src/agents/cli-runner/tool-policy.ts index 048585e24f2b..ad4328807f86 100644 --- a/src/agents/cli-runner/tool-policy.ts +++ b/src/agents/cli-runner/tool-policy.ts @@ -1,4 +1,3 @@ -import type { CliBackendToolAvailability } from "../../plugins/cli-backend.types.js"; import { normalizeToolPolicyName } from "../tool-policy.js"; /** Transport prefix CLI harnesses use for loopback OpenClaw MCP tool names. */ @@ -11,18 +10,6 @@ export function stripOpenClawMcpToolPrefix(toolName: string): string { : toolName; } -/** Builds the public backend contract plus the shipped beta MCP-name projection. */ -export function buildCliBackendToolAvailability(availability: { - native: readonly string[]; - openClaw: readonly string[]; -}): CliBackendToolAvailability { - return { - native: availability.native, - openClaw: availability.openClaw, - mcp: availability.openClaw.map((toolName) => `${OPENCLAW_MCP_TOOL_PREFIX}${toolName}`), - }; -} - /** Keeps only explicit runtime caps for backend-owned exact translation. */ export function resolveCliRuntimeToolsAllow( toolsAllow?: string[], diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index 9a7bc056356f..a6f6a8f59a08 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -27,7 +27,11 @@ import type { CronScheduledToolCallerOrigin } from "../../cron/scheduled-tool-po import type { ImageContent } from "../../llm/types.js"; import type { MediaFact } from "../../media/media-facts.js"; import type { PromptImageOrderEntry } from "../../media/prompt-image-order.js"; -import type { CliBackendConfig, CliBackendExecutionMode } from "../../plugins/cli-backend.types.js"; +import type { + CliBackendConfig, + CliBackendExecute, + CliBackendExecutionMode, +} from "../../plugins/cli-backend.types.js"; import type { PluginHookChannelContext } from "../../plugins/hook-types.js"; import type { SpawnSecretInput } from "../../process/supervisor/types.js"; import type { InputProvenance } from "../../sessions/input-provenance.js"; @@ -290,6 +294,10 @@ type CliPreparedBackend = { backend: CliBackendConfig; beforeExecution?: () => Promise; cleanup?: () => Promise; + /** Transfer process-owned native skill artifacts without claiming turn-scoped MCP/auth state. */ + claimLiveSessionResources?: () => (() => Promise) | undefined; + /** Plugin-owned transport bound to this exact prepared local run. */ + execute?: CliBackendExecute; /** Private child-only credential transport; never serialized into env or public plugin state. */ secretInput?: CliSecretInput; /** Gateway-owned capture fence for this prepared bundle-MCP client. */ @@ -347,7 +355,7 @@ export type PreparedCliRunContext = { contextWindowInfo?: ContextWindowInfo; systemPrompt: string; systemPromptReport: SessionSystemPromptReport; - claudeSkillsPluginArgs?: string[] | undefined; + claudeSkillsPluginArgs: string[]; bootstrapPromptWarningLines: string[]; openClawHistoryPrompt?: string; heartbeatPrompt?: string; diff --git a/src/agents/command/attempt-execution.cli.test.ts b/src/agents/command/attempt-execution.cli.test.ts index 19b0366bec1b..dd894b929f80 100644 --- a/src/agents/command/attempt-execution.cli.test.ts +++ b/src/agents/command/attempt-execution.cli.test.ts @@ -354,9 +354,9 @@ vi.mock("../cli-runner.js", () => ({ runCliAgent: runCliAgentMock, })); -vi.mock("../cli-runner/claude-live-registry.js", () => ({ - getClaudeGeneration: vi.fn(() => undefined), - hasClaudeSession: hasClaudeSessionMock, +vi.mock("../cli-runner/cli-live-session-registry.js", () => ({ + getCliLiveSessionGeneration: vi.fn(() => undefined), + hasCliLiveSession: hasClaudeSessionMock, })); vi.mock("../model-selection.js", () => ({ diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index 2c512de52cd5..2aa843e2c135 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -67,7 +67,7 @@ import { resolveCliExecutionAuthProfileId, } from "../cli-execution-auth.js"; import { runCliAgent } from "../cli-runner.js"; -import { hasClaudeSession } from "../cli-runner/claude-live-registry.js"; +import { hasCliLiveSession } from "../cli-runner/cli-live-session-registry.js"; import { resolveCliRuntimeToolsAllow } from "../cli-runner/tool-policy.js"; import { getCliSessionBinding, @@ -846,7 +846,7 @@ export function runAgentAttempt(params: { const hasManagedClaudeLiveSession = Boolean( isClaudeCliProvider(cliExecutionProvider) && cliSessionBinding?.sessionId && - hasClaudeSession({ + hasCliLiveSession({ backendId: cliExecutionProvider, agentAccountId: params.runContext.accountId, agentId: params.sessionAgentId, diff --git a/src/auto-reply/reply/reply-run-registry.ts b/src/auto-reply/reply/reply-run-registry.ts index 741a549d6621..99217a1902fb 100644 --- a/src/auto-reply/reply/reply-run-registry.ts +++ b/src/auto-reply/reply/reply-run-registry.ts @@ -6,7 +6,6 @@ export { ReplyRunSuccessorAdmissionBlockedError, } from "./reply-run-registry.contracts.js"; export type { - ReplyBackendHandle, ReplyBackendMessageInjection, ReplyBackendQueueMessageOptions, ReplyBackendQueueMessageResult, diff --git a/src/cli/program/register.agent-turn.ts b/src/cli/program/register.agent-turn.ts index fc8ffc89211a..799a8e82fe3e 100644 --- a/src/cli/program/register.agent-turn.ts +++ b/src/cli/program/register.agent-turn.ts @@ -68,7 +68,7 @@ export function registerAgentTurnCommand( .option("--reply-account ", "Delivery account id override") .option( "--local", - "Run the embedded agent locally (requires model provider API keys in your shell)", + "Run the embedded agent locally using configured provider credentials or local CLI logins", false, ) .option("--deliver", "Send the agent's reply back to the selected channel", false) diff --git a/src/cli/program/register.agent.test.ts b/src/cli/program/register.agent.test.ts index b5628a002099..6c450c3270c3 100644 --- a/src/cli/program/register.agent.test.ts +++ b/src/cli/program/register.agent.test.ts @@ -108,7 +108,7 @@ describe("agent command registration", () => { return call; } - it("keeps both agent thinking help surfaces aligned with the canonical levels", () => { + it("keeps agent help aligned with supported thinking levels and auth sources", () => { const program = new Command(); registerAgentTurnCommand(program, { agentChannelOptions: "last|telegram|discord" }); const agent = program.commands.find((command) => command.name() === "agent"); @@ -120,6 +120,9 @@ describe("agent command registration", () => { expect(exec?.options.find((option) => option.long === "--thinking")?.description).toContain( "ultra", ); + expect(agent?.options.find((option) => option.long === "--local")?.description).toContain( + "configured provider credentials or local CLI logins", + ); }); it("runs agent command with verbose enabled for --verbose on", async () => { diff --git a/src/commands/doctor-claude-cli.test.ts b/src/commands/doctor-claude-cli.test.ts index a7e59820b484..9235a9f03a2d 100644 --- a/src/commands/doctor-claude-cli.test.ts +++ b/src/commands/doctor-claude-cli.test.ts @@ -163,45 +163,6 @@ describe("noteClaudeCliHealth", () => { }); }); - it("advises on a version below the first-known floor without declaring it unsupported", async () => { - await withTempHome(({ homeDir, workspaceDir }) => { - resolveCliBackendConfigMock.mockReturnValue({ - id: "claude-cli", - pluginId: "anthropic", - config: { command: "claude" }, - liveSessionRequirement: { - capability: "msg_lifecycle_v1", - minimumVersion: "2.1.206", - versionArgs: ["--version"], - updateCommand: "claude update", - }, - }); - const noteFn = vi.fn(); - - noteClaudeCliHealth( - { - agents: { - defaults: { model: "claude-cli/claude-sonnet-4-6" }, - entries: { main: { default: true } }, - }, - }, - { - homeDir, - workspaceDir, - noteFn, - store: createStore(), - readClaudeCliCredentials: () => ({ type: "api_key_helper" }), - resolveCommandPath: () => "/opt/homebrew/bin/claude", - resolveCommandVersion: () => "2.1.205 (Claude Code)", - }, - ); - - expect(noteBody(noteFn)).toContain( - "Binary version advisory: Claude Code 2.1.206 is the first published build known to advertise msg_lifecycle_v1; found 2.1.205. OpenClaw verifies this capability at runtime. If this build is rejected, run `claude update`, restart OpenClaw, and retry.", - ); - }); - }); - it("stays quiet for a healthy non-default Claude CLI runtime agent", async () => { await withTempHome(({ homeDir, workspaceDir }) => { resolveModelAgentRuntimeMetadataMock.mockImplementation(({ agentId }) => ({ diff --git a/src/commands/doctor-claude-cli.ts b/src/commands/doctor-claude-cli.ts index eb99533783f3..83446f08f9bf 100644 --- a/src/commands/doctor-claude-cli.ts +++ b/src/commands/doctor-claude-cli.ts @@ -1,5 +1,4 @@ /** Doctor health note for Claude CLI binary, auth, and workspace/project directories. */ -import { spawnSync } from "node:child_process"; import fs from "node:fs"; import { normalizeOptionalLowercaseString, @@ -20,10 +19,6 @@ import type { OAuthCredential, TokenCredential, } from "../agents/auth-profiles/types.js"; -import { - formatCliBackendVersionAdvisory, - resolveCliBackendVersionGuidance, -} from "../agents/cli-backend-version-support.js"; import { resolveCliBackendConfig } from "../agents/cli-backends.js"; import { readClaudeCliCredentialsCached } from "../agents/cli-credentials.js"; import { resolveClaudeCliProjectDirForWorkspace } from "../agents/command/claude-cli-project-dir.js"; @@ -53,22 +48,6 @@ function usesClaudeCliModelSelection(cfg: OpenClawConfig): boolean { ); } -function resolveCommandVersion( - commandPath: string, - args: readonly string[], - env: NodeJS.ProcessEnv, -): string | undefined { - const result = spawnSync(commandPath, [...args], { - encoding: "utf8", - env, - maxBuffer: 16 * 1024, - timeout: 1_500, - windowsHide: true, - }); - const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); - return output.split(/\r?\n/u)[0]?.trim() || undefined; -} - function probeDirectoryHealth(dirPath: string): ClaudeCliDirHealth { try { const stat = fs.statSync(dirPath); @@ -203,11 +182,6 @@ export function noteClaudeCliHealth( store?: AuthProfileStore; readClaudeCliCredentials?: () => ClaudeCliReadableCredential | null; resolveCommandPath?: (command: string, env?: NodeJS.ProcessEnv) => string | undefined; - resolveCommandVersion?: ( - commandPath: string, - args: readonly string[], - env: NodeJS.ProcessEnv, - ) => string | undefined; workspaceDir?: string; }, ) { @@ -251,25 +225,6 @@ export function noteClaudeCliHealth( ); } - const liveSessionRequirement = backend?.liveSessionRequirement; - if (commandPath && liveSessionRequirement) { - const versionOutput = (deps?.resolveCommandVersion ?? resolveCommandVersion)( - commandPath, - liveSessionRequirement.versionArgs, - env, - ); - const guidance = resolveCliBackendVersionGuidance(versionOutput, liveSessionRequirement); - if (guidance.status === "below-known-floor") { - lines.push( - `- Binary version advisory: ${formatCliBackendVersionAdvisory({ - label: "Claude Code", - requirement: liveSessionRequirement, - version: guidance.version, - })}`, - ); - } - } - if (!credential) { lines.push("- Headless Claude auth: unavailable without interactive prompting."); fixHints.push( diff --git a/src/commands/onboard-inference.test.ts b/src/commands/onboard-inference.test.ts index 31f79114e099..3f5ab2f2164d 100644 --- a/src/commands/onboard-inference.test.ts +++ b/src/commands/onboard-inference.test.ts @@ -233,36 +233,6 @@ describe("detectInferenceBackends", () => { expect(candidates[1]?.credentials).toBeUndefined(); }); - it("keeps a lower-version Claude wrapper selectable with capability guidance", async () => { - const candidates = await detectInferenceBackends({ - env: {}, - platform: "linux", - deps: { - probeLocalCommand: async (command) => ({ - command, - found: command === "claude", - ...(command === "claude" ? { version: "2.1.205 (Claude Code)" } : {}), - }), - readClaudeCliCredentials: () => ({ type: "oauth" }), - resolveClaudeLiveSessionRequirement: () => ({ - capability: "msg_lifecycle_v1", - minimumVersion: "2.1.206", - versionArgs: ["--version"], - updateCommand: "claude update", - }), - }, - }); - - expect(candidates).toMatchObject([ - { - kind: "claude-cli", - credentials: true, - detail: - "logged in · Claude subscription; Claude Code 2.1.206 is the first published build known to advertise msg_lifecycle_v1; found 2.1.205. OpenClaw verifies this capability at runtime. If this build is rejected, run `claude update`, restart OpenClaw, and retry.", - }, - ]); - }); - it("keeps a logged-in Gemini CLI after environment keys", async () => { const candidates = await detectInferenceBackends({ env: { OPENAI_API_KEY: "sk-x" }, diff --git a/src/commands/onboard-inference.ts b/src/commands/onboard-inference.ts index d7a1868f5b20..a3239f7040cc 100644 --- a/src/commands/onboard-inference.ts +++ b/src/commands/onboard-inference.ts @@ -4,11 +4,6 @@ import os from "node:os"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { resolveAgentConfig } from "../agents/agent-scope-config.js"; -import { - formatCliBackendVersionAdvisory, - resolveCliBackendVersionGuidance, -} from "../agents/cli-backend-version-support.js"; -import { resolveCliBackendLiveSessionRequirement } from "../agents/cli-backends.js"; import { readClaudeCliCredentialsCached, readCodexCliCredentialsCached, @@ -51,7 +46,6 @@ type DetectInferenceBackendsDeps = { readGeminiCliCredentials?: () => { type: string } | null; detectCodexLoginState?: typeof detectCodexLoginState; randomInt?: (maxExclusive: number) => number; - resolveClaudeLiveSessionRequirement?: typeof resolveCliBackendLiveSessionRequirement; }; type DetectInferenceBackendsOptions = { @@ -274,13 +268,6 @@ export async function detectInferenceBackends( const cliCandidates: InferenceBackendCandidate[] = []; const subscriptionPromotionEligibleCliKinds = new Set(); if (claudeProbe.found && !claudeProbe.timedOut) { - const liveSessionRequirement = - ( - options.deps?.resolveClaudeLiveSessionRequirement ?? resolveCliBackendLiveSessionRequirement - )("claude-cli") ?? undefined; - const versionGuidance = liveSessionRequirement - ? resolveCliBackendVersionGuidance(claudeProbe.version, liveSessionRequirement) - : { status: "unknown" as const }; const claudeCredential = readClaude(); const credentials = detectCliCredentialState({ probe: claudeProbe, @@ -294,20 +281,11 @@ export async function detectInferenceBackends( { credentials, authKind: classifyClaudeCliAuth(claudeCredential, env) }, "run `claude auth login`", ); - // Only the live init record can prove capability support. Keep backports and - // wrappers selectable here even when their version predates the known release. cliCandidates.push({ kind: "claude-cli", modelRef: CLAUDE_CLI_DEFAULT_MODEL_REF, label: "Claude Code", - detail: - versionGuidance.status === "below-known-floor" && liveSessionRequirement - ? `${detail}; ${formatCliBackendVersionAdvisory({ - label: "Claude Code", - requirement: liveSessionRequirement, - version: versionGuidance.version, - })}` - : detail, + detail, ...(credentials === undefined ? {} : { credentials }), }); } diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index be6c1997d99e..490d1994270e 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -268,7 +268,7 @@ describe("Dockerfile", () => { expect(dockerfile).toContain( 'node /tmp/docker-plugin-selection.mjs "/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}" "$OPENCLAW_EXTENSIONS"', ); - expect(dockerfile).toContain("done < /out/openclaw-selected-plugin-dirs"); + expect(dockerfile).toContain("done < /tmp/openclaw-workspace-plugin-dirs"); expect(dockerfile).toContain(`if [ -f "$ext_dir/package.json" ]; then`); expect(dockerfile).toContain( "COPY --from=workspace-deps /out/openclaw-selected-plugin-dirs /tmp/openclaw-selected-plugin-dirs", diff --git a/src/gateway/gateway-cli-backend.live.test.ts b/src/gateway/gateway-cli-backend.live.test.ts index 4cd550b6ad0f..3c3ac4b4624e 100644 --- a/src/gateway/gateway-cli-backend.live.test.ts +++ b/src/gateway/gateway-cli-backend.live.test.ts @@ -10,7 +10,7 @@ import { type ResolvedCliBackend, } from "../agents/cli-backends.js"; import { testing as cliBackendsTesting } from "../agents/cli-backends.test-support.js"; -import { getClaudeGeneration } from "../agents/cli-runner/claude-live-registry.js"; +import { getCliLiveSessionGeneration } from "../agents/cli-runner/cli-live-session-registry.js"; import { isLiveTestEnabled } from "../agents/live-test-helpers.js"; import { shouldSkipLiveProviderDrift } from "../agents/live-test-provider-drift.js"; import { parseModelRef } from "../agents/model-selection.js"; @@ -643,7 +643,7 @@ describeLive("gateway live (cli backend)", () => { } logCliBackendLiveStep("agent-request:done", { status: payload?.status }); - let cacheProbeOwner: Parameters[0] | undefined; + let cacheProbeOwner: Parameters[0] | undefined; let cacheProbeSteadyGeneration: string | undefined; if (CLI_CACHE_PROBE) { const history = await activeClient.request<{ sessionId?: string }>("chat.history", { @@ -742,7 +742,7 @@ describeLive("gateway live (cli backend)", () => { ).toBe(true); } else if (CLI_RESUME) { logCliBackendLiveStep("agent-resume:start", { sessionKey, resumeNonce }); - let continuityOwner: Parameters[0] | undefined; + let continuityOwner: Parameters[0] | undefined; let expectedLiveSessionGeneration: string | undefined; if (resumeContinuityProbe) { const nativeHistory = await activeClient.request<{ @@ -763,7 +763,7 @@ describeLive("gateway live (cli backend)", () => { sessionId: continuitySessionId, sessionKey, }; - expectedLiveSessionGeneration = getClaudeGeneration(continuityOwner); + expectedLiveSessionGeneration = getCliLiveSessionGeneration(continuityOwner); expect(expectedLiveSessionGeneration).toBeTruthy(); } const resumePayload = await requestWithCodexTimeoutRetry( @@ -811,7 +811,9 @@ describeLive("gateway live (cli backend)", () => { if (!continuityOwner || !expectedLiveSessionGeneration) { throw new Error("Claude CLI continuity probe lost its live-session generation"); } - expect(getClaudeGeneration(continuityOwner)).toBe(expectedLiveSessionGeneration); + expect(getCliLiveSessionGeneration(continuityOwner)).toBe( + expectedLiveSessionGeneration, + ); } else { expect( matchesCliBackendReply(resumeText, `CLI backend RESUME OK ${resumeNonce}.`), @@ -857,7 +859,7 @@ describeLive("gateway live (cli backend)", () => { } // The first turn advertises one bootstrap-only tool. Allow the no-tool settle turn to // run hot or cold, then capture the steady process after any valid schema rotation. - cacheProbeSteadyGeneration = getClaudeGeneration(cacheProbeOwner!); + cacheProbeSteadyGeneration = getCliLiveSessionGeneration(cacheProbeOwner!); expect(cacheProbeSteadyGeneration).toBeTruthy(); const cacheNonce = randomBytes(3).toString("hex").toUpperCase(); @@ -872,7 +874,7 @@ describeLive("gateway live (cli backend)", () => { return; } expect(cacheHitRate).toBeGreaterThanOrEqual(CLI_BACKEND_MIN_CACHE_HIT_RATE); - expect(getClaudeGeneration(cacheProbeOwner!)).toBe(cacheProbeSteadyGeneration); + expect(getCliLiveSessionGeneration(cacheProbeOwner!)).toBe(cacheProbeSteadyGeneration); const thinkingPatchPayload = await activeClient.request("sessions.patch", { key: sessionKey, @@ -899,7 +901,7 @@ describeLive("gateway live (cli backend)", () => { // models that render the thinking configuration ahead of them. Assert the required // process rotation here; the following steady turn proves the new prefix is reusable. // https://platform.claude.com/docs/en/build-with-claude/prompt-caching#what-invalidates-the-cache - const switchedGeneration = getClaudeGeneration(cacheProbeOwner!); + const switchedGeneration = getCliLiveSessionGeneration(cacheProbeOwner!); expect(switchedGeneration).toBeTruthy(); expect(switchedGeneration).not.toBe(cacheProbeSteadyGeneration); @@ -912,7 +914,7 @@ describeLive("gateway live (cli backend)", () => { return; } expect(steadyHitRate).toBeGreaterThanOrEqual(CLI_BACKEND_MIN_CACHE_HIT_RATE); - expect(getClaudeGeneration(cacheProbeOwner!)).toBe(switchedGeneration); + expect(getCliLiveSessionGeneration(cacheProbeOwner!)).toBe(switchedGeneration); } } diff --git a/src/plugin-sdk/cli-backend.ts b/src/plugin-sdk/cli-backend.ts index b822426b3b2e..7b6379f2db38 100644 --- a/src/plugin-sdk/cli-backend.ts +++ b/src/plugin-sdk/cli-backend.ts @@ -4,9 +4,13 @@ export type { CliBackendAuthEpochMode, CliBackendConfig, + CliBackendExecute, + CliBackendExecuteContext, CliBackendExecutionMode, CliBackendJsonlUsage, - CliBackendLiveSessionRequirement, + CliBackendLiveSessionCapability, + CliBackendLiveSessionCloseReason, + CliBackendLiveSessionHandle, CliBackendNormalizeConfigContext, CliBackendNativeToolMode, CliBackendParseJsonlEvent, @@ -20,8 +24,10 @@ export type { CliBackendSideQuestionToolMode, CliBackendToolAvailability, CliBackendToolAvailabilityEnforcement, + CliBackendToolPermissionRequest, + CliBackendToolPermissionResult, CliBackendThinkingLevel, -} from "../plugins/types.js"; +} from "../plugins/cli-backend.types.js"; export type { CliBackendRuntimeArtifactPolicy } from "../plugins/cli-backend.types.js"; export { CliBackendAuthProfilePreparationError } from "../plugins/cli-backend-errors.js"; export { diff --git a/src/plugins/cli-backend.types.ts b/src/plugins/cli-backend.types.ts index 13c32bab8468..cb73413563b2 100644 --- a/src/plugins/cli-backend.types.ts +++ b/src/plugins/cli-backend.types.ts @@ -136,6 +136,8 @@ export type CliBackendPreparedExecution = { cleanup?: () => Promise; /** Positive acknowledgement for `prepare-execution` tool enforcement. */ toolAvailabilityEnforced?: true; + /** Optional plugin-owned execution transport for this prepared local run. */ + execute?: CliBackendExecute; }; export type CliBackendThinkingLevel = @@ -155,13 +157,75 @@ export type CliBackendToolAvailability = { native: readonly string[]; /** Canonical OpenClaw tool names served through the host-isolated transport. */ openClaw: readonly string[]; - /** - * @deprecated Compatibility projection for CLI backend plugins built against - * v2026.7.2-beta.1 through v2026.7.2-beta.3. Use `openClaw` for canonical names. - */ - mcp: readonly string[]; }; +/** Native action a plugin-owned runtime asks the admitted host run to authorize. */ +export type CliBackendToolPermissionRequest = { + toolName: string; + toolInput: Record; + toolCallId?: string; + abortSignal?: AbortSignal; +}; + +/** Host-owned native action decision; plugins never acquire approval authority. */ +export type CliBackendToolPermissionResult = + | { behavior: "allow"; updatedInput: Record } + | { behavior: "deny"; message: string }; + +/** Lifecycle reasons accepted by a plugin-owned reusable execution process. */ +export type CliBackendLiveSessionCloseReason = + | "idle" + | "restart" + | "abort" + | "mcp-capture-rotation"; + +/** Plugin-owned process lifecycle registered with the generic host owner. */ +export type CliBackendLiveSessionHandle = { + generation: string; + fingerprint: string; + isIdle(): boolean; + close(reason: CliBackendLiveSessionCloseReason, error?: unknown): void; + waitForExit(): Promise; +}; + +/** Closure-bound host capability for one admitted reusable-runtime turn. */ +export type CliBackendLiveSessionCapability = { + fingerprint: string; + current(): CliBackendLiveSessionHandle | undefined; + register(handle: CliBackendLiveSessionHandle): void; + /** Rebinds this exact admitted turn to the registered process's stable capture. */ + activate(handle: CliBackendLiveSessionHandle): void; + remove(handle: CliBackendLiveSessionHandle): void; +}; + +/** Exact prepared local process facts consumed by a plugin-owned execution transport. */ +export type CliBackendExecuteContext = { + command: string; + args: readonly string[]; + cwd: string; + env: Record; + prompt: string; + modelId: string; + systemPrompt: string; + sessionId?: string; + useResume: boolean; + abortSignal?: AbortSignal; + timeoutMs: number; + executionMode?: CliBackendExecutionMode; + toolAvailability?: CliBackendToolAvailability; + /** Exact host-owned reusable process lifecycle and current-turn admission. */ + liveSession?: CliBackendLiveSessionCapability; + /** Closure-bound approval capability; retained copies fail after the run closes. */ + requestToolPermission: ( + request: CliBackendToolPermissionRequest, + ) => Promise; +}; + +/** Plugin-owned runtime yielding the backend's existing structured stream records. */ +export type CliBackendExecute = ( + context: CliBackendExecuteContext, +) => AsyncIterable>; + export type CliBackendResolveExecutionArgsContext = { config?: OpenClawConfig; workspaceDir: string; @@ -262,18 +326,6 @@ export type CliBackendRuntimeArtifactPolicy = Readonly<{ nativeExecutableNames?: readonly string[]; }>; -/** Provider-owned protocol requirement for a long-lived CLI session. */ -export type CliBackendLiveSessionRequirement = Readonly<{ - /** Exact capability the CLI must advertise before streamed output is trusted. */ - capability: string; - /** First published version known to advertise the capability; runtime still feature-detects. */ - minimumVersion: string; - /** Arguments used by setup and Doctor to obtain the installed CLI version. */ - versionArgs: readonly string[]; - /** Operator command that installs a compatible CLI version. */ - updateCommand: string; -}>; - /** Complete backend-owned contract for in-place native session compaction. */ type CliBackendManualCompaction = Readonly<{ /** Builds the exact backend command for the resumed native session. */ @@ -324,8 +376,6 @@ type CliBackendPluginBase = { }; /** Required whenever this backend can become a verified inference owner. */ runtimeArtifact?: CliBackendRuntimeArtifactPolicy; - /** Negotiated protocol capability required by this backend's live-session transport. */ - liveSessionRequirement?: CliBackendLiveSessionRequirement; /** * Whether OpenClaw should inject bundle MCP config for this backend. * diff --git a/src/plugins/contracts/package-manifest.contract.test.ts b/src/plugins/contracts/package-manifest.contract.test.ts index f1e23ba281ec..3852816264ee 100644 --- a/src/plugins/contracts/package-manifest.contract.test.ts +++ b/src/plugins/contracts/package-manifest.contract.test.ts @@ -11,6 +11,10 @@ import { type PackageManifestContractParams = Parameters[0]; const packageManifestContractTests: PackageManifestContractParams[] = [ + { + pluginId: "anthropic", + pluginLocalRuntimeDeps: ["@anthropic-ai/claude-agent-sdk"], + }, { pluginId: "buzz", pluginLocalRuntimeDeps: ["nostr-tools"], diff --git a/src/plugins/types.ts b/src/plugins/types.ts index b809b845ce50..95d3e91fb397 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -8,24 +8,11 @@ export type { AgentHarness } from "../agents/harness/types.js"; export type { AnyAgentTool } from "../agents/tools/common.js"; export type { CliBackendAuthEpochMode, - CliBackendConfig, - CliBackendExecutionMode, - CliBackendJsonlUsage, - CliBackendLiveSessionRequirement, CliBackendNormalizeConfigContext, CliBackendNativeToolMode, - CliBackendParseJsonlEvent, - CliBackendParseJsonlEventContext, - CliBackendParsedJsonlEvent, CliBackendPlugin, - CliBackendPreparedExecution, - CliBackendPrepareExecutionContext, - CliBackendResolveExecutionArgs, - CliBackendResolveExecutionArgsContext, CliBackendSideQuestionToolMode, - CliBackendToolAvailability, CliBackendToolAvailabilityEnforcement, - CliBackendThinkingLevel, CliBundleMcpMode, PluginTextTransforms, } from "./cli-backend.types.js"; diff --git a/src/system-agent/setup-inference.test.ts b/src/system-agent/setup-inference.test.ts index a97507459661..5eea35dc8dc2 100644 --- a/src/system-agent/setup-inference.test.ts +++ b/src/system-agent/setup-inference.test.ts @@ -1289,14 +1289,13 @@ describe("detectSetupInference", () => { expect(probeLocalCommand).not.toHaveBeenCalledWith("agy"); }); - it("keeps lower-version capability-compatible CLI wrappers available for activation", async () => { + it("preserves Agent SDK runtime guidance for an available Claude CLI", async () => { vi.mocked(detectInferenceBackends).mockResolvedValueOnce([ { kind: "claude-cli", modelRef: "claude-cli/claude-opus-5", label: "Claude Code", - detail: - "logged in; Claude Code 2.1.206 is the first published build known to advertise msg_lifecycle_v1; found 2.1.205. OpenClaw verifies this capability at runtime.", + detail: "logged in; Claude Agent SDK uses the installed Claude Code executable.", credentials: true, }, ]); @@ -1310,8 +1309,7 @@ describe("detectSetupInference", () => { { brandId: "claude", credentials: true, - detail: - "logged in; Claude Code 2.1.206 is the first published build known to advertise msg_lifecycle_v1; found 2.1.205. OpenClaw verifies this capability at runtime.", + detail: "logged in; Claude Agent SDK uses the installed Claude Code executable.", kind: "claude-cli", label: "Claude Code", modelRef: "claude-cli/claude-opus-5", diff --git a/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts index 70269c012023..4b10ac239dfe 100644 --- a/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts @@ -254,6 +254,7 @@ describe("package-openclaw-for-docker", () => { "scripts/lib/optional-bundled-clusters.mjs", "scripts/lib/output-root-guard.mjs", "scripts/lib/record-shared.mjs", + "scripts/lib/root-package-bundled-plugin-excludes.mjs", "scripts/lib/windows-cmd-helpers-runtime.mts", "scripts/lib/windows-taskkill.mjs", ]; diff --git a/test/openclaw-prepack.test.ts b/test/openclaw-prepack.test.ts index 76c457ec6e84..d84125b67891 100644 --- a/test/openclaw-prepack.test.ts +++ b/test/openclaw-prepack.test.ts @@ -31,6 +31,7 @@ const standaloneBundledChannelSmokeFiles = [ "scripts/lib/optional-bundled-clusters.mjs", "scripts/lib/package-root-args.mts", "scripts/lib/record-shared.mjs", + "scripts/lib/root-package-bundled-plugin-excludes.mjs", "scripts/process-warning-filter.mts", ]; diff --git a/test/scripts/docker-plugin-selection.test.ts b/test/scripts/docker-plugin-selection.test.ts index 4ccbddb786fe..8a9e7b64edd4 100644 --- a/test/scripts/docker-plugin-selection.test.ts +++ b/test/scripts/docker-plugin-selection.test.ts @@ -9,10 +9,25 @@ const repoRoot = path.resolve(fileURLToPath(new URL("../..", import.meta.url))); const selectorScript = path.join(repoRoot, "scripts/lib/docker-plugin-selection.mjs"); const tempDirs = useAutoCleanupTempDirTracker(afterEach); -function writePlugin(extensionsRoot: string, dirName: string, manifestId?: string) { +function writePlugin( + extensionsRoot: string, + dirName: string, + manifestId?: string, + dependencies?: Record, + requiredPlatformPackages?: string[], +) { const pluginDir = path.join(extensionsRoot, dirName); fs.mkdirSync(pluginDir, { recursive: true }); - fs.writeFileSync(path.join(pluginDir, "package.json"), `${JSON.stringify({ name: dirName })}\n`); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + `${JSON.stringify({ + name: dirName, + ...(dependencies && { dependencies }), + ...(requiredPlatformPackages && { + openclaw: { install: { requiredPlatformPackages } }, + }), + })}\n`, + ); if (manifestId) { fs.writeFileSync( path.join(pluginDir, "openclaw.plugin.json"), @@ -21,13 +36,62 @@ function writePlugin(extensionsRoot: string, dirName: string, manifestId?: strin } } -function runSelector(extensionsRoot: string, selection: string) { - return spawnSync(process.execPath, [selectorScript, extensionsRoot, selection], { +function runSelector( + extensionsRoot: string, + selection: string, + rootPackagePath?: string, + requiredPlatformPackages = false, +) { + const args = [selectorScript, extensionsRoot, selection]; + if (rootPackagePath) { + args.push("--required-bundled", rootPackagePath); + } else if (requiredPlatformPackages) { + args.push("--required-platform-packages"); + } + return spawnSync(process.execPath, args, { encoding: "utf8", }); } describe("Docker plugin selection", () => { + it("includes required core-bundled dependencies without changing optional plugin selections", () => { + const fixtureRoot = tempDirs.make("openclaw-docker-required-bundled-plugins-"); + const extensionsRoot = path.join(fixtureRoot, "extensions"); + const rootPackagePath = path.join(fixtureRoot, "package.json"); + fs.mkdirSync(extensionsRoot); + fs.writeFileSync( + rootPackagePath, + `${JSON.stringify({ files: ["dist/", "!dist/extensions/optional-provider/**"] })}\n`, + ); + writePlugin(extensionsRoot, "bundled-provider", "bundled", { "provider-sdk": "1.0.0" }); + writePlugin(extensionsRoot, "bundled-without-deps", "without-deps"); + writePlugin(extensionsRoot, "optional-provider", "optional", { "optional-sdk": "1.0.0" }, [ + "optional-native-linux-arm64", + "optional-native-darwin-arm64", + "optional-native-linux-arm64", + ]); + + const required = runSelector(extensionsRoot, "", rootPackagePath); + expect(required.status).toBe(0); + expect(required.stderr).toBe(""); + expect(required.stdout).toBe("bundled-provider\n"); + + const install = runSelector(extensionsRoot, "optional", rootPackagePath); + expect(install.status).toBe(0); + expect(install.stdout).toBe("bundled-provider\noptional-provider\n"); + + const selected = runSelector(extensionsRoot, "optional"); + expect(selected.status).toBe(0); + expect(selected.stdout).toBe("optional-provider\n"); + + const platformPackages = runSelector(extensionsRoot, "optional", undefined, true); + expect(platformPackages.status).toBe(0); + expect(platformPackages.stdout).toBe( + "optional-native-darwin-arm64\noptional-native-linux-arm64\n", + ); + expect(runSelector(extensionsRoot, "", undefined, true).stdout).toBe(""); + }); + it("resolves manifest ids and source directory names deterministically", () => { const extensionsRoot = tempDirs.make("openclaw-docker-plugin-selection-"); writePlugin(extensionsRoot, "source-only");