From 750a64e7cd4a34c43df09bc05bb94fc89561a6cc Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 25 Aug 2026 17:18:06 +0530 Subject: [PATCH] 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 --- config/assertion-safety-baseline.txt | 4 +- config/max-lines-baseline.txt | 1 - docs/gateway/authentication.md | 4 +- docs/gateway/cli-backends.md | 21 +- docs/providers/anthropic.md | 32 +- .../anthropic/agent-sdk.runtime.test.ts | 1 + extensions/anthropic/cli-auth-seam.test.ts | 43 ++- extensions/anthropic/cli-auth-seam.ts | 56 ++-- extensions/anthropic/cli-backend.ts | 7 +- extensions/anthropic/cli-constants.ts | 6 +- extensions/anthropic/cli-migration.test.ts | 126 +++---- extensions/anthropic/cli-migration.ts | 51 +-- extensions/anthropic/cli-shared.test.ts | 2 +- extensions/anthropic/cli-shared.ts | 4 +- extensions/anthropic/config-defaults.ts | 14 + extensions/anthropic/index.test.ts | 139 +++----- extensions/anthropic/openclaw.plugin.json | 2 +- extensions/anthropic/provider-discovery.ts | 37 +-- extensions/anthropic/register.runtime.ts | 38 ++- extensions/anthropic/usage.test.ts | 36 +- extensions/anthropic/usage.ts | 44 +-- src/agents/auth-health.test.ts | 1 - .../auth-profiles.external-cli-sync.test.ts | 217 +------------ ...th-profiles.markauthprofilefailure.test.ts | 1 - src/agents/auth-profiles/external-auth.ts | 67 +--- .../external-cli-profile-metadata.ts | 77 ----- .../external-cli-sync.email-backfill.test.ts | 71 ---- src/agents/auth-profiles/external-cli-sync.ts | 34 +- .../auth-profiles/external-oauth.test.ts | 173 ---------- .../oauth-common-mocks.test-support.ts | 2 - .../oauth.fallback-to-main-agent.test.ts | 1 - ...auth.openai-codex-refresh-fallback.test.ts | 56 +++- src/agents/auth-profiles/oauth.test.ts | 65 +++- src/agents/auth-profiles/oauth.ts | 14 +- src/agents/auth-profiles/profiles.test.ts | 97 ++++-- src/agents/auth-profiles/profiles.ts | 63 ++-- .../runtime-external-profile-references.ts | 28 +- src/agents/cli-auth-epoch.test-support.ts | 1 - src/agents/cli-auth-epoch.test.ts | 153 --------- src/agents/cli-auth-epoch.ts | 42 --- .../cli-credentials.claude-keychain.test.ts | 96 ------ src/agents/cli-credentials.claude-keychain.ts | 35 -- src/agents/cli-credentials.test.ts | 307 ------------------ src/agents/cli-credentials.ts | 249 +------------- .../cli-runner/cli-backend-auth-policy.ts | 6 +- src/agents/cli-runner/prepare.test.ts | 203 ++++-------- src/agents/cli-runner/prepare.ts | 61 +--- .../run/auth-plan.auth-pin.test.ts | 1 - src/agents/model-auth-availability.ts | 30 +- src/agents/model-auth-label.test.ts | 44 +-- src/agents/model-auth-label.ts | 16 +- src/agents/model-auth-markers.test.ts | 4 +- src/agents/model-auth-provider.ts | 56 +++- src/agents/model-auth.profiles.test.ts | 105 ++++-- src/commands/doctor-auth-legacy-oauth.ts | 78 ++++- ...octor-auth.deprecated-cli-profiles.test.ts | 196 ++++++++++- src/commands/doctor-auth.ts | 12 - src/commands/doctor-claude-cli.test.ts | 111 +------ src/commands/doctor-claude-cli.ts | 83 ++--- .../doctor-external-cli-profiles.test.ts | 198 ----------- src/commands/doctor-external-cli-profiles.ts | 125 ------- src/commands/doctor/repair-sequencing.ts | 17 - .../onboard-guided.inference.e2e.test.ts | 2 +- src/commands/onboard-inference-ambient.ts | 13 - src/commands/onboard-inference.test.ts | 85 ++--- src/commands/onboard-inference.ts | 60 ++-- ...bution-runners.config.auth-cleanup.test.ts | 72 ++++ ...ctor-health-contribution-runners.config.ts | 12 +- src/flows/doctor-health-contribution-types.ts | 3 + src/flows/doctor-health-contributions.test.ts | 51 ++- src/flows/doctor-health-contributions.ts | 17 +- .../gateway-models.profiles.live.test.ts | 2 +- .../server-methods/models-auth-status.ts | 47 +-- src/infra/provider-usage.auth.plugin.test.ts | 51 +++ src/infra/provider-usage.auth.ts | 6 + src/plugin-sdk/provider-auth-claude-compat.ts | 46 +++ src/plugin-sdk/provider-auth.test.ts | 8 + src/plugin-sdk/provider-auth.ts | 6 +- src/plugins/provider-auth-helpers.ts | 37 ++- src/plugins/provider-plugin.types.ts | 5 +- src/plugins/provider-runtime.ts | 10 + src/plugins/provider-runtime.types.ts | 5 +- 82 files changed, 1415 insertions(+), 2987 deletions(-) delete mode 100644 src/agents/auth-profiles/external-cli-profile-metadata.ts delete mode 100644 src/agents/auth-profiles/external-cli-sync.email-backfill.test.ts delete mode 100644 src/agents/cli-credentials.claude-keychain.test.ts delete mode 100644 src/agents/cli-credentials.claude-keychain.ts delete mode 100644 src/commands/doctor-external-cli-profiles.test.ts delete mode 100644 src/commands/doctor-external-cli-profiles.ts create mode 100644 src/flows/doctor-health-contribution-runners.config.auth-cleanup.test.ts create mode 100644 src/plugin-sdk/provider-auth-claude-compat.ts diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index c9fec866c1cd..490af1ce5788 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -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 diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 3a42d3717fd6..910349edcf40 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -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 diff --git a/docs/gateway/authentication.md b/docs/gateway/authentication.md index 10b1f52e2154..7edc07a27f66 100644 --- a/docs/gateway/authentication.md +++ b/docs/gateway/authentication.md @@ -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 diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index c1a6e7cc3427..e03630d601f7 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -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 diff --git a/docs/providers/anthropic.md b/docs/providers/anthropic.md index 63eb5cabfac0..170513b1c81b 100644 --- a/docs/providers/anthropic.md +++ b/docs/providers/anthropic.md @@ -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. -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 diff --git a/extensions/anthropic/agent-sdk.runtime.test.ts b/extensions/anthropic/agent-sdk.runtime.test.ts index 77ee2739b344..29677689be66 100644 --- a/extensions/anthropic/agent-sdk.runtime.test.ts +++ b/extensions/anthropic/agent-sdk.runtime.test.ts @@ -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", }, diff --git a/extensions/anthropic/cli-auth-seam.test.ts b/extensions/anthropic/cli-auth-seam.test.ts index 4812b0284450..c0d6e799bd61 100644 --- a/extensions/anthropic/cli-auth-seam.test.ts +++ b/extensions/anthropic/cli-auth-seam.test.ts @@ -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" }); +}); diff --git a/extensions/anthropic/cli-auth-seam.ts b/extensions/anthropic/cli-auth-seam.ts index 1ca33b7a3f53..753d7599be01 100644 --- a/extensions/anthropic/cli-auth-seam.ts +++ b/extensions/anthropic/cli-auth-seam.ts @@ -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" }; + } } diff --git a/extensions/anthropic/cli-backend.ts b/extensions/anthropic/cli-backend.ts index 9a0e493ab933..f5f7723467b3 100644 --- a/extensions/anthropic/cli-backend.ts +++ b/extensions/anthropic/cli-backend.ts @@ -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()) { diff --git a/extensions/anthropic/cli-constants.ts b/extensions/anthropic/cli-constants.ts index bf735205cb96..4fa206aec2f7 100644 --- a/extensions/anthropic/cli-constants.ts +++ b/extensions/anthropic/cli-constants.ts @@ -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. */ diff --git a/extensions/anthropic/cli-migration.test.ts b/extensions/anthropic/cli-migration.test.ts index 7d1556c697a2..8aeb6b6c3080 100644 --- a/extensions/anthropic/cli-migration.test.ts +++ b/extensions/anthropic/cli-migration.test.ts @@ -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(); 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 .", + '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); diff --git a/extensions/anthropic/cli-migration.ts b/extensions/anthropic/cli-migration.ts index 8861bd5b0b4c..2b3ce4f9110c 100644 --- a/extensions/anthropic/cli-migration.ts +++ b/extensions/anthropic/cli-migration.ts @@ -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["defaults"]>["model"]; type AgentDefaultsModels = NonNullable["defaults"]>["models"]; -type ClaudeCliCredential = NonNullable>; function toAnthropicModelRef(raw: string): string | null { return resolveClaudeCliAnthropicModelRefs(raw)?.rewriteRef ?? null; @@ -180,47 +174,8 @@ function modelEntryWithClaudeCliRuntime(entry: unknown): Record 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: { diff --git a/extensions/anthropic/cli-shared.test.ts b/extensions/anthropic/cli-shared.test.ts index 1b430aec28a2..0cdf115f52b1 100644 --- a/extensions/anthropic/cli-shared.test.ts +++ b/extensions/anthropic/cli-shared.test.ts @@ -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"); diff --git a/extensions/anthropic/cli-shared.ts b/extensions/anthropic/cli-shared.ts index 5b697cbaef69..b90fb4b1a519 100644 --- a/extensions/anthropic/cli-shared.ts +++ b/extensions/anthropic/cli-shared.ts @@ -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 diff --git a/extensions/anthropic/config-defaults.ts b/extensions/anthropic/config-defaults.ts index 323f07a0f299..957aaea62af4 100644 --- a/extensions/anthropic/config-defaults.ts +++ b/extensions/anthropic/config-defaults.ts @@ -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 ?? []), diff --git a/extensions/anthropic/index.test.ts b/extensions/anthropic/index.test.ts index 1ff35e0e2bf8..9db9e17f0c0b 100644 --- a/extensions/anthropic/index.test.ts +++ b/extensions/anthropic/index.test.ts @@ -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. */ diff --git a/extensions/anthropic/openclaw.plugin.json b/extensions/anthropic/openclaw.plugin.json index 22f717e437a7..b2fb786e6892 100644 --- a/extensions/anthropic/openclaw.plugin.json +++ b/extensions/anthropic/openclaw.plugin.json @@ -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": [ { diff --git a/extensions/anthropic/provider-discovery.ts b/extensions/anthropic/provider-discovery.ts index 982f6ddf08ea..537e1219edf0 100644 --- a/extensions/anthropic/provider-discovery.ts +++ b/extensions/anthropic/provider-discovery.ts @@ -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 = { diff --git a/extensions/anthropic/register.runtime.ts b/extensions/anthropic/register.runtime.ts index b69390763104..528512395f5e 100644 --- a/extensions/anthropic/register.runtime.ts +++ b/extensions/anthropic/register.runtime.ts @@ -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 { - 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 { - 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 .", + '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"], diff --git a/extensions/anthropic/usage.test.ts b/extensions/anthropic/usage.test.ts index fbb67d9d9bfa..a68eda73358a 100644 --- a/extensions/anthropic/usage.test.ts +++ b/extensions/anthropic/usage.test.ts @@ -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(); - 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); }); diff --git a/extensions/anthropic/usage.ts b/extensions/anthropic/usage.ts index e352abb82d01..844ab68bf7f7 100644 --- a/extensions/anthropic/usage.ts +++ b/extensions/anthropic/usage.ts @@ -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. diff --git a/src/agents/auth-health.test.ts b/src/agents/auth-health.test.ts index a61b8dd7a2bf..bcd54fd3a662 100644 --- a/src/agents/auth-health.test.ts +++ b/src/agents/auth-health.test.ts @@ -18,7 +18,6 @@ const { readCodexCliCredentialsCachedMock, resolveProviderIdForAuthMock } = vi.h })); vi.mock("./cli-credentials.js", () => ({ - readClaudeCliCredentialsCached: () => null, readCodexCliCredentialsCached: readCodexCliCredentialsCachedMock, readMiniMaxCliCredentialsCached: () => null, resetCliCredentialCachesForTest: () => undefined, diff --git a/src/agents/auth-profiles.external-cli-sync.test.ts b/src/agents/auth-profiles.external-cli-sync.test.ts index 4382a5499503..352207467f0e 100644 --- a/src/agents/auth-profiles.external-cli-sync.test.ts +++ b/src/agents/auth-profiles.external-cli-sync.test.ts @@ -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; } -function expectSingleProfile( - profiles: ReturnType, - 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 | undefined, expected: Record, @@ -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, { - 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, { - 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, { - 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({ diff --git a/src/agents/auth-profiles.markauthprofilefailure.test.ts b/src/agents/auth-profiles.markauthprofilefailure.test.ts index 224b63d30baa..148fed0f398b 100644 --- a/src/agents/auth-profiles.markauthprofilefailure.test.ts +++ b/src/agents/auth-profiles.markauthprofilefailure.test.ts @@ -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, })); diff --git a/src/agents/auth-profiles/external-auth.ts b/src/agents/auth-profiles/external-auth.ts index faccab2106bf..7ed76d34bd30 100644 --- a/src/agents/auth-profiles/external-auth.ts +++ b/src/agents/auth-profiles/external-auth.ts @@ -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(); 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; diff --git a/src/agents/auth-profiles/external-cli-profile-metadata.ts b/src/agents/auth-profiles/external-cli-profile-metadata.ts deleted file mode 100644 index 39a030ad148a..000000000000 --- a/src/agents/auth-profiles/external-cli-profile-metadata.ts +++ /dev/null @@ -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; - -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 | 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()) - ); -} diff --git a/src/agents/auth-profiles/external-cli-sync.email-backfill.test.ts b/src/agents/auth-profiles/external-cli-sync.email-backfill.test.ts deleted file mode 100644 index db4e547d402e..000000000000 --- a/src/agents/auth-profiles/external-cli-sync.email-backfill.test.ts +++ /dev/null @@ -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(); - 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([]); - }); -}); diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index 8779fdfba4cb..e13c76474a8c 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -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"), }); } } diff --git a/src/agents/auth-profiles/external-oauth.test.ts b/src/agents/auth-profiles/external-oauth.test.ts index 165751fc5e6f..bd45c325beb2 100644 --- a/src/agents/auth-profiles/external-oauth.test.ts +++ b/src/agents/auth-profiles/external-oauth.test.ts @@ -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" }), diff --git a/src/agents/auth-profiles/oauth-common-mocks.test-support.ts b/src/agents/auth-profiles/oauth-common-mocks.test-support.ts index fb86f97b2207..f7892f0f98e8 100644 --- a/src/agents/auth-profiles/oauth-common-mocks.test-support.ts +++ b/src/agents/auth-profiles/oauth-common-mocks.test-support.ts @@ -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, diff --git a/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts b/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts index fef900cb995d..a254a4beb821 100644 --- a/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts +++ b/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts @@ -30,7 +30,6 @@ vi.mock("../../llm/oauth.js", () => ({ })); vi.mock("../cli-credentials.js", () => ({ - readClaudeCliCredentialsCached: () => null, readCodexCliCredentialsCached: () => null, readMiniMaxCliCredentialsCached: () => null, resetCliCredentialCachesForTest: () => undefined, diff --git a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts index d99159c0f786..e1bb9afb60ce 100644 --- a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts +++ b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts @@ -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( diff --git a/src/agents/auth-profiles/oauth.test.ts b/src/agents/auth-profiles/oauth.test.ts index 32216c385e80..f76d4d407b58 100644 --- a/src/agents/auth-profiles/oauth.test.ts +++ b/src/agents/auth-profiles/oauth.test.ts @@ -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([]); diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index 5f3e2f10838a..551dc88999a1 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -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 { 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({ diff --git a/src/agents/auth-profiles/profiles.test.ts b/src/agents/auth-profiles/profiles.test.ts index 6d40c7b1921a..e38c391484c9 100644 --- a/src/agents/auth-profiles/profiles.test.ts +++ b/src/agents/auth-profiles/profiles.test.ts @@ -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); }); }); diff --git a/src/agents/auth-profiles/profiles.ts b/src/agents/auth-profiles/profiles.ts index dfaf62d2033d..d04383cb5268 100644 --- a/src/agents/auth-profiles/profiles.ts +++ b/src/agents/auth-profiles/profiles.ts @@ -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 { const profilesByOwner = new Map>([ diff --git a/src/agents/auth-profiles/runtime-external-profile-references.ts b/src/agents/auth-profiles/runtime-external-profile-references.ts index 96b1210fca44..589c180368e1 100644 --- a/src/agents/auth-profiles/runtime-external-profile-references.ts +++ b/src/agents/auth-profiles/runtime-external-profile-references.ts @@ -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; - 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, ): 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, +): 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), ); diff --git a/src/agents/cli-auth-epoch.test-support.ts b/src/agents/cli-auth-epoch.test-support.ts index 673b5b8594bb..9db0cac1b878 100644 --- a/src/agents/cli-auth-epoch.test-support.ts +++ b/src/agents/cli-auth-epoch.test-support.ts @@ -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; diff --git a/src/agents/cli-auth-epoch.test.ts b/src/agents/cli-auth-epoch.test.ts index 0f90e56c362f..8a512a1aaf52 100644 --- a/src/agents/cli-auth-epoch.test.ts +++ b/src/agents/cli-auth-epoch.test.ts @@ -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. */ diff --git a/src/agents/cli-auth-epoch.ts b/src/agents/cli-auth-epoch.ts index 56e6787371a6..4eb9a3958e3f 100644 --- a/src/agents/cli-auth-epoch.ts +++ b/src/agents/cli-auth-epoch.ts @@ -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({ diff --git a/src/agents/cli-credentials.claude-keychain.test.ts b/src/agents/cli-credentials.claude-keychain.test.ts deleted file mode 100644 index 4c450999756e..000000000000 --- a/src/agents/cli-credentials.claude-keychain.test.ts +++ /dev/null @@ -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(); -}); diff --git a/src/agents/cli-credentials.claude-keychain.ts b/src/agents/cli-credentials.claude-keychain.ts deleted file mode 100644 index c3574e95aac6..000000000000 --- a/src/agents/cli-credentials.claude-keychain.ts +++ /dev/null @@ -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 | 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; - } -} diff --git a/src/agents/cli-credentials.test.ts b/src/agents/cli-credentials.test.ts index c9828d98ed8f..5e0b4edd67e4 100644 --- a/src/agents/cli-credentials.test.ts +++ b/src/agents/cli-credentials.test.ts @@ -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): 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): 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; diff --git a/src/agents/cli-credentials.ts b/src/agents/cli-credentials.ts index 396a3ea12e3a..02b43ceda775 100644 --- a/src/agents/cli-credentials.ts +++ b/src/agents/cli-credentials.ts @@ -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 = { sourceFingerprint?: number | string | null; }; -let claudeCliCache: CachedValue | null = null; let codexCliCache: CachedValue | null = null; let minimaxCliCache: CachedValue | null = null; let geminiCliCache: CachedValue | 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; - 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).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).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)}`; } diff --git a/src/agents/cli-runner/cli-backend-auth-policy.ts b/src/agents/cli-runner/cli-backend-auth-policy.ts index dbae45fd34cd..97d9f0413d07 100644 --- a/src/agents/cli-runner/cli-backend-auth-policy.ts +++ b/src/agents/cli-runner/cli-backend-auth-policy.ts @@ -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, diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index 479883a64f66..79af8bf939e3 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -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(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(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; diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 58ce5c27a8a6..b424a57f01ff 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -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 && diff --git a/src/agents/embedded-agent-runner/run/auth-plan.auth-pin.test.ts b/src/agents/embedded-agent-runner/run/auth-plan.auth-pin.test.ts index ac1b6c0344cd..1f6f6b7abc6f 100644 --- a/src/agents/embedded-agent-runner/run/auth-plan.auth-pin.test.ts +++ b/src/agents/embedded-agent-runner/run/auth-plan.auth-pin.test.ts @@ -13,7 +13,6 @@ const readCodexCliCredentialsCachedMock = vi.hoisted(() => ); vi.mock("../../cli-credentials.js", () => ({ - readClaudeCliCredentialsCached: () => null, readCodexCliCredentialsCached: readCodexCliCredentialsCachedMock, readMiniMaxCliCredentialsCached: () => null, })); diff --git a/src/agents/model-auth-availability.ts b/src/agents/model-auth-availability.ts index 5c01163c002d..d0023f77330a 100644 --- a/src/agents/model-auth-availability.ts +++ b/src/agents/model-auth-availability.ts @@ -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["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 => { diff --git a/src/agents/model-auth-label.test.ts b/src/agents/model-auth-label.test.ts index 331e03d30f96..e754003851b5 100644 --- a/src/agents/model-auth-label.test.ts +++ b/src/agents/model-auth-label.test.ts @@ -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", () => { diff --git a/src/agents/model-auth-label.ts b/src/agents/model-auth-label.ts index c127fee541bf..4bad226a706f 100644 --- a/src/agents/model-auth-label.ts +++ b/src/agents/model-auth-label.ts @@ -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({ diff --git a/src/agents/model-auth-markers.test.ts b/src/agents/model-auth-markers.test.ts index 143599e99e9b..165f899fedeb 100644 --- a/src/agents/model-auth-markers.test.ts +++ b/src/agents/model-auth-markers.test.ts @@ -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); diff --git a/src/agents/model-auth-provider.ts b/src/agents/model-auth-provider.ts index 85e5d45c5d58..796c4f731754 100644 --- a/src/agents/model-auth-provider.ts +++ b/src/agents/model-auth-provider.ts @@ -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; + provider: string; + store: AuthProfileStore; +}): boolean { + if (!params.deprecatedProfileIds.has(params.profileId)) { + return false; + } + return true; +} + +function assertAuthProfileNotRetired(params: Parameters[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 { const { provider, cfg, profileId, preferredProfile } = params; + let deprecatedProfileIds: ReadonlySet | 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) { diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts index bbe09f23883b..c8d25baa24b5 100644 --- a/src/agents/model-auth.profiles.test.ts +++ b/src/agents/model-auth.profiles.test.ts @@ -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. diff --git a/src/commands/doctor-auth-legacy-oauth.ts b/src/commands/doctor-auth-legacy-oauth.ts index 0bb92b8c04ba..1849f8efdc8f 100644 --- a/src/commands/doctor-auth-legacy-oauth.ts +++ b/src/commands/doctor-auth-legacy-oauth.ts @@ -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 { - if (!hasConfigOAuthProfiles(cfg)) { - return cfg; - } - const store = ensureAuthProfileStore(); - if (Object.keys(store.profiles).length === 0) { - return cfg; - } +): Promise { 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[]; +}; diff --git a/src/commands/doctor-auth.deprecated-cli-profiles.test.ts b/src/commands/doctor-auth.deprecated-cli-profiles.test.ts index 513452be1aa1..1f70a089c577 100644 --- a/src/commands/doctor-auth.deprecated-cli-profiles.test.ts +++ b/src/commands/doctor-auth.deprecated-cli-profiles.test.ts @@ -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(), +})); 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, diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts index d70290b0e600..737cdd99c143 100644 --- a/src/commands/doctor-auth.ts +++ b/src/commands/doctor-auth.ts @@ -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") diff --git a/src/commands/doctor-claude-cli.test.ts b/src/commands/doctor-claude-cli.test.ts index 9235a9f03a2d..f002d21f600c 100644 --- a/src/commands/doctor-claude-cli.test.ts +++ b/src/commands/doctor-claude-cli.test.ts @@ -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( run: (params: { homeDir: string; workspaceDir: string }) => Promise | T, ): Promise { @@ -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", }, ); diff --git a/src/commands/doctor-claude-cli.ts b/src/commands/doctor-claude-cli.ts index 83446f08f9bf..d2b79705aac1 100644 --- a/src/commands/doctor-claude-cli.ts +++ b/src/commands/doctor-claude-cli.ts @@ -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 - | Pick - | { 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) { diff --git a/src/commands/doctor-external-cli-profiles.test.ts b/src/commands/doctor-external-cli-profiles.test.ts deleted file mode 100644 index add82657b1b0..000000000000 --- a/src/commands/doctor-external-cli-profiles.test.ts +++ /dev/null @@ -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", - ); - }); -}); diff --git a/src/commands/doctor-external-cli-profiles.ts b/src/commands/doctor-external-cli-profiles.ts deleted file mode 100644 index f35c75f895da..000000000000 --- a/src/commands/doctor-external-cli-profiles.ts +++ /dev/null @@ -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 }; -} diff --git a/src/commands/doctor/repair-sequencing.ts b/src/commands/doctor/repair-sequencing.ts index 0c848ad729af..afa392a9d1ce 100644 --- a/src/commands/doctor/repair-sequencing.ts +++ b/src/commands/doctor/repair-sequencing.ts @@ -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 { diff --git a/src/commands/onboard-guided.inference.e2e.test.ts b/src/commands/onboard-guided.inference.e2e.test.ts index 6e5af419e92e..924d451b2554 100644 --- a/src/commands/onboard-guided.inference.e2e.test.ts +++ b/src/commands/onboard-guided.inference.e2e.test.ts @@ -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, diff --git a/src/commands/onboard-inference-ambient.ts b/src/commands/onboard-inference-ambient.ts index 2062a724ea23..f89686c5595c 100644 --- a/src/commands/onboard-inference-ambient.ts +++ b/src/commands/onboard-inference-ambient.ts @@ -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; diff --git a/src/commands/onboard-inference.test.ts b/src/commands/onboard-inference.test.ts index 3f5ab2f2164d..3c0e70f3fd0f 100644 --- a/src/commands/onboard-inference.test.ts +++ b/src/commands/onboard-inference.test.ts @@ -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, }, }); diff --git a/src/commands/onboard-inference.ts b/src/commands/onboard-inference.ts index a3239f7040cc..fc831fc34941 100644 --- a/src/commands/onboard-inference.ts +++ b/src/commands/onboard-inference.ts @@ -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 { + 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(); 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, diff --git a/src/flows/doctor-health-contribution-runners.config.auth-cleanup.test.ts b/src/flows/doctor-health-contribution-runners.config.auth-cleanup.test.ts new file mode 100644 index 000000000000..031b92a21023 --- /dev/null +++ b/src/flows/doctor-health-contribution-runners.config.auth-cleanup.test.ts @@ -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(); + }); +}); diff --git a/src/flows/doctor-health-contribution-runners.config.ts b/src/flows/doctor-health-contribution-runners.config.ts index cfbd948d40fe..478833a03983 100644 --- a/src/flows/doctor-health-contribution-runners.config.ts +++ b/src/flows/doctor-health-contribution-runners.config.ts @@ -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"); diff --git a/src/flows/doctor-health-contribution-types.ts b/src/flows/doctor-health-contribution-types.ts index b4fa08969c27..3b5f2e6ab275 100644 --- a/src/flows/doctor-health-contribution-types.ts +++ b/src/flows/doctor-health-contribution-types.ts @@ -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; diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index 190821b80247..7bd2ea004f8b 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -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"]], }), }), ); diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index beaa2b568f2d..28393e40127a 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -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 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 prompter: ctx.prompter, runtime: ctx.runtime, }); - ctx.cfg = await maybeRepairLegacyOAuthProfileIds(ctx.cfg, ctx.prompter); await noteAuthProfileHealth({ cfg: ctx.cfg, prompter: ctx.prompter, diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index 34c4da206712..72064a9b56a1 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -4683,7 +4683,7 @@ async function resolveGatewayLiveRequestedModels(): Promise platform: "linux", deps: { probeLocalCommand: async (command) => ({ command, found: false }), - readClaudeCliCredentials: () => null, + detectClaudeLoginState: async () => ({ credentials: false }), readCodexCliCredentials: () => null, readGeminiCliCredentials: () => null, }, diff --git a/src/gateway/server-methods/models-auth-status.ts b/src/gateway/server-methods/models-auth-status.ts index 6e2ecf30f9c0..8e6b6143add4 100644 --- a/src/gateway/server-methods/models-auth-status.ts +++ b/src/gateway/server-methods/models-auth-status.ts @@ -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, -): Map { - const profiles = cfg.auth?.profiles; - const aliases = new Map(); - 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, diff --git a/src/infra/provider-usage.auth.plugin.test.ts b/src/infra/provider-usage.auth.plugin.test.ts index 12369abc7d37..e220e89fd9df 100644 --- a/src/infra/provider-usage.auth.plugin.test.ts +++ b/src/infra/provider-usage.auth.plugin.test.ts @@ -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( diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index 0abbb1aaed77..016ce0b6bf40 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -297,6 +297,7 @@ function resolveUsageCredentialProviderIds(params: { async function resolveOAuthToken(params: { state: UsageAuthState; provider: string; + excludeProfileIds?: string[]; }): Promise { 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 ? { diff --git a/src/plugin-sdk/provider-auth-claude-compat.ts b/src/plugin-sdk/provider-auth-claude-compat.ts new file mode 100644 index 000000000000..4c282caa02bf --- /dev/null +++ b/src/plugin-sdk/provider-auth-claude-compat.ts @@ -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; +} diff --git a/src/plugin-sdk/provider-auth.test.ts b/src/plugin-sdk/provider-auth.test.ts index c2197b9b4aa3..dbc943048107 100644 --- a/src/plugin-sdk/provider-auth.test.ts +++ b/src/plugin-sdk/provider-auth.test.ts @@ -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): Promise { diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index 2f16fff64705..b78022d824a7 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -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, diff --git a/src/plugins/provider-auth-helpers.ts b/src/plugins/provider-auth-helpers.ts index e589bf837831..51ecf9e2e1b9 100644 --- a/src/plugins/provider-auth-helpers.ts +++ b/src/plugins/provider-auth-helpers.ts @@ -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 } } : {}), }; } diff --git a/src/plugins/provider-plugin.types.ts b/src/plugins/provider-plugin.types.ts index d7917bdf6b3c..aae20640d621 100644 --- a/src/plugins/provider-plugin.types.ts +++ b/src/plugins/provider-plugin.types.ts @@ -553,11 +553,12 @@ export type ProviderPlugin = { */ loginOAuth?: (callbacks: OAuthLoginCallbacks) => Promise; /** - * 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[]; /** diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 0b34d327b6b7..9e99ea62d220 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -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; diff --git a/src/plugins/provider-runtime.types.ts b/src/plugins/provider-runtime.types.ts index 77a76dc20363..d70f1f2b894e 100644 --- a/src/plugins/provider-runtime.types.ts +++ b/src/plugins/provider-runtime.types.ts @@ -164,7 +164,10 @@ export type ProviderResolveUsageAuthContext = { providerIds?: string[]; envDirect?: Array; }) => Promise; - resolveOAuthToken: (params?: { provider?: string }) => Promise; + resolveOAuthToken: (params?: { + provider?: string; + excludeProfileIds?: string[]; + }) => Promise; }; export type ProviderUsageAuthToken = {