Skip to content

Commit ebadc2d

Browse files
committed
fix(doctor): validate auth source credentials
1 parent 1ca2a63 commit ebadc2d

5 files changed

Lines changed: 343 additions & 29 deletions

File tree

src/agents/auth-profiles/persisted.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ function parseCredentialEntry(
212212
if (!AUTH_PROFILE_TYPES.has(typed.type as AuthProfileCredential["type"])) {
213213
return { ok: false, reason: "invalid_type" };
214214
}
215-
const provider = typed.provider ?? fallbackProvider;
215+
const provider = typed.provider || fallbackProvider;
216216
const normalizedProvider = typeof provider === "string" ? normalizeProviderId(provider) : "";
217217
if (!normalizedProvider) {
218218
return { ok: false, reason: "missing_provider" };
@@ -246,7 +246,7 @@ function warnRejectedCredentialEntries(source: string, rejected: RejectedCredent
246246
});
247247
}
248248

249-
function coerceLegacyAuthStore(raw: unknown): LegacyAuthStore | null {
249+
export function coerceLegacyAuthStore(raw: unknown): LegacyAuthStore | null {
250250
if (!isRecord(raw)) {
251251
return null;
252252
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterEach, describe, expect, it, vi } from "vitest";
5+
import { hasAuthProfileStoreSourceForProvider } from "./source-check.js";
6+
7+
describe("hasAuthProfileStoreSourceForProvider", () => {
8+
afterEach(() => {
9+
vi.unstubAllEnvs();
10+
});
11+
12+
async function withAgentStore(profiles: Record<string, unknown>) {
13+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-source-"));
14+
const stateDir = path.join(root, "state");
15+
const agentDir = path.join(root, "agent");
16+
await fs.mkdir(agentDir, { recursive: true });
17+
await fs.mkdir(stateDir, { recursive: true });
18+
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
19+
await fs.writeFile(
20+
path.join(agentDir, "auth-profiles.json"),
21+
JSON.stringify({ version: 1, profiles }),
22+
);
23+
return { agentDir };
24+
}
25+
26+
async function withLegacyAuthStore(profiles: Record<string, unknown>) {
27+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-source-"));
28+
const stateDir = path.join(root, "state");
29+
const agentDir = path.join(root, "agent");
30+
await fs.mkdir(agentDir, { recursive: true });
31+
await fs.mkdir(stateDir, { recursive: true });
32+
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
33+
await fs.writeFile(path.join(agentDir, "auth.json"), JSON.stringify(profiles));
34+
return { agentDir };
35+
}
36+
37+
it("counts provider-specific usable credentials", async () => {
38+
const { agentDir } = await withAgentStore({
39+
"openai:default": { type: "api_key", provider: "openai", key: "sk-test" },
40+
});
41+
42+
expect(hasAuthProfileStoreSourceForProvider("openai", agentDir)).toBe(true);
43+
});
44+
45+
it("counts legacy auth stores with alias fields and fallback providers", async () => {
46+
const { agentDir } = await withLegacyAuthStore({
47+
openai: { mode: "apiKey", apiKey: "sk-test" },
48+
});
49+
50+
expect(hasAuthProfileStoreSourceForProvider("openai", agentDir)).toBe(true);
51+
});
52+
53+
it("ignores malformed provider fields instead of throwing", async () => {
54+
const { agentDir } = await withAgentStore({
55+
"openai:default": { type: "api_key", key: "sk-test" },
56+
"openai:other": { type: "api_key", provider: 123, key: "sk-test" },
57+
});
58+
59+
expect(hasAuthProfileStoreSourceForProvider("openai", agentDir)).toBe(false);
60+
});
61+
62+
it("does not count profile ids that are bound to a different credential provider", async () => {
63+
const { agentDir } = await withAgentStore({
64+
"openai:default": { type: "api_key", provider: "anthropic", key: "sk-test" },
65+
});
66+
67+
expect(hasAuthProfileStoreSourceForProvider("openai", agentDir)).toBe(false);
68+
});
69+
70+
it("honors configured profile order constraints", async () => {
71+
const { agentDir } = await withAgentStore({
72+
"openai:default": { type: "api_key", provider: "openai", key: "sk-test" },
73+
"openai:expired": {
74+
type: "token",
75+
provider: "openai",
76+
token: "expired-token",
77+
expires: Date.now() - 1000,
78+
},
79+
});
80+
81+
expect(
82+
hasAuthProfileStoreSourceForProvider("openai", agentDir, {
83+
profileIds: ["openai:expired"],
84+
}),
85+
).toBe(false);
86+
expect(
87+
hasAuthProfileStoreSourceForProvider("openai", agentDir, {
88+
profileIds: ["openai:default"],
89+
}),
90+
).toBe(true);
91+
});
92+
93+
it("treats explicit empty profile order as no usable profile", async () => {
94+
const { agentDir } = await withAgentStore({
95+
"openai:default": { type: "api_key", provider: "openai", key: "sk-test" },
96+
});
97+
98+
expect(
99+
hasAuthProfileStoreSourceForProvider("openai", agentDir, {
100+
profileIds: [],
101+
}),
102+
).toBe(false);
103+
});
104+
105+
it("does not count empty provider profiles as credential evidence", async () => {
106+
const { agentDir } = await withAgentStore({
107+
"openai:default": { type: "api_key", provider: "openai" },
108+
});
109+
110+
expect(hasAuthProfileStoreSourceForProvider("openai", agentDir)).toBe(false);
111+
});
112+
113+
it("does not count expired token profiles as credential evidence", async () => {
114+
const { agentDir } = await withAgentStore({
115+
"openai:token": {
116+
type: "token",
117+
provider: "openai",
118+
token: "expired-token",
119+
expires: Date.now() - 1000,
120+
},
121+
});
122+
123+
expect(hasAuthProfileStoreSourceForProvider("openai", agentDir)).toBe(false);
124+
});
125+
});

src/agents/auth-profiles/source-check.ts

Lines changed: 74 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,19 @@
33
* These checks intentionally avoid loading secret-bearing credential payloads.
44
*/
55
import fs from "node:fs";
6+
import { evaluateStoredCredentialEligibility } from "./credential-state.js";
67
import {
78
resolveAuthStatePath,
89
resolveAuthStorePath,
910
resolveLegacyAuthStorePath,
1011
} from "./path-resolve.js";
12+
import { coerceLegacyAuthStore, coercePersistedAuthProfileStore } from "./persisted.js";
1113
import {
1214
getRuntimeAuthProfileStoreSnapshot,
1315
hasAnyRuntimeAuthProfileStoreSource,
1416
} from "./runtime-snapshots.js";
1517
import { readPersistedAuthProfileStateRaw, readPersistedAuthProfileStoreRaw } from "./sqlite.js";
16-
import type { AuthProfileStore } from "./types.js";
18+
import type { AuthProfileCredential, AuthProfileStore } from "./types.js";
1719

1820
// Auth-profile source checks look at runtime snapshots, JSON compatibility
1921
// files, legacy files, and SQLite stores without materializing secret values.
@@ -37,34 +39,58 @@ function normalizeProvider(provider: string): string {
3739
return provider.trim().toLowerCase();
3840
}
3941

40-
function rawStoreHasProviderProfile(raw: unknown, provider: string): boolean {
41-
if (!raw || typeof raw !== "object") {
42+
function isAuthProfileCredential(value: unknown): value is AuthProfileCredential {
43+
if (!value || typeof value !== "object") {
4244
return false;
4345
}
44-
const profiles = (raw as { profiles?: unknown }).profiles;
45-
if (!profiles || typeof profiles !== "object") {
46+
const credential = value as { provider?: unknown; type?: unknown };
47+
const type = credential.type;
48+
return (
49+
typeof credential.provider === "string" &&
50+
(type === "api_key" || type === "token" || type === "oauth")
51+
);
52+
}
53+
54+
function isEligibleProviderCredential(rawCredential: unknown, expectedProvider: string): boolean {
55+
if (!isAuthProfileCredential(rawCredential)) {
56+
return false;
57+
}
58+
return (
59+
normalizeProvider(rawCredential.provider) === expectedProvider &&
60+
evaluateStoredCredentialEligibility({ credential: rawCredential }).eligible
61+
);
62+
}
63+
64+
function coerceRawStoreProfiles(raw: unknown): Record<string, AuthProfileCredential> | null {
65+
return coercePersistedAuthProfileStore(raw)?.profiles ?? coerceLegacyAuthStore(raw);
66+
}
67+
68+
function rawStoreHasProviderProfile(
69+
raw: unknown,
70+
provider: string,
71+
profileIds?: readonly string[],
72+
): boolean {
73+
const profiles = coerceRawStoreProfiles(raw);
74+
if (!profiles) {
4675
return false;
4776
}
4877
const expected = normalizeProvider(provider);
49-
for (const [profileId, rawCredential] of Object.entries(profiles)) {
50-
if (normalizeProvider(profileId).startsWith(`${expected}:`)) {
78+
const credentials =
79+
profileIds?.map((profileId) => profiles[profileId]) ?? Object.values(profiles);
80+
for (const rawCredential of credentials) {
81+
if (isEligibleProviderCredential(rawCredential, expected)) {
5182
return true;
5283
}
53-
if (rawCredential && typeof rawCredential === "object") {
54-
const rawProvider = (rawCredential as { provider?: unknown }).provider;
55-
if (typeof rawProvider === "string" && normalizeProvider(rawProvider) === expected) {
56-
return true;
57-
}
58-
}
5984
}
6085
return false;
6186
}
6287

6388
function runtimeStoreHasProviderProfile(
6489
store: AuthProfileStore | undefined,
6590
provider: string,
91+
profileIds?: readonly string[],
6692
): boolean {
67-
return rawStoreHasProviderProfile(store, provider);
93+
return rawStoreHasProviderProfile(store, provider, profileIds);
6894
}
6995

7096
/** Returns true when any local/runtime/main auth profile source exists. */
@@ -104,37 +130,62 @@ export function hasLocalAuthProfileStoreSource(agentDir?: string): boolean {
104130
);
105131
}
106132

133+
type AuthProfileSourceForProviderOptions = {
134+
/** Optional hard order/profile constraint from config auth.order. */
135+
profileIds?: readonly string[];
136+
};
137+
107138
/** Returns true when a read-only auth-profile source contains a profile for a provider. */
108-
export function hasAuthProfileStoreSourceForProvider(provider: string, agentDir?: string): boolean {
139+
export function hasAuthProfileStoreSourceForProvider(
140+
provider: string,
141+
agentDir?: string,
142+
options?: AuthProfileSourceForProviderOptions,
143+
): boolean {
109144
if (!normalizeProvider(provider)) {
110145
return false;
111146
}
147+
const profileIds = options?.profileIds;
148+
if (profileIds?.length === 0) {
149+
return false;
150+
}
112151
const localRuntimeStore = getRuntimeAuthProfileStoreSnapshot(agentDir);
113-
if (runtimeStoreHasProviderProfile(localRuntimeStore, provider)) {
152+
if (runtimeStoreHasProviderProfile(localRuntimeStore, provider, profileIds)) {
114153
return true;
115154
}
116-
if (rawStoreHasProviderProfile(readJsonFile(resolveAuthStorePath(agentDir)), provider)) {
155+
if (
156+
rawStoreHasProviderProfile(readJsonFile(resolveAuthStorePath(agentDir)), provider, profileIds)
157+
) {
117158
return true;
118159
}
119-
if (rawStoreHasProviderProfile(readJsonFile(resolveLegacyAuthStorePath(agentDir)), provider)) {
160+
if (
161+
rawStoreHasProviderProfile(
162+
readJsonFile(resolveLegacyAuthStorePath(agentDir)),
163+
provider,
164+
profileIds,
165+
)
166+
) {
120167
return true;
121168
}
122-
if (rawStoreHasProviderProfile(readPersistedAuthProfileStoreRaw(agentDir), provider)) {
169+
if (
170+
rawStoreHasProviderProfile(readPersistedAuthProfileStoreRaw(agentDir), provider, profileIds)
171+
) {
123172
return true;
124173
}
125174

126175
if (!agentDir) {
127176
return false;
128177
}
129178
const mainRuntimeStore = getRuntimeAuthProfileStoreSnapshot();
130-
if (runtimeStoreHasProviderProfile(mainRuntimeStore, provider)) {
179+
if (runtimeStoreHasProviderProfile(mainRuntimeStore, provider, profileIds)) {
131180
return true;
132181
}
133-
if (rawStoreHasProviderProfile(readJsonFile(resolveAuthStorePath()), provider)) {
182+
if (rawStoreHasProviderProfile(readJsonFile(resolveAuthStorePath()), provider, profileIds)) {
134183
return true;
135184
}
136-
if (rawStoreHasProviderProfile(readJsonFile(resolveLegacyAuthStorePath()), provider)) {
185+
if (
186+
rawStoreHasProviderProfile(readJsonFile(resolveLegacyAuthStorePath()), provider, profileIds)
187+
) {
137188
return true;
138189
}
139-
return rawStoreHasProviderProfile(readPersistedAuthProfileStoreRaw(), provider);
190+
return rawStoreHasProviderProfile(readPersistedAuthProfileStoreRaw(), provider, profileIds);
140191
}

0 commit comments

Comments
 (0)