From c6adbd233b044fd61e6eff7f47cf68f014c88f1d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 15 Jul 2026 03:15:21 -0700 Subject: [PATCH] fix(doctor): ignore rotating Claude CLI OAuth expiry (#108205) --- .../doctor-auth.profile-health.test.ts | 98 +++++++++++++++++++ src/commands/doctor-auth.ts | 12 +++ 2 files changed, 110 insertions(+) diff --git a/src/commands/doctor-auth.profile-health.test.ts b/src/commands/doctor-auth.profile-health.test.ts index 569484c131cb..3793726f0ef4 100644 --- a/src/commands/doctor-auth.profile-health.test.ts +++ b/src/commands/doctor-auth.profile-health.test.ts @@ -31,6 +31,9 @@ vi.mock("../agents/auth-profiles.js", () => ({ })); vi.mock("../../packages/terminal-core/src/note.js", () => ({ note: vi.fn() })); +vi.mock("../agents/auth-profiles/doctor.js", () => ({ + formatAuthDoctorHint: vi.fn(async () => "Re-authenticate this profile."), +})); import { note } from "../../packages/terminal-core/src/note.js"; import { collectAuthProfileHealthFindings, noteAuthProfileHealth } from "./doctor-auth.js"; @@ -110,6 +113,101 @@ describe("noteAuthProfileHealth", () => { ]); }); + it("does not warn while Claude CLI owns refresh of an expiring access token", async () => { + const now = 1_700_000_000_000; + vi.spyOn(Date, "now").mockReturnValue(now); + const mainDir = path.join(tempDir, "main-agent"); + authProfileMocks.hasAnyAuthProfileStoreSource.mockReturnValue(true); + authProfileMocks.ensureAuthProfileStore.mockReturnValue({ + version: 1, + profiles: { + "anthropic:claude-cli": { + type: "oauth", + provider: "claude-cli", + access: "access", + refresh: "refresh", + expires: now + 3 * 60 * 60_000, + }, + }, + }); + + const findings = await collectAuthProfileHealthFindings({ + cfg: { + agents: { list: [{ id: "main", default: true, agentDir: mainDir }] }, + } as OpenClawConfig, + }); + + expect(findings).toEqual([]); + }); + + it("keeps expiring warnings for static and custom Claude CLI profiles", async () => { + const now = 1_700_000_000_000; + vi.spyOn(Date, "now").mockReturnValue(now); + const mainDir = path.join(tempDir, "main-agent"); + authProfileMocks.hasAnyAuthProfileStoreSource.mockReturnValue(true); + authProfileMocks.ensureAuthProfileStore.mockReturnValue({ + version: 1, + profiles: { + "anthropic:claude-cli": { + type: "token", + provider: "claude-cli", + token: "token", + expires: now + 3 * 60 * 60_000, + }, + "anthropic:custom-cli": { + type: "oauth", + provider: "claude-cli", + access: "access", + refresh: "refresh", + expires: now + 3 * 60 * 60_000, + }, + }, + }); + + const findings = await collectAuthProfileHealthFindings({ + cfg: { + agents: { list: [{ id: "main", default: true, agentDir: mainDir }] }, + } as OpenClawConfig, + }); + + expect(findings.map((finding) => finding.target)).toEqual([ + "anthropic:claude-cli", + "anthropic:custom-cli", + ]); + }); + + it("still warns once a Claude CLI access token is expired", async () => { + const now = 1_700_000_000_000; + vi.spyOn(Date, "now").mockReturnValue(now); + const mainDir = path.join(tempDir, "main-agent"); + authProfileMocks.hasAnyAuthProfileStoreSource.mockReturnValue(true); + authProfileMocks.ensureAuthProfileStore.mockReturnValue({ + version: 1, + profiles: { + "anthropic:claude-cli": { + type: "oauth", + provider: "claude-cli", + access: "access", + refresh: "refresh", + expires: now - 60_000, + }, + }, + }); + + const findings = await collectAuthProfileHealthFindings({ + cfg: { + agents: { list: [{ id: "main", default: true, agentDir: mainDir }] }, + } as OpenClawConfig, + }); + + expect(findings).toEqual([ + expect.objectContaining({ + message: "Auth profile anthropic:claude-cli is expired (0m).", + target: "anthropic:claude-cli", + }), + ]); + }); + it("maps disabled auth profiles to structured findings", async () => { const now = 1_700_000_000_000; vi.spyOn(Date, "now").mockReturnValue(now); diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts index 20e6a1a9a4da..6704ebff7a56 100644 --- a/src/commands/doctor-auth.ts +++ b/src/commands/doctor-auth.ts @@ -21,6 +21,7 @@ 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 { buildOAuthRefreshFailureLoginCommand, @@ -38,6 +39,7 @@ 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"]); @@ -342,6 +344,16 @@ 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")