mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-05 09:31:27 +00:00
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>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<typeof selectProviderModelAuthSources>[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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
158
src/agents/runtime-plan/prepare-auth.ambient-credential.test.ts
Normal file
158
src/agents/runtime-plan/prepare-auth.ambient-credential.test.ts
Normal file
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user