diff --git a/docs/auth-credential-semantics.md b/docs/auth-credential-semantics.md index 3c1f81ba371..ac7ca384328 100644 --- a/docs/auth-credential-semantics.md +++ b/docs/auth-credential-semantics.md @@ -85,6 +85,9 @@ the target agent signs in separately and creates its own local profile. - Runtime-only credentials owned by external CLIs are discovered only when the provider, runtime, or auth profile is in scope for the current operation, or when a stored local profile for that external source already exists. +- Auth-store callers should choose an explicit external-CLI discovery mode: + `none` for persisted/plugin auth only, `existing` for refreshing already + stored external CLI profiles, or `scoped` for a concrete provider/profile set. - Read-only/status paths pass `allowKeychainPrompt: false`; they use file-backed external CLI credentials only and do not read or reuse macOS Keychain results. diff --git a/extensions/qa-lab/src/model-selection.runtime.test.ts b/extensions/qa-lab/src/model-selection.runtime.test.ts index 5e781d48dce..616ae5f1129 100644 --- a/extensions/qa-lab/src/model-selection.runtime.test.ts +++ b/extensions/qa-lab/src/model-selection.runtime.test.ts @@ -45,6 +45,11 @@ describe("qa model selection runtime", () => { expect(resolveQaPreferredLiveModel()).toBe("openai/gpt-5.5"); expect(defaultQaRuntimeModelForMode("live-frontier")).toBe("openai/gpt-5.5"); + expect(loadAuthProfileStoreForRuntime).toHaveBeenCalledWith(undefined, { + readOnly: true, + allowKeychainPrompt: false, + externalCliProviderIds: ["openai-codex"], + }); }); it("keeps the OpenAI live default when stored OpenAI profiles are available", () => { diff --git a/extensions/qa-lab/src/providers/live-frontier/model-selection.runtime.ts b/extensions/qa-lab/src/providers/live-frontier/model-selection.runtime.ts index 00c79573d73..e355c1e8813 100644 --- a/extensions/qa-lab/src/providers/live-frontier/model-selection.runtime.ts +++ b/extensions/qa-lab/src/providers/live-frontier/model-selection.runtime.ts @@ -14,6 +14,7 @@ export function resolveQaLiveFrontierPreferredModel() { const store = loadAuthProfileStoreForRuntime(undefined, { readOnly: true, allowKeychainPrompt: false, + externalCliProviderIds: ["openai-codex"], }); if (listProfilesForProvider(store, "openai").length > 0) { return undefined; diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index 204607af9cb..0722e0961e2 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -6,6 +6,15 @@ export type { export type { AuthProfileEligibilityReasonCode } from "./auth-profiles/order.js"; export { resolveAuthProfileDisplayLabel } from "./auth-profiles/display.js"; export { formatAuthDoctorHint } from "./auth-profiles/doctor.js"; +export { + externalCliDiscoveryExisting, + externalCliDiscoveryForConfigStatus, + externalCliDiscoveryForProviderAuth, + externalCliDiscoveryForProviders, + externalCliDiscoveryNone, + externalCliDiscoveryScoped, + type ExternalCliAuthDiscovery, +} from "./auth-profiles/external-cli-discovery.js"; export { resolveApiKeyForProfile } from "./auth-profiles/oauth.js"; export { resolveAuthProfileEligibility, resolveAuthProfileOrder } from "./auth-profiles/order.js"; export { diff --git a/src/agents/auth-profiles/external-cli-discovery.ts b/src/agents/auth-profiles/external-cli-discovery.ts new file mode 100644 index 00000000000..d8c0a48ec90 --- /dev/null +++ b/src/agents/auth-profiles/external-cli-discovery.ts @@ -0,0 +1,142 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + resolveExternalCliAuthScopeFromConfig, + type ExternalCliAuthScope, +} from "./external-cli-scope.js"; + +export type ExternalCliAuthDiscovery = + | { + mode: "none"; + allowKeychainPrompt?: false; + config?: OpenClawConfig; + } + | { + mode: "existing"; + allowKeychainPrompt?: boolean; + config?: OpenClawConfig; + } + | { + mode: "scoped"; + allowKeychainPrompt?: boolean; + config?: OpenClawConfig; + providerIds?: Iterable; + profileIds?: Iterable; + }; + +type ProviderAuthDiscoveryParams = { + cfg?: OpenClawConfig; + provider: string; + profileId?: string; + preferredProfile?: string; + allowKeychainPrompt?: boolean; +}; + +type ConfigStatusDiscoveryParams = { + cfg: OpenClawConfig; + allowKeychainPrompt?: false; +}; + +type ProviderSetDiscoveryParams = { + cfg?: OpenClawConfig; + providers: Iterable; + allowKeychainPrompt?: false; +}; + +function normalizeStringList(values: Iterable): string[] { + return [...values] + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)); +} + +export function externalCliDiscoveryNone(params?: { + config?: OpenClawConfig; +}): ExternalCliAuthDiscovery { + return { + mode: "none", + allowKeychainPrompt: false, + ...(params?.config ? { config: params.config } : {}), + }; +} + +export function externalCliDiscoveryExisting(params?: { + config?: OpenClawConfig; + allowKeychainPrompt?: boolean; +}): ExternalCliAuthDiscovery { + return { + mode: "existing", + ...(params?.allowKeychainPrompt !== undefined + ? { allowKeychainPrompt: params.allowKeychainPrompt } + : {}), + ...(params?.config ? { config: params.config } : {}), + }; +} + +export function externalCliDiscoveryScoped(params: { + config?: OpenClawConfig; + providerIds?: Iterable; + profileIds?: Iterable; + allowKeychainPrompt?: boolean; +}): ExternalCliAuthDiscovery { + return { + mode: "scoped", + ...(params.allowKeychainPrompt !== undefined + ? { allowKeychainPrompt: params.allowKeychainPrompt } + : {}), + ...(params.config ? { config: params.config } : {}), + ...(params.providerIds ? { providerIds: params.providerIds } : {}), + ...(params.profileIds ? { profileIds: params.profileIds } : {}), + }; +} + +export function externalCliDiscoveryForProviderAuth( + params: ProviderAuthDiscoveryParams, +): ExternalCliAuthDiscovery { + const profileIds = normalizeStringList([params.profileId, params.preferredProfile]); + return externalCliDiscoveryScoped({ + config: params.cfg, + allowKeychainPrompt: params.allowKeychainPrompt ?? false, + providerIds: [params.provider], + ...(profileIds.length > 0 ? { profileIds } : {}), + }); +} + +export function externalCliDiscoveryForConfigStatus( + params: ConfigStatusDiscoveryParams, +): ExternalCliAuthDiscovery { + const scope = resolveExternalCliAuthScopeFromConfig(params.cfg); + return externalCliDiscoveryFromScope({ + cfg: params.cfg, + scope, + allowKeychainPrompt: params.allowKeychainPrompt ?? false, + }); +} + +export function externalCliDiscoveryForProviders( + params: ProviderSetDiscoveryParams, +): ExternalCliAuthDiscovery { + const providers = normalizeStringList(params.providers); + if (providers.length === 0) { + return externalCliDiscoveryNone({ config: params.cfg }); + } + return externalCliDiscoveryScoped({ + config: params.cfg, + allowKeychainPrompt: params.allowKeychainPrompt ?? false, + providerIds: providers, + }); +} + +function externalCliDiscoveryFromScope(params: { + cfg: OpenClawConfig; + scope: ExternalCliAuthScope | undefined; + allowKeychainPrompt: false; +}): ExternalCliAuthDiscovery { + if (!params.scope) { + return externalCliDiscoveryNone({ config: params.cfg }); + } + return externalCliDiscoveryScoped({ + config: params.cfg, + allowKeychainPrompt: params.allowKeychainPrompt, + providerIds: params.scope.providerIds, + profileIds: params.scope.profileIds, + }); +} diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index 25599413cda..8484d62fcfe 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -10,6 +10,7 @@ import { log, } from "./constants.js"; import { overlayExternalAuthProfiles, shouldPersistExternalAuthProfile } from "./external-auth.js"; +import type { ExternalCliAuthDiscovery } from "./external-cli-discovery.js"; import { isSafeToAdoptMainStoreOAuthIdentity } from "./oauth-shared.js"; import { ensureAuthStoreFile, @@ -38,6 +39,7 @@ import type { AuthProfileStore } from "./types.js"; type LoadAuthProfileStoreOptions = { allowKeychainPrompt?: boolean; config?: OpenClawConfig; + externalCli?: ExternalCliAuthDiscovery; readOnly?: boolean; syncExternalCli?: boolean; externalCliProviderIds?: Iterable; @@ -49,6 +51,13 @@ type SaveAuthProfileStoreOptions = { syncExternalCli?: boolean; }; +type ResolvedExternalCliOverlayOptions = { + allowKeychainPrompt?: boolean; + config?: OpenClawConfig; + externalCliProviderIds?: Iterable; + externalCliProfileIds?: Iterable; +}; + const loadedAuthStoreCache = new Map< string, { @@ -183,6 +192,51 @@ function writeCachedAuthProfileStore(params: { }); } +function resolveExternalCliOverlayOptions( + options: LoadAuthProfileStoreOptions | undefined, +): ResolvedExternalCliOverlayOptions { + const discovery = options?.externalCli; + if (!discovery) { + return { + ...(options?.allowKeychainPrompt !== undefined + ? { allowKeychainPrompt: options.allowKeychainPrompt } + : {}), + ...(options?.config ? { config: options.config } : {}), + ...(options?.externalCliProviderIds + ? { externalCliProviderIds: options.externalCliProviderIds } + : {}), + ...(options?.externalCliProfileIds + ? { externalCliProfileIds: options.externalCliProfileIds } + : {}), + }; + } + if (discovery.mode === "none") { + const config = discovery.config ?? options?.config; + return { + allowKeychainPrompt: false, + ...(config ? { config } : {}), + externalCliProviderIds: [], + externalCliProfileIds: [], + }; + } + if (discovery.mode === "existing") { + const allowKeychainPrompt = discovery.allowKeychainPrompt ?? options?.allowKeychainPrompt; + const config = discovery.config ?? options?.config; + return { + ...(allowKeychainPrompt !== undefined ? { allowKeychainPrompt } : {}), + ...(config ? { config } : {}), + }; + } + const allowKeychainPrompt = discovery.allowKeychainPrompt ?? options?.allowKeychainPrompt; + const config = discovery.config ?? options?.config; + return { + ...(allowKeychainPrompt !== undefined ? { allowKeychainPrompt } : {}), + ...(config ? { config } : {}), + ...(discovery.providerIds ? { externalCliProviderIds: discovery.providerIds } : {}), + ...(discovery.profileIds ? { externalCliProfileIds: discovery.profileIds } : {}), + }; +} + function shouldKeepProfileInLocalStore(params: { store: AuthProfileStore; profileId: string; @@ -384,23 +438,18 @@ export function loadAuthProfileStoreForRuntime( const store = loadAuthProfileStoreForAgent(agentDir, options); const authPath = resolveAuthStorePath(agentDir); const mainAuthPath = resolveAuthStorePath(); + const externalCli = resolveExternalCliOverlayOptions(options); if (!agentDir || authPath === mainAuthPath) { return overlayExternalAuthProfiles(store, { agentDir, - allowKeychainPrompt: options?.allowKeychainPrompt, - config: options?.config, - externalCliProviderIds: options?.externalCliProviderIds, - externalCliProfileIds: options?.externalCliProfileIds, + ...externalCli, }); } const mainStore = loadAuthProfileStoreForAgent(undefined, options); return overlayExternalAuthProfiles(mergeAuthProfileStores(mainStore, store), { agentDir, - allowKeychainPrompt: options?.allowKeychainPrompt, - config: options?.config, - externalCliProviderIds: options?.externalCliProviderIds, - externalCliProfileIds: options?.externalCliProfileIds, + ...externalCli, }); } @@ -426,18 +475,17 @@ export function ensureAuthProfileStore( options?: { allowKeychainPrompt?: boolean; config?: OpenClawConfig; + externalCli?: ExternalCliAuthDiscovery; externalCliProviderIds?: Iterable; externalCliProfileIds?: Iterable; }, ): AuthProfileStore { + const externalCli = resolveExternalCliOverlayOptions(options); return overlayExternalAuthProfiles( ensureAuthProfileStoreWithoutExternalProfiles(agentDir, options), { agentDir, - allowKeychainPrompt: options?.allowKeychainPrompt, - config: options?.config, - externalCliProviderIds: options?.externalCliProviderIds, - externalCliProfileIds: options?.externalCliProfileIds, + ...externalCli, }, ); } diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 0b3625964f5..a51b76ec9d5 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -12,6 +12,7 @@ import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js"; import { resolveOpenClawAgentDir } from "../agent-paths.js"; import { resolveSessionAgentIds } from "../agent-scope.js"; +import { externalCliDiscoveryForProviderAuth } from "../auth-profiles/external-cli-discovery.js"; import { loadAuthProfileStoreForRuntime } from "../auth-profiles/store.js"; import type { AuthProfileCredential } from "../auth-profiles/types.js"; import { @@ -115,7 +116,10 @@ export async function prepareCliRunContext( if (effectiveAuthProfileId) { const authStore = loadAuthProfileStoreForRuntime(agentDir, { readOnly: true, - allowKeychainPrompt: false, + externalCli: externalCliDiscoveryForProviderAuth({ + provider: params.provider, + profileId: effectiveAuthProfileId, + }), }); authCredential = authStore.profiles[effectiveAuthProfileId]; } diff --git a/src/agents/codex-native-web-search-core.ts b/src/agents/codex-native-web-search-core.ts index bf79591dde4..bc2a281a591 100644 --- a/src/agents/codex-native-web-search-core.ts +++ b/src/agents/codex-native-web-search-core.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isRecord } from "../utils.js"; +import { externalCliDiscoveryForProviderAuth } from "./auth-profiles/external-cli-discovery.js"; import { listProfilesForProvider } from "./auth-profiles/profile-list.js"; import { ensureAuthProfileStore } from "./auth-profiles/store.js"; import { @@ -56,7 +57,15 @@ export function hasAvailableCodexAuth(params: { if (params.agentDir) { try { if ( - listProfilesForProvider(ensureAuthProfileStore(params.agentDir), "openai-codex").length > 0 + listProfilesForProvider( + ensureAuthProfileStore(params.agentDir, { + externalCli: externalCliDiscoveryForProviderAuth({ + cfg: params.config, + provider: "openai-codex", + }), + }), + "openai-codex", + ).length > 0 ) { return true; } diff --git a/src/agents/model-auth-label.ts b/src/agents/model-auth-label.ts index c0bb602a681..ba1991e6734 100644 --- a/src/agents/model-auth-label.ts +++ b/src/agents/model-auth-label.ts @@ -1,6 +1,7 @@ import type { SessionEntry } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { + externalCliDiscoveryForProviderAuth, ensureAuthProfileStore, loadAuthProfileStoreWithoutExternalProfiles, resolveAuthProfileDisplayLabel, @@ -28,7 +29,11 @@ export function resolveModelAuthLabel(params: { params.includeExternalProfiles === false ? loadAuthProfileStoreWithoutExternalProfiles(params.agentDir) : ensureAuthProfileStore(params.agentDir, { - allowKeychainPrompt: false, + externalCli: externalCliDiscoveryForProviderAuth({ + cfg: params.cfg, + provider: providerKey, + preferredProfile: params.sessionEntry?.authProfileOverride, + }), }); const profileOverride = params.sessionEntry?.authProfileOverride?.trim(); const order = resolveAuthProfileOrder({ diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 561d5e7da4c..233d748d8de 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -21,6 +21,7 @@ import { import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import { type AuthProfileStore, + externalCliDiscoveryForProviderAuth, ensureAuthProfileStore, listProfilesForProvider, resolveApiKeyForProfile, @@ -496,14 +497,8 @@ function resolveScopedAuthProfileStore(params: { profileId?: string; preferredProfile?: string; }): AuthProfileStore { - const profileIds = [params.profileId, params.preferredProfile] - .map((value) => value?.trim()) - .filter((value): value is string => Boolean(value)); return ensureAuthProfileStore(params.agentDir, { - allowKeychainPrompt: false, - config: params.cfg, - externalCliProviderIds: [params.provider], - ...(profileIds.length > 0 ? { externalCliProfileIds: profileIds } : {}), + externalCli: externalCliDiscoveryForProviderAuth(params), }); } diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index 402ff4f59fb..905089222e4 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -7,6 +7,7 @@ import { formatErrorMessage } from "../infra/errors.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { sanitizeForLog } from "../terminal/ansi.js"; +import { externalCliDiscoveryForProviders } from "./auth-profiles/external-cli-discovery.js"; import { hasAnyAuthProfileStoreSource } from "./auth-profiles/source-check.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; @@ -383,7 +384,10 @@ function resolveFallbackSoonestCooldownExpiry(params: { // cooldowns through a separate store instance while the fallback loop runs. const refreshedStore = params.authRuntime.loadAuthProfileStoreForRuntime(params.agentDir, { readOnly: true, - allowKeychainPrompt: false, + externalCli: externalCliDiscoveryForProviders({ + cfg: params.cfg, + providers: params.candidates.map((candidate) => candidate.provider), + }), }); let soonest: number | null = null; for (const candidate of params.candidates) { @@ -772,7 +776,12 @@ export async function runWithModelFallback(params: { ? await loadModelFallbackAuthRuntime() : null; const authStore = authRuntime - ? authRuntime.ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }) + ? authRuntime.ensureAuthProfileStore(params.agentDir, { + externalCli: externalCliDiscoveryForProviders({ + cfg: params.cfg, + providers: candidates.map((candidate) => candidate.provider), + }), + }) : null; const attempts: FallbackAttempt[] = []; let lastError: unknown; diff --git a/src/agents/model-provider-auth.ts b/src/agents/model-provider-auth.ts index 3ce649988e8..0b4ce41a54d 100644 --- a/src/agents/model-provider-auth.ts +++ b/src/agents/model-provider-auth.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { + externalCliDiscoveryForProviderAuth, ensureAuthProfileStore, listProfilesForProvider, type AuthProfileStore, @@ -29,7 +30,7 @@ export function hasAuthForModelProvider(params: { const store = params.store ?? ensureAuthProfileStore(params.agentDir, { - allowKeychainPrompt: false, + externalCli: externalCliDiscoveryForProviderAuth({ cfg: params.cfg, provider }), }); if (listProfilesForProvider(store, provider).length > 0) { return true; @@ -43,9 +44,6 @@ export function createProviderAuthChecker(params: { agentDir?: string; env?: NodeJS.ProcessEnv; }): (provider: string) => boolean { - const store = ensureAuthProfileStore(params.agentDir, { - allowKeychainPrompt: false, - }); const authCache = new Map(); return (provider: string) => { const key = normalizeProviderId(provider); @@ -59,7 +57,6 @@ export function createProviderAuthChecker(params: { workspaceDir: params.workspaceDir, agentDir: params.agentDir, env: params.env, - store, }); authCache.set(key, value); return value; diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 13e3008cf69..c179c327596 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -32,6 +32,7 @@ import { markAuthProfileGood, markAuthProfileUsed, } from "../auth-profiles.js"; +import { externalCliDiscoveryForProviderAuth } from "../auth-profiles/external-cli-discovery.js"; import { resolveSessionKeyForRequest, resolveStoredSessionKeyForSessionId, @@ -509,7 +510,11 @@ export async function runEmbeddedPiAgent( const authStore = pluginHarnessOwnsTransport ? createEmptyAuthProfileStore() : ensureAuthProfileStore(agentDir, { - allowKeychainPrompt: false, + externalCli: externalCliDiscoveryForProviderAuth({ + cfg: params.config, + provider, + preferredProfile: params.authProfileId, + }), }); const requestedProfileId = params.authProfileId?.trim(); const resolvePluginHarnessPreferredProfileId = (): string | undefined => { diff --git a/src/agents/tools/model-config.helpers.ts b/src/agents/tools/model-config.helpers.ts index b6f262860d1..5b963bfa76d 100644 --- a/src/agents/tools/model-config.helpers.ts +++ b/src/agents/tools/model-config.helpers.ts @@ -6,6 +6,7 @@ import { import type { AgentModelConfig } from "../../config/types.agents-shared.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { + externalCliDiscoveryForProviderAuth, ensureAuthProfileStore, hasAnyAuthProfileStoreSource, listProfilesForProvider, @@ -46,7 +47,7 @@ export function hasAuthForProvider(params: { provider: string; agentDir?: string return false; } const store = ensureAuthProfileStore(agentDir, { - allowKeychainPrompt: false, + externalCli: externalCliDiscoveryForProviderAuth({ provider: params.provider }), }); return listProfilesForProvider(store, params.provider).length > 0; } diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index afff24efe52..b7f9f77ad41 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -10,6 +10,10 @@ vi.mock("../../agents/auth-profiles.js", () => ({ clearRuntimeAuthProfileStoreSnapshots: () => { authProfilesStoreMock.profiles = {}; }, + externalCliDiscoveryForProviderAuth: () => ({ + mode: "scoped", + allowKeychainPrompt: false, + }), ensureAuthProfileStore: () => ({ version: 1, profiles: authProfilesStoreMock.profiles, @@ -501,6 +505,8 @@ describe("/model chat UX", () => { try { await withEnvAsync( { + ANTHROPIC_API_KEY: undefined, + ANTHROPIC_OAUTH_TOKEN: undefined, OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, OPENCLAW_STATE_DIR: stateDir, WORKSPACE_MODEL_CREDENTIALS: credentialPath, diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index 93ac81c3f96..ccf69d9a2de 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -30,6 +30,10 @@ const ensureAuthProfileStore = vi.hoisted(() => const listProfilesForProvider = vi.hoisted(() => vi.fn(() => [])); const upsertAuthProfile = vi.hoisted(() => vi.fn()); vi.mock("../agents/auth-profiles.js", () => ({ + externalCliDiscoveryForProviderAuth: () => ({ + mode: "scoped", + allowKeychainPrompt: false, + }), ensureAuthProfileStore, listProfilesForProvider, upsertAuthProfile, diff --git a/src/commands/models/auth-order.ts b/src/commands/models/auth-order.ts index 889110f9616..e7fde099609 100644 --- a/src/commands/models/auth-order.ts +++ b/src/commands/models/auth-order.ts @@ -1,6 +1,7 @@ import { resolveAgentDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { type AuthProfileStore, + externalCliDiscoveryForProviderAuth, ensureAuthProfileStore, resolveAuthStatePathForDisplay, setAuthProfileOrder, @@ -48,9 +49,9 @@ export async function modelsAuthOrderGetCommand( opts: { provider: string; agent?: string; json?: boolean }, runtime: RuntimeEnv, ) { - const { agentId, agentDir, provider } = await resolveAuthOrderContext(opts, runtime); + const { cfg, agentId, agentDir, provider } = await resolveAuthOrderContext(opts, runtime); const store = ensureAuthProfileStore(agentDir, { - allowKeychainPrompt: false, + externalCli: externalCliDiscoveryForProviderAuth({ cfg, provider }), }); const order = describeOrder(store, provider); @@ -94,10 +95,10 @@ export async function modelsAuthOrderSetCommand( opts: { provider: string; agent?: string; order: string[] }, runtime: RuntimeEnv, ) { - const { agentId, agentDir, provider } = await resolveAuthOrderContext(opts, runtime); + const { cfg, agentId, agentDir, provider } = await resolveAuthOrderContext(opts, runtime); const store = ensureAuthProfileStore(agentDir, { - allowKeychainPrompt: false, + externalCli: externalCliDiscoveryForProviderAuth({ cfg, provider }), }); const providerKey = provider; const requested = normalizeStringEntries(opts.order ?? []); diff --git a/src/commands/models/auth.test.ts b/src/commands/models/auth.test.ts index 778e93344f0..c370905a22a 100644 --- a/src/commands/models/auth.test.ts +++ b/src/commands/models/auth.test.ts @@ -367,7 +367,13 @@ describe("modelsAuthLoginCommand", () => { await modelsAuthLoginCommand({ provider: "openai-codex" }, runtime); - expect(mocks.loadAuthProfileStoreForRuntime).toHaveBeenCalledWith("/tmp/openclaw/agents/main"); + expect(mocks.loadAuthProfileStoreForRuntime).toHaveBeenCalledWith("/tmp/openclaw/agents/main", { + externalCli: { + mode: "scoped", + allowKeychainPrompt: false, + providerIds: ["openai-codex"], + }, + }); expect(mocks.clearAuthProfileCooldown).toHaveBeenCalledWith({ store: fakeStore, profileId: "openai-codex:user@example.com", @@ -418,7 +424,16 @@ describe("modelsAuthLoginCommand", () => { expect(mocks.resolveDefaultAgentId).not.toHaveBeenCalled(); expect(mocks.resolveAgentDir).toHaveBeenCalledWith(originalConfig, "coder"); - expect(mocks.loadAuthProfileStoreForRuntime).toHaveBeenCalledWith("/tmp/openclaw/agents/coder"); + expect(mocks.loadAuthProfileStoreForRuntime).toHaveBeenCalledWith( + "/tmp/openclaw/agents/coder", + { + externalCli: { + mode: "scoped", + allowKeychainPrompt: false, + providerIds: ["openai-codex"], + }, + }, + ); expect(runProviderAuth).toHaveBeenCalledWith( expect.objectContaining({ agentDir: "/tmp/openclaw/agents/coder", diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index 40a7ccd2351..112aaa5d3ed 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -10,6 +10,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId, } from "../../agents/agent-scope.js"; +import { externalCliDiscoveryForProviderAuth } from "../../agents/auth-profiles.js"; import { listProfilesForProvider, upsertAuthProfile } from "../../agents/auth-profiles/profiles.js"; import { loadAuthProfileStoreForRuntime } from "../../agents/auth-profiles/store.js"; import type { AuthProfileCredential } from "../../agents/auth-profiles/types.js"; @@ -559,7 +560,9 @@ type LoginOptions = { */ async function clearStaleProfileLockouts(provider: string, agentDir: string): Promise { try { - const store = loadAuthProfileStoreForRuntime(agentDir); + const store = loadAuthProfileStoreForRuntime(agentDir, { + externalCli: externalCliDiscoveryForProviderAuth({ provider }), + }); const profileIds = listProfilesForProvider(store, provider); for (const profileId of profileIds) { await clearAuthProfileCooldown({ store, profileId, agentDir }); diff --git a/src/commands/models/list.probe.targets.test.ts b/src/commands/models/list.probe.targets.test.ts index 8449b23791e..ddc984dc46b 100644 --- a/src/commands/models/list.probe.targets.test.ts +++ b/src/commands/models/list.probe.targets.test.ts @@ -74,6 +74,10 @@ vi.mock("./shared.js", () => ({ })); vi.mock("../../agents/auth-profiles.js", () => ({ + externalCliDiscoveryScoped: (params: Record = {}) => ({ + mode: "scoped", + ...params, + }), ensureAuthProfileStore: (agentDir?: string) => agentDir === "/tmp/coder-agent" && mockAgentStore ? mockAgentStore : mockStore, listProfilesForProvider: (store: AuthProfileStore, provider: string) => @@ -400,27 +404,29 @@ describe("buildProbeTargets reason codes", () => { order: {}, }; - const defaultPlan = await buildProbeTargets({ - cfg: {} as OpenClawConfig, - providers: ["anthropic"], - modelCandidates: ["anthropic/claude-sonnet-4-6"], - options: { - timeoutMs: 5_000, - concurrency: 1, - maxTokens: 16, - }, - }); - const agentPlan = await buildProbeTargets({ - cfg: {} as OpenClawConfig, - agentDir: "/tmp/coder-agent", - providers: ["anthropic"], - modelCandidates: ["anthropic/claude-sonnet-4-6"], - options: { - timeoutMs: 5_000, - concurrency: 1, - maxTokens: 16, - }, - }); + const { defaultPlan, agentPlan } = await withClearedAnthropicEnv(async () => ({ + defaultPlan: await buildProbeTargets({ + cfg: {} as OpenClawConfig, + providers: ["anthropic"], + modelCandidates: ["anthropic/claude-sonnet-4-6"], + options: { + timeoutMs: 5_000, + concurrency: 1, + maxTokens: 16, + }, + }), + agentPlan: await buildProbeTargets({ + cfg: {} as OpenClawConfig, + agentDir: "/tmp/coder-agent", + providers: ["anthropic"], + modelCandidates: ["anthropic/claude-sonnet-4-6"], + options: { + timeoutMs: 5_000, + concurrency: 1, + maxTokens: 16, + }, + }), + })); expect(defaultPlan.targets).toEqual([]); expect(agentPlan.results).toEqual([]); diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts index 1ebd9ae7f65..1bc2a965465 100644 --- a/src/commands/models/list.probe.ts +++ b/src/commands/models/list.probe.ts @@ -5,6 +5,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/ag import { type AuthProfileCredential, type AuthProfileEligibilityReasonCode, + externalCliDiscoveryScoped, ensureAuthProfileStore, listProfilesForProvider, resolveAuthProfileDisplayLabel, @@ -256,7 +257,14 @@ export async function buildProbeTargets(params: { options: AuthProbeOptions; }): Promise<{ targets: AuthProbeTarget[]; results: AuthProbeResult[] }> { const { cfg, agentDir, providers, modelCandidates, options, workspaceDir } = params; - const store = ensureAuthProfileStore(agentDir); + const store = ensureAuthProfileStore(agentDir, { + externalCli: externalCliDiscoveryScoped({ + config: cfg, + allowKeychainPrompt: false, + providerIds: providers, + profileIds: options.profileIds, + }), + }); const providerFilter = options.provider?.trim(); const providerFilterKey = providerFilter ? normalizeProviderId(providerFilter) : null; const profileFilter = new Set((options.profileIds ?? []).map((id) => id.trim()).filter(Boolean)); diff --git a/src/gateway/server-methods/models-auth-status.test.ts b/src/gateway/server-methods/models-auth-status.test.ts index 446709a9b64..445578c0286 100644 --- a/src/gateway/server-methods/models-auth-status.test.ts +++ b/src/gateway/server-methods/models-auth-status.test.ts @@ -24,9 +24,15 @@ vi.mock("../../agents/agent-paths.js", () => ({ resolveOpenClawAgentDir: mocks.resolveOpenClawAgentDir, })); -vi.mock("../../agents/auth-profiles.js", () => ({ - ensureAuthProfileStore: mocks.ensureAuthProfileStore, -})); +vi.mock("../../agents/auth-profiles.js", async () => { + const actual = await vi.importActual( + "../../agents/auth-profiles.js", + ); + return { + ...actual, + ensureAuthProfileStore: mocks.ensureAuthProfileStore, + }; +}); vi.mock("../../agents/auth-health.js", async () => { const actual = await vi.importActual( @@ -219,28 +225,31 @@ describe("models.authStatus", () => { expect(mocks.ensureAuthProfileStore).toHaveBeenCalledWith( "/tmp/agent", expect.objectContaining({ - allowKeychainPrompt: false, - config: expect.any(Object), - externalCliProviderIds: expect.arrayContaining(["opencode-go"]), - externalCliProfileIds: ["opencode-go:default"], + externalCli: expect.objectContaining({ + mode: "scoped", + allowKeychainPrompt: false, + config: expect.any(Object), + providerIds: expect.arrayContaining(["opencode-go"]), + profileIds: ["opencode-go:default"], + }), }), ); const [, options] = mocks.ensureAuthProfileStore.mock.calls[0] ?? []; - expect((options as { externalCliProviderIds?: string[] }).externalCliProviderIds).not.toContain( - "claude-cli", - ); + const externalCli = (options as { externalCli?: { providerIds?: string[] } }).externalCli; + expect(externalCli?.providerIds).not.toContain("claude-cli"); }); - it("keeps the auth store overlay unscoped when config has no provider signal", async () => { + it("disables external CLI auth overlays when config has no provider signal", async () => { await handler(createOptions()); expect(mocks.ensureAuthProfileStore).toHaveBeenCalledWith( "/tmp/agent", expect.objectContaining({ - allowKeychainPrompt: false, - config: expect.any(Object), - externalCliProviderIds: undefined, - externalCliProfileIds: undefined, + externalCli: expect.objectContaining({ + mode: "none", + allowKeychainPrompt: false, + config: expect.any(Object), + }), }), ); }); diff --git a/src/gateway/server-methods/models-auth-status.ts b/src/gateway/server-methods/models-auth-status.ts index 1017bfaed54..e7533872824 100644 --- a/src/gateway/server-methods/models-auth-status.ts +++ b/src/gateway/server-methods/models-auth-status.ts @@ -7,8 +7,10 @@ import { buildAuthHealthSummary, formatRemainingShort, } from "../../agents/auth-health.js"; -import { ensureAuthProfileStore } from "../../agents/auth-profiles.js"; -import { resolveExternalCliAuthScopeFromConfig } from "../../agents/auth-profiles/external-cli-scope.js"; +import { + ensureAuthProfileStore, + externalCliDiscoveryForConfigStatus, +} from "../../agents/auth-profiles.js"; import { normalizeProviderId } from "../../agents/provider-id.js"; import type { OpenClawConfig } from "../../config/config.js"; import { isSecretRef } from "../../config/types.secrets.js"; @@ -293,12 +295,8 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = { try { const cfg = context.getRuntimeConfig(); const agentDir = resolveOpenClawAgentDir(); - const externalCliAuthScope = resolveExternalCliAuthScopeFromConfig(cfg); const store = ensureAuthProfileStore(agentDir, { - allowKeychainPrompt: false, - config: cfg, - externalCliProviderIds: externalCliAuthScope?.providerIds, - externalCliProfileIds: externalCliAuthScope?.profileIds, + externalCli: externalCliDiscoveryForConfigStatus({ cfg }), }); const configured = resolveConfiguredProviders(cfg); const authHealth: AuthHealthSummary = buildAuthHealthSummary({