Skip to content

Commit 3b84884

Browse files
authored
fix(agents): harden host-managed claude-cli auth path (#61276)
1 parent afca954 commit 3b84884

17 files changed

Lines changed: 992 additions & 39 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,14 @@ Docs: https://docs.openclaw.ai
141141
- Telegram/reasoning: only create a Telegram reasoning preview lane when the session is explicitly `reasoning:stream`, so hidden `<think>` traces from streamed replies stop surfacing as chat previews on normal sessions. Thanks @vincentkoc.
142142
- Feishu/reasoning: only expose streamed reasoning previews when the session is explicitly `reasoning:stream`, so hidden reasoning traces do not surface on normal streaming sessions. Thanks @vincentkoc.
143143
- Providers/OpenAI: support GPT-5.4 assistant `phase` metadata across OpenAI-family Responses replay and the Gateway `/v1/responses` compatibility layer, including `commentary` tool preambles and `final_answer` replies.
144+
- Models/Anthropic CLI auth: replace migrated `agents.defaults.models` allowlists when `openclaw models auth login --provider anthropic --method cli --set-default` switches to `claude-cli/*`, so stale `anthropic/*` entries do not linger beside the migrated Claude CLI defaults. Thanks @vincentkoc.
145+
- Plugins/auth-choice: apply provider-owned auth config patches without recursively preserving replaced default-model maps, so Anthropic Claude CLI and similar migrations can intentionally swap model allowlists during onboarding and setup instead of accumulating stale entries. Thanks @vincentkoc.
146+
- Anthropic CLI onboarding: rewrite migrated fallback model refs during non-interactive Claude CLI setup too, so onboarding and scripted setup no longer keep stale `anthropic/*` fallbacks after switching the primary model to `claude-cli/*`. Thanks @vincentkoc.
147+
- Agents/Claude CLI: treat malformed bare `--permission-mode` backend overrides as missing and fail safe back to `bypassPermissions`, so custom `cliBackends.claude-cli.args` security config cannot accidentally consume the next flag as a bogus permission mode. Thanks @vincentkoc.
148+
- Agents/Claude CLI/security: clear inherited Claude Code provider-routing and managed-auth env overrides, and mark OpenClaw-launched Claude CLI runs as host-managed, so Claude CLI backdoor sessions cannot be silently redirected to proxy, Bedrock, Vertex, Foundry, or parent-managed token contexts. Thanks @vincentkoc.
149+
- Agents/Claude CLI/security: clear inherited Claude Code config-root and plugin-root env overrides like `CLAUDE_CONFIG_DIR` and `CLAUDE_CODE_PLUGIN_*`, so OpenClaw-launched Claude CLI runs cannot be silently pointed at an alternate Claude config/plugin tree with different hooks, plugins, or auth context. Thanks @vincentkoc.
150+
- Agents/Claude CLI/security: force host-managed Claude CLI backdoor runs to `--setting-sources user`, even under custom backend arg overrides, so repo-local `.claude` project/local settings, hooks, and plugin discovery do not silently execute inside non-interactive OpenClaw sessions. Thanks @vincentkoc.
151+
- Agents/Claude CLI/images: reuse stable hydrated image file paths and preserve shared media extensions like HEIC when passing image refs to local CLI runs, so Claude CLI image prompts stop thrashing KV cache prefixes and oddball image formats do not fall back to `.bin`. Thanks @vincentkoc.
144152

145153
## 2026.4.2
146154

extensions/anthropic/cli-backend.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
import {
77
CLAUDE_CLI_BACKEND_ID,
88
CLAUDE_CLI_CLEAR_ENV,
9+
CLAUDE_CLI_HOST_MANAGED_ENV,
910
CLAUDE_CLI_MODEL_ALIASES,
1011
CLAUDE_CLI_SESSION_ID_FIELDS,
1112
normalizeClaudeBackendConfig,
@@ -23,6 +24,8 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
2324
"stream-json",
2425
"--include-partial-messages",
2526
"--verbose",
27+
"--setting-sources",
28+
"user",
2629
"--permission-mode",
2730
"bypassPermissions",
2831
],
@@ -32,6 +35,8 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
3235
"stream-json",
3336
"--include-partial-messages",
3437
"--verbose",
38+
"--setting-sources",
39+
"user",
3540
"--permission-mode",
3641
"bypassPermissions",
3742
"--resume",
@@ -47,6 +52,7 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
4752
systemPromptArg: "--append-system-prompt",
4853
systemPromptMode: "append",
4954
systemPromptWhen: "first",
55+
env: { ...CLAUDE_CLI_HOST_MANAGED_ENV },
5056
clearEnv: [...CLAUDE_CLI_CLEAR_ENV],
5157
reliability: {
5258
watchdog: {

extensions/anthropic/cli-migration.test.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import type {
2+
ProviderAuthContext,
3+
ProviderAuthMethodNonInteractiveContext,
4+
} from "openclaw/plugin-sdk/plugin-entry";
15
import { describe, expect, it, vi } from "vitest";
26

37
const { readClaudeCliCredentialsForSetup, readClaudeCliCredentialsForSetupNonInteractive } =
@@ -16,6 +20,67 @@ vi.mock("./cli-auth-seam.js", async (importActual) => {
1620
});
1721

1822
const { buildAnthropicCliMigrationResult, hasClaudeCliAuth } = await import("./cli-migration.js");
23+
const { registerSingleProviderPlugin } =
24+
await import("../../test/helpers/plugins/plugin-registration.js");
25+
const { default: anthropicPlugin } = await import("./index.js");
26+
27+
async function resolveAnthropicCliAuthMethod() {
28+
const provider = await registerSingleProviderPlugin(anthropicPlugin);
29+
const method = provider.auth.find((entry) => entry.id === "cli");
30+
if (!method) {
31+
throw new Error("anthropic cli auth method missing");
32+
}
33+
return method;
34+
}
35+
36+
function createProviderAuthContext(
37+
config: ProviderAuthContext["config"] = {},
38+
): ProviderAuthContext {
39+
return {
40+
config,
41+
opts: {},
42+
env: {},
43+
agentDir: "/tmp/openclaw/agents/main",
44+
workspaceDir: "/tmp/openclaw/workspace",
45+
prompter: {
46+
confirm: vi.fn(),
47+
note: vi.fn(),
48+
select: vi.fn(),
49+
text: vi.fn(),
50+
},
51+
runtime: {
52+
log: vi.fn(),
53+
error: vi.fn(),
54+
exit: vi.fn(),
55+
},
56+
allowSecretRefPrompt: false,
57+
isRemote: false,
58+
openUrl: vi.fn(),
59+
oauth: {
60+
createVpsAwareHandlers: vi.fn(),
61+
},
62+
};
63+
}
64+
65+
function createProviderAuthMethodNonInteractiveContext(
66+
config: ProviderAuthMethodNonInteractiveContext["config"] = {},
67+
): ProviderAuthMethodNonInteractiveContext {
68+
return {
69+
authChoice: "anthropic-cli",
70+
config,
71+
baseConfig: config,
72+
opts: {},
73+
runtime: {
74+
log: vi.fn(),
75+
error: vi.fn(),
76+
exit: vi.fn(),
77+
},
78+
agentDir: "/tmp/openclaw/agents/main",
79+
workspaceDir: "/tmp/openclaw/workspace",
80+
resolveApiKey: vi.fn(async () => null),
81+
toApiKeyCredential: vi.fn(() => null),
82+
};
83+
}
1984

2085
describe("anthropic cli migration", () => {
2186
it("detects local Claude CLI auth", () => {
@@ -95,4 +160,105 @@ describe("anthropic cli migration", () => {
95160
},
96161
});
97162
});
163+
164+
it("registered cli auth tells users to run claude auth login when local auth is missing", async () => {
165+
readClaudeCliCredentialsForSetup.mockReturnValue(null);
166+
const method = await resolveAnthropicCliAuthMethod();
167+
168+
await expect(method.run(createProviderAuthContext())).rejects.toThrow(
169+
[
170+
"Claude CLI is not authenticated on this host.",
171+
"Run claude auth login first, then re-run this setup.",
172+
].join("\n"),
173+
);
174+
});
175+
176+
it("registered cli auth returns the same migration result as the builder", async () => {
177+
readClaudeCliCredentialsForSetup.mockReturnValue({
178+
type: "oauth",
179+
provider: "anthropic",
180+
access: "access-token",
181+
refresh: "refresh-token",
182+
expires: Date.now() + 60_000,
183+
});
184+
const method = await resolveAnthropicCliAuthMethod();
185+
const config = {
186+
agents: {
187+
defaults: {
188+
model: {
189+
primary: "anthropic/claude-sonnet-4-6",
190+
fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"],
191+
},
192+
models: {
193+
"anthropic/claude-sonnet-4-6": { alias: "Sonnet" },
194+
"anthropic/claude-opus-4-6": { alias: "Opus" },
195+
"openai/gpt-5.2": {},
196+
},
197+
},
198+
},
199+
};
200+
201+
await expect(method.run(createProviderAuthContext(config))).resolves.toEqual(
202+
buildAnthropicCliMigrationResult(config),
203+
);
204+
});
205+
206+
it("registered non-interactive cli auth rewrites anthropic fallbacks before setting the claude-cli default", async () => {
207+
readClaudeCliCredentialsForSetupNonInteractive.mockReturnValue({
208+
type: "oauth",
209+
provider: "anthropic",
210+
access: "access-token",
211+
refresh: "refresh-token",
212+
expires: Date.now() + 60_000,
213+
});
214+
const method = await resolveAnthropicCliAuthMethod();
215+
const config = {
216+
agents: {
217+
defaults: {
218+
model: {
219+
primary: "anthropic/claude-sonnet-4-6",
220+
fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"],
221+
},
222+
models: {
223+
"anthropic/claude-sonnet-4-6": { alias: "Sonnet" },
224+
"anthropic/claude-opus-4-6": { alias: "Opus" },
225+
"openai/gpt-5.2": {},
226+
},
227+
},
228+
},
229+
};
230+
231+
await expect(
232+
method.runNonInteractive?.(createProviderAuthMethodNonInteractiveContext(config)),
233+
).resolves.toMatchObject({
234+
agents: {
235+
defaults: {
236+
model: {
237+
primary: "claude-cli/claude-sonnet-4-6",
238+
fallbacks: ["claude-cli/claude-opus-4-6", "openai/gpt-5.2"],
239+
},
240+
models: {
241+
"claude-cli/claude-sonnet-4-6": { alias: "Sonnet" },
242+
"claude-cli/claude-opus-4-6": { alias: "Opus" },
243+
"openai/gpt-5.2": {},
244+
},
245+
},
246+
},
247+
});
248+
});
249+
250+
it("registered non-interactive cli auth reports missing local auth and exits cleanly", async () => {
251+
readClaudeCliCredentialsForSetupNonInteractive.mockReturnValue(null);
252+
const method = await resolveAnthropicCliAuthMethod();
253+
const ctx = createProviderAuthMethodNonInteractiveContext();
254+
255+
await expect(method.runNonInteractive?.(ctx)).resolves.toBeNull();
256+
expect(ctx.runtime.error).toHaveBeenCalledWith(
257+
[
258+
'Auth choice "anthropic-cli" requires Claude CLI auth on this host.',
259+
"Run claude auth login first.",
260+
].join("\n"),
261+
);
262+
expect(ctx.runtime.exit).toHaveBeenCalledWith(1);
263+
});
98264
});

extensions/anthropic/cli-shared.test.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { describe, expect, it } from "vitest";
22
import { buildAnthropicCliBackend } from "./cli-backend.js";
3-
import { normalizeClaudeBackendConfig, normalizeClaudePermissionArgs } from "./cli-shared.js";
3+
import {
4+
CLAUDE_CLI_CLEAR_ENV,
5+
CLAUDE_CLI_HOST_MANAGED_ENV,
6+
normalizeClaudeBackendConfig,
7+
normalizeClaudePermissionArgs,
8+
normalizeClaudeSettingSourcesArgs,
9+
} from "./cli-shared.js";
410

511
describe("normalizeClaudePermissionArgs", () => {
612
it("injects bypassPermissions when args omit permission flags", () => {
@@ -33,6 +39,43 @@ describe("normalizeClaudePermissionArgs", () => {
3339
"--permission-mode=acceptEdits",
3440
]);
3541
});
42+
43+
it("treats a bare permission-mode flag as malformed and falls back to bypassPermissions", () => {
44+
expect(
45+
normalizeClaudePermissionArgs(["-p", "--permission-mode", "--output-format", "stream-json"]),
46+
).toEqual(["-p", "--output-format", "stream-json", "--permission-mode", "bypassPermissions"]);
47+
});
48+
});
49+
50+
describe("normalizeClaudeSettingSourcesArgs", () => {
51+
it("injects user-only setting sources when args omit the flag", () => {
52+
expect(
53+
normalizeClaudeSettingSourcesArgs(["-p", "--output-format", "stream-json", "--verbose"]),
54+
).toEqual(["-p", "--output-format", "stream-json", "--verbose", "--setting-sources", "user"]);
55+
});
56+
57+
it("forces explicit project or local setting sources back to user-only", () => {
58+
expect(normalizeClaudeSettingSourcesArgs(["-p", "--setting-sources", "project"])).toEqual([
59+
"-p",
60+
"--setting-sources",
61+
"user",
62+
]);
63+
expect(normalizeClaudeSettingSourcesArgs(["-p", "--setting-sources=local,user"])).toEqual([
64+
"-p",
65+
"--setting-sources=user",
66+
]);
67+
});
68+
69+
it("treats a bare setting-sources flag as malformed and falls back to user-only", () => {
70+
expect(
71+
normalizeClaudeSettingSourcesArgs([
72+
"-p",
73+
"--setting-sources",
74+
"--output-format",
75+
"stream-json",
76+
]),
77+
).toEqual(["-p", "--output-format", "stream-json", "--setting-sources", "user"]);
78+
});
3679
});
3780

3881
describe("normalizeClaudeBackendConfig", () => {
@@ -48,6 +91,8 @@ describe("normalizeClaudeBackendConfig", () => {
4891
"--output-format",
4992
"stream-json",
5093
"--verbose",
94+
"--setting-sources",
95+
"user",
5196
"--permission-mode",
5297
"bypassPermissions",
5398
]);
@@ -58,6 +103,8 @@ describe("normalizeClaudeBackendConfig", () => {
58103
"--verbose",
59104
"--resume",
60105
"{sessionId}",
106+
"--setting-sources",
107+
"user",
61108
"--permission-mode",
62109
"bypassPermissions",
63110
]);
@@ -77,7 +124,30 @@ describe("normalizeClaudeBackendConfig", () => {
77124

78125
expect(normalized?.args).toContain("--permission-mode");
79126
expect(normalized?.args).toContain("bypassPermissions");
127+
expect(normalized?.args).toContain("--setting-sources");
128+
expect(normalized?.args).toContain("user");
80129
expect(normalized?.resumeArgs).toContain("--permission-mode");
81130
expect(normalized?.resumeArgs).toContain("bypassPermissions");
131+
expect(normalized?.resumeArgs).toContain("--setting-sources");
132+
expect(normalized?.resumeArgs).toContain("user");
133+
});
134+
135+
it("marks claude cli as host-managed, restricts setting sources, and clears inherited env overrides", () => {
136+
const backend = buildAnthropicCliBackend();
137+
138+
expect(backend.config.env).toEqual(CLAUDE_CLI_HOST_MANAGED_ENV);
139+
expect(backend.config.args).toContain("--setting-sources");
140+
expect(backend.config.args).toContain("user");
141+
expect(backend.config.resumeArgs).toContain("--setting-sources");
142+
expect(backend.config.resumeArgs).toContain("user");
143+
expect(backend.config.clearEnv).toEqual([...CLAUDE_CLI_CLEAR_ENV]);
144+
expect(backend.config.clearEnv).toContain("ANTHROPIC_BASE_URL");
145+
expect(backend.config.clearEnv).toContain("CLAUDE_CONFIG_DIR");
146+
expect(backend.config.clearEnv).toContain("CLAUDE_CODE_USE_BEDROCK");
147+
expect(backend.config.clearEnv).toContain("CLAUDE_CODE_OAUTH_TOKEN");
148+
expect(backend.config.clearEnv).toContain("CLAUDE_CODE_PLUGIN_CACHE_DIR");
149+
expect(backend.config.clearEnv).toContain("CLAUDE_CODE_PLUGIN_SEED_DIR");
150+
expect(backend.config.clearEnv).toContain("CLAUDE_CODE_REMOTE");
151+
expect(backend.config.clearEnv).toContain("CLAUDE_CODE_USE_COWORK_PLUGINS");
82152
});
83153
});

0 commit comments

Comments
 (0)