[codex] Make external CLI credential discovery explicit (#75209)

* refactor(auth): make external CLI discovery explicit

* test(auth): update external cli discovery mocks

* test(auth): cover scoped external cli auth mocks

* [codex] Make external CLI credential discovery explicit

---------

Co-authored-by: clawsweeper-repair <clawsweeper-repair@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-04-30 21:32:55 +01:00
committed by GitHub
parent bb3a0c9545
commit 90419df663
23 changed files with 365 additions and 82 deletions

View File

@@ -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.

View File

@@ -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", () => {

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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<string>;
profileIds?: Iterable<string>;
};
type ProviderAuthDiscoveryParams = {
cfg?: OpenClawConfig;
provider: string;
profileId?: string;
preferredProfile?: string;
allowKeychainPrompt?: boolean;
};
type ConfigStatusDiscoveryParams = {
cfg: OpenClawConfig;
allowKeychainPrompt?: false;
};
type ProviderSetDiscoveryParams = {
cfg?: OpenClawConfig;
providers: Iterable<string>;
allowKeychainPrompt?: false;
};
function normalizeStringList(values: Iterable<string | undefined>): 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<string>;
profileIds?: Iterable<string>;
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,
});
}

View File

@@ -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<string>;
@@ -49,6 +51,13 @@ type SaveAuthProfileStoreOptions = {
syncExternalCli?: boolean;
};
type ResolvedExternalCliOverlayOptions = {
allowKeychainPrompt?: boolean;
config?: OpenClawConfig;
externalCliProviderIds?: Iterable<string>;
externalCliProfileIds?: Iterable<string>;
};
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<string>;
externalCliProfileIds?: Iterable<string>;
},
): AuthProfileStore {
const externalCli = resolveExternalCliOverlayOptions(options);
return overlayExternalAuthProfiles(
ensureAuthProfileStoreWithoutExternalProfiles(agentDir, options),
{
agentDir,
allowKeychainPrompt: options?.allowKeychainPrompt,
config: options?.config,
externalCliProviderIds: options?.externalCliProviderIds,
externalCliProfileIds: options?.externalCliProfileIds,
...externalCli,
},
);
}

View File

@@ -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];
}

View File

@@ -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;
}

View File

@@ -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({

View File

@@ -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),
});
}

View File

@@ -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<T>(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;

View File

@@ -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<string, boolean>();
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;

View File

@@ -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 => {

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 ?? []);

View File

@@ -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",

View File

@@ -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<void> {
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 });

View File

@@ -74,6 +74,10 @@ vi.mock("./shared.js", () => ({
}));
vi.mock("../../agents/auth-profiles.js", () => ({
externalCliDiscoveryScoped: (params: Record<string, unknown> = {}) => ({
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([]);

View File

@@ -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));

View File

@@ -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<typeof import("../../agents/auth-profiles.js")>(
"../../agents/auth-profiles.js",
);
return {
...actual,
ensureAuthProfileStore: mocks.ensureAuthProfileStore,
};
});
vi.mock("../../agents/auth-health.js", async () => {
const actual = await vi.importActual<typeof import("../../agents/auth-health.js")>(
@@ -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),
}),
}),
);
});

View File

@@ -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({