diff --git a/CHANGELOG.md b/CHANGELOG.md index 750717a7665..a27813b9fd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai - 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. - 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. - 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. +- 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. - 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. - 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. - 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. diff --git a/docs/concepts/oauth.md b/docs/concepts/oauth.md index f243c20e731..fb0260a1a63 100644 --- a/docs/concepts/oauth.md +++ b/docs/concepts/oauth.md @@ -48,6 +48,9 @@ To reduce that, OpenClaw treats `auth-profiles.json` as a **token sink**: `openai-codex:default` profile, but once OpenClaw has a local OAuth profile, the local refresh token is canonical; other integrations can remain externally managed and re-read their CLI auth store +- status and startup paths that already know the configured provider set scope + external CLI discovery to that set, so an unrelated CLI login store is not + probed for a single-provider setup ## Storage (where tokens live) diff --git a/src/agents/auth-profiles.external-cli-scope.test.ts b/src/agents/auth-profiles.external-cli-scope.test.ts new file mode 100644 index 00000000000..feda6aaff47 --- /dev/null +++ b/src/agents/auth-profiles.external-cli-scope.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveExternalCliAuthScopeFromConfig } from "./auth-profiles/external-cli-scope.js"; + +describe("external CLI auth scope", () => { + it("returns undefined when config has no provider signal", () => { + expect(resolveExternalCliAuthScopeFromConfig({})).toBeUndefined(); + }); + + it("scopes opencode-only config without adding unrelated CLI providers", () => { + const scope = resolveExternalCliAuthScopeFromConfig({ + auth: { + profiles: { + "opencode-go:default": { provider: "opencode-go", mode: "api_key" }, + }, + }, + agents: { + defaults: { + model: { primary: "opencode-go/kimi-k2.6" }, + }, + }, + models: { + providers: { + "opencode-go": { + baseUrl: "https://example.test/v1", + auth: "api-key", + models: [], + }, + }, + }, + }); + + expect(scope?.providerIds).toContain("opencode-go"); + expect(scope?.profileIds).toEqual(["opencode-go:default"]); + expect(scope?.providerIds).not.toContain("claude-cli"); + expect(scope?.providerIds).not.toContain("openai-codex"); + expect(scope?.providerIds).not.toContain("minimax-portal"); + }); + + it("collects model, auth order, media model, and runtime signals", () => { + const cfg = { + auth: { + order: { + "openai-codex": ["openai-codex:default"], + }, + }, + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-7", + fallbacks: ["openai/gpt-5.5"], + }, + imageGenerationModel: "minimax-portal/image-01", + cliBackends: { + "claude-cli": { command: "claude" }, + }, + }, + list: [ + { + id: "worker", + model: "opencode-go/kimi-k2.6", + agentRuntime: { id: "codex" }, + subagents: { model: { primary: "z.ai/glm-4.7" } }, + }, + ], + }, + } satisfies OpenClawConfig; + + const scope = resolveExternalCliAuthScopeFromConfig(cfg); + + expect(scope?.providerIds).toEqual( + expect.arrayContaining([ + "anthropic", + "openai", + "openai-codex", + "minimax-portal", + "claude-cli", + "codex", + "opencode-go", + "z.ai", + "zai", + ]), + ); + }); +}); diff --git a/src/agents/auth-profiles.external-cli-sync.test.ts b/src/agents/auth-profiles.external-cli-sync.test.ts index 83b12bf9c46..8e7f4952478 100644 --- a/src/agents/auth-profiles.external-cli-sync.test.ts +++ b/src/agents/auth-profiles.external-cli-sync.test.ts @@ -3,9 +3,11 @@ import type { AuthProfileStore, OAuthCredential } from "./auth-profiles/types.js import type { ClaudeCliCredential } from "./cli-credentials.js"; const mocks = vi.hoisted(() => ({ - readClaudeCliCredentialsCached: vi.fn<() => ClaudeCliCredential | null>(() => null), - readCodexCliCredentialsCached: vi.fn<() => OAuthCredential | null>(() => null), - readMiniMaxCliCredentialsCached: vi.fn<() => OAuthCredential | null>(() => null), + readClaudeCliCredentialsCached: vi.fn<(options?: unknown) => ClaudeCliCredential | null>( + () => null, + ), + readCodexCliCredentialsCached: vi.fn<(options?: unknown) => OAuthCredential | null>(() => null), + readMiniMaxCliCredentialsCached: vi.fn<(options?: unknown) => OAuthCredential | null>(() => null), })); let readManagedExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").readManagedExternalCliCredential; @@ -331,6 +333,47 @@ describe("external cli oauth resolution", () => { ]); }); + it("skips external cli readers outside the scoped provider set", () => { + const profiles = resolveExternalCliAuthProfiles(makeStore(), { + providerIds: ["opencode-go"], + }); + + expect(profiles).toEqual([]); + expect(mocks.readCodexCliCredentialsCached).not.toHaveBeenCalled(); + expect(mocks.readClaudeCliCredentialsCached).not.toHaveBeenCalled(); + expect(mocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled(); + }); + + it("passes non-prompting keychain policy to scoped Claude CLI credential reads", () => { + mocks.readClaudeCliCredentialsCached.mockReturnValue({ + type: "oauth", + provider: "anthropic", + access: "claude-cli-access", + refresh: "claude-cli-refresh", + expires: Date.now() + 5 * 24 * 60 * 60_000, + }); + + const profiles = resolveExternalCliAuthProfiles(makeStore(), { + providerIds: ["claude-cli"], + allowKeychainPrompt: false, + }); + + expect(profiles).toEqual([ + { + profileId: CLAUDE_CLI_PROFILE_ID, + credential: expect.objectContaining({ + type: "oauth", + provider: "claude-cli", + }), + }, + ]); + expect(mocks.readClaudeCliCredentialsCached).toHaveBeenCalledWith( + expect.objectContaining({ allowKeychainPrompt: false }), + ); + expect(mocks.readCodexCliCredentialsCached).not.toHaveBeenCalled(); + expect(mocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled(); + }); + it("ignores Claude CLI token credentials", () => { mocks.readClaudeCliCredentialsCached.mockReturnValue({ type: "token", diff --git a/src/agents/auth-profiles.store-cache.test.ts b/src/agents/auth-profiles.store-cache.test.ts index e16cef9f295..2ab90159953 100644 --- a/src/agents/auth-profiles.store-cache.test.ts +++ b/src/agents/auth-profiles.store-cache.test.ts @@ -12,7 +12,9 @@ import type { OAuthCredential } from "./auth-profiles/types.js"; type RuntimeOnlyOverlay = { profileId: string; credential: OAuthCredential }; const mocks = vi.hoisted(() => ({ - resolveExternalCliAuthProfiles: vi.fn<() => RuntimeOnlyOverlay[]>(() => []), + resolveExternalCliAuthProfiles: vi.fn< + (store?: unknown, options?: unknown) => RuntimeOnlyOverlay[] + >(() => []), })); vi.mock("./auth-profiles/external-cli-sync.js", () => ({ diff --git a/src/agents/auth-profiles/external-auth.ts b/src/agents/auth-profiles/external-auth.ts index 1fefa94399e..a72ea26dfed 100644 --- a/src/agents/auth-profiles/external-auth.ts +++ b/src/agents/auth-profiles/external-auth.ts @@ -10,6 +10,11 @@ import type { AuthProfileStore, OAuthCredential } from "./types.js"; type ExternalAuthProfileMap = Map; type ResolveExternalAuthProfiles = typeof resolveExternalAuthProfilesWithPlugins; +type ExternalCliOverlayOptions = { + allowKeychainPrompt?: boolean; + externalCliProviderIds?: Iterable; + externalCliProfileIds?: Iterable; +}; let resolveExternalAuthProfilesForRuntime: ResolveExternalAuthProfiles | undefined; @@ -38,6 +43,7 @@ function resolveExternalAuthProfileMap(params: { store: AuthProfileStore; agentDir?: string; env?: NodeJS.ProcessEnv; + externalCli?: ExternalCliOverlayOptions; }): ExternalAuthProfileMap { const env = params.env ?? process.env; const resolveProfiles = @@ -54,7 +60,12 @@ function resolveExternalAuthProfileMap(params: { }); const resolved: ExternalAuthProfileMap = new Map(); - const cliProfiles = externalCliSync.resolveExternalCliAuthProfiles?.(params.store) ?? []; + const cliProfiles = + externalCliSync.resolveExternalCliAuthProfiles?.(params.store, { + allowKeychainPrompt: params.externalCli?.allowKeychainPrompt, + providerIds: params.externalCli?.externalCliProviderIds, + profileIds: params.externalCli?.externalCliProfileIds, + }) ?? []; for (const profile of cliProfiles) { resolved.set(profile.profileId, { profileId: profile.profileId, @@ -76,24 +87,27 @@ function listRuntimeExternalAuthProfiles(params: { store: AuthProfileStore; agentDir?: string; env?: NodeJS.ProcessEnv; + externalCli?: ExternalCliOverlayOptions; }): RuntimeExternalOAuthProfile[] { return Array.from( resolveExternalAuthProfileMap({ store: params.store, agentDir: params.agentDir, env: params.env, + externalCli: params.externalCli, }).values(), ); } export function overlayExternalAuthProfiles( store: AuthProfileStore, - params?: { agentDir?: string; env?: NodeJS.ProcessEnv }, + params?: { agentDir?: string; env?: NodeJS.ProcessEnv } & ExternalCliOverlayOptions, ): AuthProfileStore { const profiles = listRuntimeExternalAuthProfiles({ store, agentDir: params?.agentDir, env: params?.env, + externalCli: params, }); return overlayRuntimeExternalOAuthProfiles(store, profiles); } @@ -104,11 +118,17 @@ export function shouldPersistExternalAuthProfile(params: { credential: OAuthCredential; agentDir?: string; env?: NodeJS.ProcessEnv; + externalCliProviderIds?: Iterable; + externalCliProfileIds?: Iterable; }): boolean { const profiles = listRuntimeExternalAuthProfiles({ store: params.store, agentDir: params.agentDir, env: params.env, + externalCli: { + externalCliProviderIds: params.externalCliProviderIds, + externalCliProfileIds: params.externalCliProfileIds, + }, }); return shouldPersistRuntimeExternalOAuthProfile({ profileId: params.profileId, diff --git a/src/agents/auth-profiles/external-cli-scope.ts b/src/agents/auth-profiles/external-cli-scope.ts new file mode 100644 index 00000000000..6d496a05522 --- /dev/null +++ b/src/agents/auth-profiles/external-cli-scope.ts @@ -0,0 +1,110 @@ +import { + resolveAgentModelFallbackValues, + resolveAgentModelPrimaryValue, +} from "../../config/model-input.js"; +import type { AgentModelConfig } from "../../config/types.agents-shared.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { normalizeProviderId } from "../provider-id.js"; + +export type ExternalCliAuthScope = { + providerIds: string[]; + profileIds: string[]; +}; + +function addProviderScopeId(out: Set, value: string | undefined): void { + const raw = value?.trim(); + if (!raw) { + return; + } + out.add(raw); + const normalized = normalizeProviderId(raw); + if (normalized) { + out.add(normalized); + } +} + +function addProviderScopeFromModelRef(out: Set, value: string | undefined): void { + const raw = value?.trim(); + if (!raw) { + return; + } + const slash = raw.indexOf("/"); + if (slash <= 0) { + return; + } + addProviderScopeId(out, raw.slice(0, slash)); +} + +function addProviderScopeFromModelConfig(out: Set, model: AgentModelConfig | undefined) { + addProviderScopeFromModelRef(out, resolveAgentModelPrimaryValue(model)); + for (const fallback of resolveAgentModelFallbackValues(model)) { + addProviderScopeFromModelRef(out, fallback); + } +} + +function addExternalCliRuntimeScope(out: Set, value: string | undefined): void { + const normalized = normalizeProviderId(value?.trim() ?? ""); + if ( + normalized === "claude-cli" || + normalized === "codex" || + normalized === "codex-cli" || + normalized === "openai-codex" || + normalized === "minimax" || + normalized === "minimax-cli" || + normalized === "minimax-portal" + ) { + addProviderScopeId(out, normalized); + } +} + +export function resolveExternalCliAuthScopeFromConfig( + cfg: OpenClawConfig, +): ExternalCliAuthScope | undefined { + const providerIds = new Set(); + const profileIds = new Set(); + + for (const id of Object.keys(cfg.models?.providers ?? {})) { + addProviderScopeId(providerIds, id); + } + for (const [profileId, profile] of Object.entries(cfg.auth?.profiles ?? {})) { + const normalizedProfileId = profileId.trim(); + if (normalizedProfileId) { + profileIds.add(normalizedProfileId); + } + addProviderScopeId(providerIds, profile?.provider); + } + for (const provider of Object.keys(cfg.auth?.order ?? {})) { + addProviderScopeId(providerIds, provider); + } + + const defaults = cfg.agents?.defaults; + addProviderScopeFromModelConfig(providerIds, defaults?.model); + addProviderScopeFromModelConfig(providerIds, defaults?.imageModel); + addProviderScopeFromModelConfig(providerIds, defaults?.imageGenerationModel); + addProviderScopeFromModelConfig(providerIds, defaults?.videoGenerationModel); + addProviderScopeFromModelConfig(providerIds, defaults?.musicGenerationModel); + addProviderScopeFromModelConfig(providerIds, defaults?.pdfModel); + for (const modelRef of Object.keys(defaults?.models ?? {})) { + addProviderScopeFromModelRef(providerIds, modelRef); + } + addExternalCliRuntimeScope(providerIds, defaults?.agentRuntime?.id); + addExternalCliRuntimeScope(providerIds, defaults?.embeddedHarness?.runtime); + for (const backendId of Object.keys(defaults?.cliBackends ?? {})) { + addExternalCliRuntimeScope(providerIds, backendId); + } + + for (const agent of cfg.agents?.list ?? []) { + addProviderScopeFromModelConfig(providerIds, agent.model); + addProviderScopeFromModelConfig(providerIds, agent.subagents?.model); + addExternalCliRuntimeScope(providerIds, agent.agentRuntime?.id); + addExternalCliRuntimeScope(providerIds, agent.embeddedHarness?.runtime); + } + + if (providerIds.size === 0 && profileIds.size === 0) { + return undefined; + } + return { + providerIds: [...providerIds].toSorted((left, right) => left.localeCompare(right)), + profileIds: [...profileIds].toSorted((left, right) => left.localeCompare(right)), + }; +} diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index 2888d6c886f..8a40b18f409 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -3,6 +3,7 @@ import { readCodexCliCredentialsCached, readMiniMaxCliCredentialsCached, } from "../cli-credentials.js"; +import { normalizeProviderId } from "../provider-id.js"; import { CLAUDE_CLI_PROFILE_ID, EXTERNAL_CLI_SYNC_TTL_MS, @@ -34,10 +35,19 @@ export type ExternalCliResolvedProfile = { credential: OAuthCredential; }; +export type ExternalCliAuthProfileOptions = { + allowKeychainPrompt?: boolean; + providerIds?: Iterable; + profileIds?: Iterable; +}; + type ExternalCliSyncProvider = { profileId: string; provider: string; - readCredentials: () => OAuthCredential | null; + aliases?: readonly string[]; + readCredentials: ( + options?: Pick, + ) => OAuthCredential | null; // bootstrapOnly providers adopt the external CLI credential only to // seed an empty slot; once a local OAuth credential exists for the // profile, the local refresh token is treated as canonical and the @@ -90,14 +100,18 @@ const EXTERNAL_CLI_SYNC_PROVIDERS: ExternalCliSyncProvider[] = [ { profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, provider: "openai-codex", + aliases: ["codex", "codex-cli"], readCredentials: () => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), bootstrapOnly: true, }, { profileId: CLAUDE_CLI_PROFILE_ID, provider: "claude-cli", - readCredentials: () => { - const credential = readClaudeCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }); + readCredentials: (options) => { + const credential = readClaudeCliCredentialsCached({ + ttlMs: EXTERNAL_CLI_SYNC_TTL_MS, + allowKeychainPrompt: options?.allowKeychainPrompt, + }); if (credential?.type !== "oauth") { return null; } @@ -107,6 +121,7 @@ const EXTERNAL_CLI_SYNC_PROVIDERS: ExternalCliSyncProvider[] = [ { profileId: MINIMAX_CLI_PROFILE_ID, provider: "minimax-portal", + aliases: ["minimax", "minimax-cli"], readCredentials: () => readMiniMaxCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), }, ]; @@ -147,13 +162,75 @@ export function readExternalCliBootstrapCredential(params: { export const readManagedExternalCliCredential = readExternalCliBootstrapCredential; +function normalizeProviderScope(values: Iterable | undefined): Set | undefined { + if (values === undefined) { + return undefined; + } + const out = new Set(); + for (const value of values) { + const raw = value.trim(); + if (!raw) { + continue; + } + out.add(raw.toLowerCase()); + const normalized = normalizeProviderId(raw); + if (normalized) { + out.add(normalized); + } + } + return out; +} + +function normalizeProfileScope(values: Iterable | undefined): Set | undefined { + if (values === undefined) { + return undefined; + } + const out = new Set(); + for (const value of values) { + const raw = value.trim().toLowerCase(); + if (raw) { + out.add(raw); + } + } + return out; +} + +function isExternalCliProviderInScope( + providerConfig: ExternalCliSyncProvider, + options?: ExternalCliAuthProfileOptions, +): boolean { + const providerScope = normalizeProviderScope(options?.providerIds); + const profileScope = normalizeProfileScope(options?.profileIds); + if (providerScope === undefined && profileScope === undefined) { + return true; + } + if (profileScope?.has(providerConfig.profileId.toLowerCase())) { + return true; + } + if (!providerScope || providerScope.size === 0) { + return false; + } + const aliases = [providerConfig.provider, ...(providerConfig.aliases ?? [])]; + return aliases.some((alias) => { + const raw = alias.trim().toLowerCase(); + const normalized = normalizeProviderId(alias); + return providerScope.has(raw) || (normalized ? providerScope.has(normalized) : false); + }); +} + export function resolveExternalCliAuthProfiles( store: AuthProfileStore, + options?: ExternalCliAuthProfileOptions, ): ExternalCliResolvedProfile[] { const profiles: ExternalCliResolvedProfile[] = []; const now = Date.now(); for (const providerConfig of EXTERNAL_CLI_SYNC_PROVIDERS) { - const creds = providerConfig.readCredentials(); + if (!isExternalCliProviderInScope(providerConfig, options)) { + continue; + } + const creds = providerConfig.readCredentials({ + allowKeychainPrompt: options?.allowKeychainPrompt, + }); if (!creds) { continue; } diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index eff573180c6..a529cc6c80e 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -36,6 +36,8 @@ type LoadAuthProfileStoreOptions = { allowKeychainPrompt?: boolean; readOnly?: boolean; syncExternalCli?: boolean; + externalCliProviderIds?: Iterable; + externalCliProfileIds?: Iterable; }; type SaveAuthProfileStoreOptions = { @@ -269,12 +271,20 @@ export function loadAuthProfileStoreForRuntime( const authPath = resolveAuthStorePath(agentDir); const mainAuthPath = resolveAuthStorePath(); if (!agentDir || authPath === mainAuthPath) { - return overlayExternalAuthProfiles(store, { agentDir }); + return overlayExternalAuthProfiles(store, { + agentDir, + allowKeychainPrompt: options?.allowKeychainPrompt, + externalCliProviderIds: options?.externalCliProviderIds, + externalCliProfileIds: options?.externalCliProfileIds, + }); } const mainStore = loadAuthProfileStoreForAgent(undefined, options); return overlayExternalAuthProfiles(mergeAuthProfileStores(mainStore, store), { agentDir, + allowKeychainPrompt: options?.allowKeychainPrompt, + externalCliProviderIds: options?.externalCliProviderIds, + externalCliProfileIds: options?.externalCliProfileIds, }); } @@ -297,11 +307,20 @@ export function loadAuthProfileStoreWithoutExternalProfiles(agentDir?: string): export function ensureAuthProfileStore( agentDir?: string, - options?: { allowKeychainPrompt?: boolean }, + options?: { + allowKeychainPrompt?: boolean; + externalCliProviderIds?: Iterable; + externalCliProfileIds?: Iterable; + }, ): AuthProfileStore { return overlayExternalAuthProfiles( ensureAuthProfileStoreWithoutExternalProfiles(agentDir, options), - { agentDir }, + { + agentDir, + allowKeychainPrompt: options?.allowKeychainPrompt, + externalCliProviderIds: options?.externalCliProviderIds, + externalCliProfileIds: options?.externalCliProfileIds, + }, ); } diff --git a/src/agents/models-config.providers.implicit.ts b/src/agents/models-config.providers.implicit.ts index 9ea7c5ee1a6..475b8613c06 100644 --- a/src/agents/models-config.providers.implicit.ts +++ b/src/agents/models-config.providers.implicit.ts @@ -456,6 +456,7 @@ export async function resolveImplicitProviders( const getAuthStore = () => (authStore ??= ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false, + externalCliProviderIds: params.providerDiscoveryProviderIds, })); const context: ImplicitProviderContext = { ...params, diff --git a/src/gateway/server-methods/models-auth-status.test.ts b/src/gateway/server-methods/models-auth-status.test.ts index 30298704812..42f6af1680a 100644 --- a/src/gateway/server-methods/models-auth-status.test.ts +++ b/src/gateway/server-methods/models-auth-status.test.ts @@ -5,7 +5,11 @@ import type { GatewayRequestHandlerOptions } from "./types.js"; const mocks = vi.hoisted(() => ({ getRuntimeConfig: vi.fn(() => ({})), resolveOpenClawAgentDir: vi.fn(() => "/tmp/agent"), - ensureAuthProfileStore: vi.fn(() => ({ profiles: {} })), + ensureAuthProfileStore: vi.fn((agentDir?: string, options?: unknown) => { + void agentDir; + void options; + return { profiles: {} }; + }), buildAuthHealthSummary: vi.fn( (): AuthHealthSummary => ({ now: 0, warnAfterMs: 0, profiles: [], providers: [] }), ), @@ -187,6 +191,50 @@ describe("models.authStatus", () => { expect(mocks.loadProviderUsageSummary).not.toHaveBeenCalled(); }); + it("scopes external CLI auth overlays to configured providers", async () => { + mocks.getRuntimeConfig.mockReturnValue({ + auth: { + profiles: { + "opencode-go:default": { provider: "opencode-go", mode: "api_key" }, + }, + }, + agents: { + defaults: { + model: { primary: "opencode-go/kimi-k2.6" }, + }, + }, + models: { + providers: { + "opencode-go": { + baseUrl: "https://example.test/v1", + auth: "api-key", + models: [], + }, + }, + }, + }); + + await handler(createOptions()); + + expect(mocks.ensureAuthProfileStore).toHaveBeenCalledWith( + "/tmp/agent", + expect.objectContaining({ + externalCliProviderIds: expect.arrayContaining(["opencode-go"]), + externalCliProfileIds: ["opencode-go:default"], + }), + ); + const [, options] = mocks.ensureAuthProfileStore.mock.calls[0] ?? []; + expect((options as { externalCliProviderIds?: string[] }).externalCliProviderIds).not.toContain( + "claude-cli", + ); + }); + + it("keeps the auth store overlay unscoped when config has no provider signal", async () => { + await handler(createOptions()); + + expect(mocks.ensureAuthProfileStore).toHaveBeenCalledWith("/tmp/agent"); + }); + it("still returns providers when usage fetch fails", async () => { mocks.buildAuthHealthSummary.mockReturnValue(createOpenAiCodexOauthHealthSummary()); mocks.loadProviderUsageSummary.mockRejectedValue(new Error("timeout")); diff --git a/src/gateway/server-methods/models-auth-status.ts b/src/gateway/server-methods/models-auth-status.ts index bd166e2012c..9a471a74849 100644 --- a/src/gateway/server-methods/models-auth-status.ts +++ b/src/gateway/server-methods/models-auth-status.ts @@ -8,6 +8,7 @@ import { formatRemainingShort, } from "../../agents/auth-health.js"; import { ensureAuthProfileStore } from "../../agents/auth-profiles.js"; +import { resolveExternalCliAuthScopeFromConfig } from "../../agents/auth-profiles/external-cli-scope.js"; import { normalizeProviderId } from "../../agents/provider-id.js"; import type { OpenClawConfig } from "../../config/config.js"; import { isSecretRef } from "../../config/types.secrets.js"; @@ -292,7 +293,13 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = { try { const cfg = context.getRuntimeConfig(); const agentDir = resolveOpenClawAgentDir(); - const store = ensureAuthProfileStore(agentDir); + const externalCliAuthScope = resolveExternalCliAuthScopeFromConfig(cfg); + const store = externalCliAuthScope + ? ensureAuthProfileStore(agentDir, { + externalCliProviderIds: externalCliAuthScope.providerIds, + externalCliProfileIds: externalCliAuthScope.profileIds, + }) + : ensureAuthProfileStore(agentDir); const configured = resolveConfiguredProviders(cfg); const authHealth: AuthHealthSummary = buildAuthHealthSummary({ store,