From 150f3e472b1b4e328e1ce4d9d0455ecea38ebd0a Mon Sep 17 00:00:00 2001 From: Roman Godz Date: Sat, 25 Apr 2026 16:54:25 +0530 Subject: [PATCH] fix: sync Claude CLI OAuth credentials (#70902) (thanks @starvex) --- CHANGELOG.md | 1 + src/agents/auth-health.test.ts | 1 + ...th-profiles.ensureauthprofilestore.test.ts | 1 + .../auth-profiles.external-cli-sync.test.ts | 44 ++++++++++++++++++- ...th-profiles.markauthprofilefailure.test.ts | 1 + src/agents/auth-profiles/external-cli-sync.ts | 13 ++++++ .../auth-profiles/external-oauth.test.ts | 1 + .../oauth-common-mocks.test-support.ts | 1 + .../oauth.fallback-to-main-agent.test.ts | 1 + ...auth.openai-codex-refresh-fallback.test.ts | 1 + src/agents/auth-profiles/oauth.test.ts | 1 + src/agents/model-auth-label.test.ts | 1 + src/agents/model-auth.profiles.test.ts | 1 + 13 files changed, 67 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f3075a27a1..faf08ba6f9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,7 @@ Docs: https://docs.openclaw.ai - Daemon/service-env: add Nix Home Manager profile bin directories to generated gateway service PATHs on macOS and Linux, honoring `NIX_PROFILES` right-to-left precedence and falling back to `~/.nix-profile/bin` when unset. Fixes #44402. (#59935) Thanks @jerome-benoit. - Agents/heartbeat: stop injecting the heartbeat system prompt into non-heartbeat runs, preventing ordinary user replies from being suppressed as `HEARTBEAT_OK` acknowledgments. Fixes #69079. (#69278) Thanks @stainlu. - Active Memory: keep silent recall sub-agent billing/auth failures out of shared auth-profile cooldown state, so a Claude CLI extra-usage rejection cannot disable normal Claude-backed turns. Fixes #71284. (#71539) Thanks @vishutdhar and @obviyus. +- Auth/Claude CLI: sync refreshed Claude CLI OAuth credentials into the managed auth profile so long-running Claude CLI runs stop falling back to stale OpenClaw snapshots. (#70902) Thanks @starvex. ## 2026.4.25 (Unreleased) diff --git a/src/agents/auth-health.test.ts b/src/agents/auth-health.test.ts index a573bb3ab56..830af5aad1c 100644 --- a/src/agents/auth-health.test.ts +++ b/src/agents/auth-health.test.ts @@ -6,6 +6,7 @@ const { readCodexCliCredentialsCachedMock } = vi.hoisted(() => ({ })); vi.mock("./cli-credentials.js", () => ({ + readClaudeCliCredentialsCached: () => null, readCodexCliCredentialsCached: readCodexCliCredentialsCachedMock, readMiniMaxCliCredentialsCached: () => null, resetCliCredentialCachesForTest: () => undefined, diff --git a/src/agents/auth-profiles.ensureauthprofilestore.test.ts b/src/agents/auth-profiles.ensureauthprofilestore.test.ts index ca6a062d747..be7841d2b7d 100644 --- a/src/agents/auth-profiles.ensureauthprofilestore.test.ts +++ b/src/agents/auth-profiles.ensureauthprofilestore.test.ts @@ -21,6 +21,7 @@ vi.mock("../plugins/provider-runtime.js", () => ({ })); vi.mock("./cli-credentials.js", () => ({ + readClaudeCliCredentialsCached: () => null, readCodexCliCredentialsCached: () => { const codexHome = process.env.CODEX_HOME; if (!codexHome) { diff --git a/src/agents/auth-profiles.external-cli-sync.test.ts b/src/agents/auth-profiles.external-cli-sync.test.ts index b7451589a9e..83b12bf9c46 100644 --- a/src/agents/auth-profiles.external-cli-sync.test.ts +++ b/src/agents/auth-profiles.external-cli-sync.test.ts @@ -1,7 +1,9 @@ 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<() => ClaudeCliCredential | null>(() => null), readCodexCliCredentialsCached: vi.fn<() => OAuthCredential | null>(() => null), readMiniMaxCliCredentialsCached: vi.fn<() => OAuthCredential | null>(() => null), })); @@ -12,6 +14,7 @@ let hasUsableOAuthCredential: typeof import("./auth-profiles/external-cli-sync.j let isSafeToUseExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").isSafeToUseExternalCliCredential; let shouldBootstrapFromExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").shouldBootstrapFromExternalCliCredential; let shouldReplaceStoredOAuthCredential: typeof import("./auth-profiles/external-cli-sync.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; @@ -42,9 +45,11 @@ 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); ({ @@ -55,7 +60,7 @@ describe("external cli oauth resolution", () => { shouldBootstrapFromExternalCliCredential, shouldReplaceStoredOAuthCredential, } = await import("./auth-profiles/external-cli-sync.js")); - ({ OPENAI_CODEX_DEFAULT_PROFILE_ID, MINIMAX_CLI_PROFILE_ID } = + ({ CLAUDE_CLI_PROFILE_ID, OPENAI_CODEX_DEFAULT_PROFILE_ID, MINIMAX_CLI_PROFILE_ID } = await import("./auth-profiles/constants.js")); }); @@ -302,6 +307,43 @@ 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()); + + expect(profiles).toEqual([ + { + profileId: CLAUDE_CLI_PROFILE_ID, + credential: expect.objectContaining({ + type: "oauth", + provider: "claude-cli", + access: "claude-cli-access", + refresh: "claude-cli-refresh", + }), + }, + ]); + }); + + 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()); + + expect(profiles).toEqual([]); + }); + 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 8975d3d71b2..0e837057cfa 100644 --- a/src/agents/auth-profiles.markauthprofilefailure.test.ts +++ b/src/agents/auth-profiles.markauthprofilefailure.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; vi.mock("./cli-credentials.js", () => ({ + readClaudeCliCredentialsCached: () => null, readCodexCliCredentialsCached: () => null, readMiniMaxCliCredentialsCached: () => null, })); diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index de4e8b1122c..2888d6c886f 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -1,8 +1,10 @@ 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, @@ -91,6 +93,17 @@ const EXTERNAL_CLI_SYNC_PROVIDERS: ExternalCliSyncProvider[] = [ readCredentials: () => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), bootstrapOnly: true, }, + { + profileId: CLAUDE_CLI_PROFILE_ID, + provider: "claude-cli", + readCredentials: () => { + const credential = readClaudeCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }); + if (credential?.type !== "oauth") { + return null; + } + return { ...credential, provider: "claude-cli" }; + }, + }, { profileId: MINIMAX_CLI_PROFILE_ID, provider: "minimax-portal", diff --git a/src/agents/auth-profiles/external-oauth.test.ts b/src/agents/auth-profiles/external-oauth.test.ts index 6d027e3086a..e3908250296 100644 --- a/src/agents/auth-profiles/external-oauth.test.ts +++ b/src/agents/auth-profiles/external-oauth.test.ts @@ -15,6 +15,7 @@ const readCodexCliCredentialsCachedMock = vi.hoisted(() => ); vi.mock("../cli-credentials.js", () => ({ + readClaudeCliCredentialsCached: () => null, readCodexCliCredentialsCached: readCodexCliCredentialsCachedMock, readMiniMaxCliCredentialsCached: () => null, })); 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 e873ee223e0..93a808185e6 100644 --- a/src/agents/auth-profiles/oauth-common-mocks.test-support.ts +++ b/src/agents/auth-profiles/oauth-common-mocks.test-support.ts @@ -13,6 +13,7 @@ export function getOAuthProviderRuntimeMocks() { } vi.mock("../cli-credentials.js", () => ({ + readClaudeCliCredentialsCached: () => null, readCodexCliCredentialsCached: () => null, readMiniMaxCliCredentialsCached: () => null, resetCliCredentialCachesForTest: () => undefined, 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 aea165dbcb0..8e35bfc6c28 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 @@ -19,6 +19,7 @@ vi.mock("@mariozechner/pi-ai/oauth", () => ({ })); 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 b6e36a6b2f4..79b5d13ae4d 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 @@ -37,6 +37,7 @@ const { })); vi.mock("../cli-credentials.js", () => ({ + readClaudeCliCredentialsCached: () => null, readCodexCliCredentialsCached: readCodexCliCredentialsCachedMock, readMiniMaxCliCredentialsCached: () => null, resetCliCredentialCachesForTest: () => undefined, diff --git a/src/agents/auth-profiles/oauth.test.ts b/src/agents/auth-profiles/oauth.test.ts index 3d380e55b55..bcbd977518d 100644 --- a/src/agents/auth-profiles/oauth.test.ts +++ b/src/agents/auth-profiles/oauth.test.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { AuthProfileStore } from "./types.js"; vi.mock("../cli-credentials.js", () => ({ + readClaudeCliCredentialsCached: () => null, readCodexCliCredentialsCached: () => null, readMiniMaxCliCredentialsCached: () => null, resetCliCredentialCachesForTest: () => undefined, diff --git a/src/agents/model-auth-label.test.ts b/src/agents/model-auth-label.test.ts index 78b785fe514..58f6e702b3d 100644 --- a/src/agents/model-auth-label.test.ts +++ b/src/agents/model-auth-label.test.ts @@ -23,6 +23,7 @@ vi.mock("./model-auth.js", () => ({ })); vi.mock("./cli-credentials.js", () => ({ + readClaudeCliCredentialsCached: () => null, readCodexCliCredentialsCached: mocks.readCodexCliCredentialsCached, })); diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts index cf42a97367c..5a22d2760eb 100644 --- a/src/agents/model-auth.profiles.test.ts +++ b/src/agents/model-auth.profiles.test.ts @@ -160,6 +160,7 @@ vi.mock("../plugins/providers.js", () => ({ })); vi.mock("./cli-credentials.js", () => ({ + readClaudeCliCredentialsCached: () => null, readCodexCliCredentialsCached: () => null, readMiniMaxCliCredentialsCached: () => null, }));