Skip to content

Commit 1375746

Browse files
committed
fix(agents): scope external CLI auth discovery
1 parent 3367cfa commit 1375746

12 files changed

Lines changed: 431 additions & 15 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai
4747
- Channels/WhatsApp: flag recently reconnected linked accounts in channel status even when the socket is currently healthy, so flapping WhatsApp Web sessions no longer look clean after a brief reconnect. Refs #73602. Thanks @Vksh07.
4848
- Gateway: expose `gateway.handshakeTimeoutMs` in config, schema, and docs while preserving `OPENCLAW_HANDSHAKE_TIMEOUT_MS` precedence, so loaded or low-powered hosts can tune local WebSocket pre-auth handshakes without patching dist files. Supersedes #51282; refs #73592 and #73652. Thanks @henry-the-frog.
4949
- Gateway/TUI/status: align configured and env-based WebSocket handshake budgets across local clients, probes, and fallback RPCs while preserving explicit status timeouts and paired-device auth fallback, so slow local gateways are not marked unreachable by a shorter client watchdog. Refs #73524, #73535, #73592, and #73602. Thanks @harshcatsystems-collab, @DJBlackhawk, and @Vksh07.
50+
- Agents/auth: scope external CLI credential discovery to configured providers during model auth status and startup prewarm, so opencode-only and other single-provider gateways do not block on unrelated Claude CLI Keychain probes. Fixes #73908. Thanks @Ailuras.
5051
- Agents/model selection: resolve slash-form aliases before provider/model parsing and keep alias-resolved primary models subject to transient provider cooldowns, so cron and persisted sessions do not retry cooled-down raw aliases. Fixes #73573 and #73657. Thanks @akai-shuuichi and @hashslingers.
5152
- Agents/Claude CLI: reuse already-cached macOS Keychain credentials for no-prompt Claude credential reads, so doctor/runtime checks do not miss fresh interactive Claude auth. Fixes #73682. Thanks @RyanSandoval.
5253
- Agents/Claude CLI doctor: scope workspace and project-dir checks to agents that actually use the Claude CLI runtime, so non-default Claude agents no longer make the default agent look Claude-backed. Fixes #73903. Thanks @bobfreeman1989.

docs/concepts/oauth.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ To reduce that, OpenClaw treats `auth-profiles.json` as a **token sink**:
4848
`openai-codex:default` profile, but once OpenClaw has a local OAuth profile,
4949
the local refresh token is canonical; other integrations can remain
5050
externally managed and re-read their CLI auth store
51+
- status and startup paths that already know the configured provider set scope
52+
external CLI discovery to that set, so an unrelated CLI login store is not
53+
probed for a single-provider setup
5154

5255
## Storage (where tokens live)
5356

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { OpenClawConfig } from "../config/types.openclaw.js";
3+
import { resolveExternalCliAuthScopeFromConfig } from "./auth-profiles/external-cli-scope.js";
4+
5+
describe("external CLI auth scope", () => {
6+
it("returns undefined when config has no provider signal", () => {
7+
expect(resolveExternalCliAuthScopeFromConfig({})).toBeUndefined();
8+
});
9+
10+
it("scopes opencode-only config without adding unrelated CLI providers", () => {
11+
const scope = resolveExternalCliAuthScopeFromConfig({
12+
auth: {
13+
profiles: {
14+
"opencode-go:default": { provider: "opencode-go", mode: "api_key" },
15+
},
16+
},
17+
agents: {
18+
defaults: {
19+
model: { primary: "opencode-go/kimi-k2.6" },
20+
},
21+
},
22+
models: {
23+
providers: {
24+
"opencode-go": {
25+
baseUrl: "https://example.test/v1",
26+
auth: "api-key",
27+
models: [],
28+
},
29+
},
30+
},
31+
});
32+
33+
expect(scope?.providerIds).toContain("opencode-go");
34+
expect(scope?.profileIds).toEqual(["opencode-go:default"]);
35+
expect(scope?.providerIds).not.toContain("claude-cli");
36+
expect(scope?.providerIds).not.toContain("openai-codex");
37+
expect(scope?.providerIds).not.toContain("minimax-portal");
38+
});
39+
40+
it("collects model, auth order, media model, and runtime signals", () => {
41+
const cfg = {
42+
auth: {
43+
order: {
44+
"openai-codex": ["openai-codex:default"],
45+
},
46+
},
47+
agents: {
48+
defaults: {
49+
model: {
50+
primary: "anthropic/claude-opus-4-7",
51+
fallbacks: ["openai/gpt-5.5"],
52+
},
53+
imageGenerationModel: "minimax-portal/image-01",
54+
cliBackends: {
55+
"claude-cli": { command: "claude" },
56+
},
57+
},
58+
list: [
59+
{
60+
id: "worker",
61+
model: "opencode-go/kimi-k2.6",
62+
agentRuntime: { id: "codex" },
63+
subagents: { model: { primary: "z.ai/glm-4.7" } },
64+
},
65+
],
66+
},
67+
} satisfies OpenClawConfig;
68+
69+
const scope = resolveExternalCliAuthScopeFromConfig(cfg);
70+
71+
expect(scope?.providerIds).toEqual(
72+
expect.arrayContaining([
73+
"anthropic",
74+
"openai",
75+
"openai-codex",
76+
"minimax-portal",
77+
"claude-cli",
78+
"codex",
79+
"opencode-go",
80+
"z.ai",
81+
"zai",
82+
]),
83+
);
84+
});
85+
});

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

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import type { AuthProfileStore, OAuthCredential } from "./auth-profiles/types.js
33
import type { ClaudeCliCredential } from "./cli-credentials.js";
44

55
const mocks = vi.hoisted(() => ({
6-
readClaudeCliCredentialsCached: vi.fn<() => ClaudeCliCredential | null>(() => null),
7-
readCodexCliCredentialsCached: vi.fn<() => OAuthCredential | null>(() => null),
8-
readMiniMaxCliCredentialsCached: vi.fn<() => OAuthCredential | null>(() => null),
6+
readClaudeCliCredentialsCached: vi.fn<(options?: unknown) => ClaudeCliCredential | null>(
7+
() => null,
8+
),
9+
readCodexCliCredentialsCached: vi.fn<(options?: unknown) => OAuthCredential | null>(() => null),
10+
readMiniMaxCliCredentialsCached: vi.fn<(options?: unknown) => OAuthCredential | null>(() => null),
911
}));
1012

1113
let readManagedExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").readManagedExternalCliCredential;
@@ -331,6 +333,47 @@ describe("external cli oauth resolution", () => {
331333
]);
332334
});
333335

336+
it("skips external cli readers outside the scoped provider set", () => {
337+
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
338+
providerIds: ["opencode-go"],
339+
});
340+
341+
expect(profiles).toEqual([]);
342+
expect(mocks.readCodexCliCredentialsCached).not.toHaveBeenCalled();
343+
expect(mocks.readClaudeCliCredentialsCached).not.toHaveBeenCalled();
344+
expect(mocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled();
345+
});
346+
347+
it("passes non-prompting keychain policy to scoped Claude CLI credential reads", () => {
348+
mocks.readClaudeCliCredentialsCached.mockReturnValue({
349+
type: "oauth",
350+
provider: "anthropic",
351+
access: "claude-cli-access",
352+
refresh: "claude-cli-refresh",
353+
expires: Date.now() + 5 * 24 * 60 * 60_000,
354+
});
355+
356+
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
357+
providerIds: ["claude-cli"],
358+
allowKeychainPrompt: false,
359+
});
360+
361+
expect(profiles).toEqual([
362+
{
363+
profileId: CLAUDE_CLI_PROFILE_ID,
364+
credential: expect.objectContaining({
365+
type: "oauth",
366+
provider: "claude-cli",
367+
}),
368+
},
369+
]);
370+
expect(mocks.readClaudeCliCredentialsCached).toHaveBeenCalledWith(
371+
expect.objectContaining({ allowKeychainPrompt: false }),
372+
);
373+
expect(mocks.readCodexCliCredentialsCached).not.toHaveBeenCalled();
374+
expect(mocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled();
375+
});
376+
334377
it("ignores Claude CLI token credentials", () => {
335378
mocks.readClaudeCliCredentialsCached.mockReturnValue({
336379
type: "token",

src/agents/auth-profiles.store-cache.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import type { OAuthCredential } from "./auth-profiles/types.js";
1212
type RuntimeOnlyOverlay = { profileId: string; credential: OAuthCredential };
1313

1414
const mocks = vi.hoisted(() => ({
15-
resolveExternalCliAuthProfiles: vi.fn<() => RuntimeOnlyOverlay[]>(() => []),
15+
resolveExternalCliAuthProfiles: vi.fn<
16+
(store?: unknown, options?: unknown) => RuntimeOnlyOverlay[]
17+
>(() => []),
1618
}));
1719

1820
vi.mock("./auth-profiles/external-cli-sync.js", () => ({

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import type { AuthProfileStore, OAuthCredential } from "./types.js";
1010

1111
type ExternalAuthProfileMap = Map<string, ProviderExternalAuthProfile>;
1212
type ResolveExternalAuthProfiles = typeof resolveExternalAuthProfilesWithPlugins;
13+
type ExternalCliOverlayOptions = {
14+
allowKeychainPrompt?: boolean;
15+
externalCliProviderIds?: Iterable<string>;
16+
externalCliProfileIds?: Iterable<string>;
17+
};
1318

1419
let resolveExternalAuthProfilesForRuntime: ResolveExternalAuthProfiles | undefined;
1520

@@ -38,6 +43,7 @@ function resolveExternalAuthProfileMap(params: {
3843
store: AuthProfileStore;
3944
agentDir?: string;
4045
env?: NodeJS.ProcessEnv;
46+
externalCli?: ExternalCliOverlayOptions;
4147
}): ExternalAuthProfileMap {
4248
const env = params.env ?? process.env;
4349
const resolveProfiles =
@@ -54,7 +60,12 @@ function resolveExternalAuthProfileMap(params: {
5460
});
5561

5662
const resolved: ExternalAuthProfileMap = new Map();
57-
const cliProfiles = externalCliSync.resolveExternalCliAuthProfiles?.(params.store) ?? [];
63+
const cliProfiles =
64+
externalCliSync.resolveExternalCliAuthProfiles?.(params.store, {
65+
allowKeychainPrompt: params.externalCli?.allowKeychainPrompt,
66+
providerIds: params.externalCli?.externalCliProviderIds,
67+
profileIds: params.externalCli?.externalCliProfileIds,
68+
}) ?? [];
5869
for (const profile of cliProfiles) {
5970
resolved.set(profile.profileId, {
6071
profileId: profile.profileId,
@@ -76,24 +87,27 @@ function listRuntimeExternalAuthProfiles(params: {
7687
store: AuthProfileStore;
7788
agentDir?: string;
7889
env?: NodeJS.ProcessEnv;
90+
externalCli?: ExternalCliOverlayOptions;
7991
}): RuntimeExternalOAuthProfile[] {
8092
return Array.from(
8193
resolveExternalAuthProfileMap({
8294
store: params.store,
8395
agentDir: params.agentDir,
8496
env: params.env,
97+
externalCli: params.externalCli,
8598
}).values(),
8699
);
87100
}
88101

89102
export function overlayExternalAuthProfiles(
90103
store: AuthProfileStore,
91-
params?: { agentDir?: string; env?: NodeJS.ProcessEnv },
104+
params?: { agentDir?: string; env?: NodeJS.ProcessEnv } & ExternalCliOverlayOptions,
92105
): AuthProfileStore {
93106
const profiles = listRuntimeExternalAuthProfiles({
94107
store,
95108
agentDir: params?.agentDir,
96109
env: params?.env,
110+
externalCli: params,
97111
});
98112
return overlayRuntimeExternalOAuthProfiles(store, profiles);
99113
}
@@ -104,11 +118,17 @@ export function shouldPersistExternalAuthProfile(params: {
104118
credential: OAuthCredential;
105119
agentDir?: string;
106120
env?: NodeJS.ProcessEnv;
121+
externalCliProviderIds?: Iterable<string>;
122+
externalCliProfileIds?: Iterable<string>;
107123
}): boolean {
108124
const profiles = listRuntimeExternalAuthProfiles({
109125
store: params.store,
110126
agentDir: params.agentDir,
111127
env: params.env,
128+
externalCli: {
129+
externalCliProviderIds: params.externalCliProviderIds,
130+
externalCliProfileIds: params.externalCliProfileIds,
131+
},
112132
});
113133
return shouldPersistRuntimeExternalOAuthProfile({
114134
profileId: params.profileId,
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import {
2+
resolveAgentModelFallbackValues,
3+
resolveAgentModelPrimaryValue,
4+
} from "../../config/model-input.js";
5+
import type { AgentModelConfig } from "../../config/types.agents-shared.js";
6+
import type { OpenClawConfig } from "../../config/types.openclaw.js";
7+
import { normalizeProviderId } from "../provider-id.js";
8+
9+
export type ExternalCliAuthScope = {
10+
providerIds: string[];
11+
profileIds: string[];
12+
};
13+
14+
function addProviderScopeId(out: Set<string>, value: string | undefined): void {
15+
const raw = value?.trim();
16+
if (!raw) {
17+
return;
18+
}
19+
out.add(raw);
20+
const normalized = normalizeProviderId(raw);
21+
if (normalized) {
22+
out.add(normalized);
23+
}
24+
}
25+
26+
function addProviderScopeFromModelRef(out: Set<string>, value: string | undefined): void {
27+
const raw = value?.trim();
28+
if (!raw) {
29+
return;
30+
}
31+
const slash = raw.indexOf("/");
32+
if (slash <= 0) {
33+
return;
34+
}
35+
addProviderScopeId(out, raw.slice(0, slash));
36+
}
37+
38+
function addProviderScopeFromModelConfig(out: Set<string>, model: AgentModelConfig | undefined) {
39+
addProviderScopeFromModelRef(out, resolveAgentModelPrimaryValue(model));
40+
for (const fallback of resolveAgentModelFallbackValues(model)) {
41+
addProviderScopeFromModelRef(out, fallback);
42+
}
43+
}
44+
45+
function addExternalCliRuntimeScope(out: Set<string>, value: string | undefined): void {
46+
const normalized = normalizeProviderId(value?.trim() ?? "");
47+
if (
48+
normalized === "claude-cli" ||
49+
normalized === "codex" ||
50+
normalized === "codex-cli" ||
51+
normalized === "openai-codex" ||
52+
normalized === "minimax" ||
53+
normalized === "minimax-cli" ||
54+
normalized === "minimax-portal"
55+
) {
56+
addProviderScopeId(out, normalized);
57+
}
58+
}
59+
60+
export function resolveExternalCliAuthScopeFromConfig(
61+
cfg: OpenClawConfig,
62+
): ExternalCliAuthScope | undefined {
63+
const providerIds = new Set<string>();
64+
const profileIds = new Set<string>();
65+
66+
for (const id of Object.keys(cfg.models?.providers ?? {})) {
67+
addProviderScopeId(providerIds, id);
68+
}
69+
for (const [profileId, profile] of Object.entries(cfg.auth?.profiles ?? {})) {
70+
const normalizedProfileId = profileId.trim();
71+
if (normalizedProfileId) {
72+
profileIds.add(normalizedProfileId);
73+
}
74+
addProviderScopeId(providerIds, profile?.provider);
75+
}
76+
for (const provider of Object.keys(cfg.auth?.order ?? {})) {
77+
addProviderScopeId(providerIds, provider);
78+
}
79+
80+
const defaults = cfg.agents?.defaults;
81+
addProviderScopeFromModelConfig(providerIds, defaults?.model);
82+
addProviderScopeFromModelConfig(providerIds, defaults?.imageModel);
83+
addProviderScopeFromModelConfig(providerIds, defaults?.imageGenerationModel);
84+
addProviderScopeFromModelConfig(providerIds, defaults?.videoGenerationModel);
85+
addProviderScopeFromModelConfig(providerIds, defaults?.musicGenerationModel);
86+
addProviderScopeFromModelConfig(providerIds, defaults?.pdfModel);
87+
for (const modelRef of Object.keys(defaults?.models ?? {})) {
88+
addProviderScopeFromModelRef(providerIds, modelRef);
89+
}
90+
addExternalCliRuntimeScope(providerIds, defaults?.agentRuntime?.id);
91+
addExternalCliRuntimeScope(providerIds, defaults?.embeddedHarness?.runtime);
92+
for (const backendId of Object.keys(defaults?.cliBackends ?? {})) {
93+
addExternalCliRuntimeScope(providerIds, backendId);
94+
}
95+
96+
for (const agent of cfg.agents?.list ?? []) {
97+
addProviderScopeFromModelConfig(providerIds, agent.model);
98+
addProviderScopeFromModelConfig(providerIds, agent.subagents?.model);
99+
addExternalCliRuntimeScope(providerIds, agent.agentRuntime?.id);
100+
addExternalCliRuntimeScope(providerIds, agent.embeddedHarness?.runtime);
101+
}
102+
103+
if (providerIds.size === 0 && profileIds.size === 0) {
104+
return undefined;
105+
}
106+
return {
107+
providerIds: [...providerIds].toSorted((left, right) => left.localeCompare(right)),
108+
profileIds: [...profileIds].toSorted((left, right) => left.localeCompare(right)),
109+
};
110+
}

0 commit comments

Comments
 (0)