Add standalone Claude CLI provider for local headless usage#61160
Add standalone Claude CLI provider for local headless usage#61160AlexManev wants to merge 3 commits into
Conversation
Adds a new claude-headless extension that routes inference through the local Claude CLI binary (claude -p --permission-mode bypassPermissions) instead of the Anthropic API. This lets users who cannot use Anthropic OAuth or API keys run OpenClaw using a locally authenticated Claude CLI installation. The extension registers: - A claude-headless CLI backend that spawns claude in headless/print mode with bypassPermissions (the modern replacement for the legacy --dangerously-skip-permissions flag). - A claude-headless provider plugin with a detect auth method that reads local Claude CLI credentials via readClaudeCliCredentialsCached and sets agents.defaults.model = claude-headless/claude-sonnet-4-6. Users set up via: openclaw auth (select "Claude headless (local CLI)") or manually set agents.defaults.model to a claude-headless/* ref.
feat(claude-headless): add standalone headless Claude CLI provider
Greptile SummaryNew
Confidence Score: 4/5Mostly safe to merge, but a P1 architectural violation (policy-required shared-helper extraction) should be resolved first. One P1 finding keeps this from a 5: the duplicated normalization/alias logic violates the extension boundary guide's explicit requirement to extract shared helpers in the same change rather than copy them. The logic itself is correct and mirrors the existing anthropic backend precisely; the risk is future silent divergence when the upstream helpers evolve. The P2 missing haiku alias is low-severity since the Claude CLI may accept the full model name gracefully. extensions/claude-headless/index.ts — shared logic duplication and missing haiku-4-5 alias. Prompt To Fix All With AIThis is a comment left during a code review.
Path: extensions/claude-headless/index.ts
Line: 22-118
Comment:
**Duplicated shared CLI logic violates extension boundary policy**
`normalizePermissionArgs` (lines 78–110), `MODEL_ALIASES` (lines 22–42), `SESSION_ID_FIELDS` (line 44), and `CLEAR_ENV` (line 48) are all verbatim copies of the identically-named constants already exported from `extensions/anthropic/cli-shared.ts`. The extension boundary guide in `extensions/CLAUDE.md` explicitly states: _"If two bundled providers share the same replay policy shape… stop copying the logic. Extract one shared helper and migrate both call sites in the same change."_ Cross-extension imports are prohibited, so the correct fix is to promote these to a public `openclaw/plugin-sdk` subpath (e.g. `openclaw/plugin-sdk/cli-backend`) and import from there in both extensions. As-is, any bug fix to `normalizeClaudePermissionArgs` in the anthropic extension silently leaves this extension diverged.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: extensions/claude-headless/index.ts
Line: 39-42
Comment:
**`claude-haiku-4-5` missing from `MODEL_ALIASES`**
The wizard `modelAllowlist.allowedKeys` includes `"claude-headless/claude-haiku-4-5"`, but `MODEL_ALIASES` has no entry for `haiku-4-5` or `claude-haiku-4-5` (only `haiku-3.5` / `claude-haiku-3-5`). After stripping the provider prefix, the alias lookup silently returns `undefined` and the CLI receives `--model claude-haiku-4-5` verbatim instead of the `haiku` shorthand. Add the missing entries:
```suggestion
haiku: "haiku",
"haiku-3.5": "haiku",
"haiku-4.5": "haiku",
"claude-haiku-3-5": "haiku",
"claude-haiku-4-5": "haiku",
```
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "Merge branch 'openclaw:main' into main" | Re-trigger Greptile |
| const MODEL_ALIASES: Record<string, string> = { | ||
| opus: "opus", | ||
| "opus-4.6": "opus", | ||
| "opus-4.5": "opus", | ||
| "opus-4": "opus", | ||
| "claude-opus-4-6": "opus", | ||
| "claude-opus-4-5": "opus", | ||
| "claude-opus-4": "opus", | ||
| sonnet: "sonnet", | ||
| "sonnet-4.6": "sonnet", | ||
| "sonnet-4.5": "sonnet", | ||
| "sonnet-4.1": "sonnet", | ||
| "sonnet-4.0": "sonnet", | ||
| "claude-sonnet-4-6": "sonnet", | ||
| "claude-sonnet-4-5": "sonnet", | ||
| "claude-sonnet-4-1": "sonnet", | ||
| "claude-sonnet-4-0": "sonnet", | ||
| haiku: "haiku", | ||
| "haiku-3.5": "haiku", | ||
| "claude-haiku-3-5": "haiku", | ||
| }; | ||
|
|
||
| const SESSION_ID_FIELDS = ["session_id", "sessionId", "conversation_id", "conversationId"] as const; | ||
|
|
||
| // Env vars to clear so the local Claude CLI uses its own stored credentials, | ||
| // not a direct API key that might route to a different account. | ||
| const CLEAR_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY_OLD"] as const; | ||
|
|
||
| // Headless mode args: -p (print/headless), stream-json output, and | ||
| // bypassPermissions replaces the legacy --dangerously-skip-permissions flag. | ||
| const HEADLESS_ARGS = [ | ||
| "-p", | ||
| "--output-format", | ||
| "stream-json", | ||
| "--include-partial-messages", | ||
| "--verbose", | ||
| "--permission-mode", | ||
| "bypassPermissions", | ||
| ] as const; | ||
|
|
||
| const HEADLESS_RESUME_ARGS = [ | ||
| "-p", | ||
| "--output-format", | ||
| "stream-json", | ||
| "--include-partial-messages", | ||
| "--verbose", | ||
| "--permission-mode", | ||
| "bypassPermissions", | ||
| "--resume", | ||
| "{sessionId}", | ||
| ] as const; | ||
|
|
||
| /** | ||
| * Normalizes legacy --dangerously-skip-permissions to the modern | ||
| * --permission-mode bypassPermissions in user-supplied config overrides. | ||
| */ | ||
| function normalizePermissionArgs(args?: string[]): string[] | undefined { | ||
| if (!args) { | ||
| return args; | ||
| } | ||
| const normalized: string[] = []; | ||
| let sawLegacySkip = false; | ||
| let hasPermissionMode = false; | ||
| for (let i = 0; i < args.length; i += 1) { | ||
| const arg = args[i]; | ||
| if (arg === "--dangerously-skip-permissions") { | ||
| sawLegacySkip = true; | ||
| continue; | ||
| } | ||
| if (arg === "--permission-mode") { | ||
| hasPermissionMode = true; | ||
| normalized.push(arg); | ||
| const maybeValue = args[i + 1]; | ||
| if (typeof maybeValue === "string") { | ||
| normalized.push(maybeValue); | ||
| i += 1; | ||
| } | ||
| continue; | ||
| } | ||
| if (arg.startsWith("--permission-mode=")) { | ||
| hasPermissionMode = true; | ||
| } | ||
| normalized.push(arg); | ||
| } | ||
| if (sawLegacySkip && !hasPermissionMode) { | ||
| normalized.push("--permission-mode", "bypassPermissions"); | ||
| } | ||
| return normalized; | ||
| } | ||
|
|
||
| function normalizeBackendConfig(config: CliBackendConfig): CliBackendConfig { | ||
| return { | ||
| ...config, | ||
| args: normalizePermissionArgs(config.args), | ||
| resumeArgs: normalizePermissionArgs(config.resumeArgs), | ||
| }; | ||
| } |
There was a problem hiding this comment.
Duplicated shared CLI logic violates extension boundary policy
normalizePermissionArgs (lines 78–110), MODEL_ALIASES (lines 22–42), SESSION_ID_FIELDS (line 44), and CLEAR_ENV (line 48) are all verbatim copies of the identically-named constants already exported from extensions/anthropic/cli-shared.ts. The extension boundary guide in extensions/CLAUDE.md explicitly states: "If two bundled providers share the same replay policy shape… stop copying the logic. Extract one shared helper and migrate both call sites in the same change." Cross-extension imports are prohibited, so the correct fix is to promote these to a public openclaw/plugin-sdk subpath (e.g. openclaw/plugin-sdk/cli-backend) and import from there in both extensions. As-is, any bug fix to normalizeClaudePermissionArgs in the anthropic extension silently leaves this extension diverged.
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/claude-headless/index.ts
Line: 22-118
Comment:
**Duplicated shared CLI logic violates extension boundary policy**
`normalizePermissionArgs` (lines 78–110), `MODEL_ALIASES` (lines 22–42), `SESSION_ID_FIELDS` (line 44), and `CLEAR_ENV` (line 48) are all verbatim copies of the identically-named constants already exported from `extensions/anthropic/cli-shared.ts`. The extension boundary guide in `extensions/CLAUDE.md` explicitly states: _"If two bundled providers share the same replay policy shape… stop copying the logic. Extract one shared helper and migrate both call sites in the same change."_ Cross-extension imports are prohibited, so the correct fix is to promote these to a public `openclaw/plugin-sdk` subpath (e.g. `openclaw/plugin-sdk/cli-backend`) and import from there in both extensions. As-is, any bug fix to `normalizeClaudePermissionArgs` in the anthropic extension silently leaves this extension diverged.
How can I resolve this? If you propose a fix, please make it concise.| haiku: "haiku", | ||
| "haiku-3.5": "haiku", | ||
| "claude-haiku-3-5": "haiku", | ||
| }; |
There was a problem hiding this comment.
claude-haiku-4-5 missing from MODEL_ALIASES
The wizard modelAllowlist.allowedKeys includes "claude-headless/claude-haiku-4-5", but MODEL_ALIASES has no entry for haiku-4-5 or claude-haiku-4-5 (only haiku-3.5 / claude-haiku-3-5). After stripping the provider prefix, the alias lookup silently returns undefined and the CLI receives --model claude-haiku-4-5 verbatim instead of the haiku shorthand. Add the missing entries:
| haiku: "haiku", | |
| "haiku-3.5": "haiku", | |
| "claude-haiku-3-5": "haiku", | |
| }; | |
| haiku: "haiku", | |
| "haiku-3.5": "haiku", | |
| "haiku-4.5": "haiku", | |
| "claude-haiku-3-5": "haiku", | |
| "claude-haiku-4-5": "haiku", |
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/claude-headless/index.ts
Line: 39-42
Comment:
**`claude-haiku-4-5` missing from `MODEL_ALIASES`**
The wizard `modelAllowlist.allowedKeys` includes `"claude-headless/claude-haiku-4-5"`, but `MODEL_ALIASES` has no entry for `haiku-4-5` or `claude-haiku-4-5` (only `haiku-3.5` / `claude-haiku-3-5`). After stripping the provider prefix, the alias lookup silently returns `undefined` and the CLI receives `--model claude-haiku-4-5` verbatim instead of the `haiku` shorthand. Add the missing entries:
```suggestion
haiku: "haiku",
"haiku-3.5": "haiku",
"haiku-4.5": "haiku",
"claude-haiku-3-5": "haiku",
"claude-haiku-4-5": "haiku",
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a84e411b84
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| import type { ProviderAuthResult } from "openclaw/plugin-sdk/provider-auth"; | ||
|
|
||
| const PROVIDER_ID = "claude-headless"; | ||
| const BACKEND_ID = "claude-headless"; |
There was a problem hiding this comment.
Hook claude-headless into Claude MCP loopback path
The CLI runner only injects the OpenClaw MCP loopback server and OPENCLAW_MCP_* env vars when backendResolved.id === "claude-cli" (see src/agents/cli-runner/prepare.ts), but this plugin registers a new backend id (claude-headless). As a result, selecting this provider skips the Claude-specific loopback wiring even though bundleMcp is enabled, so headless runs miss the OpenClaw MCP tool bridge that existing Claude CLI flows depend on.
Useful? React with 👍 / 👎.
|
Thanks for your contribution. The user need here is real: if someone already has a local We’re going to keep pushing that path inside the existing native Claude CLI integration rather than land a second standalone provider that duplicates the backend/auth logic. We verified this against the Claude Code source as well. As proposed, this PR would miss hardening we now require for host-managed Claude CLI runs, including broader env scrubbing, config/plugin-root isolation, and forcing We’ve also already addressed adjacent native-path issues this PR would otherwise drift from: safer auth/config patch application, correct migrated model/default replacement behavior, fallback rewrites for Claude CLI migrations, malformed permission flag fail-safe behavior, and Claude CLI image/KV stability fixes. So we’re closing this in favor of the existing native path rather than carrying a second provider that will diverge. The useful part to keep from this PR is the UX intent: making the local Claude CLI path clearer and easier to choose. Once the native follow-up is landed, we can add the landed PR link back here as the canonical path forward. |
|
thanks for your contribution. follow-up landed in #61276 via 3b84884. that native Claude CLI path now covers the user need this PR was chasing, but inside the existing hardened core flow instead of a second standalone provider: Anthropic CLI auth/default-model migration fixes, provider-owned auth patch replacement, non-interactive fallback migration, inherited Claude env/config/plugin-root scrubbing, forced we still like the UX intent here: making the local Claude CLI path obvious and easy to choose. |
Summary
Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
Root Cause (if applicable)
N/A — this is a new feature, not a bug fix.
Regression Test Plan (if applicable)
N/A — new plugin with no existing coverage to regress.
User-visible / Behavior Changes
Diagram (if applicable)
For UI changes or non-trivial logic flows, include a small ASCII diagram reviewers can scan quickly. Otherwise write
N/A.Security Impact (required)
Repro + Verification
Environment
Steps
Expected
Actual
Evidence
Attach at least one:
Human Verification (required)
What you personally verified (not just CI), and how:
Review Conversations
If a bot review conversation is addressed by this PR, resolve that conversation yourself. Do not leave bot review conversation cleanup for maintainers.
Compatibility / Migration
Yes) opt-in only, no existing behavior changed.No)No)Risks and Mitigations
List only real risks for this PR. Add/remove entries as needed. If none, write
None.