refactor(cli): delete removed backend files

This commit is contained in:
Peter Steinberger
2026-04-05 18:04:30 +01:00
parent 6243806f7b
commit 2d7157b424
15 changed files with 0 additions and 2285 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +0,0 @@
// 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"];