fix: align claude cli permissions with exec policy

Derive Claude CLI bypass mode from OpenClaw exec YOLO policy, preserve raw Claude permission-mode overrides, update docs/changelog, and cover global/per-agent policy behavior.
This commit is contained in:
Peter Steinberger
2026-04-23 23:11:34 +01:00
committed by GitHub
parent 999caf530b
commit f523bbfcd1
19 changed files with 352 additions and 56 deletions

View File

@@ -26,7 +26,7 @@ Docs: https://docs.openclaw.ai
- Plugins/install: link the host OpenClaw package into external plugins that declare `openclaw` as a peer dependency, so peer-only plugin SDK imports resolve after install without bundling a duplicate host package. (#70462) Thanks @anishesg.
- Teams/security: require shared Bot Framework audience tokens to name the configured Teams app via verified `appid` or `azp`, blocking cross-bot token replay on the global audience. (#70724) Thanks @vincentkoc.
- Plugins/startup: resolve bundled plugin Jiti loads relative to the target plugin module instead of the central loader, so Bun global installs no longer hang while discovering bundled image providers. (#70073) Thanks @yidianyiko.
- Anthropic/CLI security: stop Claude CLI backend defaults from forcing `bypassPermissions`, and strip malformed permission-mode overrides instead of silently falling back to a bypass. (#70723) Thanks @vincentkoc.
- Anthropic/CLI security: derive Claude CLI `bypassPermissions` from OpenClaw's existing YOLO exec policy, preserve explicit raw Claude `--permission-mode` overrides, and strip malformed permission-mode args instead of silently falling back to a bypass. (#70723) Thanks @vincentkoc.
- Android/security: require loopback-only cleartext gateway connections on Android manual and scanned routes, so private-LAN and link-local `ws://` endpoints now fail closed unless TLS is enabled. (#70722) Thanks @vincentkoc.
- Pairing/security: require private-IP or loopback hosts for cleartext mobile pairing, and stop treating `.local` or dotless hostnames as safe cleartext endpoints. (#70721) Thanks @vincentkoc.
- Plugins/security: stop setup-api lookup from falling back to the launch directory, so workspace-local `extensions/<plugin>/setup-api.*` files cannot be executed during provider setup resolution. (#70718) Thanks @drobison00.

View File

@@ -1,2 +1,2 @@
bc55649a80027756f37892424598653a81fec4bff7b074358fe34d08c7696ebc plugin-sdk-api-baseline.json
312a29d50b4959e4a8e242bb7559548d895a2e03d5ed1b5a395b1133de090578 plugin-sdk-api-baseline.jsonl
f30c9e61b768ca10feca401aefca3cbc8d3a57c5020f85aa9106b4f1a61032c0 plugin-sdk-api-baseline.json
9e5e3e66ac23dddb80cceb8a785f167eec8a108c6c5abe77f3346b01895f6756 plugin-sdk-api-baseline.jsonl

View File

@@ -169,6 +169,15 @@ resolver sees the same filtered set that OpenClaw would otherwise advertise in
the prompt. Skill env/API key overrides are still applied by OpenClaw to the
child process environment for the run.
Claude CLI also has its own noninteractive permission mode. OpenClaw maps that
to the existing exec policy instead of adding Claude-specific config: when the
effective requested exec policy is YOLO (`tools.exec.security: "full"` and
`tools.exec.ask: "off"`), OpenClaw adds `--permission-mode bypassPermissions`.
Per-agent `agents.list[].tools.exec` settings override global `tools.exec` for
that agent. To force a different Claude mode, set explicit raw backend args
such as `--permission-mode default` or `--permission-mode acceptEdits` under
`agents.defaults.cliBackends.claude-cli.args` and matching `resumeArgs`.
Before OpenClaw can use the bundled `claude-cli` backend, Claude Code itself
must already be logged in on the same host:

View File

@@ -12,7 +12,7 @@ Cloudflare AI Gateway sits in front of provider APIs and lets you add analytics,
| ------------- | ---------------------------------------------------------------------------------------- |
| Provider | `cloudflare-ai-gateway` |
| Base URL | `https://gateway.ai.cloudflare.com/v1/<account_id>/<gateway_id>/anthropic` |
| Default model | `cloudflare-ai-gateway/claude-sonnet-4-5` |
| Default model | `cloudflare-ai-gateway/claude-sonnet-4-6` |
| API key | `CLOUDFLARE_AI_GATEWAY_API_KEY` (your provider API key for requests through the Gateway) |
<Note>
@@ -39,7 +39,7 @@ For Anthropic models routed through Cloudflare AI Gateway, use your **Anthropic
{
agents: {
defaults: {
model: { primary: "cloudflare-ai-gateway/claude-sonnet-4-5" },
model: { primary: "cloudflare-ai-gateway/claude-sonnet-4-6" },
},
},
}

View File

@@ -125,6 +125,11 @@ Important distinction:
- `tools.exec.host=auto` chooses where exec runs: sandbox when available, otherwise gateway.
- YOLO chooses how host exec is approved: `security=full` plus `ask=off`.
- CLI-backed providers that expose their own noninteractive permission mode can follow this policy.
Claude CLI adds `--permission-mode bypassPermissions` when OpenClaw's requested exec policy is
YOLO. Override that backend behavior with explicit Claude args under
`agents.defaults.cliBackends.claude-cli.args` / `resumeArgs`, for example
`--permission-mode default`, `acceptEdits`, or `bypassPermissions`.
- In YOLO mode, OpenClaw does not add a separate heuristic command-obfuscation approval gate or script-preflight rejection layer on top of the configured host exec policy.
- `auto` does not make gateway routing a free override from a sandboxed session. A per-call `host=node` request is allowed from `auto`, and `host=gateway` is only allowed from `auto` when no sandbox runtime is active. If you want a stable non-auto default, set `tools.exec.host` or use `/exec host=...` explicitly.

View File

@@ -36,6 +36,8 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
"--verbose",
"--setting-sources",
"user",
"--allowedTools",
"mcp__openclaw__*",
],
resumeArgs: [
"-p",
@@ -45,6 +47,8 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
"--verbose",
"--setting-sources",
"user",
"--allowedTools",
"mcp__openclaw__*",
"--resume",
"{sessionId}",
],
@@ -53,6 +57,8 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
input: "stdin",
modelArg: "--model",
modelAliases: CLAUDE_CLI_MODEL_ALIASES,
imageArg: "@",
imagePathScope: "workspace",
sessionArg: "--session-id",
sessionMode: "always",
sessionIdFields: [...CLAUDE_CLI_SESSION_ID_FIELDS],

View File

@@ -5,6 +5,7 @@ import {
normalizeClaudeBackendConfig,
normalizeClaudePermissionArgs,
normalizeClaudeSettingSourcesArgs,
resolveClaudePermissionMode,
} from "./cli-shared.js";
describe("normalizeClaudePermissionArgs", () => {
@@ -89,6 +90,8 @@ describe("normalizeClaudeBackendConfig", () => {
"--verbose",
"--setting-sources",
"user",
"--permission-mode",
"bypassPermissions",
]);
expect(normalized.resumeArgs).toEqual([
"-p",
@@ -99,12 +102,67 @@ describe("normalizeClaudeBackendConfig", () => {
"{sessionId}",
"--setting-sources",
"user",
"--permission-mode",
"bypassPermissions",
]);
expect(normalized.output).toBe("jsonl");
expect(normalized.liveSession).toBe("claude-stdio");
expect(normalized.input).toBe("stdin");
});
it("derives Claude bypass from OpenClaw YOLO policy and disables it for safer policy", () => {
expect(resolveClaudePermissionMode({ backendId: "claude-cli" })).toEqual({
mode: "bypassPermissions",
overrideExisting: false,
});
expect(
resolveClaudePermissionMode({
backendId: "claude-cli",
config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } },
}),
).toEqual({ overrideExisting: false });
});
it("derives Claude bypass from per-agent OpenClaw exec policy", () => {
expect(
resolveClaudePermissionMode({
backendId: "claude-cli",
agentId: "safe-agent",
config: {
tools: { exec: { security: "full", ask: "off" } },
agents: {
list: [
{
id: "safe-agent",
tools: { exec: { security: "allowlist", ask: "on-miss" } },
},
],
},
},
}),
).toEqual({ overrideExisting: false });
expect(
resolveClaudePermissionMode({
backendId: "claude-cli",
agentId: "yolo-agent",
config: {
tools: { exec: { security: "allowlist", ask: "on-miss" } },
agents: {
list: [
{
id: "yolo-agent",
tools: { exec: { security: "full", ask: "off" } },
},
],
},
},
}),
).toEqual({
mode: "bypassPermissions",
overrideExisting: false,
});
});
it("does not infer live stdio when explicit transport overrides are incompatible", () => {
const normalized = normalizeClaudeBackendConfig({
command: "claude",
@@ -131,8 +189,12 @@ describe("normalizeClaudeBackendConfig", () => {
expect(normalized?.args).toContain("--setting-sources");
expect(normalized?.args).toContain("user");
expect(normalized?.args).toContain("--permission-mode");
expect(normalized?.args).toContain("bypassPermissions");
expect(normalized?.resumeArgs).toContain("--setting-sources");
expect(normalized?.resumeArgs).toContain("user");
expect(normalized?.resumeArgs).toContain("--permission-mode");
expect(normalized?.resumeArgs).toContain("bypassPermissions");
expect(normalized?.liveSession).toBe("claude-stdio");
});

View File

@@ -1,4 +1,7 @@
import type { CliBackendConfig } from "openclaw/plugin-sdk/cli-backend";
import type {
CliBackendConfig,
CliBackendNormalizeConfigContext,
} from "openclaw/plugin-sdk/cli-backend";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
import { CLAUDE_CLI_BACKEND_ID } from "./cli-constants.js";
export {
@@ -58,16 +61,40 @@ const CLAUDE_LEGACY_SKIP_PERMISSIONS_ARG = "--dangerously-skip-permissions";
const CLAUDE_PERMISSION_MODE_ARG = "--permission-mode";
const CLAUDE_SETTING_SOURCES_ARG = "--setting-sources";
const CLAUDE_SAFE_SETTING_SOURCES = "user";
const CLAUDE_BYPASS_PERMISSION_MODE = "bypassPermissions";
export function isClaudeCliProvider(providerId: string): boolean {
return normalizeOptionalLowercaseString(providerId) === CLAUDE_CLI_BACKEND_ID;
}
export function normalizeClaudePermissionArgs(args?: string[]): string[] | undefined {
function isOpenClawRequestedYolo(context?: CliBackendNormalizeConfigContext): boolean {
const agentExec = context?.agentId
? context.config?.agents?.list?.find((agent) => agent.id === context.agentId)?.tools?.exec
: undefined;
const exec = agentExec ?? context?.config?.tools?.exec;
const security = exec?.security ?? "full";
const ask = exec?.ask ?? "off";
return security === "full" && ask === "off";
}
export function resolveClaudePermissionMode(context?: CliBackendNormalizeConfigContext): {
mode?: string;
overrideExisting: boolean;
} {
return isOpenClawRequestedYolo(context)
? { mode: CLAUDE_BYPASS_PERMISSION_MODE, overrideExisting: false }
: { overrideExisting: false };
}
export function normalizeClaudePermissionArgs(
args?: string[],
options?: { mode?: string; overrideExisting?: boolean },
): string[] | undefined {
if (!args) {
return args;
return options?.mode ? [CLAUDE_PERMISSION_MODE_ARG, options.mode] : args;
}
const normalized: string[] = [];
let hasPermissionMode = false;
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === CLAUDE_LEGACY_SKIP_PERMISSIONS_ARG) {
@@ -80,8 +107,11 @@ export function normalizeClaudePermissionArgs(args?: string[]): string[] | undef
maybeValue.trim().length > 0 &&
!maybeValue.startsWith("-")
) {
normalized.push(arg);
normalized.push(maybeValue);
hasPermissionMode = true;
if (!options?.overrideExisting) {
normalized.push(arg);
normalized.push(maybeValue);
}
i += 1;
}
continue;
@@ -89,12 +119,18 @@ export function normalizeClaudePermissionArgs(args?: string[]): string[] | undef
if (arg.startsWith(`${CLAUDE_PERMISSION_MODE_ARG}=`)) {
const maybeValue = arg.slice(`${CLAUDE_PERMISSION_MODE_ARG}=`.length).trim();
if (maybeValue.length > 0 && !maybeValue.startsWith("-")) {
normalized.push(`${CLAUDE_PERMISSION_MODE_ARG}=${maybeValue}`);
hasPermissionMode = true;
if (!options?.overrideExisting) {
normalized.push(`${CLAUDE_PERMISSION_MODE_ARG}=${maybeValue}`);
}
}
continue;
}
normalized.push(arg);
}
if (options?.mode && (!hasPermissionMode || options.overrideExisting)) {
normalized.push(CLAUDE_PERMISSION_MODE_ARG, options.mode);
}
return normalized;
}
@@ -132,13 +168,20 @@ export function normalizeClaudeSettingSourcesArgs(args?: string[]): string[] | u
return normalized;
}
export function normalizeClaudeBackendConfig(config: CliBackendConfig): CliBackendConfig {
export function normalizeClaudeBackendConfig(
config: CliBackendConfig,
context?: CliBackendNormalizeConfigContext,
): CliBackendConfig {
const output = config.output ?? "jsonl";
const input = config.input ?? "stdin";
const permission = resolveClaudePermissionMode(context);
return {
...config,
args: normalizeClaudePermissionArgs(normalizeClaudeSettingSourcesArgs(config.args)),
resumeArgs: normalizeClaudePermissionArgs(normalizeClaudeSettingSourcesArgs(config.resumeArgs)),
args: normalizeClaudePermissionArgs(normalizeClaudeSettingSourcesArgs(config.args), permission),
resumeArgs: normalizeClaudePermissionArgs(
normalizeClaudeSettingSourcesArgs(config.resumeArgs),
permission,
),
output,
liveSession:
config.liveSession ?? (output === "jsonl" && input === "stdin" ? "claude-stdio" : undefined),

View File

@@ -1,7 +1,7 @@
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
export const CLOUDFLARE_AI_GATEWAY_PROVIDER_ID = "cloudflare-ai-gateway";
export const CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_ID = "claude-sonnet-4-5";
export const CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_ID = "claude-sonnet-4-6";
export const CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF = `${CLOUDFLARE_AI_GATEWAY_PROVIDER_ID}/${CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_ID}`;
const CLOUDFLARE_AI_GATEWAY_DEFAULT_CONTEXT_WINDOW = 200_000;
@@ -22,7 +22,7 @@ export function buildCloudflareAiGatewayModelDefinition(params?: {
const id = params?.id?.trim() || CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_ID;
return {
id,
name: params?.name ?? "Claude Sonnet 4.5",
name: params?.name ?? "Claude Sonnet 4.6",
reasoning: params?.reasoning ?? true,
input: params?.input ?? ["text", "image"],
cost: CLOUDFLARE_AI_GATEWAY_DEFAULT_COST,

View File

@@ -6,8 +6,8 @@ describe("qa runner model catalog", () => {
expect(
selectQaRunnerModelOptions([
{
key: "anthropic/claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
key: "anthropic/claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
input: "text",
available: true,
missing: false,
@@ -27,6 +27,6 @@ describe("qa runner model catalog", () => {
missing: false,
},
]).map((entry) => entry.key),
).toEqual(["openai/gpt-5.5", "anthropic/claude-sonnet-4-5"]);
).toEqual(["openai/gpt-5.5", "anthropic/claude-sonnet-4-6"]);
});
});

View File

@@ -1,7 +1,11 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { CliBackendConfig } from "../config/types.js";
import type { CliBackendAuthEpochMode, CliBundleMcpMode } from "../plugins/types.js";
import type {
CliBackendAuthEpochMode,
CliBackendNormalizeConfigContext,
CliBundleMcpMode,
} from "../plugins/types.js";
import {
__testing as cliBackendsTesting,
resolveCliBackendConfig,
@@ -27,7 +31,10 @@ function createBackendEntry(params: {
defaultAuthProfileId?: string;
authEpochMode?: CliBackendAuthEpochMode;
prepareExecution?: () => Promise<null>;
normalizeConfig?: (config: CliBackendConfig) => CliBackendConfig;
normalizeConfig?: (
config: CliBackendConfig,
context?: CliBackendNormalizeConfigContext,
) => CliBackendConfig;
}) {
return {
pluginId: params.pluginId,
@@ -111,12 +118,33 @@ const NORMALIZED_CLAUDE_FALLBACK_RESUME_ARGS = [
"user",
];
function normalizeTestClaudeArgs(args?: string[]): string[] | undefined {
function isTestYoloConfig(context?: CliBackendNormalizeConfigContext): boolean {
const agentExec = context?.agentId
? context.config?.agents?.list?.find((agent) => agent.id === context.agentId)?.tools?.exec
: undefined;
const exec = agentExec ?? context?.config?.tools?.exec;
return (exec?.security ?? "full") === "full" && (exec?.ask ?? "off") === "off";
}
function normalizeTestPermissionMode(context?: CliBackendNormalizeConfigContext): {
mode?: string;
overrideExisting: boolean;
} {
return isTestYoloConfig(context)
? { mode: "bypassPermissions", overrideExisting: false }
: { overrideExisting: false };
}
function normalizeTestClaudeArgs(
args: string[] | undefined,
permission: { mode?: string; overrideExisting: boolean },
): string[] | undefined {
if (!args) {
return args;
return permission.mode ? ["--permission-mode", permission.mode] : args;
}
const normalized: string[] = [];
let hasSettingSources = false;
let hasPermissionMode = false;
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--dangerously-skip-permissions") {
@@ -139,7 +167,10 @@ function normalizeTestClaudeArgs(args?: string[]): string[] | undefined {
if (arg === "--permission-mode") {
const maybeValue = args[i + 1];
if (maybeValue && !maybeValue.startsWith("-")) {
normalized.push(arg, maybeValue);
hasPermissionMode = true;
if (!permission.overrideExisting) {
normalized.push(arg, maybeValue);
}
i += 1;
}
continue;
@@ -147,7 +178,10 @@ function normalizeTestClaudeArgs(args?: string[]): string[] | undefined {
if (arg.startsWith("--permission-mode=")) {
const maybeValue = arg.slice("--permission-mode=".length).trim();
if (maybeValue.length > 0 && !maybeValue.startsWith("-")) {
normalized.push(`--permission-mode=${maybeValue}`);
hasPermissionMode = true;
if (!permission.overrideExisting) {
normalized.push(`--permission-mode=${maybeValue}`);
}
}
continue;
}
@@ -156,14 +190,21 @@ function normalizeTestClaudeArgs(args?: string[]): string[] | undefined {
if (!hasSettingSources) {
normalized.push("--setting-sources", "user");
}
if (permission.mode && (!hasPermissionMode || permission.overrideExisting)) {
normalized.push("--permission-mode", permission.mode);
}
return normalized;
}
function normalizeTestClaudeBackendConfig(config: CliBackendConfig): CliBackendConfig {
function normalizeTestClaudeBackendConfig(
config: CliBackendConfig,
context?: CliBackendNormalizeConfigContext,
): CliBackendConfig {
const permission = normalizeTestPermissionMode(context);
return {
...config,
args: normalizeTestClaudeArgs(config.args),
resumeArgs: normalizeTestClaudeArgs(config.resumeArgs),
args: normalizeTestClaudeArgs(config.args, permission),
resumeArgs: normalizeTestClaudeArgs(config.resumeArgs, permission),
};
}
@@ -186,6 +227,8 @@ beforeEach(() => {
"--verbose",
"--setting-sources",
"user",
"--allowedTools",
"mcp__openclaw__*",
],
resumeArgs: [
"stream-json",
@@ -193,11 +236,15 @@ beforeEach(() => {
"--verbose",
"--setting-sources",
"user",
"--allowedTools",
"mcp__openclaw__*",
"--resume",
"{sessionId}",
],
output: "jsonl",
input: "stdin",
imageArg: "@",
imagePathScope: "workspace",
clearEnv: [
"ANTHROPIC_API_KEY",
"ANTHROPIC_API_KEY_OLD",
@@ -410,7 +457,7 @@ describe("resolveCliBackendLiveTest", () => {
});
describe("resolveCliBackendConfig claude-cli defaults", () => {
it("keeps user-only setting sources without forcing a permission-mode default", () => {
it("derives bypassPermissions from OpenClaw's default YOLO exec policy", () => {
const resolved = resolveCliBackendConfig("claude-cli");
expect(resolved).not.toBeNull();
@@ -422,18 +469,102 @@ describe("resolveCliBackendConfig claude-cli defaults", () => {
expect(resolved?.config.args).toContain("--verbose");
expect(resolved?.config.args).toContain("--setting-sources");
expect(resolved?.config.args).toContain("user");
expect(resolved?.config.args).not.toContain("--permission-mode");
expect(resolved?.config.args).toContain("--allowedTools");
expect(resolved?.config.args).toContain("mcp__openclaw__*");
expect(resolved?.config.args).toContain("--permission-mode");
expect(resolved?.config.args).toContain("bypassPermissions");
expect(resolved?.config.args).not.toContain("--dangerously-skip-permissions");
expect(resolved?.config.input).toBe("stdin");
expect(resolved?.config.imageArg).toBe("@");
expect(resolved?.config.imagePathScope).toBe("workspace");
expect(resolved?.config.resumeArgs).toContain("stream-json");
expect(resolved?.config.resumeArgs).toContain("--include-partial-messages");
expect(resolved?.config.resumeArgs).toContain("--verbose");
expect(resolved?.config.resumeArgs).toContain("--setting-sources");
expect(resolved?.config.resumeArgs).toContain("user");
expect(resolved?.config.resumeArgs).not.toContain("--permission-mode");
expect(resolved?.config.resumeArgs).toContain("--allowedTools");
expect(resolved?.config.resumeArgs).toContain("mcp__openclaw__*");
expect(resolved?.config.resumeArgs).toContain("--permission-mode");
expect(resolved?.config.resumeArgs).toContain("bypassPermissions");
expect(resolved?.config.resumeArgs).not.toContain("--dangerously-skip-permissions");
});
it("keeps Claude permission mode unset when OpenClaw exec policy is not YOLO", () => {
const resolved = resolveCliBackendConfig("claude-cli", {
tools: { exec: { security: "allowlist", ask: "on-miss" } },
});
expect(resolved).not.toBeNull();
expect(resolved?.config.args).not.toContain("--permission-mode");
expect(resolved?.config.args).not.toContain("bypassPermissions");
expect(resolved?.config.resumeArgs).not.toContain("--permission-mode");
expect(resolved?.config.resumeArgs).not.toContain("bypassPermissions");
});
it("derives Claude permission mode from per-agent exec policy when an agent id is known", () => {
const cfg = {
tools: { exec: { security: "full", ask: "off" } },
agents: {
list: [
{
id: "reviewer",
tools: { exec: { security: "allowlist", ask: "on-miss" } },
},
{
id: "builder",
tools: { exec: { security: "full", ask: "off" } },
},
],
},
} satisfies OpenClawConfig;
const reviewer = resolveCliBackendConfig("claude-cli", cfg, { agentId: "reviewer" });
const builder = resolveCliBackendConfig("claude-cli", cfg, { agentId: "builder" });
expect(reviewer?.config.args).not.toContain("--permission-mode");
expect(reviewer?.config.resumeArgs).not.toContain("--permission-mode");
expect(builder?.config.args).toContain("--permission-mode");
expect(builder?.config.args).toContain("bypassPermissions");
expect(builder?.config.resumeArgs).toContain("--permission-mode");
expect(builder?.config.resumeArgs).toContain("bypassPermissions");
});
it("uses existing exec policy and raw Claude args as permission overrides", () => {
const safe = resolveCliBackendConfig("claude-cli", {
tools: { exec: { security: "full", ask: "off" } },
agents: {
defaults: {
cliBackends: {
"claude-cli": {
command: "claude",
args: ["-p", "--permission-mode", "default"],
resumeArgs: ["-p", "--permission-mode=default", "--resume", "{sessionId}"],
},
},
},
},
});
const yolo = resolveCliBackendConfig("claude-cli", {
tools: { exec: { security: "deny", ask: "always" } },
agents: {
defaults: {
cliBackends: {
"claude-cli": {
command: "claude",
args: ["-p", "--permission-mode", "bypassPermissions"],
resumeArgs: ["-p", "--permission-mode=bypassPermissions", "--resume", "{sessionId}"],
},
},
},
},
});
expect(safe?.config.args).toContain("default");
expect(safe?.config.args).not.toContain("bypassPermissions");
expect(yolo?.config.args).toContain("--permission-mode");
expect(yolo?.config.args).toContain("bypassPermissions");
});
it("retains default claude safety args when only command is overridden", () => {
const cfg = {
agents: {
@@ -453,10 +584,12 @@ describe("resolveCliBackendConfig claude-cli defaults", () => {
expect(resolved?.config.command).toBe("/usr/local/bin/claude");
expect(resolved?.config.args).toContain("--setting-sources");
expect(resolved?.config.args).toContain("user");
expect(resolved?.config.args).not.toContain("--permission-mode");
expect(resolved?.config.args).toContain("--permission-mode");
expect(resolved?.config.args).toContain("bypassPermissions");
expect(resolved?.config.resumeArgs).toContain("--setting-sources");
expect(resolved?.config.resumeArgs).toContain("user");
expect(resolved?.config.resumeArgs).not.toContain("--permission-mode");
expect(resolved?.config.resumeArgs).toContain("--permission-mode");
expect(resolved?.config.resumeArgs).toContain("bypassPermissions");
expect(resolved?.config.env).not.toHaveProperty("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST");
expect(resolved?.config.clearEnv).toContain("ANTHROPIC_API_TOKEN");
expect(resolved?.config.clearEnv).toContain("ANTHROPIC_BASE_URL");
@@ -470,7 +603,7 @@ describe("resolveCliBackendConfig claude-cli defaults", () => {
expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_USE_COWORK_PLUGINS");
});
it("drops legacy skip-permissions overrides without inventing bypassPermissions", () => {
it("drops legacy skip-permissions overrides without inventing bypassPermissions under safe policy", () => {
const cfg = {
agents: {
defaults: {
@@ -490,6 +623,7 @@ describe("resolveCliBackendConfig claude-cli defaults", () => {
},
},
},
tools: { exec: { security: "allowlist", ask: "on-miss" } },
} satisfies OpenClawConfig;
const resolved = resolveCliBackendConfig("claude-cli", cfg);
@@ -587,11 +721,14 @@ describe("resolveCliBackendConfig claude-cli defaults", () => {
});
it("falls back to user-only setting sources when a custom override leaves the flag without a value", () => {
const cfg = createClaudeCliOverrideConfig({
command: "claude",
args: ["-p", "--setting-sources", "--output-format", "stream-json"],
resumeArgs: ["-p", "--setting-sources", "--resume", "{sessionId}"],
});
const cfg = {
...createClaudeCliOverrideConfig({
command: "claude",
args: ["-p", "--setting-sources", "--output-format", "stream-json"],
resumeArgs: ["-p", "--setting-sources", "--resume", "{sessionId}"],
}),
tools: { exec: { security: "allowlist", ask: "on-miss" } },
} satisfies OpenClawConfig;
const resolved = resolveCliBackendConfig("claude-cli", cfg);
@@ -600,12 +737,15 @@ describe("resolveCliBackendConfig claude-cli defaults", () => {
expect(resolved?.config.resumeArgs).toEqual(NORMALIZED_CLAUDE_FALLBACK_RESUME_ARGS);
});
it("drops malformed permission-mode overrides without adding bypassPermissions", () => {
const cfg = createClaudeCliOverrideConfig({
command: "claude",
args: ["-p", "--permission-mode", "--output-format", "stream-json"],
resumeArgs: ["-p", "--permission-mode=--resume", "--resume", "{sessionId}"],
});
it("drops malformed permission-mode overrides without adding bypassPermissions under safe policy", () => {
const cfg = {
...createClaudeCliOverrideConfig({
command: "claude",
args: ["-p", "--permission-mode", "--output-format", "stream-json"],
resumeArgs: ["-p", "--permission-mode=--resume", "--resume", "{sessionId}"],
}),
tools: { exec: { security: "allowlist", ask: "on-miss" } },
} satisfies OpenClawConfig;
const resolved = resolveCliBackendConfig("claude-cli", cfg);
@@ -614,7 +754,7 @@ describe("resolveCliBackendConfig claude-cli defaults", () => {
expect(resolved?.config.resumeArgs).toEqual(NORMALIZED_CLAUDE_FALLBACK_RESUME_ARGS);
});
it("leaves permission-mode unset when custom args omit it", () => {
it("leaves permission-mode unset when custom args omit it under safe policy", () => {
const cfg = {
agents: {
defaults: {
@@ -634,6 +774,7 @@ describe("resolveCliBackendConfig claude-cli defaults", () => {
},
},
},
tools: { exec: { security: "allowlist", ask: "on-miss" } },
} satisfies OpenClawConfig;
const resolved = resolveCliBackendConfig("claude-cli", cfg);
@@ -711,6 +852,8 @@ describe("resolveCliBackendConfig claude-cli defaults", () => {
"json",
"--setting-sources",
"user",
"--permission-mode",
"bypassPermissions",
]);
expect(resolved?.config.resumeArgs).toEqual([
"-p",
@@ -720,6 +863,8 @@ describe("resolveCliBackendConfig claude-cli defaults", () => {
"{sessionId}",
"--setting-sources",
"user",
"--permission-mode",
"bypassPermissions",
]);
expect(resolved?.config.systemPromptArg).toBe("--append-system-prompt");
expect(resolved?.config.systemPromptWhen).toBe("first");

View File

@@ -5,6 +5,7 @@ import { resolvePluginSetupCliBackend } from "../plugins/setup-registry.js";
import { resolveRuntimeTextTransforms } from "../plugins/text-transforms.runtime.js";
import type {
CliBackendAuthEpochMode,
CliBackendNormalizeConfigContext,
CliBundleMcpMode,
CliBackendPlugin,
PluginTextTransforms,
@@ -50,7 +51,10 @@ type FallbackCliBackendPolicy = {
bundleMcp: boolean;
bundleMcpMode?: CliBundleMcpMode;
baseConfig?: CliBackendConfig;
normalizeConfig?: (config: CliBackendConfig) => CliBackendConfig;
normalizeConfig?: (
config: CliBackendConfig,
context?: CliBackendNormalizeConfigContext,
) => CliBackendConfig;
transformSystemPrompt?: CliBackendPlugin["transformSystemPrompt"];
textTransforms?: PluginTextTransforms;
defaultAuthProfileId?: string;
@@ -188,15 +192,23 @@ export function resolveCliBackendLiveTest(provider: string): ResolvedCliBackendL
export function resolveCliBackendConfig(
provider: string,
cfg?: OpenClawConfig,
options: { agentId?: string } = {},
): ResolvedCliBackend | null {
const normalized = normalizeBackendKey(provider);
const normalizeContext: CliBackendNormalizeConfigContext = {
backendId: normalized,
...(options.agentId ? { agentId: options.agentId } : {}),
...(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 config = registered.normalizeConfig ? registered.normalizeConfig(merged) : merged;
const config = registered.normalizeConfig
? registered.normalizeConfig(merged, normalizeContext)
: merged;
const command = config.command?.trim();
if (!command) {
return null;
@@ -224,7 +236,7 @@ export function resolveCliBackendConfig(
return null;
}
const baseConfig = fallbackPolicy.normalizeConfig
? fallbackPolicy.normalizeConfig(fallbackPolicy.baseConfig)
? fallbackPolicy.normalizeConfig(fallbackPolicy.baseConfig, normalizeContext)
: fallbackPolicy.baseConfig;
const command = baseConfig.command?.trim();
if (!command) {
@@ -249,7 +261,7 @@ export function resolveCliBackendConfig(
? mergeBackendConfig(fallbackPolicy.baseConfig, override)
: override;
const config = fallbackPolicy?.normalizeConfig
? fallbackPolicy.normalizeConfig(mergedFallback)
? fallbackPolicy.normalizeConfig(mergedFallback, normalizeContext)
: mergedFallback;
const command = config.command?.trim();
if (!command) {

View File

@@ -95,7 +95,9 @@ export async function prepareCliRunContext(
}
const workspaceDir = resolvedWorkspace;
const backendResolved = resolveCliBackendConfig(params.provider, params.config);
const backendResolved = resolveCliBackendConfig(params.provider, params.config, {
agentId: params.agentId,
});
if (!backendResolved) {
throw new Error(`Unknown CLI backend: ${params.provider}`);
}

View File

@@ -515,7 +515,7 @@ describe("buildAgentSystemPrompt", () => {
workspaceDir: "/tmp/openclaw",
modelAliasLines: [
"- Opus: anthropic/claude-opus-4-5",
"- Sonnet: anthropic/claude-sonnet-4-5",
"- Sonnet: anthropic/claude-sonnet-4-6",
],
});

View File

@@ -1,6 +1,7 @@
export type { CliBackendConfig } from "../config/types.js";
export type {
CliBackendAuthEpochMode,
CliBackendNormalizeConfigContext,
CliBackendPlugin,
CliBackendPreparedExecution,
CliBackendPrepareExecutionContext,

View File

@@ -35,6 +35,12 @@ export type CliBackendPreparedExecution = {
export type CliBackendAuthEpochMode = "combined" | "profile-only";
export type CliBackendNormalizeConfigContext = {
config?: OpenClawConfig;
backendId: string;
agentId?: string;
};
/** Plugin-owned CLI backend defaults used by the text-only CLI runner. */
export type CliBackendPlugin = {
/** Provider id used in model refs, for example `claude-cli/opus`. */
@@ -78,7 +84,10 @@ export type CliBackendPlugin = {
* Use this for backend-specific compatibility rewrites when old config
* shapes need to stay working.
*/
normalizeConfig?: (config: CliBackendConfig) => CliBackendConfig;
normalizeConfig?: (
config: CliBackendConfig,
context?: CliBackendNormalizeConfigContext,
) => CliBackendConfig;
/**
* Backend-owned final system-prompt transform.
*

View File

@@ -69,6 +69,7 @@ import type { VideoGenerationProvider } from "../video-generation/types.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import type {
CliBackendAuthEpochMode,
CliBackendNormalizeConfigContext,
CliBackendPreparedExecution,
CliBackendPrepareExecutionContext,
CliBackendPlugin,
@@ -148,6 +149,7 @@ export type {
} from "./conversation-binding.types.js";
export type {
CliBackendAuthEpochMode,
CliBackendNormalizeConfigContext,
CliBackendPreparedExecution,
CliBackendPrepareExecutionContext,
CliBackendPlugin,

View File

@@ -57,8 +57,8 @@ function buildCommonSystemParams(workspaceDir: string) {
os: "Darwin 24.0.0",
arch: "arm64",
node: process.version,
model: "anthropic/claude-sonnet-4-5",
defaultModel: "anthropic/claude-sonnet-4-5",
model: "anthropic/claude-sonnet-4-6",
defaultModel: "anthropic/claude-sonnet-4-6",
shell: "zsh",
},
userTimezone: "America/Los_Angeles",

View File

@@ -636,7 +636,7 @@ export function describeCloudflareAiGatewayProviderDiscoveryContract(
baseUrl: "https://gateway.ai.cloudflare.com/v1/acc-123/gw-456/anthropic",
api: "anthropic-messages",
apiKey: "CLOUDFLARE_AI_GATEWAY_API_KEY",
models: [expect.objectContaining({ id: "claude-sonnet-4-5" })],
models: [expect.objectContaining({ id: "claude-sonnet-4-6" })],
},
});
});