diff --git a/src/agents/embedded-agent-runner/run/assistant-failover.ts b/src/agents/embedded-agent-runner/run/assistant-failover.ts index 6d199af365c1..d178a68a20cf 100644 --- a/src/agents/embedded-agent-runner/run/assistant-failover.ts +++ b/src/agents/embedded-agent-runner/run/assistant-failover.ts @@ -213,7 +213,14 @@ export async function handleAssistantFailover(params: { const markFailedProfilePromise = markFailedProfile(); if (timeoutFailure && !params.isProbeSession && failedProfileId) { const timeoutLabel = terminal.idleTimedOut ? "idle timeout (model silent)" : "timed out"; - params.warn(`Profile ${failedProfileId} ${timeoutLabel}. Trying next account...`); + // Only promise a next account when one was actually selected. Credentials + // that config does not authorize are not rotation targets, so this can end + // with no further account even when one exists in the environment. + params.warn( + rotated + ? `Profile ${failedProfileId} ${timeoutLabel}. Trying next account...` + : `Profile ${failedProfileId} ${timeoutLabel}. No further authorized account for this provider; create a backup auth profile and add its id to auth.order to enable failover.`, + ); } if (params.cloudCodeAssistFormatError && failedProfileId) { params.warn( diff --git a/src/agents/model-auth-availability.test.ts b/src/agents/model-auth-availability.test.ts index 51b4c4d5b1c6..dc5804c61ccb 100644 --- a/src/agents/model-auth-availability.test.ts +++ b/src/agents/model-auth-availability.test.ts @@ -499,14 +499,18 @@ describe("createModelAuthAvailabilityResolver", () => { route: subscriptionRoute, mode: "oauth", }, - ])("selects $label", ({ cfg, env, mode, profile, profileId, route }) => { - expect(evaluate({ cfg, env, store: authStore({ [profileId]: profile }) })).toMatchObject({ - availability: true, - evidence: "environment", - selectedAuthMode: mode, - selectedRoute: route, - }); - }); + // An environment credential named nowhere in config is not authorized to + // stand in for a declared profile, including a declared profile that turned + // out to be unusable. Availability mirrors the runtime rule here so status + // does not advertise a credential the run would refuse to use. + ])( + "reports $label unavailable rather than substituting an ambient credential", + ({ cfg, env, profile, profileId }) => { + expect(evaluate({ cfg, env, store: authStore({ [profileId]: profile }) })).toMatchObject({ + availability: false, + }); + }, + ); it.each([ { env: { OPENAI_API_KEY: "resolved-key" }, availability: true }, diff --git a/src/agents/model-auth-availability.ts b/src/agents/model-auth-availability.ts index f37de0accd30..e7df215cd3cb 100644 --- a/src/agents/model-auth-availability.ts +++ b/src/agents/model-auth-availability.ts @@ -55,6 +55,7 @@ import { buildProviderModelAuthSourcePlan, fromProviderModelAuthReadiness, toProviderModelAuthReadiness, + type ProviderModelAuthAuthorization, type ProviderModelAuthEvidence, type ProviderModelAuthProfileSource, } from "./provider-model-auth-source-plan.js"; @@ -611,11 +612,15 @@ export function createModelAuthAvailabilityResolver( selectedAuthMode: configured?.auth, }; }; - const directSource = (evaluation: AuthSourceEvaluation) => + const directSource = ( + evaluation: AuthSourceEvaluation, + authorization: ProviderModelAuthAuthorization = "declared", + ) => buildProviderModelAuthDirectSource({ mode: evaluation.selectedAuthMode, availability: evaluation.availability, evidence: evaluation.evidence ?? "none", + authorization, }); const automaticProfileSource = ( provider: string, @@ -677,14 +682,28 @@ export function createModelAuthAvailabilityResolver( (hasDirectMaterial && shouldPreferExplicitConfigApiKeyAuth(params.cfg, provider)); const environment = envAuth(provider); const environmentMode = environment ? (configured?.auth ?? environment.mode) : undefined; + // Mirrors the runtime classification in runtime-plan/prepare-auth.ts: a + // credential is ambient only when it came from the environment and the + // provider entry declares no apiKey material pointing at it. Availability + // and runtime must agree, or status advertises a credential the run will + // refuse (or the reverse). + const ambientEnvironmentCredential = + !required && environmentMode !== undefined && environmentMode !== "aws-sdk" + ? !hasDirectMaterial + : false; const direct = !required && environmentMode ? buildProviderModelAuthDirectSource({ mode: environmentMode, availability: modeAllowed(provider, target, environmentMode), evidence: environmentMode === "aws-sdk" ? "aws-sdk" : "environment", + authorization: ambientEnvironmentCredential ? "ambient" : "declared", }) - : directSource(unprofiledEvaluation(provider, target)); + : ((evaluation) => + directSource( + evaluation, + evaluation.evidence === "environment" && !hasDirectMaterial ? "ambient" : "declared", + ))(unprofiledEvaluation(provider, target)); const hasDirectFallback = hasDirectMaterial || direct.evidence !== "none"; return { binding, diff --git a/src/agents/provider-model-auth-source-plan.ts b/src/agents/provider-model-auth-source-plan.ts index 4d40fb3202ce..190dfbd23e7f 100644 --- a/src/agents/provider-model-auth-source-plan.ts +++ b/src/agents/provider-model-auth-source-plan.ts @@ -18,11 +18,24 @@ export type ProviderModelAuthProfileSource = { cooldown: "active" | "clear"; }; +/** + * Whether config authorizes this credential, as opposed to where it was found. + * + * `evidence` is provenance and is reported as such by status/probe surfaces; it + * cannot carry authorization, because a *declared* credential can legitimately + * be discovered in the environment (a `${VAR}` marker or a SecretRef naming a + * canonical variable). `"ambient"` means the opposite: the credential appears in + * neither the provider entry nor `auth.profiles`/`auth.order`, so nothing in + * config points at it and it may bill an account the operator never named here. + */ +export type ProviderModelAuthAuthorization = "declared" | "ambient"; + export type ProviderModelAuthDirectSource = { kind: "direct"; mode?: string; readiness: ProviderModelAuthReadiness; evidence: ProviderModelAuthEvidence; + authorization: ProviderModelAuthAuthorization; }; export type ProviderModelAuthSource = @@ -61,6 +74,14 @@ export type ProviderModelAuthSourcePlan = orderedProfiles: readonly ProviderModelAuthProfileSource[]; allowCooldown: boolean; fallback?: ProviderModelAuthDirectSource; + /** + * How many profiles the operator declared for this provider, before any + * readiness, cooldown or route-compatibility filtering. Route filtering + * rebuilds the plan from a narrowed profile list, so `profiles.kind` alone + * cannot distinguish "operator declared nothing" (zero-config) from + * "everything the operator declared was filtered out". + */ + declaredProfileCount: number; }; export function toProviderModelAuthReadiness( @@ -80,12 +101,19 @@ export function buildProviderModelAuthDirectSource(params: { mode?: string; availability?: boolean; evidence: ProviderModelAuthEvidence; + /** + * Required, not defaulted: a permissive default would silently give every + * unaudited construction site full standing, which is exactly how a source + * escapes the ambient-credential rule. Make each caller state it. + */ + authorization: ProviderModelAuthAuthorization; }): ProviderModelAuthDirectSource { return { kind: "direct", mode: params.mode, readiness: toProviderModelAuthReadiness(params.availability), evidence: params.evidence, + authorization: params.authorization, }; } @@ -113,6 +141,8 @@ export function buildProviderModelAuthSourcePlan(params: { explicitOrder?: boolean; fallback?: ProviderModelAuthDirectSource; allowCooldown?: boolean; + /** Overrides the declared count when rebuilding a plan from filtered profiles. */ + declaredProfileCount?: number; }): ProviderModelAuthSourcePlan { if (params.ownership) { return { kind: "required", ...params.ownership }; @@ -148,6 +178,7 @@ export function buildProviderModelAuthSourcePlan(params: { profiles, orderedProfiles: ordered, allowCooldown: params.allowCooldown === true, + declaredProfileCount: params.declaredProfileCount ?? ordered.length, ...(params.fallback ? { fallback: params.fallback } : {}), }; } diff --git a/src/agents/provider-model-route-auth.test.ts b/src/agents/provider-model-route-auth.test.ts index c3f8cf6b06f9..90163e10d3c7 100644 --- a/src/agents/provider-model-route-auth.test.ts +++ b/src/agents/provider-model-route-auth.test.ts @@ -6,6 +6,7 @@ import { } from "./provider-model-auth-source-plan.js"; import { resolveProviderModelRouteMaterializationAuthMode, + selectProviderModelAuthSources, selectProviderModelRouteAuth, } from "./provider-model-route-auth.js"; @@ -38,12 +39,16 @@ function profile( return { kind: "profile", profileId, mode, readiness, cooldown }; } -function direct(mode: string): ProviderModelAuthDirectSource { +function direct( + mode: string, + authorization: ProviderModelAuthDirectSource["authorization"] = "declared", +): ProviderModelAuthDirectSource { return { kind: "direct", mode, readiness: "ready", evidence: "provider-config", + authorization, }; } @@ -406,3 +411,69 @@ describe("provider model route auth", () => { }); }); }); + +describe("ambient credential admission", () => { + const ambient = (mode: string): ProviderModelAuthDirectSource => direct(mode, "ambient"); + const ready = (profileId: string, mode: string) => profile(profileId, mode, "ready"); + const select = (plan: Parameters[0]["plan"]) => + selectProviderModelAuthSources({ provider: "openai", plan }); + + it("drops an ambient fallback queued behind usable profiles", () => { + const decision = select( + buildProviderModelAuthSourcePlan({ + profiles: [ready("openai:chatgpt", "oauth")], + fallback: ambient("api-key"), + }), + ); + + expect(decision).toMatchObject({ kind: "selected" }); + expect(decision.kind === "selected" && decision.attempts).toMatchObject([{ kind: "profile" }]); + }); + + it("keeps a declared fallback queued behind usable profiles", () => { + const decision = select( + buildProviderModelAuthSourcePlan({ + profiles: [ready("openai:chatgpt", "oauth")], + fallback: direct("api-key"), + }), + ); + + expect(decision.kind === "selected" && decision.attempts).toMatchObject([ + { kind: "profile" }, + { kind: "direct" }, + ]); + }); + + it("keeps an ambient fallback when the provider declares no profiles", () => { + const decision = select( + buildProviderModelAuthSourcePlan({ profiles: [], fallback: ambient("api-key") }), + ); + + expect(decision.kind === "selected" && decision.attempts).toMatchObject([{ kind: "direct" }]); + }); + + it("does not substitute an ambient fallback for all-unavailable profiles", () => { + const decision = select( + buildProviderModelAuthSourcePlan({ + profiles: [profile("openai:chatgpt", "oauth", "unavailable")], + fallback: ambient("api-key"), + }), + ); + + expect(decision.kind === "selected" && decision.attempts).toMatchObject([]); + }); + + it("does not re-admit an ambient fallback when route filtering empties the profile list", () => { + // Rebuild shape used by selectProviderModelRouteAuth when narrowing to a + // route-compatible subset: no profiles survive, but the operator declared one. + const decision = select( + buildProviderModelAuthSourcePlan({ + profiles: [], + declaredProfileCount: 1, + fallback: ambient("api-key"), + }), + ); + + expect(decision.kind === "selected" && decision.attempts).toMatchObject([]); + }); +}); diff --git a/src/agents/provider-model-route-auth.ts b/src/agents/provider-model-route-auth.ts index 1e4f1d46e37b..4125479207e0 100644 --- a/src/agents/provider-model-route-auth.ts +++ b/src/agents/provider-model-route-auth.ts @@ -178,6 +178,22 @@ export function selectProviderModelAuthSources(params: { ...(profiles.kind === "all-unavailable" ? { source: profiles.first } : {}), }; } + // An ambient credential — one config names nowhere — may serve a provider the + // operator left entirely unconfigured (the documented zero-config + // `PROVIDER_API_KEY` path), but it must never *succeed* a credential the + // operator did declare. Those can bill different accounts, so that transition + // needs a declaration, not a discovery. `auth.order` filtering already refuses + // to silently try a declared profile the operator omitted from an explicit + // order (docs/auth-credential-semantics.md, "Explicit auth order filtering"); + // an undeclared credential cannot rank above that. + // + // `declaredProfileCount` rather than `profiles.kind`: route filtering rebuilds + // this plan from a narrowed profile list, so an operator who declared only + // route-incompatible profiles must not be treated as zero-config. + const authorizedFallback = + fallback?.authorization === "ambient" && params.plan.declaredProfileCount > 0 + ? undefined + : fallback; if (profiles.kind === "usable") { const winner = selectReadyProfile(profiles.profiles); return { @@ -185,15 +201,15 @@ export function selectProviderModelAuthSources(params: { selection: winner ? { kind: "selected", source: winner } : { kind: "none" }, attempts: [ ...profiles.profiles.map((source) => ({ kind: "profile" as const, source })), - ...(fallback ? [directAttempt(fallback)] : []), + ...(authorizedFallback ? [directAttempt(authorizedFallback)] : []), ], }; } - if (fallback) { + if (authorizedFallback) { return { kind: "selected", - selection: { kind: "selected", source: fallback }, - attempts: [directAttempt(fallback)], + selection: { kind: "selected", source: authorizedFallback }, + attempts: [directAttempt(authorizedFallback)], }; } return { @@ -306,6 +322,10 @@ export function selectProviderModelRouteAuth(params: { ), explicitOrder: params.sourcePlan.profiles.explicitOrder, allowCooldown: params.sourcePlan.allowCooldown, + // Preserve what the operator actually declared. Filtering to a + // route-compatible subset must not make a configured provider look + // zero-config and thereby re-admit an ambient credential. + declaredProfileCount: params.sourcePlan.declaredProfileCount, ...(params.sourcePlan.fallback ? { fallback: params.sourcePlan.fallback } : {}), }) : params.sourcePlan; diff --git a/src/agents/runtime-plan/prepare-auth.ambient-credential.test.ts b/src/agents/runtime-plan/prepare-auth.ambient-credential.test.ts new file mode 100644 index 000000000000..7d4846257c9b --- /dev/null +++ b/src/agents/runtime-plan/prepare-auth.ambient-credential.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { AuthProfileStore } from "../auth-profiles.js"; +import { prepareAgentRuntimeAuth } from "./prepare-auth.js"; + +vi.mock("../../plugins/provider-runtime.js", () => ({ + buildProviderMissingAuthMessageWithPlugin: () => undefined, + resolveProviderSyntheticAuthWithPlugin: () => undefined, + shouldDeferProviderSyntheticProfileAuthWithPlugin: () => undefined, +})); + +// Regression coverage for #117956: metered API usage on a route whose only +// declared credential was a subscription auth profile. +// +// Reported shape: the `anthropic` plugin disabled, so `claude-cli` resolves as a +// bare custom provider entry (no `api`, no `baseUrl`, no `apiKey`); a single +// subscription OAuth profile; no `auth.order`; and `ANTHROPIC_API_KEY` present +// in the process environment for other, separately-named consumers. The +// environment credential was appended as a second physical attempt and became +// the live credential once the run rotated off the profile. +const oauthStore = (expires: number): AuthProfileStore => ({ + version: 1, + profiles: { + "anthropic:claude-cli": { + type: "oauth", + provider: "claude-cli", + access: "subscription-token", + refresh: "refresh-token", + expires, + }, + } as unknown as AuthProfileStore["profiles"], +}); + +const config = { + plugins: { entries: { anthropic: { enabled: false } } }, + models: { + providers: { "claude-cli": { models: [{ id: "claude-fable-5" }] } }, + }, + agents: { + defaults: { + model: { primary: "claude-cli/claude-fable-5" }, + models: { "claude-cli/claude-fable-5": { alias: "fable5" } }, + }, + }, +} as unknown as OpenClawConfig; + +const ambientEnv = { ANTHROPIC_API_KEY: "ambient-anthropic-key" } as NodeJS.ProcessEnv; + +describe("ambient provider credentials are not queued behind a declared profile", () => { + it.each([undefined, "anthropic-messages"])("modelApi=%s", (modelApi) => { + const prepared = prepareAgentRuntimeAuth({ + provider: "claude-cli", + modelId: "claude-fable-5", + ...(modelApi ? { modelApi } : {}), + config, + env: ambientEnv, + authProfileStore: oauthStore(Date.now() + 3_600_000), + }); + + expect(prepared.attempts).toMatchObject([ + { kind: "profile", profileId: "anthropic:claude-cli" }, + ]); + expect(prepared.attempts.some((attempt) => attempt.kind === "direct")).toBe(false); + }); + + // Being unable to use what was declared is not authorization to use what was + // not: an unusable profile must surface rather than silently move the run to + // an account that appears nowhere in config. + it("does not substitute an ambient credential for an unusable declared profile", () => { + const prepared = prepareAgentRuntimeAuth({ + provider: "claude-cli", + modelId: "claude-fable-5", + config, + env: ambientEnv, + authProfileStore: oauthStore(0), + }); + + expect(prepared.attempts.some((attempt) => attempt.kind === "direct")).toBe(false); + }); + + // Documented `auth.order` failover is unaffected: this change gates ambient + // credentials only, never declared profiles. Every profile named in the + // explicit order stays in the attempt list, in the order given, even when an + // ambient credential is present in the environment. + it("preserves declared auth.order failover across multiple profiles", () => { + const prepared = prepareAgentRuntimeAuth({ + provider: "openai", + modelId: "gpt-5.5", + config: { + auth: { order: { openai: ["openai:primary", "openai:backup"] } }, + } as OpenClawConfig, + env: { OPENAI_API_KEY: "ambient-platform-key" } as NodeJS.ProcessEnv, + authProfileStore: { + version: 1, + profiles: { + "openai:primary": { + type: "oauth", + provider: "openai", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 600_000, + }, + "openai:backup": { + type: "oauth", + provider: "openai", + access: "backup-token", + refresh: "backup-refresh", + expires: Date.now() + 600_000, + }, + }, + order: { openai: ["openai:primary", "openai:backup"] }, + } as unknown as AuthProfileStore, + }); + + expect( + prepared.attempts.map((attempt) => + attempt.kind === "profile" ? attempt.profileId : "direct", + ), + ).toEqual(["openai:primary", "openai:backup"]); + }); + + // The documented remedy for operators who relied on an ambient key as a + // backup: declare an API-key profile and order it after the subscription + // profile (docs/concepts/model-failover.md). That path must keep working. + it("preserves a declared API-key backup ordered after a subscription profile", () => { + const prepared = prepareAgentRuntimeAuth({ + provider: "openai", + modelId: "gpt-5.5", + config: { + auth: { order: { openai: ["openai:chatgpt", "openai:platform"] } }, + } as OpenClawConfig, + env: { OPENAI_API_KEY: "ambient-platform-key" } as NodeJS.ProcessEnv, + authProfileStore: { + version: 1, + profiles: { + "openai:chatgpt": { + type: "oauth", + provider: "openai", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 600_000, + }, + "openai:platform": { + type: "api_key", + provider: "openai", + key: "declared-platform-key", + }, + }, + order: { openai: ["openai:chatgpt", "openai:platform"] }, + } as unknown as AuthProfileStore, + }); + + expect(prepared.attempts.map((a) => (a.kind === "profile" ? a.profileId : "direct"))).toEqual([ + "openai:chatgpt", + "openai:platform", + ]); + }); +}); diff --git a/src/agents/runtime-plan/prepare-auth.test.ts b/src/agents/runtime-plan/prepare-auth.test.ts index 3426447a739c..038b8c23160e 100644 --- a/src/agents/runtime-plan/prepare-auth.test.ts +++ b/src/agents/runtime-plan/prepare-auth.test.ts @@ -1290,9 +1290,51 @@ describe("prepareAgentRuntimeAuthPlan", () => { ).toBe(true); }); + // Zero-config still works: when a provider has no usable auth profile at all, + // a bare `PROVIDER_API_KEY` remains the credential for the route. Refusing an + // undeclared credential is about not letting it silently *succeed a declared + // profile*, not about banning the documented zero-config path. + it("still uses an undeclared env key when the provider has no usable profile", () => { + const prepared = prepareAgentRuntimeAuth({ + provider: "openai", + modelId: "gpt-5.5", + env: { OPENAI_API_KEY: "ambient-platform-key" }, + authProfileStore: authStore({}), + }); + + expect(prepared.attempts).toMatchObject([{ kind: "direct" }]); + expect(prepared.attempts.some((attempt) => attempt.kind === "profile")).toBe(false); + }); + + // Declared apiKey material keeps normal direct-source standing, so the + // narrowing is scoped to credentials that appear nowhere in config. + it("still routes a declared provider apiKey with no profiles present", () => { + const prepared = prepareAgentRuntimeAuth({ + provider: "openai", + modelId: "gpt-5.5", + config: { + models: { + providers: { openai: { apiKey: "configured-platform-key", baseUrl: "", models: [] } }, + }, + } as OpenClawConfig, + env: {}, + authProfileStore: authStore({}), + }); + + expect(prepared.attempts).toMatchObject([{ kind: "direct" }]); + }); + + // An environment credential named nowhere in config is not an authorized + // route. `auth.order` filtering already refuses to silently try a *stored* + // profile the operator omitted from the explicit order + // (docs/auth-credential-semantics.md, "Explicit auth order filtering"), and + // docs/providers/openai.md reserves bare `OPENAI_API_KEY` for non-agent + // surfaces. An undeclared env key must therefore not be queued behind a + // declared profile, where it would silently absorb that profile's failures — + // potentially onto a different billing account. it.each([ { - label: "OAuth profile then ambient Platform key", + label: "ambient Platform key behind an OAuth profile", env: { OPENAI_API_KEY: "ambient-platform-key" }, profileId: "openai:chatgpt", profile: { @@ -1302,10 +1344,10 @@ describe("prepareAgentRuntimeAuthPlan", () => { refresh: "refresh-token", expires: Date.now() + 60_000, }, - requirements: ["subscription", "api-key"], + requirements: ["subscription"], }, { - label: "Platform profile then ambient OAuth token", + label: "ambient OAuth token behind a Platform profile", config: { models: { providers: { openai: { auth: "oauth", baseUrl: "", models: [] } } }, } as OpenClawConfig, @@ -1316,32 +1358,23 @@ describe("prepareAgentRuntimeAuthPlan", () => { provider: "openai", key: "profile-platform-key", }, - requirements: ["api-key", "subscription"], + requirements: ["api-key"], }, - ])( - "prepares $label as distinct physical attempts", - ({ config, env, profile, profileId, requirements }) => { - const prepared = prepareAgentRuntimeAuth({ - provider: "openai", - modelId: "gpt-5.5", - config, - env, - authProfileStore: authStore({ [profileId]: profile }, { openai: [profileId] }), - }); + ])("does not queue $label", ({ config, env, profile, profileId, requirements }) => { + const prepared = prepareAgentRuntimeAuth({ + provider: "openai", + modelId: "gpt-5.5", + config, + env, + authProfileStore: authStore({ [profileId]: profile }, { openai: [profileId] }), + }); - expect(prepared.attempts.map((attempt) => attempt.plan.modelRoute?.authRequirement)).toEqual( - requirements, - ); - expect(prepared.attempts).toMatchObject([ - { kind: "profile", profileId }, - { - kind: "direct", - allowAuthProfileFallback: false, - requiresPriorProfileAttempt: true, - }, - ]); - }, - ); + expect(prepared.attempts.map((attempt) => attempt.plan.modelRoute?.authRequirement)).toEqual( + requirements, + ); + expect(prepared.attempts).toMatchObject([{ kind: "profile", profileId }]); + expect(prepared.attempts.some((attempt) => attempt.kind === "direct")).toBe(false); + }); it("resolves an env SecretRef on its prepared Platform route", async () => { vi.stubEnv("OPENAI_PLATFORM_KEY", "secret-ref-platform-key"); diff --git a/src/agents/runtime-plan/prepare-auth.ts b/src/agents/runtime-plan/prepare-auth.ts index 7d23cb93fc3e..08c7cbe4c5bd 100644 --- a/src/agents/runtime-plan/prepare-auth.ts +++ b/src/agents/runtime-plan/prepare-auth.ts @@ -339,7 +339,8 @@ export function prepareAgentRuntimeAuth( ? "runtime" : "provider-config", availability?: boolean, - ) => buildProviderModelAuthDirectSource({ mode, evidence, availability }); + authorization: ProviderModelAuthDirectSource["authorization"] = "declared", + ) => buildProviderModelAuthDirectSource({ mode, evidence, availability, authorization }); const directPlanningCandidate = harnessAllowsAuthProfileForwarding ? resolveProviderDirectAuthPlanningEvidence( authProfileSelectionProvider, @@ -360,11 +361,19 @@ export function prepareAgentRuntimeAuth( const directPlanningMode = directPlanningEvidence ? (configuredAuthMode ?? directPlanningEvidence.mode) : undefined; + // Provenance ("where was it found") is not authorization ("may it be used + // here"). A credential found in the environment is still *declared* when the + // provider entry points at it — a literal apiKey, a `${VAR}` marker, or a + // SecretRef naming a canonical variable. Only a credential that nothing in + // config references is ambient, and only ambient credentials are restricted. + const fallbackIsAmbientCredential = + directPlanningEvidence?.kind === "environment" && !providerHasDirectMaterial; const fallbackDirectSource = directPlanningMode ? directSource( directPlanningMode, directPlanningEvidence?.kind === "environment" ? "environment" : "runtime", directPlanningEvidence?.kind === "environment" ? true : undefined, + fallbackIsAmbientCredential ? "ambient" : "declared", ) : providerBindingNeedsNonProfileFallback ? directSource(selectedConfiguredAuthMode) diff --git a/src/agents/simple-completion-runtime.ts b/src/agents/simple-completion-runtime.ts index 672db1b2e29d..f1650085d133 100644 --- a/src/agents/simple-completion-runtime.ts +++ b/src/agents/simple-completion-runtime.ts @@ -331,6 +331,10 @@ export async function prepareSimpleCompletionModel(params: { mode: auth.mode, availability: true, evidence: "runtime", + // The credential is already resolved by the caller and wrapped as + // required provider-binding ownership below, so it is not an + // ambient discovery competing with declared profiles. + authorization: "declared", }); const routeAuthDecision = selectOpenAIModelRouteAuth({ resolution: routeResolution,