mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
778be6f5cc
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>
185 lines
6.3 KiB
TypeScript
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 } : {}),
|
|
};
|
|
}
|