Skip to content

Commit 9f07b21

Browse files
authored
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 <[email protected]>
1 parent 640258d commit 9f07b21

5 files changed

Lines changed: 420 additions & 14 deletions

File tree

src/commands/doctor-auth.hints.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
import { beforeEach, describe, expect, it, vi } from "vitest";
33
import type { OpenClawConfig } from "../config/types.openclaw.js";
44
import {
5+
collectAuthProfileHealthFindings,
56
formatOAuthRefreshFailureDoctorLine,
7+
legacyCodexProviderOverrideToHealthFinding,
68
noteLegacyCodexProviderOverride,
79
resolveUnusableProfileHint,
810
} from "./doctor-auth.js";
@@ -127,6 +129,52 @@ describe("resolveUnusableProfileHint", () => {
127129
);
128130
});
129131

132+
it("maps legacy Codex overrides to structured auth profile findings", () => {
133+
expect(
134+
legacyCodexProviderOverrideToHealthFinding({
135+
api: "openai-responses",
136+
baseUrl: "https://api.openai.com/v1",
137+
}),
138+
).toMatchObject({
139+
checkId: "core/doctor/auth-profiles",
140+
severity: "warning",
141+
message:
142+
"Legacy openai-codex transport override can shadow configured Codex OAuth credentials.",
143+
path: "models.providers.openai-codex",
144+
target: "openai-codex",
145+
});
146+
});
147+
148+
it("collects legacy Codex override structured findings", async () => {
149+
const findings = await collectAuthProfileHealthFindings({
150+
cfg: doctorFixtureConfig({
151+
auth: {
152+
profiles: {
153+
"openai:default": {
154+
provider: "openai",
155+
mode: "oauth",
156+
},
157+
},
158+
},
159+
models: {
160+
providers: {
161+
"openai-codex": {
162+
api: "openai-responses",
163+
},
164+
},
165+
},
166+
}),
167+
});
168+
169+
expect(findings).toEqual([
170+
expect.objectContaining({
171+
checkId: "core/doctor/auth-profiles",
172+
path: "models.providers.openai-codex",
173+
target: "openai-codex",
174+
}),
175+
]);
176+
});
177+
130178
it("warns when a legacy Codex override shadows stored legacy OAuth state", () => {
131179
mocks.ensureAuthProfileStore.mockReturnValue({
132180
version: 1,

src/commands/doctor-auth.profile-health.test.ts

Lines changed: 146 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,16 @@ import fs from "node:fs";
33
import os from "node:os";
44
import path from "node:path";
55
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6+
import type { AuthProfileStore } from "../agents/auth-profiles/types.js";
67
import type { OpenClawConfig } from "../config/types.openclaw.js";
78
import type { DoctorPrompter } from "./doctor-prompter.js";
89

9-
type MockAuthProfileStore = {
10-
version: number;
11-
profiles: Record<
12-
string,
13-
| { type: "oauth"; provider: string; access: string; refresh: string; expires: number }
14-
| { type: "api_key"; provider: string; key: string }
15-
>;
16-
};
17-
1810
const authProfileMocks = vi.hoisted(() => ({
1911
ensureAuthProfileStore: vi.fn<
20-
(agentDir?: string, options?: { allowKeychainPrompt?: boolean }) => MockAuthProfileStore
12+
(
13+
agentDir?: string,
14+
options?: { allowKeychainPrompt?: boolean; readOnly?: boolean },
15+
) => AuthProfileStore
2116
>(() => {
2217
throw new Error("unexpected auth profile load");
2318
}),
@@ -38,7 +33,7 @@ vi.mock("../agents/auth-profiles.js", () => ({
3833
vi.mock("../../packages/terminal-core/src/note.js", () => ({ note: vi.fn() }));
3934

4035
import { note } from "../../packages/terminal-core/src/note.js";
41-
import { noteAuthProfileHealth } from "./doctor-auth.js";
36+
import { collectAuthProfileHealthFindings, noteAuthProfileHealth } from "./doctor-auth.js";
4237

4338
const noteMock = vi.mocked(note);
4439

@@ -67,6 +62,10 @@ describe("noteAuthProfileHealth", () => {
6762
fs.writeFileSync(path.join(agentDir, "auth-profiles.json"), "{}\n", "utf8");
6863
}
6964

65+
function expectedAuthStorePath(agentDir: string): string {
66+
return path.join(agentDir, "openclaw-agent.sqlite");
67+
}
68+
7069
function expiredStore(profileId: string, expires: number) {
7170
return {
7271
version: 1,
@@ -79,8 +78,142 @@ describe("noteAuthProfileHealth", () => {
7978
expires,
8079
},
8180
},
82-
};
81+
} satisfies AuthProfileStore;
8382
}
83+
84+
it("maps expired stored auth profiles to structured findings without refreshing", async () => {
85+
const now = 1_700_000_000_000;
86+
vi.spyOn(Date, "now").mockReturnValue(now);
87+
const mainDir = path.join(tempDir, "main-agent");
88+
authProfileMocks.hasAnyAuthProfileStoreSource.mockReturnValue(true);
89+
authProfileMocks.ensureAuthProfileStore.mockReturnValue(
90+
expiredStore("openai:default", now - 60_000),
91+
);
92+
93+
const findings = await collectAuthProfileHealthFindings({
94+
cfg: {
95+
agents: {
96+
list: [{ id: "main", default: true, agentDir: mainDir }],
97+
},
98+
} as OpenClawConfig,
99+
});
100+
101+
expect(authProfileMocks.resolveApiKeyForProfile).not.toHaveBeenCalled();
102+
expect(findings).toEqual([
103+
expect.objectContaining({
104+
checkId: "core/doctor/auth-profiles",
105+
severity: "warning",
106+
message: "Auth profile openai:default is expired (0m).",
107+
path: expectedAuthStorePath(mainDir),
108+
target: "openai:default",
109+
}),
110+
]);
111+
});
112+
113+
it("maps disabled auth profiles to structured findings", async () => {
114+
const now = 1_700_000_000_000;
115+
vi.spyOn(Date, "now").mockReturnValue(now);
116+
const mainDir = path.join(tempDir, "main-agent");
117+
authProfileMocks.hasAnyAuthProfileStoreSource.mockReturnValue(true);
118+
authProfileMocks.resolveProfileUnusableUntilForDisplay.mockReturnValue(now + 5 * 60_000);
119+
authProfileMocks.ensureAuthProfileStore.mockReturnValue({
120+
version: 1,
121+
profiles: {},
122+
usageStats: {
123+
"openai:billing": {
124+
disabledUntil: now + 5 * 60_000,
125+
disabledReason: "billing",
126+
},
127+
},
128+
} satisfies AuthProfileStore);
129+
130+
const findings = await collectAuthProfileHealthFindings({
131+
cfg: {
132+
agents: {
133+
list: [{ id: "main", default: true, agentDir: mainDir }],
134+
},
135+
} as OpenClawConfig,
136+
});
137+
138+
expect(findings).toEqual([
139+
expect.objectContaining({
140+
checkId: "core/doctor/auth-profiles",
141+
message: "Auth profile openai:billing is disabled:billing (5m).",
142+
path: expectedAuthStorePath(mainDir),
143+
target: "openai:billing",
144+
fixHint: "Top up credits (provider billing) or switch provider.",
145+
}),
146+
]);
147+
});
148+
149+
it("maps malformed API-key auth profiles to structured findings", async () => {
150+
const mainDir = path.join(tempDir, "main-agent");
151+
authProfileMocks.hasAnyAuthProfileStoreSource.mockReturnValue(true);
152+
authProfileMocks.ensureAuthProfileStore.mockReturnValue({
153+
version: 1,
154+
profiles: {
155+
"zai:default": {
156+
type: "api_key",
157+
provider: "zai",
158+
key: "openclaw onboard --auth-choice zai-coding-global",
159+
},
160+
},
161+
} satisfies AuthProfileStore);
162+
163+
const findings = await collectAuthProfileHealthFindings({
164+
cfg: {
165+
agents: {
166+
list: [{ id: "main", default: true, agentDir: mainDir }],
167+
},
168+
} as OpenClawConfig,
169+
});
170+
171+
expect(findings).toEqual([
172+
expect.objectContaining({
173+
checkId: "core/doctor/auth-profiles",
174+
severity: "warning",
175+
message: "Auth profile zai:default is missing [malformed_api_key].",
176+
path: expectedAuthStorePath(mainDir),
177+
target: "zai:default",
178+
requirement: "malformed_api_key",
179+
fixHint: "Paste the API key value, not an OpenClaw onboarding command.",
180+
}),
181+
]);
182+
});
183+
184+
it("labels structured auth profile findings by agent when multiple stores are checked", async () => {
185+
const now = 1_700_000_000_000;
186+
vi.spyOn(Date, "now").mockReturnValue(now);
187+
const mainDir = path.join(tempDir, "main-agent");
188+
const coderDir = path.join(tempDir, "coder-agent");
189+
authProfileMocks.hasAnyAuthProfileStoreSource.mockReturnValue(true);
190+
authProfileMocks.hasLocalAuthProfileStoreSource.mockReturnValue(true);
191+
authProfileMocks.ensureAuthProfileStore.mockImplementation((agentDir) => {
192+
if (agentDir === mainDir) {
193+
return expiredStore("openai:main", now - 60_000);
194+
}
195+
if (agentDir === coderDir) {
196+
return expiredStore("openai:coder", now - 60_000);
197+
}
198+
throw new Error(`unexpected agent dir: ${agentDir ?? "<default>"}`);
199+
});
200+
201+
const findings = await collectAuthProfileHealthFindings({
202+
cfg: {
203+
agents: {
204+
list: [
205+
{ id: "main", default: true, agentDir: mainDir },
206+
{ id: "coder", agentDir: coderDir },
207+
],
208+
},
209+
} as OpenClawConfig,
210+
});
211+
212+
expect(findings.map((finding) => finding.message)).toEqual([
213+
"Agent main auth profile openai:main is expired (0m).",
214+
"Agent coder auth profile openai:coder is expired (0m).",
215+
]);
216+
});
84217
it("skips external auth profile resolution when no auth source exists", async () => {
85218
await noteAuthProfileHealth({
86219
cfg: { channels: { telegram: { enabled: true } } } as OpenClawConfig,
@@ -209,7 +342,7 @@ describe("noteAuthProfileHealth", () => {
209342
writeAuthStore(agentDir);
210343
authProfileMocks.hasAnyAuthProfileStoreSource.mockReturnValue(true);
211344
authProfileMocks.ensureAuthProfileStore.mockImplementation(
212-
(receivedAgentDir): MockAuthProfileStore => {
345+
(receivedAgentDir): AuthProfileStore => {
213346
if (receivedAgentDir === agentDir) {
214347
return {
215348
version: 1,

0 commit comments

Comments
 (0)