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
This commit is contained in:
Peter Steinberger
2026-07-22 00:25:29 -07:00
committed by GitHub
parent b02d3e4707
commit 3c4a1ec905
81 changed files with 860 additions and 2439 deletions
+1
View File
@@ -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}"; \
-2
View File
@@ -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
+1 -1
View File
@@ -1,5 +1,5 @@
{
"core": 2352,
"core": 2305,
"channel": 3627,
"plugin": 3556
}
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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.<id>.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.<id>.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.<id>.timeoutSeconds` for slow local providers. |
| Provider HTTP request timeout | `models.providers.<id>.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. |
+2 -2
View File
@@ -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
+3 -1
View File
@@ -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
+30 -60
View File
@@ -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: `<provider>/<model>`.
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.<id>.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.<id>` 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
+6 -37
View File
@@ -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/<model>"].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`
-1
View File
@@ -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.<provider>.wildcard` | critical | `tools.elevated.allowFrom.<provider>` includes `"*"`, approving every sender | `tools.elevated.allowFrom.<provider>` | no |
| `tools.elevated.allowFrom.<provider>.large` | warn | Elevated allowlist for `<provider>` has more than 25 entries | `tools.elevated.allowFrom.<provider>` | 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 |
+3 -8
View File
@@ -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:
+39 -26
View File
@@ -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.
</Step>
</Steps>
## 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.
<Check>`openclaw.plugin.json` declares `cliBackends` and intentional `activation.onStartup`</Check>
<Check>`setup.cliBackends` is present when setup/model discovery should see the backend cold</Check>
<Check>`api.registerCliBackend(...)` uses the same backend id as the manifest</Check>
<Check>User overrides under `agents.defaults.cliBackends.<id>` still win</Check>
<Check>The backend model prefix or model-scoped `agentRuntime.id` selects the registration</Check>
<Check>Session, system prompt, image, and output parser settings match the real CLI contract</Check>
<Check>Targeted tests and at least one live CLI smoke prove the backend path</Check>
## 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
+6 -5
View File
@@ -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.<id>`.
- User config still wins. OpenClaw merges `agents.defaults.cliBackends.<id>` 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
+45
View File
@@ -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({
+38
View File
@@ -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",
@@ -1330,7 +1330,6 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
agents: {
defaults: {
models: state.defaultRuntimeConfig.agents.defaults.models,
cliBackends: { codex: { command: "codex" } },
},
},
};
@@ -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" } },
},
+17 -7
View File
@@ -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 {
File diff suppressed because it is too large Load Diff
+29 -124
View File
@@ -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<string, CliBackendConfig>,
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,
};
}
+1 -1
View File
@@ -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 {
@@ -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("");
+17 -14
View File
@@ -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" })),
+1 -1
View File
@@ -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";
@@ -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 = {
+1 -1
View File
@@ -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,
@@ -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";
+1 -1
View File
@@ -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";
+36 -31
View File
@@ -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));
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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";
@@ -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",
+10 -48
View File
@@ -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";
@@ -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" } },
},
-21
View File
@@ -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({
+11 -5
View File
@@ -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",
},
+1 -9
View File
@@ -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);
});
-4
View File
@@ -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;
@@ -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 }),
@@ -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",
@@ -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({
@@ -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"
);
},
};
@@ -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",
@@ -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();
+8 -63
View File
@@ -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,
},
}),
),
+2 -10
View File
@@ -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: {
+21 -19
View File
@@ -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 () => {
+32
View File
@@ -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(
+3 -13
View File
@@ -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.",
);
}
@@ -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"])]]),
},
);
@@ -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",
@@ -62,6 +62,22 @@ function deprecatedCompatRecord<Code extends string>(
// 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",
@@ -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<string, unknown> = {
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<string, unknown> = { 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([]);
});
});
@@ -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}).`,
);
},
}),
];
@@ -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,
@@ -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];
+1 -5
View File
@@ -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: {} },
+1 -4
View File
@@ -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",
+13 -63
View File
@@ -863,27 +863,11 @@ describe("config io write prepare", () => {
const changedPaths = new Set<string>();
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<string>();
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", () => {
-1
View File
@@ -116,7 +116,6 @@ export const AGENT_FIELD_HELP: Record<string, string> = {
"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":
-1
View File
@@ -614,7 +614,6 @@ export const FIELD_LABELS: Record<string, string> = {
"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",
-85
View File
@@ -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<string, string>;
/** 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<string, string>;
/** 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<string, CliBackendConfig>;
/** Opt-in: prune old tool results from the LLM context to reduce token usage. */
contextPruning?: AgentContextPruningConfig;
/** Compaction tuning and pre-compaction memory flush behavior. */
-2
View File
@@ -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(),
-60
View File
@@ -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 | number>): string[] =>
normalizeStringEntries(values);
+31 -34
View File
@@ -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<string, Record<string, unknown>>;
};
};
};
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,
},
],
-6
View File
@@ -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;
+1 -1
View File
@@ -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,
+86 -6
View File
@@ -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<string, string>;
/** 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<string, string>;
/** 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,
+1
View File
@@ -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,
+1 -134
View File
@@ -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,
+1 -144
View File
@@ -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<GatewayAuthConfig, "mode" | "token" | "password">;
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<string, CliBackendConfig> | 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",
+1 -5
View File
@@ -134,11 +134,7 @@ describe("buildStatusMessage context window", () => {
const text = buildStatusMessage({
config: {
agents: {
defaults: {
cliBackends: {
"claude-cli": { command: "claude" },
},
},
defaults: {},
},
models: {
providers: {
+15 -145
View File
@@ -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. */
@@ -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",
-2
View File
@@ -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;
+1 -1
View File
@@ -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"]) {
+2 -2
View File
@@ -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";
}
-5
View File
@@ -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
? {
+2 -10
View File
@@ -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",
@@ -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" } },