From 9f07b21e6231ddb5db3fa845e4fc2763fcdecf86 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Tue, 30 Jun 2026 13:28:26 -0700 Subject: [PATCH] Doctor: expose auth profile findings (#97125) Expose `core/doctor/auth-profiles` as a default-disabled structured Doctor lint check. This maps the existing auth-profile health diagnostics into structured findings while preserving legacy Doctor repair behavior. The check remains opt-in through `--only core/doctor/auth-profiles` or `--all`; default `doctor --lint` behavior is unchanged. Validation: - focused auth-profile/hints/contribution Vitest passed - changed-file oxfmt and oxlint passed - core tsgo passed - git diff --check passed - exact-head hosted CI/Testbox gates passed Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com> --- src/commands/doctor-auth.hints.test.ts | 48 +++++ .../doctor-auth.profile-health.test.ts | 159 +++++++++++++-- src/commands/doctor-auth.ts | 184 +++++++++++++++++- src/flows/doctor-health-contributions.test.ts | 30 +++ src/flows/doctor-health-contributions.ts | 13 ++ 5 files changed, 420 insertions(+), 14 deletions(-) diff --git a/src/commands/doctor-auth.hints.test.ts b/src/commands/doctor-auth.hints.test.ts index 874aeb960ca..e1208198806 100644 --- a/src/commands/doctor-auth.hints.test.ts +++ b/src/commands/doctor-auth.hints.test.ts @@ -2,7 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { + collectAuthProfileHealthFindings, formatOAuthRefreshFailureDoctorLine, + legacyCodexProviderOverrideToHealthFinding, noteLegacyCodexProviderOverride, resolveUnusableProfileHint, } from "./doctor-auth.js"; @@ -127,6 +129,52 @@ describe("resolveUnusableProfileHint", () => { ); }); + it("maps legacy Codex overrides to structured auth profile findings", () => { + expect( + legacyCodexProviderOverrideToHealthFinding({ + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }), + ).toMatchObject({ + checkId: "core/doctor/auth-profiles", + severity: "warning", + message: + "Legacy openai-codex transport override can shadow configured Codex OAuth credentials.", + path: "models.providers.openai-codex", + target: "openai-codex", + }); + }); + + it("collects legacy Codex override structured findings", async () => { + const findings = await collectAuthProfileHealthFindings({ + cfg: doctorFixtureConfig({ + auth: { + profiles: { + "openai:default": { + provider: "openai", + mode: "oauth", + }, + }, + }, + models: { + providers: { + "openai-codex": { + api: "openai-responses", + }, + }, + }, + }), + }); + + expect(findings).toEqual([ + expect.objectContaining({ + checkId: "core/doctor/auth-profiles", + path: "models.providers.openai-codex", + target: "openai-codex", + }), + ]); + }); + it("warns when a legacy Codex override shadows stored legacy OAuth state", () => { mocks.ensureAuthProfileStore.mockReturnValue({ version: 1, diff --git a/src/commands/doctor-auth.profile-health.test.ts b/src/commands/doctor-auth.profile-health.test.ts index 9af5467d554..dbe0921b7da 100644 --- a/src/commands/doctor-auth.profile-health.test.ts +++ b/src/commands/doctor-auth.profile-health.test.ts @@ -3,21 +3,16 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; -type MockAuthProfileStore = { - version: number; - profiles: Record< - string, - | { type: "oauth"; provider: string; access: string; refresh: string; expires: number } - | { type: "api_key"; provider: string; key: string } - >; -}; - const authProfileMocks = vi.hoisted(() => ({ ensureAuthProfileStore: vi.fn< - (agentDir?: string, options?: { allowKeychainPrompt?: boolean }) => MockAuthProfileStore + ( + agentDir?: string, + options?: { allowKeychainPrompt?: boolean; readOnly?: boolean }, + ) => AuthProfileStore >(() => { throw new Error("unexpected auth profile load"); }), @@ -38,7 +33,7 @@ vi.mock("../agents/auth-profiles.js", () => ({ vi.mock("../../packages/terminal-core/src/note.js", () => ({ note: vi.fn() })); import { note } from "../../packages/terminal-core/src/note.js"; -import { noteAuthProfileHealth } from "./doctor-auth.js"; +import { collectAuthProfileHealthFindings, noteAuthProfileHealth } from "./doctor-auth.js"; const noteMock = vi.mocked(note); @@ -67,6 +62,10 @@ describe("noteAuthProfileHealth", () => { fs.writeFileSync(path.join(agentDir, "auth-profiles.json"), "{}\n", "utf8"); } + function expectedAuthStorePath(agentDir: string): string { + return path.join(agentDir, "openclaw-agent.sqlite"); + } + function expiredStore(profileId: string, expires: number) { return { version: 1, @@ -79,8 +78,142 @@ describe("noteAuthProfileHealth", () => { expires, }, }, - }; + } satisfies AuthProfileStore; } + + it("maps expired stored auth profiles to structured findings without refreshing", 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( + expiredStore("openai:default", now - 60_000), + ); + + const findings = await collectAuthProfileHealthFindings({ + cfg: { + agents: { + list: [{ id: "main", default: true, agentDir: mainDir }], + }, + } as OpenClawConfig, + }); + + expect(authProfileMocks.resolveApiKeyForProfile).not.toHaveBeenCalled(); + expect(findings).toEqual([ + expect.objectContaining({ + checkId: "core/doctor/auth-profiles", + severity: "warning", + message: "Auth profile openai:default is expired (0m).", + path: expectedAuthStorePath(mainDir), + target: "openai:default", + }), + ]); + }); + + it("maps disabled auth profiles to structured findings", 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.resolveProfileUnusableUntilForDisplay.mockReturnValue(now + 5 * 60_000); + authProfileMocks.ensureAuthProfileStore.mockReturnValue({ + version: 1, + profiles: {}, + usageStats: { + "openai:billing": { + disabledUntil: now + 5 * 60_000, + disabledReason: "billing", + }, + }, + } satisfies AuthProfileStore); + + const findings = await collectAuthProfileHealthFindings({ + cfg: { + agents: { + list: [{ id: "main", default: true, agentDir: mainDir }], + }, + } as OpenClawConfig, + }); + + expect(findings).toEqual([ + expect.objectContaining({ + checkId: "core/doctor/auth-profiles", + message: "Auth profile openai:billing is disabled:billing (5m).", + path: expectedAuthStorePath(mainDir), + target: "openai:billing", + fixHint: "Top up credits (provider billing) or switch provider.", + }), + ]); + }); + + it("maps malformed API-key auth profiles to structured findings", async () => { + const mainDir = path.join(tempDir, "main-agent"); + authProfileMocks.hasAnyAuthProfileStoreSource.mockReturnValue(true); + authProfileMocks.ensureAuthProfileStore.mockReturnValue({ + version: 1, + profiles: { + "zai:default": { + type: "api_key", + provider: "zai", + key: "openclaw onboard --auth-choice zai-coding-global", + }, + }, + } satisfies AuthProfileStore); + + const findings = await collectAuthProfileHealthFindings({ + cfg: { + agents: { + list: [{ id: "main", default: true, agentDir: mainDir }], + }, + } as OpenClawConfig, + }); + + expect(findings).toEqual([ + expect.objectContaining({ + checkId: "core/doctor/auth-profiles", + severity: "warning", + message: "Auth profile zai:default is missing [malformed_api_key].", + path: expectedAuthStorePath(mainDir), + target: "zai:default", + requirement: "malformed_api_key", + fixHint: "Paste the API key value, not an OpenClaw onboarding command.", + }), + ]); + }); + + it("labels structured auth profile findings by agent when multiple stores are checked", async () => { + const now = 1_700_000_000_000; + vi.spyOn(Date, "now").mockReturnValue(now); + const mainDir = path.join(tempDir, "main-agent"); + const coderDir = path.join(tempDir, "coder-agent"); + authProfileMocks.hasAnyAuthProfileStoreSource.mockReturnValue(true); + authProfileMocks.hasLocalAuthProfileStoreSource.mockReturnValue(true); + authProfileMocks.ensureAuthProfileStore.mockImplementation((agentDir) => { + if (agentDir === mainDir) { + return expiredStore("openai:main", now - 60_000); + } + if (agentDir === coderDir) { + return expiredStore("openai:coder", now - 60_000); + } + throw new Error(`unexpected agent dir: ${agentDir ?? ""}`); + }); + + const findings = await collectAuthProfileHealthFindings({ + cfg: { + agents: { + list: [ + { id: "main", default: true, agentDir: mainDir }, + { id: "coder", agentDir: coderDir }, + ], + }, + } as OpenClawConfig, + }); + + expect(findings.map((finding) => finding.message)).toEqual([ + "Agent main auth profile openai:main is expired (0m).", + "Agent coder auth profile openai:coder is expired (0m).", + ]); + }); it("skips external auth profile resolution when no auth source exists", async () => { await noteAuthProfileHealth({ cfg: { channels: { telegram: { enabled: true } } } as OpenClawConfig, @@ -209,7 +342,7 @@ describe("noteAuthProfileHealth", () => { writeAuthStore(agentDir); authProfileMocks.hasAnyAuthProfileStoreSource.mockReturnValue(true); authProfileMocks.ensureAuthProfileStore.mockImplementation( - (receivedAgentDir): MockAuthProfileStore => { + (receivedAgentDir): AuthProfileStore => { if (receivedAgentDir === agentDir) { return { version: 1, diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts index 3aaf2fab311..305d78f56d2 100644 --- a/src/commands/doctor-auth.ts +++ b/src/commands/doctor-auth.ts @@ -26,8 +26,10 @@ import { classifyOAuthRefreshFailure, type OAuthRefreshFailureReason, } from "../agents/auth-profiles/oauth-refresh-failure.js"; +import { resolveAuthStorePathForDisplay } from "../agents/auth-profiles/path-resolve.js"; import { buildProviderAuthRecoveryHint } from "../agents/provider-auth-recovery-hint.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { HealthFinding } from "../flows/health-checks.js"; import { formatErrorMessage } from "../infra/errors.js"; import { isRecord } from "../utils.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; @@ -37,6 +39,7 @@ const LEGACY_CODEX_PROVIDER_ID = "openai-codex"; 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"]); +const AUTH_PROFILES_CHECK_ID = "core/doctor/auth-profiles"; const DOCTOR_REAUTH_PROVIDER_ALIASES: Readonly> = { [LEGACY_CODEX_PROVIDER_ID]: OPENAI_PROVIDER_ID, }; @@ -50,7 +53,7 @@ function hasConfiguredCodexOAuthProfile(cfg: OpenClawConfig): boolean { } function hasStoredCodexOAuthProfile(): boolean { - const store = ensureAuthProfileStore(undefined, { allowKeychainPrompt: false }); + const store = ensureAuthProfileStore(undefined, { allowKeychainPrompt: false, readOnly: true }); return Object.values(store.profiles).some( (profile) => (profile.provider === OPENAI_PROVIDER_ID || profile.provider === LEGACY_CODEX_PROVIDER_ID) && @@ -114,6 +117,22 @@ function buildCodexProviderOverrideWarning(providerOverride: unknown): string { return lines.join("\n"); } +export function legacyCodexProviderOverrideToHealthFinding( + providerOverride: unknown, +): HealthFinding { + const message = + "Legacy openai-codex transport override can shadow configured Codex OAuth credentials."; + const details = buildCodexProviderOverrideWarning(providerOverride); + return { + checkId: AUTH_PROFILES_CHECK_ID, + severity: "warning", + message, + path: `models.providers.${LEGACY_CODEX_PROVIDER_ID}`, + target: LEGACY_CODEX_PROVIDER_ID, + fixHint: details, + }; +} + /** Emits a warning when legacy Codex transport overrides can shadow configured Codex OAuth. */ export function noteLegacyCodexProviderOverride(cfg: OpenClawConfig): void { const providerOverride = cfg.models?.providers?.[LEGACY_CODEX_PROVIDER_ID]; @@ -263,6 +282,169 @@ async function formatAuthIssueLine( return `- ${issue.profileId}: ${issue.status}${reason}${remaining}${hint ? ` — ${hint}` : ""}`; } +function resolveAuthProfileStorePath(target: AuthProfileHealthTarget): string { + return resolveAuthStorePathForDisplay(target.agentDir); +} + +function authProfileIssueToHealthFinding(params: { + issue: AuthIssue; + target: AuthProfileHealthTarget; + labelAgents: boolean; + hint: string | null; +}): HealthFinding { + const remaining = + params.issue.remainingMs !== undefined + ? ` (${formatRemainingShort(params.issue.remainingMs)})` + : ""; + const reason = params.issue.reasonCode ? ` [${params.issue.reasonCode}]` : ""; + const owner = params.labelAgents ? `Agent ${params.target.agentId} auth profile` : "Auth profile"; + return { + checkId: AUTH_PROFILES_CHECK_ID, + severity: "warning", + message: `${owner} ${params.issue.profileId} is ${params.issue.status}${reason}${remaining}.`, + path: resolveAuthProfileStorePath(params.target), + target: params.issue.profileId, + ...(params.issue.reasonCode ? { requirement: params.issue.reasonCode } : {}), + fixHint: + params.hint ?? + (params.issue.status === "expiring" + ? "Run `openclaw doctor --fix` to refresh expiring OAuth profiles, or re-authenticate static tokens." + : "Run `openclaw doctor --fix` to refresh OAuth profiles, or re-authenticate this provider."), + }; +} + +function authProfileCooldownToHealthFinding(params: { + profileId: string; + target: AuthProfileHealthTarget; + labelAgents: boolean; + kind: string; + remaining: string; + hint: string; +}): HealthFinding { + return { + checkId: AUTH_PROFILES_CHECK_ID, + severity: "warning", + message: params.labelAgents + ? `Agent ${params.target.agentId} auth profile ${params.profileId} is ${params.kind} (${params.remaining}).` + : `Auth profile ${params.profileId} is ${params.kind} (${params.remaining}).`, + path: resolveAuthProfileStorePath(params.target), + target: params.profileId, + fixHint: params.hint, + }; +} + +async function collectAuthProfileHealthFindingsForTarget(params: { + cfg: OpenClawConfig; + allowKeychainPrompt: boolean; + target: AuthProfileHealthTarget; + labelAgents: boolean; +}): Promise { + const store = ensureAuthProfileStore(params.target.agentDir, { + allowKeychainPrompt: params.allowKeychainPrompt, + readOnly: true, + }); + const findings: HealthFinding[] = []; + const now = Date.now(); + for (const profileId of Object.keys(store.usageStats ?? {})) { + const until = resolveProfileUnusableUntilForDisplay(store, profileId); + if (!until || now >= until) { + continue; + } + const stats = store.usageStats?.[profileId]; + const remaining = formatRemainingShort(until - now); + const disabledActive = typeof stats?.disabledUntil === "number" && now < stats.disabledUntil; + const kind = disabledActive + ? `disabled${stats.disabledReason ? `:${stats.disabledReason}` : ""}` + : "cooldown"; + const hint = resolveUnusableProfileHint({ + kind: disabledActive ? "disabled" : "cooldown", + reason: stats?.disabledReason, + }); + findings.push( + authProfileCooldownToHealthFinding({ + profileId, + target: params.target, + labelAgents: params.labelAgents, + kind, + remaining, + hint, + }), + ); + } + + const summary = buildAuthHealthSummary({ + store, + cfg: params.cfg, + warnAfterMs: DEFAULT_OAUTH_WARN_MS, + allowKeychainPrompt: params.allowKeychainPrompt, + }); + const issues = summary.profiles.filter((profile) => { + if (profile.type === "api_key") { + return profile.status === "missing"; + } + return ( + (profile.type === "oauth" || profile.type === "token") && + (profile.status === "expired" || + profile.status === "expiring" || + profile.status === "missing") + ); + }); + for (const issue of issues) { + const authIssue: AuthIssue = { + profileId: issue.profileId, + provider: issue.provider, + status: issue.status, + reasonCode: issue.reasonCode, + remainingMs: issue.remainingMs, + }; + findings.push( + authProfileIssueToHealthFinding({ + issue: authIssue, + target: params.target, + labelAgents: params.labelAgents, + hint: await resolveAuthIssueHint(authIssue, params.cfg, store), + }), + ); + } + return findings; +} + +/** Collects read-only structured findings for auth profile health. */ +export async function collectAuthProfileHealthFindings(params: { + cfg: OpenClawConfig; + allowKeychainPrompt?: boolean; +}): Promise { + const configuredProfiles = Object.keys(params.cfg.auth?.profiles ?? {}).length > 0; + const targets = listAuthProfileHealthTargets(params.cfg); + const activeTargets = targets.filter((target) => + target.isDefault + ? hasAnyAuthProfileStoreSource(target.agentDir) || configuredProfiles + : hasLocalAuthProfileStoreSource(target.agentDir), + ); + const findings: HealthFinding[] = []; + const labelAgents = activeTargets.length > 1; + for (const target of activeTargets) { + findings.push( + ...(await collectAuthProfileHealthFindingsForTarget({ + cfg: params.cfg, + allowKeychainPrompt: params.allowKeychainPrompt ?? false, + target, + labelAgents, + })), + ); + } + + const providerOverride = params.cfg.models?.providers?.[LEGACY_CODEX_PROVIDER_ID]; + if ( + providerOverride && + hasLegacyCodexTransportOverride(providerOverride) && + (hasConfiguredCodexOAuthProfile(params.cfg) || hasStoredCodexOAuthProfile()) + ) { + findings.push(legacyCodexProviderOverrideToHealthFinding(providerOverride)); + } + return findings; +} + async function noteAuthProfileHealthForTarget(params: { cfg: OpenClawConfig; prompter: DoctorPrompter; diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index f8224ee7908..a6bd8b311f6 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({ maybeRepairGatewayDaemon: vi.fn().mockResolvedValue(undefined), maybeRepairLegacyOAuthProfileIds: vi.fn(async (cfg: unknown) => cfg), maybeRepairLegacyOAuthSidecarProfiles: vi.fn().mockResolvedValue(undefined), + collectAuthProfileHealthFindings: vi.fn(async () => []), noteAuthProfileHealth: vi.fn().mockResolvedValue(undefined), noteLegacyCodexProviderOverride: vi.fn(), buildGatewayConnectionDetails: vi.fn(() => ({ message: "gateway details" })), @@ -145,6 +146,7 @@ vi.mock("../commands/doctor-auth-oauth-sidecar.js", () => ({ })); vi.mock("../commands/doctor-auth.js", () => ({ + collectAuthProfileHealthFindings: mocks.collectAuthProfileHealthFindings, noteAuthProfileHealth: mocks.noteAuthProfileHealth, noteLegacyCodexProviderOverride: mocks.noteLegacyCodexProviderOverride, })); @@ -327,6 +329,8 @@ describe("doctor health contributions", () => { mocks.maybeRepairLegacyOAuthProfileIds.mockImplementation(async (cfg: unknown) => cfg); mocks.maybeRepairLegacyOAuthSidecarProfiles.mockClear(); mocks.maybeRepairLegacyOAuthSidecarProfiles.mockResolvedValue(undefined); + mocks.collectAuthProfileHealthFindings.mockClear(); + mocks.collectAuthProfileHealthFindings.mockResolvedValue([]); mocks.noteAuthProfileHealth.mockClear(); mocks.noteAuthProfileHealth.mockResolvedValue(undefined); mocks.noteLegacyCodexProviderOverride.mockClear(); @@ -1007,6 +1011,32 @@ describe("doctor health contributions", () => { }); }); + it("registers auth profile health as an opt-in structured check", async () => { + const contribution = requireDoctorContribution("doctor:auth-profiles"); + const [check] = contribution.healthChecks; + + expect(contribution.healthCheckIds).toEqual(["core/doctor/auth-profiles"]); + expect(check).toMatchObject({ + id: "core/doctor/auth-profiles", + kind: "core", + defaultEnabled: false, + }); + if (!check || !("detect" in check)) { + throw new Error("expected split auth profile health check"); + } + + await check.detect({ + mode: "lint", + cfg: { auth: { profiles: {} } }, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + }); + + expect(mocks.collectAuthProfileHealthFindings).toHaveBeenCalledWith({ + cfg: { auth: { profiles: {} } }, + allowKeychainPrompt: false, + }); + }); + it("forwards skipped Gateway health to daemon repair", async () => { const contribution = requireDoctorContribution("doctor:gateway-daemon"); const ctx = { diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 98397d2d8b4..43081b152d1 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -1222,6 +1222,19 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] { createDoctorHealthContribution({ id: "doctor:auth-profiles", label: "Auth profiles", + healthChecks: { + id: "core/doctor/auth-profiles", + kind: "core", + description: "Auth profile cooldown, expiry, missing credential, and legacy override state", + defaultEnabled: false, + async detect(ctx) { + const { collectAuthProfileHealthFindings } = await import("../commands/doctor-auth.js"); + return collectAuthProfileHealthFindings({ + cfg: ctx.cfg, + allowKeychainPrompt: false, + }); + }, + }, run: runAuthProfileHealth, }), createDoctorHealthContribution({