fix(anthropic): keep Claude CLI authentication native (#129052)

Stop OpenClaw from copying or refreshing Claude CLI OAuth tokens.
Claude CLI now owns native login and refresh state; Doctor removes retired copies while preserving CLI routing.

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Ayaan Zaidi
2026-08-25 17:18:06 +05:30
committed by GitHub
parent 22fa9f5786
commit 750a64e7cd
82 changed files with 1415 additions and 2987 deletions
+1 -3
View File
@@ -1712,7 +1712,6 @@ src/agents/auth-profiles/policy.ts 1
src/agents/auth-profiles/portability.ts 1
src/agents/auth-profiles/profiles.ts 1
src/agents/auth-profiles/repair.ts 1
src/agents/auth-profiles/runtime-external-profile-references.ts 2
src/agents/auth-profiles/runtime-snapshots.ts 1
src/agents/auth-profiles/source-check.ts 1
src/agents/auth-profiles/sqlite.ts 2
@@ -1738,7 +1737,7 @@ src/agents/cache-trace.ts 3
src/agents/channel-tools.ts 3
src/agents/cli-auth-epoch.ts 1
src/agents/cli-backends.ts 1
src/agents/cli-credentials.ts 15
src/agents/cli-credentials.ts 10
src/agents/cli-executable-identity.ts 3
src/agents/cli-output-records.ts 1
src/agents/cli-runner.ts 1
@@ -2527,7 +2526,6 @@ src/commands/doctor-agent-memory-schema.ts 1
src/commands/doctor-auth-flat-profiles.ts 5
src/commands/doctor-auth-migration-receipts.ts 3
src/commands/doctor-auth-profile-config.ts 4
src/commands/doctor-claude-cli.ts 1
src/commands/doctor-config-analysis.ts 3
src/commands/doctor-config-flow.ts 3
src/commands/doctor-config-preflight.cron.ts 4
-1
View File
@@ -323,7 +323,6 @@ src/agents/bash-tools.exec.approval-id.test.ts
src/agents/bash-tools.process.ts
src/agents/btw.test.ts
src/agents/btw.ts
src/agents/cli-auth-epoch.test.ts
src/agents/cli-runner.reliability.test.ts
src/agents/cli-runner.spawn.test.ts
src/agents/cli-runner/execute.supervisor-capture.test.ts
+2 -2
View File
@@ -56,9 +56,9 @@ claude auth status --text
openclaw models auth login --provider anthropic --method cli --set-default
```
This is two steps: log Claude Code into Anthropic on the host, then tell OpenClaw to route Anthropic model selection through the local `claude-cli` backend and store the matching OpenClaw auth profile.
This is two steps: log Claude Code into Anthropic on the host, then tell OpenClaw to route Anthropic models through the local `claude-cli` backend.
At run time OpenClaw treats a reused Claude CLI login as Claude's own credential: it verifies the host's current `claude` login matches the selected profile's account and then lets the `claude` subprocess authenticate natively, so Claude keeps refreshing its own login during runs. OpenClaw never forwards a copied token for this path. If the host login is missing or belongs to a different account, the run fails before spawn with the exact re-authentication commands. OpenClaw-managed credentials (Anthropic OAuth login profiles, setup tokens, API keys) are still delivered to the subprocess directly and refreshed by OpenClaw where applicable.
OpenClaw never reads, stores, refreshes, or forwards the native login tokens. The installed `claude` process reads and refreshes its own login. `CLAUDE_CONFIG_DIR` selects a separate Claude login when set on the Gateway process. OpenClaw-managed setup tokens and API keys remain separate credentials.
The gateway service must resolve `claude` on `PATH`. If a deployment needs a
nonstandard executable path, register a wrapper through a
+11 -10
View File
@@ -118,10 +118,11 @@ The `openclaw agent` command also has its own request deadline. Its 600-second f
The bundled Anthropic plugin runs the installed Claude Code executable through
Anthropic's official Agent SDK. Claude Code owns its existing local login and
subscription; OpenClaw does not extract that login or send synthesized
Anthropic API requests. Compatible agent turns share one warm SDK query and
Claude Code subprocess. A changed model, system prompt, authenticated identity,
or tool policy starts a new query; persisted Claude session IDs still provide
subscription. OpenClaw uses a non-secret route marker. It never reads, persists,
refreshes, or forwards native tokens, or sends synthesized Anthropic API
requests. Compatible agent turns share one warm SDK query and
Claude Code subprocess. A changed model, system prompt, or tool policy starts a
new query; persisted Claude session IDs still provide
conversation continuity when the gateway or subprocess restarts.
Keep Claude Code updated, especially if the SDK reports an incompatible
@@ -350,12 +351,12 @@ If no MCP servers are enabled, OpenClaw still injects a strict config when a bac
Session-scoped bundled MCP runtimes are cached for reuse within a session, then reaped after 10 minutes of idle time. One-shot embedded runs such as auth probes, slug generation, and active-memory recall request cleanup at run end so stdio children and Streamable HTTP/SSE streams do not outlive the run.
For `claude-cli`, an imported native OAuth profile reuses the matching,
identity-verified Claude Code login without forwarding an extracted access
token. Explicit non-native API-key and token profiles continue to use the
protected, per-invocation credential-forwarding CLI path, keeping selected
per-agent profiles authoritative without placing credential values in command
arguments.
For `claude-cli`, the installed Claude Code process uses its current native
login. OpenClaw uses a non-secret route marker and never reads, persists,
refreshes, selects, or forwards the native tokens.
Set `CLAUDE_CONFIG_DIR` on the Gateway process to use a separate Claude configuration directory.
Explicit OpenClaw-managed API-key and token profiles continue to use the
protected, per-invocation credential-forwarding CLI path.
## Reseed history cap
+20 -12
View File
@@ -15,15 +15,16 @@ Anthropic builds the **Claude** model family. OpenClaw supports two auth routes:
OpenClaw detects the available Anthropic credential and selects the matching usage surface:
- Claude subscription/setup credentials show quota windows and optional extra-usage budget.
- OpenClaw-managed subscription/setup credentials show quota windows and optional extra-usage budget.
- Native Claude CLI logins stay under Claude's exclusive refresh control, so OpenClaw does not poll their quota endpoint.
- `ANTHROPIC_ADMIN_KEY` or `ANTHROPIC_ADMIN_API_KEY` shows 30 days of provider-reported organization cost and Messages API usage in Control UI **Usage**, including daily spend, token/cache totals, top models, and cost categories.
- An `sk-ant-admin...` credential stored in the Anthropic provider profile is detected as an Admin API key automatically.
Admin API cost history comes from Anthropic's [Usage and Cost API](https://platform.claude.com/docs/en/manage-claude/usage-cost-api). It is actual provider billing, separate from OpenClaw's session-derived estimated cost.
<Warning>
Claude Code owns its existing login and subscription; OpenClaw does not extract
that login or synthesize Anthropic API requests. Agent SDK and `claude -p`
Claude Code owns its existing login and subscription; OpenClaw does not persist
or refresh that login. Agent SDK and `claude -p`
usage currently draw from the signed-in subscription's limits. API-key auth
uses separate pay-as-you-go billing and is preferable for shared automation or
predictable production spend.
@@ -89,6 +90,13 @@ OpenClaw release:
```bash
claude --version
claude auth status --text
```
If Claude is not logged in, authenticate once as the Gateway user:
```bash
claude auth login
```
If the installed build is incompatible, update Claude Code and restart
@@ -104,15 +112,15 @@ OpenClaw release:
# choose: Claude CLI
```
OpenClaw detects the existing Claude CLI login. Normal agent turns use
the official Agent SDK with the installed, authenticated Claude Code
executable, including native-tool turns whose approvals remain under
OpenClaw control. Schema-valid native calls pass through OpenClaw's
canonical tool policy before native approval. Imported native OAuth
profiles reuse the verified Claude Code login; explicitly selected
API-key or token credentials still use protected file-descriptor
forwarding. Isolated side-question
completions and paired-node execution retain the supervised CLI path.
Normal agent turns use the official Agent SDK with the installed,
authenticated Claude Code executable. OpenClaw uses a non-secret route
marker and never reads, persists, refreshes, selects, or forwards the
native login tokens. Claude owns the login and token refresh lifecycle.
Explicitly selected API-key or token credentials still use protected
file-descriptor forwarding. Native-tool approvals remain under OpenClaw
control. Schema-valid native calls pass through OpenClaw's canonical
tool policy before native approval. Isolated side-question completions
and paired-node execution retain the supervised CLI path.
Consecutive agent turns reuse the same warm Agent SDK query and Claude
Code subprocess when their authenticated session and execution policy
@@ -53,6 +53,7 @@ function createContext(
cwd: "/tmp/openclaw-workspace",
env: {
HOME: "/tmp/claude-login-home",
CLAUDE_CONFIG_DIR: "/tmp/claude-login-home/custom-config",
PATH: "/usr/local/bin:/usr/bin",
OPENCLAW_MCP_TOKEN: "test-grant-not-a-real-secret",
},
+21 -22
View File
@@ -1,35 +1,34 @@
import { beforeEach, expect, it, vi } from "vitest";
const { readClaudeCliCredentialsCached } = vi.hoisted(() => ({
readClaudeCliCredentialsCached: vi.fn(),
const { spawnSync } = vi.hoisted(() => ({
spawnSync: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/provider-auth", () => ({
readClaudeCliCredentialsCached,
}));
vi.mock("node:child_process", () => ({ spawnSync }));
const { readClaudeCliCredentialsForRuntime, readClaudeCliCredentialsForSetupNonInteractive } =
await import("./cli-auth-seam.js");
const { probeClaudeCliAuthStatus } = await import("./cli-auth-seam.js");
beforeEach(() => {
readClaudeCliCredentialsCached.mockReset();
spawnSync.mockReset();
});
it("keeps runtime Claude credential reads on the non-prompting path", () => {
readClaudeCliCredentialsForRuntime();
it("asks Claude CLI to verify its own login", () => {
spawnSync.mockReturnValue({
status: 0,
stdout: JSON.stringify({ loggedIn: true, authMethod: "claude.ai" }),
});
expect(readClaudeCliCredentialsCached).toHaveBeenCalledWith({ allowKeychainPrompt: false });
});
expect(probeClaudeCliAuthStatus()).toEqual({ status: "available" });
it("uses the bounded credential inspector only for non-interactive setup", () => {
readClaudeCliCredentialsForSetupNonInteractive();
expect(readClaudeCliCredentialsCached).toHaveBeenCalledWith(
expect.objectContaining({
allowKeychainPrompt: false,
tryKeychainWithoutPrompt: true,
ttlMs: 0,
onStoredCredentialUnreadable: expect.any(Function),
}),
expect(spawnSync).toHaveBeenCalledWith(
"claude",
["auth", "status", "--json"],
expect.objectContaining({ timeout: 3_000 }),
);
});
it("does not inspect Claude token storage when the CLI reports logout", () => {
spawnSync.mockReturnValue({ status: 1, stdout: "" });
expect(probeClaudeCliAuthStatus()).toEqual({ status: "missing" });
});
+29 -27
View File
@@ -1,31 +1,33 @@
/**
* Claude CLI auth seam. Setup may prompt for keychain-backed credentials while
* runtime paths stay non-interactive.
*/
import { readClaudeCliCredentialsCached } from "openclaw/plugin-sdk/provider-auth";
import { spawnSync } from "node:child_process";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
/** Read Claude CLI credentials for interactive setup paths. */
export function readClaudeCliCredentialsForSetup() {
return readClaudeCliCredentialsCached();
}
type ClaudeCliAuthStatus = { status: "available" } | { status: "missing" | "unreadable" };
/** Read Claude CLI credentials for setup checks that must not prompt. */
export function readClaudeCliCredentialsForSetupNonInteractive() {
let unreadable = false;
const credential = readClaudeCliCredentialsCached({
allowKeychainPrompt: false,
tryKeychainWithoutPrompt: true,
ttlMs: 0,
onStoredCredentialUnreadable: () => {
unreadable = true;
},
/** Ask Claude CLI whether its own login is usable without reading token material. */
export function probeClaudeCliAuthStatus(params?: {
command?: string;
env?: NodeJS.ProcessEnv;
}): ClaudeCliAuthStatus {
const result = spawnSync(params?.command ?? "claude", ["auth", "status", "--json"], {
encoding: "utf8",
env: params?.env ?? process.env,
maxBuffer: 64 * 1024,
timeout: 3_000,
windowsHide: true,
});
return credential
? ({ status: "available", credential } as const)
: ({ status: unreadable ? "unreadable" : "missing" } as const);
}
/** Read Claude CLI credentials for runtime without keychain prompts. */
export function readClaudeCliCredentialsForRuntime() {
return readClaudeCliCredentialsCached({ allowKeychainPrompt: false });
if (result.error || result.status === null) {
return { status: "unreadable" };
}
if (result.status !== 0) {
return { status: "missing" };
}
try {
const parsed: unknown = JSON.parse(result.stdout);
if (!isRecord(parsed) || parsed.loggedIn !== true) {
return { status: "missing" };
}
return { status: "available" };
} catch {
return { status: "unreadable" };
}
}
+3 -4
View File
@@ -93,10 +93,9 @@ function resolveClaudeCliAuthInput(
credential: ClaudeCliAuthCredential | undefined,
): ClaudeCliPreparedExecution | undefined {
// Forwarded OAuth here is OpenClaw-managed material (its refresh path is
// OpenClaw-owned). Imported native `claude` logins are never forwarded
// core runs those as identity-verified passthrough — so an expired token
// reaching this point is a real fault worth failing loudly, not refreshable
// state this plugin could repair.
// OpenClaw-owned). Native `claude` logins are never forwarded; the current
// Claude process reads its own config directory. An expired token here is
// therefore OpenClaw-managed state that must fail loudly.
if (credential?.type === "oauth" && "access" in credential) {
const expires = "expires" in credential ? credential.expires : undefined;
if (typeof expires !== "number" || !Number.isFinite(expires) || expires <= Date.now()) {
+2 -4
View File
@@ -4,10 +4,8 @@
*/
/** Synthetic provider/backend id for Claude Code CLI-backed Anthropic models. */
export const CLAUDE_CLI_BACKEND_ID = "claude-cli";
/** Non-secret marker for Claude Code settings.json apiKeyHelper auth. */
export const CLAUDE_CLI_API_KEY_HELPER_AUTH_MARKER = ["openclaw", "claude-cli-api-key-helper"].join(
":",
);
/** Non-secret marker telling OpenClaw that the installed Claude CLI owns auth. */
export const CLAUDE_CLI_NATIVE_AUTH_MARKER = ["openclaw", "claude-cli-native-auth"].join(":");
/** Default Claude CLI model ref for agent defaults and live tests. */
export const CLAUDE_CLI_DEFAULT_MODEL_REF = `${CLAUDE_CLI_BACKEND_ID}/claude-opus-5`;
/** Provider-relative model id for Anthropic runtime-policy resolution. */
+40 -86
View File
@@ -5,18 +5,15 @@ import type {
} from "openclaw/plugin-sdk/plugin-entry";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
const { readClaudeCliCredentialsForSetup, readClaudeCliCredentialsForSetupNonInteractive } =
vi.hoisted(() => ({
readClaudeCliCredentialsForSetup: vi.fn(),
readClaudeCliCredentialsForSetupNonInteractive: vi.fn(),
}));
const { probeClaudeCliAuthStatus } = vi.hoisted(() => ({
probeClaudeCliAuthStatus: vi.fn(),
}));
vi.mock("./cli-auth-seam.js", async (importActual) => {
const actual = await importActual<typeof import("./cli-auth-seam.js")>();
return {
...actual,
readClaudeCliCredentialsForSetup,
readClaudeCliCredentialsForSetupNonInteractive,
probeClaudeCliAuthStatus,
};
});
@@ -27,8 +24,8 @@ const { createTestWizardPrompter, registerSingleProviderPlugin } =
const { default: anthropicPlugin } = await import("./index.js");
beforeEach(() => {
readClaudeCliCredentialsForSetup.mockReset();
readClaudeCliCredentialsForSetupNonInteractive.mockReset();
probeClaudeCliAuthStatus.mockReset();
vi.unstubAllEnvs();
});
afterAll(() => {
@@ -480,7 +477,7 @@ describe("anthropic cli migration", () => {
});
it("registered cli auth tells users to run claude auth login when local auth is missing", async () => {
readClaudeCliCredentialsForSetup.mockReturnValue(null);
probeClaudeCliAuthStatus.mockReturnValue({ status: "missing" });
const method = await resolveAnthropicCliAuthMethod();
await expect(method.run(createProviderAuthContext())).rejects.toThrow(
@@ -492,14 +489,7 @@ describe("anthropic cli migration", () => {
});
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);
probeClaudeCliAuthStatus.mockReturnValue({ status: "available" });
const method = await resolveAnthropicCliAuthMethod();
const config = {
agents: {
@@ -518,80 +508,31 @@ describe("anthropic cli migration", () => {
};
await expect(method.run(createProviderAuthContext(config))).resolves.toEqual(
buildAnthropicCliMigrationResult(config, credential),
buildAnthropicCliMigrationResult(config),
);
});
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,
},
);
it("probes auth with the Claude runtime command and setup environment", async () => {
probeClaudeCliAuthStatus.mockReturnValue({ status: "available" });
const method = await resolveAnthropicCliAuthMethod();
const ctx = createProviderAuthContext();
ctx.env = { CLAUDE_CONFIG_DIR: "/tmp/claude-work" };
expect(result.profiles).toEqual([
{
profileId: "anthropic:claude-cli",
credential: {
type: "oauth",
provider: "claude-cli",
access: "access-token",
refresh: "refresh-token",
expires: 123,
},
},
]);
await method.run(ctx);
expect(probeClaudeCliAuthStatus).toHaveBeenCalledWith({
command: "claude",
env: { CLAUDE_CONFIG_DIR: "/tmp/claude-work" },
});
});
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("does not persist a synthetic profile for Claude CLI apiKeyHelper auth", () => {
const result = buildAnthropicCliMigrationResult(
{},
{ type: "api_key_helper", provider: "anthropic", helperHash: "helper-hash" },
);
it("does not copy native Claude credentials into OpenClaw", () => {
const result = buildAnthropicCliMigrationResult({});
expect(result.profiles).toEqual([]);
});
it("registered non-interactive cli auth keeps anthropic fallbacks and selects claude-cli runtime", async () => {
readClaudeCliCredentialsForSetupNonInteractive.mockReturnValue({
status: "available",
credential: {
type: "oauth",
provider: "anthropic",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
});
probeClaudeCliAuthStatus.mockReturnValue({ status: "available" });
const method = await resolveAnthropicCliAuthMethod();
const config = {
agents: {
@@ -637,8 +578,21 @@ describe("anthropic cli migration", () => {
expect(defaults?.models?.["openai/gpt-5.2"]).toEqual({});
});
it("uses the Gateway Claude config directory for non-interactive auth probes", async () => {
vi.stubEnv("CLAUDE_CONFIG_DIR", "/tmp/gateway-claude-work");
probeClaudeCliAuthStatus.mockReturnValue({ status: "available" });
const method = await resolveAnthropicCliAuthMethod();
await method.runNonInteractive?.(createProviderAuthMethodNonInteractiveContext());
expect(probeClaudeCliAuthStatus).toHaveBeenCalledWith({
command: "claude",
env: expect.objectContaining({ CLAUDE_CONFIG_DIR: "/tmp/gateway-claude-work" }),
});
});
it("registered non-interactive cli auth reports missing local auth and exits cleanly", async () => {
readClaudeCliCredentialsForSetupNonInteractive.mockReturnValue({ status: "missing" });
probeClaudeCliAuthStatus.mockReturnValue({ status: "missing" });
const method = await resolveAnthropicCliAuthMethod();
const ctx = createProviderAuthMethodNonInteractiveContext();
@@ -653,15 +607,15 @@ describe("anthropic cli migration", () => {
});
it("registered non-interactive cli auth reports stored credentials that need interaction", async () => {
readClaudeCliCredentialsForSetupNonInteractive.mockReturnValue({ status: "unreadable" });
probeClaudeCliAuthStatus.mockReturnValue({ status: "unreadable" });
const method = await resolveAnthropicCliAuthMethod();
const ctx = createProviderAuthMethodNonInteractiveContext();
await expect(method.runNonInteractive?.(ctx)).resolves.toBeNull();
expect(ctx.runtime.error).toHaveBeenCalledWith(
[
'Auth choice "anthropic-cli" found Claude CLI credentials on this host, but they could not be read non-interactively.',
"Re-run this command without --non-interactive, or use --auth-choice setup-token / --anthropic-api-key <key>.",
'Auth choice "anthropic-cli" could not verify the installed Claude CLI login.',
"Run claude auth status, then retry.",
].join("\n"),
);
expect(ctx.runtime.exit).toHaveBeenCalledWith(1);
+3 -48
View File
@@ -2,22 +2,16 @@
* Claude CLI setup migration helpers. They rewrite legacy Claude CLI model refs
* to Anthropic refs while preserving runtime allowlist entries for CLI execution.
*/
import {
CLAUDE_CLI_PROFILE_ID,
type OpenClawConfig,
type ProviderAuthResult,
} from "openclaw/plugin-sdk/provider-auth";
import type { OpenClawConfig, ProviderAuthResult } from "openclaw/plugin-sdk/provider-auth";
import {
isRecord,
normalizeLowercaseStringOrEmpty,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveClaudeCliAnthropicModelRefs } from "./claude-model-refs.js";
import type { readClaudeCliCredentialsForSetup } from "./cli-auth-seam.js";
import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS } 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 toAnthropicModelRef(raw: string): string | null {
return resolveClaudeCliAnthropicModelRefs(raw)?.rewriteRef ?? null;
@@ -180,47 +174,8 @@ function modelEntryWithClaudeCliRuntime(entry: unknown): Record<string, unknown>
return base;
}
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,
},
},
];
}
if (credential.type === "api_key_helper") {
return [];
}
return [
{
profileId: CLAUDE_CLI_PROFILE_ID,
credential: {
type: "token",
provider: CLAUDE_CLI_BACKEND_ID,
token: credential.token,
expires: credential.expires,
},
},
];
}
/** Build the config migration result for adopting Claude CLI-backed Anthropic defaults. */
export function buildAnthropicCliMigrationResult(
config: OpenClawConfig,
credential?: ClaudeCliCredential | null,
): ProviderAuthResult {
export function buildAnthropicCliMigrationResult(config: OpenClawConfig): ProviderAuthResult {
const defaults = config.agents?.defaults;
const rewrittenModel = rewriteModelSelection(defaults?.model);
const rewrittenModels = rewriteModelEntryMap(defaults?.models);
@@ -235,7 +190,7 @@ export function buildAnthropicCliMigrationResult(
const defaultModel = rewrittenModel.primary ?? "anthropic/claude-opus-5";
return {
profiles: buildClaudeCliAuthProfiles(credential),
profiles: [],
configPatch: {
agents: {
defaults: {
+1 -1
View File
@@ -881,7 +881,7 @@ describe("normalizeClaudeBackendConfig", () => {
expect(backend.config.clearEnv).toContain("ANTHROPIC_BASE_URL");
expect(backend.config.clearEnv).toContain("ANTHROPIC_CUSTOM_HEADERS");
expect(backend.config.clearEnv).toContain("ANTHROPIC_OAUTH_TOKEN");
expect(backend.config.clearEnv).toContain("CLAUDE_CONFIG_DIR");
expect(backend.config.clearEnv).not.toContain("CLAUDE_CONFIG_DIR");
expect(backend.config.clearEnv).toContain("CLAUDE_CODE_AUTO_COMPACT_WINDOW");
expect(backend.config.clearEnv).toContain("CLAUDE_CODE_USE_BEDROCK");
expect(backend.config.clearEnv).toContain("CLAUDE_CODE_OAUTH_TOKEN");
+2 -2
View File
@@ -22,7 +22,8 @@ export {
// 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.
// endpoint, token source, plugin source, or telemetry bootstrap mode. Claude's
// config directory remains inherited because it owns the selected native login.
/** Environment variables removed before launching OpenClaw-managed Claude CLI runs. */
export const CLAUDE_CLI_CLEAR_ENV = [
"ANTHROPIC_API_KEY",
@@ -33,7 +34,6 @@ export const CLAUDE_CLI_CLEAR_ENV = [
"ANTHROPIC_CUSTOM_HEADERS",
"ANTHROPIC_OAUTH_TOKEN",
"ANTHROPIC_UNIX_SOCKET",
"CLAUDE_CONFIG_DIR",
// Re-injected per run from OpenClaw's canonical context budget.
"CLAUDE_CODE_AUTO_COMPACT_WINDOW",
// Re-injected only for 200K runs. Claude's user settings `env` block has
+14
View File
@@ -4,6 +4,7 @@ import { listAgentIds, resolveAgentConfig } from "openclaw/plugin-sdk/agent-scop
* model refs and cache-retention params based on configured auth mode.
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
import { CLAUDE_CLI_PROFILE_ID } from "openclaw/plugin-sdk/provider-auth";
import {
isRecord,
normalizeLowercaseStringOrEmpty,
@@ -32,6 +33,9 @@ function resolveAnthropicDefaultAuthMode(
config: OpenClawConfig,
env: NodeJS.ProcessEnv,
): "api_key" | "oauth" | null {
if (usesRetiredClaudeCliProviderEntry(config)) {
return "oauth";
}
const profiles = config.auth?.profiles ?? {};
const anthropicProfiles = Object.entries(profiles).filter(
([, profile]) =>
@@ -85,6 +89,13 @@ function resolveAnthropicDefaultAuthMode(
return null;
}
function usesRetiredClaudeCliProviderEntry(config: OpenClawConfig): boolean {
return Object.entries(config.models?.providers ?? {}).some(
([provider, entry]) =>
normalizeProviderId(provider) === "anthropic" && entry.apiKey === CLAUDE_CLI_PROFILE_ID,
);
}
function resolveModelPrimaryValue(
value: string | { primary?: string; fallbacks?: string[] } | undefined,
): string | undefined {
@@ -159,6 +170,9 @@ function usesClaudeCliModelSelection(config: OpenClawConfig): boolean {
}
function usesSelectedClaudeCliAuthProfile(config: OpenClawConfig): boolean {
if (usesRetiredClaudeCliProviderEntry(config)) {
return true;
}
const profiles = config.auth?.profiles ?? {};
const orderedProfileIds = [
...(config.auth?.order?.anthropic ?? []),
+48 -91
View File
@@ -12,28 +12,23 @@ import {
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
const { readClaudeCliCredentialsForSetupMock, readClaudeCliCredentialsForRuntimeMock } = vi.hoisted(
() => ({
readClaudeCliCredentialsForSetupMock: vi.fn(),
readClaudeCliCredentialsForRuntimeMock: vi.fn(),
}),
);
const { probeClaudeCliAuthStatusMock } = vi.hoisted(() => ({
probeClaudeCliAuthStatusMock: vi.fn(),
}));
vi.mock("./cli-auth-seam.js", () => {
return {
readClaudeCliCredentialsForSetup: readClaudeCliCredentialsForSetupMock,
readClaudeCliCredentialsForRuntime: readClaudeCliCredentialsForRuntimeMock,
probeClaudeCliAuthStatus: probeClaudeCliAuthStatusMock,
};
});
import { buildClaudeCliCatalogEntries } from "./cli-catalog.js";
import { CLAUDE_CLI_API_KEY_HELPER_AUTH_MARKER } from "./cli-constants.js";
import { CLAUDE_CLI_NATIVE_AUTH_MARKER } from "./cli-constants.js";
import anthropicPlugin from "./index.js";
import anthropicProviderDiscovery from "./provider-discovery.js";
beforeEach(() => {
readClaudeCliCredentialsForSetupMock.mockReset();
readClaudeCliCredentialsForRuntimeMock.mockReset();
probeClaudeCliAuthStatusMock.mockReset();
});
afterAll(() => {
@@ -104,6 +99,12 @@ describe("anthropic provider replay hooks", () => {
expect(backend.config.reliability?.watchdog?.resume).toBeUndefined();
});
it("declares the copied Claude CLI profile as retired", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
expect(provider.deprecatedProfileIds).toEqual(["anthropic:claude-cli"]);
});
it("lets native session discovery be disabled without disabling Anthropic", () => {
const registerCliBackend = vi.fn();
const registerNodeHostCommand = vi.fn();
@@ -404,6 +405,35 @@ describe("anthropic provider replay hooks", () => {
}
});
it("backfills Claude CLI routing from a retired provider-entry profile reference", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const next = provider.applyConfigDefaults?.({
provider: "anthropic",
env: {},
config: {
models: {
providers: {
anthropic: {
baseUrl: "https://api.anthropic.com",
apiKey: "anthropic:claude-cli",
models: [],
},
},
},
agents: {
defaults: {
model: { primary: "anthropic/claude-sonnet-4-6" },
},
},
},
} as never);
expect(next?.agents?.defaults?.models?.["anthropic/claude-sonnet-4-6"]?.agentRuntime).toEqual({
id: "claude-cli",
});
});
it("backfills raw and canonical Claude CLI policies for provider-qualified shorthand refs", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
@@ -1374,62 +1404,7 @@ describe("anthropic provider replay hooks", () => {
}
});
it("resolves claude-cli synthetic oauth auth", async () => {
readClaudeCliCredentialsForRuntimeMock.mockReset();
readClaudeCliCredentialsForRuntimeMock.mockReturnValue({
type: "oauth",
provider: "anthropic",
access: "access-token",
refresh: "refresh-token",
expires: 123,
});
const provider = await registerSingleProviderPlugin(anthropicPlugin);
expect(
provider.resolveSyntheticAuth?.({
provider: "claude-cli",
} as never),
).toEqual({
apiKey: "access-token",
source: "Claude CLI native auth",
mode: "oauth",
expiresAt: 123,
});
expect(readClaudeCliCredentialsForRuntimeMock).toHaveBeenCalledTimes(1);
});
it("resolves claude-cli synthetic token auth", async () => {
readClaudeCliCredentialsForRuntimeMock.mockReset();
readClaudeCliCredentialsForRuntimeMock.mockReturnValue({
type: "token",
provider: "anthropic",
token: "bearer-token",
expires: 123,
});
const provider = await registerSingleProviderPlugin(anthropicPlugin);
expect(
provider.resolveSyntheticAuth?.({
provider: "claude-cli",
} as never),
).toEqual({
apiKey: "bearer-token",
source: "Claude CLI native auth",
mode: "token",
expiresAt: 123,
});
});
it("resolves claude-cli apiKeyHelper synthetic auth without exposing helper output", async () => {
readClaudeCliCredentialsForRuntimeMock.mockReset();
readClaudeCliCredentialsForRuntimeMock.mockReturnValue({
type: "api_key_helper",
provider: "anthropic",
helperHash: "helper-hash",
});
it("resolves claude-cli with a non-secret native auth marker", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const runtimeAuth = provider.resolveSyntheticAuth?.({
@@ -1439,21 +1414,14 @@ describe("anthropic provider replay hooks", () => {
provider: "claude-cli",
} as never);
for (const auth of [runtimeAuth, discoveryAuth]) {
expect(auth?.apiKey).toBe(CLAUDE_CLI_API_KEY_HELPER_AUTH_MARKER);
expect(auth?.source).toBe("Claude CLI apiKeyHelper");
expect(auth?.mode).toBe("api-key");
expect(auth?.apiKey).toBe(CLAUDE_CLI_NATIVE_AUTH_MARKER);
expect(auth?.source).toBe("Claude CLI native auth");
expect(auth?.mode).toBe("oauth");
}
});
it("stores a claude-cli auth profile during anthropic cli migration", async () => {
readClaudeCliCredentialsForSetupMock.mockReset();
readClaudeCliCredentialsForSetupMock.mockReturnValue({
type: "oauth",
provider: "anthropic",
access: "setup-access-token",
refresh: "refresh-token",
expires: 123,
});
it("does not copy native Claude auth during anthropic cli migration", async () => {
probeClaudeCliAuthStatusMock.mockReturnValue({ status: "available" });
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const cliAuth = provider.auth.find((entry) => entry.id === "cli");
@@ -1466,18 +1434,7 @@ describe("anthropic provider replay hooks", () => {
config: {},
} as never);
expect(result?.profiles).toEqual([
{
profileId: "anthropic:claude-cli",
credential: {
type: "oauth",
provider: "claude-cli",
access: "setup-access-token",
refresh: "refresh-token",
expires: 123,
},
},
]);
expect(result?.profiles).toEqual([]);
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
+1 -1
View File
@@ -258,7 +258,7 @@
},
"cliBackends": ["claude-cli"],
"syntheticAuthRefs": ["claude-cli"],
"nonSecretAuthMarkers": ["openclaw:claude-cli-api-key-helper"],
"nonSecretAuthMarkers": ["openclaw:claude-cli-native-auth"],
"setup": {
"providers": [
{
+6 -31
View File
@@ -3,41 +3,16 @@
* synthetic auth for catalog/runtime discovery without full Anthropic registration.
*/
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
import { readClaudeCliCredentialsForRuntime } from "./cli-auth-seam.js";
import { CLAUDE_CLI_API_KEY_HELPER_AUTH_MARKER } from "./cli-constants.js";
import { CLAUDE_CLI_NATIVE_AUTH_MARKER } from "./cli-constants.js";
const CLAUDE_CLI_BACKEND_ID = "claude-cli";
export function resolveClaudeCliSyntheticAuth() {
const credential = readClaudeCliCredentialsForRuntime();
if (!credential) {
return undefined;
}
switch (credential.type) {
case "oauth":
return {
apiKey: credential.access,
source: "Claude CLI native auth",
mode: "oauth" as const,
expiresAt: credential.expires,
};
case "token":
return {
apiKey: credential.token,
source: "Claude CLI native auth",
mode: "token" as const,
expiresAt: credential.expires,
};
case "api_key_helper": {
const marker = CLAUDE_CLI_API_KEY_HELPER_AUTH_MARKER;
return {
apiKey: marker,
source: "Claude CLI apiKeyHelper",
mode: "api-key" as const,
};
}
}
return undefined;
return {
apiKey: CLAUDE_CLI_NATIVE_AUTH_MARKER,
source: "Claude CLI native auth",
mode: "oauth" as const,
};
}
const anthropicProviderDiscovery: ProviderPlugin = {
+28 -10
View File
@@ -17,6 +17,7 @@ import {
applyAuthProfileConfig,
type AuthProfileStore,
buildTokenProfileId,
CLAUDE_CLI_PROFILE_ID,
createProviderApiKeyAuthMethod,
listProfilesForProvider,
type OpenClawConfig as ProviderAuthConfig,
@@ -128,7 +129,7 @@ const ANTHROPIC_SONNET_46_MODEL_ID = "claude-sonnet-4-6";
const ANTHROPIC_SONNET_46_DOT_MODEL_ID = "claude-sonnet-4.6";
const ANTHROPIC_SETUP_TOKEN_NOTE_LINES = [
"Anthropic setup-token auth is supported in OpenClaw.",
"OpenClaw prefers Claude CLI reuse when it is available on the host.",
"OpenClaw prefers the native Claude CLI runtime when it is available on the host.",
"Anthropic staff told us this OpenClaw path is allowed again.",
`If you want a direct API billing path instead, use ${formatCliCommand("openclaw models auth login --provider anthropic --method api-key --set-default")} or ${formatCliCommand("openclaw models auth login --provider anthropic --method cli --set-default")}.`,
] as const;
@@ -968,8 +969,10 @@ function buildAnthropicAuthDoctorHint(params: {
}
async function runAnthropicCliMigration(ctx: ProviderAuthContext): Promise<ProviderAuthResult> {
const credential = claudeCliAuth.readClaudeCliCredentialsForSetup();
if (!credential) {
const authStatus = claudeCliAuth.probeClaudeCliAuthStatus(
resolveAnthropicCliAuthProbe(ctx.env ?? process.env),
);
if (authStatus.status !== "available") {
throw new Error(
[
"Claude CLI is not authenticated on this host.",
@@ -977,7 +980,7 @@ async function runAnthropicCliMigration(ctx: ProviderAuthContext): Promise<Provi
].join("\n"),
);
}
return buildAnthropicCliMigrationResult(ctx.config, credential);
return buildAnthropicCliMigrationResult(ctx.config);
}
async function runAnthropicCliMigrationNonInteractive(ctx: {
@@ -985,13 +988,15 @@ async function runAnthropicCliMigrationNonInteractive(ctx: {
runtime: ProviderAuthContext["runtime"];
agentDir?: string;
}): Promise<ProviderAuthContext["config"] | null> {
const credentialResult = claudeCliAuth.readClaudeCliCredentialsForSetupNonInteractive();
if (credentialResult.status !== "available") {
const authStatus = claudeCliAuth.probeClaudeCliAuthStatus(
resolveAnthropicCliAuthProbe(process.env),
);
if (authStatus.status !== "available") {
const error =
credentialResult.status === "unreadable"
authStatus.status === "unreadable"
? [
'Auth choice "anthropic-cli" found Claude CLI credentials on this host, but they could not be read non-interactively.',
"Re-run this command without --non-interactive, or use --auth-choice setup-token / --anthropic-api-key <key>.",
'Auth choice "anthropic-cli" could not verify the installed Claude CLI login.',
`Run ${formatCliCommand("claude auth status")}, then retry.`,
]
: [
'Auth choice "anthropic-cli" requires Claude CLI auth on this host.',
@@ -1002,7 +1007,7 @@ async function runAnthropicCliMigrationNonInteractive(ctx: {
return null;
}
const result = buildAnthropicCliMigrationResult(ctx.config, credentialResult.credential);
const result = buildAnthropicCliMigrationResult(ctx.config);
const currentDefaults = ctx.config.agents?.defaults;
const currentModel = currentDefaults?.model;
const currentFallbacks =
@@ -1034,6 +1039,18 @@ async function runAnthropicCliMigrationNonInteractive(ctx: {
};
}
function resolveAnthropicCliAuthProbe(env: NodeJS.ProcessEnv): {
command: string;
env: NodeJS.ProcessEnv;
} {
const backend = buildAnthropicCliBackend().config;
const probeEnv = { ...env, ...backend.env };
for (const name of backend.clearEnv ?? []) {
delete probeEnv[name];
}
return { command: backend.command, env: probeEnv };
}
/** Build the full Anthropic provider descriptor used by runtime registration. */
export function buildAnthropicProvider(): ProviderPlugin {
const providerId = "anthropic";
@@ -1041,6 +1058,7 @@ export function buildAnthropicProvider(): ProviderPlugin {
return {
id: providerId,
label: "Anthropic",
deprecatedProfileIds: [CLAUDE_CLI_PROFILE_ID],
docsPath: "/providers/models",
hookAliases: [CLAUDE_CLI_BACKEND_ID],
envVars: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
+10 -26
View File
@@ -1,22 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { fetchAnthropicUsage, resolveAnthropicUsageAuth } from "./usage.js";
vi.mock("openclaw/plugin-sdk/provider-auth", async (importActual) => {
const actual = await importActual<typeof import("openclaw/plugin-sdk/provider-auth")>();
return {
...actual,
readClaudeCliCredentialsCached: vi.fn(() => ({
type: "oauth",
provider: "anthropic",
access: "cli-access",
refresh: "cli-refresh",
expires: Date.now() + 3_600_000,
subscriptionType: "max",
rateLimitTier: "default_max_20x",
})),
};
});
function requestUrl(input: string | URL | Request): URL {
return new URL(input instanceof Request ? input.url : input);
}
@@ -184,10 +168,8 @@ describe("Anthropic provider usage", () => {
});
});
it("falls back to the synced claude-cli OAuth profile when anthropic has none", async () => {
const resolveOAuthToken = vi.fn(async (params?: { provider?: string }) =>
params?.provider === "claude-cli" ? { token: "claude-cli-token" } : null,
);
it("does not refresh the native Claude login for usage polling", async () => {
const resolveOAuthToken = vi.fn(async () => null);
const result = await resolveAnthropicUsageAuth({
config: {},
env: {},
@@ -195,12 +177,14 @@ describe("Anthropic provider usage", () => {
resolveApiKeyFromConfigAndStore: () => undefined,
resolveOAuthToken,
});
expect(result).toEqual({ token: "claude-cli-token" });
expect(resolveOAuthToken).toHaveBeenNthCalledWith(1);
expect(resolveOAuthToken).toHaveBeenNthCalledWith(2, { provider: "claude-cli" });
expect(result).toEqual({ handled: true });
expect(resolveOAuthToken).toHaveBeenCalledOnce();
expect(resolveOAuthToken).toHaveBeenCalledWith({
excludeProfileIds: ["anthropic:claude-cli"],
});
});
it("prefers plan metadata from the resolved auth profile over CLI reads", async () => {
it("uses plan metadata from the resolved auth profile", async () => {
const fetchFn = vi.fn(
async () => new Response(JSON.stringify({ five_hour: { utilization: 10 } }), { status: 200 }),
);
@@ -217,7 +201,7 @@ describe("Anthropic provider usage", () => {
expect(snapshot.plan).toBe("Pro");
});
it("labels OAuth usage snapshots with the local Claude CLI plan", async () => {
it("does not read Claude CLI auth to label OAuth usage", async () => {
const fetchFn = vi.fn(
async () =>
new Response(
@@ -236,7 +220,7 @@ describe("Anthropic provider usage", () => {
timeoutMs: 5000,
fetchFn,
});
expect(snapshot.plan).toBe("Max (20x)");
expect(snapshot.plan).toBeUndefined();
expect(snapshot.windows).toHaveLength(2);
});
+9 -35
View File
@@ -4,7 +4,7 @@ import type {
ProviderResolvedUsageAuth,
} from "openclaw/plugin-sdk/plugin-entry";
import {
readClaudeCliCredentialsCached,
CLAUDE_CLI_PROFILE_ID,
validateAnthropicSetupToken,
} from "openclaw/plugin-sdk/provider-auth";
import {
@@ -24,7 +24,6 @@ import {
resolveProviderUsageDisplayName,
type ProviderUsageSnapshot,
} from "openclaw/plugin-sdk/provider-usage";
import { CLAUDE_CLI_BACKEND_ID } from "./cli-constants.js";
const ANTHROPIC_COST_URL = "https://api.anthropic.com/v1/organizations/cost_report";
const ANTHROPIC_MESSAGES_USAGE_URL =
@@ -253,22 +252,13 @@ export async function resolveAnthropicUsageAuth(
return { token: encodeAdminToken(storedAdminKey) };
}
const oauthToken = await ctx.resolveOAuthToken();
const oauthToken = await ctx.resolveOAuthToken({
excludeProfileIds: [CLAUDE_CLI_PROFILE_ID],
});
if (oauthToken) {
return oauthToken;
}
// Claude CLI-only setups have their keychain login synced under the
// claude-cli profile, not anthropic; without this fallback those setups
// never surface subscription usage windows. Usage snapshots are keyed per
// provider, so when a native anthropic OAuth account and a different Claude
// Code account coexist, the native account wins and both auth rows display
// its quota. Per-profile usage attribution is tracked in #102807.
const claudeCliToken = await ctx.resolveOAuthToken({ provider: CLAUDE_CLI_BACKEND_ID });
if (claudeCliToken) {
return claudeCliToken;
}
const apiKey = ctx.resolveApiKeyFromConfigAndStore();
const adminKey = normalizeAdminKey(apiKey);
if (adminKey) {
@@ -277,6 +267,9 @@ export async function resolveAnthropicUsageAuth(
if (apiKey && validateAnthropicSetupToken(apiKey) === undefined) {
return { token: apiKey };
}
// Claude owns its native refresh-token family. Do not resolve a copied
// claude-cli profile here: generic OAuth refresh invalidates Claude's login.
return { handled: true };
}
@@ -294,26 +287,8 @@ function formatClaudePlanLabel(
return tier ? `${label} (${tier})` : label;
}
// Best-effort plan label. Preferred source is plan metadata on the resolved
// auth profile (captured when the external CLI login was synced — the only
// prompt-free source on keychain-backed macOS installs). Fallback reads the
// Claude CLI credential file without keychain prompts for file-based logins.
// When multiple Claude accounts are in play the CLI login may differ from the
// profile that fetched usage; a mislabeled plan chip is acceptable, a second
// network call is not.
function resolveClaudePlanLabel(ctx: ProviderFetchUsageSnapshotContext): string | undefined {
const fromAuth = formatClaudePlanLabel(ctx.subscriptionType, ctx.rateLimitTier);
if (fromAuth) {
return fromAuth;
}
const credential = readClaudeCliCredentialsCached({
allowKeychainPrompt: false,
ttlMs: 5 * 60_000,
});
if (!credential || credential.type !== "oauth") {
return undefined;
}
return formatClaudePlanLabel(credential.subscriptionType, credential.rateLimitTier);
return formatClaudePlanLabel(ctx.subscriptionType, ctx.rateLimitTier);
}
export async function fetchAnthropicUsage(
@@ -331,8 +306,7 @@ export async function fetchAnthropicUsage(
if (snapshot.error) {
return snapshot;
}
// Identity is captured on the credential (profile store or the CLI-sync
// read), so a fetch-time ambient config read can never mislabel an account.
// Identity comes from the selected credential, so ambient config cannot mislabel an account.
const accountEmail = ctx.email;
// Plan labels stay window-gated: a windowless response has no plan quota to
// label, while the account identity is still worth surfacing.
-1
View File
@@ -18,7 +18,6 @@ const { readCodexCliCredentialsCachedMock, resolveProviderIdForAuthMock } = vi.h
}));
vi.mock("./cli-credentials.js", () => ({
readClaudeCliCredentialsCached: () => null,
readCodexCliCredentialsCached: readCodexCliCredentialsCachedMock,
readMiniMaxCliCredentialsCached: () => null,
resetCliCredentialCachesForTest: () => undefined,
@@ -5,12 +5,8 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AuthProfileStore, OAuthCredential } from "./auth-profiles/types.js";
import type { ClaudeCliCredential } from "./cli-credentials.js";
const mocks = vi.hoisted(() => ({
readClaudeCliCredentialsCached: vi.fn<(options?: unknown) => ClaudeCliCredential | null>(
() => null,
),
readCodexCliCredentialsCached: vi.fn<(options?: unknown) => OAuthCredential | null>(() => null),
readMiniMaxCliCredentialsCached: vi.fn<(options?: unknown) => OAuthCredential | null>(() => null),
}));
@@ -20,7 +16,6 @@ let resolveExternalCliAuthProfiles: typeof import("./auth-profiles/external-cli-
let hasUsableOAuthCredential: typeof import("./auth-profiles/credential-state.js").hasUsableOAuthCredential;
let shouldBootstrapFromExternalCliCredential: typeof import("./auth-profiles/oauth-shared.js").shouldBootstrapFromExternalCliCredential;
let shouldReplaceStoredOAuthCredential: typeof import("./auth-profiles/oauth-shared.js").shouldReplaceStoredOAuthCredential;
let CLAUDE_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").CLAUDE_CLI_PROFILE_ID;
let OPENAI_CODEX_DEFAULT_PROFILE_ID: typeof import("./auth-profiles/constants.js").OPENAI_CODEX_DEFAULT_PROFILE_ID;
let MINIMAX_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").MINIMAX_CLI_PROFILE_ID;
@@ -65,24 +60,6 @@ function expectSingleProfileCredential(
return credential as Record<string, unknown>;
}
function expectSingleProfile(
profiles: ReturnType<typeof resolveExternalCliAuthProfiles>,
profileId: string,
) {
expect(profiles).toStrictEqual([
{
credential: expect.any(Object),
persistence: profileId === OPENAI_CODEX_DEFAULT_PROFILE_ID ? "runtime-only" : "persisted",
profileId,
},
]);
const profile = profiles[0];
if (!profile?.credential) {
throw new Error(`Expected credential for profile ${profileId}`);
}
return profile;
}
function expectCredentialFields(
credential: Record<string, unknown> | undefined,
expected: Record<string, unknown>,
@@ -110,11 +87,9 @@ describe("external cli oauth resolution", () => {
beforeEach(async () => {
vi.resetModules();
vi.doMock("./cli-credentials.js", () => ({
readClaudeCliCredentialsCached: mocks.readClaudeCliCredentialsCached,
readCodexCliCredentialsCached: mocks.readCodexCliCredentialsCached,
readMiniMaxCliCredentialsCached: mocks.readMiniMaxCliCredentialsCached,
}));
mocks.readClaudeCliCredentialsCached.mockReset().mockReturnValue(null);
mocks.readCodexCliCredentialsCached.mockReset().mockReturnValue(null);
mocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null);
({ readExternalCliBootstrapCredential, resolveExternalCliAuthProfiles } =
@@ -122,7 +97,7 @@ describe("external cli oauth resolution", () => {
({ hasUsableOAuthCredential } = await import("./auth-profiles/credential-state.js"));
({ shouldBootstrapFromExternalCliCredential, shouldReplaceStoredOAuthCredential } =
await import("./auth-profiles/oauth-shared.js"));
({ CLAUDE_CLI_PROFILE_ID, OPENAI_CODEX_DEFAULT_PROFILE_ID, MINIMAX_CLI_PROFILE_ID } =
({ OPENAI_CODEX_DEFAULT_PROFILE_ID, MINIMAX_CLI_PROFILE_ID } =
await import("./auth-profiles/constants.js"));
});
@@ -419,70 +394,6 @@ describe("external cli oauth resolution", () => {
expect(credential).toBeNull();
});
it("normalizes Claude CLI oauth credentials into the managed Claude profile", () => {
mocks.readClaudeCliCredentialsCached.mockReturnValue({
type: "oauth",
provider: "anthropic",
access: "claude-cli-access",
refresh: "claude-cli-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
});
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
providerIds: ["claude-cli"],
});
const profile = expectSingleProfile(profiles, CLAUDE_CLI_PROFILE_ID);
expect(profile?.persistence).toBe("persisted");
expectCredentialFields(profile?.credential as Record<string, unknown>, {
type: "oauth",
provider: "claude-cli",
access: "claude-cli-access",
refresh: "claude-cli-refresh",
});
});
it("keeps provider discovery when requested profiles belong to another provider", () => {
const expires = Date.now() + 5 * 24 * 60 * 60_000;
const claudeCredential: ClaudeCliCredential = {
type: "oauth",
provider: "anthropic",
access: "claude-cli-access",
refresh: "claude-cli-refresh",
expires,
};
const codexCredential = makeOAuthCredential({
provider: "openai",
access: "codex-cli-access",
refresh: "codex-cli-refresh",
expires,
});
mocks.readClaudeCliCredentialsCached.mockReturnValue(claudeCredential);
mocks.readCodexCliCredentialsCached.mockReturnValue(codexCredential);
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
providerIds: ["claude-cli", "openai"],
profileIds: [CLAUDE_CLI_PROFILE_ID],
allowKeychainPrompt: false,
});
expect(profiles).toStrictEqual([
{
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
credential: codexCredential,
persistence: "runtime-only",
},
{
profileId: CLAUDE_CLI_PROFILE_ID,
credential: { ...claudeCredential, provider: "claude-cli" },
persistence: "persisted",
},
]);
expectReaderPolicyCall(mocks.readCodexCliCredentialsCached);
expectReaderPolicyCall(mocks.readClaudeCliCredentialsCached);
expect(mocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled();
});
it("skips external cli readers outside the scoped provider set", () => {
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
providerIds: ["opencode-go"],
@@ -490,100 +401,6 @@ describe("external cli oauth resolution", () => {
expect(profiles).toStrictEqual([]);
expect(mocks.readCodexCliCredentialsCached).not.toHaveBeenCalled();
expect(mocks.readClaudeCliCredentialsCached).not.toHaveBeenCalled();
expect(mocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled();
});
it("does not scan missing external CLI profiles without an explicit scope", () => {
mocks.readClaudeCliCredentialsCached.mockReturnValue({
type: "oauth",
provider: "anthropic",
access: "claude-cli-access",
refresh: "claude-cli-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
});
const profiles = resolveExternalCliAuthProfiles(makeStore());
expect(profiles).toStrictEqual([]);
expect(mocks.readClaudeCliCredentialsCached).not.toHaveBeenCalled();
});
it("refreshes a stored external CLI profile without an explicit scope", () => {
mocks.readClaudeCliCredentialsCached.mockReturnValue({
type: "oauth",
provider: "anthropic",
access: "claude-cli-fresh-access",
refresh: "claude-cli-fresh-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
});
const profiles = resolveExternalCliAuthProfiles(
makeStore(CLAUDE_CLI_PROFILE_ID, {
type: "oauth",
provider: "claude-cli",
access: "claude-cli-stale-access",
refresh: "claude-cli-stale-refresh",
expires: Date.now() - 5_000,
}),
);
const profile = expectSingleProfile(profiles, CLAUDE_CLI_PROFILE_ID);
expect(profile?.persistence).toBe("persisted");
expectCredentialFields(profile?.credential as Record<string, unknown>, {
provider: "claude-cli",
access: "claude-cli-fresh-access",
});
});
it("does not reread external CLI credentials for a usable stored managed profile", () => {
mocks.readClaudeCliCredentialsCached.mockReturnValue({
type: "oauth",
provider: "anthropic",
access: "external-access",
refresh: "external-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
});
const profiles = resolveExternalCliAuthProfiles(
makeStore(CLAUDE_CLI_PROFILE_ID, {
type: "oauth",
provider: "claude-cli",
access: "usable-local-access",
refresh: "usable-local-refresh",
expires: Date.now() + 10 * 60_000,
// Identity-complete steady state; profiles missing the email get one
// bounded backfill read (external-cli-sync.email-backfill.test.ts).
email: "stored@example.com",
}),
);
expect(profiles).toStrictEqual([]);
expect(mocks.readClaudeCliCredentialsCached).not.toHaveBeenCalled();
});
it("passes non-prompting keychain policy to scoped Claude CLI credential reads", () => {
mocks.readClaudeCliCredentialsCached.mockReturnValue({
type: "oauth",
provider: "anthropic",
access: "claude-cli-access",
refresh: "claude-cli-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
});
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
providerIds: ["claude-cli"],
allowKeychainPrompt: false,
});
const profile = expectSingleProfile(profiles, CLAUDE_CLI_PROFILE_ID);
expect(profile?.persistence).toBe("persisted");
expectCredentialFields(profile?.credential as Record<string, unknown>, {
type: "oauth",
provider: "claude-cli",
});
expectReaderPolicyCall(mocks.readClaudeCliCredentialsCached);
expect(mocks.readCodexCliCredentialsCached).not.toHaveBeenCalled();
expect(mocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled();
});
@@ -609,41 +426,9 @@ describe("external cli oauth resolution", () => {
},
);
expectReaderPolicyCall(mocks.readCodexCliCredentialsCached);
expect(mocks.readClaudeCliCredentialsCached).not.toHaveBeenCalled();
expect(mocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled();
});
it("ignores Claude CLI token credentials", () => {
mocks.readClaudeCliCredentialsCached.mockReturnValue({
type: "token",
provider: "anthropic",
token: "claude-cli-token",
expires: Date.now() + 5 * 24 * 60 * 60_000,
});
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
providerIds: ["claude-cli"],
});
expect(profiles).toStrictEqual([]);
});
it("ignores Claude CLI apiKeyHelper credentials for OAuth profile sync", () => {
mocks.readClaudeCliCredentialsCached.mockReturnValue({
type: "api_key_helper",
provider: "anthropic",
helperHash: "helper-hash",
});
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
providerIds: ["claude-cli"],
allowKeychainPrompt: false,
});
expect(profiles).toStrictEqual([]);
expectReaderPolicyCall(mocks.readClaudeCliCredentialsCached);
});
it("resolves fresher minimax external oauth profiles as runtime overlays", () => {
mocks.readMiniMaxCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
@@ -10,7 +10,6 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
vi.mock("./cli-credentials.js", () => ({
readClaudeCliCredentialsCached: () => null,
readCodexCliCredentialsCached: () => null,
readMiniMaxCliCredentialsCached: () => null,
}));
+6 -61
View File
@@ -8,12 +8,7 @@ import type { ProviderExternalAuthProfile } from "../../plugins/provider-externa
import { resolveExternalAuthProfilesWithPlugins } from "../../plugins/provider-runtime.js";
import { isAmbientCredentialAllowedByProviderAuthPin } from "./ambient-auth.js";
import { cloneAuthProfileStore } from "./clone.js";
import { CLAUDE_CLI_PROFILE_ID, MINIMAX_CLI_PROFILE_ID } from "./constants.js";
import {
isUsablePersistedExternalCliProfileCredential,
listConfiguredExternalCliProfileMetadataIds,
listExternalCliProfileMetadataIds,
} from "./external-cli-profile-metadata.js";
import { MINIMAX_CLI_PROFILE_ID } from "./constants.js";
import * as externalCliSync from "./external-cli-sync.js";
import {
areOAuthCredentialsEquivalent,
@@ -115,46 +110,13 @@ function resolveExternalAuthProfiles(params: {
store: params.store,
},
});
const configuredProfileIds = listConfiguredExternalCliProfileMetadataIds(
params.externalCli?.config?.auth?.profiles,
);
const externalCli = configuredProfileIds.length
? {
...params.externalCli,
externalCliProfileIds: [
...(params.externalCli?.externalCliProfileIds ?? []),
...configuredProfileIds,
],
}
: params.externalCli;
const externalCli = params.externalCli;
const resolved = resolveExternalCliAuthProfileMap({ ...params, externalCli });
const runtimeExternalCliProfileIds = new Set(
[...resolved.values()]
.filter((profile) => profile.persistence !== "persisted")
.map((profile) => profile.profileId),
);
// A persisted Claude CLI profile may be usable and identity-complete, in which
// case its resolver intentionally avoids rereading the CLI and emits no overlay.
// Its canonical profile slot still establishes refresh ownership
// after a process restart, when runtime-only provenance is no longer available.
for (const profileId of listExternalCliProfileMetadataIds()) {
const credential = params.store.profiles[profileId];
const hasUsablePersistedCliCredential = isUsablePersistedExternalCliProfileCredential(
profileId,
credential,
);
if (
(resolved.has(profileId) || hasUsablePersistedCliCredential) &&
externalCliSync.isExternalCliAuthProfileInScope({
store: params.store,
profileId,
providerIds: externalCli?.externalCliProviderIds,
profileIds: externalCli?.externalCliProfileIds,
})
) {
runtimeExternalCliProfileIds.add(profileId);
}
}
const pluginProfileIds = new Set<string>();
const explicitProfileIds = resolveExplicitProfileIds(params.externalCli?.externalCliProfileIds);
for (const rawProfile of profiles) {
@@ -246,16 +208,10 @@ function hasPersistableExternalCliSyncCandidate(
if (params?.externalCliProviderIds || params?.externalCliProfileIds) {
return true;
}
// Keep the existing Claude and MiniMax steady-state sync trigger independent
// from the narrower legacy Claude metadata migration/recovery registry.
for (const profileId of [CLAUDE_CLI_PROFILE_ID, MINIMAX_CLI_PROFILE_ID]) {
// MiniMax keeps its persisted external profile fresh without an explicit scope.
for (const profileId of [MINIMAX_CLI_PROFILE_ID]) {
const credential = store.profiles[profileId];
if (
credential?.type === "oauth" ||
listConfiguredExternalCliProfileMetadataIds(params?.config?.auth?.profiles).includes(
profileId,
)
) {
if (credential?.type === "oauth") {
return true;
}
}
@@ -321,21 +277,10 @@ export function syncPersistedExternalCliAuthProfiles(
if (!hasPersistableExternalCliSyncCandidate(store, params)) {
return store;
}
const configuredProfileIds = listConfiguredExternalCliProfileMetadataIds(
params?.config?.auth?.profiles,
);
const persistedProfiles = resolveAllowedExternalCliAuthProfiles({
store,
env: params?.env,
externalCli: configuredProfileIds.length
? {
...params,
externalCliProfileIds: [
...(params?.externalCliProfileIds ?? []),
...configuredProfileIds,
],
}
: params,
externalCli: params,
}).filter((profile) => profile.persistence === "persisted");
if (persistedProfiles.length === 0) {
return store;
@@ -1,77 +0,0 @@
/** Canonical metadata for the legacy built-in Claude CLI auth profile slot. */
import type { AuthProfileConfig } from "../../config/types.auth.js";
import { CLAUDE_CLI_PROFILE_ID } from "./constants.js";
import { hasUsableOAuthCredential } from "./credential-state.js";
import type { AuthProfileCredential } from "./types.js";
type ExternalCliProfileMetadata = Pick<AuthProfileConfig, "provider" | "mode">;
const EXTERNAL_CLI_PROFILE_METADATA = new Map<
string,
{
provider: string;
legacyProviders: readonly string[];
}
>([
[CLAUDE_CLI_PROFILE_ID, { provider: "claude-cli", legacyProviders: ["anthropic", "claude-cli"] }],
]);
export function listExternalCliProfileMetadataIds(): string[] {
return [...EXTERNAL_CLI_PROFILE_METADATA.keys()];
}
/**
* Converts only the known pre-OAuth metadata spelling for a built-in CLI slot.
* Other configured profiles remain user-owned and must never be reclassified.
*/
export function normalizeExternalCliProfileMetadata(
profileId: string,
profile: AuthProfileConfig | undefined,
): ExternalCliProfileMetadata | undefined {
const definition = EXTERNAL_CLI_PROFILE_METADATA.get(profileId);
if (!definition || !profile) {
return undefined;
}
const provider = profile.provider.trim().toLowerCase();
if (!definition.legacyProviders.includes(provider)) {
return undefined;
}
if (profile.mode === "oauth") {
return { provider: definition.provider, mode: "oauth" };
}
if (profile.mode === "token") {
return { provider: definition.provider, mode: "oauth" };
}
return undefined;
}
export function listConfiguredExternalCliProfileMetadataIds(
profiles: Record<string, AuthProfileConfig> | undefined,
): string[] {
if (!profiles) {
return [];
}
return listExternalCliProfileMetadataIds().filter((profileId) =>
Boolean(normalizeExternalCliProfileMetadata(profileId, profiles[profileId])),
);
}
/**
* A persisted CLI credential can re-establish refresh ownership only when it
* is current, bound to the expected CLI provider family, and identity-complete.
*/
export function isUsablePersistedExternalCliProfileCredential(
profileId: string,
credential: AuthProfileCredential | undefined,
): boolean {
const definition = EXTERNAL_CLI_PROFILE_METADATA.get(profileId);
if (!definition || credential?.type !== "oauth") {
return false;
}
const provider = credential.provider.trim().toLowerCase();
return (
definition.legacyProviders.includes(provider) &&
hasUsableOAuthCredential(credential) &&
Boolean(credential.accountId?.trim() || credential.email?.trim())
);
}
@@ -1,71 +0,0 @@
/** Upgrade path: pre-identity claude-cli profiles gain the CLI account email. */
import { describe, expect, it, vi } from "vitest";
import type { AuthProfileStore } from "./types.js";
const readClaudeCliCredentialsCachedMock = vi.fn();
vi.mock("../cli-credentials.js", async (importActual) => {
const actual = await importActual<typeof import("../cli-credentials.js")>();
return {
...actual,
readClaudeCliCredentialsCached: readClaudeCliCredentialsCachedMock,
};
});
const { resolveExternalCliAuthProfiles } = await import("./external-cli-sync.js");
function refreshFixture(): string {
return ["stored", "refresh"].join("-");
}
function storeWithClaudeProfile(): AuthProfileStore {
return {
version: 1,
profiles: {
"anthropic:claude-cli": {
type: "oauth",
provider: "claude-cli",
access: ["stored", "access"].join("-"),
refresh: refreshFixture(),
expires: Date.now() + 3_600_000,
},
},
} as unknown as AuthProfileStore;
}
describe("external cli sync email backfill", () => {
it("backfills the email onto a usable stored profile from the same login", () => {
readClaudeCliCredentialsCachedMock.mockReturnValue({
type: "oauth",
provider: "anthropic",
access: ["rotated", "access"].join("-"),
refresh: refreshFixture(),
expires: Date.now() + 3_600_000,
email: "cli-login@example.com",
});
const profiles = resolveExternalCliAuthProfiles(storeWithClaudeProfile());
const backfilledEmail = "cli-login@example.com";
expect(profiles).toEqual([
{
profileId: "anthropic:claude-cli",
credential: expect.objectContaining({ email: backfilledEmail }),
persistence: "persisted",
},
]);
});
it("does not backfill from a different CLI login", () => {
readClaudeCliCredentialsCachedMock.mockReturnValue({
type: "oauth",
provider: "anthropic",
access: ["other", "access"].join("-"),
refresh: ["other", "refresh"].join("-"),
expires: Date.now() + 3_600_000,
email: "someone-else@example.com",
});
expect(resolveExternalCliAuthProfiles(storeWithClaudeProfile())).toEqual([]);
});
});
+11 -23
View File
@@ -5,12 +5,10 @@
*/
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import {
readClaudeCliCredentialsCached,
readCodexCliCredentialsCached,
readMiniMaxCliCredentialsCached,
} from "../cli-credentials.js";
import {
CLAUDE_CLI_PROFILE_ID,
EXTERNAL_CLI_SYNC_TTL_MS,
MINIMAX_CLI_PROFILE_ID,
OPENAI_CODEX_DEFAULT_PROFILE_ID,
@@ -51,13 +49,12 @@ type ExternalCliSyncProvider = {
// CLI state must not replace or shadow it. Codex requires this to
// avoid clobbering a locally refreshed token with stale CLI state.
bootstrapOnly?: boolean;
persistence?: ExternalCliResolvedProfile["persistence"];
};
// Keep this gate aligned with the canonical identity-copy rule in oauth.ts.
// Also the passthrough gate in cli-runner/prepare.ts: a live CLI login that
// this sync would refuse to import must not authenticate a run either.
// External CLI bootstrap must never replace a local profile with another identity.
/** Return true when imported CLI credentials match an existing profile identity. */
export function isSafeToUseExternalCliCredential(
function isSafeToUseExternalCliCredential(
existing: OAuthCredential | undefined,
imported: OAuthCredential,
): boolean {
@@ -83,21 +80,6 @@ const EXTERNAL_CLI_SYNC_PROVIDERS: ExternalCliSyncProvider[] = [
}),
bootstrapOnly: true,
},
{
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "claude-cli",
aliases: ["anthropic"],
readCredentials: (options) => {
const credential = readClaudeCliCredentialsCached({
ttlMs: EXTERNAL_CLI_SYNC_TTL_MS,
allowKeychainPrompt: options?.allowKeychainPrompt,
});
if (credential?.type !== "oauth") {
return null;
}
return { ...credential, provider: "claude-cli" };
},
},
{
profileId: MINIMAX_CLI_PROFILE_ID,
provider: "minimax-portal",
@@ -399,7 +381,11 @@ export function resolveExternalCliAuthProfiles(
allowKeychainPrompt: options?.allowKeychainPrompt,
});
if (backfilled) {
profiles.push({ profileId, credential: backfilled, persistence: "persisted" });
profiles.push({
profileId,
credential: backfilled,
persistence: providerConfig.persistence ?? "persisted",
});
}
continue;
}
@@ -462,7 +448,9 @@ export function resolveExternalCliAuthProfiles(
profiles.push({
profileId,
credential: creds,
persistence: providerConfig.bootstrapOnly ? "runtime-only" : "persisted",
persistence:
providerConfig.persistence ??
(providerConfig.bootstrapOnly ? "runtime-only" : "persisted"),
});
}
}
@@ -30,15 +30,11 @@ const readCodexCliCredentialsCachedMock = vi.hoisted(() => {
vi.resetModules();
return vi.fn<(_options?: unknown) => OAuthCredential | null>(() => null);
});
const readClaudeCliCredentialsCachedMock = vi.hoisted(() =>
vi.fn<(_options?: unknown) => OAuthCredential | null>(() => null),
);
const readMiniMaxCliCredentialsCachedMock = vi.hoisted(() =>
vi.fn<(_options?: unknown) => OAuthCredential | null>(() => null),
);
vi.mock("../cli-credentials.js", () => ({
readClaudeCliCredentialsCached: readClaudeCliCredentialsCachedMock,
readCodexCliCredentialsCached: readCodexCliCredentialsCachedMock,
readMiniMaxCliCredentialsCached: readMiniMaxCliCredentialsCachedMock,
}));
@@ -76,8 +72,6 @@ describe("auth external oauth helpers", () => {
resolveExternalAuthProfilesWithPluginsMock.mockReturnValue([]);
readCodexCliCredentialsCachedMock.mockReset();
readCodexCliCredentialsCachedMock.mockReturnValue(null);
readClaudeCliCredentialsCachedMock.mockReset();
readClaudeCliCredentialsCachedMock.mockReturnValue(null);
readMiniMaxCliCredentialsCachedMock.mockReset();
readMiniMaxCliCredentialsCachedMock.mockReturnValue(null);
testing.setResolveExternalAuthProfilesForTest(resolveExternalAuthProfilesWithPluginsMock);
@@ -175,84 +169,6 @@ describe("auth external oauth helpers", () => {
expect(getRuntimeExternalCliProfileIds(loggedOut)).toEqual([]);
});
it("marks a refreshed persisted Claude CLI profile as runtime CLI-owned", () => {
const profileId = "anthropic:claude-cli";
const refresh = "claude-cli-refresh";
readClaudeCliCredentialsCachedMock.mockReturnValueOnce(
createCredential({
provider: "anthropic",
access: "fresh-claude-access",
refresh,
expires: createUsableOAuthExpiry(),
}),
);
const prepared = overlayExternalAuthProfiles(
createStore({
[profileId]: createCredential({
provider: "claude-cli",
access: "expired-claude-access",
refresh,
expires: Date.now() - 60_000,
}),
}),
{ externalCliProviderIds: ["claude-cli"] },
);
expect(prepared.profiles[profileId]).toMatchObject({
provider: "claude-cli",
access: "fresh-claude-access",
});
expect(getRuntimeExternalCliProfileIds(prepared)).toEqual([profileId]);
});
it("bootstraps a missing Claude CLI profile from an Anthropic provider scope", () => {
const profileId = "anthropic:claude-cli";
readClaudeCliCredentialsCachedMock.mockReturnValueOnce(
createCredential({
provider: "anthropic",
access: "fresh-claude-access",
refresh: "fresh-claude-refresh",
expires: createUsableOAuthExpiry(),
}),
);
const prepared = overlayExternalAuthProfiles(createStore(), {
externalCliProviderIds: ["anthropic"],
});
expect(prepared.profiles[profileId]).toMatchObject({
provider: "claude-cli",
access: "fresh-claude-access",
});
expect(getRuntimeExternalCliProfileIds(prepared)).toEqual([profileId]);
});
it("recovers legacy Claude metadata after restart when no cached profile was persisted", () => {
const profileId = "anthropic:claude-cli";
readClaudeCliCredentialsCachedMock.mockReturnValueOnce(
createCredential({
provider: "anthropic",
access: "rotated-claude-access",
refresh: "rotated-claude-refresh",
expires: createUsableOAuthExpiry(),
}),
);
const restarted = overlayExternalAuthProfiles(createStore(), {
config: {
auth: { profiles: { [profileId]: { provider: "anthropic", mode: "token" } } },
},
});
expect(restarted.profiles[profileId]).toMatchObject({
type: "oauth",
provider: "claude-cli",
access: "rotated-claude-access",
});
expect(getRuntimeExternalCliProfileIds(restarted)).toEqual([profileId]);
});
it("does not reinterpret legacy MiniMax metadata as managed CLI ownership", () => {
const profileId = "minimax-portal:minimax-cli";
readMiniMaxCliCredentialsCachedMock.mockReturnValueOnce(
@@ -333,95 +249,6 @@ describe("auth external oauth helpers", () => {
expect(getRuntimeExternalCliProfileIds(prepared)).toEqual([]);
});
it("recovers an identity-less persisted Claude profile after restart and access expiry", () => {
const profileId = "anthropic:claude-cli";
const config = {
auth: { profiles: { [profileId]: { provider: "anthropic", mode: "token" as const } } },
};
const restarted = overlayExternalAuthProfiles(
createStore({
[profileId]: createCredential({
provider: "claude-cli",
access: "persisted-access",
refresh: "persisted-refresh",
expires: createUsableOAuthExpiry(),
}),
}),
{ config },
);
expect(getRuntimeExternalCliProfileIds(restarted)).toEqual([]);
readClaudeCliCredentialsCachedMock.mockReset().mockReturnValueOnce(
createCredential({
provider: "anthropic",
access: "rotated-access",
refresh: "rotated-refresh",
expires: createUsableOAuthExpiry(),
}),
);
const recovered = overlayExternalAuthProfiles(
{
...restarted,
profiles: {
...restarted.profiles,
[profileId]: {
...restarted.profiles[profileId],
type: "oauth",
provider: "claude-cli",
access: "persisted-access",
refresh: "persisted-refresh",
expires: Date.now() - 60_000,
},
},
},
{ config },
);
expect(recovered.profiles[profileId]).toMatchObject({
provider: "claude-cli",
access: "rotated-access",
refresh: "rotated-refresh",
});
expect(getRuntimeExternalCliProfileIds(recovered)).toEqual([profileId]);
});
it("restores runtime CLI ownership for a steady-state persisted Claude profile", () => {
const profileId = "anthropic:claude-cli";
const prepared = overlayExternalAuthProfiles(
createStore({
[profileId]: createCredential({
provider: "claude-cli",
access: "usable-claude-access",
refresh: "usable-claude-refresh",
expires: createUsableOAuthExpiry(),
email: "stored@example.com",
}),
}),
);
expect(readClaudeCliCredentialsCachedMock).not.toHaveBeenCalled();
expect(getRuntimeExternalCliProfileIds(prepared)).toEqual([profileId]);
});
it("does not retain CLI refresh ownership for an expired persisted Claude profile", () => {
const profileId = "anthropic:claude-cli";
const prepared = overlayExternalAuthProfiles(
createStore({
[profileId]: createCredential({
provider: "claude-cli",
access: "expired-claude-access",
refresh: "expired-claude-refresh",
expires: Date.now() - 60_000,
email: "stored@example.com",
}),
}),
);
expect(readClaudeCliCredentialsCachedMock).toHaveBeenCalledTimes(1);
expect(getRuntimeExternalCliProfileIds(prepared)).toEqual([]);
});
it("preserves a plugin winner that collides with a built-in CLI profile id", () => {
readCodexCliCredentialsCachedMock.mockReturnValue(
createCredential({ access: "cli-access", refresh: "cli-refresh" }),
@@ -22,7 +22,6 @@ export function getOAuthProviderRuntimeMocks() {
}
vi.mock("../cli-credentials.js", () => ({
readClaudeCliCredentialsCached: () => null,
readCodexCliCredentialsCached: () => null,
readMiniMaxCliCredentialsCached: () => null,
resetCliCredentialCachesForTest: () => undefined,
@@ -53,7 +52,6 @@ vi.mock("./external-cli-sync.js", () => ({
credential.access.trim().length > 0 &&
Number.isFinite(credential.expires) &&
credential.expires - now > 5 * 60 * 1000,
isSafeToUseExternalCliCredential: () => true,
readExternalCliBootstrapCredential: () => null,
resolveExternalCliAuthProfiles: () => [],
shouldBootstrapFromExternalCliCredential: () => false,
@@ -30,7 +30,6 @@ vi.mock("../../llm/oauth.js", () => ({
}));
vi.mock("../cli-credentials.js", () => ({
readClaudeCliCredentialsCached: () => null,
readCodexCliCredentialsCached: () => null,
readMiniMaxCliCredentialsCached: () => null,
resetCliCredentialCachesForTest: () => undefined,
@@ -59,7 +59,6 @@ const {
}));
vi.mock("../cli-credentials.js", () => ({
readClaudeCliCredentialsCached: () => null,
readCodexCliCredentialsCached: readCodexCliCredentialsCachedMock,
readMiniMaxCliCredentialsCached: () => null,
resetCliCredentialCachesForTest: () => undefined,
@@ -89,6 +88,7 @@ vi.mock("../../plugins/provider-runtime.runtime.js", () => ({
vi.mock("../../plugins/provider-runtime.js", () => ({
buildProviderMissingAuthMessageWithPlugin: () => undefined,
resolveExternalAuthProfilesWithPlugins: () => [],
resolveProviderDeprecatedAuthProfileIds: () => [],
resolveProviderSyntheticAuthWithPlugin: () => undefined,
shouldDeferProviderSyntheticProfileAuthWithPlugin: () => false,
}));
@@ -233,6 +233,60 @@ describe("resolveApiKeyForProfile openai refresh fallback", () => {
expect(refreshProviderOAuthCredentialWithPluginMock).toHaveBeenCalledTimes(1);
});
it("never refreshes a retired Claude CLI token as a legacy-profile fallback", async () => {
const legacyProfileId = "anthropic:default";
const retiredProfileId = "anthropic:claude-cli";
saveAuthProfileStore(
{
version: 1,
profiles: {
[legacyProfileId]: {
type: "oauth",
provider: "anthropic",
access: "expired-default-access",
refresh: "expired-default-refresh",
expires: Date.now() - 60_000,
},
[retiredProfileId]: {
type: "oauth",
provider: "anthropic",
access: "copied-native-access",
refresh: "copied-native-refresh",
expires: Date.now() - 60_000,
},
},
},
agentDir,
{ filterExternalAuthProfiles: false, syncExternalCli: false },
);
refreshProviderOAuthCredentialWithPluginMock
.mockRejectedValueOnce(new Error("initial refresh failed"))
.mockResolvedValueOnce({
type: "oauth",
provider: "anthropic",
access: "refreshed-copied-native-access",
refresh: "refreshed-copied-native-refresh",
expires: Date.now() + 60 * 60_000,
});
await expect(
resolveApiKeyForProfile({
cfg: {
auth: {
profiles: {
[legacyProfileId]: { provider: "anthropic", mode: "oauth" },
[retiredProfileId]: { provider: "anthropic", mode: "oauth" },
},
},
},
store: ensureAuthProfileStore(agentDir),
profileId: legacyProfileId,
agentDir,
}),
).rejects.toThrow(/OAuth token refresh failed for anthropic/);
expect(refreshProviderOAuthCredentialWithPluginMock).toHaveBeenCalledTimes(1);
});
it("fails closed when provider refresh returns an unchanged expired credential", async () => {
const profileId = "openai:default";
saveAuthProfileStore(
+62 -3
View File
@@ -8,14 +8,17 @@ import type { OpenClawConfig } from "../../config/config.js";
import { resolveAuthProfileSecretOwnerId } from "../../secrets/runtime-auth-profile-owner.js";
import { setActiveDegradedSecretOwners } from "../../secrets/runtime-degraded-state.js";
import { withEnvAsync } from "../../test-utils/env.js";
import type { AuthProfileStore } from "./types.js";
import type { AuthProfileStore, RuntimeAuthProfileStore } from "./types.js";
vi.hoisted(() => {
vi.resetModules();
});
const resolveProviderOAuthCredentialWithPlugin = vi.hoisted(() =>
vi.fn(async () => ({ status: "unhandled" as const })),
);
vi.mock("../cli-credentials.js", () => ({
readClaudeCliCredentialsCached: () => null,
readCodexCliCredentialsCached: () => null,
readMiniMaxCliCredentialsCached: () => null,
resetCliCredentialCachesForTest: () => undefined,
@@ -25,7 +28,7 @@ vi.mock("../../plugins/provider-runtime.runtime.js", () => ({
buildProviderAuthDoctorHintWithPlugin: async () => undefined,
formatProviderAuthProfileApiKeyWithPlugin: async (params: { context?: { access?: string } }) =>
params.context?.access,
resolveProviderOAuthCredentialWithPlugin: async () => ({ status: "unhandled" }),
resolveProviderOAuthCredentialWithPlugin,
}));
let resolveApiKeyForProfile: typeof import("./oauth.js").resolveApiKeyForProfile;
@@ -120,6 +123,7 @@ async function expectResolvedApiKey(params: {
beforeAll(loadOAuthModuleForTest);
beforeEach(() => {
resolveProviderOAuthCredentialWithPlugin.mockClear();
clearRuntimeAuthProfileStoreSnapshots();
setActiveDegradedSecretOwners([]);
// SecretRef cases consume the materialized store published by runtime activation.
@@ -160,6 +164,61 @@ beforeEach(() => {
});
});
describe("resolveApiKeyForProfile retired external CLI profiles", () => {
it("rejects a persisted Claude CLI token even when legacy metadata marks it external", async () => {
const profileId = "anthropic:claude-cli";
const store: RuntimeAuthProfileStore = {
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "copied-native-access",
refresh: "copied-native-refresh",
expires: Date.now() + 60 * 60_000,
},
},
runtimePersistedProfileIds: [profileId],
runtimeExternalCliProfileIds: [profileId],
};
await expect(
resolveApiKeyForProfile({
cfg: cfgFor(profileId, "anthropic", "oauth"),
store,
profileId,
}),
).resolves.toBeNull();
expect(resolveProviderOAuthCredentialWithPlugin).not.toHaveBeenCalled();
});
it("rejects a runtime-only Claude CLI token without refreshing it", async () => {
const profileId = "anthropic:claude-cli";
const store: RuntimeAuthProfileStore = {
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "claude-cli",
access: "current-native-access",
refresh: "current-native-refresh",
expires: Date.now() + 60 * 60_000,
},
},
runtimeExternalCliProfileIds: [profileId],
};
await expect(
resolveApiKeyForProfile({
cfg: cfgFor(profileId, "claude-cli", "oauth"),
store,
profileId,
}),
).resolves.toBeNull();
expect(resolveProviderOAuthCredentialWithPlugin).not.toHaveBeenCalled();
});
});
afterAll(() => {
clearRuntimeAuthProfileStoreSnapshots();
setActiveDegradedSecretOwners([]);
+13 -1
View File
@@ -28,7 +28,7 @@ import {
} from "../../secrets/runtime-degraded-state.js";
import { normalizeOptionalSecretInput } from "../../utils/normalize-secret-input.js";
import { resolveProviderIdForAuth } from "../provider-auth-aliases.js";
import { authProfilesLog } from "./constants.js";
import { authProfilesLog, CLAUDE_CLI_PROFILE_ID } from "./constants.js";
import {
evaluateStoredCredentialEligibility,
resolveTokenExpiryState,
@@ -257,6 +257,9 @@ async function tryResolveOAuthProfile(
params: ResolveApiKeyForProfileParams,
): Promise<ResolveApiKeyForProfileResult | null> {
const { cfg, store, profileId } = params;
if (isRetiredOAuthProfileId(profileId)) {
return null;
}
const cred = store.profiles[profileId];
if (!cred || cred.type !== "oauth") {
return null;
@@ -293,6 +296,10 @@ async function tryResolveOAuthProfile(
});
}
function isRetiredOAuthProfileId(profileId: string): boolean {
return profileId === CLAUDE_CLI_PROFILE_ID;
}
function authProfileSecretRefKey(
profile: AuthProfileCredential,
defaults: SecretDefaults | undefined,
@@ -382,6 +389,11 @@ export async function resolveApiKeyForProfile(
if (!storedProfile) {
return null;
}
// Claude owns this native login slot. Legacy persisted copies must never
// resolve, refresh, or leave OpenClaw as bearer tokens.
if (isRetiredOAuthProfileId(profileId)) {
return null;
}
const configForRefResolution = cfg ?? getRuntimeConfig();
const refDefaults = configForRefResolution.secrets?.defaults;
const runtimeProfile = resolveRuntimeAuthProfile({
+73 -24
View File
@@ -26,6 +26,10 @@ import {
setAuthProfileOrder,
upsertAuthProfileWithLock,
} from "./profiles.js";
import {
getRuntimeExternalCliProfileIds,
getRuntimeLocalProfileIds,
} from "./runtime-external-profile-references.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
getRuntimeAuthProfileStoreSnapshotCore as getInternalRuntimeAuthProfileStoreSnapshot,
@@ -1429,34 +1433,36 @@ describe("promoteAuthProfileInOrder", () => {
it("narrows provider removal to selected profiles", async () => {
await withAuthProfileTestState("openclaw-auth-remove-selected-", async ({ agentDir }) => {
fs.mkdirSync(agentDir, { recursive: true });
saveAuthProfileStore(
{
version: AUTH_STORE_VERSION,
profiles: {
"openrouter:oauth": {
type: "oauth",
provider: "openrouter",
access: "oauth-access",
refresh: "oauth-refresh",
expires: Date.now() + 60_000,
},
"openrouter:api-key": {
type: "api_key",
provider: "openrouter",
key: "api-key",
},
const initialStore: RuntimeAuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {
"openrouter:oauth": {
type: "oauth",
provider: "openrouter",
access: "oauth-access",
refresh: "oauth-refresh",
expires: Date.now() + 60_000,
},
order: { openrouter: ["openrouter:oauth", "openrouter:api-key"] },
lastGood: { openrouter: "openrouter:oauth" },
usageStats: {
"openrouter:oauth": { lastUsed: 1 },
"openrouter:api-key": { lastUsed: 2 },
"openrouter:api-key": {
type: "api_key",
provider: "openrouter",
key: "api-key",
},
},
agentDir,
);
order: { openrouter: ["openrouter:oauth", "openrouter:api-key"] },
lastGood: { openrouter: "openrouter:oauth" },
usageStats: {
"openrouter:oauth": { lastUsed: 1 },
"openrouter:api-key": { lastUsed: 2 },
},
runtimePersistedProfileIds: ["openrouter:oauth", "openrouter:api-key"],
runtimeLocalProfileIds: ["openrouter:oauth", "openrouter:api-key"],
runtimeExternalProfileIds: ["openrouter:oauth", "openrouter:api-key"],
runtimeExternalCliProfileIds: ["openrouter:oauth", "openrouter:api-key"],
};
saveAuthProfileStore(initialStore, agentDir);
await removeProviderAuthProfilesWithLock({
const removedStore = await removeProviderAuthProfilesWithLock({
agentDir,
provider: "openrouter",
profileIds: ["openrouter:oauth"],
@@ -1469,6 +1475,49 @@ describe("promoteAuthProfileInOrder", () => {
});
expect(loadAuthProfileStoreForRuntime(agentDir).profiles["openrouter:oauth"]).toBeUndefined();
expect(loadAuthProfileStoreForRuntime(agentDir).lastGood).toBeUndefined();
expect(removedStore?.runtimePersistedProfileIds ?? []).not.toContain("openrouter:oauth");
expect(removedStore ? getRuntimeLocalProfileIds(removedStore) : []).not.toContain(
"openrouter:oauth",
);
expect(removedStore?.runtimeExternalProfileIds ?? []).not.toContain("openrouter:oauth");
expect(removedStore ? getRuntimeExternalCliProfileIds(removedStore) : []).not.toContain(
"openrouter:oauth",
);
});
});
it("does not rewrite the store when selected profiles are absent", async () => {
await withAuthProfileTestState("openclaw-auth-remove-noop-", async ({ agentDir }) => {
fs.mkdirSync(agentDir, { recursive: true });
const initialStore: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {
"openrouter:api-key": {
type: "api_key",
provider: "openrouter",
key: "api-key",
},
},
};
saveAuthProfileStore(initialStore, agentDir);
replaceRuntimeAuthProfileStoreSnapshots([
{ agentDir, store: loadAuthProfileStoreForRuntime(agentDir) },
]);
const credentialRevision =
getRuntimeAuthProfileStoreCredentialMutationToken(agentDir).revision;
const stateRevision = getRuntimeAuthProfileStoreStateMutationToken(agentDir).revision;
await removeProviderAuthProfilesWithLock({
agentDir,
provider: "openrouter",
profileIds: ["openrouter:missing"],
});
expect(loadPersistedAuthProfileStore(agentDir)).toEqual(initialStore);
expect(getRuntimeAuthProfileStoreCredentialMutationToken(agentDir).revision).toBe(
credentialRevision,
);
expect(getRuntimeAuthProfileStoreStateMutationToken(agentDir).revision).toBe(stateRevision);
});
});
+24 -39
View File
@@ -3,12 +3,20 @@
* Updates profile order, last-good state, usage stats, and provider profile
* records through locked or immediate store writes.
*/
import { isDeepStrictEqual } from "node:util";
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { resolveProviderIdForAuth } from "../provider-auth-aliases.js";
import { normalizeAuthProfileCredential } from "./credential-normalize.js";
import { dedupeProfileIds, listProfilesForProvider } from "./profile-list.js";
import {
getRuntimeExternalCliProfileIds,
getRuntimeLocalProfileIds,
removeRuntimeExternalProfileReferences,
setRuntimeExternalCliProfileIds,
setRuntimeLocalProfileIds,
} from "./runtime-external-profile-references.js";
import {
ensureAuthProfileStoreForLocalUpdate,
resolvePersistedAuthProfileOwnerAgentDir,
@@ -256,45 +264,22 @@ export async function removeAuthProfilesWithLock(params: {
return await updateAuthProfileStoreWithLock({
agentDir: params.agentDir,
updater: (store) => {
let changed = false;
for (const profileId of profileIds) {
if (store.profiles[profileId]) {
delete store.profiles[profileId];
changed = true;
}
if (store.usageStats?.[profileId]) {
delete store.usageStats[profileId];
changed = true;
}
const next = removeRuntimeExternalProfileReferences({ store, profileIds });
if (isDeepStrictEqual(store, next)) {
return false;
}
for (const [provider, order] of Object.entries(store.order ?? {})) {
const next = order.filter((profileId) => !profileIds.has(profileId));
if (next.length === order.length) {
continue;
}
changed = true;
if (next.length > 0) {
store.order![provider] = next;
} else {
delete store.order![provider];
}
}
for (const [provider, profileId] of Object.entries(store.lastGood ?? {})) {
if (profileIds.has(profileId)) {
delete store.lastGood![provider];
changed = true;
}
}
if (store.order && Object.keys(store.order).length === 0) {
store.order = undefined;
}
if (store.lastGood && Object.keys(store.lastGood).length === 0) {
store.lastGood = undefined;
}
if (store.usageStats && Object.keys(store.usageStats).length === 0) {
store.usageStats = undefined;
}
return changed;
Object.assign(store, {
profiles: next.profiles,
order: next.order,
lastGood: next.lastGood,
usageStats: next.usageStats,
runtimePersistedProfileIds: next.runtimePersistedProfileIds,
runtimeExternalProfileIds: next.runtimeExternalProfileIds,
runtimeExternalProfileIdsAuthoritative: next.runtimeExternalProfileIdsAuthoritative,
});
setRuntimeLocalProfileIds(store, getRuntimeLocalProfileIds(next));
setRuntimeExternalCliProfileIds(store, getRuntimeExternalCliProfileIds(next));
return true;
},
});
}
@@ -305,7 +290,7 @@ export async function removeAuthProfilesWithLock(params: {
* store lets the profile reappear on the next status read and auth warmup.
*/
export async function removeAuthProfilesAcrossOwnerStores(params: {
agentDir: string;
agentDir?: string;
profileIds: readonly string[];
}): Promise<boolean> {
const profilesByOwner = new Map<string | undefined, Set<string>>([
@@ -2,11 +2,9 @@ import { isDeepStrictEqual } from "node:util";
import { cloneAuthProfileStore } from "./clone.js";
import type { AuthProfileStore, RuntimeAuthProfileStore } from "./types.js";
type RuntimeExternalCliStore = AuthProfileStore &
Pick<RuntimeAuthProfileStore, "runtimeExternalCliProfileIds">;
export function getRuntimeExternalCliProfileIds(store: AuthProfileStore): readonly string[] {
return (store as RuntimeExternalCliStore).runtimeExternalCliProfileIds ?? [];
const runtimeStore: RuntimeAuthProfileStore = store;
return runtimeStore.runtimeExternalCliProfileIds ?? [];
}
export function setRuntimeExternalCliProfileIds(
@@ -14,8 +12,22 @@ export function setRuntimeExternalCliProfileIds(
profileIds: Iterable<string>,
): void {
const ids = [...new Set(profileIds)].filter((profileId) => store.profiles[profileId]).toSorted();
(store as RuntimeExternalCliStore).runtimeExternalCliProfileIds =
ids.length > 0 ? ids : undefined;
const runtimeStore: RuntimeAuthProfileStore = store;
runtimeStore.runtimeExternalCliProfileIds = ids.length > 0 ? ids : undefined;
}
export function getRuntimeLocalProfileIds(store: AuthProfileStore): readonly string[] {
const runtimeStore: RuntimeAuthProfileStore = store;
return runtimeStore.runtimeLocalProfileIds ?? [];
}
export function setRuntimeLocalProfileIds(
store: AuthProfileStore,
profileIds: Iterable<string>,
): void {
const ids = [...new Set(profileIds)].filter((profileId) => store.profiles[profileId]).toSorted();
const runtimeStore: RuntimeAuthProfileStore = store;
runtimeStore.runtimeLocalProfileIds = ids.length > 0 ? ids : undefined;
}
export function removeRuntimeExternalProfileReferences(params: {
@@ -61,6 +73,10 @@ export function removeRuntimeExternalProfileReferences(params: {
if (next.runtimePersistedProfileIds?.length === 0) {
next.runtimePersistedProfileIds = undefined;
}
setRuntimeLocalProfileIds(
next,
getRuntimeLocalProfileIds(next).filter((profileId) => !params.profileIds.has(profileId)),
);
next.runtimeExternalProfileIds = next.runtimeExternalProfileIds?.filter(
(profileId) => !params.profileIds.has(profileId),
);
@@ -1,7 +1,6 @@
import "./cli-auth-epoch.js";
type CliAuthEpochDeps = {
readClaudeCliCredentialsCached: typeof import("./cli-credentials.js").readClaudeCliCredentialsCached;
readCodexCliCredentialsCached: typeof import("./cli-credentials.js").readCodexCliCredentialsCached;
readGeminiCliCredentialsCached: typeof import("./cli-credentials.js").readGeminiCliCredentialsCached;
ensureAuthProfileStore: typeof import("./auth-profiles/store.js").ensureAuthProfileStore;
-153
View File
@@ -35,7 +35,6 @@ describe("resolveCliAuthEpoch", () => {
it("returns undefined when no local or auth-profile credentials exist", async () => {
setCliAuthEpochTestDeps({
readClaudeCliCredentialsCached: () => null,
readCodexCliCredentialsCached: () => null,
readGeminiCliCredentialsCached: () => null,
loadAuthProfileStoreForRuntime: () => ({
@@ -192,60 +191,6 @@ describe("resolveCliAuthEpoch", () => {
expect(renamed).not.toBe(primary);
});
it("keeps identity-less claude cli oauth epochs stable across token changes", async () => {
let access = "access-a";
let refresh = "refresh-a";
let expires = 1;
setCliAuthEpochTestDeps({
readClaudeCliCredentialsCached: () => ({
type: "oauth",
provider: "anthropic",
access,
refresh,
expires,
}),
});
const first = await resolveCliAuthEpoch({ provider: "claude-cli" });
access = "access-b";
refresh = "refresh-b";
expires = 2;
const second = await resolveCliAuthEpoch({ provider: "claude-cli" });
expectCliAuthEpoch(first);
expect(second).toBe(first);
});
it("uses stricter binding semantics for identity-less CLI OAuth", async () => {
let access = "access-a";
let refresh = "refresh-a";
setCliAuthEpochTestDeps({
readClaudeCliCredentialsCached: () => ({
type: "oauth",
provider: "anthropic",
access,
refresh,
expires: 1,
}),
});
const reusableEpoch = await resolveCliAuthEpoch({ provider: "claude-cli" });
const firstBinding = resolveCliAuthBindingFingerprint({
provider: "claude-cli",
config: {},
});
access = "access-b";
refresh = "refresh-b";
const reusableEpochAfterRefresh = await resolveCliAuthEpoch({ provider: "claude-cli" });
const secondBinding = resolveCliAuthBindingFingerprint({
provider: "claude-cli",
config: {},
});
expect(reusableEpochAfterRefresh).toBe(reusableEpoch);
expect(secondBinding).not.toBe(firstBinding);
});
it("keeps strict CLI bindings stable for a known OAuth principal", () => {
let access = "access-a";
let refresh = "refresh-a";
@@ -363,102 +308,6 @@ describe("resolveCliAuthEpoch", () => {
expect(second).toBe(first);
});
it("keeps claude cli token epochs stable across token rotation", async () => {
let token = "token-a";
setCliAuthEpochTestDeps({
readClaudeCliCredentialsCached: () => ({
type: "token",
provider: "anthropic",
token,
expires: 1,
}),
});
const first = await resolveCliAuthEpoch({ provider: "claude-cli" });
token = "token-b";
const second = await resolveCliAuthEpoch({ provider: "claude-cli" });
expectCliAuthEpoch(first);
// Static-token rotation is an authorized credential refresh, not an
// identity change. After #74312 the hash is identity-only for both
// OAuth and token branches, so rotation does not invalidate the epoch.
expect(second).toBe(first);
});
it("matches claude cli token and oauth epochs so partial keychain reads do not flip", async () => {
setCliAuthEpochTestDeps({
readClaudeCliCredentialsCached: () => ({
type: "oauth",
provider: "anthropic",
access: "access",
refresh: "refresh",
expires: 1,
}),
});
const oauthEpoch = await resolveCliAuthEpoch({ provider: "claude-cli" });
setCliAuthEpochTestDeps({
readClaudeCliCredentialsCached: () => ({
type: "token",
provider: "anthropic",
token: "access",
expires: 1,
}),
});
const tokenEpoch = await resolveCliAuthEpoch({ provider: "claude-cli" });
expectCliAuthEpoch(oauthEpoch);
expectCliAuthEpoch(tokenEpoch);
// The macOS Claude keychain rewrite is not atomic. A transient read with
// `refreshToken` missing falls into the parser's token branch; the OAuth
// and token encodings must produce the same hash so the auth-epoch does
// not flip during a token rotation. Regression for #74312.
expect(tokenEpoch).toBe(oauthEpoch);
});
it("changes the Claude CLI auth epoch when apiKeyHelper configuration changes", async () => {
let helperHash = "helper-hash-a";
setCliAuthEpochTestDeps({
readClaudeCliCredentialsCached: () => ({
type: "api_key_helper",
provider: "anthropic",
helperHash,
}),
});
const first = await resolveCliAuthEpoch({ provider: "claude-cli" });
helperHash = "helper-hash-b";
const second = await resolveCliAuthEpoch({ provider: "claude-cli" });
expectCliAuthEpoch(first);
expectCliAuthEpoch(second);
expect(second).not.toBe(first);
});
it("drops the claude cli epoch when the credential read is absent", async () => {
setCliAuthEpochTestDeps({
readClaudeCliCredentialsCached: () => ({
type: "oauth",
provider: "anthropic",
access: "access",
refresh: "refresh",
expires: 1,
}),
});
const successfulRead = await resolveCliAuthEpoch({ provider: "claude-cli" });
// A null read can mean the credential was removed or logout left no
// readable auth state. Keep that absence visible so reusable sessions do
// not survive a true auth-state loss.
setCliAuthEpochTestDeps({
readClaudeCliCredentialsCached: () => null,
});
const nullRead = await resolveCliAuthEpoch({ provider: "claude-cli" });
expectCliAuthEpoch(successfulRead);
expect(nullRead).toBeUndefined();
});
it("keeps gemini cli oauth epochs stable through token rotation and flips on account change", async () => {
let access = "gemini-access-a";
let refresh = "gemini-refresh-a";
@@ -1020,7 +869,6 @@ describe("resolveCliAuthEpoch", () => {
it("attests an opaque CLI backend owner without reading credential material", async () => {
setCliAuthEpochTestDeps({
readClaudeCliCredentialsCached: () => null,
ensureAuthProfileStore: () => ({ version: 1, profiles: {} }),
});
@@ -1165,4 +1013,3 @@ describe("resolveCliAuthEpoch", () => {
).resolves.toBeUndefined();
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
-42
View File
@@ -9,10 +9,8 @@ import { ensureAuthProfileStore, loadAuthProfileStoreForRuntime } from "./auth-p
import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles/types.js";
import { resolveCliBackendConfig } from "./cli-backends.js";
import {
readClaudeCliCredentialsCached,
readCodexCliCredentialsCached,
readGeminiCliCredentialsCached,
type ClaudeCliCredential,
type CodexCliCredential,
type GeminiCliCredential,
} from "./cli-credentials.js";
@@ -29,7 +27,6 @@ import {
import type { ResolvedProviderAuth } from "./model-auth-runtime-shared.js";
type CliAuthEpochDeps = {
readClaudeCliCredentialsCached: typeof readClaudeCliCredentialsCached;
readCodexCliCredentialsCached: typeof readCodexCliCredentialsCached;
readGeminiCliCredentialsCached: typeof readGeminiCliCredentialsCached;
ensureAuthProfileStore: typeof ensureAuthProfileStore;
@@ -37,7 +34,6 @@ type CliAuthEpochDeps = {
};
const defaultCliAuthEpochDeps: CliAuthEpochDeps = {
readClaudeCliCredentialsCached,
readCodexCliCredentialsCached,
readGeminiCliCredentialsCached,
ensureAuthProfileStore,
@@ -99,25 +95,6 @@ function encodeOAuthIdentity(credential: {
]);
}
function encodeClaudeCredential(credential: ClaudeCliCredential): string {
if (credential.type === "api_key_helper") {
return JSON.stringify([credential.type, credential.provider, credential.helperHash]);
}
// Identity-only hashing for Claude CLI-managed credentials.
// The Claude CLI keychain rewrite is not atomic: a token rotation can
// briefly produce a partial read where `refreshToken` is missing, and the
// parser falls back to a token-shaped credential. With the previous
// token-inclusive hash, that transient race flipped the auth-epoch and
// forced a session reset on every rotation. Routing these branches through
// `encodeOAuthIdentity` collapses partial reads and rotations onto the same
// provider-keyed identity hash. Helper auth stays distinct because changing
// its configured command can switch accounts. Fixes #74312.
return encodeOAuthIdentity({
type: "oauth",
provider: credential.provider,
});
}
function encodeCodexCredential(credential: CodexCliCredential): string {
return encodeOAuthIdentity(credential);
}
@@ -194,16 +171,6 @@ function encodeAuthProfileEpochPart(
function getLocalCliCredentialFingerprint(provider: string): string | undefined {
switch (provider) {
case "claude-cli": {
const credential = cliAuthEpochDeps.readClaudeCliCredentialsCached({
ttlMs: 5000,
allowKeychainPrompt: false,
});
// Keep true credential absence absent so logout/removal invalidates
// reusable sessions. The 5s credential cache still masks transient
// null reads immediately after a successful read.
return credential ? hashCliAuthEpochPart(encodeClaudeCredential(credential)) : undefined;
}
case "codex-cli": {
const credential = cliAuthEpochDeps.readCodexCliCredentialsCached({
ttlMs: 5000,
@@ -224,15 +191,6 @@ function getLocalCliCredentialFingerprint(provider: string): string | undefined
function getLocalCliCredential(provider: string): AuthProfileCredential | undefined {
switch (provider) {
case "claude-cli": {
const auth = cliAuthEpochDeps.readClaudeCliCredentialsCached({
ttlMs: 0,
allowKeychainPrompt: false,
});
// Helper auth has no persisted secret/profile shape; its opaque command
// fingerprint is already carried by getLocalCliCredentialFingerprint.
return auth?.type === "api_key_helper" ? undefined : (auth ?? undefined);
}
case "codex-cli":
return (
cliAuthEpochDeps.readCodexCliCredentialsCached({
@@ -1,96 +0,0 @@
import { afterEach, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { readClaudeCliCredentialsCached } from "./cli-credentials.js";
const execSyncMock = vi.fn();
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
afterEach(() => {
execSyncMock.mockReset();
});
function readNonInteractiveClaudeCredential(platform: NodeJS.Platform) {
let unreadable = false;
const credential = readClaudeCliCredentialsCached({
platform,
homeDir: tempDirs.make("openclaw-claude-non-interactive-"),
execSync: execSyncMock,
allowKeychainPrompt: false,
tryKeychainWithoutPrompt: true,
ttlMs: 0,
onStoredCredentialUnreadable: () => {
unreadable = true;
},
});
return { credential, unreadable };
}
function mockReadableKeychainCredential() {
execSyncMock.mockReturnValue(
JSON.stringify({
claudeAiOauth: {
accessToken: "test-access",
refreshToken: "test-refresh",
expiresAt: Date.now() + 60_000,
},
}),
);
}
it("reads an already-authorized Claude keychain credential during non-interactive setup", () => {
mockReadableKeychainCredential();
expect(readNonInteractiveClaudeCredential("darwin")).toEqual({
credential: expect.objectContaining({
type: "oauth",
provider: "anthropic",
refresh: "test-refresh",
}),
unreadable: false,
});
expect(execSyncMock).toHaveBeenCalledWith(
expect.stringContaining(" -w"),
expect.objectContaining({ timeout: 2_000 }),
);
});
it("reports a present but unreadable Claude keychain credential", () => {
execSyncMock.mockImplementation((command: string) => {
if (command.includes(" -w")) {
throw new Error("User interaction is not allowed");
}
return "keychain metadata";
});
expect(readNonInteractiveClaudeCredential("darwin")).toEqual({
credential: null,
unreadable: true,
});
expect(execSyncMock).toHaveBeenNthCalledWith(
2,
expect.not.stringContaining(" -w"),
expect.objectContaining({ timeout: 2_000 }),
);
});
it("reports missing Claude CLI auth when neither keychain nor file credentials exist", () => {
execSyncMock.mockImplementation(() => {
throw new Error("item not found");
});
expect(readNonInteractiveClaudeCredential("darwin")).toEqual({
credential: null,
unreadable: false,
});
expect(execSyncMock).toHaveBeenCalledTimes(2);
});
it("keeps non-darwin non-interactive Claude auth on the file path", () => {
mockReadableKeychainCredential();
expect(readNonInteractiveClaudeCredential("linux")).toEqual({
credential: null,
unreadable: false,
});
expect(execSyncMock).not.toHaveBeenCalled();
});
@@ -1,35 +0,0 @@
import { execSync } from "node:child_process";
const CLAUDE_CLI_KEYCHAIN_SERVICE = "Claude Code-credentials";
export const CLAUDE_CLI_KEYCHAIN_TIMEOUT_MS = 2_000;
type ExecSyncFn = typeof execSync;
export function readClaudeCliKeychainPayload(
execSyncImpl: ExecSyncFn = execSync,
timeout = 5000,
): Record<string, unknown> | null {
try {
const result = execSyncImpl(
`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w`,
{ encoding: "utf8", timeout, stdio: ["pipe", "pipe", "pipe"] },
);
const parsed = JSON.parse(result.trim());
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
}
export function hasClaudeCliKeychainItem(execSyncImpl: ExecSyncFn = execSync): boolean {
try {
execSyncImpl(`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}"`, {
encoding: "utf8",
timeout: CLAUDE_CLI_KEYCHAIN_TIMEOUT_MS,
stdio: ["pipe", "pipe", "pipe"],
});
return true;
} catch {
return false;
}
}
-307
View File
@@ -3,27 +3,15 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
const execSyncMock = vi.fn();
const CLI_CREDENTIALS_CACHE_TTL_MS = 15 * 60 * 1000;
let readClaudeCliCredentialsCached: typeof import("./cli-credentials.js").readClaudeCliCredentialsCached;
let readCodexCliActiveApiKey: typeof import("./cli-credentials.js").readCodexCliActiveApiKey;
let readCodexCliCredentialsCached: typeof import("./cli-credentials.js").readCodexCliCredentialsCached;
let readGeminiCliCredentialsCached: typeof import("./cli-credentials.js").readGeminiCliCredentialsCached;
let readMiniMaxCliCredentialsCached: typeof import("./cli-credentials.js").readMiniMaxCliCredentialsCached;
let readCodexAuth: typeof import("./cli-auth.test-support.js").readCodexAuth;
let resetCliAuthCaches: typeof import("./cli-auth.test-support.js").resetCliAuthCaches;
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
async function readCachedClaudeCliCredentials(allowKeychainPrompt: boolean) {
return readClaudeCliCredentialsCached({
allowKeychainPrompt,
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
platform: "darwin",
execSync: execSyncMock,
});
}
function createJwtWithExp(expSeconds: number): string {
// Signature verification is out of scope; expiration extraction only needs a
@@ -33,20 +21,6 @@ function createJwtWithExp(expSeconds: number): string {
return `${encode({ alg: "RS256", typ: "JWT" })}.${encode({ exp: expSeconds })}.signature`;
}
function mockClaudeCliCredentialRead() {
execSyncMock.mockImplementation(() =>
JSON.stringify({
claudeAiOauth: {
accessToken: `token-${Date.now()}`,
refreshToken: "cached-refresh",
expiresAt: Date.now() + 60_000,
subscriptionType: "max",
rateLimitTier: "default_max_20x",
},
}),
);
}
function expectFields(value: unknown, expected: Record<string, unknown>): void {
// Keeps large credential objects readable while still asserting exact fields
// relevant to the branch under test.
@@ -62,7 +36,6 @@ function expectFields(value: unknown, expected: Record<string, unknown>): void {
describe("cli credentials", () => {
beforeAll(async () => {
({
readClaudeCliCredentialsCached,
readCodexCliActiveApiKey,
readCodexCliCredentialsCached,
readGeminiCliCredentialsCached,
@@ -93,16 +66,6 @@ describe("cli credentials", () => {
delete process.env.CODEX_HOME;
try {
const files = [
{
filePath: path.join(osHome, ".claude", ".credentials.json"),
value: {
claudeAiOauth: {
accessToken: "claude-access",
refreshToken: "claude-refresh",
expiresAt: expires,
},
},
},
{
filePath: path.join(osHome, ".codex", "auth.json"),
value: {
@@ -134,16 +97,6 @@ describe("cli credentials", () => {
fs.writeFileSync(file.filePath, JSON.stringify(file.value), "utf8");
}
const decoys = [
{
filePath: path.join(openClawHome, ".claude", ".credentials.json"),
value: {
claudeAiOauth: {
accessToken: "decoy-claude-access",
refreshToken: "decoy-claude-refresh",
expiresAt: expires,
},
},
},
{
filePath: path.join(openClawHome, ".codex", "auth.json"),
value: {
@@ -175,14 +128,6 @@ describe("cli credentials", () => {
fs.writeFileSync(file.filePath, JSON.stringify(file.value), "utf8");
}
expectFields(
readClaudeCliCredentialsCached({
allowKeychainPrompt: false,
platform: "linux",
ttlMs: 0,
}),
{ access: "claude-access", refresh: "claude-refresh" },
);
expectFields(
readCodexCliCredentialsCached({
allowKeychainPrompt: false,
@@ -205,258 +150,6 @@ describe("cli credentials", () => {
}
});
it.each([
{
name: "caches Claude Code CLI credentials within the TTL window",
allowKeychainPromptSecondRead: true,
advanceMs: 0,
expectedCalls: 1,
expectSameObject: true,
},
{
name: "refreshes Claude Code CLI credentials after the TTL window",
allowKeychainPromptSecondRead: true,
advanceMs: CLI_CREDENTIALS_CACHE_TTL_MS + 1,
expectedCalls: 2,
expectSameObject: false,
},
] as const)(
"$name",
async ({ allowKeychainPromptSecondRead, advanceMs, expectedCalls, expectSameObject }) => {
mockClaudeCliCredentialRead();
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
const first = await readCachedClaudeCliCredentials(true);
if (advanceMs > 0) {
vi.advanceTimersByTime(advanceMs);
}
const second = await readCachedClaudeCliCredentials(allowKeychainPromptSecondRead);
if (!first || !second) {
throw new Error("expected cached Claude CLI credentials to be available");
}
expectFields(first, {
type: "oauth",
provider: "anthropic",
access: "token-1735689600000",
refresh: "cached-refresh",
subscriptionType: "max",
rateLimitTier: "default_max_20x",
});
expectFields(second, {
type: "oauth",
provider: "anthropic",
access: expectSameObject ? "token-1735689600000" : "token-1735690500001",
refresh: "cached-refresh",
});
if (expectSameObject) {
expect(second).toEqual(first);
} else {
expect(second).not.toEqual(first);
}
expect(execSyncMock).toHaveBeenCalledTimes(expectedCalls);
},
);
it("does not let no-keychain Claude cache misses poison keychain reads", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-claude-cache-"));
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
const withoutKeychain = readClaudeCliCredentialsCached({
allowKeychainPrompt: false,
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
platform: "darwin",
homeDir: tempDir,
execSync: execSyncMock,
});
expect(withoutKeychain).toBeNull();
expect(execSyncMock).not.toHaveBeenCalled();
mockClaudeCliCredentialRead();
const withKeychain = readClaudeCliCredentialsCached({
allowKeychainPrompt: true,
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
platform: "darwin",
homeDir: tempDir,
execSync: execSyncMock,
});
expectFields(withKeychain, {
type: "oauth",
provider: "anthropic",
refresh: "cached-refresh",
});
expect(execSyncMock).toHaveBeenCalledTimes(1);
});
function claudeAccessFixture(): string {
return ["claude", "access"].join("-");
}
function claudeRefreshFixture(): string {
return ["claude", "refresh"].join("-");
}
it("attaches the CLI config account email to Claude credentials", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-claude-email-"));
const expires = Date.parse("2036-04-25T12:00:00Z");
fs.mkdirSync(path.join(tempDir, ".claude"), { recursive: true, mode: 0o700 });
fs.writeFileSync(
path.join(tempDir, ".claude", ".credentials.json"),
JSON.stringify({
claudeAiOauth: {
accessToken: claudeAccessFixture(),
refreshToken: claudeRefreshFixture(),
expiresAt: expires,
},
}),
"utf8",
);
fs.writeFileSync(
path.join(tempDir, ".claude.json"),
JSON.stringify({ oauthAccount: { emailAddress: "cli-login@example.com" } }),
"utf8",
);
const cliLogin = readClaudeCliCredentialsCached({
allowKeychainPrompt: false,
ttlMs: 0,
platform: "darwin",
homeDir: tempDir,
execSync: execSyncMock,
});
expectFields(cliLogin, {
type: "oauth",
provider: "anthropic",
access: claudeAccessFixture(),
email: "cli-login@example.com",
});
});
it("leaves Claude credentials email-less without the CLI config file", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-claude-email-"));
const expires = Date.parse("2036-04-25T12:00:00Z");
fs.mkdirSync(path.join(tempDir, ".claude"), { recursive: true, mode: 0o700 });
fs.writeFileSync(
path.join(tempDir, ".claude", ".credentials.json"),
JSON.stringify({
claudeAiOauth: {
accessToken: claudeAccessFixture(),
refreshToken: claudeRefreshFixture(),
expiresAt: expires,
},
}),
"utf8",
);
const cliLogin = readClaudeCliCredentialsCached({
allowKeychainPrompt: false,
ttlMs: 0,
platform: "darwin",
homeDir: tempDir,
execSync: execSyncMock,
});
expectFields(cliLogin, { type: "oauth", provider: "anthropic", access: claudeAccessFixture() });
expect(cliLogin && "email" in cliLogin ? cliLogin.email : undefined).toBeUndefined();
});
it("keeps runtime-style no-prompt Claude reads on the file credential path", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-claude-cache-"));
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
mockClaudeCliCredentialRead();
const withKeychain = readClaudeCliCredentialsCached({
allowKeychainPrompt: true,
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
platform: "darwin",
homeDir: tempDir,
execSync: execSyncMock,
});
const withoutPrompt = readClaudeCliCredentialsCached({
allowKeychainPrompt: false,
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
platform: "darwin",
homeDir: tempDir,
execSync: execSyncMock,
});
expectFields(withKeychain, {
type: "oauth",
provider: "anthropic",
refresh: "cached-refresh",
});
expect(withoutPrompt).toBeNull();
expect(execSyncMock).toHaveBeenCalledTimes(1);
});
it("recognizes Claude Code user apiKeyHelper settings as CLI-managed auth", () => {
const tempDir = tempDirs.make("openclaw-claude-settings-");
const settingsDir = path.join(tempDir, ".claude");
fs.mkdirSync(settingsDir, { recursive: true });
const options = {
allowKeychainPrompt: false,
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
platform: "linux" as const,
homeDir: tempDir,
execSync: execSyncMock,
};
expect(readClaudeCliCredentialsCached(options)).toBeNull();
fs.writeFileSync(
path.join(settingsDir, "settings.json"),
JSON.stringify({ apiKeyHelper: "test-api-key-helper" }),
);
const result = readClaudeCliCredentialsCached(options);
expect(result).toEqual({
type: "api_key_helper",
provider: "anthropic",
helperHash: expect.stringMatching(/^[a-f0-9]{64}$/),
});
expect(execSyncMock).not.toHaveBeenCalled();
});
it("prefers Claude Code user apiKeyHelper settings over stored Claude credentials", () => {
const tempDir = tempDirs.make("openclaw-claude-helper-first-");
const settingsDir = path.join(tempDir, ".claude");
fs.mkdirSync(settingsDir, { recursive: true });
fs.writeFileSync(
path.join(settingsDir, "settings.json"),
JSON.stringify({ apiKeyHelper: "test-api-key-helper" }),
);
fs.writeFileSync(
path.join(settingsDir, ".credentials.json"),
JSON.stringify({
claudeAiOauth: {
accessToken: "test-access-token",
refreshToken: "test-refresh-token",
expiresAt: Date.parse("2099-01-01T00:00:00Z"),
},
}),
);
mockClaudeCliCredentialRead();
const result = readClaudeCliCredentialsCached({
allowKeychainPrompt: true,
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
platform: "darwin",
homeDir: tempDir,
execSync: execSyncMock,
});
expect(result).toEqual({
type: "api_key_helper",
provider: "anthropic",
helperHash: expect.stringMatching(/^[a-f0-9]{64}$/),
});
expect(execSyncMock).not.toHaveBeenCalled();
});
it("reads Codex credentials from keychain when available", () => {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-"));
process.env.CODEX_HOME = tempHome;
+1 -248
View File
@@ -1,6 +1,6 @@
/**
* Reads and refreshes credentials stored by external CLI runtimes such as
* Claude Code, Codex, Gemini, and MiniMax.
* Codex, Gemini, and MiniMax.
*/
import { execSync } from "node:child_process";
import { createHash } from "node:crypto";
@@ -12,18 +12,8 @@ import {
} from "@openclaw/normalization-core/number-coercion";
import { resolveOsHomeRelativePath } from "../infra/home-dir.js";
import { loadJsonFileThroughSymlink } from "../infra/json-file.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import type { OAuthProvider } from "./auth-profiles/types.js";
import {
CLAUDE_CLI_KEYCHAIN_TIMEOUT_MS,
hasClaudeCliKeychainItem,
readClaudeCliKeychainPayload,
} from "./cli-credentials.claude-keychain.js";
const log = createSubsystemLogger("agents/auth-profiles");
const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = ".claude/.credentials.json";
const CLAUDE_CLI_USER_SETTINGS_RELATIVE_PATH = ".claude/settings.json";
const CODEX_CLI_AUTH_FILENAME = "auth.json";
const MINIMAX_CLI_CREDENTIALS_RELATIVE_PATH = ".minimax/oauth_creds.json";
const GEMINI_CLI_CREDENTIALS_RELATIVE_PATH = ".gemini/oauth_creds.json";
@@ -36,46 +26,17 @@ type CachedValue<T> = {
sourceFingerprint?: number | string | null;
};
let claudeCliCache: CachedValue<ClaudeCliCredential> | null = null;
let codexCliCache: CachedValue<CodexCliCredential> | null = null;
let minimaxCliCache: CachedValue<MiniMaxCliCredential> | null = null;
let geminiCliCache: CachedValue<GeminiCliCredential> | null = null;
/** Clears in-memory CLI credential caches for isolated tests. */
function resetCliCredentialCachesForTest(): void {
claudeCliCache = null;
codexCliCache = null;
minimaxCliCache = null;
geminiCliCache = null;
}
/** Credential shape parsed from Claude Code CLI storage. */
export type ClaudeCliCredential =
| {
type: "oauth";
provider: "anthropic";
access: string;
refresh: string;
expires: number;
subscriptionType?: string;
rateLimitTier?: string;
email?: string;
}
| {
type: "token";
provider: "anthropic";
token: string;
expires: number;
subscriptionType?: string;
rateLimitTier?: string;
email?: string;
}
| {
type: "api_key_helper";
provider: "anthropic";
helperHash: string;
};
/** Credential shape parsed from Codex CLI storage. */
export type CodexCliCredential = {
type: "oauth";
@@ -116,66 +77,6 @@ export type GeminiCliCredential = {
type ExecSyncFn = typeof execSync;
function resolveClaudeCliCredentialsPath(homeDir?: string) {
const baseDir = resolveOsHomeRelativePath(homeDir ?? "~");
return path.join(baseDir, CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH);
}
function resolveClaudeCliUserSettingsPath(homeDir?: string) {
// Managed Claude CLI launches clear CLAUDE_CONFIG_DIR, so auth discovery
// inspects the canonical user settings tree that the child will use.
const baseDir = resolveOsHomeRelativePath(homeDir ?? "~");
return path.join(baseDir, CLAUDE_CLI_USER_SETTINGS_RELATIVE_PATH);
}
function parseClaudeCliOauthCredential(claudeOauth: unknown): ClaudeCliCredential | null {
if (!claudeOauth || typeof claudeOauth !== "object") {
return null;
}
const data = claudeOauth as Record<string, unknown>;
const accessToken = data.accessToken;
const refreshToken = data.refreshToken;
const expiresAt = data.expiresAt;
// Plan metadata (e.g. subscriptionType "max", rateLimitTier "default_max_20x")
// lets usage surfaces label subscription windows without another API call.
const subscriptionType =
typeof data.subscriptionType === "string" && data.subscriptionType.trim()
? data.subscriptionType.trim()
: undefined;
const rateLimitTier =
typeof data.rateLimitTier === "string" && data.rateLimitTier.trim()
? data.rateLimitTier.trim()
: undefined;
const planFields = {
...(subscriptionType ? { subscriptionType } : {}),
...(rateLimitTier ? { rateLimitTier } : {}),
};
if (typeof accessToken !== "string" || !accessToken) {
return null;
}
if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= 0) {
return null;
}
if (typeof refreshToken === "string" && refreshToken) {
return {
type: "oauth",
provider: "anthropic",
access: accessToken,
refresh: refreshToken,
expires: expiresAt,
...planFields,
};
}
return {
type: "token",
provider: "anthropic",
token: accessToken,
expires: expiresAt,
...planFields,
};
}
export function resolveCodexCliHomePath(codexHome?: string, env: NodeJS.ProcessEnv = process.env) {
const configured = codexHome ?? env.CODEX_HOME;
// External CLI state belongs to the OS user, not OpenClaw's relocatable
@@ -464,154 +365,6 @@ function readGeminiCliCredentials(options?: { homeDir?: string }): GeminiCliCred
};
}
function readClaudeCliUserApiKeyHelperCredential(homeDir?: string): ClaudeCliCredential | null {
const raw = loadJsonFileThroughSymlink(resolveClaudeCliUserSettingsPath(homeDir));
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return null;
}
const helper = (raw as Record<string, unknown>).apiKeyHelper;
return typeof helper === "string" && helper.trim().length > 0
? {
type: "api_key_helper",
provider: "anthropic",
helperHash: createHash("sha256").update(helper.trim()).digest("hex"),
}
: null;
}
// The CLI login flow writes the account identity to the config file next to
// the credential store, so the pair describes one login. Capturing it here
// keeps usage surfaces from re-reading ambient config at fetch time, where a
// later account switch could mislabel another credential's quota.
function readClaudeCliAccountEmail(homeDir?: string): string | undefined {
const baseDir = resolveOsHomeRelativePath(homeDir ?? "~");
const raw = loadJsonFileThroughSymlink(path.join(baseDir, ".claude.json"));
if (!raw || typeof raw !== "object") {
return undefined;
}
const account = (raw as { oauthAccount?: unknown }).oauthAccount;
if (!account || typeof account !== "object") {
return undefined;
}
const email = (account as { emailAddress?: unknown }).emailAddress;
return typeof email === "string" && email.trim() ? email.trim() : undefined;
}
function withClaudeAccountEmail(
cliLogin: ClaudeCliCredential | null,
homeDir?: string,
): ClaudeCliCredential | null {
if (!cliLogin) {
return null;
}
if (cliLogin.type === "api_key_helper") {
return cliLogin;
}
const email = readClaudeCliAccountEmail(homeDir);
return email ? { ...cliLogin, email } : cliLogin;
}
/** Reads Claude CLI credentials in Claude Code's credential precedence order. */
function readClaudeCliCredentials(options?: {
allowKeychainPrompt?: boolean;
tryKeychainWithoutPrompt?: boolean;
onStoredCredentialUnreadable?: () => void;
platform?: NodeJS.Platform;
homeDir?: string;
execSync?: ExecSyncFn;
}): ClaudeCliCredential | null {
const helperAuth = readClaudeCliUserApiKeyHelperCredential(options?.homeDir);
if (helperAuth) {
return helperAuth;
}
const platform = options?.platform ?? process.platform;
const tryKeychain =
platform === "darwin" &&
(options?.allowKeychainPrompt !== false || options?.tryKeychainWithoutPrompt === true);
if (tryKeychain) {
const keychainPayload = readClaudeCliKeychainPayload(
options?.execSync,
options?.tryKeychainWithoutPrompt ? CLAUDE_CLI_KEYCHAIN_TIMEOUT_MS : undefined,
);
const keychainCreds = parseClaudeCliOauthCredential(keychainPayload?.claudeAiOauth);
if (keychainCreds) {
log.info("read anthropic credentials from claude cli keychain", {
type: keychainCreds.type,
});
return withClaudeAccountEmail(keychainCreds, options?.homeDir);
}
}
const credPath = resolveClaudeCliCredentialsPath(options?.homeDir);
const raw = loadJsonFileThroughSymlink(credPath);
const fileCredential =
raw && typeof raw === "object"
? withClaudeAccountEmail(
parseClaudeCliOauthCredential((raw as Record<string, unknown>).claudeAiOauth),
options?.homeDir,
)
: null;
if (fileCredential) {
return fileCredential;
}
if (
options?.tryKeychainWithoutPrompt &&
(fs.existsSync(credPath) ||
(platform === "darwin" && hasClaudeCliKeychainItem(options.execSync)))
) {
options.onStoredCredentialUnreadable?.();
}
return null;
}
type ClaudeCliCredentialReadOptions = {
allowKeychainPrompt?: boolean;
tryKeychainWithoutPrompt?: boolean;
onStoredCredentialUnreadable?: () => void;
ttlMs?: number;
platform?: NodeJS.Platform;
homeDir?: string;
execSync?: ExecSyncFn;
};
/** @deprecated Anthropic provider-owned CLI credential helper; do not use from third-party plugins. */
export function readClaudeCliCredentialsCached(
options?: ClaudeCliCredentialReadOptions,
): ClaudeCliCredential | null {
const platform = options?.platform ?? process.platform;
const ttlMs = options?.ttlMs ?? 0;
const credentialsPath = resolveClaudeCliCredentialsPath(options?.homeDir);
const settingsPath = resolveClaudeCliUserSettingsPath(options?.homeDir);
const keychainIntent =
platform !== "darwin"
? "file"
: options?.tryKeychainWithoutPrompt
? "keychain-bounded"
: options?.allowKeychainPrompt !== false
? "keychain"
: "file";
return readCachedCliCredential({
ttlMs,
cache: claudeCliCache,
cacheKey: `${credentialsPath}:${keychainIntent}`,
read: () =>
readClaudeCliCredentials({
allowKeychainPrompt: options?.allowKeychainPrompt,
tryKeychainWithoutPrompt: options?.tryKeychainWithoutPrompt,
onStoredCredentialUnreadable: options?.onStoredCredentialUnreadable,
platform,
homeDir: options?.homeDir,
execSync: options?.execSync,
}),
setCache: (next) => {
claudeCliCache = next;
},
readSourceFingerprint: () =>
`${readFileMtimeMs(credentialsPath) ?? "missing"}:${readFileMtimeMs(settingsPath) ?? "missing"}`,
});
}
function formatCodexApiKeyForLoginStatus(key: string): string {
return key.length <= 13 ? "***" : `${key.slice(0, 8)}***${key.slice(-5)}`;
}
@@ -8,15 +8,15 @@ export type BundledCliBackendAuthPolicy = {
strictSelectedProfile: boolean;
/** Owner responsible for refreshing selected OAuth credentials before execution. */
oauthRefreshOwner: "core" | "cli";
/** Provider whose imported OAuth profiles use identity-verified native passthrough. */
nativePassthroughProviderId?: string;
/** Retired OAuth profile identities that the native runtime owns instead. */
nativeAuthProfileIds?: readonly string[];
};
const BUNDLED_CLI_BACKEND_AUTH_POLICIES = {
"claude-cli": {
strictSelectedProfile: true,
oauthRefreshOwner: "core",
nativePassthroughProviderId: "claude-cli",
nativeAuthProfileIds: ["anthropic:claude-cli"],
},
"google-gemini-cli": {
strictSelectedProfile: false,
+65 -138
View File
@@ -37,7 +37,6 @@ import {
createTestAdmittedRunContext,
createTestPreparedRunAdmission,
} from "../admitted-run-context.test-support.js";
import { readExternalCliBootstrapCredential as readExternalCliBootstrapCredentialImpl } from "../auth-profiles/external-cli-sync.js";
import { resolveApiKeyForProfile as resolveApiKeyForProfileImpl } from "../auth-profiles/oauth.js";
import {
loadAuthProfileStoreWithoutExternalProfiles,
@@ -457,7 +456,6 @@ describe("prepareCliRunContext", () => {
cleanup: vi.fn(async () => undefined),
})),
getCliLiveSessionGeneration: vi.fn(() => undefined),
readExternalCliBootstrapCredential: readExternalCliBootstrapCredentialImpl,
resolveApiKeyForProfile: resolveApiKeyForProfileImpl,
// Keep preparation off the real plugin-metadata snapshot; catalog-driven
// cases inject their own rows.
@@ -968,148 +966,77 @@ describe("prepareCliRunContext", () => {
);
});
it("runs an imported Claude CLI login natively without forwarding a credential", async () => {
const { dir } = fixture.session;
const agentDir = path.join(dir, "agents", "main", "agent");
const authProfileId = "anthropic:claude-cli";
const prepareExecution = vi.fn(async () => undefined);
fs.mkdirSync(agentDir, { recursive: true });
saveAuthProfileStore(
{
version: 1,
profiles: {
[authProfileId]: {
type: "oauth",
provider: "claude-cli",
access: "expired-imported-access",
refresh: "imported-refresh",
expires: Date.now() - 60_000,
email: "owner@example.com",
},
},
},
agentDir,
);
setCliBackendForPrepareTest({ prepareExecution, authEpochMode: "profile-only" });
const resolveApiKeyForProfile = vi.fn<typeof resolveApiKeyForProfileImpl>(async () => null);
setCliRunnerPrepareTestDeps({
resolveApiKeyForProfile,
// Live login is expired too: expiry is Claude's to repair via its own
// refresh token, so passthrough must not gate on it.
readExternalCliBootstrapCredential: vi.fn(() => ({
it.each([
{
label: "OAuth under the Claude CLI provider",
credential: {
type: "oauth" as const,
provider: "claude-cli",
access: "live-native-access",
refresh: "live-native-refresh",
expires: Date.now() - 30_000,
access: "expired-imported-access",
refresh: "imported-refresh",
expires: Date.now() - 60_000,
email: "owner@example.com",
})),
});
await fixture.prepare({
sessionKey: "agent:main:main",
agentDir,
provider: "claude-cli",
model: "sonnet",
authProfileId,
config: {},
});
expect(prepareExecution).toHaveBeenCalledTimes(1);
expect(prepareExecution).toHaveBeenCalledWith(
expect.objectContaining({ authProfileId, authCredential: undefined }),
);
expect(resolveApiKeyForProfile).not.toHaveBeenCalled();
});
it("fails closed when the imported Claude CLI login is gone from the host", async () => {
const { dir } = fixture.session;
const agentDir = path.join(dir, "agents", "main", "agent");
const authProfileId = "anthropic:claude-cli";
const prepareExecution = vi.fn(async () => undefined);
fs.mkdirSync(agentDir, { recursive: true });
saveAuthProfileStore(
{
version: 1,
profiles: {
[authProfileId]: {
type: "oauth",
provider: "claude-cli",
access: "expired-imported-access",
refresh: "imported-refresh",
expires: Date.now() - 60_000,
},
},
},
agentDir,
);
setCliBackendForPrepareTest({ prepareExecution, authEpochMode: "profile-only" });
setCliRunnerPrepareTestDeps({
readExternalCliBootstrapCredential: vi.fn(() => null),
});
const preparation = fixture.prepare({
sessionKey: "agent:main:main",
agentDir,
provider: "claude-cli",
model: "sonnet",
authProfileId,
config: {},
});
await expect(preparation).rejects.toThrow("no reusable Claude CLI login is available");
await expect(preparation).rejects.toThrow("claude auth login");
expect(prepareExecution).not.toHaveBeenCalled();
});
it("fails closed when the host Claude CLI login is a different account", async () => {
const { dir } = fixture.session;
const agentDir = path.join(dir, "agents", "main", "agent");
const authProfileId = "anthropic:claude-cli";
const prepareExecution = vi.fn(async () => undefined);
fs.mkdirSync(agentDir, { recursive: true });
saveAuthProfileStore(
{
version: 1,
profiles: {
[authProfileId]: {
type: "oauth",
provider: "claude-cli",
access: "imported-access",
refresh: "imported-refresh",
expires: Date.now() + 60 * 60_000,
accountId: "acct-selected",
email: "owner@example.com",
},
},
},
agentDir,
);
setCliBackendForPrepareTest({ prepareExecution, authEpochMode: "profile-only" });
setCliRunnerPrepareTestDeps({
readExternalCliBootstrapCredential: vi.fn(() => ({
},
{
label: "OAuth under the historical Anthropic provider",
credential: {
type: "oauth" as const,
provider: "claude-cli",
access: "other-account-access",
refresh: "other-account-refresh",
expires: Date.now() + 60 * 60_000,
accountId: "acct-other",
email: "other@example.com",
})),
});
provider: "anthropic",
access: "expired-imported-access",
refresh: "imported-refresh",
expires: Date.now() - 60_000,
email: "owner@example.com",
},
},
{
label: "a historical token credential",
credential: {
type: "token" as const,
provider: "anthropic",
token: "imported-token",
expires: Date.now() - 60_000,
},
},
])(
"runs an imported Claude CLI login stored as $label natively without forwarding a credential",
async ({ credential }) => {
const { dir } = fixture.session;
const agentDir = path.join(dir, "agents", "main", "agent");
const authProfileId = "anthropic:claude-cli";
const prepareExecution = vi.fn(async () => undefined);
fs.mkdirSync(agentDir, { recursive: true });
saveAuthProfileStore(
{
version: 1,
profiles: {
[authProfileId]: credential,
},
},
agentDir,
);
setCliBackendForPrepareTest({ prepareExecution, authEpochMode: "profile-only" });
const resolveApiKeyForProfile = vi.fn<typeof resolveApiKeyForProfileImpl>(async () => null);
setCliRunnerPrepareTestDeps({
resolveApiKeyForProfile,
});
const preparation = fixture.prepare({
sessionKey: "agent:main:main",
agentDir,
provider: "claude-cli",
model: "sonnet",
authProfileId,
config: {},
});
await expect(preparation).rejects.toThrow(
"current Claude CLI login belongs to a different account",
);
expect(prepareExecution).not.toHaveBeenCalled();
});
await fixture.prepare({
sessionKey: "agent:main:main",
agentDir,
provider: "claude-cli",
model: "sonnet",
authProfileId,
config: {},
});
expect(prepareExecution).toHaveBeenCalledTimes(1);
expect(prepareExecution).toHaveBeenCalledWith(
expect.objectContaining({ authProfileId: undefined, authCredential: undefined }),
);
expect(resolveApiKeyForProfile).not.toHaveBeenCalled();
},
);
it("does not revive a selected managed credential when auth resolution returns null", async () => {
const { dir } = fixture.session;
+10 -51
View File
@@ -58,10 +58,6 @@ import { hasAgentRosterProperty, resolveAgentWorkspaceDir } from "../agent-scope
import { resolveAgentDir, resolveSessionAgentIds } from "../agent-scope.js";
import { hasUsableOAuthCredential } from "../auth-profiles/credential-state.js";
import { externalCliDiscoveryForProviderAuth } from "../auth-profiles/external-cli-discovery.js";
import {
isSafeToUseExternalCliCredential,
readExternalCliBootstrapCredential,
} from "../auth-profiles/external-cli-sync.js";
import { buildOAuthRefreshFailureLoginCommand } from "../auth-profiles/oauth-refresh-failure.js";
import { resolveApiKeyForProfile } from "../auth-profiles/oauth.js";
import { resolveAuthProfileOrder } from "../auth-profiles/order.js";
@@ -199,7 +195,6 @@ const defaultPrepareDeps = {
claudeCliSessionTranscriptHasContent,
claudeCliSessionTranscriptHasOrphanedToolUse,
getCliLiveSessionGeneration,
readExternalCliBootstrapCredential,
resolveApiKeyForProfile,
loadManifestModelCatalog,
};
@@ -402,9 +397,7 @@ function shouldRefreshAuthProfileForExecution(params: {
type CliAuthProfileResolutionFailure =
| { kind: "unmaterialized" }
| { kind: "resolved-as-other"; resolvedProfileId: string }
| { kind: "native-login-missing" }
| { kind: "native-login-identity-mismatch" };
| { kind: "resolved-as-other"; resolvedProfileId: string };
function describeCliAuthProfileResolutionFailure(
profileId: string,
@@ -413,10 +406,6 @@ function describeCliAuthProfileResolutionFailure(
switch (failure.kind) {
case "resolved-as-other":
return `selected auth profile "${profileId}" resolved as "${failure.resolvedProfileId}"`;
case "native-login-missing":
return `selected auth profile "${profileId}" reuses the host's Claude CLI login, but no reusable Claude CLI login is available`;
case "native-login-identity-mismatch":
return `selected auth profile "${profileId}" reuses the host's Claude CLI login, but the current Claude CLI login belongs to a different account`;
case "unmaterialized":
return `could not materialize selected auth profile "${profileId}"`;
}
@@ -641,45 +630,15 @@ export async function prepareCliRunContext(
authCredential = authStore.profiles[effectiveAuthProfileId];
}
}
// Claude CLI-provider OAuth credentials exist only as imports of the host's
// own `claude` login; Claude owns that single-use refresh-token family.
// Forwarding a snapshot goes stale within hours and blocks the subprocess
// from refreshing itself, so verify the live login matches the selected
// identity and let Claude authenticate natively (it refreshes in place).
const nativeClaudeCliCredential =
backendAuthPolicy?.nativePassthroughProviderId !== undefined &&
authCredential?.type === "oauth" &&
authCredential.provider === backendAuthPolicy.nativePassthroughProviderId
? authCredential
: undefined;
if (effectiveAuthProfileId && authStore && nativeClaudeCliCredential) {
const authProfileId = effectiveAuthProfileId;
const liveNativeLogin = prepareDeps.readExternalCliBootstrapCredential({
store: authStore,
profileId: authProfileId,
credential: nativeClaudeCliCredential,
});
if (!liveNativeLogin) {
throw buildCliAuthProfileResolutionError({
backendId: backendResolved.id,
profileId: authProfileId,
provider: nativeClaudeCliCredential.provider,
agentDir,
failure: { kind: "native-login-missing" },
});
}
if (!isSafeToUseExternalCliCredential(nativeClaudeCliCredential, liveNativeLogin)) {
throw buildCliAuthProfileResolutionError({
backendId: backendResolved.id,
profileId: authProfileId,
provider: nativeClaudeCliCredential.provider,
agentDir,
failure: { kind: "native-login-identity-mismatch" },
});
}
// Spawn with no forwarded credential. The local-login auth epoch then keys
// the session to the host account (identity-hashed, rotation-stable), and
// the next store load re-adopts whatever Claude rotates.
// Claude owns its native login and single-use refresh-token family. Never
// preflight, refresh, or forward OpenClaw's snapshot; the installed Claude
// process validates and refreshes its own current login.
const usesNativeAuthProfile =
backendAuthPolicy?.nativeAuthProfileIds !== undefined &&
effectiveAuthProfileId !== undefined &&
backendAuthPolicy.nativeAuthProfileIds.includes(effectiveAuthProfileId);
if (usesNativeAuthProfile) {
effectiveAuthProfileId = undefined;
authCredential = undefined;
} else if (
effectiveAuthProfileId &&
@@ -13,7 +13,6 @@ const readCodexCliCredentialsCachedMock = vi.hoisted(() =>
);
vi.mock("../../cli-credentials.js", () => ({
readClaudeCliCredentialsCached: () => null,
readCodexCliCredentialsCached: readCodexCliCredentialsCachedMock,
readMiniMaxCliCredentialsCached: () => null,
}));
+1 -29
View File
@@ -19,7 +19,6 @@ import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot
import { isValidSecretRef } from "../secrets/ref-contract.js";
import type { PreparedAgentCredentialModes } from "./agent-auth-credential-modes.js";
import { hasUsableOAuthCredential } from "./auth-profiles/credential-state.js";
import { normalizeExternalCliProfileMetadata } from "./auth-profiles/external-cli-profile-metadata.js";
import {
listExternalCliSyncProviderIds,
resolveExternalCliAuthProfiles,
@@ -302,34 +301,7 @@ export function createModelAuthAvailabilityResolver(
...external.map((profile) => profile.profileId),
...getRuntimeExternalCliProfileIds(runtimeStore ?? store),
]);
// Runtime-owned CLI credentials are authoritative over legacy config metadata
// that described their canonical profile slots before OAuth was imported.
// Normalize only those exact marked profiles for read-only selection.
let readOnlyAuthProfiles:
| NonNullable<NonNullable<OpenClawConfig["auth"]>["profiles"]>
| undefined;
for (const profileId of externalCliRefreshProfileIds) {
const credential = (runtimeStore ?? store).profiles[profileId];
const configured = params.cfg.auth?.profiles?.[profileId];
const canonicalMetadata = normalizeExternalCliProfileMetadata(profileId, configured);
if (
credential?.type !== "oauth" ||
!configured ||
!canonicalMetadata ||
(configured.provider === canonicalMetadata.provider &&
configured.mode === canonicalMetadata.mode)
) {
continue;
}
readOnlyAuthProfiles ??= { ...params.cfg.auth?.profiles };
readOnlyAuthProfiles[profileId] = {
...configured,
...canonicalMetadata,
};
}
const readOnlyAuthConfig = readOnlyAuthProfiles
? { ...params.cfg, auth: { ...params.cfg.auth, profiles: readOnlyAuthProfiles } }
: params.cfg;
const readOnlyAuthConfig = params.cfg;
const providerConfig = (provider: string) =>
resolveMergedModelProviderConfig(params.cfg, provider);
const prepareAuthTarget = (provider: string, ref: ModelAuthAvailabilityRef): AuthTarget => {
+2 -42
View File
@@ -13,7 +13,6 @@ const mocks = vi.hoisted(() => ({
() => null,
),
resolveEnvApiKey: vi.fn<() => { apiKey: string; source: string } | null>(() => null),
readClaudeCliCredentialsCached: vi.fn<(options?: unknown) => unknown>(() => null),
readCodexCliCredentialsCached: vi.fn<(options?: unknown) => unknown>(() => null),
}));
@@ -32,7 +31,6 @@ vi.mock("./model-auth.js", () => ({
}));
vi.mock("./cli-credentials.js", () => ({
readClaudeCliCredentialsCached: mocks.readClaudeCliCredentialsCached,
readCodexCliCredentialsCached: mocks.readCodexCliCredentialsCached,
}));
@@ -50,8 +48,6 @@ describe("resolveModelAuthLabel", () => {
mocks.resolveUsableCustomProviderApiKey.mockReturnValue(null);
mocks.resolveEnvApiKey.mockReset();
mocks.resolveEnvApiKey.mockReturnValue(null);
mocks.readClaudeCliCredentialsCached.mockReset();
mocks.readClaudeCliCredentialsCached.mockReturnValue(null);
mocks.readCodexCliCredentialsCached.mockReset();
mocks.readCodexCliCredentialsCached.mockReturnValue(null);
});
@@ -225,54 +221,18 @@ describe("resolveModelAuthLabel", () => {
expect(mocks.resolveEnvApiKey).not.toHaveBeenCalled();
});
it("shows claude cli auth for claude-cli provider without auth profiles", () => {
it("shows native Claude CLI auth without reading credential storage", () => {
mocks.ensureAuthProfileStore.mockReturnValue({
version: 1,
profiles: {},
} as never);
mocks.resolveAuthProfileOrder.mockReturnValue([]);
mocks.readClaudeCliCredentialsCached.mockReturnValue({
type: "oauth",
provider: "claude-cli",
access: "token",
refresh: "refresh",
expires: Date.now() + 60_000,
});
const label = resolveModelAuthLabel({
provider: "claude-cli",
cfg: {},
});
expect(label).toBe("oauth (claude-cli)");
expect(mocks.readClaudeCliCredentialsCached).toHaveBeenCalledWith({
ttlMs: 5_000,
allowKeychainPrompt: false,
});
});
it("shows claude cli apiKeyHelper auth without calling it oauth", () => {
mocks.ensureAuthProfileStore.mockReturnValue({
version: 1,
profiles: {},
} as never);
mocks.resolveAuthProfileOrder.mockReturnValue([]);
mocks.readClaudeCliCredentialsCached.mockReturnValue({
type: "api_key_helper",
provider: "anthropic",
helperHash: "helper-hash",
});
const label = resolveModelAuthLabel({
provider: "claude-cli",
cfg: {},
});
expect(label).toBe("api-key-helper (claude-cli)");
expect(mocks.readClaudeCliCredentialsCached).toHaveBeenCalledWith({
ttlMs: 5_000,
allowKeychainPrompt: false,
});
expect(label).toBe("native (claude-cli)");
});
it("can skip external auth profile overlays for status labels", () => {
+2 -14
View File
@@ -13,10 +13,7 @@ import {
resolveAuthProfileOrder,
} from "./auth-profiles.js";
import { isStoredCredentialCompatibleWithAuthProvider } from "./auth-profiles/order.js";
import {
readClaudeCliCredentialsCached,
readCodexCliCredentialsCached,
} from "./cli-credentials.js";
import { readCodexCliCredentialsCached } from "./cli-credentials.js";
import {
resolveEnvApiKey,
resolveProviderEntryApiKeyProfileReference,
@@ -149,16 +146,7 @@ export function resolveModelAuthLabel(params: {
return "oauth (codex-cli)";
}
if (providerKey === "claude-cli") {
const auth = readClaudeCliCredentialsCached({
ttlMs: 5_000,
allowKeychainPrompt: false,
});
if (auth?.type === "api_key_helper") {
return "api-key-helper (claude-cli)";
}
if (auth) {
return "oauth (claude-cli)";
}
return "native (claude-cli)";
}
const customKey = resolveUsableCustomProviderApiKey({
+1 -3
View File
@@ -84,9 +84,7 @@ describe("model auth markers", () => {
it("reads bundled plugin-owned non-secret markers from manifests", () => {
withEnv(cleanPluginManifestEnv(), () => {
expect(isNonSecretApiKeyMarker("codex-app-server")).toBe(true);
expect(isNonSecretApiKeyMarker(["openclaw", "claude-cli-api-key-helper"].join(":"))).toBe(
true,
);
expect(isNonSecretApiKeyMarker(["openclaw", "claude-cli-native-auth"].join(":"))).toBe(true);
expect(isNonSecretApiKeyMarker("gcp-vertex-credentials")).toBe(true);
expect(isNonSecretApiKeyMarker("lmstudio-local")).toBe(true);
expect(isNonSecretApiKeyMarker("minimax-oauth")).toBe(true);
+55 -1
View File
@@ -7,6 +7,7 @@ import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
buildProviderMissingAuthMessageWithPlugin,
resolveProviderDeprecatedAuthProfileIds,
shouldDeferProviderSyntheticProfileAuthWithPlugin,
} from "../plugins/provider-runtime.js";
import { resolveOwningPluginIdsForProviderRef } from "../plugins/providers.js";
@@ -38,6 +39,27 @@ export type ProviderCredentialPrecedence = "profile-first" | "env-first";
const log = createSubsystemLogger("model-auth");
function isAuthProfileRetired(params: {
profileId: string;
deprecatedProfileIds: ReadonlySet<string>;
provider: string;
store: AuthProfileStore;
}): boolean {
if (!params.deprecatedProfileIds.has(params.profileId)) {
return false;
}
return true;
}
function assertAuthProfileNotRetired(params: Parameters<typeof isAuthProfileRetired>[0]): void {
if (!isAuthProfileRetired(params)) {
return;
}
throw new Error(
`Auth profile "${params.profileId}" is retired. Run ${formatCliCommand("openclaw doctor --fix")}.`,
);
}
function shouldDeferSyntheticProfileAuth(params: {
cfg: OpenClawConfig | undefined;
provider: string;
@@ -96,6 +118,11 @@ export async function resolveApiKeyForProviderCore(params: {
secretSentinels?: boolean;
}): Promise<ResolvedProviderAuth> {
const { provider, cfg, profileId, preferredProfile } = params;
let deprecatedProfileIds: ReadonlySet<string> | undefined;
const getDeprecatedProfileIds = () =>
(deprecatedProfileIds ??= new Set(
resolveProviderDeprecatedAuthProfileIds({ provider, config: cfg }),
));
const agentDir = params.agentDir?.trim() || (cfg ? resolveDefaultAgentDir(cfg) : undefined);
// Pending credential files own this agent's auth route until Doctor commits
// and archives them; do not fall through to env/config credentials.
@@ -123,6 +150,12 @@ export async function resolveApiKeyForProviderCore(params: {
return awsSdkProfileAuth;
}
const store = getScopedStore(profileId);
assertAuthProfileNotRetired({
profileId,
deprecatedProfileIds: getDeprecatedProfileIds(),
provider,
store,
});
const configuredProfileType = store.profiles[profileId]?.type;
if (configuredProfileType) {
assertAuthModeAllowedForModel({
@@ -269,6 +302,19 @@ export async function resolveApiKeyForProviderCore(params: {
// Matched profile references are terminal so bad bindings cannot silently
// fall through to a different credential or to the profile id as bearer text.
const providerEntryStore = getScopedStore();
const providerEntryReference = authConfig.resolveProviderEntryApiKeyProfileReference({
cfg,
provider,
store: providerEntryStore,
});
if ("profileId" in providerEntryReference) {
assertAuthProfileNotRetired({
profileId: providerEntryReference.profileId,
deprecatedProfileIds: getDeprecatedProfileIds(),
provider,
store: providerEntryStore,
});
}
const providerEntryBinding = await authConfig.resolveProviderEntryApiKeyBinding({
cfg,
provider,
@@ -371,7 +417,15 @@ export async function resolveApiKeyForProviderCore(params: {
provider,
preferredProfile,
forModel: params.modelId,
});
}).filter(
(candidateProfileId) =>
!isAuthProfileRetired({
profileId: candidateProfileId,
deprecatedProfileIds: getDeprecatedProfileIds(),
provider,
store,
}),
);
let deferredAuthProfileResult: ResolvedProviderAuth | null = null;
let refreshFailure: OAuthRefreshFailureError | undefined;
for (const candidate of order) {
+73 -32
View File
@@ -15,9 +15,9 @@ import type {
AuthProfileCredential,
AuthProfileStore,
OAuthCredential,
RuntimeAuthProfileStore,
} from "./auth-profiles/types.js";
import { resolveInlineProviderApiKeyUsageId } from "./auth-profiles/usage.js";
import type { ClaudeCliCredential } from "./cli-credentials.js";
import {
createRuntimeProviderAuthLookup,
getApiKeyForModelCore,
@@ -218,6 +218,8 @@ vi.mock("../plugins/provider-runtime.js", () => ({
},
formatProviderAuthProfileApiKeyWithPlugin: async () => undefined,
refreshProviderOAuthCredentialWithPlugin: async () => null,
resolveProviderDeprecatedAuthProfileIds: ({ provider }: { provider: string }) =>
provider === "anthropic" || provider === "claude-cli" ? ["anthropic:claude-cli"] : [],
resolveProviderSyntheticAuthWithPlugin: (params: {
provider: string;
context: { providerConfig?: { api?: string; baseUrl?: string; models?: unknown[] } };
@@ -257,9 +259,6 @@ vi.mock("../plugins/providers.js", () => ({
}));
const cliCredentialMocks = vi.hoisted(() => ({
readClaudeCliCredentialsCached: vi.fn<(options?: unknown) => ClaudeCliCredential | null>(
() => null,
),
readCodexCliCredentialsCached: vi.fn<(options?: unknown) => OAuthCredential | null>(() => null),
readMiniMaxCliCredentialsCached: vi.fn<(options?: unknown) => OAuthCredential | null>(() => null),
}));
@@ -268,7 +267,6 @@ vi.mock("./cli-credentials.js", () => cliCredentialMocks);
beforeEach(() => {
clearRuntimeAuthProfileStoreSnapshots();
cliCredentialMocks.readClaudeCliCredentialsCached.mockReset().mockReturnValue(null);
cliCredentialMocks.readCodexCliCredentialsCached.mockReset().mockReturnValue(null);
cliCredentialMocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null);
});
@@ -715,14 +713,6 @@ describe("getApiKeyForModelCore", () => {
});
it("does not read unrelated external CLI credentials when resolving provider auth", async () => {
cliCredentialMocks.readClaudeCliCredentialsCached.mockReturnValue({
type: "oauth",
provider: "anthropic",
access: "claude-cli-access",
refresh: "claude-cli-refresh",
expires: createUsableOAuthExpiry(),
});
await withOpenClawTestState(
{
layout: "state-only",
@@ -753,20 +743,11 @@ describe("getApiKeyForModelCore", () => {
},
);
expect(cliCredentialMocks.readClaudeCliCredentialsCached).not.toHaveBeenCalled();
expect(cliCredentialMocks.readCodexCliCredentialsCached).toHaveBeenCalled();
expect(cliCredentialMocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled();
});
it("reads Claude CLI credentials when the Claude CLI provider is resolved", async () => {
cliCredentialMocks.readClaudeCliCredentialsCached.mockReturnValue({
type: "oauth",
provider: "anthropic",
access: "claude-cli-access",
refresh: "claude-cli-refresh",
expires: createUsableOAuthExpiry(),
});
it("does not read Claude CLI credentials when the Claude CLI provider is resolved", async () => {
await withOpenClawTestState(
{
layout: "state-only",
@@ -774,18 +755,46 @@ describe("getApiKeyForModelCore", () => {
agentEnv: "main",
},
async () => {
const resolved = await resolveApiKeyForProviderCore({ provider: "claude-cli" });
expect(resolved.apiKey).toBe("claude-cli-access");
expect(resolved.profileId).toBe("anthropic:claude-cli");
expect(resolved.source).toBe("profile:anthropic:claude-cli");
expect(resolved.mode).toBe("oauth");
const error = await resolveApiKeyForProviderCore({ provider: "claude-cli" }).catch(
(caught: unknown) => caught,
);
expect(error).toMatchObject({
code: "missing-provider-auth",
provider: "claude-cli",
});
},
);
});
const options = cliCredentialMocks.readClaudeCliCredentialsCached.mock.calls.at(0)?.[0] as
| { allowKeychainPrompt?: boolean }
| undefined;
expect(options?.allowKeychainPrompt).toBe(false);
it("keeps the native Claude CLI profile out of Anthropic SDK auth resolution", async () => {
await withEnvAsync(
{
ANTHROPIC_API_KEY: "current-anthropic-key",
ANTHROPIC_OAUTH_TOKEN: undefined,
},
async () => {
const store: RuntimeAuthProfileStore = {
version: 1,
profiles: {
"anthropic:claude-cli": {
type: "oauth",
provider: "claude-cli",
access: "copied-native-access",
refresh: "copied-native-refresh",
expires: createUsableOAuthExpiry(),
},
},
runtimeExternalCliProfileIds: ["anthropic:claude-cli"],
};
const resolved = await resolveApiKeyForProviderCore({
provider: "anthropic",
store,
});
expect(resolved.apiKey).toBe("current-anthropic-key");
expect(resolved.source).toContain("ANTHROPIC_API_KEY");
},
);
});
it("throws when ZAI API key is missing", async () => {
@@ -1919,6 +1928,38 @@ describe("getApiKeyForModelCore", () => {
});
describe("resolveApiKeyForProviderCore — per-entry apiKey as profile ID reference", () => {
it("rejects a retired profile reference before resolving its copied credential", async () => {
await expect(
resolveApiKeyForProviderCore({
provider: "anthropic",
cfg: {
models: {
providers: {
anthropic: {
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
apiKey: "anthropic:claude-cli",
models: [],
},
},
},
},
store: {
version: 1,
profiles: {
"anthropic:claude-cli": {
type: "oauth",
provider: "anthropic",
access: "copied-native-access",
refresh: "copied-native-refresh",
expires: createUsableOAuthExpiry(),
},
},
},
}),
).rejects.toThrow(/anthropic:claude-cli.*retired.*doctor --fix/);
});
it("resolves actual credential when per-entry apiKey matches a profile ID in the store", async () => {
// Scenario from #67423: openrouter-minimax.apiKey = "openrouter:key-b"
// should resolve the actual key from that profile, not use the string literally.
+67 -11
View File
@@ -1,8 +1,15 @@
/** Migrates legacy provider-declared OAuth profile ids to current auth profile ids. */
/** Removes retired provider profiles and repairs legacy OAuth profile ids. */
import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js";
import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js";
import { repairOAuthProfileIdMismatch } from "../agents/auth-profiles/repair.js";
import { ensureAuthProfileStore } from "../agents/auth-profiles/store.js";
import { ensureAuthProfileStoreWithoutExternalProfiles } from "../agents/auth-profiles/store.js";
import { applyProviderConfigDefaultsForConfig } from "../config/provider-policy.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
configReferencesAuthProfile,
removeAuthProfileConfig,
} from "../plugins/provider-auth-helpers.js";
import { listAuthProfileRepairCandidates } from "./doctor-auth-legacy-paths.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
async function loadProviderRuntime() {
@@ -31,21 +38,60 @@ function sanitizePromptLabel(label: string | undefined): string | undefined {
export async function maybeRepairLegacyOAuthProfileIds(
cfg: OpenClawConfig,
prompter: DoctorPrompter,
): Promise<OpenClawConfig> {
if (!hasConfigOAuthProfiles(cfg)) {
return cfg;
}
const store = ensureAuthProfileStore();
if (Object.keys(store.profiles).length === 0) {
return cfg;
}
): Promise<LegacyOAuthProfileRepairResult> {
let nextCfg = cfg;
const retiredProfileCleanupPlans: RetiredAuthProfileCleanupPlan[] = [];
const { resolvePluginProvidersCore } = await loadProviderRuntime();
const providers = resolvePluginProvidersCore({
config: cfg,
env: process.env,
mode: "setup",
});
const repairCandidates = listAuthProfileRepairCandidates(nextCfg, process.env);
for (const provider of providers) {
for (const profileId of provider.deprecatedProfileIds ?? []) {
const profileStores = repairCandidates.filter((candidate) =>
Boolean(loadPersistedAuthProfileStore(candidate.agentDir)?.profiles[profileId]),
);
if (profileStores.length === 0 && !configReferencesAuthProfile(nextCfg, profileId)) {
continue;
}
const { note } = await loadNoteRuntime();
note(
`- Remove retired auth profile ${profileId}. The provider's native login remains unchanged.`,
"Auth profiles",
);
const label = sanitizePromptLabel(provider.label) ?? provider.id;
const apply = await prompter.confirm({
message: `Remove retired ${label} auth profile now?`,
initialValue: true,
});
if (!apply) {
continue;
}
// Preserve provider-owned runtime selection while the retired profile still
// identifies it. Removing the profile first loses that migration signal.
nextCfg = applyProviderConfigDefaultsForConfig({
provider: provider.id,
config: nextCfg,
env: process.env,
});
nextCfg = removeAuthProfileConfig(nextCfg, profileId);
for (const candidate of profileStores) {
retiredProfileCleanupPlans.push({
agentDir: candidate.agentDir,
profileIds: [profileId],
});
}
}
}
if (!hasConfigOAuthProfiles(nextCfg)) {
return { config: nextCfg, retiredProfileCleanupPlans };
}
const store = ensureAuthProfileStoreWithoutExternalProfiles();
if (Object.keys(store.profiles).length === 0) {
return { config: nextCfg, retiredProfileCleanupPlans };
}
for (const provider of providers) {
for (const repairSpec of provider.oauthProfileIdRepairs ?? []) {
const repair = repairOAuthProfileIdMismatch({
@@ -74,5 +120,15 @@ export async function maybeRepairLegacyOAuthProfileIds(
nextCfg = repair.config;
}
}
return nextCfg;
return { config: nextCfg, retiredProfileCleanupPlans };
}
export type RetiredAuthProfileCleanupPlan = {
agentDir?: string;
profileIds: readonly string[];
};
export type LegacyOAuthProfileRepairResult = {
config: OpenClawConfig;
retiredProfileCleanupPlans: readonly RetiredAuthProfileCleanupPlan[];
};
@@ -12,9 +12,19 @@ const resolvePluginProvidersMock = vi.fn<() => ProviderPlugin[]>(() => []);
const authProfileStoreMock = vi.hoisted(() => ({
store: { version: 1, profiles: {} } as AuthProfileStore,
}));
const candidateMocks = vi.hoisted(() => ({
candidates: [{ agentDir: undefined, authPath: "/tmp/shared/openclaw-agent.sqlite" }] as Array<{
agentDir?: string;
authPath: string;
}>,
stores: new Map<string | undefined, AuthProfileStore>(),
}));
const repairMocks = vi.hoisted(() => ({
repairOAuthProfileIdMismatch: vi.fn(),
}));
const providerPolicyMocks = vi.hoisted(() => ({
applyConfigDefaults: vi.fn((params: { config: OpenClawConfig }) => params.config),
}));
vi.mock("../plugins/providers.runtime.js", () => ({
resolvePluginProvidersCore: () => resolvePluginProvidersMock(),
@@ -24,8 +34,25 @@ vi.mock("../agents/auth-profiles/repair.js", () => ({
repairOAuthProfileIdMismatch: repairMocks.repairOAuthProfileIdMismatch,
}));
vi.mock("../config/provider-policy.js", () => ({
applyProviderConfigDefaultsForConfig: providerPolicyMocks.applyConfigDefaults,
}));
vi.mock("../agents/auth-profiles/persisted.js", () => ({
loadPersistedAuthProfileStore: (agentDir?: string) =>
candidateMocks.stores.has(agentDir)
? candidateMocks.stores.get(agentDir)
: agentDir === undefined
? authProfileStoreMock.store
: undefined,
}));
vi.mock("./doctor-auth-legacy-paths.js", () => ({
listAuthProfileRepairCandidates: () => candidateMocks.candidates,
}));
vi.mock("../agents/auth-profiles/store.js", () => ({
ensureAuthProfileStore: () => authProfileStoreMock.store,
ensureAuthProfileStoreWithoutExternalProfiles: () => authProfileStoreMock.store,
}));
vi.mock("../../packages/terminal-core/src/note.js", () => ({
@@ -72,22 +99,29 @@ beforeEach(() => {
resolvePluginProvidersMock.mockReset();
resolvePluginProvidersMock.mockReturnValue([]);
authProfileStoreMock.store = { version: 1, profiles: {} };
candidateMocks.candidates = [
{ agentDir: undefined, authPath: "/tmp/shared/openclaw-agent.sqlite" },
];
candidateMocks.stores.clear();
repairMocks.repairOAuthProfileIdMismatch.mockReset();
repairMocks.repairOAuthProfileIdMismatch.mockReturnValue({
config: {},
changes: [],
migrated: false,
});
providerPolicyMocks.applyConfigDefaults.mockReset();
providerPolicyMocks.applyConfigDefaults.mockImplementation(({ config }) => config);
});
describe("maybeRepairLegacyOAuthProfileIds", () => {
it("skips provider loading when config has no legacy OAuth profiles", async () => {
it("skips profile repair when config has no legacy OAuth profiles", async () => {
const cfg = { channels: { telegram: { enabled: true } } } as OpenClawConfig;
const next = await maybeRepairLegacyOAuthProfileIds(cfg, makePrompter(true));
const result = await maybeRepairLegacyOAuthProfileIds(cfg, makePrompter(true));
expect(next).toBe(cfg);
expect(resolvePluginProvidersMock).not.toHaveBeenCalled();
expect(result.config).toBe(cfg);
expect(result.retiredProfileCleanupPlans).toEqual([]);
expect(resolvePluginProvidersMock).toHaveBeenCalledOnce();
expect(repairMocks.repairOAuthProfileIdMismatch).not.toHaveBeenCalled();
});
@@ -136,7 +170,7 @@ describe("maybeRepairLegacyOAuthProfileIds", () => {
},
});
const next = await maybeRepairLegacyOAuthProfileIds(
const { config: next } = await maybeRepairLegacyOAuthProfileIds(
{
auth: {
profiles: {
@@ -176,6 +210,156 @@ describe("maybeRepairLegacyOAuthProfileIds", () => {
expect(auth.order?.anthropic).toEqual(["anthropic:user@example.com"]);
});
it("removes a provider-declared retired auth profile and config references", async () => {
authProfileStoreMock.store = {
version: 1,
profiles: {
"anthropic:claude-cli": {
type: "oauth",
provider: "anthropic",
access: "copied-native-access",
refresh: "copied-native-refresh",
expires: Date.now() + 60_000,
},
"anthropic:managed": {
type: "api_key",
provider: "anthropic",
key: "managed-key",
},
},
};
resolvePluginProvidersMock.mockReturnValue([
{
id: "anthropic",
label: "Anthropic",
auth: [],
deprecatedProfileIds: ["anthropic:claude-cli"],
},
]);
providerPolicyMocks.applyConfigDefaults.mockImplementation(({ config }) => ({
...config,
agents: {
...config.agents,
defaults: {
...config.agents?.defaults,
models: {
...config.agents?.defaults?.models,
"anthropic/claude-sonnet-4-6": {
agentRuntime: { id: "claude-cli" },
},
},
},
},
}));
const result = await maybeRepairLegacyOAuthProfileIds(
{
auth: {
profiles: {
"anthropic:claude-cli": { provider: "claude-cli", mode: "oauth" },
"anthropic:managed": { provider: "anthropic", mode: "api_key" },
},
order: {
anthropic: ["anthropic:claude-cli", "anthropic:managed"],
},
},
agents: {
defaults: {
model: { primary: "anthropic/claude-sonnet-4-6" },
},
},
models: {
providers: {
anthropic: {
baseUrl: "https://api.anthropic.com",
apiKey: "anthropic:claude-cli",
models: [],
},
},
},
} as OpenClawConfig,
makePrompter(true),
);
const next = result.config;
expect(next.auth?.profiles).toEqual({
"anthropic:managed": { provider: "anthropic", mode: "api_key" },
});
expect(next.auth?.order?.anthropic).toEqual(["anthropic:managed"]);
expect(next.agents?.defaults?.models?.["anthropic/claude-sonnet-4-6"]?.agentRuntime).toEqual({
id: "claude-cli",
});
expect(next.models?.providers?.anthropic?.apiKey).toBeUndefined();
expect(result.retiredProfileCleanupPlans).toContainEqual({
agentDir: undefined,
profileIds: ["anthropic:claude-cli"],
});
});
it("removes a config-only provider entry reference to a retired profile", async () => {
resolvePluginProvidersMock.mockReturnValue([
{
id: "anthropic",
label: "Anthropic",
auth: [],
deprecatedProfileIds: ["anthropic:claude-cli"],
},
]);
const { config: next, retiredProfileCleanupPlans } = await maybeRepairLegacyOAuthProfileIds(
{
models: {
providers: {
anthropic: {
baseUrl: "https://api.anthropic.com",
apiKey: "anthropic:claude-cli",
models: [],
},
},
},
} as OpenClawConfig,
makePrompter(true),
);
expect(next.models?.providers?.anthropic?.apiKey).toBeUndefined();
expect(retiredProfileCleanupPlans).toEqual([]);
});
it("removes a retired profile from a secondary agent store", async () => {
const secondaryAgentDir = "/tmp/state/agents/secondary/agent";
candidateMocks.candidates = [
{ agentDir: undefined, authPath: "/tmp/shared/openclaw-agent.sqlite" },
{ agentDir: secondaryAgentDir, authPath: `${secondaryAgentDir}/openclaw-agent.sqlite` },
];
candidateMocks.stores.set(secondaryAgentDir, {
version: 1,
profiles: {
"anthropic:claude-cli": {
type: "oauth",
provider: "anthropic",
access: "copied-native-access",
refresh: "copied-native-refresh",
expires: Date.now() + 60_000,
},
},
});
resolvePluginProvidersMock.mockReturnValue([
{
id: "anthropic",
label: "Anthropic",
auth: [],
deprecatedProfileIds: ["anthropic:claude-cli"],
},
]);
const result = await maybeRepairLegacyOAuthProfileIds({} as OpenClawConfig, makePrompter(true));
expect(result.retiredProfileCleanupPlans).toContainEqual({
agentDir: secondaryAgentDir,
profileIds: ["anthropic:claude-cli"],
});
});
it("strips provider-controlled terminal escapes from repair prompts", async () => {
authProfileStoreMock.store = {
version: 1,
-12
View File
@@ -21,7 +21,6 @@ import {
resolveApiKeyForProfile,
resolveProfileUnusableUntilForDisplay,
} from "../agents/auth-profiles.js";
import { CLAUDE_CLI_PROFILE_ID } from "../agents/auth-profiles/constants.js";
import { formatAuthDoctorHint } from "../agents/auth-profiles/doctor.js";
import {
buildAuthProfileUnusableHint,
@@ -42,7 +41,6 @@ import type { DoctorPrompter } from "./doctor-prompter.js";
const OPENAI_PROVIDER_ID = "openai";
const LEGACY_CODEX_PROVIDER_ID = "openai-codex";
const CLAUDE_CLI_PROVIDER_ID = "claude-cli";
const CODEX_OAUTH_WARNING_TITLE = "Codex OAuth";
const OPENAI_BASE_URL = "https://api.openai.com/v1";
const LEGACY_CODEX_APIS = new Set(["openai-responses", "openai-completions"]);
@@ -345,16 +343,6 @@ function isAuthProfileHealthIssue(profile: AuthHealthSummary["profiles"][number]
if (profile.type === "api_key") {
return profile.status === "missing";
}
// Claude CLI refreshes its short-lived access token when the process runs.
// Warn once that external credential is unusable, not throughout its normal lifetime.
if (
profile.profileId === CLAUDE_CLI_PROFILE_ID &&
profile.provider === CLAUDE_CLI_PROVIDER_ID &&
profile.type === "oauth" &&
profile.status === "expiring"
) {
return false;
}
return (
(profile.type === "oauth" || profile.type === "token") &&
(profile.status === "expired" || profile.status === "expiring" || profile.status === "missing")
+10 -101
View File
@@ -3,8 +3,6 @@ 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 { resolveClaudeCliProjectDirForWorkspace } from "../agents/command/claude-cli-project-dir.js";
import { noteClaudeCliHealth } from "./doctor-claude-cli.js";
@@ -21,17 +19,6 @@ vi.mock("../agents/agent-runtime-metadata.js", () => ({
resolveModelAgentRuntimeMetadata: resolveModelAgentRuntimeMetadataMock,
}));
vi.mock("../agents/auth-profiles/store.js", () => ({
ensureAuthProfileStore: vi.fn(),
}));
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> {
@@ -100,8 +87,6 @@ describe("noteClaudeCliHealth", () => {
homeDir,
workspaceDir,
noteFn: vi.fn(),
store: createStore(),
readClaudeCliCredentials: () => null,
resolveCommandPath,
},
);
@@ -116,8 +101,6 @@ describe("noteClaudeCliHealth", () => {
{},
{
noteFn,
store: createStore(),
readClaudeCliCredentials: () => null,
},
);
expect(noteFn).not.toHaveBeenCalled();
@@ -142,19 +125,7 @@ describe("noteClaudeCliHealth", () => {
homeDir,
workspaceDir,
noteFn,
store: createStore({
[CLAUDE_CLI_PROFILE_ID]: {
type: "oauth",
provider: "claude-cli",
access: "test-auth-token",
refresh: "test-token-placeholder",
expires: Date.now() + 60_000,
},
}),
readClaudeCliCredentials: () => ({
type: "oauth",
expires: Date.now() + 60_000,
}),
isAuthenticated: () => true,
resolveCommandPath: () => "/opt/homebrew/bin/claude",
},
);
@@ -207,19 +178,7 @@ describe("noteClaudeCliHealth", () => {
{
homeDir,
noteFn,
store: createStore({
[CLAUDE_CLI_PROFILE_ID]: {
type: "oauth",
provider: "claude-cli",
access: "test-auth-token",
refresh: "test-token-placeholder",
expires: Date.now() + 60_000,
},
}),
readClaudeCliCredentials: () => ({
type: "oauth",
expires: Date.now() + 60_000,
}),
isAuthenticated: () => true,
resolveCommandPath: () => "/opt/homebrew/bin/claude",
},
);
@@ -228,7 +187,7 @@ describe("noteClaudeCliHealth", () => {
});
});
it("explains the exact bad wiring when the claude-cli auth profile is missing", async () => {
it("reports when Claude CLI owns no active login", async () => {
await withTempHome(({ homeDir, workspaceDir }) => {
const noteFn = vi.fn();
noteClaudeCliHealth(
@@ -244,26 +203,19 @@ describe("noteClaudeCliHealth", () => {
homeDir,
workspaceDir,
noteFn,
store: createStore(),
readClaudeCliCredentials: () => ({
type: "oauth",
expires: Date.now() + 60_000,
}),
isAuthenticated: () => false,
resolveCommandPath: () => "/opt/homebrew/bin/claude",
},
);
const body = noteBody(noteFn);
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).not.toContain("Headless Claude auth: OK");
expect(body).not.toContain("not created yet");
expect(body).toContain("Claude auth: not logged in.");
expect(body).toContain("claude auth login");
expect(body).not.toContain("openclaw models auth login");
});
});
it("accepts Claude CLI apiKeyHelper without a stored auth profile", async () => {
it("warns when the Claude binary is missing", async () => {
await withTempHome(({ homeDir, workspaceDir }) => {
const noteFn = vi.fn();
noteClaudeCliHealth(
@@ -279,44 +231,13 @@ describe("noteClaudeCliHealth", () => {
homeDir,
workspaceDir,
noteFn,
store: createStore(),
readClaudeCliCredentials: () => ({
type: "api_key_helper",
}),
resolveCommandPath: () => "/opt/homebrew/bin/claude",
},
);
expect(noteFn).not.toHaveBeenCalled();
});
});
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" },
},
entries: { main: { default: true } },
},
},
{
homeDir,
workspaceDir,
noteFn,
store: createStore(),
readClaudeCliCredentials: () => null,
resolveCommandPath: () => undefined,
},
);
const body = noteBody(noteFn);
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");
expect(body).not.toContain("claude auth login");
});
});
@@ -358,19 +279,7 @@ describe("noteClaudeCliHealth", () => {
{
homeDir,
noteFn,
store: createStore({
[CLAUDE_CLI_PROFILE_ID]: {
type: "oauth",
provider: "claude-cli",
access: "test-auth-token",
refresh: "test-token-placeholder",
expires: Date.now() + 60_000,
},
}),
readClaudeCliCredentials: () => ({
type: "oauth",
expires: Date.now() + 60_000,
}),
isAuthenticated: () => true,
resolveCommandPath: () => "/opt/homebrew/bin/claude",
},
);
+30 -53
View File
@@ -1,5 +1,7 @@
/** Doctor health note for Claude CLI binary, auth, and workspace/project directories. */
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import {
normalizeOptionalLowercaseString,
resolvePrimaryStringValue,
@@ -11,16 +13,7 @@ import {
resolveAgentWorkspaceDir,
tryResolveDefaultAgentId,
} from "../agents/agent-scope-config.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 { resolveCliBackendConfig } from "../agents/cli-backends.js";
import { readClaudeCliCredentialsCached } from "../agents/cli-credentials.js";
import { resolveClaudeCliProjectDirForWorkspace } from "../agents/command/claude-cli-project-dir.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -29,17 +22,29 @@ import { shortenHomePath } from "../utils.js";
const CLAUDE_CLI_PROVIDER = "claude-cli";
type ClaudeCliReadableCredential =
| Pick<OAuthCredential, "type" | "expires">
| Pick<TokenCredential, "type" | "expires">
| { type: "api_key_helper" };
type ClaudeCliDirHealth = "present" | "missing" | "not_directory" | "unreadable" | "readonly";
function isClaudeCliAuthenticated(commandPath: string, env: NodeJS.ProcessEnv): boolean {
const result = spawnSync(commandPath, ["auth", "status", "--json"], {
encoding: "utf8",
env,
maxBuffer: 64 * 1024,
timeout: 3_000,
windowsHide: true,
});
if (result.error || result.status !== 0) {
return false;
}
try {
const parsed: unknown = JSON.parse(result.stdout);
return isRecord(parsed) && parsed.loggedIn === true;
} catch {
return false;
}
}
function usesClaudeCliModelSelection(cfg: OpenClawConfig): boolean {
const primary = resolvePrimaryStringValue(
cfg.agents?.defaults?.model as string | { primary?: string; fallbacks?: string[] } | undefined,
);
const primary = resolvePrimaryStringValue(cfg.agents?.defaults?.model);
if (normalizeOptionalLowercaseString(primary)?.startsWith(`${CLAUDE_CLI_PROVIDER}/`)) {
return true;
}
@@ -170,8 +175,7 @@ function resolveClaudeCliWorkspaceTargets(params: {
/**
* Emits Claude CLI health diagnostics for every agent currently routed through the CLI backend.
*
* The optional deps let tests inject auth stores, PATH resolution, and workspace roots without
* touching the user's real Claude credentials or filesystem.
* The optional deps let tests inject the CLI status probe, PATH resolution, and workspace roots.
*/
export function noteClaudeCliHealth(
cfg: OpenClawConfig,
@@ -179,8 +183,7 @@ export function noteClaudeCliHealth(
noteFn?: typeof note;
env?: NodeJS.ProcessEnv;
homeDir?: string;
store?: AuthProfileStore;
readClaudeCliCredentials?: () => ClaudeCliReadableCredential | null;
isAuthenticated?: (commandPath: string, env: NodeJS.ProcessEnv) => boolean;
resolveCommandPath?: (command: string, env?: NodeJS.ProcessEnv) => string | undefined;
workspaceDir?: string;
},
@@ -196,11 +199,6 @@ export function noteClaudeCliHealth(
return;
}
const store = deps?.store ?? ensureAuthProfileStore(undefined, { allowKeychainPrompt: false });
const readClaudeCliCredentials =
deps?.readClaudeCliCredentials ??
(() => readClaudeCliCredentialsCached({ allowKeychainPrompt: false }));
const credential = readClaudeCliCredentials();
const backend = resolveCliBackendConfig(CLAUDE_CLI_PROVIDER, cfg);
const command = backend?.config.command ?? "claude";
const resolveCommandPath =
@@ -208,8 +206,9 @@ export function noteClaudeCliHealth(
((rawCommand: string, nextEnv?: NodeJS.ProcessEnv) =>
resolveExecutablePath(rawCommand, { env: nextEnv }));
const commandPath = resolveCommandPath(command, env);
const authStorePath = resolveAuthStorePathForDisplay();
const storedProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
const authenticated = commandPath
? (deps?.isAuthenticated ?? isClaudeCliAuthenticated)(commandPath, env)
: false;
const defaultAgentId = tryResolveDefaultAgentId(cfg);
const showAgentLabels =
workspaceTargets.length > 1 ||
@@ -225,31 +224,9 @@ export function noteClaudeCliHealth(
);
}
if (!credential) {
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 && credential?.type !== "api_key_helper") {
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 && 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.`,
);
if (commandPath && !authenticated) {
lines.push("- Claude auth: not logged in.");
fixHints.push(`- Fix: run ${formatCliCommand("claude auth login")}.`);
}
for (const target of workspaceTargets) {
@@ -1,198 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AuthProfileStore } from "../agents/auth-profiles/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
const mocks = vi.hoisted(() => ({
listCandidates: vi.fn(() => [
{ agentDir: "/tmp/main", authPath: "/tmp/main/auth-profiles.json" },
]),
loadStore: vi.fn<() => AuthProfileStore | null>(() => null),
resolveExternalCliAuthProfiles: vi.fn<() => unknown[]>(() => []),
runTransaction: vi.fn((_agentDir, callback) => callback({})),
saveStore: vi.fn(),
}));
vi.mock("./doctor-auth-legacy-paths.js", () => ({
listAuthProfileRepairCandidates: mocks.listCandidates,
}));
vi.mock("../agents/auth-profiles/persisted.js", () => ({
loadPersistedAuthProfileStore: mocks.loadStore,
}));
vi.mock("../agents/auth-profiles/external-cli-sync.js", () => ({
resolveExternalCliAuthProfiles: mocks.resolveExternalCliAuthProfiles,
}));
vi.mock("../agents/auth-profiles/sqlite.js", () => ({
runAuthProfileWriteTransaction: mocks.runTransaction,
}));
vi.mock("../agents/auth-profiles/store.js", () => ({ saveAuthProfileStore: mocks.saveStore }));
import { maybeMigrateExternalCliProfileMetadata } from "./doctor-external-cli-profiles.js";
afterEach(() => vi.clearAllMocks());
describe("external CLI auth profile doctor migration", () => {
it("leaves legacy MiniMax metadata outside the Claude migration scope", () => {
const profileId = "minimax-portal:minimax-cli";
const cfg = {
auth: { profiles: { [profileId]: { provider: "minimax", mode: "token" } } },
} as OpenClawConfig;
const result = maybeMigrateExternalCliProfileMetadata({ cfg, env: {} });
expect(cfg.auth?.profiles?.[profileId]).toEqual({ provider: "minimax", mode: "token" });
expect(mocks.resolveExternalCliAuthProfiles).not.toHaveBeenCalled();
expect(mocks.saveStore).not.toHaveBeenCalled();
expect(result).toEqual({ changes: [], warnings: [], configChanged: false });
});
it("persists the CLI credential before canonicalizing legacy Claude metadata", () => {
const profileId = "anthropic:claude-cli";
mocks.resolveExternalCliAuthProfiles.mockReturnValueOnce([
{
profileId,
persistence: "persisted",
credential: {
type: "oauth",
provider: "claude-cli",
access: "rotated-access",
refresh: "rotated-refresh",
expires: Date.now() + 30 * 60_000,
email: "stored@example.com",
},
},
]);
const cfg = {
auth: { profiles: { [profileId]: { provider: "anthropic", mode: "token" } } },
} as OpenClawConfig;
const result = maybeMigrateExternalCliProfileMetadata({ cfg, env: {} });
expect(cfg.auth?.profiles?.[profileId]).toEqual({ provider: "claude-cli", mode: "oauth" });
expect(mocks.resolveExternalCliAuthProfiles).toHaveBeenCalledWith(
expect.objectContaining({ profiles: {} }),
expect.objectContaining({ profileIds: [profileId], allowKeychainPrompt: false }),
);
expect(mocks.saveStore).toHaveBeenCalledWith(
expect.objectContaining({
profiles: expect.objectContaining({
[profileId]: expect.objectContaining({ type: "oauth" }),
}),
}),
"/tmp/main",
{ syncExternalCli: false },
{},
);
expect(result).toMatchObject({ configChanged: true, warnings: [] });
});
it("keeps legacy metadata when the imported CLI credential has no identity", () => {
const profileId = "anthropic:claude-cli";
mocks.resolveExternalCliAuthProfiles.mockReturnValueOnce([
{
profileId,
persistence: "persisted",
credential: {
type: "oauth",
provider: "claude-cli",
access: "rotated-access",
refresh: "rotated-refresh",
expires: Date.now() + 30 * 60_000,
},
},
]);
const cfg = {
auth: { profiles: { [profileId]: { provider: "anthropic", mode: "token" } } },
} as OpenClawConfig;
const result = maybeMigrateExternalCliProfileMetadata({ cfg, env: {} });
expect(cfg.auth?.profiles?.[profileId]).toEqual({ provider: "anthropic", mode: "token" });
expect(mocks.saveStore).toHaveBeenCalledWith(
expect.objectContaining({
profiles: expect.objectContaining({
[profileId]: expect.objectContaining({ type: "oauth" }),
}),
}),
"/tmp/main",
{ syncExternalCli: false },
{},
);
expect(result).toMatchObject({ configChanged: false });
expect(result.warnings).toContain(
"Kept legacy external CLI metadata for anthropic:claude-cli: identity-complete OAuth credentials were not saved for every auth profile store.",
);
});
it("keeps legacy metadata when no current CLI credential can be persisted", () => {
const profileId = "anthropic:claude-cli";
const cfg = {
auth: { profiles: { [profileId]: { provider: "anthropic", mode: "token" } } },
} as OpenClawConfig;
const result = maybeMigrateExternalCliProfileMetadata({ cfg, env: {} });
expect(cfg.auth?.profiles?.[profileId]).toEqual({ provider: "anthropic", mode: "token" });
expect(mocks.saveStore).not.toHaveBeenCalled();
expect(result).toMatchObject({ configChanged: false });
expect(result.warnings).toContain(
"Kept legacy external CLI metadata for anthropic:claude-cli: identity-complete OAuth credentials were not saved for every auth profile store.",
);
});
it("canonicalizes legacy metadata for an already-valid persisted CLI credential", () => {
const profileId = "anthropic:claude-cli";
mocks.loadStore.mockReturnValueOnce({
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "claude-cli",
access: "stored-access",
refresh: "stored-refresh",
expires: Date.now() + 30 * 60_000,
email: "stored@example.com",
},
},
});
const cfg = {
auth: { profiles: { [profileId]: { provider: "anthropic", mode: "token" } } },
} as OpenClawConfig;
const result = maybeMigrateExternalCliProfileMetadata({ cfg, env: {} });
expect(cfg.auth?.profiles?.[profileId]).toEqual({ provider: "claude-cli", mode: "oauth" });
expect(mocks.saveStore).not.toHaveBeenCalled();
expect(result).toMatchObject({ configChanged: true, warnings: [] });
});
it("keeps legacy metadata when the credential store write fails", () => {
const profileId = "anthropic:claude-cli";
mocks.resolveExternalCliAuthProfiles.mockReturnValueOnce([
{
profileId,
persistence: "persisted",
credential: {
type: "oauth",
provider: "claude-cli",
access: "rotated-access",
refresh: "rotated-refresh",
expires: Date.now() + 60_000,
},
},
]);
mocks.runTransaction.mockImplementationOnce(() => {
throw new Error("database unavailable");
});
const cfg = {
auth: { profiles: { [profileId]: { provider: "anthropic", mode: "token" } } },
} as OpenClawConfig;
const result = maybeMigrateExternalCliProfileMetadata({ cfg, env: {} });
expect(cfg.auth?.profiles?.[profileId]).toEqual({ provider: "anthropic", mode: "token" });
expect(result).toMatchObject({ configChanged: false });
expect(result.warnings).toContain(
"Could not persist external CLI OAuth credentials for /tmp/main: database unavailable",
);
});
});
@@ -1,125 +0,0 @@
/** Doctor-owned migration for legacy external CLI profile metadata and credentials. */
import { isDeepStrictEqual } from "node:util";
import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js";
import {
isUsablePersistedExternalCliProfileCredential,
listConfiguredExternalCliProfileMetadataIds,
normalizeExternalCliProfileMetadata,
} from "../agents/auth-profiles/external-cli-profile-metadata.js";
import { resolveExternalCliAuthProfiles } from "../agents/auth-profiles/external-cli-sync.js";
import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js";
import { runAuthProfileWriteTransaction } from "../agents/auth-profiles/sqlite.js";
import { saveAuthProfileStore } from "../agents/auth-profiles/store.js";
import type { AuthProfileStore } from "../agents/auth-profiles/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { listAuthProfileRepairCandidates } from "./doctor-auth-legacy-paths.js";
type DoctorExternalCliProfileMigration = {
changes: string[];
warnings: string[];
configChanged: boolean;
};
/**
* Doctor is the sole durable migration owner. Runtime recognizes this legacy
* spelling only to keep a live install recoverable until this repair is run.
*/
export function maybeMigrateExternalCliProfileMetadata(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): DoctorExternalCliProfileMigration {
const env = params.env ?? process.env;
const profiles = params.cfg.auth?.profiles;
const profileIds = listConfiguredExternalCliProfileMetadataIds(profiles);
if (profileIds.length === 0 || !profiles) {
return { changes: [], warnings: [], configChanged: false };
}
const pendingMetadata = new Map(
profileIds.flatMap((profileId) => {
const canonical = normalizeExternalCliProfileMetadata(profileId, profiles[profileId]);
return canonical ? [[profileId, canonical] as const] : [];
}),
);
const migrationSucceeded = new Map(
[...pendingMetadata.keys()].map((profileId) => [profileId, true]),
);
let candidateCount = 0;
const changes: string[] = [];
const warnings: string[] = [];
for (const candidate of listAuthProfileRepairCandidates(params.cfg, env)) {
candidateCount += 1;
const existing: AuthProfileStore = loadPersistedAuthProfileStore(candidate.agentDir) ?? {
version: AUTH_STORE_VERSION,
profiles: {},
};
const imported = resolveExternalCliAuthProfiles(existing, {
profileIds,
allowKeychainPrompt: false,
}).filter((profile) => profile.persistence === "persisted");
const importedProfileIds = new Set(imported.map((profile) => profile.profileId));
const importedCredentials = new Map(
imported.map((profile) => [profile.profileId, profile.credential] as const),
);
for (const profileId of pendingMetadata.keys()) {
if (
!isUsablePersistedExternalCliProfileCredential(
profileId,
importedCredentials.get(profileId),
) &&
!isUsablePersistedExternalCliProfileCredential(profileId, existing.profiles[profileId])
) {
migrationSucceeded.set(profileId, false);
}
}
const next = {
...existing,
profiles: {
...existing.profiles,
...Object.fromEntries(imported.map((profile) => [profile.profileId, profile.credential])),
},
};
try {
if (!isDeepStrictEqual(next, existing)) {
runAuthProfileWriteTransaction(candidate.agentDir, (database) => {
const authoritative =
loadPersistedAuthProfileStore(candidate.agentDir, { database }) ??
({ version: AUTH_STORE_VERSION, profiles: {} } as const);
if (!isDeepStrictEqual(authoritative, existing)) {
throw new Error("auth profile store changed during external CLI migration");
}
saveAuthProfileStore(next, candidate.agentDir, { syncExternalCli: false }, database);
});
changes.push(
`Persisted external CLI OAuth credentials for ${candidate.agentDir ?? "main"}.`,
);
}
} catch (error) {
for (const profileId of importedProfileIds) {
migrationSucceeded.set(profileId, false);
}
warnings.push(
`Could not persist external CLI OAuth credentials for ${candidate.agentDir ?? "main"}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
let configChanged = false;
for (const [profileId, canonical] of pendingMetadata) {
if (candidateCount === 0 || !migrationSucceeded.get(profileId)) {
warnings.push(
`Kept legacy external CLI metadata for ${profileId}: identity-complete OAuth credentials were not saved for every auth profile store.`,
);
continue;
}
const current = profiles[profileId];
if (current && (current.provider !== canonical.provider || current.mode !== canonical.mode)) {
profiles[profileId] = { ...current, ...canonical };
configChanged = true;
}
}
if (configChanged) {
changes.unshift("Migrated legacy external CLI auth.profiles metadata to OAuth.");
}
return { changes, warnings, configChanged };
}
-17
View File
@@ -18,7 +18,6 @@ import {
maybeRepairOpenAICodexAuthConfig,
} from "../doctor-auth-flat-profiles.js";
import { maybeRepairLegacyOAuthSidecarProfiles } from "../doctor-auth-oauth-sidecar.js";
import { maybeMigrateExternalCliProfileMetadata } from "../doctor-external-cli-profiles.js";
import { maybeRepairPluginOpenClawHostLinks } from "../doctor-plugin-host-links.js";
import { maybeRepairStaleManagedNpmBundledPlugins } from "../doctor-plugin-registry.js";
import { migrateLegacySkillWorkshopProposals } from "../doctor-skill-workshop-sqlite.js";
@@ -339,21 +338,6 @@ export async function runDoctorRepairSequence(params: {
env,
});
appendRepairNotes(staleOAuthShadowRepair);
const externalCliProfileMigration = maybeMigrateExternalCliProfileMetadata({
cfg: state.candidate,
env,
});
if (externalCliProfileMigration.configChanged) {
state = applyDoctorConfigMutation({
state,
mutation: {
config: state.candidate,
changes: ["External CLI OAuth migration updated auth.profiles."],
},
shouldRepair: true,
});
}
appendRepairNotes(externalCliProfileMigration);
const authProfileSqliteMigration = await maybeMigrateAuthProfileJsonStoresToSqlite({
cfg: state.candidate,
prompter: { confirmAutoFix: async () => true },
@@ -379,7 +363,6 @@ export async function runDoctorRepairSequence(params: {
const authProfilesRepaired =
legacyOAuthSidecarRepair.changes.length > 0 ||
staleOAuthShadowRepair.changes.length > 0 ||
externalCliProfileMigration.changes.length > 0 ||
authProfileSqliteMigration.changes.length > 0;
return {
@@ -141,7 +141,7 @@ describe("guided onboarding inference composition", () => {
platform: "linux",
deps: {
probeLocalCommand,
readClaudeCliCredentials: () => null,
detectClaudeLoginState: async () => ({ credentials: false }),
readCodexCliCredentials: () => null,
readGeminiCliCredentials: () => null,
randomInt: () => 0,
-13
View File
@@ -1,6 +1,5 @@
import os from "node:os";
import {
readClaudeCliCredentialsCached,
readCodexCliCredentialsCached,
resolveCodexCliHomePath,
} from "../agents/cli-credentials.js";
@@ -60,18 +59,6 @@ export function detectAmbientInferenceBackends(
// fall through to another process user's home or prompt a keychain.
const homedir = env === process.env ? os.homedir : () => "";
const homeDir = resolveOsHomeDir(env, homedir);
const claudeCredential = homeDir
? readClaudeCliCredentialsCached({ homeDir, allowKeychainPrompt: false, ttlMs: 0 })
: null;
if (claudeCredential && claudeCredential.type !== "api_key_helper") {
candidates.push({
kind: "claude-cli",
modelRef: CLAUDE_CLI_DEFAULT_MODEL_REF,
label: "Claude Code",
detail: "credential file found",
credentials: true,
});
}
try {
const codexHome =
homeDir || env.CODEX_HOME?.trim() ? resolveCodexCliHomePath(undefined, env) : undefined;
+47 -38
View File
@@ -70,7 +70,7 @@ describe("detectInferenceBackends", () => {
platform: "linux",
deps: {
probeLocalCommand: probeDeps({}),
readClaudeCliCredentials: () => null,
detectClaudeLoginState: async () => ({ credentials: false }),
readCodexCliCredentials: () => null,
},
});
@@ -88,7 +88,10 @@ describe("detectInferenceBackends", () => {
timedOut: true,
error: "timed out after 1500ms",
}),
readClaudeCliCredentials: () => ({ type: "oauth" }),
detectClaudeLoginState: async () => ({
credentials: true,
authKind: "claude-subscription",
}),
readCodexCliCredentials: () => ({ type: "oauth" }),
readGeminiCliCredentials: () => ({ type: "oauth" }),
},
@@ -109,7 +112,10 @@ describe("detectInferenceBackends", () => {
platform: "linux",
deps: {
probeLocalCommand: probeDeps({ claude: true, codex: true, gemini: true }),
readClaudeCliCredentials: () => ({ type: "oauth" }),
detectClaudeLoginState: async () => ({
credentials: true,
authKind: "claude-subscription",
}),
readCodexCliCredentials: () => ({ type: "oauth" }),
readGeminiCliCredentials: () => ({ type: "oauth" }),
randomInt: () => 0,
@@ -165,7 +171,7 @@ describe("detectInferenceBackends", () => {
platform: "linux",
deps: {
probeLocalCommand: probeDeps({ claude: true }),
readClaudeCliCredentials: () => ({ type: "api_key_helper" }),
detectClaudeLoginState: async () => ({ credentials: true, authKind: "api-key" }),
},
});
@@ -185,7 +191,7 @@ describe("detectInferenceBackends", () => {
platform: "linux",
deps: {
probeLocalCommand: probeDeps({ claude: true }),
readClaudeCliCredentials: () => null,
detectClaudeLoginState: async () => ({ credentials: true, authKind: "api-key" }),
},
});
@@ -194,35 +200,35 @@ describe("detectInferenceBackends", () => {
);
});
it.each(["oauth", "token"])(
"labels parsed Claude CLI %s credentials as a subscription",
async (type) => {
const candidates = await detectInferenceBackends({
env: {},
platform: "linux",
deps: {
probeLocalCommand: probeDeps({ claude: true }),
readClaudeCliCredentials: () => ({ type }),
},
});
expect(candidates).toMatchObject([
{
kind: "claude-cli",
it("labels a Claude CLI subscription reported by its status command", async () => {
const candidates = await detectInferenceBackends({
env: {},
platform: "linux",
deps: {
probeLocalCommand: probeDeps({ claude: true }),
detectClaudeLoginState: async () => ({
credentials: true,
detail: "logged in · Claude subscription",
},
]);
},
);
authKind: "claude-subscription",
}),
},
});
it("keeps an Anthropic environment key ahead of unknown Claude credentials", async () => {
expect(candidates).toMatchObject([
{
kind: "claude-cli",
credentials: true,
detail: "logged in · Claude subscription",
},
]);
});
it("keeps an Anthropic environment key ahead of unknown Claude status", async () => {
const candidates = await detectInferenceBackends({
env: { ANTHROPIC_API_KEY: "sk-y" },
platform: "darwin",
deps: {
probeLocalCommand: probeDeps({ claude: true }),
readClaudeCliCredentials: () => null,
detectClaudeLoginState: async () => ({ credentials: undefined }),
},
});
@@ -258,7 +264,7 @@ describe("detectInferenceBackends", () => {
platform: "linux",
deps: {
probeLocalCommand: probeDeps({ claude: true, codex: true, gemini: true }),
readClaudeCliCredentials: () => null,
detectClaudeLoginState: async () => ({ credentials: false }),
readCodexCliCredentials: () => ({ type: "oauth" }),
readGeminiCliCredentials: () => null,
},
@@ -288,7 +294,7 @@ describe("detectInferenceBackends", () => {
platform: "linux",
deps: {
probeLocalCommand: probeDeps({}),
readClaudeCliCredentials: () => null,
detectClaudeLoginState: async () => ({ credentials: false }),
readCodexCliCredentials: () => null,
},
});
@@ -313,7 +319,7 @@ describe("detectInferenceBackends", () => {
platform: "linux",
deps: {
probeLocalCommand: probeDeps({}),
readClaudeCliCredentials: () => null,
detectClaudeLoginState: async () => ({ credentials: false }),
readCodexCliCredentials: () => null,
},
});
@@ -329,7 +335,7 @@ describe("detectInferenceBackends", () => {
platform: "linux",
deps: {
probeLocalCommand: probeDeps({ claude: true, codex: true }),
readClaudeCliCredentials: () => null,
detectClaudeLoginState: async () => ({ credentials: false }),
readCodexCliCredentials: () => ({ type: "oauth" }),
},
});
@@ -347,7 +353,7 @@ describe("detectInferenceBackends", () => {
platform: "linux",
deps: {
probeLocalCommand: probeDeps({ claude: true, codex: true, gemini: true }),
readClaudeCliCredentials: () => null,
detectClaudeLoginState: async () => ({ credentials: false }),
readCodexCliCredentials: () => null,
readGeminiCliCredentials: () => null,
},
@@ -442,7 +448,10 @@ describe("detectInferenceBackends", () => {
platform: "linux",
deps: {
probeLocalCommand: probeDeps({ claude: true, codex: true }),
readClaudeCliCredentials: () => ({ type: "oauth" }),
detectClaudeLoginState: async () => ({
credentials: true,
authKind: "claude-subscription",
}),
readCodexCliCredentials: () => ({ type: "oauth" }),
randomInt: () => pick,
},
@@ -458,13 +467,13 @@ describe("detectInferenceBackends", () => {
]);
});
it("treats missing file credentials as unknown on macOS (keychain may hold the login)", async () => {
it("keeps an unverified Claude status unknown", async () => {
const candidates = await detectInferenceBackends({
env: {},
platform: "darwin",
deps: {
probeLocalCommand: probeDeps({ claude: true }),
readClaudeCliCredentials: () => null,
detectClaudeLoginState: async () => ({ credentials: undefined }),
readCodexCliCredentials: () => null,
},
});
@@ -518,7 +527,7 @@ describe("detectInferenceBackends", () => {
platform: "darwin",
deps: {
probeLocalCommand: probeDeps({ [appCli]: true }),
readClaudeCliCredentials: () => null,
detectClaudeLoginState: async () => ({ credentials: false }),
readCodexCliCredentials: () => null,
},
});
@@ -545,7 +554,7 @@ describe("detectInferenceBackends", () => {
found: command === chatGPTCli || command === legacyCodexCli,
};
},
readClaudeCliCredentials: () => null,
detectClaudeLoginState: async () => ({ credentials: false }),
readCodexCliCredentials: () => null,
},
});
@@ -561,7 +570,7 @@ describe("detectInferenceBackends", () => {
platform: "linux",
deps: {
probeLocalCommand: probeDeps({}),
readClaudeCliCredentials: () => null,
detectClaudeLoginState: async () => ({ credentials: false }),
readCodexCliCredentials: () => null,
},
});
+31 -29
View File
@@ -5,7 +5,6 @@ import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { resolveAgentConfig } from "../agents/agent-scope-config.js";
import {
readClaudeCliCredentialsCached,
readCodexCliCredentialsCached,
readGeminiCliCredentialsCached,
} from "../agents/cli-credentials.js";
@@ -41,7 +40,7 @@ export {
type DetectInferenceBackendsDeps = {
probeLocalCommand?: typeof probeLocalCommand;
readClaudeCliCredentials?: () => { type: string } | null;
detectClaudeLoginState?: typeof detectClaudeLoginState;
readCodexCliCredentials?: () => { type: string } | null;
readGeminiCliCredentials?: () => { type: string } | null;
detectCodexLoginState?: typeof detectCodexLoginState;
@@ -101,19 +100,6 @@ function describeCliDetail(state: CliLoginState, loginHint: string): string {
return "installed";
}
function classifyClaudeCliAuth(
credential: { type: string } | null,
env: NodeJS.ProcessEnv,
): CliAuthKind | undefined {
if (env.ANTHROPIC_API_KEY?.trim() || credential?.type === "api_key_helper") {
return "api-key";
}
if (credential?.type === "oauth" || credential?.type === "token") {
return "claude-subscription";
}
return undefined;
}
function describeGeminiCliDetail(credentials: boolean | undefined): string {
return credentials === true
? "installed; credentials found"
@@ -139,6 +125,30 @@ async function classifyCodexLoginStatus(
return { credentials: true };
}
async function detectClaudeLoginState(
probe: typeof probeLocalCommand,
command: string,
): Promise<CliLoginState> {
const status = await probe(command, ["auth", "status", "--text"], { timeoutMs: 3_000 });
if (status.timedOut) {
return { credentials: undefined };
}
if (status.error) {
return { credentials: false };
}
const method = status.version?.replace(/^Login method:\s*/iu, "").trim();
return {
credentials: true,
...(method
? {
authKind: /api\s*key/iu.test(method)
? ("api-key" as const)
: ("claude-subscription" as const),
}
: {}),
};
}
// Deliberately boolean-shaped: this signature is reachable from the exported
// detectInferenceBackends options type and therefore part of the plugin-sdk
// agent-harness API contract. Widening it would bump the contract hash — the
@@ -219,9 +229,6 @@ export async function detectInferenceBackends(
const env = options.env ?? process.env;
const platform = options.platform ?? process.platform;
const probe = options.deps?.probeLocalCommand ?? probeLocalCommand;
const readClaude =
options.deps?.readClaudeCliCredentials ??
(() => readClaudeCliCredentialsCached({ allowKeychainPrompt: false, ttlMs: 60_000 }));
const readCodex =
options.deps?.readCodexCliCredentials ??
(() => readCodexCliCredentialsCached({ allowKeychainPrompt: false, ttlMs: 60_000 }));
@@ -268,19 +275,14 @@ export async function detectInferenceBackends(
const cliCandidates: InferenceBackendCandidate[] = [];
const subscriptionPromotionEligibleCliKinds = new Set<InferenceBackendKind>();
if (claudeProbe.found && !claudeProbe.timedOut) {
const claudeCredential = readClaude();
const credentials = detectCliCredentialState({
probe: claudeProbe,
hasStoredCredentials: claudeCredential !== null,
platform,
});
if (credentials === true && claudeCredential?.type === "oauth") {
const loginState = options.deps?.detectClaudeLoginState
? await options.deps.detectClaudeLoginState(probe, claudeProbe.command)
: await detectClaudeLoginState(probe, claudeProbe.command);
const credentials = loginState.credentials;
if (credentials === true && loginState.authKind === "claude-subscription") {
subscriptionPromotionEligibleCliKinds.add("claude-cli");
}
const detail = describeCliDetail(
{ credentials, authKind: classifyClaudeCliAuth(claudeCredential, env) },
"run `claude auth login`",
);
const detail = describeCliDetail(loginState, "run `claude auth login`");
cliCandidates.push({
kind: "claude-cli",
modelRef: CLAUDE_CLI_DEFAULT_MODEL_REF,
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { runWriteConfigHealth } from "./doctor-health-contribution-runners.config.js";
import type { DoctorHealthFlowContext } from "./doctor-health-contribution-types.js";
const mocks = vi.hoisted(() => ({
removeAuthProfilesAcrossOwnerStores: vi.fn(async () => true),
replaceConfigFile: vi.fn(async () => undefined),
}));
vi.mock("../agents/auth-profiles.js", () => ({
removeAuthProfilesAcrossOwnerStores: mocks.removeAuthProfilesAcrossOwnerStores,
}));
vi.mock("../config/config.js", () => ({
replaceConfigFile: mocks.replaceConfigFile,
}));
vi.mock("../config/logging.js", () => ({
logConfigUpdated: vi.fn(),
}));
vi.mock("../commands/onboard-helpers.js", () => ({
applyWizardMetadata: (cfg: OpenClawConfig) => cfg,
}));
function createContext(): DoctorHealthFlowContext {
const cfg = { gateway: { mode: "local" } } satisfies OpenClawConfig;
return {
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
options: {},
prompter: {} as DoctorHealthFlowContext["prompter"],
configResult: {
cfg,
retiredAuthProfileCleanupPlans: [
{ agentDir: "/tmp/openclaw/agents/main", profileIds: ["anthropic:claude-cli"] },
],
},
cfg,
cfgForPersistence: {},
sourceConfigValid: true,
configPath: "/tmp/openclaw.json",
};
}
describe("Doctor retired auth profile cleanup", () => {
beforeEach(() => {
mocks.removeAuthProfilesAcrossOwnerStores.mockClear().mockResolvedValue(true);
mocks.replaceConfigFile.mockClear().mockResolvedValue(undefined);
});
it("removes retired profiles only after the repaired config commits", async () => {
await runWriteConfigHealth(createContext());
expect(mocks.replaceConfigFile).toHaveBeenCalledOnce();
expect(mocks.removeAuthProfilesAcrossOwnerStores).toHaveBeenCalledWith({
agentDir: "/tmp/openclaw/agents/main",
profileIds: ["anthropic:claude-cli"],
});
expect(mocks.replaceConfigFile.mock.invocationCallOrder[0]).toBeLessThan(
mocks.removeAuthProfilesAcrossOwnerStores.mock.invocationCallOrder[0]!,
);
});
it("keeps retired profiles when the repaired config write fails", async () => {
mocks.replaceConfigFile.mockRejectedValueOnce(new Error("write failed"));
await expect(runWriteConfigHealth(createContext())).rejects.toThrow("write failed");
expect(mocks.removeAuthProfilesAcrossOwnerStores).not.toHaveBeenCalled();
});
});
@@ -67,7 +67,7 @@ export async function runWriteConfigHealth(
allowConfigSizeDrop: ctx.configResult.shouldWriteConfig === true || updateDoctorRun,
skipPluginValidation:
ctx.configResult.skipPluginValidationOnWrite === true || updateDoctorRun,
...(configResultWritePending && ctx.configResult.explicitSetPaths
...(ctx.configResult.explicitSetPaths
? { explicitSetPaths: ctx.configResult.explicitSetPaths }
: {}),
preservedLegacyRootKeys: ctx.configResult.preservedLegacyRootKeys,
@@ -147,6 +147,16 @@ export async function runWriteConfigHealth(
if (options.runPostWriteRepairs === false) {
return;
}
const retiredAuthProfileCleanupPlans = ctx.configResult.retiredAuthProfileCleanupPlans;
if (retiredAuthProfileCleanupPlans?.length) {
const { removeAuthProfilesAcrossOwnerStores } = await import("../agents/auth-profiles.js");
for (const plan of retiredAuthProfileCleanupPlans) {
if (!(await removeAuthProfilesAcrossOwnerStores(plan))) {
throw new Error(`Failed to remove retired auth profile "${plan.profileIds.join(", ")}".`);
}
}
delete ctx.configResult.retiredAuthProfileCleanupPlans;
}
if (ctx.configResult.retiredPhoneControlStateCleanupPending === true) {
const { finalizeRetiredPhoneControlCleanup } =
await import("../commands/doctor-retired-phone-control.js");
@@ -1,3 +1,4 @@
import type { RetiredAuthProfileCleanupPlan } from "../commands/doctor-auth-legacy-oauth.js";
import type { probeGatewayMemoryStatus } from "../commands/doctor-gateway-health.js";
import type { DoctorOptions, DoctorPrompter } from "../commands/doctor-prompter.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -23,6 +24,8 @@ type DoctorConfigResult = {
preservedLegacyRootKeys?: readonly string[];
shouldRepairCronCodexModelRefsAfterConfigWrite?: boolean;
retiredPhoneControlStateCleanupPending?: boolean;
/** Store cleanup deferred until the repaired config reaches disk. */
retiredAuthProfileCleanupPlans?: readonly RetiredAuthProfileCleanupPlan[];
blockedCodexModelIdentities?: readonly string[];
/** Ephemeral doctor-only auth rename plan; never part of persisted config. */
openAICodexAuthProfileIdMap?: ReadonlyMap<string, string>;
+44 -7
View File
@@ -49,7 +49,10 @@ const mocks = vi.hoisted(() => ({
warnings: [],
})),
maybeRepairGatewayDaemon: vi.fn().mockResolvedValue(undefined),
maybeRepairLegacyOAuthProfileIds: vi.fn(async (cfg: unknown) => cfg),
maybeRepairLegacyOAuthProfileIds: vi.fn(async (cfg: unknown) => ({
config: cfg,
retiredProfileCleanupPlans: [],
})),
maybeRepairLegacyOAuthSidecarProfiles: vi.fn().mockResolvedValue(undefined),
collectAuthProfileHealthFindings: vi.fn(async () => []),
noteAuthProfileHealth: vi.fn().mockResolvedValue(undefined),
@@ -648,9 +651,10 @@ describe("doctor health contributions", () => {
warnings: [],
});
mocks.maybeRepairGatewayDaemon.mockClear().mockResolvedValue(undefined);
mocks.maybeRepairLegacyOAuthProfileIds
.mockClear()
.mockImplementation(async (cfg: unknown) => cfg);
mocks.maybeRepairLegacyOAuthProfileIds.mockClear().mockImplementation(async (cfg: unknown) => ({
config: cfg,
retiredProfileCleanupPlans: [],
}));
mocks.collectLegacyPluginManifestContractMigrations.mockReset().mockReturnValue([]);
mocks.legacyPluginManifestContractMigrationToHealthFinding.mockClear();
mocks.maybeRepairLegacyPluginManifestContracts.mockClear().mockResolvedValue(undefined);
@@ -2211,6 +2215,39 @@ describe("doctor health contributions", () => {
prompter: ctx.prompter,
runtime: ctx.runtime,
});
expect(mocks.maybeRepairLegacyOAuthProfileIds.mock.invocationCallOrder[0]).toBeLessThan(
mocks.maybeMigrateModelCatalogCredentials.mock.invocationCallOrder[0]!,
);
});
it("persists provider runtime mappings added while removing retired auth profiles", async () => {
const contribution = requireDoctorContribution("doctor:auth-profiles");
const cfg = {
agents: { defaults: { models: { "anthropic/claude-sonnet-4-6": {} } } },
};
mocks.maybeRepairLegacyOAuthProfileIds.mockResolvedValue({
config: {
agents: {
defaults: {
models: {
"anthropic/claude-sonnet-4-6": { agentRuntime: { id: "claude-cli" } },
},
},
},
},
retiredProfileCleanupPlans: [],
});
const ctx = createDoctorHealthFlowContext({
cfg,
sourceConfigValid: true,
prompter: buildDoctorPrompter(true),
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
options: { nonInteractive: true },
});
await contribution.run(ctx);
expect(ctx.configResult.explicitSetPaths).toContainEqual(["agents", "defaults", "models"]);
});
it("registers auth profile health as an opt-in structured check", async () => {
@@ -4035,7 +4072,7 @@ describe("doctor health contributions", () => {
);
});
it("forwards explicit paths only for the pending doctor migration write", async () => {
it("forwards explicit paths through later doctor repair writes", async () => {
const ctx = buildWriteConfigCtx({});
ctx.configResult.explicitSetPaths = [["agents", "entries"]];
@@ -4057,8 +4094,8 @@ describe("doctor health contributions", () => {
expect(mocks.replaceConfigFile).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
writeOptions: expect.not.objectContaining({
explicitSetPaths: expect.anything(),
writeOptions: expect.objectContaining({
explicitSetPaths: [["agents", "entries"]],
}),
}),
);
+16 -1
View File
@@ -1,6 +1,7 @@
// Doctor health contributions preserve the ordered interactive doctor flow while
// exposing the same checks to structured lint and repair commands.
import fs from "node:fs";
import { isDeepStrictEqual } from "node:util";
import { isGatewayHostServiceEnvironment } from "../infra/gateway-supervision.js";
import { scrubDoctorErrorMessage } from "./doctor-error-message.js";
import { hasActiveGatewayExecCredential } from "./doctor-gateway-exec-credential.js";
@@ -87,6 +88,21 @@ async function runAuthProfileHealth(ctx: DoctorHealthFlowContext): Promise<void>
prompter: ctx.prompter,
runtime: ctx.runtime,
});
const modelsBeforeRepair = ctx.cfg.agents?.defaults?.models;
const legacyOAuthRepair = await maybeRepairLegacyOAuthProfileIds(ctx.cfg, ctx.prompter);
ctx.cfg = legacyOAuthRepair.config;
if (legacyOAuthRepair.retiredProfileCleanupPlans.length > 0) {
ctx.configResult.retiredAuthProfileCleanupPlans = [
...(ctx.configResult.retiredAuthProfileCleanupPlans ?? []),
...legacyOAuthRepair.retiredProfileCleanupPlans,
];
}
if (!isDeepStrictEqual(modelsBeforeRepair, ctx.cfg.agents?.defaults?.models)) {
ctx.configResult.explicitSetPaths = [
...(ctx.configResult.explicitSetPaths ?? []),
["agents", "defaults", "models"],
];
}
const { maybeMigrateModelCatalogCredentials } =
await import("../commands/doctor-model-catalog-credentials.js");
await maybeMigrateModelCatalogCredentials({
@@ -95,7 +111,6 @@ async function runAuthProfileHealth(ctx: DoctorHealthFlowContext): Promise<void>
prompter: ctx.prompter,
runtime: ctx.runtime,
});
ctx.cfg = await maybeRepairLegacyOAuthProfileIds(ctx.cfg, ctx.prompter);
await noteAuthProfileHealth({
cfg: ctx.cfg,
prompter: ctx.prompter,
@@ -4683,7 +4683,7 @@ async function resolveGatewayLiveRequestedModels(): Promise<string | undefined>
platform: "linux",
deps: {
probeLocalCommand: async (command) => ({ command, found: false }),
readClaudeCliCredentials: () => null,
detectClaudeLoginState: async () => ({ credentials: false }),
readCodexCliCredentials: () => null,
readGeminiCliCredentials: () => null,
},
@@ -21,10 +21,6 @@ import {
removeProviderAuthProfilesWithLock,
resolvePersistedAuthProfileOwnerAgentDir,
} from "../../agents/auth-profiles.js";
import {
listConfiguredExternalCliProfileMetadataIds,
normalizeExternalCliProfileMetadata,
} from "../../agents/auth-profiles/external-cli-profile-metadata.js";
import { getRuntimeExternalCliProfileIds } from "../../agents/auth-profiles/runtime-external-profile-references.js";
import {
isNonSecretApiKeyMarker,
@@ -434,43 +430,6 @@ function resolveConfiguredProviders(
return { providers: Array.from(out), expectsOAuth, directModelAuthProviders };
}
function resolveLegacyExternalCliAliasProfileIds(
cfg: OpenClawConfig,
directModelAuthProviders: ReadonlySet<string>,
): Map<string, string> {
const profiles = cfg.auth?.profiles;
const aliases = new Map<string, string>();
for (const profileId of listConfiguredExternalCliProfileMetadataIds(profiles)) {
const profile = profiles?.[profileId];
const canonical = normalizeExternalCliProfileMetadata(profileId, profile);
if (!profile || !canonical) {
continue;
}
const provider = normalizeProviderId(profile.provider);
const hasIndependentAuthProfile = Object.entries(profiles ?? {}).some(
([otherProfileId, otherProfile]) =>
otherProfileId !== profileId &&
normalizeProviderId(otherProfile?.provider) === provider &&
(otherProfile?.mode === "oauth" || otherProfile?.mode === "token"),
);
const hasIndependentAuthOrder = Object.entries(cfg.auth?.order ?? {}).some(
([orderProvider, orderedProfileIds]) =>
normalizeProviderId(orderProvider) === provider &&
orderedProfileIds.some((orderedProfileId) => orderedProfileId !== profileId),
);
if (
provider &&
provider !== canonical.provider &&
!directModelAuthProviders.has(provider) &&
!hasIndependentAuthProfile &&
!hasIndependentAuthOrder
) {
aliases.set(provider, profileId);
}
}
return aliases;
}
export const modelsAuthStatusHandlers: GatewayRequestHandlers = {
"models.authLogout": async ({ params, respond, context }) => {
const provider = readProviderParam(params);
@@ -686,10 +645,6 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = {
.map(([profileId]) => profileId),
);
const configBoundProfileIds = resolveConfigBoundProfileIds(cfg, store, authAliasLookupParams);
const legacyExternalCliAliasProfileIds = resolveLegacyExternalCliAliasProfileIds(
cfg,
configured.directModelAuthProviders,
);
const providers = suppressSyntheticAliasRowsCoveredByExternalCli(
authHealth.providers.map((prov) =>
mapProvider(
@@ -703,7 +658,7 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = {
),
),
externalCliProfileIds,
legacyExternalCliAliasProfileIds,
new Map(),
);
const providerCapabilities = buildProviderCapabilities({
config: cfg,
@@ -225,6 +225,57 @@ describe("resolveProviderAuths plugin boundary", () => {
]);
});
it("excludes native credential providers from plugin OAuth resolution", async () => {
const store = {
profiles: {
"anthropic:claude-cli": {
type: "oauth",
provider: "anthropic",
access: "native-access",
refresh: "native-refresh",
expires: Date.now() + 60_000,
},
"anthropic:managed": {
type: "oauth",
provider: "anthropic",
access: "managed-access",
refresh: "managed-refresh",
expires: Date.now() + 60_000,
},
},
};
ensureAuthProfileStoreMock.mockReturnValue(store as never);
hasAnyAuthProfileStoreSourceMock.mockReturnValue(true);
ensureAuthProfileStoreWithoutExternalProfilesMock.mockReturnValue(store as never);
resolveAuthProfileOrderMock.mockReturnValue(["anthropic:claude-cli", "anthropic:managed"]);
resolveApiKeyForProfileMock.mockImplementation(async (params) => {
const profileId = (params as { profileId: string }).profileId;
return profileId === "anthropic:managed"
? { apiKey: "managed-access", provider: "anthropic" }
: { apiKey: "native-access", provider: "claude-cli" };
});
resolveProviderUsageAuthWithPluginMock.mockImplementationOnce(async (rawParams) => {
const params = rawParams as {
context: {
resolveOAuthToken: (options: {
excludeProfileIds: string[];
}) => Promise<{ token: string } | null>;
};
};
return params.context.resolveOAuthToken({
excludeProfileIds: ["anthropic:claude-cli"],
});
});
await expect(resolveProviderAuthsForTest({ providers: ["anthropic"] })).resolves.toEqual([
{ provider: "anthropic", token: "managed-access" },
]);
expect(resolveApiKeyForProfileMock).toHaveBeenCalledTimes(1);
expect(resolveApiKeyForProfileMock).toHaveBeenCalledWith(
expect.objectContaining({ profileId: "anthropic:managed" }),
);
});
it("does not synthesize Codex app-server auth for generic OpenAI usage", async () => {
await withTempHome(async (homeDir) => {
await expect(
+6
View File
@@ -297,6 +297,7 @@ function resolveUsageCredentialProviderIds(params: {
async function resolveOAuthToken(params: {
state: UsageAuthState;
provider: string;
excludeProfileIds?: string[];
}): Promise<ProviderAuth | null> {
if (!params.state.allowAuthProfileStore) {
return null;
@@ -308,8 +309,12 @@ async function resolveOAuthToken(params: {
provider: params.provider,
});
const deduped = dedupeProfileIds(order);
const excludedProfileIds = new Set(params.excludeProfileIds ?? []);
for (const profileId of deduped) {
if (excludedProfileIds.has(profileId)) {
continue;
}
const cred = store.profiles[profileId];
if (!cred || (cred.type !== "oauth" && cred.type !== "token")) {
continue;
@@ -385,6 +390,7 @@ async function resolveProviderUsageAuthViaPlugin(params: {
const auth = await resolveOAuthToken({
state: params.state,
provider: options?.provider ?? params.provider,
excludeProfileIds: options?.excludeProfileIds,
});
return auth
? {
@@ -0,0 +1,46 @@
/** Retired Claude CLI credential shape kept only for source compatibility. */
export type ClaudeCliCredential =
| {
type: "oauth";
provider: "anthropic";
access: string;
refresh: string;
expires: number;
subscriptionType?: string;
rateLimitTier?: string;
email?: string;
}
| {
type: "token";
provider: "anthropic";
token: string;
expires: number;
subscriptionType?: string;
rateLimitTier?: string;
email?: string;
}
| {
type: "api_key_helper";
provider: "anthropic";
helperHash: string;
};
export type ClaudeCliCredentialReadOptions = {
allowKeychainPrompt?: boolean;
tryKeychainWithoutPrompt?: boolean;
onStoredCredentialUnreadable?: () => void;
ttlMs?: number;
platform?: NodeJS.Platform;
homeDir?: string;
execSync?: typeof import("node:child_process").execSync;
};
/**
* @deprecated Claude CLI owns its native login. This returns null without reading credentials.
* Scheduled for removal after v2026.10.
*/
export function readClaudeCliCredentialsCached(
_options?: ClaudeCliCredentialReadOptions,
): ClaudeCliCredential | null {
return null;
}
+8
View File
@@ -16,6 +16,7 @@ import {
deriveCopilotApiBaseUrlFromToken,
isProviderApiKeyConfigured,
normalizeGithubCopilotDomain,
readClaudeCliCredentialsCached,
removeProviderAuthProfilesWithLock,
resolveCopilotApiToken,
} from "./provider-auth.js";
@@ -31,6 +32,13 @@ describe("provider auth public SDK", () => {
it("retains provider-scoped profile removal", () => {
expect(removeProviderAuthProfilesWithLock).toBeTypeOf("function");
});
it("keeps the retired Claude credential reader as a null-only compatibility export", () => {
const onStoredCredentialUnreadable = vi.fn();
expect(readClaudeCliCredentialsCached({ onStoredCredentialUnreadable })).toBeNull();
expect(onStoredCredentialUnreadable).not.toHaveBeenCalled();
});
});
async function withPartialCopilotResponse(run: (port: number) => Promise<void>): Promise<void> {
+4 -2
View File
@@ -68,10 +68,12 @@ export {
upsertAuthProfileWithLockCompat as upsertAuthProfileWithLock,
} from "./provider-auth-write-compat.js";
export { resolveEnvApiKey } from "../agents/model-auth-env.js";
export { readCodexCliCredentialsCached } from "../agents/cli-credentials.js";
export {
type ClaudeCliCredential,
type ClaudeCliCredentialReadOptions,
readClaudeCliCredentialsCached,
readCodexCliCredentialsCached,
} from "../agents/cli-credentials.js";
} from "./provider-auth-claude-compat.js";
export { suggestOAuthProfileIdForLegacyDefault } from "../agents/auth-profiles/repair.js";
export {
CUSTOM_LOCAL_AUTH_MARKER,
+27 -10
View File
@@ -237,20 +237,23 @@ export function applyAuthProfileConfig(
export function configReferencesAuthProfile(cfg: OpenClawConfig, profileId: string): boolean {
return (
Boolean(cfg.auth?.profiles?.[profileId]) ||
Object.values(cfg.auth?.order ?? {}).some((order) => order.includes(profileId))
Object.values(cfg.auth?.order ?? {}).some((order) => order.includes(profileId)) ||
Object.values(cfg.models?.providers ?? {}).some((provider) => provider.apiKey === profileId)
);
}
/**
* Counterpart to {@link applyAuthProfileConfig}: drops a profile from
* `auth.profiles` and every `auth.order` list. An emptied provider order is
* deleted rather than left as `[]`, because an authored empty order is a hard
* "select no profiles" instruction and would disable the provider entirely.
* Drops a profile from `auth.profiles`, every `auth.order` list, and provider-entry
* `apiKey` references. An emptied provider order is deleted rather than left as
* `[]`, because an authored empty order is a hard "select no profiles" instruction.
*/
export function removeAuthProfileConfig(cfg: OpenClawConfig, profileId: string): OpenClawConfig {
if (!configReferencesAuthProfile(cfg, profileId)) {
return cfg;
}
const authReferencesProfile =
Boolean(cfg.auth?.profiles?.[profileId]) ||
Object.values(cfg.auth?.order ?? {}).some((providerOrder) => providerOrder.includes(profileId));
const profiles = Object.fromEntries(
Object.entries(cfg.auth?.profiles ?? {}).filter(([id]) => id !== profileId),
);
@@ -268,13 +271,27 @@ export function removeAuthProfileConfig(cfg: OpenClawConfig, profileId: string):
{},
);
const { order: _droppedOrder, ...auth } = cfg.auth ?? {};
const providers = Object.fromEntries(
Object.entries(cfg.models?.providers ?? {}).map(([providerId, provider]) => {
if (provider.apiKey !== profileId) {
return [providerId, provider];
}
const { apiKey: _droppedApiKey, ...nextProvider } = provider;
return [providerId, nextProvider];
}),
);
return {
...cfg,
auth: {
...auth,
profiles,
...(Object.keys(order).length > 0 ? { order } : {}),
},
...(authReferencesProfile
? {
auth: {
...auth,
profiles,
...(Object.keys(order).length > 0 ? { order } : {}),
},
}
: {}),
...(cfg.models?.providers ? { models: { ...cfg.models, providers } } : {}),
};
}
+3 -2
View File
@@ -553,11 +553,12 @@ export type ProviderPlugin = {
*/
loginOAuth?: (callbacks: OAuthLoginCallbacks) => Promise<SessionOAuthCredentials>;
/**
* Legacy auth-profile ids that should be retired by `openclaw doctor`.
* Legacy auth-profile ids that generic auth must ignore and `openclaw doctor` should remove.
*
* Use this when a provider plugin replaces an older core-managed profile id
* and wants cleanup/migration messaging to live with the provider instead of
* in hardcoded doctor tables.
* in hardcoded doctor tables. A runtime-only external CLI profile remains usable by its exact
* provider when it intentionally reuses a retired id.
*/
deprecatedProfileIds?: string[];
/**
+10
View File
@@ -892,6 +892,16 @@ export function resolveProviderModernModelRef(params: {
return resolveProviderRuntimePlugin(params)?.isModernModelRef?.(params.context);
}
/** Returns provider-owned profile ids retired from generic credential resolution. */
export function resolveProviderDeprecatedAuthProfileIds(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): readonly string[] {
return resolveProviderRuntimePlugin(params)?.deprecatedProfileIds ?? [];
}
export function buildProviderMissingAuthMessageWithPlugin(params: {
provider: string;
config?: OpenClawConfig;
+4 -1
View File
@@ -164,7 +164,10 @@ export type ProviderResolveUsageAuthContext = {
providerIds?: string[];
envDirect?: Array<string | undefined>;
}) => Promise<string[]>;
resolveOAuthToken: (params?: { provider?: string }) => Promise<ProviderUsageAuthToken | null>;
resolveOAuthToken: (params?: {
provider?: string;
excludeProfileIds?: string[];
}) => Promise<ProviderUsageAuthToken | null>;
};
export type ProviderUsageAuthToken = {