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>
This commit is contained in:
Gio Della-Libera
2026-06-30 13:28:26 -07:00
committed by GitHub
parent 640258d7b3
commit 9f07b21e62
5 changed files with 420 additions and 14 deletions

View File

@@ -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,

View File

@@ -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 ?? "<default>"}`);
});
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,

View File

@@ -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<Record<string, string>> = {
[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<readonly HealthFinding[]> {
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<readonly HealthFinding[]> {
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;

View File

@@ -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 = {

View File

@@ -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({