Skip to content

Commit 581fbea

Browse files
committed
fix(auth): scope external CLI credential discovery
1 parent 54e6e3d commit 581fbea

6 files changed

Lines changed: 208 additions & 26 deletions

src/agents/auth-profiles.external-cli-scope.test.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ describe("external CLI auth scope", () => {
3737
expect(scope?.providerIds).not.toContain("minimax-portal");
3838
});
3939

40-
it("collects model, auth order, media model, and runtime signals", () => {
40+
it("collects active model, auth order, media model, and runtime signals", () => {
4141
const cfg = {
4242
auth: {
4343
order: {
@@ -54,6 +54,9 @@ describe("external CLI auth scope", () => {
5454
cliBackends: {
5555
"claude-cli": { command: "claude" },
5656
},
57+
models: {
58+
"claude-cli/claude-opus-4-7": { alias: "opus" },
59+
},
5760
},
5861
list: [
5962
{
@@ -74,13 +77,29 @@ describe("external CLI auth scope", () => {
7477
"openai",
7578
"openai-codex",
7679
"minimax-portal",
77-
"claude-cli",
7880
"codex-app-server",
7981
"opencode-go",
8082
"z.ai",
8183
"zai",
8284
]),
8385
);
86+
expect(scope?.providerIds).not.toContain("claude-cli");
8487
expect(scope?.profileIds).toContain("openai-codex:default");
8588
});
89+
90+
it("includes a CLI provider only when it is the active runtime", () => {
91+
const scope = resolveExternalCliAuthScopeFromConfig({
92+
agents: {
93+
defaults: {
94+
model: "openai/gpt-5.5",
95+
agentRuntime: { id: "claude-cli" },
96+
cliBackends: {
97+
"claude-cli": { command: "claude" },
98+
},
99+
},
100+
},
101+
});
102+
103+
expect(scope?.providerIds).toContain("claude-cli");
104+
});
86105
});

src/agents/auth-profiles.external-cli-sync.test.ts

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ describe("external cli oauth resolution", () => {
243243
expect(credential).toBeNull();
244244
});
245245

246-
it("bootstraps the default codex profile from Codex CLI credentials when missing locally", () => {
246+
it("bootstraps the default codex profile from Codex CLI credentials when in scope", () => {
247247
mocks.readCodexCliCredentialsCached.mockReturnValue(
248248
makeOAuthCredential({
249249
provider: "openai-codex",
@@ -254,7 +254,9 @@ describe("external cli oauth resolution", () => {
254254
}),
255255
);
256256

257-
const profiles = resolveExternalCliAuthProfiles(makeStore());
257+
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
258+
providerIds: ["openai-codex"],
259+
});
258260

259261
expect(profiles).toEqual([
260262
{
@@ -318,7 +320,9 @@ describe("external cli oauth resolution", () => {
318320
expires: Date.now() + 5 * 24 * 60 * 60_000,
319321
});
320322

321-
const profiles = resolveExternalCliAuthProfiles(makeStore());
323+
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
324+
providerIds: ["claude-cli"],
325+
});
322326

323327
expect(profiles).toEqual([
324328
{
@@ -344,6 +348,51 @@ describe("external cli oauth resolution", () => {
344348
expect(mocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled();
345349
});
346350

351+
it("does not scan missing external CLI profiles without an explicit scope", () => {
352+
mocks.readClaudeCliCredentialsCached.mockReturnValue({
353+
type: "oauth",
354+
provider: "anthropic",
355+
access: "claude-cli-access",
356+
refresh: "claude-cli-refresh",
357+
expires: Date.now() + 5 * 24 * 60 * 60_000,
358+
});
359+
360+
const profiles = resolveExternalCliAuthProfiles(makeStore());
361+
362+
expect(profiles).toEqual([]);
363+
expect(mocks.readClaudeCliCredentialsCached).not.toHaveBeenCalled();
364+
});
365+
366+
it("refreshes a stored external CLI profile without an explicit scope", () => {
367+
mocks.readClaudeCliCredentialsCached.mockReturnValue({
368+
type: "oauth",
369+
provider: "anthropic",
370+
access: "claude-cli-fresh-access",
371+
refresh: "claude-cli-fresh-refresh",
372+
expires: Date.now() + 5 * 24 * 60 * 60_000,
373+
});
374+
375+
const profiles = resolveExternalCliAuthProfiles(
376+
makeStore(CLAUDE_CLI_PROFILE_ID, {
377+
type: "oauth",
378+
provider: "claude-cli",
379+
access: "claude-cli-stale-access",
380+
refresh: "claude-cli-stale-refresh",
381+
expires: Date.now() - 5_000,
382+
}),
383+
);
384+
385+
expect(profiles).toEqual([
386+
{
387+
profileId: CLAUDE_CLI_PROFILE_ID,
388+
credential: expect.objectContaining({
389+
provider: "claude-cli",
390+
access: "claude-cli-fresh-access",
391+
}),
392+
},
393+
]);
394+
});
395+
347396
it("passes non-prompting keychain policy to scoped Claude CLI credential reads", () => {
348397
mocks.readClaudeCliCredentialsCached.mockReturnValue({
349398
type: "oauth",
@@ -412,7 +461,9 @@ describe("external cli oauth resolution", () => {
412461
expires: Date.now() + 5 * 24 * 60 * 60_000,
413462
});
414463

415-
const profiles = resolveExternalCliAuthProfiles(makeStore());
464+
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
465+
providerIds: ["claude-cli"],
466+
});
416467

417468
expect(profiles).toEqual([]);
418469
});

src/agents/auth-profiles/external-cli-scope.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,8 @@ export function resolveExternalCliAuthScopeFromConfig(
9191
addProviderScopeFromModelConfig(providerIds, defaults?.videoGenerationModel);
9292
addProviderScopeFromModelConfig(providerIds, defaults?.musicGenerationModel);
9393
addProviderScopeFromModelConfig(providerIds, defaults?.pdfModel);
94-
for (const modelRef of Object.keys(defaults?.models ?? {})) {
95-
addProviderScopeFromModelRef(providerIds, modelRef);
96-
}
9794
addExternalCliRuntimeScope(providerIds, defaults?.agentRuntime?.id);
9895
addExternalCliRuntimeScope(providerIds, defaults?.embeddedHarness?.runtime);
99-
for (const backendId of Object.keys(defaults?.cliBackends ?? {})) {
100-
addExternalCliRuntimeScope(providerIds, backendId);
101-
}
10296

10397
for (const agent of cfg.agents?.list ?? []) {
10498
addProviderScopeFromModelConfig(providerIds, agent.model);

src/agents/auth-profiles/external-cli-sync.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -199,14 +199,17 @@ function normalizeProfileScope(values: Iterable<string> | undefined): Set<string
199199
return out;
200200
}
201201

202-
function isExternalCliProviderInScope(
203-
providerConfig: ExternalCliSyncProvider,
204-
options?: ExternalCliAuthProfileOptions,
205-
): boolean {
202+
function isExternalCliProviderInScope(params: {
203+
providerConfig: ExternalCliSyncProvider;
204+
store: AuthProfileStore;
205+
options?: ExternalCliAuthProfileOptions;
206+
}): boolean {
207+
const { providerConfig, options, store } = params;
206208
const providerScope = normalizeProviderScope(options?.providerIds);
207209
const profileScope = normalizeProfileScope(options?.profileIds);
208210
if (providerScope === undefined && profileScope === undefined) {
209-
return true;
211+
const existing = store.profiles[providerConfig.profileId];
212+
return existing?.type === "oauth" && existing.provider === providerConfig.provider;
210213
}
211214
if (profileScope?.has(providerConfig.profileId.toLowerCase())) {
212215
return true;
@@ -229,7 +232,7 @@ export function resolveExternalCliAuthProfiles(
229232
const profiles: ExternalCliResolvedProfile[] = [];
230233
const now = Date.now();
231234
for (const providerConfig of EXTERNAL_CLI_SYNC_PROVIDERS) {
232-
if (!isExternalCliProviderInScope(providerConfig, options)) {
235+
if (!isExternalCliProviderInScope({ providerConfig, store, options })) {
233236
continue;
234237
}
235238
const creds = providerConfig.readCredentials({

src/agents/model-auth.profiles.test.ts

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
clearRuntimeAuthProfileStoreSnapshots,
1111
ensureAuthProfileStore,
1212
} from "./auth-profiles/store.js";
13+
import type { OAuthCredential } from "./auth-profiles/types.js";
14+
import type { ClaudeCliCredential } from "./cli-credentials.js";
1315
import {
1416
getApiKeyForModel,
1517
hasAvailableAuthForProvider,
@@ -206,14 +208,21 @@ vi.mock("../plugins/providers.js", () => ({
206208
provider === "openai" ? ["openai"] : [],
207209
}));
208210

209-
vi.mock("./cli-credentials.js", () => ({
210-
readClaudeCliCredentialsCached: () => null,
211-
readCodexCliCredentialsCached: () => null,
212-
readMiniMaxCliCredentialsCached: () => null,
211+
const cliCredentialMocks = vi.hoisted(() => ({
212+
readClaudeCliCredentialsCached: vi.fn<(options?: unknown) => ClaudeCliCredential | null>(
213+
() => null,
214+
),
215+
readCodexCliCredentialsCached: vi.fn<(options?: unknown) => OAuthCredential | null>(() => null),
216+
readMiniMaxCliCredentialsCached: vi.fn<(options?: unknown) => OAuthCredential | null>(() => null),
213217
}));
214218

219+
vi.mock("./cli-credentials.js", () => cliCredentialMocks);
220+
215221
beforeEach(() => {
216222
clearRuntimeAuthProfileStoreSnapshots();
223+
cliCredentialMocks.readClaudeCliCredentialsCached.mockReset().mockReturnValue(null);
224+
cliCredentialMocks.readCodexCliCredentialsCached.mockReset().mockReturnValue(null);
225+
cliCredentialMocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null);
217226
});
218227

219228
afterEach(() => {
@@ -386,6 +395,67 @@ describe("getApiKeyForModel", () => {
386395
);
387396
});
388397

398+
it("does not read unrelated external CLI credentials when resolving provider auth", async () => {
399+
cliCredentialMocks.readClaudeCliCredentialsCached.mockReturnValue({
400+
type: "oauth",
401+
provider: "anthropic",
402+
access: "claude-cli-access",
403+
refresh: "claude-cli-refresh",
404+
expires: createUsableOAuthExpiry(),
405+
});
406+
407+
await withOpenClawTestState(
408+
{
409+
layout: "state-only",
410+
prefix: "openclaw-auth-scope-",
411+
agentEnv: "main",
412+
env: {
413+
OPENAI_API_KEY: undefined,
414+
},
415+
},
416+
async () => {
417+
await expect(resolveApiKeyForProvider({ provider: "openai" })).rejects.toThrow(
418+
'No API key found for provider "openai".',
419+
);
420+
},
421+
);
422+
423+
expect(cliCredentialMocks.readClaudeCliCredentialsCached).not.toHaveBeenCalled();
424+
expect(cliCredentialMocks.readCodexCliCredentialsCached).not.toHaveBeenCalled();
425+
expect(cliCredentialMocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled();
426+
});
427+
428+
it("reads Claude CLI credentials when the Claude CLI provider is resolved", async () => {
429+
cliCredentialMocks.readClaudeCliCredentialsCached.mockReturnValue({
430+
type: "oauth",
431+
provider: "anthropic",
432+
access: "claude-cli-access",
433+
refresh: "claude-cli-refresh",
434+
expires: createUsableOAuthExpiry(),
435+
});
436+
437+
await withOpenClawTestState(
438+
{
439+
layout: "state-only",
440+
prefix: "openclaw-auth-claude-cli-",
441+
agentEnv: "main",
442+
},
443+
async () => {
444+
const resolved = await resolveApiKeyForProvider({ provider: "claude-cli" });
445+
expect(resolved).toMatchObject({
446+
apiKey: "claude-cli-access",
447+
profileId: "anthropic:claude-cli",
448+
source: "profile:anthropic:claude-cli",
449+
mode: "oauth",
450+
});
451+
},
452+
);
453+
454+
expect(cliCredentialMocks.readClaudeCliCredentialsCached).toHaveBeenCalledWith(
455+
expect.objectContaining({ allowKeychainPrompt: false }),
456+
);
457+
});
458+
389459
it("throws when ZAI API key is missing", async () => {
390460
await withEnvAsync(
391461
{

src/agents/model-auth.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,24 @@ function shouldDeferSyntheticProfileAuth(params: {
489489
);
490490
}
491491

492+
function resolveScopedAuthProfileStore(params: {
493+
agentDir?: string;
494+
cfg?: OpenClawConfig;
495+
provider: string;
496+
profileId?: string;
497+
preferredProfile?: string;
498+
}): AuthProfileStore {
499+
const profileIds = [params.profileId, params.preferredProfile]
500+
.map((value) => value?.trim())
501+
.filter((value): value is string => Boolean(value));
502+
return ensureAuthProfileStore(params.agentDir, {
503+
allowKeychainPrompt: false,
504+
config: params.cfg,
505+
externalCliProviderIds: [params.provider],
506+
...(profileIds.length > 0 ? { externalCliProfileIds: profileIds } : {}),
507+
});
508+
}
509+
492510
export async function resolveApiKeyForProvider(params: {
493511
provider: string;
494512
cfg?: OpenClawConfig;
@@ -505,7 +523,15 @@ export async function resolveApiKeyForProvider(params: {
505523
const { provider, cfg, profileId, preferredProfile } = params;
506524

507525
if (profileId) {
508-
const store = params.store ?? ensureAuthProfileStore(params.agentDir);
526+
const store =
527+
params.store ??
528+
resolveScopedAuthProfileStore({
529+
agentDir: params.agentDir,
530+
cfg,
531+
provider,
532+
profileId,
533+
preferredProfile,
534+
});
509535
const resolved = await resolveApiKeyForProfile({
510536
cfg,
511537
store,
@@ -591,7 +617,14 @@ export async function resolveApiKeyForProvider(params: {
591617
mode: "api-key",
592618
};
593619
}
594-
const store = params.store ?? ensureAuthProfileStore(params.agentDir);
620+
const store =
621+
params.store ??
622+
resolveScopedAuthProfileStore({
623+
agentDir: params.agentDir,
624+
cfg,
625+
provider,
626+
preferredProfile,
627+
});
595628
const order = resolveAuthProfileOrder({
596629
cfg,
597630
store,
@@ -719,7 +752,12 @@ export function resolveModelAuthMode(
719752
return "aws-sdk";
720753
}
721754

722-
const authStore = store ?? ensureAuthProfileStore();
755+
const authStore =
756+
store ??
757+
resolveScopedAuthProfileStore({
758+
cfg,
759+
provider: resolved,
760+
});
723761
const profiles = listProfilesForProvider(authStore, resolved);
724762
if (profiles.length > 0) {
725763
const modes = new Set(
@@ -794,7 +832,14 @@ export async function hasAvailableAuthForProvider(params: {
794832
return true;
795833
}
796834

797-
const store = params.store ?? ensureAuthProfileStore(params.agentDir);
835+
const store =
836+
params.store ??
837+
resolveScopedAuthProfileStore({
838+
agentDir: params.agentDir,
839+
cfg,
840+
provider,
841+
preferredProfile,
842+
});
798843
const order = resolveAuthProfileOrder({
799844
cfg,
800845
store,

0 commit comments

Comments
 (0)