Files
openclaw/src/agents/provider-model-auth-source-plan.ts
Marvinthebored 778be6f5cc fix(auth): do not admit ambient environment credentials behind declared profiles (#118458)
A provider credential present only in the process environment — named in
neither the provider entry, `auth.profiles`, nor `auth.order` — was appended to
the auth attempt list behind the operator's declared profiles. A run that left
a declared profile could therefore continue on an undeclared credential, which
may bill a different account, with no configuration authorizing the transition
and nothing reporting that it happened.

Add an explicit `authorization: "declared" | "ambient"` fact on direct auth
sources and enforce it during source selection. The field is required rather
than defaulted so every construction site is audited; `evidence` is left as
provenance, since a declared credential can legitimately be environment-sourced
via a `${VAR}` marker or a SecretRef.

An ambient credential may still serve a provider with no declared profiles (the
documented zero-config `PROVIDER_API_KEY` path), but it is no longer admitted
behind declared profiles, nor substituted for declared profiles that turned out
to be unusable. `auth.order` failover is unaffected: profiles are never filtered
by this change. The read-only availability evaluator applies the same rule so
status cannot advertise a credential the runtime refuses.

This restores the invariant that held before 1b1cebfe42 (#104685), where an
environment candidate was reachable only when a provider had no declared
profiles. The regression shipped in 2026.7.2-beta.1 onwards; no GA release is
affected.

Reported in #117956.


Claude-Session: https://claude.ai/code/session_01Nwk2KaB6zL71xa1i3Xwgwi

Co-authored-by: Marvinthebored <262704729+Marvinthebored@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:15:35 -07:00

185 lines
6.3 KiB
TypeScript

type ProviderModelAuthReadiness = "ready" | "unknown" | "unavailable";
export type ProviderModelAuthEvidence =
| "aws-sdk"
| "environment"
| "none"
| "profile"
| "provider-config"
| "runtime"
| "synthetic";
export type ProviderModelAuthProfileSource = {
kind: "profile";
profileId: string;
provider?: string;
mode?: string;
readiness: ProviderModelAuthReadiness;
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 =
| ProviderModelAuthProfileSource
| ProviderModelAuthDirectSource;
type ProviderModelAuthRequiredReason = "configured-auth" | "provider-binding" | "user-lock";
type ProviderModelAuthAutomaticProfiles =
| { kind: "empty"; explicitOrder: boolean }
| {
kind: "usable";
explicitOrder: boolean;
profiles: readonly ProviderModelAuthProfileSource[];
}
| {
kind: "all-unavailable";
explicitOrder: boolean;
first: ProviderModelAuthProfileSource;
}
| {
kind: "all-cooldown";
explicitOrder: boolean;
first: ProviderModelAuthProfileSource;
};
export type ProviderModelAuthSourcePlan =
| {
kind: "required";
reason: ProviderModelAuthRequiredReason;
source: ProviderModelAuthSource;
}
| {
kind: "automatic";
profiles: ProviderModelAuthAutomaticProfiles;
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(
availability: boolean | undefined,
): ProviderModelAuthReadiness {
return availability === true ? "ready" : availability === false ? "unavailable" : "unknown";
}
export function fromProviderModelAuthReadiness(
readiness: ProviderModelAuthReadiness,
): boolean | undefined {
return readiness === "ready" ? true : readiness === "unavailable" ? false : undefined;
}
/** Creates a source fact without retaining credential material. */
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,
};
}
function reorderPreferredProfile(
profiles: readonly ProviderModelAuthProfileSource[],
preferredProfileId: string | undefined,
): ProviderModelAuthProfileSource[] {
if (!preferredProfileId) {
return [...profiles];
}
const preferred = profiles.find((profile) => profile.profileId === preferredProfileId);
return preferred
? [preferred, ...profiles.filter((profile) => profile.profileId !== preferredProfileId)]
: [...profiles];
}
/** Applies source precedence and automatic-tier readiness/cooldown policy once. */
export function buildProviderModelAuthSourcePlan(params: {
ownership?: {
reason: ProviderModelAuthRequiredReason;
source: ProviderModelAuthSource;
};
profiles: readonly ProviderModelAuthProfileSource[];
preferredProfileId?: string;
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 };
}
const explicitOrder = params.explicitOrder === true;
const ordered = reorderPreferredProfile(params.profiles, params.preferredProfileId);
let profiles: ProviderModelAuthAutomaticProfiles;
if (ordered.length === 0) {
profiles = { kind: "empty", explicitOrder };
} else {
const available = ordered.filter((profile) => profile.readiness !== "unavailable");
if (available.length === 0) {
const [firstOrdered] = ordered;
profiles = firstOrdered
? { kind: "all-unavailable", explicitOrder, first: firstOrdered }
: { kind: "empty", explicitOrder };
} else {
const outsideCooldown = available.filter((profile) => profile.cooldown === "clear");
if (outsideCooldown.length > 0) {
profiles = { kind: "usable", explicitOrder, profiles: outsideCooldown };
} else if (params.allowCooldown) {
profiles = { kind: "usable", explicitOrder, profiles: available.slice(0, 1) };
} else {
const [firstAvailable] = available;
profiles = firstAvailable
? { kind: "all-cooldown", explicitOrder, first: firstAvailable }
: { kind: "empty", explicitOrder };
}
}
}
return {
kind: "automatic",
profiles,
orderedProfiles: ordered,
allowCooldown: params.allowCooldown === true,
declaredProfileCount: params.declaredProfileCount ?? ordered.length,
...(params.fallback ? { fallback: params.fallback } : {}),
};
}