fix(gateway): refresh chat metadata after runtime auth succeeds (#121193)

* fix(gateway): converge chat metadata after runtime auth

* refactor(auth): isolate runtime materialization state

* fix(auth): guard runtime route API types

* fix(auth): default runtime transport override state

* test(gateway): narrow prepared auth fixture

* fix(auth): avoid recursive external auth publication

* fix(auth): keep publication suppression internal

* refactor(auth): hide runtime publication scope

* fix(auth): isolate models list materialization

* fix(gateway): type startup auth hydration deps

* refactor(auth): extract materialization registration

* chore(auth): keep materialization helper private
This commit is contained in:
Peter Steinberger
2026-08-09 15:24:45 -07:00
committed by GitHub
parent ce133d4a35
commit ba35d2a09a
24 changed files with 1063 additions and 10 deletions
@@ -11,6 +11,13 @@ import { addEnvBackedAgentCredentials } from "../agent-auth-discovery-core.js";
import { overlayExternalAuthProfiles } from "./external-auth.js";
import { testing } from "./external-auth.test-support.js";
import { readExternalCliBootstrapCredential } from "./external-cli-sync.js";
import { registerRuntimeAuthProfileStoreMutationListener } from "./runtime-snapshots.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
ensureAuthProfileStore,
getRuntimeAuthProfileStoreSnapshot,
replaceRuntimeAuthProfileStoreSnapshots,
} from "./store.js";
import type { AuthProfileStore, OAuthCredential } from "./types.js";
const resolveExternalAuthProfilesWithPluginsMock = vi.fn<
@@ -55,6 +62,7 @@ function requireProfile(store: AuthProfileStore, profileId: string): Record<stri
describe("auth external oauth helpers", () => {
beforeEach(() => {
clearRuntimeAuthProfileStoreSnapshots();
resolveExternalAuthProfilesWithPluginsMock.mockReset();
resolveExternalAuthProfilesWithPluginsMock.mockReturnValue([]);
readCodexCliCredentialsCachedMock.mockReset();
@@ -63,6 +71,7 @@ describe("auth external oauth helpers", () => {
});
afterEach(() => {
clearRuntimeAuthProfileStoreSnapshots();
testing.resetResolveExternalAuthProfilesForTest();
});
@@ -105,6 +114,99 @@ describe("auth external oauth helpers", () => {
expect(readCodexCliCredentialsCachedMock).toHaveBeenCalledTimes(1);
});
it("publishes a usable scoped CLI bootstrap into the runtime auth owner", () => {
const agentDir = "/tmp/openclaw-external-oauth-publication";
readCodexCliCredentialsCachedMock.mockReturnValue(
createCredential({ expires: createUsableOAuthExpiry() }),
);
const listener = vi.fn();
const unregister = registerRuntimeAuthProfileStoreMutationListener(listener);
try {
const scoped = ensureAuthProfileStore(agentDir, {
externalCliProviderIds: ["openai"],
allowKeychainPrompt: false,
readOnly: true,
syncExternalCli: false,
});
expect(scoped.profiles["openai:default"]?.type).toBe("oauth");
expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:default"]?.type).toBe(
"oauth",
);
expect(listener).toHaveBeenCalledWith({ agentDir, affectsInheritedStores: false });
} finally {
unregister();
}
});
it("does not replace an explicit unresolved API-key profile with CLI OAuth", () => {
const agentDir = "/tmp/openclaw-external-oauth-explicit-owner";
const explicit = createStore({
"openai:default": {
type: "api_key",
provider: "openai",
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
});
replaceRuntimeAuthProfileStoreSnapshots([{ agentDir, store: explicit }]);
readCodexCliCredentialsCachedMock.mockReturnValue(
createCredential({ expires: createUsableOAuthExpiry() }),
);
const listener = vi.fn();
const unregister = registerRuntimeAuthProfileStoreMutationListener(listener);
try {
const scoped = ensureAuthProfileStore(agentDir, {
externalCliProviderIds: ["openai"],
allowKeychainPrompt: false,
readOnly: true,
syncExternalCli: false,
});
expect(scoped.profiles["openai:default"]).toEqual(explicit.profiles["openai:default"]);
expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:default"]).toEqual(
explicit.profiles["openai:default"],
);
expect(listener).not.toHaveBeenCalled();
} finally {
unregister();
}
});
it("preserves resolved runtime refs when startup publishes scoped external auth", () => {
const agentDir = "/tmp/openclaw-external-oauth-prepared-owner";
const resolved = createStore({
"openai:configured": {
type: "api_key",
provider: "openai",
key: "resolved-runtime-key",
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
});
replaceRuntimeAuthProfileStoreSnapshots([{ agentDir, store: resolved }]);
readCodexCliCredentialsCachedMock.mockReturnValue(
createCredential({ expires: createUsableOAuthExpiry() }),
);
const listener = vi.fn();
const unregister = registerRuntimeAuthProfileStoreMutationListener(listener);
try {
const hydrated = ensureAuthProfileStore(agentDir, {
externalCliProviderIds: ["openai"],
allowKeychainPrompt: false,
readOnly: true,
syncExternalCli: false,
});
expect(hydrated.profiles["openai:configured"]).toEqual(
resolved.profiles["openai:configured"],
);
expect(hydrated.profiles["openai:default"]?.type).toBe("oauth");
expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles).toEqual(hydrated.profiles);
expect(listener).toHaveBeenCalledOnce();
} finally {
unregister();
}
});
it("keeps ambient Codex OAuth from outranking an env key under an api-key pin", () => {
const cfg = {
models: {
@@ -0,0 +1,126 @@
import { isDeepStrictEqual } from "node:util";
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { resolveAuthProfileDatabasePath } from "./sqlite.js";
/** Secret-free proof that one exact provider/model transport completed with usable auth. */
export type RuntimeAuthMaterialization = Readonly<{
provider: string;
modelId: string;
modelApi: string;
modelBaseUrl: string;
requestTransportOverrides: "none" | "present";
authMode: string;
runtimeOwnerId: string;
authProfileId?: string;
}>;
type RuntimeAuthMaterializationMutationListener = (event: {
agentDir?: string;
affectsInheritedStores: boolean;
}) => void;
const MAX_RUNTIME_AUTH_MATERIALIZATIONS_PER_OWNER = 64;
const materializations = new Map<string, RuntimeAuthMaterialization[]>();
const listeners = new Set<RuntimeAuthMaterializationMutationListener>();
function ownerKey(agentDir?: string): string {
return resolveAuthProfileDatabasePath(agentDir);
}
function notify(agentDir?: string): void {
const event = {
...(agentDir ? { agentDir } : {}),
affectsInheritedStores: agentDir === undefined,
};
for (const listener of listeners) {
listener(event);
}
}
export function registerRuntimeAuthMaterializationMutationListener(
listener: RuntimeAuthMaterializationMutationListener,
): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** Records successful auth at the boundary that proved one exact runtime route. */
export function recordRuntimeAuthMaterialization(params: {
agentDir?: string;
provider: string;
modelId: string;
modelApi: string;
modelBaseUrl: string;
requestTransportOverrides: "none" | "present";
authMode: string;
runtimeOwnerId: string;
authProfileId?: string;
}): boolean {
const provider = normalizeProviderId(params.provider);
const fact: RuntimeAuthMaterialization = {
provider,
modelId: params.modelId.trim().toLowerCase(),
modelApi: params.modelApi.trim().toLowerCase(),
modelBaseUrl: params.modelBaseUrl.trim(),
requestTransportOverrides: params.requestTransportOverrides,
authMode: params.authMode.trim().toLowerCase(),
runtimeOwnerId: params.runtimeOwnerId.trim().toLowerCase(),
...(params.authProfileId?.trim() ? { authProfileId: params.authProfileId.trim() } : {}),
};
if (Object.values(fact).some((value) => !value)) {
return false;
}
const key = ownerKey(params.agentDir);
const existing = materializations.get(key) ?? [];
if (existing.some((candidate) => isDeepStrictEqual(candidate, fact))) {
return false;
}
materializations.set(
key,
[...existing, fact].slice(-MAX_RUNTIME_AUTH_MATERIALIZATIONS_PER_OWNER),
);
notify(params.agentDir);
return true;
}
/** Revokes all facts backed by one runtime owner after a classified auth failure. */
export function revokeRuntimeAuthMaterializations(params: {
agentDir?: string;
provider: string;
runtimeOwnerId: string;
}): boolean {
const key = ownerKey(params.agentDir);
const provider = normalizeProviderId(params.provider);
const runtimeOwnerId = params.runtimeOwnerId.trim().toLowerCase();
const existing = materializations.get(key);
if (!provider || !runtimeOwnerId || !existing) {
return false;
}
const next = existing.filter(
(fact) => fact.provider !== provider || fact.runtimeOwnerId !== runtimeOwnerId,
);
if (next.length === existing.length) {
return false;
}
if (next.length) {
materializations.set(key, next);
} else {
materializations.delete(key);
}
notify(params.agentDir);
return true;
}
export function getPreparedRuntimeAuthMaterializations(
agentDir?: string,
): readonly RuntimeAuthMaterialization[] {
return materializations.get(ownerKey(agentDir)) ?? [];
}
export function clearRuntimeAuthMaterializations(agentDir?: string): void {
materializations.delete(ownerKey(agentDir));
}
export function clearAllRuntimeAuthMaterializations(): void {
materializations.clear();
}
@@ -5,6 +5,12 @@
import { expectDefined } from "@openclaw/normalization-core";
import { describe, expect, it, vi } from "vitest";
import {
getPreparedRuntimeAuthMaterializations,
recordRuntimeAuthMaterialization,
registerRuntimeAuthMaterializationMutationListener,
revokeRuntimeAuthMaterializations,
} from "./runtime-materializations.js";
import {
clearRuntimeAuthProfileStoreSnapshot,
clearRuntimeAuthProfileStoreSnapshots,
@@ -60,6 +66,97 @@ function expectOpenAICodexSnapshotCredential(
}
describe("runtime auth profile snapshots", () => {
it("marks default-owner materializations as inherited mutations", () => {
const listener = vi.fn();
const unregister = registerRuntimeAuthMaterializationMutationListener(listener);
try {
recordRuntimeAuthMaterialization({
provider: "openai",
modelId: "gpt-5.4",
modelApi: "openai-chatgpt-responses",
modelBaseUrl: "https://chatgpt.com/backend-api/codex",
requestTransportOverrides: "none",
authMode: "oauth",
runtimeOwnerId: "codex",
});
expect(listener).toHaveBeenCalledWith({ affectsInheritedStores: true });
} finally {
unregister();
clearRuntimeAuthProfileStoreSnapshots();
}
});
it("publishes successful-auth facts without impersonating credential rotation", () => {
const agentDir = "/tmp/openclaw-auth-runtime-materialized";
const pluginStoreListener = vi.fn();
const materializationListener = vi.fn();
setRuntimeAuthProfileStoreSnapshot(createStore("materialized"), agentDir);
const unregisterStore = registerRuntimeAuthProfileStoreMutationListener(pluginStoreListener);
const unregisterMaterialization =
registerRuntimeAuthMaterializationMutationListener(materializationListener);
try {
const materialization = {
agentDir,
provider: "openai",
modelId: "gpt-5.4",
modelApi: "openai-chatgpt-responses",
modelBaseUrl: "https://chatgpt.com/backend-api/codex",
requestTransportOverrides: "none",
authMode: "oauth",
runtimeOwnerId: "codex",
authProfileId: "openai:default",
} as const;
expect(recordRuntimeAuthMaterialization(materialization)).toBe(true);
expect(recordRuntimeAuthMaterialization(materialization)).toBe(false);
expect(getPreparedRuntimeAuthMaterializations(agentDir)).toEqual([
{
provider: "openai",
modelId: "gpt-5.4",
modelApi: "openai-chatgpt-responses",
modelBaseUrl: "https://chatgpt.com/backend-api/codex",
requestTransportOverrides: "none",
authMode: "oauth",
runtimeOwnerId: "codex",
authProfileId: "openai:default",
},
]);
const sibling = { ...materialization, modelId: "gpt-5.5" };
const distinctOwner = { ...materialization, runtimeOwnerId: "other-harness" };
recordRuntimeAuthMaterialization(sibling);
recordRuntimeAuthMaterialization(distinctOwner);
expect(
revokeRuntimeAuthMaterializations({
agentDir,
provider: "openai",
runtimeOwnerId: "codex",
}),
).toBe(true);
expect(
revokeRuntimeAuthMaterializations({
agentDir,
provider: "openai",
runtimeOwnerId: "codex",
}),
).toBe(false);
expect(getPreparedRuntimeAuthMaterializations(agentDir)).toEqual([
expect.objectContaining({ runtimeOwnerId: "other-harness", modelId: "gpt-5.4" }),
]);
expect(materializationListener).toHaveBeenCalledTimes(4);
expect(pluginStoreListener).not.toHaveBeenCalled();
recordRuntimeAuthMaterialization(materialization);
setRuntimeAuthProfileStoreSnapshot(createStore("replaced"), agentDir);
expect(getPreparedRuntimeAuthMaterializations(agentDir)).toEqual([]);
expect(pluginStoreListener).toHaveBeenCalledOnce();
} finally {
unregisterMaterialization();
unregisterStore();
clearRuntimeAuthProfileStoreSnapshots();
}
});
it("notifies listeners only when credential ownership changes", () => {
const agentDir = "/tmp/openclaw-auth-runtime-listener";
const listener = vi.fn();
@@ -6,6 +6,10 @@ import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import { cloneAuthProfileStore } from "./clone.js";
import { mergeAuthProfileStores } from "./persisted.js";
import {
clearAllRuntimeAuthMaterializations,
clearRuntimeAuthMaterializations,
} from "./runtime-materializations.js";
import { resolveAuthProfileDatabasePath } from "./sqlite.js";
import type { AuthProfileStore, RuntimeAuthProfileStore } from "./types.js";
@@ -208,6 +212,13 @@ function notifyRuntimeAuthStoreMutation(agentDir?: string): void {
}
}
function authProfilesChanged(
previous: RuntimeAuthProfileStore | undefined,
next: RuntimeAuthProfileStore | undefined,
): boolean {
return !isDeepStrictEqual(previous?.profiles ?? {}, next?.profiles ?? {});
}
/** Observes credential snapshot changes at their lifecycle publication edge. */
export function registerRuntimeAuthProfileStoreMutationListener(
listener: RuntimeAuthProfileStoreMutationListener,
@@ -283,6 +294,14 @@ export function replaceRuntimeAuthProfileStoreSnapshots(
if (credentialsChanged) {
runtimeAuthStoreCredentialsRevision += 1;
}
const next = new Map(
entries.map((entry) => [resolveRuntimeStoreKey(entry.agentDir), entry.store] as const),
);
for (const key of new Set([...runtimeAuthStoreSnapshots.keys(), ...next.keys()])) {
if (authProfilesChanged(runtimeAuthStoreSnapshots.get(key), next.get(key))) {
clearRuntimeAuthMaterializations(path.dirname(key));
}
}
recordChangedSnapshotRevisions(entries);
runtimeAuthStoreSnapshots.clear();
for (const entry of entries) {
@@ -307,6 +326,7 @@ export function clearRuntimeAuthProfileStoreSnapshots(): void {
runtimeAuthStoreSnapshotsRevision += 1;
}
runtimeAuthStoreSnapshots.clear();
clearAllRuntimeAuthMaterializations();
runtimeAuthStoreSnapshotRevisions.clear();
if (snapshotsChanged) {
notifyRuntimeAuthStoreMutation();
@@ -325,6 +345,7 @@ export function clearRuntimeAuthProfileStoreSnapshot(agentDir?: string): boolean
}
runtimeAuthStoreSnapshotsRevision += 1;
runtimeAuthStoreSnapshots.delete(key);
clearRuntimeAuthMaterializations(agentDir);
runtimeAuthStoreSnapshotRevisions.delete(key);
notifyRuntimeAuthStoreMutation(agentDir);
return true;
@@ -346,6 +367,9 @@ export function setRuntimeAuthProfileStoreSnapshot(
runtimeAuthStoreCredentialsRevision += 1;
}
const previousStore = runtimeAuthStoreSnapshots.get(key);
if (authProfilesChanged(previousStore, store)) {
clearRuntimeAuthMaterializations(agentDir);
}
const ownerChanged = !isDeepStrictEqual(ownerState(previousStore), ownerState(store));
const snapshotChanged = !isDeepStrictEqual(previousStore, store);
if (snapshotChanged) {
@@ -381,6 +405,9 @@ export function noteRuntimeAuthProfileStorePersistedMutation(
runtimeAuthStoreCredentialsRevision += 1;
}
const ownerKey = resolveRuntimeStoreKey(agentDir);
if (mutation.credentialsChanged || mutation.profileSetChanged) {
clearRuntimeAuthMaterializations(agentDir);
}
const record = getOrCreatePersistedMutationRecord(ownerKey);
if (mutation.profileSetChanged) {
record.profileSetRevision = persistedMutationRevision;
+17 -1
View File
@@ -1170,7 +1170,23 @@ export function ensureAuthProfileStore(
...externalCli,
},
);
if (!runtimeStore || hasScopedExternalCliOverlay(externalCli)) {
if (!runtimeStore) {
if (
hasScopedExternalCliOverlay(externalCli) &&
(store.runtimeExternalProfileIds?.length ?? 0) > 0
) {
setRuntimeAuthProfileStoreSnapshot(store, effectiveAgentDir);
}
return store;
}
if (hasScopedExternalCliOverlay(externalCli)) {
// Scoped turn/control-plane resolution returns only the requested overlay, but the lifecycle
// snapshot must retain unrelated external profiles. Publish the merged owner fact so prepared
// model and chat metadata generations converge without reopening credential sources.
const materialized = mergeRuntimeExternalProfileState({ next: store, existing: runtimeStore });
if (!isDeepStrictEqual(materialized, runtimeStore)) {
setRuntimeAuthProfileStoreSnapshot(materialized, effectiveAgentDir);
}
return store;
}
return mergeRuntimeExternalProfileState({
@@ -264,6 +264,7 @@ export async function runPreparedEmbeddedLoop(
getLastProfileId: () => preparedRuntime.snapshot().lastProfileId,
getSessionId: () => sessionPromptState.sessionId,
harnessOwnsTransport: () => preparedRuntime.snapshot().pluginHarnessOwnsTransport,
getRuntimeAuthOwnerId: () => preparedRuntime.snapshot().agentHarness.id,
getApiKeyInfo,
});
const ownsContextEngineLogicalTurnLease = params.contextEngineLogicalTurnLease === undefined;
@@ -602,6 +603,11 @@ export async function runPreparedEmbeddedLoop(
return terminalTimeoutResult;
}
const terminalAuthPlan = preparedRuntime.snapshot().activePreparedAuthPlan;
const requestTransportOverrides =
terminalAuthPlan.modelRoute?.requestTransportOverrides ??
terminalAuthPlan.deferredRouteSupport?.requestTransportOverrides ??
"none";
const terminalResolution = await resolveEmbeddedRunTerminal({
runParams: params,
retryState: terminalRetryState,
@@ -638,6 +644,8 @@ export async function runPreparedEmbeddedLoop(
modelId,
modelTransportId: effectiveModel.id ?? modelId,
modelTransportApi: effectiveModel.api ?? model.api,
...(effectiveModel.baseUrl ? { modelTransportBaseUrl: effectiveModel.baseUrl } : {}),
requestTransportOverrides,
authProfileId: lastProfileId,
profileFailureStore,
attemptAuthProfileStore,
@@ -1,9 +1,14 @@
import { sanitizeForLog } from "../../../../packages/terminal-core/src/ansi.js";
import { MODEL_APIS, type ModelApi } from "../../../config/types.models.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { formatErrorMessage } from "../../../infra/errors.js";
import { redactIdentifier } from "../../../logging/redact-identifier.js";
import type { ProviderRouteOverridePresence } from "../../../plugin-sdk/provider-model-types.js";
import { resolveProviderModelRoutes } from "../../../plugins/provider-model-routes.js";
import { looksLikeSecretSentinel, resolveSecretSentinel } from "../../../secrets/sentinel.js";
import type { AuthProfileStore } from "../../auth-profiles.js";
import { markAuthProfileSuccess } from "../../auth-profiles.js";
import { recordRuntimeAuthMaterialization } from "../../auth-profiles/runtime-materializations.js";
import {
fingerprintAuthProfileOwnerShape,
fingerprintAwsSdkRuntimeOwner,
@@ -13,6 +18,7 @@ import {
type AgentExecutionAuthBinding,
} from "../../execution-auth-binding.js";
import type { ResolvedProviderAuth } from "../../model-auth.js";
import { modelMatchesProviderModelRoute } from "../../provider-model-route.js";
import { log } from "../logger.js";
import type { EmbeddedRunAttemptResult } from "./types.js";
@@ -75,8 +81,12 @@ export function reportEmbeddedRunSuccessfulAuthBinding(input: {
apiKeyInfo: ResolvedProviderAuth | null;
attempt: EmbeddedRunAttemptResult;
provider: string;
agentDir?: string;
modelId: string;
modelApi: string;
modelBaseUrl?: string;
requestTransportOverrides?: ProviderRouteOverridePresence;
config?: OpenClawConfig;
agentHarnessId: string;
pluginHarnessOwnsTransport: boolean;
pluginHarnessOwnsAuthBootstrap: boolean;
@@ -140,6 +150,20 @@ export function reportEmbeddedRunSuccessfulAuthBinding(input: {
: input.pluginHarnessOwnsTransport
? ("plugin-harness" as const)
: undefined;
const materializedRoute = resolveOpaqueHarnessMaterialization(input, credential);
if (materializedRoute) {
recordRuntimeAuthMaterialization({
agentDir: input.agentDir,
provider: input.provider,
modelId: input.modelId,
modelApi: materializedRoute.api,
modelBaseUrl: materializedRoute.baseUrl,
requestTransportOverrides: materializedRoute.requestTransportOverrides,
authMode: materializedRoute.authRequirement === "subscription" ? "oauth" : "api-key",
runtimeOwnerId: input.agentHarnessId,
...(input.profileId ? { authProfileId: input.profileId } : {}),
});
}
input.onSuccessfulAuthBinding?.({
...(input.profileId ? { authProfileId: input.profileId } : {}),
agentHarnessId: input.agentHarnessId,
@@ -158,6 +182,63 @@ export function reportEmbeddedRunSuccessfulAuthBinding(input: {
});
}
function resolveOpaqueHarnessMaterialization(
input: Parameters<typeof reportEmbeddedRunSuccessfulAuthBinding>[0],
credential: AuthProfileStore["profiles"][string] | undefined,
) {
if (
!input.pluginHarnessOwnsAuthBootstrap ||
input.apiKeyInfo ||
hasInlineCredentialMaterial(credential) ||
!isModelApi(input.modelApi)
) {
return undefined;
}
const resolution = resolveProviderModelRoutes({
provider: input.provider,
modelId: input.modelId,
api: input.modelApi,
baseUrl: input.modelBaseUrl,
config: input.config,
requestTransportOverrides: input.requestTransportOverrides ?? "none",
});
if (resolution?.kind !== "routes") {
return undefined;
}
const routes = resolution.routes.filter(
(route) =>
route.api === input.modelApi &&
route.requestTransportOverrides === (input.requestTransportOverrides ?? "none") &&
(!input.modelBaseUrl ||
modelMatchesProviderModelRoute({
provider: input.provider,
api: input.modelApi,
baseUrl: input.modelBaseUrl,
route,
})),
);
return routes.length === 1 ? routes[0] : undefined;
}
function isModelApi(value: string): value is ModelApi {
return (MODEL_APIS as readonly string[]).includes(value);
}
function hasInlineCredentialMaterial(
credential: AuthProfileStore["profiles"][string] | undefined,
): boolean {
if (!credential) {
return false;
}
if (credential.type === "api_key") {
return Boolean(credential.key?.trim());
}
if (credential.type === "token") {
return Boolean(credential.token?.trim());
}
return Boolean(credential.access?.trim() && credential.refresh?.trim());
}
function resolvePluginHarnessApiKeyInfo(input: {
apiKeyInfo: ResolvedProviderAuth | null;
pluginHarnessOwnsTransport: boolean;
@@ -5,6 +5,7 @@ import {
markAuthProfileFailure,
markInlineProviderApiKeyFailure,
} from "../../auth-profiles.js";
import { revokeRuntimeAuthMaterializations } from "../../auth-profiles/runtime-materializations.js";
import type { FailoverReason } from "../../embedded-agent-helpers.js";
import { FailoverError, resolveFailoverStatus } from "../../failover-error.js";
import { isConfigBackedInlineProviderApiKey, type ResolvedProviderAuth } from "../../model-auth.js";
@@ -34,6 +35,7 @@ export function createEmbeddedRunFailoverRetryController(input: {
getLastProfileId: () => string | undefined;
getSessionId: () => string;
harnessOwnsTransport: () => boolean;
getRuntimeAuthOwnerId: () => string;
getApiKeyInfo: () => ResolvedProviderAuth | null;
}) {
const {
@@ -111,10 +113,17 @@ export function createEmbeddedRunFailoverRetryController(input: {
reason?: AuthProfileFailureReason | null;
modelId?: string;
}) => {
const { profileId, reason } = failure;
if (input.harnessOwnsTransport() && (reason === "auth" || reason === "auth_permanent")) {
revokeRuntimeAuthMaterializations({
agentDir,
provider,
runtimeOwnerId: input.getRuntimeAuthOwnerId(),
});
}
if (params.authProfileStateMode === "read-only") {
return;
}
const { profileId, reason } = failure;
if (!reason) {
return;
}
@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto";
import { SILENT_REPLY_TOKEN } from "../../../auto-reply/tokens.js";
import { freezeDiagnosticTraceContext } from "../../../infra/diagnostic-trace-context.js";
import type { AssistantMessage } from "../../../llm/types.js";
import type { ProviderRouteOverridePresence } from "../../../plugin-sdk/provider-model-types.js";
import { projectAgentRunAttemptTerminal } from "../../agent-run-terminal-outcome.js";
import type { AuthProfileFailureReason, AuthProfileStore } from "../../auth-profiles.js";
import type { AgentExecutionAuthBinding } from "../../execution-auth-binding.js";
@@ -183,6 +184,8 @@ export async function resolveEmbeddedRunTerminal(input: {
modelId: string;
modelTransportId: string;
modelTransportApi: string;
modelTransportBaseUrl?: string;
requestTransportOverrides?: ProviderRouteOverridePresence;
authProfileId?: string;
profileFailureStore: AuthProfileStore;
attemptAuthProfileStore: AuthProfileStore;
@@ -522,8 +525,12 @@ function completeEmbeddedRun(
apiKeyInfo: input.apiKeyInfo,
attempt: input.attempt,
provider: input.provider,
agentDir: input.runParams.agentDir,
modelId: input.modelTransportId,
modelApi: input.modelTransportApi,
...(input.modelTransportBaseUrl ? { modelBaseUrl: input.modelTransportBaseUrl } : {}),
requestTransportOverrides: input.requestTransportOverrides ?? "none",
config: input.runParams.config,
agentHarnessId: input.agentHarnessId,
pluginHarnessOwnsTransport: input.pluginHarnessOwnsTransport,
pluginHarnessOwnsAuthBootstrap: input.pluginHarnessOwnsAuthBootstrap,
@@ -6,6 +6,7 @@ import type {
} from "../plugin-sdk/provider-model-types.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
import type { PreparedAgentCredentialModes } from "./agent-auth-credentials.js";
import type { RuntimeAuthMaterialization } from "./auth-profiles/runtime-materializations.js";
import type { AuthProfileStore } from "./auth-profiles/types.js";
import {
createModelAuthAvailabilityResolver,
@@ -59,6 +60,7 @@ function evaluate(params: {
preparedRuntimeAuthStore?: AuthProfileStore;
syntheticAuthProviderRefs?: readonly string[];
preparedRuntimeAuthModes?: PreparedAgentCredentialModes;
preparedRuntimeAuthMaterializations?: readonly RuntimeAuthMaterialization[];
}) {
return createModelAuthAvailabilityResolver({
cfg: (params.cfg ?? {}) as OpenClawConfig,
@@ -68,6 +70,7 @@ function evaluate(params: {
syntheticAuthProviderRefs: params.syntheticAuthProviderRefs,
preparedRuntimeAuthModes: params.preparedRuntimeAuthModes,
preparedRuntimeAuthStore: params.preparedRuntimeAuthStore,
preparedRuntimeAuthMaterializations: params.preparedRuntimeAuthMaterializations,
}).evaluateModelAuth("openai", params.ref);
}
@@ -158,6 +161,67 @@ describe("createModelAuthAvailabilityResolver", () => {
},
);
it("keeps successful harness auth scoped to the exact model route", () => {
const materialization = {
provider: "openai",
modelId: "gpt-5.4",
modelApi: "openai-chatgpt-responses",
modelBaseUrl: "https://chatgpt.com/backend-api/codex",
requestTransportOverrides: "none",
authMode: "oauth",
runtimeOwnerId: "codex",
} as const;
const store = authStore({
"openai:default": {
type: "api_key",
provider: "openai",
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
});
expect(
evaluate({
store,
ref: { modelId: "gpt-5.4" },
preparedRuntimeAuthMaterializations: [materialization],
}),
).toMatchObject({
availability: true,
evidence: "runtime",
selectedRoute: subscriptionRoute,
});
expect(
evaluate({
store,
ref: { modelId: "gpt-5.5" },
preparedRuntimeAuthMaterializations: [materialization],
}).availability,
).not.toBe(true);
expect(
evaluate({
cfg: {
models: {
providers: {
openai: {
auth: "api-key",
apiKey: "configured-platform-key",
baseUrl: "https://api.openai.com/v1",
models: [],
},
},
},
},
store,
ref: { modelId: "gpt-5.4" },
preparedRuntimeAuthMaterializations: [materialization],
}),
).toMatchObject({
availability: true,
evidence: "provider-config",
selectedRoute: platformRoute,
});
});
it.each([
{ label: "resolved", key: "runtime-key", availability: true },
{ label: "unresolved", key: undefined, availability: undefined },
+78
View File
@@ -31,6 +31,7 @@ import {
resolveSecretRefReadOnlyAvailability,
resolveStoredCredentialReadOnlyAvailability,
} from "./auth-profiles/read-only-availability.js";
import type { RuntimeAuthMaterialization } from "./auth-profiles/runtime-materializations.js";
import { getRuntimeAuthProfileStoreSnapshot } from "./auth-profiles/runtime-snapshots.js";
import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles/types.js";
import {
@@ -71,6 +72,7 @@ import {
selectProviderModelAuthSources,
type ProviderModelAuthSourceSelection,
} from "./provider-model-route-auth.js";
import { modelMatchesProviderModelRoute } from "./provider-model-route.js";
const OPENAI_PROVIDER_ID = "openai";
const OPENAI_CODEX_RESPONSES_API = "openai-chatgpt-responses";
@@ -122,6 +124,7 @@ type CreateModelAuthAvailabilityResolverParams = {
allowPreparedRuntimeAuth?: boolean;
preparedRuntimeAuthStore?: AuthProfileStore;
preparedRuntimeAuthModes?: PreparedAgentCredentialModes;
preparedRuntimeAuthMaterializations?: readonly RuntimeAuthMaterialization[];
};
type AuthTarget = ModelAuthAvailabilityRef & {
@@ -888,6 +891,81 @@ export function createModelAuthAvailabilityResolver(
!modelLock && !awsSdkTerminal && basePolicy.binding.kind === "profile"
? basePolicy.binding.profileId
: undefined;
const explicitProfileOrder = profileOrder(
provider,
ref.modelId,
ref.preferredProfileId,
ref.lockedProfileId,
).hasExplicitOrder;
const materializedModelId = ref.modelId
? normalizeModelIdForProvider(provider, ref.modelId)?.toLowerCase()
: undefined;
const materialized =
!modelLock &&
!bindingProfileId &&
!basePolicy.required &&
!explicitProfileOrder &&
materializedModelId
? params.preparedRuntimeAuthMaterializations?.find(
(fact) =>
normalizeProvider(fact.provider) === provider &&
fact.modelId === materializedModelId &&
routeResolution.routes.some((route) => {
const configuredRequirement =
resolveProviderModelRouteAuthRequirement(configuredAuthMode);
return (
(!configuredRequirement || configuredRequirement === route.authRequirement) &&
route.runtimePolicy?.compatibleIds.some(
(runtimeId) => runtimeId.trim().toLowerCase() === fact.runtimeOwnerId,
) === true &&
route.api.toLowerCase() === fact.modelApi &&
route.requestTransportOverrides === fact.requestTransportOverrides &&
modelMatchesProviderModelRoute({
provider,
api: fact.modelApi,
baseUrl: fact.modelBaseUrl,
route,
}) &&
modeAllowed(
provider,
{
...ref,
api: route.api,
baseUrl: route.baseUrl,
authRequirement: route.authRequirement,
},
fact.authMode,
)
);
}),
)
: undefined;
if (materialized) {
const selectedRoute = routeResolution.routes.find(
(route) =>
route.runtimePolicy?.compatibleIds.some(
(runtimeId) => runtimeId.trim().toLowerCase() === materialized.runtimeOwnerId,
) === true &&
route.api.toLowerCase() === materialized.modelApi &&
route.requestTransportOverrides === materialized.requestTransportOverrides &&
modelMatchesProviderModelRoute({
provider,
api: materialized.modelApi,
baseUrl: materialized.modelBaseUrl,
route,
}),
);
if (selectedRoute) {
return {
availability: true,
routeResolution,
selectedRoute,
selectedAuthMode: materialized.authMode,
...(materialized.authProfileId ? { selectedProfileId: materialized.authProfileId } : {}),
evidence: "runtime",
};
}
}
const selectedConfiguredMode = awsSdkTerminal
? "aws-sdk"
: bindingProfileId
+21
View File
@@ -0,0 +1,21 @@
/** Secret-free successful-auth facts owned by an immutable prepared model generation. */
import type { RuntimeAuthMaterialization } from "./auth-profiles/runtime-materializations.js";
import type { PreparedModelRuntimeSnapshot } from "./prepared-model-runtime.types.js";
const materializationsBySnapshot = new WeakMap<
PreparedModelRuntimeSnapshot,
readonly RuntimeAuthMaterialization[]
>();
export function setPreparedModelRuntimeAuthMaterializations(
snapshot: PreparedModelRuntimeSnapshot,
materializations: readonly RuntimeAuthMaterialization[],
): void {
materializationsBySnapshot.set(snapshot, materializations);
}
export function getPreparedModelRuntimeAuthMaterializations(
snapshot: PreparedModelRuntimeSnapshot,
): readonly RuntimeAuthMaterialization[] {
return materializationsBySnapshot.get(snapshot) ?? [];
}
@@ -0,0 +1,65 @@
import {
getPreparedRuntimeAuthMaterializations,
registerRuntimeAuthMaterializationMutationListener,
type RuntimeAuthMaterialization,
} from "./auth-profiles/runtime-materializations.js";
import { setPreparedModelRuntimeAuthMaterializations } from "./prepared-model-runtime-auth.js";
import {
normalizeOptionalDir,
type PreparedModelRuntimeOwner,
} from "./prepared-model-runtime.owner.js";
type MaterializationMutationEvent = {
agentDir?: string;
affectsInheritedStores: boolean;
};
export function registerPreparedRuntimeAuthMaterializationPublisher(
owners: ReadonlyMap<string, PreparedModelRuntimeOwner>,
notify: (event: { phase: "invalidated" | "published" }) => void,
): () => void {
return registerRuntimeAuthMaterializationMutationListener((event) => {
publishPreparedRuntimeAuthMaterializations({
event,
owners,
onInvalidated: () => notify({ phase: "invalidated" }),
onPublished: () => notify({ phase: "published" }),
});
});
}
function publishPreparedRuntimeAuthMaterializations(params: {
event: MaterializationMutationEvent;
owners: ReadonlyMap<string, PreparedModelRuntimeOwner>;
onInvalidated: () => void;
onPublished: () => void;
read?: (agentDir?: string) => readonly RuntimeAuthMaterialization[];
}): void {
const event = {
...params.event,
agentDir: normalizeOptionalDir(params.event.agentDir),
};
const affectedOwners = [...params.owners.values()].flatMap((owner) => {
const affected =
event.affectsInheritedStores ||
owner.input.agentDir === event.agentDir ||
owner.input.inheritedAuthDir === event.agentDir;
return affected && owner.snapshot && !owner.pending && !owner.needsRefresh
? [{ owner, snapshot: owner.snapshot }]
: [];
});
if (affectedOwners.length === 0) {
return;
}
params.onInvalidated();
const read = params.read ?? getPreparedRuntimeAuthMaterializations;
for (const { owner, snapshot } of affectedOwners) {
// A successful route only changes this bounded secret-free fact set. Rebuilding the model
// catalog here would pull plugin lifecycle work into the turn-completion boundary.
setPreparedModelRuntimeAuthMaterializations(
snapshot,
Object.freeze([...read(owner.input.agentDir)]),
);
}
params.onPublished();
}
+8 -1
View File
@@ -3,7 +3,9 @@ import pLimit from "p-limit";
import { withTimeout } from "../node-host/with-timeout.js";
import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js";
import { resolveUsableAgentCredentialModes } from "./agent-auth-credentials.js";
import { getPreparedRuntimeAuthMaterializations } from "./auth-profiles/runtime-materializations.js";
import type { ModelCatalogSnapshot } from "./model-catalog.types.js";
import { setPreparedModelRuntimeAuthMaterializations } from "./prepared-model-runtime-auth.js";
import {
PreparedModelRuntimePublicationSupersededError,
toPreparedModelRuntimeError,
@@ -177,7 +179,7 @@ function createSnapshot(
const authStorage = AuthStorage.inMemory(credentials);
return { authStorage, modelRegistry: templateModelRegistry.fork(authStorage) };
};
return Object.freeze({
const snapshot: PreparedModelRuntimeSnapshot = Object.freeze({
...(input.agentId ? { agentId: input.agentId } : {}),
agentDir: input.agentDir,
activeProjectKeys: [],
@@ -197,6 +199,11 @@ function createSnapshot(
inlineProviderModels,
createStores,
});
setPreparedModelRuntimeAuthMaterializations(
snapshot,
Object.freeze([...getPreparedRuntimeAuthMaterializations(input.agentDir)]),
);
return snapshot;
}
async function buildSnapshotBatch(
+2 -2
View File
@@ -135,8 +135,8 @@ function prepareAgentFacts(
const env = input.env ?? process.env;
const templateAuthStorage = discoverAuthStorage(input.agentDir, {
config: input.config,
// Snapshot construction never initializes, migrates, or externally syncs auth. ModelRegistry
// discovery only parses the credential generation captured here.
// Prepared owners consume only the already-published runtime auth generation. External CLI
// hydration belongs to startup/control-plane and turn-time producers, never rebuilds.
readOnly: true,
ambientCredentials,
...(input.skipCredentials ? { skipCredentials: true } : {}),
@@ -12,6 +12,8 @@ type StaticCatalogResolver = ReturnType<CreateStaticCatalogResolver>;
const preparedModelRuntimeMocks = vi.hoisted(() => ({
preparedAuthStore: undefined as import("./auth-profiles/types.js").AuthProfileStore | undefined,
preparedAuthMaterializations:
[] as import("./auth-profiles/runtime-materializations.js").RuntimeAuthMaterialization[],
authStorage: {
getAll: vi.fn<() => AuthStorageData>(() => ({
custom: { type: "api_key", key: "test-key" },
@@ -52,6 +54,12 @@ const preparedModelRuntimeMocks = vi.hoisted(() => ({
mutationListener: undefined as
| ((event: { agentDir?: string; affectsInheritedStores: boolean }) => void)
| undefined,
mutationListeners: new Set<
(event: { agentDir?: string; affectsInheritedStores: boolean }) => void
>(),
materializationListeners: new Set<
(event: { agentDir?: string; affectsInheritedStores: boolean }) => void
>(),
}));
vi.mock("./model-catalog.js", () => ({
@@ -108,6 +116,70 @@ vi.mock("./agent-scope.js", () => ({
}),
}));
vi.mock("./auth-profiles/runtime-materializations.js", () => ({
getPreparedRuntimeAuthMaterializations: () =>
preparedModelRuntimeMocks.preparedAuthMaterializations,
registerRuntimeAuthMaterializationMutationListener: (
listener: (event: { agentDir?: string; affectsInheritedStores: boolean }) => void,
) => {
preparedModelRuntimeMocks.materializationListeners.add(listener);
return () => preparedModelRuntimeMocks.materializationListeners.delete(listener);
},
recordRuntimeAuthMaterialization: (params: {
agentDir?: string;
provider: string;
modelId: string;
modelApi: string;
modelBaseUrl: string;
requestTransportOverrides: "none" | "present";
authMode: string;
runtimeOwnerId: string;
authProfileId?: string;
}) => {
preparedModelRuntimeMocks.preparedAuthMaterializations.push({
provider: params.provider.trim().toLowerCase(),
modelId: params.modelId.trim().toLowerCase(),
modelApi: params.modelApi.trim().toLowerCase(),
modelBaseUrl: params.modelBaseUrl,
requestTransportOverrides: params.requestTransportOverrides,
authMode: params.authMode.trim().toLowerCase(),
runtimeOwnerId: params.runtimeOwnerId.trim().toLowerCase(),
...(params.authProfileId ? { authProfileId: params.authProfileId } : {}),
});
const event = {
agentDir: params.agentDir,
affectsInheritedStores: params.agentDir === undefined,
};
for (const listener of preparedModelRuntimeMocks.materializationListeners) {
listener(event);
}
return true;
},
revokeRuntimeAuthMaterializations: (params: {
agentDir?: string;
provider: string;
runtimeOwnerId: string;
}) => {
const previousLength = preparedModelRuntimeMocks.preparedAuthMaterializations.length;
preparedModelRuntimeMocks.preparedAuthMaterializations =
preparedModelRuntimeMocks.preparedAuthMaterializations.filter(
(fact) =>
fact.provider !== params.provider || fact.runtimeOwnerId !== params.runtimeOwnerId,
);
if (preparedModelRuntimeMocks.preparedAuthMaterializations.length === previousLength) {
return false;
}
const event = {
agentDir: params.agentDir,
affectsInheritedStores: params.agentDir === undefined,
};
for (const listener of preparedModelRuntimeMocks.materializationListeners) {
listener(event);
}
return true;
},
}));
vi.mock("./auth-profiles/runtime-snapshots.js", () => ({
getPreparedRuntimeAuthProfileStoreSnapshot: () => preparedModelRuntimeMocks.preparedAuthStore,
getRuntimeAuthProfileStoreSnapshot: () => preparedModelRuntimeMocks.preparedAuthStore,
@@ -115,8 +187,9 @@ vi.mock("./auth-profiles/runtime-snapshots.js", () => ({
registerRuntimeAuthProfileStoreMutationListener: (
listener: (event: { agentDir?: string; affectsInheritedStores: boolean }) => void,
) => {
preparedModelRuntimeMocks.mutationListener = listener;
return () => {};
preparedModelRuntimeMocks.mutationListener ??= listener;
preparedModelRuntimeMocks.mutationListeners.add(listener);
return () => preparedModelRuntimeMocks.mutationListeners.delete(listener);
},
}));
@@ -179,6 +252,7 @@ export function resetPreparedModelRuntimeHarness(): void {
});
preparedModelRuntimeMocks.authStorage.getOAuthProviders.mockReset().mockReturnValue([]);
preparedModelRuntimeMocks.preparedAuthStore = undefined;
preparedModelRuntimeMocks.preparedAuthMaterializations = [];
preparedModelRuntimeMocks.modelRegistry.fork
.mockReset()
.mockImplementation((authStorage: unknown) => ({ authStorage }));
+37
View File
@@ -563,6 +563,43 @@ describe("prepared model runtime snapshots", () => {
expect(secondStores.modelRegistry).not.toBe(firstStores.modelRegistry);
});
it.each([
{
label: "usable",
credential: { type: "oauth", access: "a", refresh: "r", expires: 1 },
expected: "oauth",
},
{
label: "unusable",
credential: { type: "oauth", access: "", refresh: "", expires: 0 },
expected: undefined,
},
] as const)(
"consumes $label startup CLI hydration without rediscovery",
async ({ credential, expected }) => {
const config = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.4" },
models: { "openai/gpt-5.4": {} },
},
},
};
mocks.authStorage.getAll.mockReturnValue({ openai: credential });
const snapshot = await publishPreparedModelRuntimeSnapshot({
config,
agentDir: "/tmp/prepared-model-runtime-cli-startup",
});
const discoveryOptions = mocks.discoverAuthStorage.mock.calls[0]?.[1] as {
externalCli?: unknown;
};
expect(discoveryOptions.externalCli).toBeUndefined();
expect(snapshot.authModes.openai).toBe(expected);
},
);
it("ignores request config identity until lifecycle publication", async () => {
const agentDir = "/tmp/prepared-model-runtime-request-config";
const initialConfig = {};
+2
View File
@@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { isReservedSystemAgentId } from "../system-agent/agent-id.js";
import { registerRuntimeAuthProfileStoreMutationListener } from "./auth-profiles/runtime-snapshots.js";
import { registerPreparedRuntimeAuthMaterializationPublisher } from "./prepared-model-runtime-materializations.js";
import {
PreparedModelRuntimeOwnerNotPublishedError,
PreparedModelRuntimeOwnerRetention,
@@ -748,6 +749,7 @@ function invalidateForAuthMutation(event: AuthMutationEvent): void {
}
registerRuntimeAuthProfileStoreMutationListener(invalidateForAuthMutation);
registerPreparedRuntimeAuthMaterializationPublisher(owners, notifyPreparedModelRuntimePublication);
function resetPreparedModelRuntimeSnapshotsForTest(): void {
pendingModelRuntimeReplacement?.resolve();
@@ -1,6 +1,10 @@
import "../agents/prepared-model-runtime.test-harness.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { revokeRuntimeAuthMaterializations } from "../agents/auth-profiles/runtime-materializations.js";
import { reportEmbeddedRunSuccessfulAuthBinding } from "../agents/embedded-agent-runner/run/auth-profile-success.js";
import type { EmbeddedRunAttemptResult } from "../agents/embedded-agent-runner/run/types.js";
import { getPreparedModelCatalogOwnerSnapshot } from "../agents/prepared-model-catalog.js";
import { getPreparedModelRuntimeAuthMaterializations } from "../agents/prepared-model-runtime-auth.js";
import { refreshPreparedModelRuntimeSnapshots } from "../agents/prepared-model-runtime.js";
import {
getPreparedModelRuntimeMocks,
@@ -82,6 +86,22 @@ function configureAuthFixture(kind: "secret-ref" | "external-oauth" | "unresolve
};
}
function configureHarnessOwnedUnresolvedAuth() {
mocks.authStorage.getAll.mockReturnValue({
openai: { type: "api_key", key: "openclaw-secret-ref-configured" },
});
mocks.preparedAuthStore = {
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
},
};
}
afterEach(async () => {
vi.unstubAllEnvs();
for (const sidecar of sidecars) {
@@ -125,6 +145,7 @@ async function expectAvailable(
metadataSnapshot: owner.metadataSnapshot,
preparedAuthStore: mocks.preparedAuthStore ?? { version: 1, profiles: {} },
preparedRuntimeAuthModes: owner.authModes,
preparedRuntimeAuthMaterializations: getPreparedModelRuntimeAuthMaterializations(owner),
});
const [metadata, modelsList] = await Promise.all([
lifecycle.read({ agentId: "main" }),
@@ -182,6 +203,61 @@ describe("gateway chat metadata lifecycle composition", () => {
await expectAvailable(lifecycle);
});
it("publishes a successful harness auth binding before the next metadata read", async () => {
configureHarnessOwnedUnresolvedAuth();
await publishOwner();
const lifecycle = await createLifecycle();
await lifecycle.attachContext(context, sidecars);
await expectAvailable(lifecycle, false);
const profileStore = mocks.preparedAuthStore;
if (!profileStore) {
throw new Error("expected unresolved prepared auth store");
}
reportEmbeddedRunSuccessfulAuthBinding({
profileStore,
apiKeyInfo: null,
attempt: {
runtimeArtifact: {
id: "codex-app-server:test",
fingerprint: "codex-runtime-fingerprint",
},
} as EmbeddedRunAttemptResult,
provider: "openai",
agentDir: "/tmp/configured-main",
modelId: "gpt-5.4",
modelApi: "openai-chatgpt-responses",
modelBaseUrl: "https://chatgpt.com/backend-api/codex",
requestTransportOverrides: "none",
config,
agentHarnessId: "codex",
pluginHarnessOwnsTransport: true,
pluginHarnessOwnsAuthBootstrap: true,
});
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce();
expect(mocks.preparedAuthMaterializations).toEqual([
expect.objectContaining({
provider: "openai",
modelId: "gpt-5.4",
modelApi: "openai-chatgpt-responses",
modelBaseUrl: "https://chatgpt.com/backend-api/codex",
requestTransportOverrides: "none",
authMode: "oauth",
runtimeOwnerId: "codex",
}),
]);
await vi.waitFor(async () => await expectAvailable(lifecycle));
revokeRuntimeAuthMaterializations({
agentDir: "/tmp/configured-main",
provider: "openai",
runtimeOwnerId: "codex",
});
await vi.waitFor(async () => await expectAvailable(lifecycle, false));
});
it("recovers a failed catch-up when the prepared owner publishes after attachment", async () => {
const lifecycle = await createLifecycle();
await lifecycle.attachContext(context, sidecars);
@@ -13,6 +13,7 @@ import {
getPreparedModelCatalogOwnerSnapshot,
type LoadPreparedModelCatalogParams,
} from "../../agents/prepared-model-catalog.js";
import { getPreparedModelRuntimeAuthMaterializations } from "../../agents/prepared-model-runtime-auth.js";
import type { PreparedModelRuntimeSnapshot } from "../../agents/prepared-model-runtime.js";
import { resolveSwarmConfig } from "../../agents/swarm-config.js";
import { resolveRuntimeConfigCacheKey } from "../../config/runtime-snapshot.js";
@@ -242,6 +243,9 @@ async function defaultBuildProjection(params: {
preparedAuthStore: params.facts.authStore,
// The owner records usable auth at discovery; metadata must share that exact generation fact.
preparedRuntimeAuthModes: params.facts.owner.authModes,
preparedRuntimeAuthMaterializations: getPreparedModelRuntimeAuthMaterializations(
params.facts.owner,
),
...(params.preferredProfileId ? { preferredProfileId: params.preferredProfileId } : {}),
...(params.lockedProfileId ? { lockedProfileId: params.lockedProfileId } : {}),
});
@@ -1,9 +1,16 @@
import type { PreparedAgentCredentialModes } from "../../agents/agent-auth-credentials.js";
import { resolveAgentDir } from "../../agents/agent-scope.js";
import { loadAuthProfileStoreWithoutExternalProfiles } from "../../agents/auth-profiles.js";
import { resolveExternalCliAuthProfiles } from "../../agents/auth-profiles/external-cli-sync.js";
import {
recordRuntimeAuthMaterialization,
type RuntimeAuthMaterialization,
} from "../../agents/auth-profiles/runtime-materializations.js";
import type { AuthProfileStore } from "../../agents/auth-profiles/types.js";
import {
createModelAuthAvailabilityResolver,
type ModelAuthAvailabilityEvaluation,
type ModelAuthAvailabilityRef,
type ModelAuthAvailabilityResolver,
} from "../../agents/model-auth-availability.js";
import { createOpenAIModelRoutesResolver } from "../../agents/openai-model-routes.js";
@@ -41,6 +48,7 @@ export function createModelsListAuthResolver(params: {
metadataSnapshot?: PluginMetadataSnapshot;
preparedAuthStore?: AuthProfileStore;
preparedRuntimeAuthModes?: PreparedAgentCredentialModes;
preparedRuntimeAuthMaterializations?: readonly RuntimeAuthMaterialization[];
workspaceDir: string;
routeResolverFactory?: typeof createOpenAIModelRoutesResolver;
}): ModelAuthAvailabilityResolver {
@@ -55,7 +63,17 @@ export function createModelsListAuthResolver(params: {
// A prepared projection must hydrate from its own auth-store generation. Reading the global
// snapshot can mix generations; treating this store as persisted loses resolved SecretRefs.
const preparedRuntimeAuthStore = params.preparedAuthStore;
return createModelAuthAvailabilityResolver({
const externalCliProviderIds =
!params.preparedAuthStore && params.includeOpenAIExternalProfiles ? ["openai"] : [];
const externalProfileIds = new Set(
externalCliProviderIds.length
? resolveExternalCliAuthProfiles(authStore, {
allowKeychainPrompt: false,
providerIds: externalCliProviderIds,
}).map(({ profileId }) => profileId)
: [],
);
const resolver = createModelAuthAvailabilityResolver({
cfg: params.cfg,
authStore,
agentDir,
@@ -63,11 +81,52 @@ export function createModelsListAuthResolver(params: {
env: process.env,
metadataSnapshot: params.metadataSnapshot,
preparedRuntimeAuthModes: params.preparedRuntimeAuthModes,
preparedRuntimeAuthMaterializations: params.preparedRuntimeAuthMaterializations,
skipSetupProviderFallback: true,
syntheticAuthProviderRefs: listEnabledSyntheticAuthProviderRefs(params),
externalCliProviderIds:
!params.preparedAuthStore && params.includeOpenAIExternalProfiles ? ["openai"] : [],
externalCliProviderIds,
...(preparedRuntimeAuthStore ? { preparedRuntimeAuthStore } : {}),
routeResolverFactory: params.routeResolverFactory,
});
if (externalProfileIds.size === 0) {
return resolver;
}
const evaluateModelAuth = (
provider: string,
ref: ModelAuthAvailabilityRef = {},
): ModelAuthAvailabilityEvaluation => {
const evaluation = resolver.evaluateModelAuth(provider, ref);
const route = evaluation.selectedRoute;
const profileId = evaluation.selectedProfileId;
if (
evaluation.availability === true &&
route &&
profileId &&
externalProfileIds.has(profileId)
) {
const modelId = ref.modelId?.trim();
if (modelId) {
recordRuntimeAuthMaterialization({
agentDir,
provider,
modelId,
modelApi: route.api,
modelBaseUrl: route.baseUrl,
requestTransportOverrides: route.requestTransportOverrides,
authMode:
evaluation.selectedAuthMode ??
(route.authRequirement === "subscription" ? "oauth" : "api-key"),
runtimeOwnerId: "external-cli",
authProfileId: profileId,
});
}
}
return evaluation;
};
return {
...resolver,
evaluateModelAuth,
resolveProviderAuthAvailability: (provider, ref) =>
evaluateModelAuth(provider, ref).availability,
};
}
@@ -8,6 +8,7 @@ import {
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../../agents/agent-scope.js";
import type { RuntimeAuthMaterialization } from "../../agents/auth-profiles/runtime-materializations.js";
import type { AuthProfileStore } from "../../agents/auth-profiles/types.js";
import { DEFAULT_PROVIDER } from "../../agents/defaults.js";
import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js";
@@ -283,6 +284,7 @@ export function createGatewayAgentModelCatalogProjector(params: {
metadataSnapshot?: PluginMetadataSnapshot;
preparedAuthStore?: AuthProfileStore;
preparedRuntimeAuthModes?: PreparedAgentCredentialModes;
preparedRuntimeAuthMaterializations?: readonly RuntimeAuthMaterialization[];
preferredProfileId?: string;
lockedProfileId?: string;
routeResolverFactory?: typeof createOpenAIModelRoutesResolver;
@@ -338,6 +340,7 @@ export function createGatewayAgentModelCatalogProjector(params: {
metadataSnapshot,
...(params.preparedAuthStore ? { preparedAuthStore: params.preparedAuthStore } : {}),
preparedRuntimeAuthModes: params.preparedRuntimeAuthModes,
preparedRuntimeAuthMaterializations: params.preparedRuntimeAuthMaterializations,
workspaceDir,
routeResolverFactory: params.routeResolverFactory,
});
+65
View File
@@ -448,6 +448,67 @@ async function prewarmConfiguredPrimaryModel(params: {
await publishConfiguredModelRuntimeSnapshots(params);
}
type StartupExternalAuthHydrationDeps = {
listAgentIds: (cfg: OpenClawConfig) => string[];
resolveAgentDir: (cfg: OpenClawConfig, agentId: string) => string;
collectConfiguredRefs: (cfg: OpenClawConfig, agentId: string) => readonly { value: string }[];
hydrate: (agentDir: string, providers: readonly string[]) => void;
};
async function hydrateConfiguredExternalCliAuth(params: {
cfg: OpenClawConfig;
log: { warn: (msg: string) => void };
deps?: StartupExternalAuthHydrationDeps;
}): Promise<void> {
const deps: StartupExternalAuthHydrationDeps =
params.deps ??
(await Promise.all([
import("../agents/agent-scope.js"),
import("../agents/prepared-model-runtime.configured.js"),
import("../agents/auth-profiles/store.js"),
import("../agents/auth-profiles/external-cli-discovery.js"),
]).then(([scope, configured, store, external]) => ({
listAgentIds: scope.listAgentIds,
resolveAgentDir: scope.resolveAgentDir,
collectConfiguredRefs: configured.collectPreparedModelRuntimeConfiguredRefs,
hydrate: (agentDir: string, providers: readonly string[]) => {
const discovery = external.externalCliDiscoveryForProviders({
cfg: params.cfg,
providers,
});
if (discovery.mode === "none") {
return;
}
store.ensureAuthProfileStore(agentDir, {
config: params.cfg,
externalCli: discovery,
allowKeychainPrompt: false,
readOnly: true,
syncExternalCli: false,
});
},
})));
const hydratedDirs = new Set<string>();
for (const agentId of deps.listAgentIds(params.cfg)) {
const providers = deps.collectConfiguredRefs(params.cfg, agentId).flatMap(({ value }) => {
const separator = value.indexOf("/");
return separator > 0 ? [value.slice(0, separator)] : [];
});
const agentDir = deps.resolveAgentDir(params.cfg, agentId);
if (providers.length === 0 || hydratedDirs.has(agentDir)) {
continue;
}
hydratedDirs.add(agentDir);
try {
deps.hydrate(agentDir, providers);
} catch (error) {
params.log.warn(
`startup external CLI auth hydration failed for agent ${agentId}: ${String(error)}`,
);
}
}
}
async function publishConfiguredModelRuntimeSnapshots(params: {
cfg: OpenClawConfig;
workspaceDir?: string;
@@ -587,6 +648,9 @@ export async function startGatewaySidecars(params: {
);
}
});
await measureStartup(params.startupTrace, "sidecars.model-auth", () =>
hydrateConfiguredExternalCliAuth({ cfg: params.cfg, log: params.log }),
);
// Agent RPC remains available when transports are disabled. Publish configured/static facts before
// accepting work; live provider catalogs stay advisory and never enter the Gateway lifecycle.
await measureStartup(params.startupTrace, "sidecars.model-runtime", () =>
@@ -1385,6 +1449,7 @@ export const testing = {
providerAuthPrewarmStartDelayMs: PROVIDER_AUTH_PREWARM_START_DELAY_MS,
hasRestartSentinelFast,
prewarmConfiguredPrimaryModel,
hydrateConfiguredExternalCliAuth,
publishConfiguredModelRuntimeSnapshots,
publishStartupModelRuntime,
refreshLatestUpdateRestartSentinelIfPresent,
+25
View File
@@ -37,6 +37,7 @@ vi.mock("../agents/prepared-model-runtime.js", () => ({
}));
let prewarmConfiguredPrimaryModel: typeof import("./server-startup-post-attach.js").testing.prewarmConfiguredPrimaryModel;
let hydrateConfiguredExternalCliAuth: typeof import("./server-startup-post-attach.js").testing.hydrateConfiguredExternalCliAuth;
let publishStartupModelRuntime: typeof import("./server-startup-post-attach.js").testing.publishStartupModelRuntime;
let shouldSkipStartupModelPrewarm: typeof import("./server-startup-post-attach.js").testing.shouldSkipStartupModelPrewarm;
@@ -45,6 +46,7 @@ describe("gateway startup primary model warmup", () => {
({
testing: {
prewarmConfiguredPrimaryModel,
hydrateConfiguredExternalCliAuth,
publishStartupModelRuntime,
shouldSkipStartupModelPrewarm,
},
@@ -79,6 +81,29 @@ describe("gateway startup primary model warmup", () => {
});
});
it("hydrates configured external CLI auth before prepared owner publication", async () => {
const cfg = {} as OpenClawConfig;
const hydrate = vi.fn();
await hydrateConfiguredExternalCliAuth({
cfg,
log: { warn: vi.fn() },
deps: {
listAgentIds: () => ["main", "secondary"],
resolveAgentDir: (_config, agentId) => `/tmp/${agentId}`,
collectConfiguredRefs: (_config, agentId) => [
{ value: agentId === "main" ? "openai/gpt-5.4" : "anthropic/sonnet-4.6" },
],
hydrate,
},
});
expect(hydrate).toHaveBeenCalledTimes(2);
expect(hydrate).toHaveBeenCalledWith("/tmp/main", ["openai"]);
expect(hydrate).toHaveBeenCalledWith("/tmp/secondary", ["anthropic"]);
expect(refreshPreparedModelRuntimeSnapshotsMock).not.toHaveBeenCalled();
});
it("prewarms the default catalog when no explicit primary model is configured", async () => {
const cfg = {} as OpenClawConfig;
await prewarmConfiguredPrimaryModel({