mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-01 07:10:26 +00:00
refactor(cli): delete removed backend files
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export { buildAnthropicCliBackend } from "./cli-backend.js";
|
||||
export {
|
||||
CLAUDE_CLI_BACKEND_ID,
|
||||
isClaudeCliProvider,
|
||||
normalizeClaudeBackendConfig,
|
||||
} from "./cli-shared.js";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(", ")}.`]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { buildAnthropicCliBackend } from "./cli-backend.js";
|
||||
export { normalizeClaudeBackendConfig } from "./cli-shared.js";
|
||||
export { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"];
|
||||
Reference in New Issue
Block a user