fix(auth): persist claude-cli login profiles

This commit is contained in:
Vincent Koc
2026-04-05 14:02:59 +01:00
parent e6f1a59e67
commit 1a7c2a9bc8
4 changed files with 121 additions and 15 deletions

View File

@@ -170,13 +170,14 @@ describe("anthropic cli migration", () => {
});
it("registered cli auth returns the same migration result as the builder", async () => {
readClaudeCliCredentialsForSetup.mockReturnValue({
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: {
@@ -195,10 +196,60 @@ describe("anthropic cli migration", () => {
};
await expect(method.run(createProviderAuthContext(config))).resolves.toEqual(
buildAnthropicCliMigrationResult(config),
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",

View File

@@ -1,12 +1,18 @@
import type { OpenClawConfig, ProviderAuthResult } from "openclaw/plugin-sdk/provider-auth";
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 } from "./cli-shared.js";
const DEFAULT_CLAUDE_CLI_MODEL = "claude-cli/claude-sonnet-4-6";
const DEFAULT_CLAUDE_CLI_MODEL = `${CLAUDE_CLI_BACKEND_ID}/claude-sonnet-4-6`;
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();
@@ -104,7 +110,43 @@ export function hasClaudeCliAuth(options?: { allowKeychainPrompt?: boolean }): b
);
}
export function buildAnthropicCliMigrationResult(config: OpenClawConfig): ProviderAuthResult {
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);
@@ -114,7 +156,7 @@ export function buildAnthropicCliMigrationResult(config: OpenClawConfig): Provid
const defaultModel = rewrittenModel.primary ?? DEFAULT_CLAUDE_CLI_MODEL;
return {
profiles: [],
profiles: buildClaudeCliAuthProfiles(credential),
configPatch: {
agents: {
defaults: {

View File

@@ -164,6 +164,17 @@ describe("anthropic provider replay hooks", () => {
config: {},
} as never);
expect(result?.profiles).toEqual([]);
expect(result?.profiles).toEqual([
{
profileId: "anthropic:claude-cli",
credential: {
type: "oauth",
provider: "claude-cli",
access: "setup-access-token",
refresh: "refresh-token",
expires: 123,
},
},
]);
});
});

View File

@@ -23,9 +23,9 @@ import {
} from "openclaw/plugin-sdk/provider-auth";
import { cloneFirstTemplateModel } from "openclaw/plugin-sdk/provider-model-shared";
import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage";
import { readClaudeCliCredentialsForRuntime } from "./cli-auth-seam.js";
import * as claudeCliAuth from "./cli-auth-seam.js";
import { buildAnthropicCliBackend } from "./cli-backend.js";
import { buildAnthropicCliMigrationResult, hasClaudeCliAuth } from "./cli-migration.js";
import { buildAnthropicCliMigrationResult } from "./cli-migration.js";
import { CLAUDE_CLI_BACKEND_ID } from "./cli-shared.js";
import {
applyAnthropicConfigDefaults,
@@ -285,7 +285,7 @@ function buildAnthropicAuthDoctorHint(params: {
}
function resolveClaudeCliSyntheticAuth() {
const credential = readClaudeCliCredentialsForRuntime();
const credential = claudeCliAuth.readClaudeCliCredentialsForRuntime();
if (!credential) {
return undefined;
}
@@ -303,7 +303,8 @@ function resolveClaudeCliSyntheticAuth() {
}
async function runAnthropicCliMigration(ctx: ProviderAuthContext): Promise<ProviderAuthResult> {
if (!hasClaudeCliAuth()) {
const credential = claudeCliAuth.readClaudeCliCredentialsForSetup();
if (!credential) {
throw new Error(
[
"Claude CLI is not authenticated on this host.",
@@ -311,7 +312,7 @@ async function runAnthropicCliMigration(ctx: ProviderAuthContext): Promise<Provi
].join("\n"),
);
}
return buildAnthropicCliMigrationResult(ctx.config);
return buildAnthropicCliMigrationResult(ctx.config, credential);
}
async function runAnthropicCliMigrationNonInteractive(ctx: {
@@ -319,7 +320,8 @@ async function runAnthropicCliMigrationNonInteractive(ctx: {
runtime: ProviderAuthContext["runtime"];
agentDir?: string;
}): Promise<ProviderAuthContext["config"] | null> {
if (!hasClaudeCliAuth({ allowKeychainPrompt: false })) {
const credential = claudeCliAuth.readClaudeCliCredentialsForSetupNonInteractive();
if (!credential) {
ctx.runtime.error(
[
'Auth choice "anthropic-cli" requires Claude CLI auth on this host.',
@@ -330,7 +332,7 @@ async function runAnthropicCliMigrationNonInteractive(ctx: {
return null;
}
const result = buildAnthropicCliMigrationResult(ctx.config);
const result = buildAnthropicCliMigrationResult(ctx.config, credential);
const currentDefaults = ctx.config.agents?.defaults;
const currentModel = currentDefaults?.model;
const currentFallbacks =