From 3c4a1ec905fa4938420fecbc241ba3cd53ed38a7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Jul 2026 00:25:29 -0700 Subject: [PATCH] refactor(agents): move CLI backend adapters from config DSL to registerCliBackend plugins (review request) (#112539) * refactor(agents): move CLI backend adapters into plugins * test(agents): register CLI backend fixtures through plugins --- Dockerfile | 1 + config/max-lines-baseline.txt | 2 - docs/.generated/config-baseline.counts.json | 2 +- docs/.generated/config-baseline.sha256 | 4 +- docs/concepts/agent-loop.md | 2 +- docs/docs_map.md | 4 +- docs/gateway/authentication.md | 4 +- docs/gateway/cli-backends.md | 90 +- docs/gateway/config-agents.md | 43 +- docs/gateway/security/audit-checks.md | 1 - docs/install/docker.md | 11 +- docs/plugins/cli-backend-plugins.md | 65 +- docs/plugins/sdk-overview.md | 11 +- extensions/anthropic/cli-shared.test.ts | 45 + extensions/google/setup-api.test.ts | 38 + .../agent-command.live-model-switch.test.ts | 1 - .../auth-profiles.external-cli-scope.test.ts | 6 - src/agents/cli-auth-epoch.test.ts | 24 +- src/agents/cli-backends.test.ts | 1237 +++-------------- src/agents/cli-backends.ts | 153 +- src/agents/cli-output.ts | 2 +- ...cli-runner.before-agent-reply-cron.test.ts | 34 +- src/agents/cli-runner.reliability.test.ts | 31 +- src/agents/cli-runner/bundle-mcp.ts | 2 +- .../claude-live-session.test-support.ts | 2 +- src/agents/cli-runner/claude-live-session.ts | 2 +- .../helpers.system-prompt-resume.test.ts | 2 +- src/agents/cli-runner/helpers.ts | 2 +- src/agents/cli-runner/prepare.test.ts | 67 +- src/agents/cli-runner/prepare.ts | 2 +- src/agents/cli-runner/reliability.ts | 2 +- src/agents/cli-runner/types.ts | 2 +- .../command/attempt-execution.cli.test.ts | 16 +- src/agents/command/session-store.test.ts | 58 +- .../run.overflow-compaction.test.ts | 3 - src/agents/harness/selection.test.ts | 21 - src/agents/model-fallback.test.ts | 16 +- src/agents/model-runtime-aliases.test.ts | 10 +- src/agents/model-selection-cli.ts | 4 - .../agent-runner-execution-runtime.test.ts | 10 +- .../reply/agent-runner-memory.test.ts | 15 +- .../agent-runner-runtime-selection.test.ts | 14 +- .../reply/agent-runner.media-paths.test.ts | 5 +- .../agent-runner.misc.runreplyagent.test.ts | 18 +- .../agent-runner.runreplyagent.e2e.test.ts | 10 +- src/auto-reply/reply/followup-runner.test.ts | 71 +- src/auto-reply/status.test.ts | 12 +- src/cli/config-model-validation.test.ts | 40 +- src/commands/doctor-claude-cli.test.ts | 32 + src/commands/doctor-claude-cli.ts | 16 +- .../doctor-legacy-config.migrations.test.ts | 5 +- .../doctor/shared/deprecation-compat.test.ts | 1 + .../doctor/shared/deprecation-compat.ts | 16 + ...ig-migrations.runtime.cli-backends.test.ts | 44 + ...-config-migrations.runtime.cli-backends.ts | 33 + .../legacy-config-migrations.runtime.ts | 2 + .../shared/stale-agent-model-ref-repair.ts | 9 - src/commands/models/list.status.test.ts | 6 +- src/config/dead-config-keys.test.ts | 5 +- src/config/io.write-prepare.test.ts | 76 +- src/config/schema.help.agents.ts | 1 - src/config/schema.labels.ts | 1 - src/config/types.agent-defaults.ts | 85 -- src/config/zod-schema.agent-defaults.ts | 2 - src/config/zod-schema.core.ts | 60 - src/gateway/gateway-cli-backend.live.test.ts | 65 +- src/gateway/server-startup.test.ts | 6 - src/plugin-sdk/cli-backend.ts | 2 +- src/plugins/cli-backend.types.ts | 92 +- src/plugins/types.ts | 1 + src/security/audit-exec-surface.test.ts | 135 +- src/security/audit.ts | 145 +- src/status/status-message.test.ts | 6 +- src/system-agent/agent-turn.test.ts | 160 +-- src/system-agent/assistant.configured.test.ts | 2 +- src/system-agent/chat-engine.test.ts | 2 - src/system-agent/config-write-parity.test.ts | 2 +- src/system-agent/config-write-policy.ts | 4 +- src/system-agent/inference-route.ts | 5 - src/system-agent/setup-inference.test.ts | 12 +- src/system-agent/verified-inference.test.ts | 49 - 81 files changed, 860 insertions(+), 2439 deletions(-) create mode 100644 src/commands/doctor/shared/legacy-config-migrations.runtime.cli-backends.test.ts create mode 100644 src/commands/doctor/shared/legacy-config-migrations.runtime.cli-backends.ts diff --git a/Dockerfile b/Dockerfile index 17d3dd49f142..24dd827b85ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -263,6 +263,7 @@ RUN install -d -m 0755 "$COREPACK_HOME" && \ # Legacy alias: OPENCLAW_DOCKER_APT_PACKAGES is still accepted as a fallback. ARG OPENCLAW_IMAGE_APT_PACKAGES ARG OPENCLAW_DOCKER_APT_PACKAGES="" +ENV PATH="/home/node/.local/bin:${PATH}" RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ packages="${OPENCLAW_IMAGE_APT_PACKAGES-$OPENCLAW_DOCKER_APT_PACKAGES}"; \ diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 78731f30953e..0db0c7b33af2 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -405,7 +405,6 @@ src/agents/bash-tools.process.ts src/agents/btw.test.ts src/agents/btw.ts src/agents/cli-auth-epoch.test.ts -src/agents/cli-backends.test.ts src/agents/cli-output.test.ts src/agents/cli-output.ts src/agents/cli-runner.reliability.test.ts @@ -1049,7 +1048,6 @@ src/snapshot/local-repository.ts src/state/openclaw-agent-db.test.ts src/state/openclaw-state-db.test.ts src/status/status-message.ts -src/system-agent/agent-turn.test.ts src/system-agent/chat-engine.test.ts src/system-agent/chat-engine.ts src/system-agent/setup-inference.test.ts diff --git a/docs/.generated/config-baseline.counts.json b/docs/.generated/config-baseline.counts.json index 1acb25b3f58e..e0594a8e01f1 100644 --- a/docs/.generated/config-baseline.counts.json +++ b/docs/.generated/config-baseline.counts.json @@ -1,5 +1,5 @@ { - "core": 2352, + "core": 2305, "channel": 3627, "plugin": 3556 } diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 286ccdca7443..c833963bb6e2 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -4cf2f76190328585d716ed30cf5bcd6add889c8fdbf90ce09f0271d8f930219f config-baseline.json -2ec8c8f599e3ba74cce1939a066b02d7178aefe3bebe646f52eb5f3e34e6bb95 config-baseline.core.json +44130ea5925c44f8817fb74fb502c271b45fa343026fd0e98e0fd686497e868a config-baseline.json +388219300c6e874aee82b33706a00486ed334b21da27f4b89ebb4697af890331 config-baseline.core.json d8a79905c6191dfb9391c16afd33cf9ac573691d26b3b8cc635e19fd7f2ae316 config-baseline.channel.json 28460228b14a94a2b93040ab3f43b214bdc6d4fda4139a75b21e70fc5983dd56 config-baseline.plugin.json diff --git a/docs/concepts/agent-loop.md b/docs/concepts/agent-loop.md index 8dd552c4d683..07b6e7b8f326 100644 --- a/docs/concepts/agent-loop.md +++ b/docs/concepts/agent-loop.md @@ -130,7 +130,7 @@ Assistant deltas buffer into chat `delta` messages. A chat `final` is emitted on | ------------------------------------------------ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `agent.wait` | 30s | Wait-only; `timeoutMs` param overrides. Does not stop the underlying run. | | Agent runtime (`agents.defaults.timeoutSeconds`) | 172800s (48h) | Enforced by `runEmbeddedAgent`'s abort timer. Set `0` for an unlimited run budget; model stream liveness watchdogs still apply. | -| CLI backend no-output watchdog | computed per fresh/resumed CLI run | Separate from the agent runtime. Configure `agents.defaults.cliBackends..reliability.watchdog.{fresh,resume}` for CLIs that can remain silent while working. A CLI-internal background task shares the parent subprocess and does not outlive an overall agent timeout. | +| CLI backend no-output watchdog | computed per fresh/resumed CLI run | Separate from the agent runtime and owned by the registered backend plugin. A CLI-internal background task shares the parent subprocess and does not outlive an overall agent timeout. | | Cron isolated agent turn | owned by cron | The scheduler starts its own timer when execution begins, aborts the run at the configured deadline, then runs bounded cleanup before recording the timeout so a stale child session cannot keep the lane stuck. | | Model idle timeout | Cloud 120s; self-hosted 300s | OpenClaw aborts a model request when no response chunks arrive before the idle window. `models.providers..timeoutSeconds` extends this idle watchdog for slow local/self-hosted providers, but stays bounded by any lower finite `agents.defaults.timeoutSeconds` or run-specific timeout, since those govern the whole agent run. Unlimited run budgets still keep the provider-class idle watchdog. Cron-triggered cloud model runs with no explicit model/agent timeout use the same default; with an explicit cron run timeout, cloud model stream stalls cap at 60s so configured model fallbacks can still run before the outer cron deadline. Cron-triggered runs on genuinely local endpoints (loopback/private baseUrl) keep the local idle opt-out; self-hosted providers on network baseUrls get the 300s implicit watchdog. With an explicit cron run timeout, local/self-hosted stalls cap at that timeout. Set `models.providers..timeoutSeconds` for slow local providers. | | Provider HTTP request timeout | `models.providers..timeoutSeconds` | Covers connect, headers, body, SDK request timeout, guarded-fetch abort handling, and the model stream idle watchdog for that provider. Use for slow local/self-hosted providers (for example Ollama) before raising the whole agent runtime timeout; keep the agent/runtime timeout at least as high when the model request needs to run longer. | diff --git a/docs/docs_map.md b/docs/docs_map.md index 849282ba5852..9eb50fe0be0c 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -3312,7 +3312,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H3: agents.defaults.timeFormat - H3: agents.defaults.model - H3: Runtime policy - - H3: agents.defaults.cliBackends + - H3: CLI backend selection - H3: agents.defaults.promptOverlays - H3: agents.defaults.heartbeat - H3: agents.defaults.compaction @@ -5714,7 +5714,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Advanced backend hooks - H3: ownsNativeCompaction: opting out of OpenClaw compaction - H2: MCP tool bridge - - H2: User configuration + - H2: Selecting the backend - H2: Verification - H2: Checklist - H2: Related diff --git a/docs/gateway/authentication.md b/docs/gateway/authentication.md index 3ffaec973a0b..6872a8f3a0d8 100644 --- a/docs/gateway/authentication.md +++ b/docs/gateway/authentication.md @@ -58,7 +58,9 @@ openclaw models auth login --provider anthropic --method cli --set-default This is two steps: log Claude Code into Anthropic on the host, then tell OpenClaw to route Anthropic model selection through the local `claude-cli` backend and store the matching OpenClaw auth profile. -If `claude` isn't on `PATH`, install Claude Code or set `agents.defaults.cliBackends.claude-cli.command` to the binary path. +The gateway service must resolve `claude` on `PATH`. If a deployment needs a +nonstandard executable path, register a wrapper through a +[CLI backend plugin](/plugins/cli-backend-plugins). ## Manual token entry diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index 8855f2af0e57..1ec99da017b5 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -30,23 +30,13 @@ openclaw agent --agent main --message "hi" --model claude-cli/claude-sonnet-4-6 `main` is the default agent id when no explicit agent list is configured; swap in your own agent id otherwise. -If the gateway runs under launchd/systemd with a minimal `PATH`, point at the binary explicitly: +The gateway service must have the CLI on its `PATH`. If a deployment needs a +nonstandard executable path or arguments, register that adapter in a +[CLI backend plugin](/plugins/cli-backend-plugins) instead of putting launch +mechanics in `openclaw.json`. -```json5 -{ - agents: { - defaults: { - cliBackends: { - "claude-cli": { - command: "/opt/homebrew/bin/claude", - }, - }, - }, - }, -} -``` - -If you use a bundled CLI backend as the primary message provider on a gateway host, OpenClaw auto-loads the owning bundled plugin when your config references that backend in a model ref or under `agents.defaults.cliBackends`. +OpenClaw auto-loads an owning bundled plugin when model selection or a +model-scoped `agentRuntime.id` references its backend. ## Using it as a fallback @@ -73,39 +63,17 @@ Configured fallbacks remain eligible when the primary provider fails (auth, rate ## Configuration -All CLI backends live under `agents.defaults.cliBackends`, keyed by provider id (e.g. `claude-cli`, `my-cli`). The provider id becomes the left side of the model ref: `/`. +Users choose a registered backend through the model and runtime policy. Keep +the model ref canonical and select the CLI runtime per model: ```json5 { agents: { defaults: { - cliBackends: { - "my-cli": { - command: "my-cli", - args: ["--json"], - output: "json", - input: "arg", - modelArg: "--model", - modelAliases: { - "claude-opus-4-6": "opus", - "claude-sonnet-4-6": "sonnet", - }, - sessionArgs: ["--session", "{sessionId}"], - sessionMode: "existing", - sessionIdFields: ["session_id", "conversation_id"], - systemPromptArg: "--system", - // Dedicated prompt-file flag: - // systemPromptFileArg: "--system-file", - // Codex-style config-override flag instead: - // systemPromptFileConfigArg: "-c", - // systemPromptFileConfigKey: "model_instructions_file", - systemPromptWhen: "first", - imageArg: "--image", - imageMode: "repeat", - // Opt in only if this backend may reseed invalidated sessions from - // bounded raw OpenClaw transcript history before compaction. - reseedFromRawTranscriptWhenUncompacted: true, - serialize: true, + model: "anthropic/claude-opus-4-8", + models: { + "anthropic/claude-opus-4-8": { + agentRuntime: { id: "claude-cli" }, }, }, }, @@ -113,6 +81,10 @@ All CLI backends live under `agents.defaults.cliBackends`, keyed by provider id } ``` +Credentials remain in OpenClaw auth profiles or the owning plugin's config. +Command, argv, environment, parsing, session, image, and watchdog mechanics are +plugin code registered with `api.registerCliBackend(...)`. + ## How it works 1. Selects a backend by provider prefix (`claude-cli/...`). @@ -126,7 +98,7 @@ All CLI backends live under `agents.defaults.cliBackends`, keyed by provider id CLI backends have two independent limits: - `agents.defaults.timeoutSeconds` limits the whole agent turn. Normal Gateway turns inherit the 48-hour default; `0` makes the turn budget unlimited. A stored override such as `600` replaces that default. -- The CLI no-output watchdog stops a subprocess that remains silent. It uses separate fresh/resume profiles under `agents.defaults.cliBackends..reliability.watchdog` and remains active even when the overall turn budget is unlimited. +- The CLI no-output watchdog stops a subprocess that remains silent. Each backend plugin owns separate fresh/resume profiles, and the watchdog remains active even when the overall turn budget is unlimited. Remove a short overall-timeout override to return to the 48-hour default, or set an explicit budget such as 12 hours: @@ -146,7 +118,7 @@ The `openclaw agent` command also has its own request deadline. Its 600-second f 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; OpenClaw still answers Claude's stdio tool-control requests from the configured exec policy. Per-agent `agents.entries.*.tools.exec` settings override the global `tools.exec` for that agent. Raw backend args may still include `--permission-mode`, but live Claude launches normalize that flag to match the effective policy and host restriction. +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; OpenClaw still answers Claude's stdio tool-control requests from the configured exec policy. 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 backend also maps OpenClaw `/think` levels to Claude Code's native `--effort` flag: `minimal`/`low` -> `low`, `medium` -> `medium`, and `high`/`xhigh`/`max` pass through directly. This keeps the supported Fable 5 effort levels the same for subscription-backed Claude CLI and API-key routes. `adaptive` removes configured `--effort` flags and supplies no replacement, so Claude Code resolves effective effort from its own environment, settings, and model defaults. Other CLI backends need their owning plugin to declare an equivalent argv mapper before `/think` affects the spawned CLI. @@ -160,7 +132,8 @@ openclaw models auth login --provider anthropic --method cli --set-default Docker installs need Claude Code installed and logged in inside the persisted container home, not only on the host; see [Claude CLI backend in Docker](/install/docker#claude-cli-backend-in-docker). -Set `agents.defaults.cliBackends.claude-cli.command` only when the `claude` binary is not already on `PATH`. +The gateway service must resolve `claude` on `PATH`. For a nonstandard path, +register a small wrapper backend plugin. ## Sessions @@ -188,7 +161,7 @@ When a `claude-cli` attempt fails over to a non-CLI candidate in [`agents.defaul ## Images -If your CLI accepts image paths, set `imageArg`: +Plugin authors declare image-path support with `imageArg`: ```json5 imageArg: "--image", @@ -202,7 +175,7 @@ OpenClaw writes base64 images to temp files. If `imageArg` is set, those paths a - `output: "text"` (default) treats stdout as the final response. - `output: "json"` tries to parse JSON and extract text plus a session id. - `output: "jsonl"` parses a JSONL stream and extracts the final agent message plus session identifiers when present. -- For Gemini CLI JSON output, OpenClaw reads reply text from `response` and usage from `stats` when `usage` is missing or empty. The bundled Gemini CLI default uses `stream-json`; old `--output-format json` overrides still use the JSON parser. +- For Gemini CLI JSON output, OpenClaw reads reply text from `response` and usage from `stats` when `usage` is missing or empty. The bundled Gemini CLI adapter uses `stream-json`. Input modes: @@ -216,8 +189,8 @@ CLI backend defaults are part of the plugin surface: - Plugins register them with `api.registerCliBackend(...)`. - The backend `id` becomes the provider prefix in model refs. -- User config in `agents.defaults.cliBackends.` still overrides the plugin default. -- Backend-specific config cleanup stays plugin-owned through the optional `normalizeConfig` hook. +- Command, argv, environment, parser, session, and watchdog behavior stays in plugin code. +- Backend-specific normalization stays plugin-owned through the optional `normalizeConfig` hook. Anthropic owns `claude-cli` and Google owns `google-gemini-cli`. OpenAI Codex agent runs use the Codex app-server harness through `openai/*`; OpenClaw no longer registers a bundled `codex-cli` backend. @@ -257,11 +230,8 @@ Prerequisite: the local Gemini CLI must be installed and on `PATH` as `gemini` ( Gemini CLI output notes: - The default `stream-json` parser reads assistant `message` events, tool events, final `result` usage, and fatal Gemini error events. -- If you override Gemini args to `--output-format json`, OpenClaw normalizes that backend back to `output: "json"` and reads reply text from the JSON `response` field. - Usage falls back to `stats` when `usage` is absent or empty; `stats.cached` normalizes into OpenClaw `cacheRead`, and if `stats.input` is missing, input tokens derive from `stats.input_tokens - stats.cached`. -Override defaults only if needed (most commonly an absolute `command` path). - ## Text transform overlays Plugins that need small prompt/message compatibility shims can declare bidirectional text transforms without replacing a provider or CLI backend: @@ -338,12 +308,12 @@ Claude CLI backends scale this cap with the resolved Claude context window inste ## Troubleshooting -| Symptom | Fix | -| --------------------- | ----------------------------------------------------------------- | -| CLI not found | Set `command` to a full path. | -| Wrong model name | Use `modelAliases` to map `provider/model` to the CLI's model id. | -| No session continuity | Ensure `sessionArgs` is set and `sessionMode` is not `none`. | -| Images ignored | Set `imageArg` and verify the CLI supports file paths. | +| Symptom | Fix | +| --------------------- | ---------------------------------------------------------------------------------------------- | +| CLI not found | Put the CLI on the gateway service's `PATH`, or update the owning plugin's registered command. | +| Wrong model name | Update the plugin's `modelAliases` mapping. | +| No session continuity | Check the plugin's `sessionArgs` and `sessionMode`. | +| Images ignored | Check the plugin's `imageArg` and the CLI's file-path support. | ## Related diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 10d342b8c56e..e63309178968 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -509,44 +509,13 @@ Z.AI GLM-4.x models automatically enable thinking mode unless you set `--thinkin Z.AI models enable `tool_stream` by default for tool call streaming. Set `agents.defaults.models["zai/"].params.tool_stream` to `false` to disable it. Anthropic Claude Opus 4.8 keeps thinking off by default in OpenClaw; when adaptive thinking is explicitly enabled, Anthropic's provider-owned effort default is `high`. Claude 4.6 models default to `adaptive` when no explicit thinking level is set. -### `agents.defaults.cliBackends` +### CLI backend selection -Optional CLI backends for text-only fallback runs (no tool calls). Useful as a backup when API providers fail. - -```json5 -{ - agents: { - defaults: { - cliBackends: { - "claude-cli": { - command: "/opt/homebrew/bin/claude", - }, - "my-cli": { - command: "my-cli", - args: ["--json"], - output: "json", - modelArg: "--model", - sessionArgs: ["--session", "{sessionId}"], - sessionMode: "existing", - systemPromptArg: "--system", - // Or use systemPromptFileArg when the CLI accepts a prompt file flag. - systemPromptWhen: "first", - imageArg: "--image", - imageMode: "repeat", - }, - }, - }, - }, -} -``` - -- CLI backends are text-first; tools are always disabled. -- Sessions are supported when `sessionArgs` includes `{sessionId}`. -- Image pass-through supported when `imageArg` accepts file paths. -- `reseedFromRawTranscriptWhenUncompacted: true` lets a backend recover safe - invalidated sessions from a bounded raw OpenClaw transcript tail before the - first compaction summary exists. Auth profile or credential-epoch changes - still never raw-reseed. +CLI adapter mechanics are registered by plugins, not configured under agent +defaults. Select a registered CLI backend with model-scoped `agentRuntime.id`, +as shown above. See [CLI backends](/gateway/cli-backends) for operations and +[building CLI backend plugins](/plugins/cli-backend-plugins) for command, +session, image, and parser registration. ### `agents.defaults.promptOverlays` diff --git a/docs/gateway/security/audit-checks.md b/docs/gateway/security/audit-checks.md index 8c2b0e11f1a1..7d8abffb11eb 100644 --- a/docs/gateway/security/audit-checks.md +++ b/docs/gateway/security/audit-checks.md @@ -109,7 +109,6 @@ exhaustive): | `tools.exec.safe_bin_trusted_dirs_risky` | warn | `safeBinTrustedDirs` includes mutable or risky directories | `tools.exec.safeBinTrustedDirs`, `agents.entries.*.tools.exec.safeBinTrustedDirs` | no | | `tools.elevated.allowFrom..wildcard` | critical | `tools.elevated.allowFrom.` includes `"*"`, approving every sender | `tools.elevated.allowFrom.` | no | | `tools.elevated.allowFrom..large` | warn | Elevated allowlist for `` has more than 25 entries | `tools.elevated.allowFrom.` | no | -| `agents.claude_cli.permission_mode_overridden_by_yolo` | warn | Claude CLI `--permission-mode` is ignored because OpenClaw exec is fully unattended | `tools.exec.security`, `tools.exec.ask`, `cliBackends.claude-cli` args | no | | `skills.workspace.symlink_escape` | warn | Workspace `skills/**/SKILL.md` resolves outside workspace root (symlink-chain drift) | workspace `skills/**` filesystem state | no | | `skills.workspace.scan_truncated` | warn | Workspace skill scan hit its directory-visit cap before finishing | flatten/simplify the workspace `skills/` directory tree | no | | `plugins.extensions_no_allowlist` | warn | Plugins are installed without an explicit plugin allowlist | `plugins.allowlist` | no | diff --git a/docs/install/docker.md b/docs/install/docker.md index b5193b51260f..925bcbe31bbf 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -348,14 +348,9 @@ docker compose -f docker-compose.yml -f docker-compose.extra.yml run --rm \ 'curl -fsSL https://claude.ai/install.sh | bash' ``` -The native installer writes `claude` to `/home/node/.local/bin/claude`. Point OpenClaw at that path: - -```bash -docker compose -f docker-compose.yml -f docker-compose.extra.yml run --rm \ - openclaw-cli config set \ - agents.defaults.cliBackends.claude-cli.command \ - /home/node/.local/bin/claude -``` +The native installer writes `claude` to `/home/node/.local/bin/claude`. The +OpenClaw image includes `/home/node/.local/bin` on `PATH`, so the bundled +Anthropic plugin resolves it without an adapter config override. Log in and verify from the same persisted home: diff --git a/docs/plugins/cli-backend-plugins.md b/docs/plugins/cli-backend-plugins.md index c137e5d69cee..c82a70e8ce44 100644 --- a/docs/plugins/cli-backend-plugins.md +++ b/docs/plugins/cli-backend-plugins.md @@ -97,7 +97,7 @@ runtime behavior. Runtime behavior starts when the plugin entry calls ``` `cliBackends` is the runtime ownership list; it lets OpenClaw auto-load the - plugin when config or model selection mentions `acme-cli/...`. + plugin when model selection or `agentRuntime.id` mentions `acme-cli`. `setup.cliBackends` is the descriptor-first setup surface. Add it when model discovery, onboarding, or status should recognize the backend @@ -129,10 +129,25 @@ runtime behavior. Runtime behavior starts when the plugin entry calls }, config: { command: "acme", - args: ["chat", "--json"], - output: "json", - input: "stdin", + args: ["chat", "--output-format", "stream-json", "--prompt", "{prompt}"], + resumeArgs: [ + "chat", + "--resume", + "{sessionId}", + "--output-format", + "stream-json", + "--prompt", + "{prompt}", + ], + output: "jsonl", + resumeOutput: "jsonl", + jsonlDialect: "gemini-stream-json", + input: "arg", modelArg: "--model", + modelAliases: { + large: "acme-large-2026", + fast: "acme-fast-2026", + }, sessionArgs: ["--session", "{sessionId}"], sessionMode: "existing", sessionIdFields: ["session_id", "conversation_id"], @@ -140,6 +155,7 @@ runtime behavior. Runtime behavior starts when the plugin entry calls systemPromptWhen: "first", imageArg: "--image", imageMode: "repeat", + imagePathScope: "workspace", reliability: { watchdog: { fresh: { ...CLI_FRESH_WATCHDOG_DEFAULTS }, @@ -161,16 +177,19 @@ runtime behavior. Runtime behavior starts when the plugin entry calls }); ``` - The backend id must match the manifest `cliBackends` entry. The - registered `config` is only the default; user config under - `agents.defaults.cliBackends.acme-cli` merges over it at runtime. + The backend id must match the manifest `cliBackends` entry. The registered + adapter is authoritative plugin code; OpenClaw config selects the backend + but does not rewrite its command contract. ## Config shape -`CliBackendConfig` describes how OpenClaw should launch and parse the CLI: +`CliBackendConfig` describes how OpenClaw should launch and parse the CLI. The +worked example above intentionally exercises the same command, resume, JSONL, +model-alias, session, image, and watchdog fields as the bundled +`google-gemini-cli` adapter: | Field | Use | | --------------------------------------------------------- | --------------------------------------------------------------------------------- | @@ -207,7 +226,7 @@ only for behavior that really belongs to the backend. | Hook | Use | | ---------------------------------- | --------------------------------------------------------------------------- | -| `normalizeConfig(config, context)` | Rewrite legacy user config after merge | +| `normalizeConfig(config, context)` | Normalize the registered static adapter with runtime context | | `resolveExecutionArgs(ctx)` | Add request-scoped flags such as thinking effort or side-question isolation | | `prepareExecution(ctx)` | Create temporary auth, config, or environment bridges before launch | | `transformSystemPrompt(ctx)` | Apply a final CLI-specific system prompt transform | @@ -228,7 +247,7 @@ a backend hook can express the behavior. limit selected for the run. Backends that own native compaction can map that budget into their CLI-specific launch contract. -`runtimeArtifact` is plugin-owned and is not user-overridable. It is consulted +`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 mint verified CLI setup authority. A `bundled-package-tree` declaration names @@ -242,7 +261,7 @@ do not make an external implementation graph safe. If the same backend also ships a self-contained native executable, list its canonical basenames in `nativeExecutableNames`. Other native commands remain -unverified even when a user overrides the backend command. +unverified. `ctx.executionMode` is `"agent"` for normal turns and `"side-question"` for ephemeral `/btw` calls. Use it when the CLI needs different one-shot flags, @@ -324,23 +343,16 @@ its own built-in tool layer that cannot be disabled, set `nativeToolMode: tools. If it can disable every native tool per run, use `"selectable"` with the `resolveExecutionArgs` contract above. -## User configuration +## Selecting the backend -Users can override any backend default: +Users select a standalone backend through its model-ref prefix. A backend that +declares a canonical `modelProvider` can instead be selected through that +provider model's `agentRuntime.id`. Adapter mechanics remain in the plugin: ```json5 { agents: { defaults: { - cliBackends: { - "acme-cli": { - command: "/opt/acme/bin/acme", - args: ["chat", "--json", "--profile", "work"], - modelAliases: { - large: "acme-large-2026", - }, - }, - }, model: { primary: "openai/gpt-5.6-sol", fallbacks: ["acme-cli/large"], @@ -350,8 +362,9 @@ Users can override any backend default: } ``` -Document the minimum override users are likely to need - usually only -`command` when the binary is outside `PATH`. +Put credentials in OpenClaw auth profiles or plugin-owned config. Ensure the +registered command is on the gateway service's `PATH`; deployments that need a +different path or argv should change or wrap the plugin registration. ## Verification @@ -379,13 +392,13 @@ MCP, or session-resume behavior. `openclaw.plugin.json` declares `cliBackends` and intentional `activation.onStartup` `setup.cliBackends` is present when setup/model discovery should see the backend cold `api.registerCliBackend(...)` uses the same backend id as the manifest -User overrides under `agents.defaults.cliBackends.` still win +The backend model prefix or model-scoped `agentRuntime.id` selects the registration Session, system prompt, image, and output parser settings match the real CLI contract Targeted tests and at least one live CLI smoke prove the backend path ## Related -- [CLI backends](/gateway/cli-backends) - user configuration and runtime behavior +- [CLI backends](/gateway/cli-backends) - runtime selection and behavior - [Building plugins](/plugins/building-plugins) - package and manifest basics - [Plugin SDK overview](/plugins/sdk-overview) - registration API reference - [Plugin manifest](/plugins/manifest) - `cliBackends` and setup descriptors diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index f66462702bef..24eedc85905b 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -573,11 +573,12 @@ descriptor-backed placeholders for parse-time lazy loading. AI CLI backend such as `claude-cli` or `my-cli`. - The backend `id` becomes the provider prefix in model refs like `my-cli/gpt-5`. -- The backend `config` uses the same shape as `agents.defaults.cliBackends.`. -- User config still wins. OpenClaw merges `agents.defaults.cliBackends.` over the - plugin default before running the CLI. -- Use `normalizeConfig` when a backend needs compatibility rewrites after merge - (for example normalizing old flag shapes). +- The backend `config` is the authoritative command adapter: argv, environment, + parser, session, image, and reliability behavior live in plugin code. +- Users select the backend through model refs or model-scoped `agentRuntime.id`; + `openclaw.json` does not rewrite the adapter. +- Use `normalizeConfig` when registered static fields need a runtime-aware + normalization pass. - Use `resolveExecutionArgs` for request-scoped argv rewrites that belong to the CLI dialect, such as mapping OpenClaw thinking levels to a native effort flag. The hook receives `ctx.executionMode`; use `"side-question"` to add diff --git a/extensions/anthropic/cli-shared.test.ts b/extensions/anthropic/cli-shared.test.ts index 46aa04bbc534..d57d2bfa2e55 100644 --- a/extensions/anthropic/cli-shared.test.ts +++ b/extensions/anthropic/cli-shared.test.ts @@ -23,6 +23,51 @@ type ClaudePreparedExecutionWithSecret = { const CLAUDE_CLI_DISALLOWED_TOOLS = "ScheduleWakeup,CronCreate,Bash(run_in_background:true),Monitor"; +describe("Claude CLI adapter equivalence", () => { + const commonArgs = [ + "-p", + "--output-format", + "stream-json", + "--include-partial-messages", + "--verbose", + "--setting-sources", + "user", + "--allowedTools", + "mcp__openclaw__*", + "--disallowedTools", + CLAUDE_CLI_DISALLOWED_TOOLS, + ]; + + it.each([ + { phase: "fresh", key: "args" as const, expected: commonArgs }, + { + phase: "resume", + key: "resumeArgs" as const, + expected: [...commonArgs, "--resume", "{sessionId}"], + }, + ])("preserves the legacy $phase command bytes in plugin code", ({ key, expected }) => { + const backend = buildAnthropicCliBackend(); + + expect(backend.config.command).toBe("claude"); + expect(backend.config[key]).toEqual(expected); + expect(backend.config.env).toBeUndefined(); + expect(backend.config.clearEnv).toEqual([...CLAUDE_CLI_CLEAR_ENV]); + }); + + it("preserves the prepared launch environment for the same context budget", () => { + const backend = buildAnthropicCliBackend(); + + expect( + backend.prepareExecution?.({ + workspaceDir: "/tmp/openclaw-claude-cli", + provider: "claude-cli", + modelId: "claude-opus-4-8", + contextTokenBudget: 100_000, + }), + ).toEqual({ env: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: "100000" } }); + }); +}); + describe("resolveClaudeCliAutoCompactEnv", () => { it("maps the effective OpenClaw context budget into Claude Code compaction", () => { expect(resolveClaudeCliAutoCompactEnv(100_000.9)).toEqual({ diff --git a/extensions/google/setup-api.test.ts b/extensions/google/setup-api.test.ts index 55edbfbc45d5..d8e46a068fb8 100644 --- a/extensions/google/setup-api.test.ts +++ b/extensions/google/setup-api.test.ts @@ -103,6 +103,44 @@ describe("google setup entry", () => { }); describe("google gemini cli backend config", () => { + it.each([ + { + phase: "fresh", + key: "args" as const, + expected: [ + "--skip-trust", + "--approval-mode", + "auto_edit", + "--output-format", + "stream-json", + "--prompt", + "{prompt}", + ], + }, + { + phase: "resume", + key: "resumeArgs" as const, + expected: [ + "--skip-trust", + "--approval-mode", + "auto_edit", + "--resume", + "{sessionId}", + "--output-format", + "stream-json", + "--prompt", + "{prompt}", + ], + }, + ])("preserves the legacy $phase command bytes in plugin code", ({ key, expected }) => { + const backend = buildGoogleGeminiCliBackend(); + + expect(backend.config.command).toBe("gemini"); + expect(backend.config[key]).toEqual(expected); + expect(backend.config.env).toBeUndefined(); + expect(backend.config.clearEnv).toBeUndefined(); + }); + it("declares its bundled package implementation boundary", () => { expect(buildGoogleGeminiCliBackend().runtimeArtifact).toEqual({ kind: "bundled-package-tree", diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index 394c93f69b92..2a96991895ef 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -1330,7 +1330,6 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { agents: { defaults: { models: state.defaultRuntimeConfig.agents.defaults.models, - cliBackends: { codex: { command: "codex" } }, }, }, }; diff --git a/src/agents/auth-profiles.external-cli-scope.test.ts b/src/agents/auth-profiles.external-cli-scope.test.ts index 4e8fcdc681a6..dbd34386b037 100644 --- a/src/agents/auth-profiles.external-cli-scope.test.ts +++ b/src/agents/auth-profiles.external-cli-scope.test.ts @@ -57,9 +57,6 @@ describe("external CLI auth scope", () => { }, mediaModels: { image: "minimax-portal/image-01" }, voiceModel: "elevenlabs/eleven_multilingual_v2", - cliBackends: { - "claude-cli": { command: "claude" }, - }, models: { "claude-cli/claude-opus-4-7": { alias: "opus" }, }, @@ -96,9 +93,6 @@ describe("external CLI auth scope", () => { agents: { defaults: { model: "openai/gpt-5.5", - cliBackends: { - "claude-cli": { command: "claude" }, - }, models: { "openai/gpt-5.5": { agentRuntime: { id: "claude-cli" } }, }, diff --git a/src/agents/cli-auth-epoch.test.ts b/src/agents/cli-auth-epoch.test.ts index f3ca2b3c0a95..0f90e56c362f 100644 --- a/src/agents/cli-auth-epoch.test.ts +++ b/src/agents/cli-auth-epoch.test.ts @@ -14,11 +14,13 @@ import { resetCliAuthEpochTestDeps, setCliAuthEpochTestDeps, } from "./cli-auth-epoch.test-support.js"; +import { testing as cliBackendsTesting } from "./cli-backends.test-support.js"; import { resolveCliExecutableIdentity } from "./cli-executable-identity.js"; describe("resolveCliAuthEpoch", () => { afterEach(() => { resetCliAuthEpochTestDeps(); + cliBackendsTesting.resetDepsForTest(); }); function expectCliAuthEpoch( @@ -982,15 +984,23 @@ describe("resolveCliAuthEpoch", () => { }); function cliConfig(command: string): OpenClawConfig { - return { - agents: { - defaults: { - cliBackends: { - "claude-cli": { command }, + cliBackendsTesting.setDepsForTest({ + resolvePluginSetupCliBackend: () => undefined, + resolveRuntimeCliBackends: () => [ + { + id: "claude-cli", + pluginId: "anthropic", + config: { command }, + runtimeArtifact: { + kind: "bundled-package-tree", + packageName: "@fixture/claude-cli", + entrypoint: "command", + nativeExecutableNames: ["claude", "claude.exe"], }, }, - }, - }; + ], + }); + return {}; } function copyNativeExecutable(filePath: string, source = process.execPath): void { diff --git a/src/agents/cli-backends.test.ts b/src/agents/cli-backends.test.ts index c1c381479f2b..17eee9ad3f71 100644 --- a/src/agents/cli-backends.test.ts +++ b/src/agents/cli-backends.test.ts @@ -1,17 +1,20 @@ -/** Tests CLI backend config resolution, normalization, and live-test defaults. */ - -import { expectDefined } from "@openclaw/normalization-core"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +/** Tests plugin-owned CLI backend resolution and runtime bindings. */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import type { CliBackendConfig } from "../config/types.js"; -import type { CliBackendRuntimeArtifactPolicy } from "../plugins/cli-backend.types.js"; import type { - CliBackendAuthEpochMode, - CliBackendNormalizeConfigContext, - CliBackendResolveExecutionArgs, - CliBundleMcpMode, -} from "../plugins/types.js"; -import { resolveCliBackendConfig, resolveCliBackendLiveTest } from "./cli-backends.js"; + CliBackendConfig, + CliBackendPlugin, + CliBackendRuntimeArtifactPolicy, +} from "../plugins/cli-backend.types.js"; +import { + isCliRuntimeModelBackendForProvider, + listCliRuntimeModelBackendBindings, + listCliRuntimeProviderIds, + resolveCliBackendConfig, + resolveCliBackendLiveTest, + resolveCliRuntimeCanonicalProvider, + resolveCliRuntimeModelBackendBinding, +} from "./cli-backends.js"; import { testing as cliBackendsTesting } from "./cli-backends.test-support.js"; type RuntimeBackendEntry = ReturnType< @@ -21,1102 +24,236 @@ type SetupBackendEntry = NonNullable< ReturnType<(typeof import("../plugins/setup-registry.js"))["resolvePluginSetupCliBackend"]> >; -let runtimeBackendEntries: RuntimeBackendEntry[] = []; -let setupBackendEntries: SetupBackendEntry[] = []; +const runtimeArtifact: CliBackendRuntimeArtifactPolicy = { + kind: "bundled-package-tree", + packageName: "@fixture/acme-cli", + entrypoint: "command", +}; -function createBackendEntry(params: { - autoSelectAuthProfile?: boolean; - pluginId: string; - id: string; - config: CliBackendConfig; - bundleMcp?: boolean; - bundleMcpMode?: CliBundleMcpMode; - defaultAuthProfileId?: string; - authEpochMode?: CliBackendAuthEpochMode; - ownsNativeCompaction?: boolean; - prepareExecution?: () => Promise; - resolveExecutionArgs?: CliBackendResolveExecutionArgs; - runtimeArtifact?: CliBackendRuntimeArtifactPolicy; - normalizeConfig?: ( - config: CliBackendConfig, - context?: CliBackendNormalizeConfigContext, - ) => CliBackendConfig; -}) { - // Runtime/setup backend entries share most shape; tests build both from one - // helper so registry behavior stays aligned. +function createBackend(overrides: Partial = {}): CliBackendPlugin { return { - pluginId: params.pluginId, - source: "test", - backend: { - id: params.id, - config: params.config, - ...(params.bundleMcp ? { bundleMcp: params.bundleMcp } : {}), - ...(params.bundleMcpMode ? { bundleMcpMode: params.bundleMcpMode } : {}), - ...(params.defaultAuthProfileId ? { defaultAuthProfileId: params.defaultAuthProfileId } : {}), - ...(params.authEpochMode ? { authEpochMode: params.authEpochMode } : {}), - ...(params.autoSelectAuthProfile !== undefined - ? { autoSelectAuthProfile: params.autoSelectAuthProfile } - : {}), - ...(params.ownsNativeCompaction ? { ownsNativeCompaction: params.ownsNativeCompaction } : {}), - ...(params.prepareExecution ? { prepareExecution: params.prepareExecution } : {}), - ...(params.resolveExecutionArgs ? { resolveExecutionArgs: params.resolveExecutionArgs } : {}), - ...(params.runtimeArtifact ? { runtimeArtifact: params.runtimeArtifact } : {}), - ...(params.normalizeConfig ? { normalizeConfig: params.normalizeConfig } : {}), - liveTest: { - defaultModelRef: - params.id === "claude-cli" - ? "claude-cli/claude-sonnet-4-6" - : params.id === "codex-cli" - ? "codex-cli/gpt-5.5" - : params.id === "google-gemini-cli" - ? "google-gemini-cli/gemini-3-flash-preview" - : undefined, - defaultImageProbe: true, - defaultMcpProbe: true, - docker: { - npmPackage: - params.id === "claude-cli" - ? "@anthropic-ai/claude-code" - : params.id === "codex-cli" - ? "@openai/codex@0.132.0" - : params.id === "google-gemini-cli" - ? "@google/gemini-cli" - : undefined, - binaryName: - params.id === "claude-cli" - ? "claude" - : params.id === "codex-cli" - ? "codex" - : params.id === "google-gemini-cli" - ? "gemini" - : undefined, - }, + id: "acme-cli", + modelProvider: "acme", + config: { + command: "acme", + args: ["chat", "--json"], + output: "json", + input: "stdin", + modelArg: "--model", + sessionArgs: ["--session", "{sessionId}"], + sessionMode: "existing", + }, + bundleMcp: true, + bundleMcpMode: "claude-config-file", + runtimeArtifact, + liveTest: { + defaultModelRef: "acme/acme-large", + defaultImageProbe: true, + defaultMcpProbe: false, + docker: { + npmPackage: "@fixture/acme-cli", + binaryName: "acme", }, }, + ...overrides, }; } -function createRuntimeBackendEntry(params: Parameters[0]) { - const entry = createBackendEntry(params); - return { - ...entry.backend, - pluginId: entry.pluginId, - } satisfies RuntimeBackendEntry; +function runtimeEntry( + overrides: Partial = {}, + pluginId = "acme-plugin", +): RuntimeBackendEntry { + return { ...createBackend(overrides), pluginId } as RuntimeBackendEntry; } -function requireCliBackendConfig(...args: Parameters) { - const resolved = resolveCliBackendConfig(...args); +function setupEntry( + overrides: Partial = {}, + pluginId = "acme-plugin", +): SetupBackendEntry { + return { + pluginId, + source: "test", + backend: createBackend(overrides), + } as SetupBackendEntry; +} + +function requireBackend(provider = "acme-cli", cfg?: OpenClawConfig) { + const resolved = resolveCliBackendConfig(provider, cfg); if (!resolved) { - throw new Error(`expected CLI backend config for ${args[0]}`); + throw new Error(`Expected CLI backend ${provider}`); } return resolved; } -function createClaudeCliOverrideConfig(config: CliBackendConfig): OpenClawConfig { - return { - agents: { - defaults: { - cliBackends: { - "claude-cli": config, - }, - }, - }, - } satisfies OpenClawConfig; -} - -const NORMALIZED_CLAUDE_FALLBACK_ARGS = [ - "-p", - "--output-format", - "stream-json", - "--setting-sources", - "user", -]; - -const NORMALIZED_CLAUDE_FALLBACK_RESUME_ARGS = [ - "-p", - "--resume", - "{sessionId}", - "--setting-sources", - "user", -]; - -function isTestYoloConfig(context?: CliBackendNormalizeConfigContext): boolean { - const agentExec = context?.agentId - ? context.config?.agents?.list?.find((agent) => agent.id === context.agentId)?.tools?.exec - : undefined; - const exec = agentExec ?? context?.config?.tools?.exec; - return (exec?.mode ?? "full") === "full"; -} - -function normalizeTestPermissionMode(context?: CliBackendNormalizeConfigContext): { - mode?: string; - overrideExisting: boolean; -} { - return isTestYoloConfig(context) - ? { mode: "bypassPermissions", overrideExisting: false } - : { overrideExisting: false }; -} - -function normalizeTestClaudeArgs( - args: string[] | undefined, - permission: { mode?: string; overrideExisting: boolean }, -): string[] | undefined { - // Mirrors Claude backend normalization without loading the bundled runtime. - if (!args) { - return permission.mode ? ["--permission-mode", permission.mode] : args; - } - const normalized: string[] = []; - let hasSettingSources = false; - let hasPermissionMode = false; - for (let i = 0; i < args.length; i += 1) { - const arg = expectDefined(args[i], "args[i] test invariant"); - if (arg === "--dangerously-skip-permissions") { - continue; - } - if (arg === "--setting-sources") { - const maybeValue = args[i + 1]; - if (maybeValue && !maybeValue.startsWith("-")) { - hasSettingSources = true; - normalized.push(arg, "user"); - i += 1; - } - continue; - } - if (arg.startsWith("--setting-sources=")) { - hasSettingSources = true; - normalized.push("--setting-sources=user"); - continue; - } - if (arg === "--permission-mode") { - const maybeValue = args[i + 1]; - if (maybeValue && !maybeValue.startsWith("-")) { - hasPermissionMode = true; - if (!permission.overrideExisting) { - normalized.push(arg, maybeValue); - } - i += 1; - } - continue; - } - if (arg.startsWith("--permission-mode=")) { - const maybeValue = arg.slice("--permission-mode=".length).trim(); - if (maybeValue.length > 0 && !maybeValue.startsWith("-")) { - hasPermissionMode = true; - if (!permission.overrideExisting) { - normalized.push(`--permission-mode=${maybeValue}`); - } - } - continue; - } - normalized.push(arg); - } - if (!hasSettingSources) { - normalized.push("--setting-sources", "user"); - } - if (permission.mode && (!hasPermissionMode || permission.overrideExisting)) { - normalized.push("--permission-mode", permission.mode); - } - return normalized; -} - -function normalizeTestClaudeBackendConfig( - config: CliBackendConfig, - context?: CliBackendNormalizeConfigContext, -): CliBackendConfig { - const permission = normalizeTestPermissionMode(context); - return { - ...config, - args: normalizeTestClaudeArgs(config.args, permission), - resumeArgs: normalizeTestClaudeArgs(config.resumeArgs, permission), - }; -} - -function readTestGeminiOutput( - args: readonly string[] | undefined, -): NonNullable { - for (let index = 0; index < (args?.length ?? 0); index += 1) { - const arg = args?.[index]; - const value = - arg === "--output-format" || arg === "-o" - ? args?.[index + 1] - : arg?.startsWith("--output-format=") - ? arg.slice("--output-format=".length) - : arg?.startsWith("-o=") - ? arg.slice("-o=".length) - : undefined; - if (value === "stream-json") { - return "jsonl"; - } - if (value === "json" || value === "text") { - return value; - } - } - return "text"; -} - -function normalizeTestGeminiBackendConfig(config: CliBackendConfig): CliBackendConfig { - const output = readTestGeminiOutput(config.args); - const resumeOutput = readTestGeminiOutput(config.resumeArgs ?? config.args); - return { - ...config, - output, - resumeOutput, - jsonlDialect: output === "jsonl" || resumeOutput === "jsonl" ? "gemini-stream-json" : undefined, - }; -} +beforeEach(() => { + const entries = [runtimeEntry()]; + cliBackendsTesting.setDepsForTest({ + resolveRuntimeCliBackends: () => entries, + resolvePluginSetupCliBackend: () => undefined, + resolvePluginSetupRegistry: () => ({ cliBackends: [] }) as never, + }); +}); afterEach(() => { cliBackendsTesting.resetDepsForTest(); }); -beforeEach(() => { - runtimeBackendEntries = [ - createRuntimeBackendEntry({ - pluginId: "anthropic", - id: "claude-cli", +describe("resolveCliBackendConfig", () => { + it("returns the plugin-owned command adapter and registration metadata", () => { + const resolved = requireBackend(); + + expect(resolved).toMatchObject({ + id: "acme-cli", + modelProvider: "acme", + pluginId: "acme-plugin", bundleMcp: true, bundleMcpMode: "claude-config-file", - autoSelectAuthProfile: false, - ownsNativeCompaction: true, + runtimeArtifact, config: { - command: "claude", - args: [ - "stream-json", - "--include-partial-messages", - "--verbose", - "--setting-sources", - "user", - "--allowedTools", - "mcp__openclaw__*", - ], - resumeArgs: [ - "stream-json", - "--include-partial-messages", - "--verbose", - "--setting-sources", - "user", - "--allowedTools", - "mcp__openclaw__*", - "--resume", - "{sessionId}", - ], - output: "jsonl", + command: "acme", + args: ["chat", "--json"], + output: "json", input: "stdin", - imageArg: "@", - imagePathScope: "workspace", - clearEnv: [ - "ANTHROPIC_API_KEY", - "ANTHROPIC_API_KEY_OLD", - "ANTHROPIC_API_TOKEN", - "ANTHROPIC_AUTH_TOKEN", - "ANTHROPIC_BASE_URL", - "ANTHROPIC_CUSTOM_HEADERS", - "ANTHROPIC_OAUTH_TOKEN", - "ANTHROPIC_UNIX_SOCKET", - "CLAUDE_CONFIG_DIR", - "CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR", - "CLAUDE_CODE_ENTRYPOINT", - "CLAUDE_CODE_OAUTH_REFRESH_TOKEN", - "CLAUDE_CODE_OAUTH_SCOPES", - "CLAUDE_CODE_OAUTH_TOKEN", - "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", - "CLAUDE_CODE_PLUGIN_CACHE_DIR", - "CLAUDE_CODE_PLUGIN_SEED_DIR", - "CLAUDE_CODE_REMOTE", - "CLAUDE_CODE_USE_COWORK_PLUGINS", - "CLAUDE_CODE_USE_BEDROCK", - "CLAUDE_CODE_USE_FOUNDRY", - "CLAUDE_CODE_USE_VERTEX", - ], - }, - normalizeConfig: normalizeTestClaudeBackendConfig, - }), - createRuntimeBackendEntry({ - pluginId: "openai", - id: "codex-cli", - bundleMcp: true, - bundleMcpMode: "codex-config-overrides", - config: { - command: "codex", - args: [ - "exec", - "--json", - "--color", - "never", - "--sandbox", - "workspace-write", - "-c", - 'service_tier="fast"', - "--skip-git-repo-check", - ], - resumeArgs: [ - "exec", - "resume", - "{sessionId}", - "-c", - 'sandbox_mode="workspace-write"', - "-c", - 'service_tier="fast"', - "--skip-git-repo-check", - ], - systemPromptFileConfigArg: "-c", - systemPromptFileConfigKey: "model_instructions_file", - systemPromptWhen: "first", - imagePathScope: "workspace", - reliability: { - watchdog: { - fresh: { - noOutputTimeoutRatio: 0.8, - minMs: 60_000, - maxMs: 180_000, - }, - resume: { - noOutputTimeoutRatio: 0.3, - minMs: 60_000, - maxMs: 180_000, - }, - }, - }, - }, - }), - createRuntimeBackendEntry({ - pluginId: "google", - id: "google-gemini-cli", - bundleMcp: true, - bundleMcpMode: "gemini-system-settings", - authEpochMode: "profile-only", - prepareExecution: async () => null, - normalizeConfig: normalizeTestGeminiBackendConfig, - config: { - command: "gemini", - args: [ - "--skip-trust", - "--approval-mode", - "auto_edit", - "--output-format", - "stream-json", - "--prompt", - "{prompt}", - ], - resumeArgs: [ - "--skip-trust", - "--approval-mode", - "auto_edit", - "--resume", - "{sessionId}", - "--output-format", - "stream-json", - "--prompt", - "{prompt}", - ], - output: "jsonl", - jsonlDialect: "gemini-stream-json", - imageArg: "@", - imagePathScope: "workspace", modelArg: "--model", + sessionArgs: ["--session", "{sessionId}"], sessionMode: "existing", - sessionIdFields: ["session_id", "sessionId"], - modelAliases: { pro: "gemini-3.1-pro-preview" }, }, - }), - ]; - const claudeBackend = runtimeBackendEntries.find((entry) => entry.id === "claude-cli"); - setupBackendEntries = claudeBackend - ? [ - { - pluginId: claudeBackend.pluginId, - backend: { - ...claudeBackend, - config: { - ...claudeBackend.config, - sessionArgs: ["--session-id", "{sessionId}"], - sessionMode: "always", - systemPromptFileArg: "--append-system-prompt-file", - systemPromptWhen: "always", // fix(#80374): was "first" - }, - }, - }, - ] - : []; - cliBackendsTesting.setDepsForTest({ - resolveRuntimeCliBackends: () => runtimeBackendEntries, - resolvePluginSetupCliBackend: ({ backend }) => { - return setupBackendEntries.find((entry) => entry.backend.id === backend); - }, + }); }); -}); -describe("resolveCliBackendConfig reliability merge", () => { - it("preserves backend-owned runtime artifacts across command overrides", () => { - const runtimeArtifact = { - kind: "bundled-package-tree", - packageName: "@fixture/cli", - entrypoint: "command", - } as const; - runtimeBackendEntries.unshift( - createRuntimeBackendEntry({ - pluginId: "fixture", - id: "fixture-cli", - config: { command: "fixture", args: ["run"] }, - runtimeArtifact, + it("normalizes the registered adapter with agent and runtime config context", () => { + const normalizeConfig = vi.fn( + (config: CliBackendConfig): CliBackendConfig => ({ + ...config, + args: [...(config.args ?? []), "--normalized"], }), ); - - const resolved = resolveCliBackendConfig("fixture-cli", { - agents: { defaults: { cliBackends: { "fixture-cli": { command: "/opt/fixture" } } } }, + cliBackendsTesting.setDepsForTest({ + resolveRuntimeCliBackends: () => [runtimeEntry({ normalizeConfig })], + resolvePluginSetupCliBackend: () => undefined, }); - expect(resolved?.config.command).toBe("/opt/fixture"); - expect(resolved?.runtimeArtifact).toEqual(runtimeArtifact); - }); + const cfg: OpenClawConfig = { tools: { exec: { mode: "ask" } } }; - it("defaults codex-cli fresh sandboxing and config-pinned resume sandboxing", () => { - const resolved = requireCliBackendConfig("codex-cli"); + const resolved = resolveCliBackendConfig("acme-cli", cfg, { agentId: "reviewer" }); - expect(resolved.config.args).toEqual([ - "exec", - "--json", - "--color", - "never", - "--sandbox", - "workspace-write", - "-c", - 'service_tier="fast"', - "--skip-git-repo-check", - ]); - expect(resolved.config.resumeArgs).toEqual([ - "exec", - "resume", - "{sessionId}", - "-c", - 'sandbox_mode="workspace-write"', - "-c", - 'service_tier="fast"', - "--skip-git-repo-check", - ]); - }); -}); - -describe("resolveCliBackendLiveTest", () => { - it("returns plugin-owned live smoke metadata for claude", () => { - expect(resolveCliBackendLiveTest("claude-cli")).toEqual({ - defaultModelRef: "claude-cli/claude-sonnet-4-6", - defaultImageProbe: true, - defaultMcpProbe: true, - dockerNpmPackage: "@anthropic-ai/claude-code", - dockerBinaryName: "claude", + expect(resolved?.config.args).toEqual(["chat", "--json", "--normalized"]); + expect(normalizeConfig).toHaveBeenCalledWith(expect.objectContaining({ command: "acme" }), { + backendId: "acme-cli", + agentId: "reviewer", + config: cfg, }); }); - it("returns plugin-owned live smoke metadata for codex", () => { - expect(resolveCliBackendLiveTest("codex-cli")).toEqual({ - defaultModelRef: "codex-cli/gpt-5.5", - defaultImageProbe: true, - defaultMcpProbe: true, - dockerNpmPackage: "@openai/codex@0.132.0", - dockerBinaryName: "codex", - }); - }); - - it("returns plugin-owned live smoke metadata for gemini", () => { - expect(resolveCliBackendLiveTest("google-gemini-cli")).toEqual({ - defaultModelRef: "google-gemini-cli/gemini-3-flash-preview", - defaultImageProbe: true, - defaultMcpProbe: true, - dockerNpmPackage: "@google/gemini-cli", - dockerBinaryName: "gemini", - }); - }); -}); - -describe("resolveCliBackendConfig claude-cli defaults", () => { - it("derives bypassPermissions from OpenClaw's default YOLO exec policy", () => { - const resolved = requireCliBackendConfig("claude-cli"); - - expect(resolved?.bundleMcp).toBe(true); - expect(resolved?.bundleMcpMode).toBe("claude-config-file"); - expect(resolved?.autoSelectAuthProfile).toBe(false); - expect(resolved?.config.output).toBe("jsonl"); - expect(resolved?.config.args).toContain("stream-json"); - expect(resolved?.config.args).toContain("--include-partial-messages"); - expect(resolved?.config.args).toContain("--verbose"); - expect(resolved?.config.args).toContain("--setting-sources"); - expect(resolved?.config.args).toContain("user"); - expect(resolved?.config.args).toContain("--allowedTools"); - expect(resolved?.config.args).toContain("mcp__openclaw__*"); - expect(resolved?.config.args).toContain("--permission-mode"); - expect(resolved?.config.args).toContain("bypassPermissions"); - expect(resolved?.config.args).not.toContain("--dangerously-skip-permissions"); - expect(resolved?.config.input).toBe("stdin"); - expect(resolved?.config.imageArg).toBe("@"); - expect(resolved?.config.imagePathScope).toBe("workspace"); - expect(resolved?.config.resumeArgs).toContain("stream-json"); - expect(resolved?.config.resumeArgs).toContain("--include-partial-messages"); - expect(resolved?.config.resumeArgs).toContain("--verbose"); - expect(resolved?.config.resumeArgs).toContain("--setting-sources"); - expect(resolved?.config.resumeArgs).toContain("user"); - expect(resolved?.config.resumeArgs).toContain("--allowedTools"); - expect(resolved?.config.resumeArgs).toContain("mcp__openclaw__*"); - expect(resolved?.config.resumeArgs).toContain("--permission-mode"); - expect(resolved?.config.resumeArgs).toContain("bypassPermissions"); - expect(resolved?.config.resumeArgs).not.toContain("--dangerously-skip-permissions"); - }); - - it("declares ownsNativeCompaction for claude-cli", () => { - const resolved = requireCliBackendConfig("claude-cli"); - expect(resolved?.ownsNativeCompaction).toBe(true); - }); - - it("keeps Claude permission mode unset when OpenClaw exec policy is not YOLO", () => { - const resolved = requireCliBackendConfig("claude-cli", { - tools: { exec: { mode: "ask" } }, - }); - - expect(resolved?.config.args).not.toContain("--permission-mode"); - expect(resolved?.config.args).not.toContain("bypassPermissions"); - expect(resolved?.config.resumeArgs).not.toContain("--permission-mode"); - expect(resolved?.config.resumeArgs).not.toContain("bypassPermissions"); - }); - - it("derives Claude permission mode from per-agent exec policy when an agent id is known", () => { - const cfg = { - tools: { exec: { mode: "full" } }, - agents: { - list: [ - { - id: "reviewer", - tools: { exec: { mode: "ask" } }, - }, - { - id: "builder", - tools: { exec: { mode: "full" } }, - }, - ], - }, - } satisfies OpenClawConfig; - - const reviewer = resolveCliBackendConfig("claude-cli", cfg, { agentId: "reviewer" }); - const builder = resolveCliBackendConfig("claude-cli", cfg, { agentId: "builder" }); - - expect(reviewer?.config.args).not.toContain("--permission-mode"); - expect(reviewer?.config.resumeArgs).not.toContain("--permission-mode"); - expect(builder?.config.args).toContain("--permission-mode"); - expect(builder?.config.args).toContain("bypassPermissions"); - expect(builder?.config.resumeArgs).toContain("--permission-mode"); - expect(builder?.config.resumeArgs).toContain("bypassPermissions"); - }); - - it("preserves raw Claude permission args during backend normalization", () => { - const safe = resolveCliBackendConfig("claude-cli", { - tools: { exec: { mode: "full" } }, - agents: { - defaults: { - cliBackends: { - "claude-cli": { - command: "claude", - args: ["-p", "--permission-mode", "default"], - resumeArgs: ["-p", "--permission-mode=default", "--resume", "{sessionId}"], - }, - }, - }, + it("does not let a mutating normalizer rewrite the registered adapter", () => { + const backend = runtimeEntry({ + normalizeConfig(config, context) { + config.command = `${config.command}-${context?.agentId ?? "default"}`; + return config; }, }); - const yolo = resolveCliBackendConfig("claude-cli", { - tools: { exec: { mode: "deny" } }, - agents: { - defaults: { - cliBackends: { - "claude-cli": { - command: "claude", - args: ["-p", "--permission-mode", "bypassPermissions"], - resumeArgs: ["-p", "--permission-mode=bypassPermissions", "--resume", "{sessionId}"], - }, - }, - }, - }, + cliBackendsTesting.setDepsForTest({ + resolveRuntimeCliBackends: () => [backend], + resolvePluginSetupCliBackend: () => undefined, }); - expect(safe?.config.args).toContain("default"); - expect(safe?.config.args).not.toContain("bypassPermissions"); - expect(yolo?.config.args).toContain("--permission-mode"); - expect(yolo?.config.args).toContain("bypassPermissions"); + expect(resolveCliBackendConfig("acme-cli", {}, { agentId: "reviewer" })?.config.command).toBe( + "acme-reviewer", + ); + expect(resolveCliBackendConfig("acme-cli", {}, { agentId: "builder" })?.config.command).toBe( + "acme-builder", + ); + expect(backend.config.command).toBe("acme"); }); - it("retains default claude safety args when only command is overridden", () => { - const cfg = { - agents: { - defaults: { - cliBackends: { - "claude-cli": { - command: "/usr/local/bin/claude", - }, - }, - }, - }, - } satisfies OpenClawConfig; - - const resolved = requireCliBackendConfig("claude-cli", cfg); - - expect(resolved?.config.command).toBe("/usr/local/bin/claude"); - expect(resolved?.config.args).toContain("--setting-sources"); - expect(resolved?.config.args).toContain("user"); - expect(resolved?.config.args).toContain("--permission-mode"); - expect(resolved?.config.args).toContain("bypassPermissions"); - expect(resolved?.config.resumeArgs).toContain("--setting-sources"); - expect(resolved?.config.resumeArgs).toContain("user"); - expect(resolved?.config.resumeArgs).toContain("--permission-mode"); - expect(resolved?.config.resumeArgs).toContain("bypassPermissions"); - expect(resolved?.config.env).not.toHaveProperty("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST"); - expect(resolved?.config.clearEnv).toContain("ANTHROPIC_API_TOKEN"); - expect(resolved?.config.clearEnv).toContain("ANTHROPIC_BASE_URL"); - expect(resolved?.config.clearEnv).toContain("ANTHROPIC_CUSTOM_HEADERS"); - expect(resolved?.config.clearEnv).toContain("ANTHROPIC_OAUTH_TOKEN"); - expect(resolved?.config.clearEnv).toContain("CLAUDE_CONFIG_DIR"); - expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_OAUTH_TOKEN"); - expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_PLUGIN_CACHE_DIR"); - expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_PLUGIN_SEED_DIR"); - expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_REMOTE"); - expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_USE_COWORK_PLUGINS"); - }); - - it("drops legacy skip-permissions overrides without inventing bypassPermissions under safe policy", () => { - const cfg = { - agents: { - defaults: { - cliBackends: { - "claude-cli": { - command: "claude", - args: ["-p", "--dangerously-skip-permissions", "--output-format", "json"], - resumeArgs: [ - "-p", - "--dangerously-skip-permissions", - "--output-format", - "json", - "--resume", - "{sessionId}", - ], - }, - }, - }, - }, - tools: { exec: { mode: "ask" } }, - } satisfies OpenClawConfig; - - const resolved = requireCliBackendConfig("claude-cli", cfg); - - expect(resolved?.config.args).not.toContain("--dangerously-skip-permissions"); - expect(resolved?.config.args).not.toContain("--permission-mode"); - expect(resolved?.config.resumeArgs).not.toContain("--dangerously-skip-permissions"); - expect(resolved?.config.resumeArgs).not.toContain("--permission-mode"); - }); - - it("keeps explicit permission-mode overrides while removing legacy skip flag", () => { - const cfg = { - agents: { - defaults: { - cliBackends: { - "claude-cli": { - command: "claude", - args: ["-p", "--dangerously-skip-permissions", "--permission-mode", "acceptEdits"], - resumeArgs: [ - "-p", - "--dangerously-skip-permissions", - "--permission-mode=acceptEdits", - "--resume", - "{sessionId}", - ], - }, - }, - }, - }, - } satisfies OpenClawConfig; - - const resolved = requireCliBackendConfig("claude-cli", cfg); - - expect(resolved?.config.args).not.toContain("--dangerously-skip-permissions"); - expect(resolved?.config.args).toEqual([ - "-p", - "--permission-mode", - "acceptEdits", - "--setting-sources", - "user", - ]); - expect(resolved?.config.resumeArgs).not.toContain("--dangerously-skip-permissions"); - expect(resolved?.config.resumeArgs).toEqual([ - "-p", - "--permission-mode=acceptEdits", - "--resume", - "{sessionId}", - "--setting-sources", - "user", - ]); - expect(resolved?.config.args).not.toContain("bypassPermissions"); - expect(resolved?.config.resumeArgs).not.toContain("bypassPermissions"); - }); - - it("forces project or local setting-source overrides back to user-only", () => { - const cfg = { - agents: { - defaults: { - cliBackends: { - "claude-cli": { - command: "claude", - args: ["-p", "--setting-sources", "project", "--permission-mode", "acceptEdits"], - resumeArgs: [ - "-p", - "--setting-sources=local,user", - "--resume", - "{sessionId}", - "--permission-mode=acceptEdits", - ], - }, - }, - }, - }, - } satisfies OpenClawConfig; - - const resolved = requireCliBackendConfig("claude-cli", cfg); - - expect(resolved?.config.args).toEqual([ - "-p", - "--setting-sources", - "user", - "--permission-mode", - "acceptEdits", - ]); - expect(resolved?.config.resumeArgs).toEqual([ - "-p", - "--setting-sources=user", - "--resume", - "{sessionId}", - "--permission-mode=acceptEdits", - ]); - }); - - it("falls back to user-only setting sources when a custom override leaves the flag without a value", () => { - const cfg = { - ...createClaudeCliOverrideConfig({ - command: "claude", - args: ["-p", "--setting-sources", "--output-format", "stream-json"], - resumeArgs: ["-p", "--setting-sources", "--resume", "{sessionId}"], - }), - tools: { exec: { mode: "ask" } }, - } satisfies OpenClawConfig; - - const resolved = requireCliBackendConfig("claude-cli", cfg); - - expect(resolved?.config.args).toEqual(NORMALIZED_CLAUDE_FALLBACK_ARGS); - expect(resolved?.config.resumeArgs).toEqual(NORMALIZED_CLAUDE_FALLBACK_RESUME_ARGS); - }); - - it("drops malformed permission-mode overrides without adding bypassPermissions under safe policy", () => { - const cfg = { - ...createClaudeCliOverrideConfig({ - command: "claude", - args: ["-p", "--permission-mode", "--output-format", "stream-json"], - resumeArgs: ["-p", "--permission-mode=--resume", "--resume", "{sessionId}"], - }), - tools: { exec: { mode: "ask" } }, - } satisfies OpenClawConfig; - - const resolved = requireCliBackendConfig("claude-cli", cfg); - - expect(resolved?.config.args).toEqual(NORMALIZED_CLAUDE_FALLBACK_ARGS); - expect(resolved?.config.resumeArgs).toEqual(NORMALIZED_CLAUDE_FALLBACK_RESUME_ARGS); - }); - - it("leaves permission-mode unset when custom args omit it under safe policy", () => { - const cfg = { - agents: { - defaults: { - cliBackends: { - "claude-cli": { - command: "claude", - args: ["-p", "--output-format", "stream-json", "--verbose"], - resumeArgs: [ - "-p", - "--output-format", - "stream-json", - "--verbose", - "--resume", - "{sessionId}", - ], - }, - }, - }, - }, - tools: { exec: { mode: "ask" } }, - } satisfies OpenClawConfig; - - const resolved = requireCliBackendConfig("claude-cli", cfg); - - expect(resolved?.config.args).toContain("--setting-sources"); - expect(resolved?.config.args).toContain("user"); - expect(resolved?.config.args).not.toContain("--permission-mode"); - expect(resolved?.config.resumeArgs).toContain("--setting-sources"); - expect(resolved?.config.resumeArgs).toContain("user"); - expect(resolved?.config.resumeArgs).not.toContain("--permission-mode"); - }); - - it("keeps hardened clearEnv defaults when custom claude env overrides are merged", () => { - const cfg = { - agents: { - defaults: { - cliBackends: { - "claude-cli": { - command: "claude", - env: { - SAFE_CUSTOM: "ok", - ANTHROPIC_BASE_URL: "https://evil.example.com/v1", - }, - clearEnv: ["EXTRA_CLEAR"], - }, - }, - }, - }, - } satisfies OpenClawConfig; - - const resolved = requireCliBackendConfig("claude-cli", cfg); - - expect(resolved?.config.env).toEqual({ - SAFE_CUSTOM: "ok", - ANTHROPIC_BASE_URL: "https://evil.example.com/v1", + it("falls back to setup registration before runtime activation", () => { + const entry = setupEntry({ config: { command: "setup-acme", args: ["run"] } }); + cliBackendsTesting.setDepsForTest({ + resolveRuntimeCliBackends: () => [], + resolvePluginSetupCliBackend: ({ backend }) => (backend === "acme-cli" ? entry : undefined), }); - expect(resolved?.config.clearEnv).toContain("ANTHROPIC_BASE_URL"); - expect(resolved?.config.clearEnv).toContain("ANTHROPIC_API_TOKEN"); - expect(resolved?.config.clearEnv).toContain("ANTHROPIC_CUSTOM_HEADERS"); - expect(resolved?.config.clearEnv).toContain("ANTHROPIC_OAUTH_TOKEN"); - expect(resolved?.config.clearEnv).toContain("CLAUDE_CONFIG_DIR"); - expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_OAUTH_TOKEN"); - expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_PLUGIN_CACHE_DIR"); - expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_PLUGIN_SEED_DIR"); - expect(resolved?.config.clearEnv).toContain("EXTRA_CLEAR"); + + const resolved = requireBackend(); + + expect(resolved.pluginId).toBeUndefined(); + expect(resolved.config).toEqual({ command: "setup-acme", args: ["run"] }); + expect(resolved.runtimeArtifact).toEqual(runtimeArtifact); }); - it("normalizes override-only claude-cli config when the plugin registry is absent", () => { - runtimeBackendEntries = []; + it("returns null when no plugin owns the backend", () => { + cliBackendsTesting.setDepsForTest({ + resolveRuntimeCliBackends: () => [], + resolvePluginSetupCliBackend: () => undefined, + }); - const cfg = { - agents: { - defaults: { - cliBackends: { - "claude-cli": { - command: "/usr/local/bin/claude", - args: ["-p", "--output-format", "json"], - resumeArgs: ["-p", "--output-format", "json", "--resume", "{sessionId}"], - }, - }, - }, - }, - } satisfies OpenClawConfig; - - const resolved = requireCliBackendConfig("claude-cli", cfg); - - expect(resolved?.bundleMcp).toBe(true); - expect(resolved?.bundleMcpMode).toBe("claude-config-file"); - expect(resolved?.autoSelectAuthProfile).toBe(false); - expect(resolved?.config.args).toEqual([ - "-p", - "--output-format", - "json", - "--setting-sources", - "user", - "--permission-mode", - "bypassPermissions", - ]); - expect(resolved?.config.resumeArgs).toEqual([ - "-p", - "--output-format", - "json", - "--resume", - "{sessionId}", - "--setting-sources", - "user", - "--permission-mode", - "bypassPermissions", - ]); - expect(resolved?.config.systemPromptFileArg).toBe("--append-system-prompt-file"); - expect(resolved?.config.systemPromptWhen).toBe("always"); // fix(#80374): was "first" - expect(resolved?.config.sessionArgs).toEqual(["--session-id", "{sessionId}"]); - expect(resolved?.config.sessionMode).toBe("always"); - expect(resolved?.config.input).toBe("stdin"); - expect(resolved?.config.output).toBe("jsonl"); - }); -}); - -describe("resolveCliBackendConfig google-gemini-cli defaults", () => { - it("uses Gemini CLI stream-json args and existing-session resume mode", () => { - const resolved = requireCliBackendConfig("google-gemini-cli"); - - expect(resolved?.bundleMcp).toBe(true); - expect(resolved?.bundleMcpMode).toBe("gemini-system-settings"); - expect(resolved?.authEpochMode).toBe("profile-only"); - expect(resolved?.prepareExecution).toBeTypeOf("function"); - expect(resolved?.config.args).toEqual([ - "--skip-trust", - "--approval-mode", - "auto_edit", - "--output-format", - "stream-json", - "--prompt", - "{prompt}", - ]); - expect(resolved?.config.resumeArgs).toEqual([ - "--skip-trust", - "--approval-mode", - "auto_edit", - "--resume", - "{sessionId}", - "--output-format", - "stream-json", - "--prompt", - "{prompt}", - ]); - expect(resolved?.config.output).toBe("jsonl"); - expect(resolved?.config.resumeOutput).toBe("jsonl"); - expect(resolved?.config.jsonlDialect).toBe("gemini-stream-json"); - expect(resolved?.config.modelArg).toBe("--model"); - expect(resolved?.config.sessionMode).toBe("existing"); - expect(resolved?.config.sessionIdFields).toEqual(["session_id", "sessionId"]); - expect(resolved?.config.modelAliases?.pro).toBe("gemini-3.1-pro-preview"); + expect(resolveCliBackendConfig("missing-cli")).toBeNull(); }); - it("keeps legacy Gemini CLI json arg overrides on the json parser", () => { - const cfg = { - agents: { - defaults: { - cliBackends: { - "google-gemini-cli": { - command: "gemini", - args: ["--skip-trust", "--output-format", "json", "--prompt", "{prompt}"], - resumeArgs: [ - "--skip-trust", - "--resume", - "{sessionId}", - "--output-format=json", - "--prompt", - "{prompt}", - ], - }, - }, - }, - }, - } satisfies OpenClawConfig; - - const resolved = requireCliBackendConfig("google-gemini-cli", cfg); - - expect(resolved?.config.args).toEqual([ - "--skip-trust", - "--output-format", - "json", - "--prompt", - "{prompt}", - ]); - expect(resolved?.config.output).toBe("json"); - expect(resolved?.config.resumeOutput).toBe("json"); - expect(resolved?.config.jsonlDialect).toBeUndefined(); - }); - - it("keeps Gemini CLI short stream-json arg overrides on the jsonl parser", () => { - const cfg = { - agents: { - defaults: { - cliBackends: { - "google-gemini-cli": { - command: "gemini", - args: ["--skip-trust", "-o", "stream-json", "--prompt", "{prompt}"], - resumeArgs: [ - "--skip-trust", - "--resume", - "{sessionId}", - "-o=stream-json", - "--prompt", - "{prompt}", - ], - }, - }, - }, - }, - } satisfies OpenClawConfig; - - const resolved = requireCliBackendConfig("google-gemini-cli", cfg); - - expect(resolved?.config.output).toBe("jsonl"); - expect(resolved?.config.resumeOutput).toBe("jsonl"); - expect(resolved?.config.jsonlDialect).toBe("gemini-stream-json"); - }); - - it("uses Codex CLI bundle MCP config overrides", () => { - const resolved = requireCliBackendConfig("codex-cli"); - - expect(resolved?.bundleMcp).toBe(true); - expect(resolved?.bundleMcpMode).toBe("codex-config-overrides"); - expect(resolved?.defaultAuthProfileId).toBeUndefined(); - expect(resolved?.authEpochMode).toBeUndefined(); - expect(resolved?.prepareExecution).toBeUndefined(); - expect(resolved?.config.systemPromptFileConfigArg).toBe("-c"); - expect(resolved?.config.systemPromptFileConfigKey).toBe("model_instructions_file"); - expect(resolved?.config.systemPromptWhen).toBe("first"); - expect(resolved?.config.imagePathScope).toBe("workspace"); - }); - - it("preserves backend-owned per-run arg resolvers", () => { - const resolveExecutionArgs: CliBackendResolveExecutionArgs = ({ baseArgs }) => [ + it("preserves backend-owned execution hooks", () => { + const prepareExecution = vi.fn(async () => ({ env: { ACME_HOME: "/tmp/acme" } })); + const resolveExecutionArgs = vi.fn(({ baseArgs }: { baseArgs: readonly string[] }) => [ ...baseArgs, "--effort", "high", - ]; - runtimeBackendEntries = [ - createRuntimeBackendEntry({ - pluginId: "anthropic", - id: "claude-cli", - config: { - command: "claude", - args: ["-p"], - }, - resolveExecutionArgs, - }), - ]; + ]); + cliBackendsTesting.setDepsForTest({ + resolveRuntimeCliBackends: () => [ + runtimeEntry({ + prepareExecution, + resolveExecutionArgs: resolveExecutionArgs as never, + ownsNativeCompaction: true, + nativeToolMode: "selectable", + sideQuestionToolMode: "disabled", + }), + ], + resolvePluginSetupCliBackend: () => undefined, + }); - const resolved = requireCliBackendConfig("claude-cli"); + const resolved = requireBackend(); - expect(resolved?.resolveExecutionArgs).toBe(resolveExecutionArgs); + expect(resolved.prepareExecution).toBe(prepareExecution); + expect(resolved.resolveExecutionArgs).toBe(resolveExecutionArgs); + expect(resolved.ownsNativeCompaction).toBe(true); + expect(resolved.nativeToolMode).toBe("selectable"); + expect(resolved.sideQuestionToolMode).toBe("disabled"); }); }); -describe("resolveCliBackendConfig alias precedence", () => { - it("prefers the canonical backend key over legacy aliases when both are configured", () => { - runtimeBackendEntries = [ - createRuntimeBackendEntry({ - pluginId: "moonshot", - id: "kimi", - config: { - command: "kimi", - args: ["--default"], - }, - }), - ]; +describe("CLI backend metadata and bindings", () => { + it("returns plugin-owned live smoke metadata", () => { + expect(resolveCliBackendLiveTest("acme-cli")).toEqual({ + defaultModelRef: "acme/acme-large", + defaultImageProbe: true, + defaultMcpProbe: false, + dockerNpmPackage: "@fixture/acme-cli", + dockerBinaryName: "acme", + }); + }); - const cfg = { - agents: { - defaults: { - cliBackends: { - "kimi-coding": { - command: "kimi-legacy", - args: ["--legacy"], - }, - kimi: { - command: "kimi-canonical", - args: ["--canonical"], - }, - }, - }, - }, - } satisfies OpenClawConfig; + it("lists canonical provider to CLI runtime bindings", () => { + expect(listCliRuntimeModelBackendBindings()).toEqual([ + { provider: "acme", runtime: "acme-cli", pluginId: "acme-plugin" }, + ]); + expect(listCliRuntimeProviderIds()).toEqual(["acme-cli"]); + expect(resolveCliRuntimeCanonicalProvider({ runtime: "ACME-CLI" })).toBe("acme"); + expect(resolveCliRuntimeModelBackendBinding({ provider: "acme", runtime: "acme-cli" })).toEqual( + { provider: "acme", runtime: "acme-cli", pluginId: "acme-plugin" }, + ); + expect(isCliRuntimeModelBackendForProvider({ provider: "acme", runtime: "acme-cli" })).toBe( + true, + ); + }); - const resolved = requireCliBackendConfig("kimi", cfg); + it("includes setup bindings only when requested", () => { + cliBackendsTesting.setDepsForTest({ + resolveRuntimeCliBackends: () => [], + resolvePluginSetupCliBackend: ({ backend }) => + backend === "acme-cli" ? setupEntry() : undefined, + resolvePluginSetupRegistry: () => ({ cliBackends: [setupEntry()] }) as never, + }); - expect(resolved?.config.command).toBe("kimi-canonical"); - expect(resolved?.config.args).toEqual(["--canonical"]); + expect(listCliRuntimeModelBackendBindings()).toEqual([]); + expect(listCliRuntimeModelBackendBindings({ includeSetupRegistry: true })).toEqual([ + { provider: "acme", runtime: "acme-cli", pluginId: "acme-plugin" }, + ]); }); }); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts index ef022c616550..e9fbd22cd3f6 100644 --- a/src/agents/cli-backends.ts +++ b/src/agents/cli-backends.ts @@ -2,12 +2,12 @@ * Resolves CLI runtime backends registered by plugins or setup metadata. */ import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; -import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; -import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; -import type { CliBackendConfig } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { ContextEngineHostCapability } from "../context-engine/types.js"; -import type { CliBackendRuntimeArtifactPolicy } from "../plugins/cli-backend.types.js"; +import type { + CliBackendConfig, + CliBackendRuntimeArtifactPolicy, +} from "../plugins/cli-backend.types.js"; import { resolveRuntimeCliBackends } from "../plugins/cli-backends.runtime.js"; import { resolvePluginSetupCliBackend, @@ -155,24 +155,6 @@ function normalizeBackendKey(key: string): string { return normalizeProviderId(key); } -function pickBackendConfig( - config: Record, - normalizedId: string, -): CliBackendConfig | undefined { - const directKey = Object.keys(config).find( - (key) => normalizeOptionalLowercaseString(key) === normalizedId, - ); - if (directKey) { - return config[directKey]; - } - for (const [key, entry] of Object.entries(config)) { - if (normalizeBackendKey(key) === normalizedId) { - return entry; - } - } - return undefined; -} - function resolveRegisteredBackend(provider: string) { const normalized = normalizeBackendKey(provider); return cliBackendsDeps @@ -334,43 +316,6 @@ export function isCliRuntimeModelBackendForProvider(params: { return resolveCliRuntimeModelBackendBinding(params) !== undefined; } -function mergeBackendConfig(base: CliBackendConfig, override?: CliBackendConfig): CliBackendConfig { - if (!override) { - return { ...base }; - } - const baseFresh = base.reliability?.watchdog?.fresh ?? {}; - const baseResume = base.reliability?.watchdog?.resume ?? {}; - const overrideFresh = override.reliability?.watchdog?.fresh ?? {}; - const overrideResume = override.reliability?.watchdog?.resume ?? {}; - return { - ...base, - ...override, - args: override.args ?? base.args, - env: { ...base.env, ...override.env }, - modelAliases: { ...base.modelAliases, ...override.modelAliases }, - clearEnv: uniqueStrings([...(base.clearEnv ?? []), ...(override.clearEnv ?? [])]), - sessionIdFields: override.sessionIdFields ?? base.sessionIdFields, - sessionArgs: override.sessionArgs ?? base.sessionArgs, - resumeArgs: override.resumeArgs ?? base.resumeArgs, - reliability: { - ...base.reliability, - ...override.reliability, - watchdog: { - ...base.reliability?.watchdog, - ...override.reliability?.watchdog, - fresh: { - ...baseFresh, - ...overrideFresh, - }, - resume: { - ...baseResume, - ...overrideResume, - }, - }, - }, - }; -} - /** Resolves live-test defaults advertised by a CLI backend plugin. */ export function resolveCliBackendLiveTest(provider: string): ResolvedCliBackendLiveTest | null { const normalized = normalizeBackendKey(provider); @@ -392,7 +337,7 @@ export function resolveCliBackendLiveTest(provider: string): ResolvedCliBackendL }; } -/** Resolves the executable CLI backend config after plugin defaults and user overrides. */ +/** Resolves the executable CLI backend registered by its owning plugin. */ export function resolveCliBackendConfig( provider: string, cfg?: OpenClawConfig, @@ -405,14 +350,12 @@ export function resolveCliBackendConfig( ...(cfg ? { config: cfg } : {}), }; const runtimeTextTransforms = resolveRuntimeTextTransforms(); - const configured = cfg?.agents?.defaults?.cliBackends ?? {}; - const override = pickBackendConfig(configured, normalized); const registered = resolveRegisteredBackend(normalized); if (registered) { - const merged = mergeBackendConfig(registered.config, override); + const registeredConfig = { ...registered.config }; const config = registered.normalizeConfig - ? registered.normalizeConfig(merged, normalizeContext) - : merged; + ? registered.normalizeConfig(registeredConfig, normalizeContext) + : registeredConfig; const command = config.command?.trim(); if (!command) { return null; @@ -446,73 +389,35 @@ export function resolveCliBackendConfig( } const fallbackPolicy = resolveFallbackCliBackendPolicy(normalized); - if (!override) { - if (!fallbackPolicy?.baseConfig) { - return null; - } - const baseConfig = fallbackPolicy.normalizeConfig - ? fallbackPolicy.normalizeConfig(fallbackPolicy.baseConfig, normalizeContext) - : fallbackPolicy.baseConfig; - const command = baseConfig.command?.trim(); - if (!command) { - return null; - } - return { - id: normalized, - ...(fallbackPolicy.modelProvider ? { modelProvider: fallbackPolicy.modelProvider } : {}), - config: { ...baseConfig, command }, - bundleMcp: fallbackPolicy.bundleMcp, - bundleMcpMode: fallbackPolicy.bundleMcpMode, - transformSystemPrompt: fallbackPolicy.transformSystemPrompt, - textTransforms: mergePluginTextTransforms( - runtimeTextTransforms, - fallbackPolicy.textTransforms, - ), - defaultAuthProfileId: fallbackPolicy.defaultAuthProfileId, - authEpochMode: fallbackPolicy.authEpochMode, - autoSelectAuthProfile: fallbackPolicy.autoSelectAuthProfile, - contextEngineHostCapabilities: fallbackPolicy.contextEngineHostCapabilities, - ownsNativeCompaction: fallbackPolicy.ownsNativeCompaction, - prepareExecution: fallbackPolicy.prepareExecution, - resolveExecutionArgs: fallbackPolicy.resolveExecutionArgs, - resolveRuntimeToolAvailability: fallbackPolicy.resolveRuntimeToolAvailability, - nativeToolMode: fallbackPolicy.nativeToolMode, - sideQuestionToolMode: fallbackPolicy.sideQuestionToolMode, - runtimeArtifact: fallbackPolicy.runtimeArtifact, - }; + if (!fallbackPolicy?.baseConfig) { + return null; } - const mergedFallback = fallbackPolicy?.baseConfig - ? mergeBackendConfig(fallbackPolicy.baseConfig, override) - : override; - const config = fallbackPolicy?.normalizeConfig - ? fallbackPolicy.normalizeConfig(mergedFallback, normalizeContext) - : mergedFallback; + const config = fallbackPolicy.normalizeConfig + ? fallbackPolicy.normalizeConfig(fallbackPolicy.baseConfig, normalizeContext) + : fallbackPolicy.baseConfig; const command = config.command?.trim(); if (!command) { return null; } return { id: normalized, - ...(fallbackPolicy?.modelProvider ? { modelProvider: fallbackPolicy.modelProvider } : {}), + ...(fallbackPolicy.modelProvider ? { modelProvider: fallbackPolicy.modelProvider } : {}), config: { ...config, command }, - bundleMcp: fallbackPolicy?.bundleMcp === true, - bundleMcpMode: fallbackPolicy?.bundleMcpMode, - transformSystemPrompt: fallbackPolicy?.transformSystemPrompt, - textTransforms: mergePluginTextTransforms( - runtimeTextTransforms, - fallbackPolicy?.textTransforms, - ), - defaultAuthProfileId: fallbackPolicy?.defaultAuthProfileId, - authEpochMode: fallbackPolicy?.authEpochMode, - autoSelectAuthProfile: fallbackPolicy?.autoSelectAuthProfile, - contextEngineHostCapabilities: fallbackPolicy?.contextEngineHostCapabilities, - ownsNativeCompaction: fallbackPolicy?.ownsNativeCompaction, - prepareExecution: fallbackPolicy?.prepareExecution, - resolveExecutionArgs: fallbackPolicy?.resolveExecutionArgs, - resolveRuntimeToolAvailability: fallbackPolicy?.resolveRuntimeToolAvailability, - nativeToolMode: fallbackPolicy?.nativeToolMode, - sideQuestionToolMode: fallbackPolicy?.sideQuestionToolMode, - runtimeArtifact: fallbackPolicy?.runtimeArtifact, + bundleMcp: fallbackPolicy.bundleMcp, + bundleMcpMode: fallbackPolicy.bundleMcpMode, + transformSystemPrompt: fallbackPolicy.transformSystemPrompt, + textTransforms: mergePluginTextTransforms(runtimeTextTransforms, fallbackPolicy.textTransforms), + defaultAuthProfileId: fallbackPolicy.defaultAuthProfileId, + authEpochMode: fallbackPolicy.authEpochMode, + autoSelectAuthProfile: fallbackPolicy.autoSelectAuthProfile, + contextEngineHostCapabilities: fallbackPolicy.contextEngineHostCapabilities, + ownsNativeCompaction: fallbackPolicy.ownsNativeCompaction, + prepareExecution: fallbackPolicy.prepareExecution, + resolveExecutionArgs: fallbackPolicy.resolveExecutionArgs, + resolveRuntimeToolAvailability: fallbackPolicy.resolveRuntimeToolAvailability, + nativeToolMode: fallbackPolicy.nativeToolMode, + sideQuestionToolMode: fallbackPolicy.sideQuestionToolMode, + runtimeArtifact: fallbackPolicy.runtimeArtifact, }; } diff --git a/src/agents/cli-output.ts b/src/agents/cli-output.ts index 24990b1735c7..c0ada261b5b3 100644 --- a/src/agents/cli-output.ts +++ b/src/agents/cli-output.ts @@ -7,7 +7,7 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import type { AgentPlanStep } from "../channels/streaming.js"; -import type { CliBackendConfig } from "../config/types.js"; +import type { CliBackendConfig } from "../plugins/cli-backend.types.js"; import { extractBalancedJsonFragments } from "../shared/balanced-json.js"; import { isRecord } from "../utils.js"; import type { 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 6c4e36f1a3ab..eb6f78c0c270 100644 --- a/src/agents/cli-runner.before-agent-reply-cron.test.ts +++ b/src/agents/cli-runner.before-agent-reply-cron.test.ts @@ -12,6 +12,7 @@ import { resetDiagnosticEventsForTest, type DiagnosticEventPayload, } from "../infra/diagnostic-events.js"; +import { testing as cliBackendsTesting } from "./cli-backends.test-support.js"; import type { CliOutput } from "./cli-output.js"; import { cliBackendLog } from "./cli-runner/log.js"; @@ -150,6 +151,7 @@ beforeAll(async () => { }); afterEach(() => { + cliBackendsTesting.resetDepsForTest(); vi.clearAllMocks(); resetDiagnosticEventsForTest(); }); @@ -362,27 +364,29 @@ describe("runCliAgent before_agent_reply seam", () => { ); it("clears stateless CLI bindings when before_agent_reply claims a cron turn", async () => { + cliBackendsTesting.setDepsForTest({ + resolvePluginSetupCliBackend: () => undefined, + resolveRuntimeCliBackends: () => [ + { + id: "codex-cli", + pluginId: "test-codex-cli", + config: { + command: "codex", + args: ["exec"], + output: "text", + input: "arg", + sessionMode: "none", + }, + }, + ], + }); hasHooksMock.mockImplementation((hookName) => hookName === "before_agent_reply"); runBeforeAgentReplyMock.mockResolvedValue({ handled: true }); const result = await runCliAgent({ ...baseRunParams, trigger: "cron", - config: { - agents: { - defaults: { - cliBackends: { - "codex-cli": { - command: "codex", - args: ["exec"], - output: "text", - input: "arg", - sessionMode: "none", - }, - }, - }, - }, - }, + config: {}, }); expect(result.meta.agentMeta?.sessionId).toBe(""); diff --git a/src/agents/cli-runner.reliability.test.ts b/src/agents/cli-runner.reliability.test.ts index 99b012ea86b3..6158ec35c6dd 100644 --- a/src/agents/cli-runner.reliability.test.ts +++ b/src/agents/cli-runner.reliability.test.ts @@ -39,6 +39,7 @@ import { import { createTestUserTurnTranscriptTarget } from "../sessions/user-turn-transcript.test-support.js"; import { runSkillResearchAutoCapture } from "../skills/research/autocapture.js"; import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; +import { testing as cliBackendsTesting } from "./cli-backends.test-support.js"; import { restoreCliRunnerTestDeps, runPreparedCliAgent, @@ -375,6 +376,7 @@ describe("runCliAgent reliability", () => { sessionFileEnvSnapshot = undefined; resetClaudeLiveSessionsForTest(); resetDiagnosticEventsForTest(); + cliBackendsTesting.resetDepsForTest(); vi.useRealTimers(); }); @@ -4334,22 +4336,23 @@ describe("runCliAgent reliability", () => { })}\n`, "utf-8", ); - const config: OpenClawConfig = { - agents: { - defaults: { - workspace: dir, - cliBackends: { - "codex-cli": { - command: "codex", - args: ["exec"], - output: "text", - input: "arg", - sessionMode: "existing", - }, + const config: OpenClawConfig = { agents: { defaults: { workspace: dir } } }; + cliBackendsTesting.setDepsForTest({ + resolvePluginSetupCliBackend: () => undefined, + resolveRuntimeCliBackends: () => [ + { + id: "codex-cli", + pluginId: "test-codex", + config: { + command: "codex", + args: ["exec"], + output: "text", + input: "arg", + sessionMode: "existing", }, }, - }, - }; + ], + }); const hookRunner = { hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"), runBeforePromptBuild: vi.fn(async () => ({ prependContext: "hook context" })), diff --git a/src/agents/cli-runner/bundle-mcp.ts b/src/agents/cli-runner/bundle-mcp.ts index 257019008b65..0022ad15677a 100644 --- a/src/agents/cli-runner/bundle-mcp.ts +++ b/src/agents/cli-runner/bundle-mcp.ts @@ -6,7 +6,6 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { applyMergePatch } from "../../config/merge-patch.js"; -import type { CliBackendConfig } from "../../config/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { tryReadJson } from "../../infra/json-files.js"; @@ -16,6 +15,7 @@ import { OPENCLAW_TOOLS_MCP_TOOLS_ENV, } from "../../mcp/openclaw-tools-serve-config.js"; import { extractMcpServerMap, type BundleMcpConfig } from "../../plugins/bundle-mcp.js"; +import type { CliBackendConfig } from "../../plugins/cli-backend.types.js"; import type { CliBundleMcpMode } from "../../plugins/types.js"; import { loadMergedBundleMcpConfig, toCliBundleMcpServerConfig } from "../bundle-mcp-config.js"; import { resolveMcpBearerBundleConfig } from "../mcp-auth-profile.js"; diff --git a/src/agents/cli-runner/claude-live-session.test-support.ts b/src/agents/cli-runner/claude-live-session.test-support.ts index be0a5e48054b..63bae6d71f2e 100644 --- a/src/agents/cli-runner/claude-live-session.test-support.ts +++ b/src/agents/cli-runner/claude-live-session.test-support.ts @@ -1,4 +1,4 @@ -import type { CliBackendConfig } from "../../config/types.js"; +import type { CliBackendConfig } from "../../plugins/cli-backend.types.js"; import "./claude-live-session.js"; type BuildClaudeLiveArgsParams = { diff --git a/src/agents/cli-runner/claude-live-session.ts b/src/agents/cli-runner/claude-live-session.ts index ebe081e0304f..120303165cd5 100644 --- a/src/agents/cli-runner/claude-live-session.ts +++ b/src/agents/cli-runner/claude-live-session.ts @@ -4,7 +4,6 @@ import crypto from "node:crypto"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import type { ReplyBackendHandle } from "../../auto-reply/reply/reply-run-registry.js"; -import type { CliBackendConfig } from "../../config/types.js"; import { createAbortError as createNamedAbortError } from "../../infra/abort-signal.js"; import { emitTrustedDiagnosticEvent, @@ -24,6 +23,7 @@ import { type ExecSecurity, } from "../../infra/exec-approvals.js"; import { BLOCKED_TOOL_CALL_ABORT_FLOOR_MS } from "../../logging/diagnostic-run-activity.js"; +import type { CliBackendConfig } from "../../plugins/cli-backend.types.js"; import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { CLI_STREAM_JSON_DEFAULT_MAX_TURN_RAW_CHARS, diff --git a/src/agents/cli-runner/helpers.system-prompt-resume.test.ts b/src/agents/cli-runner/helpers.system-prompt-resume.test.ts index 8897e9637cf3..98107c9b6f97 100644 --- a/src/agents/cli-runner/helpers.system-prompt-resume.test.ts +++ b/src/agents/cli-runner/helpers.system-prompt-resume.test.ts @@ -28,7 +28,7 @@ * via buildClaudeLiveArgs) — covered here. */ import { describe, expect, it } from "vitest"; -import type { CliBackendConfig } from "../../config/types.js"; +import type { CliBackendConfig } from "../../plugins/cli-backend.types.js"; import { buildClaudeLiveArgs } from "./claude-live-session.test-support.js"; import { buildCliArgs, resolveSystemPromptUsage } from "./helpers.js"; diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index 4d0f58ab7e1c..164b77e3f426 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -17,7 +17,6 @@ import { isAcpRuntimeSpawnAvailable } from "../../acp/runtime/availability.js"; import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { ChatType } from "../../channels/chat-type.js"; -import type { CliBackendConfig } from "../../config/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { resolveRuntimeOsLabel } from "../../infra/os-summary.js"; import { privateFileStore } from "../../infra/private-file-store.js"; @@ -26,6 +25,7 @@ import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js" import type { ImageContent } from "../../llm/types.js"; import type { PromptImageOrderEntry } from "../../media/prompt-image-order.js"; import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js"; +import type { CliBackendConfig } from "../../plugins/cli-backend.types.js"; import { listRegisteredPluginAgentPromptGuidance } from "../../plugins/command-registry-state.js"; import type { BootstrapMode } from "../bootstrap-mode.js"; import type { EmbeddedContextFile } from "../embedded-agent-helpers.js"; diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index 5439f454e332..4e7584e89d46 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -153,36 +153,40 @@ async function createTestMcpLoopbackServer(port = 0) { }; } -function createCliBackendConfig( - params: { - bundleMcp?: boolean; - reseedFromRawTranscriptWhenUncompacted?: boolean; - systemPromptWhen?: "first" | "always" | "never"; - } = {}, -): OpenClawConfig { +type TestCliBackendParams = { + bundleMcp?: boolean; + reseedFromRawTranscriptWhenUncompacted?: boolean; + systemPromptWhen?: "first" | "always" | "never"; +}; + +function buildDefaultTestCliBackend( + params: TestCliBackendParams = {}, +): CliBackendPlugin & { pluginId: string } { return { - agents: { - defaults: { - cliBackends: { - "test-cli": { - command: "test-cli", - args: ["--print"], - systemPromptArg: "--system-prompt", - systemPromptWhen: params.systemPromptWhen ?? "first", - sessionMode: "existing", - output: "text", - input: "arg", - ...(params.reseedFromRawTranscriptWhenUncompacted - ? { reseedFromRawTranscriptWhenUncompacted: true } - : {}), - ...(params.bundleMcp - ? { bundleMcp: true, bundleMcpMode: "claude-config-file" as const } - : {}), - }, - }, - }, + id: "test-cli", + pluginId: "test-cli-plugin", + bundleMcp: params.bundleMcp === true, + ...(params.bundleMcp ? { bundleMcpMode: "claude-config-file" as const } : {}), + config: { + command: "test-cli", + args: ["--print"], + systemPromptArg: "--system-prompt", + systemPromptWhen: params.systemPromptWhen ?? "first", + sessionMode: "existing", + output: "text", + input: "arg", + ...(params.reseedFromRawTranscriptWhenUncompacted + ? { reseedFromRawTranscriptWhenUncompacted: true } + : {}), }, - } satisfies OpenClawConfig; + }; +} + +let defaultTestCliBackend = buildDefaultTestCliBackend(); + +function createCliBackendConfig(params: TestCliBackendParams = {}): OpenClawConfig { + defaultTestCliBackend = buildDefaultTestCliBackend(params); + return {}; } function setCliBackendForPrepareTest( @@ -418,9 +422,10 @@ describe("prepareCliRunContext", () => { beforeEach(() => { // Install narrow test doubles for external runtime seams so preparation // remains about data flow, not bundled plugin or loopback startup cost. + defaultTestCliBackend = buildDefaultTestCliBackend(); cliBackendsTesting.setDepsForTest({ resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [], + resolveRuntimeCliBackends: () => [defaultTestCliBackend], }); setCliRunnerPrepareTestDeps({ isWorkspaceBootstrapPending: vi.fn(async () => false), @@ -2627,7 +2632,7 @@ describe("prepareCliRunContext", () => { sourceReplyDeliveryMode: "message_tool_only", currentMessageId: "msg-1", cliSessionBindingFacts, - config: createCliBackendConfig({ bundleMcp: true }), + config: createCliBackendConfig(), }); const second = await prepareCliRunContext({ sessionId: "session-test", @@ -2650,7 +2655,7 @@ describe("prepareCliRunContext", () => { promptToolNamesHash: first.promptToolNamesHash, cwdHash: hashCliSessionText(dir), }, - config: createCliBackendConfig({ bundleMcp: true }), + config: createCliBackendConfig(), }); expect(first.extraSystemPromptHash).toBe(hashCliSessionText(staticPrompt)); diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 084ae7080b44..ecc8661b517b 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -5,7 +5,6 @@ import { ensureSystemPromptCacheBoundary } from "@openclaw/ai/internal/shared"; */ import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import { getRuntimeConfig } from "../../config/config.js"; -import type { CliBackendConfig } from "../../config/types.agent-defaults.js"; import { assertContextEngineHostSupport, buildGenericCliContextEngineHostSupport, @@ -25,6 +24,7 @@ import { } from "../../gateway/mcp-http.loopback-runtime.js"; import { resolveMcpLoopbackScopedTools } from "../../gateway/mcp-http.runtime.js"; import { buildSystemAgentToolsMcpServerConfig } from "../../mcp/openclaw-tools-serve-config.js"; +import type { CliBackendConfig } from "../../plugins/cli-backend.types.js"; import type { CliBackendAuthEpochMode, CliBackendPreparedExecution, diff --git a/src/agents/cli-runner/reliability.ts b/src/agents/cli-runner/reliability.ts index b502305296f8..d678cb3893d3 100644 --- a/src/agents/cli-runner/reliability.ts +++ b/src/agents/cli-runner/reliability.ts @@ -3,8 +3,8 @@ */ import path from "node:path"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import type { CliBackendConfig } from "../../config/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { CliBackendConfig } from "../../plugins/cli-backend.types.js"; import { CLI_FRESH_WATCHDOG_DEFAULTS, CLI_RESUME_WATCHDOG_DEFAULTS, diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index 2b1c79e149cf..d0521a4cb509 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -12,11 +12,11 @@ import type { FastMode } from "../../auto-reply/thinking.shared.js"; import type { InboundEventKind } from "../../channels/inbound-event/kind.js"; import type { CliSessionBinding, SessionEntry } from "../../config/sessions.js"; import type { SessionSystemPromptReport } from "../../config/sessions/types.js"; -import type { CliBackendConfig } from "../../config/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { ContextEngine } from "../../context-engine/types.js"; import type { ImageContent } from "../../llm/types.js"; import type { PromptImageOrderEntry } from "../../media/prompt-image-order.js"; +import type { CliBackendConfig } from "../../plugins/cli-backend.types.js"; import type { CliBackendExecutionMode } from "../../plugins/cli-backend.types.js"; import type { PluginHookChannelContext } from "../../plugins/hook-types.js"; import type { SpawnSecretInput } from "../../process/supervisor/types.js"; diff --git a/src/agents/command/attempt-execution.cli.test.ts b/src/agents/command/attempt-execution.cli.test.ts index 813324291468..5d256b4cf18c 100644 --- a/src/agents/command/attempt-execution.cli.test.ts +++ b/src/agents/command/attempt-execution.cli.test.ts @@ -78,15 +78,12 @@ vi.mock("../cli-runner/claude-live-session.js", () => ({ })); vi.mock("../model-selection.js", () => ({ - isCliProvider: (provider: string, cfg?: OpenClawConfig) => { + isCliProvider: (provider: string, _cfg?: OpenClawConfig) => { const normalized = provider.trim().toLowerCase(); return ( normalized === "claude-cli" || normalized === "codex-cli" || - normalized === "google-gemini-cli" || - Object.keys(cfg?.agents?.defaults?.cliBackends ?? {}).some( - (candidate) => candidate.trim().toLowerCase() === normalized, - ) + normalized === "google-gemini-cli" ); }, normalizeProviderId: (provider: string) => provider.trim().toLowerCase(), @@ -3075,7 +3072,6 @@ describe("embedded attempt harness pinning", () => { cfg: { agents: { defaults: { - cliBackends: { codex: { command: "codex" } }, models: { "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, }, @@ -3171,13 +3167,7 @@ describe("embedded attempt harness pinning", () => { providerOverride: "openai", originalProvider: "openai", modelOverride: "gpt-5.4", - cfg: { - agents: { - defaults: { - cliBackends: { "claude-cli": { command: "claude" } }, - }, - }, - } as OpenClawConfig, + cfg: {} as OpenClawConfig, sessionEntry, sessionId: sessionEntry.sessionId, sessionKey: "agent:main:main", diff --git a/src/agents/command/session-store.test.ts b/src/agents/command/session-store.test.ts index 986882a4bc24..6ea80b38f0a1 100644 --- a/src/agents/command/session-store.test.ts +++ b/src/agents/command/session-store.test.ts @@ -24,8 +24,8 @@ import { import { resolveSession } from "./session.js"; vi.mock("../model-selection.js", () => ({ - isCliProvider: (provider: string, cfg?: OpenClawConfig) => - Object.hasOwn(cfg?.agents?.defaults?.cliBackends ?? {}, provider), + isCliProvider: (provider: string, _cfg?: OpenClawConfig) => + ["claude-cli", "codex-cli", "google-gemini-cli"].includes(provider.trim().toLowerCase()), normalizeProviderId: (provider: string) => provider.trim().toLowerCase(), })); @@ -497,11 +497,7 @@ describe("updateSessionStoreAfterAgentRun", () => { await withTempSessionStore(async ({ storePath }) => { const cfg = { agents: { - defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, - }, + defaults: {}, }, } as unknown as OpenClawConfig; const sessionKey = "agent:main:explicit:test-claude-cli-configured-context"; @@ -546,13 +542,7 @@ describe("updateSessionStoreAfterAgentRun", () => { await withTempSessionStore(async ({ storePath }) => { const cfg = { agents: { - defaults: { - cliBackends: { - "claude-cli": { - command: "claude", - }, - }, - }, + defaults: {}, }, } as OpenClawConfig; const sessionKey = "agent:main:explicit:test-harness-pin-cli"; @@ -598,13 +588,7 @@ describe("updateSessionStoreAfterAgentRun", () => { await withTempSessionStore(async ({ storePath }) => { const cfg = { agents: { - defaults: { - cliBackends: { - "claude-cli": { - command: "claude", - }, - }, - }, + defaults: {}, }, } as OpenClawConfig; const sessionKey = "agent:main:explicit:test-claude-cli"; @@ -664,13 +648,7 @@ describe("updateSessionStoreAfterAgentRun", () => { await withTempSessionStore(async ({ storePath }) => { const cfg = { agents: { - defaults: { - cliBackends: { - "claude-cli": { - command: "claude", - }, - }, - }, + defaults: {}, }, } as OpenClawConfig; const sessionKey = "agent:main:explicit:test-clear-unflushed-cli"; @@ -911,11 +889,7 @@ describe("updateSessionStoreAfterAgentRun", () => { mainKey: "main", }, agents: { - defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, - }, + defaults: {}, }, } as never; @@ -1189,11 +1163,7 @@ describe("updateSessionStoreAfterAgentRun", () => { await withTempSessionStore(async ({ storePath }) => { const cfg = { agents: { - defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, - }, + defaults: {}, }, } as OpenClawConfig; const sessionKey = "agent:main:explicit:test-cli-cumulative-usage"; @@ -1355,11 +1325,7 @@ describe("updateSessionStoreAfterAgentRun", () => { await withTempSessionStore(async ({ storePath }) => { const cfg = { agents: { - defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, - }, + defaults: {}, }, } as OpenClawConfig; const sessionKey = "agent:main:explicit:test-cli-last-call-usage"; @@ -2218,11 +2184,7 @@ describe("updateSessionStoreAfterAgentRun", () => { await withTempSessionStore(async ({ storePath }) => { const cfg = { agents: { - defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, - }, + defaults: {}, }, } as OpenClawConfig; const sessionKey = "agent:main:explicit:test-preserve-user-facing-run-state"; diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts index 66b2cf417a29..3a2a287bdbf2 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts @@ -666,9 +666,6 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { }, agents: { defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, models: { "anthropic/test-model": { agentRuntime: { id: "claude-cli" } }, }, diff --git a/src/agents/harness/selection.test.ts b/src/agents/harness/selection.test.ts index 2523f72bc186..32a18349c131 100644 --- a/src/agents/harness/selection.test.ts +++ b/src/agents/harness/selection.test.ts @@ -2638,27 +2638,6 @@ describe("selectAgentHarness", () => { }, ); - it("still throws MissingAgentHarnessError for an explicit configured cliBackends id", () => { - const config = { - agents: { - defaults: { - cliBackends: { - "my-custom-cli": { command: "echo" }, - }, - }, - }, - } as OpenClawConfig; - - expect(() => - selectAgentHarness({ - provider: "anthropic", - modelId: "sonnet-4.6", - agentHarnessRuntimeOverride: "my-custom-cli", - config, - }), - ).toThrow('Requested agent harness "my-custom-cli" is not registered'); - }); - it("still throws MissingAgentHarnessError for an explicit non-CLI unknown runtime", () => { expect(() => selectAgentHarness({ diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 77d47056d7f0..e64ea089c098 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -19,6 +19,7 @@ import { import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; +import { testing as cliBackendsTesting } from "./cli-backends.test-support.js"; import { classifyEmbeddedAgentRunResultForModelFallback } from "./embedded-agent-runner/result-fallback-classifier.js"; import { abortable } from "./embedded-agent-runner/run/abortable.js"; import type { EmbeddedAgentRunResult } from "./embedded-agent-runner/types.js"; @@ -239,7 +240,10 @@ function setDefaultPluginMetadataSnapshot(): void { }); } -afterEach(resetModelFallbackTestState); +afterEach(() => { + resetModelFallbackTestState(); + cliBackendsTesting.resetDepsForTest(); +}); beforeEach(() => { setLoggerOverride({ level: "silent", consoleLevel: "silent" }); @@ -1351,10 +1355,15 @@ describe("runWithModelFallback", () => { }); it("prefers a prepared harness over a colliding CLI runtime id", async () => { + cliBackendsTesting.setDepsForTest({ + resolvePluginSetupCliBackend: () => undefined, + resolveRuntimeCliBackends: () => [ + { id: "codex", pluginId: "test-codex-cli", config: { command: "codex" } }, + ], + }); const cfg = makeCfg({ agents: { defaults: { - cliBackends: { codex: { command: "codex" } }, model: { primary: "anthropic/claude-sonnet-4-6" }, }, }, @@ -1444,9 +1453,6 @@ describe("runWithModelFallback", () => { const cfg = makeCfg({ agents: { defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, model: { primary: "claude-cli/opus", }, diff --git a/src/agents/model-runtime-aliases.test.ts b/src/agents/model-runtime-aliases.test.ts index 965c89c8fa70..7a08481d445b 100644 --- a/src/agents/model-runtime-aliases.test.ts +++ b/src/agents/model-runtime-aliases.test.ts @@ -252,15 +252,7 @@ describe("areRuntimeModelRefsEquivalent", () => { expect( areRuntimeModelRefsEquivalent("anthropic/claude-opus-4-7", "claude-cli/claude-opus-4-7", { - config: { - agents: { - defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, - }, - }, - }, + config: {}, }), ).toBe(true); }); diff --git a/src/agents/model-selection-cli.ts b/src/agents/model-selection-cli.ts index ad897d843aa6..51ae43b1a75c 100644 --- a/src/agents/model-selection-cli.ts +++ b/src/agents/model-selection-cli.ts @@ -9,10 +9,6 @@ import { normalizeProviderId } from "./model-selection-normalize.js"; /** Return true when a provider id resolves to a configured or plugin CLI backend. */ export function isCliProvider(provider: string, cfg?: OpenClawConfig): boolean { const normalized = normalizeProviderId(provider); - const backends = cfg?.agents?.defaults?.cliBackends ?? {}; - if (Object.keys(backends).some((key) => normalizeProviderId(key) === normalized)) { - return true; - } const cliBackends = resolveRuntimeCliBackends(); if (cliBackends.some((backend) => normalizeProviderId(backend.id) === normalized)) { return true; diff --git a/src/auto-reply/reply/agent-runner-execution-runtime.test.ts b/src/auto-reply/reply/agent-runner-execution-runtime.test.ts index 231b26e57a91..330fdb03c220 100644 --- a/src/auto-reply/reply/agent-runner-execution-runtime.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-runtime.test.ts @@ -236,15 +236,7 @@ describe("runAgentTurnWithFallback: runtime selection", () => { const followupRun = createFollowupRun(); followupRun.run.provider = "openai"; followupRun.run.model = "gpt-5.4"; - followupRun.run.config = { - agents: { - defaults: { - cliBackends: { - codex: { command: "codex" }, - }, - }, - }, - }; + followupRun.run.config = {}; const result = await runAgentTurnWithFallback({ ...createMinimalRunAgentTurnParams({ followupRun }), diff --git a/src/auto-reply/reply/agent-runner-memory.test.ts b/src/auto-reply/reply/agent-runner-memory.test.ts index 39df150c997a..698469ee63f3 100644 --- a/src/auto-reply/reply/agent-runner-memory.test.ts +++ b/src/auto-reply/reply/agent-runner-memory.test.ts @@ -20,6 +20,8 @@ import { registerMemoryCapability, type MemoryFlushPlanResolver, } from "../../plugins/memory-state.test-fixtures.js"; +import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; import type { TemplateContext } from "../templating.js"; import type { ReplyPayload } from "../types.js"; import { runMemoryFlushIfNeeded, runPreflightCompactionIfNeeded } from "./agent-runner-memory.js"; @@ -335,6 +337,7 @@ describe("runMemoryFlushIfNeeded", () => { afterEach(async () => { setAgentRunnerMemoryTestDeps(); cliBackendsTesting.resetDepsForTest(); + setActivePluginRegistry(createEmptyPluginRegistry()); clearMemoryPluginState(); await fs.rm(rootDir, { recursive: true, force: true }); }); @@ -499,9 +502,6 @@ describe("runMemoryFlushIfNeeded", () => { cfg: { agents: { defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, compaction: { memoryFlush: {} }, models: { "anthropic/claude-opus-4-6": { agentRuntime: { id: "claude-cli" } }, @@ -1157,6 +1157,13 @@ describe("runMemoryFlushIfNeeded", () => { }); it("skips memory flush for CLI providers", async () => { + const registry = createEmptyPluginRegistry(); + registry.cliBackends.push({ + pluginId: "test-codex-cli", + source: "test", + backend: { id: "codex-cli", config: { command: "codex" } }, + }); + setActivePluginRegistry(registry); const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now(), @@ -1165,7 +1172,7 @@ describe("runMemoryFlushIfNeeded", () => { }; const result = await runMemoryFlushIfNeeded({ - cfg: { agents: { defaults: { cliBackends: { "codex-cli": { command: "codex" } } } } }, + cfg: {}, followupRun: createTestFollowupRun({ provider: "codex-cli" }), sessionCtx: { Provider: "whatsapp" } as unknown as TemplateContext, defaultModel: "codex-cli/gpt-5.5", diff --git a/src/auto-reply/reply/agent-runner-runtime-selection.test.ts b/src/auto-reply/reply/agent-runner-runtime-selection.test.ts index 85309442b035..cc328f346499 100644 --- a/src/auto-reply/reply/agent-runner-runtime-selection.test.ts +++ b/src/auto-reply/reply/agent-runner-runtime-selection.test.ts @@ -63,8 +63,8 @@ describe("resolveSessionRuntimeOverrideForProvider", () => { it("keeps CLI runtime pins only when the runtime serves the selected provider", () => { cliBackendsTesting.setDepsForTest({ resolveRuntimeCliBackends: () => [], - resolvePluginSetupCliBackend: ({ backend, config }) => - backend === "claude-cli" && config + resolvePluginSetupCliBackend: ({ backend }) => + backend === "claude-cli" ? { pluginId: "anthropic", backend: { @@ -76,15 +76,7 @@ describe("resolveSessionRuntimeOverrideForProvider", () => { } : undefined, }); - const cfg = { - agents: { - defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, - }, - }, - }; + const cfg = {}; expect( resolveSessionRuntimeOverrideForProvider({ diff --git a/src/auto-reply/reply/agent-runner.media-paths.test.ts b/src/auto-reply/reply/agent-runner.media-paths.test.ts index d7b3f902c365..a839fe8ce447 100644 --- a/src/auto-reply/reply/agent-runner.media-paths.test.ts +++ b/src/auto-reply/reply/agent-runner.media-paths.test.ts @@ -58,13 +58,12 @@ vi.mock("../../agents/model-selection.js", async () => { ); return { ...actual, - isCliProvider: (provider: string, cfg?: OpenClawConfig) => { + isCliProvider: (provider: string, _cfg?: OpenClawConfig) => { const normalized = provider.trim().toLowerCase(); return ( normalized === "claude-cli" || normalized === "google-gemini-cli" || - normalized === "codex-cli" || - Boolean(cfg?.agents?.defaults?.cliBackends?.[normalized]) + normalized === "codex-cli" ); }, }; diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts index 00fb5d24fab7..783898ff1b35 100644 --- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts @@ -37,16 +37,7 @@ import { testing as replyRunRegistryTesting } from "./reply-run-registry.test-su import { createMockTypingController } from "./test-helpers.js"; function createCliBackendTestConfig() { - return { - agents: { - defaults: { - cliBackends: { - "claude-cli": {}, - "google-gemini-cli": {}, - }, - }, - }, - }; + return {}; } function registerCliBackendsForTest(): void { @@ -137,13 +128,12 @@ vi.mock("../../agents/model-selection.js", async () => { ); return { ...actual, - isCliProvider: (provider: string, cfg?: OpenClawConfig) => { + isCliProvider: (provider: string, _cfg?: OpenClawConfig) => { const normalized = provider.trim().toLowerCase(); return ( normalized === "claude-cli" || normalized === "google-gemini-cli" || - normalized === "codex-cli" || - Boolean(cfg?.agents?.defaults?.cliBackends?.[normalized]) + normalized === "codex-cli" ); }, }; @@ -2127,7 +2117,7 @@ describe("runReplyAgent claude-cli routing", () => { messageProvider: "webchat", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp", - config: { agents: { defaults: { cliBackends: { "claude-cli": {} } } } }, + config: {}, skillsSnapshot: {}, provider: "claude-cli", model: "opus-4.5", diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index 70912e81b047..a412f97095cb 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -4477,15 +4477,7 @@ describe("runReplyAgent typing (heartbeat)", () => { runOverrides: { provider: "anthropic", model: "claude-opus-4-7", - config: { - agents: { - defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, - }, - }, - }, + config: {}, }, }); await run(); diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index c765a82f7f87..af06ebb05782 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -546,6 +546,12 @@ function setFastFollowupCliBackendDeps(): void { config: { command: "claude" }, bundleMcp: false, }; + const codexBackend = { + id: "codex", + pluginId: "test-codex-cli", + config: { command: "codex" }, + bundleMcp: false, + }; cliBackendsTestingForTest.setDepsForTest({ resolvePluginSetupCliBackend: ({ backend }) => backend === "claude-cli" @@ -561,7 +567,7 @@ function setFastFollowupCliBackendDeps(): void { autoEnableProbes: [], diagnostics: [], }), - resolveRuntimeCliBackends: () => [claudeBackend], + resolveRuntimeCliBackends: () => [claudeBackend, codexBackend], }); } @@ -1469,10 +1475,6 @@ describe("createFollowupRunner runtime config", () => { const runtimeConfig: OpenClawConfig = { agents: { defaults: { - cliBackends: { - codex: { command: "codex" }, - "claude-cli": { command: "claude" }, - }, models: { "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, }, @@ -1530,9 +1532,6 @@ describe("createFollowupRunner runtime config", () => { const runtimeConfig: OpenClawConfig = { agents: { defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, models: { "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, }, @@ -1648,9 +1647,6 @@ describe("createFollowupRunner runtime config", () => { const runtimeConfig: OpenClawConfig = { agents: { defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, models: { "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, }, @@ -1712,9 +1708,6 @@ describe("createFollowupRunner runtime config", () => { const runtimeConfig: OpenClawConfig = { agents: { defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, models: { "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, }, @@ -1788,9 +1781,6 @@ describe("createFollowupRunner runtime config", () => { const runtimeConfig: OpenClawConfig = { agents: { defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, models: { "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, }, @@ -1859,9 +1849,6 @@ describe("createFollowupRunner runtime config", () => { const runtimeConfig: OpenClawConfig = { agents: { defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, models: { "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, }, @@ -1929,9 +1916,6 @@ describe("createFollowupRunner runtime config", () => { const runtimeConfig: OpenClawConfig = { agents: { defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, models: { "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, }, @@ -1980,9 +1964,6 @@ describe("createFollowupRunner runtime config", () => { const runtimeConfig: OpenClawConfig = { agents: { defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, models: { "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, }, @@ -2030,9 +2011,6 @@ describe("createFollowupRunner runtime config", () => { const runtimeConfig: OpenClawConfig = { agents: { defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, models: { "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, }, @@ -2093,9 +2071,6 @@ describe("createFollowupRunner runtime config", () => { const runtimeConfig: OpenClawConfig = { agents: { defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, models: { "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, }, @@ -2148,9 +2123,6 @@ describe("createFollowupRunner runtime config", () => { const runtimeConfig: OpenClawConfig = { agents: { defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, models: { "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, }, @@ -2206,9 +2178,6 @@ describe("createFollowupRunner runtime config", () => { const runtimeConfig: OpenClawConfig = { agents: { defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, models: { "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, }, @@ -2281,7 +2250,6 @@ describe("createFollowupRunner runtime config", () => { const runtimeConfig: OpenClawConfig = { agents: { defaults: { - cliBackends: { "claude-cli": { command: "claude" } }, models: { "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, }, @@ -2368,9 +2336,6 @@ describe("createFollowupRunner runtime config", () => { const runtimeConfig: OpenClawConfig = { agents: { defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, models: { "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, }, @@ -2456,9 +2421,6 @@ describe("createFollowupRunner runtime config", () => { const runtimeConfig: OpenClawConfig = { agents: { defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, models: { "openai/gpt-5.6-sol": { agentRuntime: { id: "openclaw" } }, "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, @@ -2724,9 +2686,6 @@ describe("createFollowupRunner runtime config", () => { const runtimeConfig: OpenClawConfig = { agents: { defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, models: { "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, }, @@ -3416,9 +3375,6 @@ describe("createFollowupRunner progress forwarding", () => { const runtimeConfig: OpenClawConfig = { agents: { defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, models: { "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, }, @@ -3491,9 +3447,6 @@ describe("createFollowupRunner progress forwarding", () => { const runtimeConfig: OpenClawConfig = { agents: { defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, models: { "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, }, @@ -5308,15 +5261,7 @@ describe("createFollowupRunner messaging delivery and dedupe", () => { createQueuedRun({ run: { provider: "openai", - config: { - agents: { - defaults: { - cliBackends: { - anthropic: { command: "anthropic" }, - }, - }, - }, - } as OpenClawConfig, + config: {} as OpenClawConfig, }, }), ), diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 0b903b6c8c96..763d3c8ccb5b 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -421,11 +421,7 @@ describe("buildStatusMessage", () => { const text = buildStatusMessage({ config: { agents: { - defaults: { - cliBackends: { - "claude-cli": {}, - }, - }, + defaults: {}, }, } as unknown as OpenClawConfig, agent: { @@ -448,11 +444,7 @@ describe("buildStatusMessage", () => { const text = buildStatusMessage({ config: { agents: { - defaults: { - cliBackends: { - "claude-cli": {}, - }, - }, + defaults: {}, }, } as unknown as OpenClawConfig, agent: { diff --git a/src/cli/config-model-validation.test.ts b/src/cli/config-model-validation.test.ts index 8f0a71ba16f6..6dea4a6c8fd9 100644 --- a/src/cli/config-model-validation.test.ts +++ b/src/cli/config-model-validation.test.ts @@ -346,54 +346,56 @@ describe("config model validation", () => { }); }); - it("accepts a configured CLI backend model without an embedded catalog row", async () => { - const result = await checkTouchedTextModelRefs({ - config: { - agents: { - defaults: { - model: { primary: "acme-cli/foo" }, - cliBackends: { "acme-cli": { command: "acme" } }, - }, - }, - }, - touchedPaths: [["agents", "defaults", "model", "primary"]], - }); - - expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] }); - }); - - it("infers a configured provider for a bare primary model", async () => { + it("passes a configured bare primary model to runtime resolution", async () => { + const resolveModelRef = vi.fn(async () => undefined); const result = await checkTouchedTextModelRefs({ config: { agents: { defaults: { model: { primary: "foo" }, models: { "acme-cli/foo": {} }, - cliBackends: { "acme-cli": { command: "acme" } }, }, }, }, touchedPaths: [["agents", "defaults", "model", "primary"]], + resolveModelRef, }); expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] }); + expect(resolveModelRef).toHaveBeenCalledWith({ + config: expect.any(Object), + ref: { + path: "agents.defaults.model.primary", + value: "foo", + fallback: false, + }, + }); }); it("keeps an explicit qualified primary ahead of a same-named bare alias", async () => { + const resolveModelRef = vi.fn(async () => undefined); const result = await checkTouchedTextModelRefs({ config: { agents: { defaults: { model: { primary: "acme-cli/foo" }, models: { bar: { alias: "acme-cli/foo" } }, - cliBackends: { "acme-cli": { command: "acme" } }, }, }, }, touchedPaths: [["agents", "defaults", "model", "primary"]], + resolveModelRef, }); expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] }); + expect(resolveModelRef).toHaveBeenCalledWith({ + config: expect.any(Object), + ref: { + path: "agents.defaults.model.primary", + value: "acme-cli/foo", + fallback: false, + }, + }); }); it("reports resolver setup failures without claiming refs were checked", async () => { diff --git a/src/commands/doctor-claude-cli.test.ts b/src/commands/doctor-claude-cli.test.ts index 05a7b6981d16..6f3f7edf65f4 100644 --- a/src/commands/doctor-claude-cli.test.ts +++ b/src/commands/doctor-claude-cli.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { CLAUDE_CLI_PROFILE_ID } from "../agents/auth-profiles/constants.js"; import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; +import { testing as cliBackendsTesting } from "../agents/cli-backends.test-support.js"; import { resolveClaudeCliProjectDirForWorkspace } from "../agents/command/claude-cli-project-dir.js"; import { noteClaudeCliHealth } from "./doctor-claude-cli.js"; @@ -69,9 +70,40 @@ describe("resolveClaudeCliProjectDirForWorkspace", () => { describe("noteClaudeCliHealth", () => { afterEach(() => { + cliBackendsTesting.resetDepsForTest(); vi.restoreAllMocks(); }); + it("probes the executable registered by the owning backend plugin", async () => { + await withTempHome(({ homeDir, workspaceDir }) => { + cliBackendsTesting.setDepsForTest({ + resolvePluginSetupCliBackend: () => undefined, + resolveRuntimeCliBackends: () => [ + { + id: "claude-cli", + pluginId: "custom-anthropic", + config: { command: "/opt/custom/bin/claude" }, + }, + ], + }); + const resolveCommandPath = vi.fn(() => undefined); + + noteClaudeCliHealth( + { agents: { defaults: { model: "claude-cli/claude-sonnet-4-6" } } }, + { + homeDir, + workspaceDir, + noteFn: vi.fn(), + store: createStore(), + readClaudeCliCredentials: () => null, + resolveCommandPath, + }, + ); + + expect(resolveCommandPath).toHaveBeenCalledWith("/opt/custom/bin/claude", expect.any(Object)); + }); + }); + it("stays quiet when Claude CLI is not configured or detected", () => { const noteFn = vi.fn(); noteClaudeCliHealth( diff --git a/src/commands/doctor-claude-cli.ts b/src/commands/doctor-claude-cli.ts index 6d649b41855f..4415914990f0 100644 --- a/src/commands/doctor-claude-cli.ts +++ b/src/commands/doctor-claude-cli.ts @@ -2,7 +2,6 @@ import fs from "node:fs"; import { normalizeOptionalLowercaseString, - normalizeOptionalString, resolvePrimaryStringValue, } from "@openclaw/normalization-core/string-coerce"; import { note } from "../../packages/terminal-core/src/note.js"; @@ -20,6 +19,7 @@ import type { OAuthCredential, TokenCredential, } from "../agents/auth-profiles/types.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"; import { formatCliCommand } from "../cli/command-format.js"; @@ -49,17 +49,7 @@ function usesClaudeCliModelSelection(cfg: OpenClawConfig): boolean { } function resolveClaudeCliCommand(cfg: OpenClawConfig): string { - const configured = cfg.agents?.defaults?.cliBackends ?? {}; - for (const [key, entry] of Object.entries(configured)) { - if (normalizeOptionalLowercaseString(key) !== CLAUDE_CLI_PROVIDER) { - continue; - } - const command = normalizeOptionalString(entry?.command); - if (command) { - return command; - } - } - return "claude"; + return resolveCliBackendConfig(CLAUDE_CLI_PROVIDER, cfg)?.config.command ?? "claude"; } function probeDirectoryHealth(dirPath: string): ClaudeCliDirHealth { @@ -233,7 +223,7 @@ export function noteClaudeCliHealth( if (!commandPath) { lines.push(`- Binary: command "${command}" was not found on PATH.`); fixHints.push( - "- Fix: install Claude CLI or set agents.defaults.cliBackends.claude-cli.command to the real binary path.", + "- Fix: install Claude CLI on PATH for the gateway user; custom executable paths belong in a CLI backend plugin registration.", ); } diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index 39f1f60c7d47..c4a7d3c6ae56 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -863,13 +863,12 @@ describe("normalizeCompatibilityConfigValues", () => { ]); }); - it("preserves configured CLI backends and agent-local models.json providers", () => { + it("preserves plugin-owned CLI providers and agent-local models.json providers", () => { const result = repairStaleAgentModelRefs( { agents: { defaults: { model: "my-cli/model", - cliBackends: { "my-cli": { command: "my-cli" } }, }, list: [ { id: "worker", model: "agent-local/model" }, @@ -878,7 +877,7 @@ describe("normalizeCompatibilityConfigValues", () => { }, } as OpenClawConfig, { - pluginProviderIds: new Set(["anthropic"]), + pluginProviderIds: new Set(["anthropic", "my-cli"]), persistedProviderIdsByAgentId: new Map([["worker", new Set(["agent-local"])]]), }, ); diff --git a/src/commands/doctor/shared/deprecation-compat.test.ts b/src/commands/doctor/shared/deprecation-compat.test.ts index 27068d69b86c..fe58b20584e4 100644 --- a/src/commands/doctor/shared/deprecation-compat.test.ts +++ b/src/commands/doctor/shared/deprecation-compat.test.ts @@ -16,6 +16,7 @@ const requiredDoctorCompatCodes = [ "doctor-plugin-install-config-ledger", "doctor-bundled-plugin-load-paths", "doctor-bundled-provider-discovery-allowlist", + "doctor-cli-backends-plugin-registration", "doctor-codex-supervisor-plugin-config", "doctor-message-queue-steering-modes", "doctor-web-search-plugin-config", diff --git a/src/commands/doctor/shared/deprecation-compat.ts b/src/commands/doctor/shared/deprecation-compat.ts index 149d163783f2..e900ad4fff11 100644 --- a/src/commands/doctor/shared/deprecation-compat.ts +++ b/src/commands/doctor/shared/deprecation-compat.ts @@ -62,6 +62,22 @@ function deprecatedCompatRecord( // doctor fixes, and replacement notes should be revalidated against the current // architecture because ownership and config footprint can shift during rollout. const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ + deprecatedCompatRecord({ + code: "doctor-cli-backends-plugin-registration", + deprecated: "2026-07-21", + warningStarts: "2026-07-21", + removeAfter: "2026-09-22", + owner: "agent-runtime", + introduced: "2026-07-21", + source: "agents.defaults.cliBackends adapter DSL", + migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.cli-backends.ts", + replacement: "registerCliBackend plugin registrations and model-scoped agentRuntime.id", + docsPath: "/plugins/cli-backend-plugins", + tests: [ + "src/commands/doctor/shared/legacy-config-migrations.runtime.cli-backends.test.ts", + "src/config/dead-config-keys.test.ts", + ], + }), deprecatedCompatRecord({ code: "doctor-tier-eval-tranche", deprecated: "2026-07-20", diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.cli-backends.test.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.cli-backends.test.ts new file mode 100644 index 000000000000..bb8d9bc883ea --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.cli-backends.test.ts @@ -0,0 +1,44 @@ +// CLI backend legacy config migration tests cover adapter DSL retirement. +import { describe, expect, it } from "vitest"; +import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_CLI_BACKENDS } from "./legacy-config-migrations.runtime.cli-backends.js"; + +const migration = LEGACY_CONFIG_MIGRATIONS_RUNTIME_CLI_BACKENDS[0]; + +describe("CLI backend config migration", () => { + it("strips the complete adapter map and points users to the plugin recipe", () => { + const raw: Record = { + agents: { + defaults: { + model: "anthropic/claude-sonnet-4-6", + cliBackends: { + "claude-cli": { + command: "/opt/claude", + args: ["-p", "--output-format", "stream-json"], + env: { CLAUDE_CONFIG_DIR: "/srv/claude" }, + }, + }, + }, + }, + }; + const changes: string[] = []; + + migration?.apply(raw, changes); + + expect(raw).toEqual({ + agents: { defaults: { model: "anthropic/claude-sonnet-4-6" } }, + }); + expect(changes).toEqual([ + "Removed agents.defaults.cliBackends; CLI backend adapters now register through plugins (https://docs.openclaw.ai/plugins/cli-backend-plugins).", + ]); + }); + + it("leaves config without the retired key unchanged", () => { + const raw: Record = { agents: { defaults: { model: "openai/gpt-5.6" } } }; + const changes: string[] = []; + + migration?.apply(raw, changes); + + expect(raw).toEqual({ agents: { defaults: { model: "openai/gpt-5.6" } } }); + expect(changes).toEqual([]); + }); +}); diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.cli-backends.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.cli-backends.ts new file mode 100644 index 000000000000..58693b2953c7 --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.cli-backends.ts @@ -0,0 +1,33 @@ +// Doctor-only migration for the retired CLI backend adapter config DSL. +import { + defineLegacyConfigMigration, + getRecord, + type LegacyConfigMigrationSpec, +} from "../../../config/legacy.shared.js"; + +const CLI_BACKENDS_PLUGIN_GUIDE = "https://docs.openclaw.ai/plugins/cli-backend-plugins"; + +export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_CLI_BACKENDS: LegacyConfigMigrationSpec[] = [ + defineLegacyConfigMigration({ + id: "agents.defaults.cliBackends-plugin-registration", + describe: "Remove CLI backend adapter config now owned by plugins", + legacyRules: [ + { + path: ["agents", "defaults", "cliBackends"], + message: `CLI backend adapters now register through plugins; see ${CLI_BACKENDS_PLUGIN_GUIDE}`, + }, + ], + apply: (raw, changes) => { + const defaults = getRecord(getRecord(raw.agents)?.defaults); + if (!defaults || !Object.hasOwn(defaults, "cliBackends")) { + return; + } + // Adapter data is intentionally retired, not interpreted at runtime. + // Arbitrary launch policy cannot be safely synthesized into executable plugin code. + delete defaults.cliBackends; + changes.push( + `Removed agents.defaults.cliBackends; CLI backend adapters now register through plugins (${CLI_BACKENDS_PLUGIN_GUIDE}).`, + ); + }, + }), +]; diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.ts index d2d0a840b6f6..45def6e6f22f 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.ts @@ -1,6 +1,7 @@ // Aggregated runtime legacy config migration specs across agents, gateway, models, and tools. import type { LegacyConfigMigrationSpec } from "../../../config/legacy.shared.js"; import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_AGENTS } from "./legacy-config-migrations.runtime.agents.js"; +import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_CLI_BACKENDS } from "./legacy-config-migrations.runtime.cli-backends.js"; import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_CRON } from "./legacy-config-migrations.runtime.cron.js"; import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_DIAGNOSTICS } from "./legacy-config-migrations.runtime.diagnostics.js"; import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_ENTRIES } from "./legacy-config-migrations.runtime.entries.js"; @@ -16,6 +17,7 @@ import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_TTS } from "./legacy-config-migrations /** Ordered runtime legacy config migrations applied by doctor. */ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME: LegacyConfigMigrationSpec[] = [ ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_AGENTS, + ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_CLI_BACKENDS, ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_CRON, ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_DIAGNOSTICS, ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_GATEWAY, diff --git a/src/commands/doctor/shared/stale-agent-model-ref-repair.ts b/src/commands/doctor/shared/stale-agent-model-ref-repair.ts index f455041c9d49..21f6024d6212 100644 --- a/src/commands/doctor/shared/stale-agent-model-ref-repair.ts +++ b/src/commands/doctor/shared/stale-agent-model-ref-repair.ts @@ -252,15 +252,6 @@ export function repairStaleAgentModelRefs( baseAvailableProviders.add(normalized); } } - for (const backendId of Object.keys( - isRecord(cfg.agents?.defaults?.cliBackends) ? cfg.agents.defaults.cliBackends : {}, - )) { - const normalized = normalizeProviderId(backendId); - if (normalized) { - baseAvailableProviders.add(normalized); - } - } - const config = structuredClone(cfg); const changes: string[] = []; const warnings = [...pluginProviders.warnings]; diff --git a/src/commands/models/list.status.test.ts b/src/commands/models/list.status.test.ts index 221d85281836..ce9a55876611 100644 --- a/src/commands/models/list.status.test.ts +++ b/src/commands/models/list.status.test.ts @@ -257,10 +257,7 @@ vi.mock("../../agents/provider-auth-aliases.js", () => ({ ), })); vi.mock("../../agents/model-selection-cli.js", () => ({ - isCliProvider: vi.fn( - (provider: string, cfg?: { agents?: { defaults?: { cliBackends?: object } } }) => - Object.hasOwn(cfg?.agents?.defaults?.cliBackends ?? {}, provider), - ), + isCliProvider: vi.fn((provider: string) => provider === "claude-cli"), })); vi.mock("../../infra/shell-env.js", () => ({ getShellEnvAppliedKeys: mocks.getShellEnvAppliedKeys, @@ -1267,7 +1264,6 @@ describe("modelsStatusCommand auth overview", () => { defaults: { model: { primary: "claude-cli/claude-sonnet-4-6", fallbacks: [] }, models: { "claude-cli/claude-sonnet-4-6": {} }, - cliBackends: { "claude-cli": {} }, }, }, models: { providers: {} }, diff --git a/src/config/dead-config-keys.test.ts b/src/config/dead-config-keys.test.ts index f4a294831f4f..9be9f0327910 100644 --- a/src/config/dead-config-keys.test.ts +++ b/src/config/dead-config-keys.test.ts @@ -54,7 +54,7 @@ describe("dead config keys", () => { "agents.defaults.videoGenerationModel", "agents.defaults.musicGenerationModel", "agents.defaults.promptOverlays", - "agents.defaults.cliBackends.custom.sessionArg", + "agents.defaults.cliBackends", "agents.defaults.heartbeat.ackMaxChars", "agents.defaults.heartbeat.includeReasoning", "agents.defaults.heartbeat.includeSystemPromptSection", @@ -114,9 +114,6 @@ describe("dead config keys", () => { "memory.qmd.mcporter", "memory.qmd.update", "memory.search.cache.maxEntries", - "agents.defaults.cliBackends.codex.reliability.outputLimits", - "agents.defaults.cliBackends.codex.reliability.watchdog.fresh.noOutputTimeoutMs", - "agents.defaults.cliBackends.codex.reliability.watchdog.resume.noOutputTimeoutMs", "agents.defaults.runRetries", "agents.entries.test.memory.search.chunking", "agents.entries.test.runRetries", diff --git a/src/config/io.write-prepare.test.ts b/src/config/io.write-prepare.test.ts index 3df150f5f32b..655f67b2c95a 100644 --- a/src/config/io.write-prepare.test.ts +++ b/src/config/io.write-prepare.test.ts @@ -863,27 +863,11 @@ describe("config io write prepare", () => { const changedPaths = new Set(); collectChangedPaths( { - agents: { - defaults: { - cliBackends: { - codex: { - env: { OPENAI_API_KEY: "sk-secret" }, - }, - }, - }, - }, + plugins: { entries: { acme: { config: { env: { API_KEY: "secret" } } } } }, gateway: { port: 18789 }, }, { - agents: { - defaults: { - cliBackends: { - codex: { - env: { OPENAI_API_KEY: "sk-secret" }, - }, - }, - }, - }, + plugins: { entries: { acme: { config: { env: { API_KEY: "secret" } } } } }, gateway: { port: 18789, auth: { mode: "token" }, @@ -895,29 +879,21 @@ describe("config io write prepare", () => { const restored = restoreEnvRefsFromMap( { - agents: { - defaults: { - cliBackends: { - codex: { - env: { OPENAI_API_KEY: "sk-secret" }, - }, - }, - }, - }, + plugins: { entries: { acme: { config: { env: { API_KEY: "secret" } } } } }, gateway: { port: 18789, auth: { mode: "token" }, }, }, "", - new Map([["agents.defaults.cliBackends.codex.env.OPENAI_API_KEY", "${OPENAI_API_KEY}"]]), + new Map([["plugins.entries.acme.config.env.API_KEY", "${ACME_API_KEY}"]]), changedPaths, ) as { - agents: { defaults: { cliBackends: { codex: { env: { OPENAI_API_KEY: string } } } } }; + plugins: { entries: { acme: { config: { env: { API_KEY: string } } } } }; gateway: { port: number; auth: { mode: string } }; }; - expect(restored.agents.defaults.cliBackends.codex.env.OPENAI_API_KEY).toBe("${OPENAI_API_KEY}"); + expect(restored.plugins.entries.acme.config.env.API_KEY).toBe("${ACME_API_KEY}"); expect(restored.gateway).toEqual({ port: 18789, auth: { mode: "token" }, @@ -928,25 +904,11 @@ describe("config io write prepare", () => { const changedPaths = new Set(); collectChangedPaths( { - agents: { - defaults: { - cliBackends: { - codex: { - args: ["${DISCORD_USER_ID}", "123"], - }, - }, - }, - }, + plugins: { entries: { acme: { config: { args: ["${USER_ID}", "123"] } } } }, }, { - agents: { - defaults: { - cliBackends: { - codex: { - args: ["${DISCORD_USER_ID}", "123", "456"], - }, - }, - }, + plugins: { + entries: { acme: { config: { args: ["${USER_ID}", "123", "456"] } } }, }, }, "", @@ -955,28 +917,16 @@ describe("config io write prepare", () => { const restored = restoreEnvRefsFromMap( { - agents: { - defaults: { - cliBackends: { - codex: { - args: ["999", "123", "456"], - }, - }, - }, - }, + plugins: { entries: { acme: { config: { args: ["999", "123", "456"] } } } }, }, "", - new Map([["agents.defaults.cliBackends.codex.args[0]", "${DISCORD_USER_ID}"]]), + new Map([["plugins.entries.acme.config.args[0]", "${USER_ID}"]]), changedPaths, ) as { - agents: { defaults: { cliBackends: { codex: { args: string[] } } } }; + plugins: { entries: { acme: { config: { args: string[] } } } }; }; - expect(restored.agents.defaults.cliBackends.codex.args).toEqual([ - "${DISCORD_USER_ID}", - "123", - "456", - ]); + expect(restored.plugins.entries.acme.config.args).toEqual(["${USER_ID}", "123", "456"]); }); it("does not overwrite identity-restored env refs with positional map entries", () => { diff --git a/src/config/schema.help.agents.ts b/src/config/schema.help.agents.ts index 2a8a451af575..30fb1a43c598 100644 --- a/src/config/schema.help.agents.ts +++ b/src/config/schema.help.agents.ts @@ -116,7 +116,6 @@ export const AGENT_FIELD_HELP: Record = { "Max image side length in pixels when sanitizing transcript/tool-result image payloads (default: 1200).", "agents.defaults.imageQuality": 'Image-tool media compression preference: "auto" adapts to provider/model limits and image count, "efficient" saves tokens and bytes, "balanced" keeps the current middle ground, and "high" preserves more detail for screenshots and document images.', - "agents.defaults.cliBackends": "Optional CLI backends for text-only fallback (claude-cli, etc.).", "agents.defaults.compaction": "Compaction behavior for when context nears token limits, including strategy and pre-compaction memory flush behavior. Use this when long-running sessions need stable continuity under tight context windows.", "agents.defaults.compaction.mode": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 4686eb980055..ce12aec0e99c 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -614,7 +614,6 @@ export const FIELD_LABELS: Record = { "agents.entries.*.sandbox.docker.dangerouslyAllowContainerNamespaceJoin": "Agent Sandbox Docker Allow Container Namespace Join", "agents.entries.*.sandbox.docker.gpus": "Agent Sandbox Docker GPUs", - "agents.defaults.cliBackends": "CLI Backends", "agents.defaults.compaction": "Compaction", "agents.defaults.compaction.mode": "Compaction Mode", "agents.defaults.compaction.provider": "Compaction Provider", diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 50f2fad7ba93..24a8a2531d79 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -104,89 +104,6 @@ export type AgentContextLimitsConfig = { postCompactionMaxChars?: number; }; -export type CliBackendConfig = { - /** CLI command to execute (absolute path or on PATH). */ - command: string; - /** Base args applied to every invocation. */ - args?: string[]; - /** Output parsing mode (default: json). */ - output?: "json" | "text" | "jsonl"; - /** Output parsing mode when resuming a CLI session. */ - resumeOutput?: "json" | "text" | "jsonl"; - /** JSONL event dialect for CLIs with provider-specific stream formats. */ - jsonlDialect?: "claude-stream-json" | "gemini-stream-json"; - /** Long-lived CLI process mode. */ - liveSession?: "claude-stdio"; - /** Prompt input mode (default: arg). */ - input?: "arg" | "stdin"; - /** Max prompt length for arg mode (if exceeded, stdin is used). */ - maxPromptArgChars?: number; - /** Extra env vars injected for this CLI. */ - env?: Record; - /** Env vars to remove before launching this CLI. */ - clearEnv?: string[]; - /** Flag used to pass model id (e.g. --model). */ - modelArg?: string; - /** Model aliases mapping (config model id → CLI model id). */ - modelAliases?: Record; - /** Args used to pass a session id (use {sessionId} placeholder). */ - sessionArgs?: string[]; - /** Alternate args to use when resuming a session (use {sessionId} placeholder). */ - resumeArgs?: string[]; - /** Argument appended to one explicitly forked resume invocation. */ - forkArg?: string; - /** When to pass session ids. */ - sessionMode?: "always" | "existing" | "none"; - /** JSON fields to read session id from (in order). */ - sessionIdFields?: string[]; - /** Flag used to pass system prompt. */ - systemPromptArg?: string; - /** Flag used to pass a system prompt file. */ - systemPromptFileArg?: string; - /** Config override flag used to pass a system prompt file (e.g. -c). */ - systemPromptFileConfigArg?: string; - /** Config override key used to pass a system prompt file. */ - systemPromptFileConfigKey?: string; - /** System prompt behavior (append vs replace). */ - systemPromptMode?: "append" | "replace"; - /** When to send system prompt. */ - systemPromptWhen?: "first" | "always" | "never"; - /** Flag used to pass image paths. */ - imageArg?: string; - /** How to pass multiple images. */ - imageMode?: "repeat" | "list"; - /** Where staged image files should live before handing them to the CLI. */ - imagePathScope?: "temp" | "workspace"; - /** Serialize runs for this CLI. */ - serialize?: boolean; - /** Opt in to bounded raw transcript reseed before compaction for safe session resets. */ - reseedFromRawTranscriptWhenUncompacted?: boolean; - /** Runtime reliability tuning for this backend's process lifecycle. */ - reliability?: { - /** No-output watchdog tuning (fresh vs resumed runs). */ - watchdog?: { - /** Fresh/new sessions (non-resume). */ - fresh?: { - /** Fraction of overall timeout used when fixed timeout is not set. */ - noOutputTimeoutRatio?: number; - /** Lower bound for computed watchdog timeout. */ - minMs?: number; - /** Upper bound for computed watchdog timeout. */ - maxMs?: number; - }; - /** Resume sessions. */ - resume?: { - /** Fraction of overall timeout used when fixed timeout is not set. */ - noOutputTimeoutRatio?: number; - /** Lower bound for computed watchdog timeout. */ - minMs?: number; - /** Upper bound for computed watchdog timeout. */ - maxMs?: number; - }; - }; - }; -}; - export type AgentDefaultsConfig = { /** @deprecated Doctor-only legacy input. */ imageGenerationModel?: AgentToolModelConfig; @@ -298,8 +215,6 @@ export type AgentDefaultsConfig = { */ /** Optional context window cap (used for runtime estimates + status %). */ contextTokens?: number; - /** Optional CLI backends for text-only fallback (claude-cli, etc.). */ - cliBackends?: Record; /** Opt-in: prune old tool results from the LLM context to reduce token usage. */ contextPruning?: AgentContextPruningConfig; /** Compaction tuning and pre-compaction memory flush behavior. */ diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 01dc46cda9cc..e65e328ce931 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -13,7 +13,6 @@ import { import { BlockStreamingChunkSchema, BlockStreamingCoalesceSchema, - CliBackendSchema, HumanDelaySchema, TypingModeSchema, } from "./zod-schema.core.js"; @@ -117,7 +116,6 @@ export const AgentDefaultsSchema = z .optional(), contextLimits: AgentContextLimitsSchema, contextTokens: z.number().int().positive().optional(), - cliBackends: z.record(z.string(), CliBackendSchema).optional(), contextPruning: z .object({ mode: z.union([z.literal("off"), z.literal("cache-ttl")]).optional(), diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 90bc56f0672b..1fd6fcd696af 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -765,66 +765,6 @@ export const HumanDelaySchema = z }) .strict(); -const CliBackendWatchdogModeSchema = z - .object({ - noOutputTimeoutRatio: z.number().min(0.05).max(0.95).optional(), - minMs: z.number().int().min(1000).optional(), - maxMs: z.number().int().min(1000).optional(), - }) - .strict() - .optional(); - -export const CliBackendSchema = z - .object({ - command: z.string(), - args: z.array(z.string()).optional(), - output: z.union([z.literal("json"), z.literal("text"), z.literal("jsonl")]).optional(), - resumeOutput: z.union([z.literal("json"), z.literal("text"), z.literal("jsonl")]).optional(), - jsonlDialect: z - .union([z.literal("claude-stream-json"), z.literal("gemini-stream-json")]) - .optional(), - liveSession: z.literal("claude-stdio").optional(), - input: z.union([z.literal("arg"), z.literal("stdin")]).optional(), - maxPromptArgChars: z.number().int().positive().optional(), - env: z.record(z.string(), z.string()).optional(), - clearEnv: z.array(z.string()).optional(), - modelArg: z.string().optional(), - modelAliases: z.record(z.string(), z.string()).optional(), - sessionArgs: z.array(z.string()).optional(), - resumeArgs: z.array(z.string()).optional(), - forkArg: z.string().optional(), - sessionMode: z - .union([z.literal("always"), z.literal("existing"), z.literal("none")]) - .optional(), - sessionIdFields: z.array(z.string()).optional(), - systemPromptArg: z.string().optional(), - systemPromptFileArg: z.string().optional(), - systemPromptFileConfigArg: z.string().optional(), - systemPromptFileConfigKey: z.string().optional(), - systemPromptMode: z.union([z.literal("append"), z.literal("replace")]).optional(), - systemPromptWhen: z - .union([z.literal("first"), z.literal("always"), z.literal("never")]) - .optional(), - imageArg: z.string().optional(), - imageMode: z.union([z.literal("repeat"), z.literal("list")]).optional(), - imagePathScope: z.union([z.literal("temp"), z.literal("workspace")]).optional(), - serialize: z.boolean().optional(), - reseedFromRawTranscriptWhenUncompacted: z.boolean().optional(), - reliability: z - .object({ - watchdog: z - .object({ - fresh: CliBackendWatchdogModeSchema, - resume: CliBackendWatchdogModeSchema, - }) - .strict() - .optional(), - }) - .strict() - .optional(), - }) - .strict(); - const normalizeAllowFrom = (values?: Array): string[] => normalizeStringEntries(values); diff --git a/src/gateway/gateway-cli-backend.live.test.ts b/src/gateway/gateway-cli-backend.live.test.ts index 84470acfb7ae..8d33d1cc916c 100644 --- a/src/gateway/gateway-cli-backend.live.test.ts +++ b/src/gateway/gateway-cli-backend.live.test.ts @@ -1,4 +1,4 @@ -// CLI backend live gateway tests exercise configured backend sessions, model switching, MCP loopback, and image probes. +// CLI backend live gateway tests exercise registered backend sessions, model switching, MCP loopback, and image probes. import { randomBytes, randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; @@ -386,7 +386,9 @@ describeLive("gateway live (cli backend)", () => { "OPENCLAW_LIVE_CLI_BACKEND_IMAGE_MODE requires OPENCLAW_LIVE_CLI_BACKEND_IMAGE_ARG.", ); } - + if (!backendResolved || !providerDefaults) { + throw new Error(`missing CLI backend metadata for ${providerId}`); + } const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-live-cli-")); const stateDir = path.join(tempDir, "state"); await fs.mkdir(stateDir, { recursive: true }); @@ -395,7 +397,7 @@ describeLive("gateway live (cli backend)", () => { : undefined; const useMinimalToolsProfile = providerId === "codex-cli" && !schemaProbePluginPath; setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); - const bundleMcp = backendResolved?.bundleMcp === true && !resumeContinuityProbe; + const bundleMcp = backendResolved.bundleMcp && !resumeContinuityProbe; const bootstrapWorkspace = await createBootstrapWorkspace(tempDir); const disableMcpConfig = process.env.OPENCLAW_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG !== "0"; let cliArgs = baseCliArgs; @@ -408,16 +410,32 @@ describeLive("gateway live (cli backend)", () => { await fs.writeFile(mcpConfigPath, `${JSON.stringify({ mcpServers: {} }, null, 2)}\n`); cliArgs = withClaudeMcpConfigOverrides(baseCliArgs, mcpConfigPath); } + const liveBackend = { + ...backendResolved, + pluginId: backendResolved.pluginId ?? providerId, + config: { + ...providerDefaults, + command: cliCommand, + args: cliArgs, + resumeArgs: baseCliResumeArgs, + clearEnv: filteredCliClearEnv.length > 0 ? filteredCliClearEnv : undefined, + env: Object.keys(preservedCliEnv).length > 0 ? preservedCliEnv : undefined, + systemPromptWhen: providerDefaults.systemPromptWhen ?? "never", + ...(cliImageArg + ? { + imageArg: cliImageArg, + imageMode: cliImageMode, + imagePathScope: providerDefaults.imagePathScope, + } + : {}), + }, + }; + cliBackendsTesting.setDepsForTest({ + resolvePluginSetupCliBackend: () => undefined, + resolveRuntimeCliBackends: () => [liveBackend], + }); const cfg: OpenClawConfig = {}; - const cfgWithCliBackends = cfg as OpenClawConfig & { - agents?: { - defaults?: { - cliBackends?: Record>; - }; - }; - }; - const existingBackends = cfgWithCliBackends.agents?.defaults?.cliBackends ?? {}; const nextCfg = { ...cfg, ...(schemaProbePluginPath @@ -474,24 +492,6 @@ describeLive("gateway live (cli backend)", () => { ? { [modelSwitchTarget]: { agentRuntime: modelSelection.agentRuntime } } : {}), }, - cliBackends: { - ...existingBackends, - [providerId]: { - command: cliCommand, - args: cliArgs, - resumeArgs: baseCliResumeArgs, - clearEnv: filteredCliClearEnv.length > 0 ? filteredCliClearEnv : undefined, - env: Object.keys(preservedCliEnv).length > 0 ? preservedCliEnv : undefined, - systemPromptWhen: providerDefaults?.systemPromptWhen ?? "never", - ...(cliImageArg - ? { - imageArg: cliImageArg, - imageMode: cliImageMode, - imagePathScope: providerDefaults?.imagePathScope, - } - : {}), - }, - }, sandbox: { mode: "off" }, }, // The live requests below use agent:dev:* session keys. Declare the @@ -541,14 +541,11 @@ describeLive("gateway live (cli backend)", () => { initializeGlobalHookRunner(continuityHookRegistry); // Bundled MCP capture intentionally retires a Claude child after each turn. This probe // isolates the exact warm-session path while leaving production defaults untouched. - if (!backendResolved) { - throw new Error(`missing CLI backend metadata for ${providerId}`); - } cliBackendsTesting.setDepsForTest({ resolveRuntimeCliBackends: () => [ { - ...backendResolved, - pluginId: backendResolved.pluginId ?? CLI_CONTINUITY_PROBE_PLUGIN_ID, + ...liveBackend, + pluginId: liveBackend.pluginId ?? CLI_CONTINUITY_PROBE_PLUGIN_ID, bundleMcp: false, }, ], diff --git a/src/gateway/server-startup.test.ts b/src/gateway/server-startup.test.ts index 4040952b4a82..750f81916b8d 100644 --- a/src/gateway/server-startup.test.ts +++ b/src/gateway/server-startup.test.ts @@ -134,12 +134,6 @@ describe("gateway startup primary model warmup", () => { model: { primary: "codex-cli/gpt-5.5", }, - cliBackends: { - "codex-cli": { - command: "codex", - args: ["exec"], - }, - }, }, }, } as OpenClawConfig; diff --git a/src/plugin-sdk/cli-backend.ts b/src/plugin-sdk/cli-backend.ts index 8a9845b879ae..c27f51c04978 100644 --- a/src/plugin-sdk/cli-backend.ts +++ b/src/plugin-sdk/cli-backend.ts @@ -1,9 +1,9 @@ /** * Public SDK type surface for CLI backend plugins and watchdog defaults. */ -export type { CliBackendConfig } from "../config/types.js"; export type { CliBackendAuthEpochMode, + CliBackendConfig, CliBackendExecutionMode, CliBackendNormalizeConfigContext, CliBackendNativeToolMode, diff --git a/src/plugins/cli-backend.types.ts b/src/plugins/cli-backend.types.ts index d2abab0c4ab7..889586b2fcdd 100644 --- a/src/plugins/cli-backend.types.ts +++ b/src/plugins/cli-backend.types.ts @@ -1,8 +1,91 @@ /** Type contracts for plugin-owned CLI backend integrations. */ -import type { CliBackendConfig } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { ContextEngineHostCapability } from "../context-engine/types.js"; +/** Static command adapter owned by a CLI backend plugin registration. */ +export type CliBackendConfig = { + /** CLI command to execute (absolute path or on PATH). */ + command: string; + /** Base args applied to every invocation. */ + args?: string[]; + /** Output parsing mode (default: json). */ + output?: "json" | "text" | "jsonl"; + /** Output parsing mode when resuming a CLI session. */ + resumeOutput?: "json" | "text" | "jsonl"; + /** JSONL event dialect for CLIs with provider-specific stream formats. */ + jsonlDialect?: "claude-stream-json" | "gemini-stream-json"; + /** Long-lived CLI process mode. */ + liveSession?: "claude-stdio"; + /** Prompt input mode (default: arg). */ + input?: "arg" | "stdin"; + /** Max prompt length for arg mode (if exceeded, stdin is used). */ + maxPromptArgChars?: number; + /** Extra env vars injected for this CLI. */ + env?: Record; + /** Env vars to remove before launching this CLI. */ + clearEnv?: string[]; + /** Flag used to pass model id (e.g. --model). */ + modelArg?: string; + /** Model aliases mapping (OpenClaw model id → CLI model id). */ + modelAliases?: Record; + /** Args used to pass a session id (use {sessionId} placeholder). */ + sessionArgs?: string[]; + /** Alternate args to use when resuming a session (use {sessionId} placeholder). */ + resumeArgs?: string[]; + /** Argument appended to one explicitly forked resume invocation. */ + forkArg?: string; + /** When to pass session ids. */ + sessionMode?: "always" | "existing" | "none"; + /** JSON fields to read session id from (in order). */ + sessionIdFields?: string[]; + /** Flag used to pass system prompt. */ + systemPromptArg?: string; + /** Flag used to pass a system prompt file. */ + systemPromptFileArg?: string; + /** Config override flag used to pass a system prompt file (e.g. -c). */ + systemPromptFileConfigArg?: string; + /** Config override key used to pass a system prompt file. */ + systemPromptFileConfigKey?: string; + /** System prompt behavior (append vs replace). */ + systemPromptMode?: "append" | "replace"; + /** When to send system prompt. */ + systemPromptWhen?: "first" | "always" | "never"; + /** Flag used to pass image paths. */ + imageArg?: string; + /** How to pass multiple images. */ + imageMode?: "repeat" | "list"; + /** Where staged image files should live before handing them to the CLI. */ + imagePathScope?: "temp" | "workspace"; + /** Serialize runs for this CLI. */ + serialize?: boolean; + /** Opt in to bounded raw transcript reseed before compaction for safe session resets. */ + reseedFromRawTranscriptWhenUncompacted?: boolean; + /** Runtime reliability tuning for this backend's process lifecycle. */ + reliability?: { + /** No-output watchdog tuning (fresh vs resumed runs). */ + watchdog?: { + /** Fresh/new sessions (non-resume). */ + fresh?: { + /** Fraction of overall timeout used when fixed timeout is not set. */ + noOutputTimeoutRatio?: number; + /** Lower bound for computed watchdog timeout. */ + minMs?: number; + /** Upper bound for computed watchdog timeout. */ + maxMs?: number; + }; + /** Resume sessions. */ + resume?: { + /** Fraction of overall timeout used when fixed timeout is not set. */ + noOutputTimeoutRatio?: number; + /** Lower bound for computed watchdog timeout. */ + minMs?: number; + /** Upper bound for computed watchdog timeout. */ + maxMs?: number; + }; + }; + }; +}; + export type PluginTextReplacement = { from: string | RegExp; to: string; @@ -122,7 +205,7 @@ export type CliBackendPlugin = { id: string; /** Canonical model provider whose models this CLI backend can execute. */ modelProvider?: string; - /** Default backend config before user overrides from `agents.defaults.cliBackends`. */ + /** Static command adapter owned by this plugin. */ config: CliBackendConfig; /** * Context-engine host capabilities provided by this backend when it is @@ -178,10 +261,7 @@ export type CliBackendPlugin = { */ bundleMcpMode?: CliBundleMcpMode; /** - * Optional config normalizer applied after user overrides merge. - * - * Use this for backend-specific compatibility rewrites when old config - * shapes need to stay working. + * Optional config normalizer applied to the registered adapter. */ normalizeConfig?: ( config: CliBackendConfig, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 967c22d7df00..90e35cf3fab6 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -8,6 +8,7 @@ export type { AgentHarness } from "../agents/harness/types.js"; export type { AnyAgentTool } from "../agents/tools/common.js"; export type { CliBackendAuthEpochMode, + CliBackendConfig, CliBackendExecutionMode, CliBackendNormalizeConfigContext, CliBackendNativeToolMode, diff --git a/src/security/audit-exec-surface.test.ts b/src/security/audit-exec-surface.test.ts index 9892ad40c222..ad3fdadecde8 100644 --- a/src/security/audit-exec-surface.test.ts +++ b/src/security/audit-exec-surface.test.ts @@ -15,8 +15,7 @@ function hasFinding( | "tools.exec.allowlist_interpreter_without_strict_inline_eval" | "security.exposure.open_channels_with_exec" | "tools.exec.security_full_configured" - | "tools.exec.fs_tools_disabled_but_exec_enabled" - | "agents.claude_cli.permission_mode_overridden_by_yolo", + | "tools.exec.fs_tools_disabled_but_exec_enabled", severity: "warn" | "critical", findings: SecurityAuditFinding[], ) { @@ -90,138 +89,6 @@ describe("security audit exec surface findings", () => { ).toBe(true); }); - it("warns when YOLO exec overrides restrictive Claude permission mode", async () => { - const findings = await collectSecurityAuditFindings({ - agents: { - defaults: { - cliBackends: { - "claude-cli": { - command: "claude", - args: ["-p", "--permission-mode", "default"], - resumeArgs: ["-p", "--permission-mode=acceptEdits", "--resume", "{sessionId}"], - }, - }, - }, - }, - } satisfies OpenClawConfig); - - const finding = findings.find( - (entry) => entry.checkId === "agents.claude_cli.permission_mode_overridden_by_yolo", - ); - expect(finding).toEqual( - expect.objectContaining({ - severity: "warn", - detail: expect.stringContaining("args=default"), - remediation: expect.stringContaining("tools.exec.mode"), - }), - ); - expect(finding?.detail).toContain("resumeArgs=acceptEdits"); - expect(finding?.detail).toContain("OpenClaw exec is YOLO"); - }); - - it("warns for normalized Claude backend keys", async () => { - const findings = await collectSecurityAuditFindings({ - agents: { - defaults: { - cliBackends: { - "Anthropic-CLI": { - command: "claude", - args: ["-p", "--permission-mode", "default"], - }, - }, - }, - }, - } satisfies OpenClawConfig); - - expect( - hasFinding("agents.claude_cli.permission_mode_overridden_by_yolo", "warn", findings), - ).toBe(true); - }); - - it("prefers exact Claude backend config over duplicate normalized aliases", async () => { - const findings = await collectSecurityAuditFindings({ - agents: { - defaults: { - cliBackends: { - "Anthropic-CLI": { - command: "claude", - args: ["-p", "--permission-mode", "default"], - }, - "claude-cli": { - command: "claude", - args: ["-p"], - }, - }, - }, - }, - } satisfies OpenClawConfig); - - expect( - hasFinding("agents.claude_cli.permission_mode_overridden_by_yolo", "warn", findings), - ).toBe(false); - }); - - it("does not warn for restrictive Claude permission mode when OpenClaw exec is restrictive", async () => { - const findings = await collectSecurityAuditFindings({ - tools: { exec: { mode: "ask" } }, - agents: { - defaults: { - cliBackends: { - "claude-cli": { - command: "claude", - args: ["-p", "--permission-mode", "default"], - }, - }, - }, - }, - } satisfies OpenClawConfig); - - expect( - hasFinding("agents.claude_cli.permission_mode_overridden_by_yolo", "warn", findings), - ).toBe(false); - }); - - it("does not warn when sandbox host defaults make exec restrictive", async () => { - const findings = await collectSecurityAuditFindings({ - tools: { exec: { host: "sandbox" } }, - agents: { - defaults: { - cliBackends: { - "claude-cli": { - command: "claude", - args: ["-p", "--permission-mode", "default"], - }, - }, - }, - }, - } satisfies OpenClawConfig); - - expect( - hasFinding("agents.claude_cli.permission_mode_overridden_by_yolo", "warn", findings), - ).toBe(false); - }); - - it("does not warn for restrictive Claude permission mode on non-live backend configs", async () => { - const findings = await collectSecurityAuditFindings({ - agents: { - defaults: { - cliBackends: { - "claude-cli": { - command: "claude", - output: "json", - input: "arg", - args: ["--permission-mode", "default"], - }, - }, - }, - }, - } satisfies OpenClawConfig); - - expect( - hasFinding("agents.claude_cli.permission_mode_overridden_by_yolo", "warn", findings), - ).toBe(false); - }); - it("warns when interpreter allowlists are present without strictInlineEval", async () => { saveExecApprovals({ version: 1, diff --git a/src/security/audit.ts b/src/security/audit.ts index 80409a16eb69..e15418531e2d 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -1,11 +1,7 @@ // Orchestrates security audit collection and report formatting. import path from "node:path"; -import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; -import { - normalizeOptionalLowercaseString, - normalizeOptionalString, -} from "@openclaw/normalization-core/string-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveExecDefaults } from "../agents/exec-defaults.js"; @@ -13,7 +9,6 @@ import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/config.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; -import type { CliBackendConfig } from "../config/types.agent-defaults.js"; import type { GatewayAuthConfig } from "../config/types.gateway.js"; import type { SecurityAuditSuppression } from "../config/types.openclaw.js"; import { @@ -25,10 +20,7 @@ import { emitTrustedSecurityEvent } from "../infra/diagnostic-events.js"; import { type ExecApprovalsFile, loadExecApprovals, - maxAsk, - minSecurity, resolveExecModePolicy, - resolveExecApprovalsFromFile, } from "../infra/exec-approvals.js"; import { normalizeConfiguredSafeBins, @@ -71,10 +63,6 @@ type SecurityAuditExplicitGatewayAuth = { password?: string; }; type SecurityAuditGatewayAuthOverride = Pick; -type ClaudePermissionModeHit = { - argSet: "args" | "resumeArgs"; - mode: string; -}; type McpServerSourceSummary = { label: string; names: string[]; @@ -609,121 +597,6 @@ function collectElevatedFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { return findings; } -const CLAUDE_PERMISSION_MODE_FLAG = "--permission-mode"; -const CLAUDE_BYPASS_PERMISSION_MODE = "bypassPermissions"; - -function extractClaudePermissionMode(args: readonly string[] | undefined): string | undefined { - if (!Array.isArray(args)) { - return undefined; - } - for (let i = args.length - 1; i >= 0; i -= 1) { - const arg = args[i] ?? ""; - if (arg === CLAUDE_PERMISSION_MODE_FLAG) { - const value = args[i + 1]; - if (typeof value === "string" && value.trim().length > 0 && !value.startsWith("-")) { - return value.trim(); - } - continue; - } - if (arg.startsWith(`${CLAUDE_PERMISSION_MODE_FLAG}=`)) { - const value = arg.slice(`${CLAUDE_PERMISSION_MODE_FLAG}=`.length).trim(); - if (value.length > 0 && !value.startsWith("-")) { - return value; - } - } - } - return undefined; -} - -function collectRestrictiveClaudePermissionModeHits( - backend: CliBackendConfig | undefined, -): ClaudePermissionModeHit[] { - if (!isManagedClaudeLiveBackendConfig(backend)) { - return []; - } - const hits: ClaudePermissionModeHit[] = []; - const argsMode = extractClaudePermissionMode(backend.args); - if (argsMode && argsMode !== CLAUDE_BYPASS_PERMISSION_MODE) { - hits.push({ argSet: "args", mode: argsMode }); - } - const resumeArgsMode = extractClaudePermissionMode(backend.resumeArgs); - if (resumeArgsMode && resumeArgsMode !== CLAUDE_BYPASS_PERMISSION_MODE) { - hits.push({ argSet: "resumeArgs", mode: resumeArgsMode }); - } - return hits; -} - -function isManagedClaudeLiveBackendConfig( - backend: CliBackendConfig | undefined, -): backend is CliBackendConfig { - if (!backend) { - return false; - } - const output = backend.output ?? "jsonl"; - const input = backend.input ?? "stdin"; - const liveSession = - backend.liveSession ?? (output === "jsonl" && input === "stdin" ? "claude-stdio" : undefined); - return liveSession === "claude-stdio" && output === "jsonl" && input === "stdin"; -} - -function findClaudeCliBackendConfig( - backends: Record | undefined, -): CliBackendConfig | undefined { - if (!backends) { - return undefined; - } - const directKey = Object.keys(backends).find( - (key) => normalizeOptionalLowercaseString(key) === "claude-cli", - ); - if (directKey) { - return backends[directKey]; - } - for (const [key, backend] of Object.entries(backends)) { - const normalizedKey = normalizeProviderId(key); - const command = normalizeOptionalLowercaseString(backend.command); - if ( - normalizedKey === "claude-cli" || - normalizedKey === "anthropic-cli" || - command === "claude" - ) { - return backend; - } - } - return undefined; -} - -function collectYoloExecScopeIds(cfg: OpenClawConfig, approvals: ExecApprovalsFile): string[] { - const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; - return [ - { id: DEFAULT_AGENT_ID }, - ...agents - .filter( - (entry): entry is NonNullable<(typeof agents)[number]> => - Boolean(entry) && typeof entry === "object" && typeof entry.id === "string", - ) - .map((entry) => ({ id: entry.id })), - ] - .filter((entry) => { - const execDefaults = resolveExecDefaults({ - cfg, - agentId: entry.id === DEFAULT_AGENT_ID ? undefined : entry.id, - }); - const resolvedApprovals = resolveExecApprovalsFromFile({ - file: approvals, - agentId: entry.id === DEFAULT_AGENT_ID ? undefined : entry.id, - overrides: { - security: execDefaults.security, - ask: execDefaults.ask, - }, - }); - return ( - minSecurity(execDefaults.security, resolvedApprovals.agent.security) === "full" && - maxAsk(execDefaults.ask, resolvedApprovals.agent.ask) === "off" - ); - }) - .map((entry) => entry.id); -} - function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; const globalExecHost = cfg.tools?.exec?.host; @@ -731,11 +604,6 @@ function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[] const defaultSandboxMode = resolveSandboxConfigForAgent(cfg).mode; const defaultHostIsExplicitSandbox = globalExecHost === "sandbox"; const approvals = loadExecApprovals(); - const claudePermissionModeHits = collectRestrictiveClaudePermissionModeHits( - findClaudeCliBackendConfig(cfg.agents?.defaults?.cliBackends), - ); - const yoloExecScopeIds = - claudePermissionModeHits.length > 0 ? collectYoloExecScopeIds(cfg, approvals) : []; if (defaultHostIsExplicitSandbox && defaultSandboxMode === "off") { findings.push({ @@ -831,17 +699,6 @@ function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[] }); } - if (claudePermissionModeHits.length > 0 && yoloExecScopeIds.length > 0) { - findings.push({ - checkId: "agents.claude_cli.permission_mode_overridden_by_yolo", - severity: "warn", - title: "Claude permission mode is ignored under YOLO exec", - detail: `claude-cli sets ${claudePermissionModeHits.map((hit) => `${hit.argSet}=${hit.mode}`).join(", ")}, but OpenClaw exec is YOLO for: ${yoloExecScopeIds.join(", ")}. Managed Claude live sessions use --permission-mode bypassPermissions.`, - remediation: - "Restrict OpenClaw tools.exec.mode, or remove the Claude --permission-mode override.", - }); - } - if (openExecSurfacePaths.length > 0 && execEnabledScopes.length > 0) { findings.push({ checkId: "security.exposure.open_channels_with_exec", diff --git a/src/status/status-message.test.ts b/src/status/status-message.test.ts index f85266d5a607..da5c8729563c 100644 --- a/src/status/status-message.test.ts +++ b/src/status/status-message.test.ts @@ -134,11 +134,7 @@ describe("buildStatusMessage context window", () => { const text = buildStatusMessage({ config: { agents: { - defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, - }, + defaults: {}, }, models: { providers: { diff --git a/src/system-agent/agent-turn.test.ts b/src/system-agent/agent-turn.test.ts index 28fbac6804f2..8481668447f5 100644 --- a/src/system-agent/agent-turn.test.ts +++ b/src/system-agent/agent-turn.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { testing as cliBackendsTesting } from "../agents/cli-backends.test-support.js"; import { fingerprintResolvedProviderAuth } from "../agents/execution-auth-binding.js"; -import type { CliBackendConfig, OpenClawConfig } from "../config/types.js"; +import type { OpenClawConfig } from "../config/types.js"; import { cleanupSystemAgentSession, createSystemAgentSession, @@ -93,31 +93,6 @@ async function createVerifiedSession(config: OpenClawConfig) { }; } -const cliBackendRouteChanges: Array<{ - name: string; - first: CliBackendConfig; - second: CliBackendConfig; -}> = [ - { - name: "backend command", - first: { command: "claude" }, - second: { command: "/opt/openclaw/bin/claude" }, - }, - { - name: "effective model alias", - first: { command: "claude", modelAliases: { current: "claude-opus-4-8" } }, - second: { command: "claude", modelAliases: { current: "claude-sonnet-5" } }, - }, - { - name: "resume protocol", - first: { command: "claude", resumeArgs: ["--resume", "{sessionId}", "--print", "{prompt}"] }, - second: { - command: "claude", - resumeArgs: ["--resume-session", "{sessionId}", "--print", "{prompt}"], - }, - }, -]; - beforeEach(() => { // Core tests install a contract-level selectable backend instead of loading // a plugin's generated setup artifact from dist/. @@ -307,7 +282,6 @@ describe("runSystemAgentTurn", () => { agents: { defaults: { model: { primary: "openai/gpt-global" }, - cliBackends: { "claude-cli": { command: "claude" } }, }, list: [ { @@ -373,10 +347,20 @@ describe("runSystemAgentTurn", () => { it("rejects an always-on CLI backend before launching OpenClaw", async () => { useTempStateDir(); + cliBackendsTesting.setDepsForTest({ + resolveRuntimeCliBackends: () => [ + { + id: "google-gemini-cli", + pluginId: "google", + modelProvider: "google", + config: { command: "gemini" }, + nativeToolMode: "always-on", + }, + ], + }); const config = { agents: { defaults: { - cliBackends: { "google-gemini-cli": { command: "gemini" } }, model: "google-gemini-cli/gemini-3.1-pro-preview", }, }, @@ -423,7 +407,6 @@ describe("runSystemAgentTurn", () => { const config = { agents: { defaults: { - cliBackends: { "claude-cli": { command: "claude" } }, model: "claude-cli/claude-opus-4-8@claude-cli:ops", }, }, @@ -483,7 +466,7 @@ describe("runSystemAgentTurn", () => { const agentDir = path.join(stateDir, "ops-agent"); const config = { agents: { - defaults: { cliBackends: { "claude-cli": { command: "claude" } } }, + defaults: {}, list: [ { id: "ops", @@ -535,7 +518,7 @@ describe("runSystemAgentTurn", () => { const agentDir = path.join(stateDir, "ops-agent"); const config = { agents: { - defaults: { cliBackends: { "claude-cli": { command: "claude" } } }, + defaults: {}, list: [ { id: "ops", @@ -605,7 +588,6 @@ describe("runSystemAgentTurn", () => { ({ agents: { defaults: { - cliBackends: { "claude-cli": { command: "claude" } }, model: `claude-cli/claude-opus-4-8@${profileId}`, }, }, @@ -644,116 +626,6 @@ describe("runSystemAgentTurn", () => { expect(session.cliSession).toBeUndefined(); }); - it.each(cliBackendRouteChanges)( - "rejects a $name change without resuming the CLI binding", - async ({ first, second }) => { - useTempStateDir(); - const configForBackend = (backend: CliBackendConfig) => - ({ - agents: { - defaults: { - cliBackends: { "claude-cli": backend }, - model: "claude-cli/current@claude-cli:ops", - }, - }, - }) as OpenClawConfig; - const binding = { - sessionId: "native-claude-session", - authProfileId: "claude-cli:ops", - authEpoch: "auth-epoch", - authEpochVersion: 4, - cwdHash: "cwd-hash", - mcpResumeHash: "resume-hash", - }; - const runCliAgent = vi.fn(async (_params: RunCliAgentParams) => ({ - payloads: [{ text: "ready" }], - meta: { agentMeta: { cliSessionBinding: binding } }, - })); - const readConfigFileSnapshot = vi - .fn() - .mockResolvedValueOnce(configSnapshot(configForBackend(first))) - .mockResolvedValueOnce(configSnapshot(configForBackend(first))) - .mockResolvedValueOnce(configSnapshot(configForBackend(second))); - const { session, deps } = await createVerifiedSession(configForBackend(first)); - const turn = async () => - await runSystemAgentTurnWithDeps( - { - input: "hello", - overview: { defaultModel: "claude-cli/current" } as never, - surface: "gateway", - approvalArmed: false, - session, - }, - { - ...deps, - runCliAgent: runCliAgent as never, - readConfigFileSnapshot: readConfigFileSnapshot as never, - }, - ); - - await turn(); - await expect(turn()).rejects.toBeInstanceOf(SystemAgentInferenceUnavailableError); - - expect(runCliAgent).toHaveBeenCalledOnce(); - const firstCall = requireValue(runCliAgent.mock.calls[0]?.[0], "missing first CLI call"); - expect(firstCall.cliSessionBinding).toBeUndefined(); - expect(session.cliSession).toBeUndefined(); - }, - ); - - it("rejects an alias-identity change without resuming the CLI binding", async () => { - useTempStateDir(); - const configForModel = (model: string) => - ({ - agents: { - defaults: { - cliBackends: { - "claude-cli": { - command: "claude", - modelAliases: { - current: "claude-opus-4-8", - stable: "claude-opus-4-8", - }, - }, - }, - model: `claude-cli/${model}@claude-cli:ops`, - }, - }, - }) as OpenClawConfig; - const binding = { sessionId: "native-claude-session", authEpochVersion: 1 }; - const runCliAgent = vi.fn(async (_params: RunCliAgentParams) => ({ - payloads: [{ text: "ready" }], - meta: { agentMeta: { cliSessionBinding: binding } }, - })); - const readConfigFileSnapshot = vi - .fn() - .mockResolvedValueOnce(configSnapshot(configForModel("current"))) - .mockResolvedValueOnce(configSnapshot(configForModel("current"))) - .mockResolvedValueOnce(configSnapshot(configForModel("stable"))); - const { session, deps } = await createVerifiedSession(configForModel("current")); - const turn = async () => - await runSystemAgentTurnWithDeps( - { - input: "hello", - overview: { defaultModel: "claude-cli/claude-opus-4-8" } as never, - surface: "gateway", - approvalArmed: false, - session, - }, - { - ...deps, - runCliAgent: runCliAgent as never, - readConfigFileSnapshot: readConfigFileSnapshot as never, - }, - ); - - await turn(); - await expect(turn()).rejects.toBeInstanceOf(SystemAgentInferenceUnavailableError); - - expect(runCliAgent).toHaveBeenCalledOnce(); - expect(session.cliSession).toBeUndefined(); - }); - it("rejects an executable-policy change and invalidates CLI continuity", async () => { useTempStateDir(); const configForGlobalPolicy = (mode: "full" | "deny") => @@ -761,7 +633,6 @@ describe("runSystemAgentTurn", () => { tools: { exec: { mode } }, agents: { defaults: { - cliBackends: { "claude-cli": { command: "claude" } }, model: "claude-cli/claude-opus-4-8@claude-cli:ops", }, list: [ @@ -818,7 +689,7 @@ describe("runSystemAgentTurn", () => { const agentDir = path.join(stateDir, "ops-agent"); const cliConfig = { agents: { - defaults: { cliBackends: { "claude-cli": { command: "claude" } } }, + defaults: {}, list: [ { id: "ops", @@ -1077,4 +948,3 @@ describe("runSystemAgentTurn", () => { expect(session.cliSession).toBeUndefined(); }); }); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/system-agent/assistant.configured.test.ts b/src/system-agent/assistant.configured.test.ts index 5565fceff387..a3ec607885c6 100644 --- a/src/system-agent/assistant.configured.test.ts +++ b/src/system-agent/assistant.configured.test.ts @@ -225,7 +225,7 @@ describe("OpenClaw configured-model planner", () => { it("plans through the configured default agent CLI route with native tools disabled", async () => { const config: OpenClawConfig = { agents: { - defaults: { cliBackends: { "claude-cli": { command: "claude" } } }, + defaults: {}, list: [ { id: "ops", diff --git a/src/system-agent/chat-engine.test.ts b/src/system-agent/chat-engine.test.ts index 721050bc0600..8af164a79b14 100644 --- a/src/system-agent/chat-engine.test.ts +++ b/src/system-agent/chat-engine.test.ts @@ -2166,7 +2166,6 @@ describe("OpenClaw agent loop backends", () => { agents: { defaults: { model: { primary: "claude-cli/claude-opus-4-8" }, - cliBackends: { "claude-cli": { command: "claude" } }, }, }, } satisfies OpenClawConfig; @@ -2230,7 +2229,6 @@ describe("OpenClaw agent loop backends", () => { agents: { defaults: { model: { primary: "claude-cli/claude-opus-4-8" }, - cliBackends: { "claude-cli": { command: "claude" } }, }, }, } satisfies OpenClawConfig; diff --git a/src/system-agent/config-write-parity.test.ts b/src/system-agent/config-write-parity.test.ts index 1e42870490f4..3aaf3e2e23a6 100644 --- a/src/system-agent/config-write-parity.test.ts +++ b/src/system-agent/config-write-parity.test.ts @@ -70,7 +70,7 @@ describe("system-agent config write parity", () => { expect(classifyInferenceRouteConfigPath(["agents", "defaults", "models"])).toBe("blocked"); expect(classifyInferenceRouteConfigPath(["agents", "list"])).toBe("blocked"); expect(classifyInferenceRouteConfigPath(["agents", "list", "0"])).toBe("blocked"); - for (const field of ["model", "models", "params", "agentRuntime", "cliBackends"]) { + for (const field of ["model", "models", "params", "agentRuntime"]) { expect(classifyInferenceRouteConfigPath(["agents", "list", "1", field])).toBe("agent-route"); } for (const field of ["id", "default", "agentDir"]) { diff --git a/src/system-agent/config-write-policy.ts b/src/system-agent/config-write-policy.ts index 8fe4f76188b5..90fe2e853f7e 100644 --- a/src/system-agent/config-write-policy.ts +++ b/src/system-agent/config-write-policy.ts @@ -46,7 +46,7 @@ export function classifyInferenceRouteConfigPath( return "blocked"; } if (scope === "defaults") { - return ["agentruntime", "clibackends", "model", "models", "params"].includes(ownerOrField ?? "") + return ["agentruntime", "model", "models", "params"].includes(ownerOrField ?? "") ? "blocked" : "allowed"; } @@ -63,7 +63,7 @@ export function classifyInferenceRouteConfigPath( if (["agentdir", "default", "id"].includes(routeField ?? "")) { return "blocked"; } - return ["agentruntime", "clibackends", "model", "models", "params"].includes(routeField ?? "") + return ["agentruntime", "model", "models", "params"].includes(routeField ?? "") ? "agent-route" : "allowed"; } diff --git a/src/system-agent/inference-route.ts b/src/system-agent/inference-route.ts index c41fd8235ff0..634640585cd2 100644 --- a/src/system-agent/inference-route.ts +++ b/src/system-agent/inference-route.ts @@ -295,11 +295,6 @@ export async function projectInferenceRoute( rawModel, }), agentRuntime: structuredClone(defaults?.agentRuntime), - cliBackends: Object.fromEntries( - Object.entries(defaults?.cliBackends ?? {}).filter(([provider]) => - providerIds.has(normalizeProviderId(provider)), - ), - ), }, ...(agent ? { diff --git a/src/system-agent/setup-inference.test.ts b/src/system-agent/setup-inference.test.ts index ca74ccb8a04f..044ef026ba46 100644 --- a/src/system-agent/setup-inference.test.ts +++ b/src/system-agent/setup-inference.test.ts @@ -1745,9 +1745,6 @@ describe("activateSetupInference", () => { agents: { defaults: { model: "claude-cli/claude-opus-4-8", - cliBackends: { - "claude-cli": { command: "claude" }, - }, }, }, } satisfies OpenClawConfig; @@ -5789,11 +5786,7 @@ describe("verifySetupInference", () => { const result = await verifySetupInferenceConfig({ config: { agents: { - defaults: { - cliBackends: { - "claude-cli": { command: "claude" }, - }, - }, + defaults: {}, list: [ { id: "ops", @@ -5888,7 +5881,6 @@ describe("verifySetupInference", () => { agents: { defaults: { model: "claude-cli/claude-opus-4-8@claude-cli:locked", - cliBackends: { "claude-cli": { command: "claude" } }, }, }, }, @@ -5940,7 +5932,7 @@ describe("verifySetupInference", () => { order: { [testCase.profileProvider]: [testCase.profileId] }, }, agents: { - defaults: { cliBackends: { "google-gemini-cli": { command: "gemini" } } }, + defaults: {}, list: [ { id: "ops", diff --git a/src/system-agent/verified-inference.test.ts b/src/system-agent/verified-inference.test.ts index e8a6f992e9f3..3ee997586234 100644 --- a/src/system-agent/verified-inference.test.ts +++ b/src/system-agent/verified-inference.test.ts @@ -475,55 +475,6 @@ describe("verified OpenClaw inference binding", () => { ).resolves.toBeNull(); }); - it("invalidates an opaque CLI owner after backend config drift", async () => { - const cliConfig = { - agents: { - defaults: { - model: "claude-cli/claude-opus-4-8", - cliBackends: { "claude-cli": { command: "claude" } }, - }, - }, - } satisfies OpenClawConfig; - const changedConfig = { - agents: { - defaults: { - ...cliConfig.agents.defaults, - cliBackends: { "claude-cli": { command: "/opt/other/claude" } }, - }, - }, - } satisfies OpenClawConfig; - const route = await resolveSystemAgentConfiguredRouteFromConfig(cliConfig); - if (!route || route.runner !== "cli") { - throw new Error("missing test CLI route"); - } - const binding = await createSystemAgentVerifiedInferenceBinding({ - configuredRoute: route, - executionRoute: route, - auth: { - runtimeOwnerFingerprint: "opaque-cli-owner", - runtimeOwnerKind: "cli-runtime", - runtimeOwnerId: "claude-cli", - ...cliRuntimeArtifactAuth, - }, - deps: { - ...pluginArtifactDeps(), - ...cliRuntimeArtifactDeps(), - resolveCliRuntimeOwnerFingerprint: vi.fn(async () => "opaque-cli-owner"), - }, - }); - - await expect( - resolveSystemAgentVerifiedInferenceRoute(binding, { - readConfigFileSnapshot: vi.fn(async () => ({ - exists: true, - valid: true, - config: changedConfig, - })) as never, - resolveCliRuntimeOwnerFingerprint: vi.fn(async () => "opaque-cli-owner"), - }), - ).resolves.toBeNull(); - }); - it("invalidates a strict CLI credential when its package artifact changes", async () => { const cliConfig = { agents: { defaults: { model: "claude-cli/claude-opus-4-8" } },