From 3864270f60bb150fe922df709b039e93809aaaab Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:55:23 -0500 Subject: [PATCH] fix(models): unify full catalog auth discovery --- ...d-model-catalog-worker.integration.test.ts | 42 +++++++ src/agents/prepared-model-catalog-worker.ts | 35 ++++-- src/agents/prepared-model-catalog.test.ts | 4 +- src/agents/prepared-model-catalog.ts | 26 ++++- src/agents/prepared-model-catalog.worker.ts | 106 +++++++++++++----- src/agents/prepared-model-runtime.build.ts | 6 - ...ared-model-runtime.owner-selection.test.ts | 12 +- .../prepared-model-runtime.test-harness.ts | 2 - .../chat-metadata-runtime.test.ts | 15 ++- 9 files changed, 186 insertions(+), 62 deletions(-) diff --git a/src/agents/prepared-model-catalog-worker.integration.test.ts b/src/agents/prepared-model-catalog-worker.integration.test.ts index b7475d9f81ce..8d2aec2c953f 100644 --- a/src/agents/prepared-model-catalog-worker.integration.test.ts +++ b/src/agents/prepared-model-catalog-worker.integration.test.ts @@ -19,6 +19,7 @@ import { } from "./plugin-model-catalog.js"; import { createPreparedModelCatalogWorkerInput, + getPreparedModelFullCatalogAuth, runPreparedModelCatalogWorker, } from "./prepared-model-catalog-worker.js"; import { copyPreparedModelRuntimeAuthState } from "./prepared-model-runtime-auth.js"; @@ -37,6 +38,7 @@ const REF_ONLY_API_ENV = "OPENCLAW_WORKER_REF_ONLY_API_KEY"; const REF_ONLY_TOKEN_PROVIDER_ID = `${PROVIDER_ID}-ref-token`; const REF_ONLY_TOKEN_ENV = "OPENCLAW_WORKER_REF_ONLY_TOKEN"; const DURABLE_AUTH_PROVIDER_ID = `${PROVIDER_ID}-durable-auth`; +const DURABLE_AUTH_KEY = "post-startup-durable-key-not-real"; const tempDirs = useAutoCleanupTempDirTracker((cleanup) => { afterEach(() => { clearRuntimeAuthProfileStoreSnapshots(); @@ -68,6 +70,7 @@ module.exports = { run(context) { const refOnlyApi = context.resolveProviderApiKey(${JSON.stringify(REF_ONLY_API_PROVIDER_ID)}).apiKey; const refOnlyToken = context.resolveProviderApiKey(${JSON.stringify(REF_ONLY_TOKEN_PROVIDER_ID)}).apiKey; + const durableAuth = context.resolveProviderApiKey(${JSON.stringify(DURABLE_AUTH_PROVIDER_ID)}).apiKey; const hasRefOnlyApi = refOnlyApi === ${JSON.stringify(REF_ONLY_API_ENV)} || refOnlyApi === process.env[${JSON.stringify(REF_ONLY_API_ENV)}]; const hasRefOnlyToken = refOnlyToken === ${JSON.stringify(REF_ONLY_TOKEN_ENV)} || refOnlyToken === process.env[${JSON.stringify(REF_ONLY_TOKEN_ENV)}]; return { provider: { @@ -79,6 +82,9 @@ module.exports = { id: \`ref-proof-api-\${hasRefOnlyApi}-token-\${hasRefOnlyToken}\`, name: "Ref-only worker proof", }, + ...(durableAuth === ${JSON.stringify(DURABLE_AUTH_KEY)} + ? [{ id: "post-startup-auth-model", name: "Post-startup auth model" }] + : []), ], } }; }, @@ -211,6 +217,42 @@ async function waitForMarker(marker: string): Promise { } describe("prepared model catalog worker boundary", () => { + it("refreshes durable auth before provider hooks decide catalog membership", async () => { + const fixture = await createStaticSnapshot(0); + saveAuthProfileStore( + { + version: 1, + profiles: { + [`${DURABLE_AUTH_PROVIDER_ID}:default`]: { + type: "api_key", + provider: DURABLE_AUTH_PROVIDER_ID, + key: DURABLE_AUTH_KEY, + }, + }, + }, + fixture.agentDir, + ); + + const catalog = await fixture.snapshot.loadFullModelCatalog?.(); + expect(catalog?.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + provider: PROVIDER_ID, + id: "post-startup-auth-model", + }), + ]), + ); + expect(getPreparedModelFullCatalogAuth(catalog!)).toMatchObject({ + authStore: { + profiles: { + [`${DURABLE_AUTH_PROVIDER_ID}:default`]: expect.objectContaining({ + key: DURABLE_AUTH_KEY, + }), + }, + }, + }); + }); + it("refreshes durable auth profiles added, updated, and removed after startup", async () => { const fixture = await createStaticSnapshot(0); const route = { diff --git a/src/agents/prepared-model-catalog-worker.ts b/src/agents/prepared-model-catalog-worker.ts index 577c18b6b8a2..b231fa8a4f0e 100644 --- a/src/agents/prepared-model-catalog-worker.ts +++ b/src/agents/prepared-model-catalog-worker.ts @@ -4,6 +4,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { Worker } from "node:worker_threads"; import { resolveInstalledManifestRegistryIndexFingerprint } from "../plugins/manifest-registry-installed.js"; import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; +import type { PreparedAgentCredentialModes } from "./agent-auth-credential-modes.js"; import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles/types.js"; import type { ModelCatalogSnapshot } from "./model-catalog.types.js"; import { PreparedModelRuntimePublicationSupersededError } from "./prepared-model-runtime.errors.js"; @@ -13,14 +14,12 @@ import { type PreparedModelRuntimeAgentFacts, } from "./prepared-model-runtime.facts.js"; import type { PreparedModelRuntimeInput } from "./prepared-model-runtime.types.js"; -import type { AuthStorageData } from "./sessions/auth-storage.js"; export type PreparedModelCatalogWorkerInput = Readonly<{ kind: "catalog"; generationFingerprint: string; input: PreparedModelRuntimeInput; authStore: AuthProfileStore; - credentials: Readonly; providerIds: readonly string[]; }>; @@ -41,6 +40,8 @@ export type PreparedModelWorkerResult = kind: "catalog"; generationFingerprint: string; snapshot: ModelCatalogSnapshot; + authStore: AuthProfileStore; + authModes: PreparedAgentCredentialModes; }> | Readonly<{ status: "ok"; @@ -50,6 +51,22 @@ export type PreparedModelWorkerResult = }> | Readonly<{ status: "failed"; error: string }>; +const authByFullCatalog = new WeakMap< + object, + Readonly<{ authStore: AuthProfileStore; authModes: PreparedAgentCredentialModes }> +>(); + +export function setPreparedModelFullCatalogAuth( + modelCatalog: object, + auth: Readonly<{ authStore: AuthProfileStore; authModes: PreparedAgentCredentialModes }>, +): void { + authByFullCatalog.set(modelCatalog, auth); +} + +export function getPreparedModelFullCatalogAuth(modelCatalog: object) { + return authByFullCatalog.get(modelCatalog); +} + // Cold source/plugin loading can take well over a minute. Three minutes preserves exact full-view // discovery while bounding a wedged provider; expiry rejects and never returns partial results. const PREPARED_MODEL_CATALOG_WORKER_TIMEOUT_MS = 180_000; @@ -68,14 +85,12 @@ function fingerprintPreparedModelCatalogPlugins(snapshot: PluginMetadataSnapshot export function fingerprintPreparedModelCatalogGeneration(params: { input: PreparedModelRuntimeInput; authStore: AuthProfileStore; - credentials: Readonly; providerIds: readonly string[]; pluginMetadataSnapshot: PluginMetadataSnapshot; }): string { return fingerprintPreparedRuntimeFacts({ input: params.input, authStore: params.authStore, - credentials: params.credentials, providerIds: params.providerIds, pluginFingerprint: fingerprintPreparedModelCatalogPlugins(params.pluginMetadataSnapshot), }); @@ -110,7 +125,7 @@ export function createPreparedModelCatalogWorkerInput(params: { const input: PreparedModelRuntimeInput = { ...(source.agentId ? { agentId: source.agentId } : {}), agentDir: source.agentDir, - inheritedAuthDir: source.agentDir, + inheritedAuthDir: source.inheritedAuthDir ?? source.agentDir, ...(source.workspaceDir ? { workspaceDir: source.workspaceDir } : {}), ...(source.readOnly ? { readOnly: true } : {}), skipCredentials: true, @@ -122,20 +137,17 @@ export function createPreparedModelCatalogWorkerInput(params: { config: source.config, }; const authStore = projectWorkerAuthStore(params.agentFacts.authStore); - const credentials = { ...params.agentFacts.credentials }; const providerIds = [...params.agentFacts.providerIds]; return { kind: "catalog", generationFingerprint: fingerprintPreparedModelCatalogGeneration({ input, authStore, - credentials, providerIds, pluginMetadataSnapshot: params.pluginMetadataSnapshot, }), input, authStore, - credentials, providerIds, }; } @@ -304,7 +316,12 @@ export function runPreparedModelCatalogWorker(params: { if (message.kind !== "catalog") { throw new Error("prepared model catalog worker returned an auth refresh result"); } - return markPreparedModelCatalogFull(message.snapshot); + const modelCatalog = markPreparedModelCatalogFull(message.snapshot); + setPreparedModelFullCatalogAuth(modelCatalog, { + authStore: message.authStore, + authModes: message.authModes, + }); + return modelCatalog; }, }); } diff --git a/src/agents/prepared-model-catalog.test.ts b/src/agents/prepared-model-catalog.test.ts index f4d6c385642a..2cea9647ac7b 100644 --- a/src/agents/prepared-model-catalog.test.ts +++ b/src/agents/prepared-model-catalog.test.ts @@ -57,6 +57,7 @@ vi.mock("./prepared-model-runtime.scoped-catalog.js", () => ({ prepareScopedReadOnlyModelCatalog: (...args: unknown[]) => mocks.prepareScopedCatalog(...args), })); +import { setPreparedModelFullCatalogAuth } from "./prepared-model-catalog-worker.js"; import { PreparedModelCatalogConfigReplacedError } from "./prepared-model-catalog.errors.js"; import { getPublishedPreparedModelCatalogOwnerSnapshot, @@ -198,8 +199,9 @@ describe("prepared model catalog access", () => { entries: [{ provider: "test", id: "discovered", name: "Discovered" }], routeVariants: [], }; - const loadFullModelCatalog = vi.fn(async () => discoveredCatalog); const { authStore, ...snapshotFacts } = fullSnapshot; + setPreparedModelFullCatalogAuth(discoveredCatalog, { authStore, authModes: {} }); + const loadFullModelCatalog = vi.fn(async () => discoveredCatalog); const snapshot = { ...snapshotFacts, modelCatalog: configuredCatalog, diff --git a/src/agents/prepared-model-catalog.ts b/src/agents/prepared-model-catalog.ts index bd1479561cf2..fa632efe054c 100644 --- a/src/agents/prepared-model-catalog.ts +++ b/src/agents/prepared-model-catalog.ts @@ -11,9 +11,14 @@ import { import { resolveLegacyInheritedAuthDir } from "./legacy-inherited-auth-dir.js"; import type { ModelCatalogEntry, ModelCatalogSnapshot } from "./model-catalog.types.js"; import { resolvePublishedModelCatalogOwner } from "./prepared-model-catalog-owner.js"; +import { getPreparedModelFullCatalogAuth } from "./prepared-model-catalog-worker.js"; import { PreparedModelCatalogConfigReplacedError } from "./prepared-model-catalog.errors.js"; import type { ResolvedPublishedModelCatalogOwner } from "./prepared-model-catalog.types.js"; -import { copyPreparedModelRuntimeAuthState } from "./prepared-model-runtime-auth.js"; +import { + getPreparedModelRuntimeAuthMaterializations, + setPreparedModelRuntimeAuthMaterializations, + setPreparedModelRuntimeAuthStore, +} from "./prepared-model-runtime-auth.js"; import { isPreparedModelCatalogFull } from "./prepared-model-runtime.facts.js"; import { acquireAgentRunPreparedModelRuntime, @@ -63,11 +68,22 @@ async function materializeRequestedModelCatalog( return snapshot; } const modelCatalog = await snapshot.loadFullModelCatalog(); - if (modelCatalog === snapshot.modelCatalog) { - return snapshot; + const fullAuth = getPreparedModelFullCatalogAuth(modelCatalog); + if (!fullAuth) { + throw new Error("prepared full model catalog omitted its auth generation"); } - const materialized = Object.freeze({ ...snapshot, modelCatalog }); - copyPreparedModelRuntimeAuthState(snapshot, materialized); + const materialized = Object.freeze({ + ...snapshot, + authModes: fullAuth.authModes, + modelCatalog, + }); + // A materialized full read owns the worker's refreshed auth generation. Do not copy the + // original loader or Gateway projection would start a second, split refresh after discovery. + setPreparedModelRuntimeAuthStore(materialized, fullAuth.authStore); + setPreparedModelRuntimeAuthMaterializations( + materialized, + getPreparedModelRuntimeAuthMaterializations(snapshot), + ); return materialized; } diff --git a/src/agents/prepared-model-catalog.worker.ts b/src/agents/prepared-model-catalog.worker.ts index 6ffc6e0de59a..24bdd3b7534b 100644 --- a/src/agents/prepared-model-catalog.worker.ts +++ b/src/agents/prepared-model-catalog.worker.ts @@ -1,5 +1,11 @@ /** Worker-thread entrypoint for complete model-catalog discovery. */ import { parentPort, workerData } from "node:worker_threads"; +import { withPluginRuntimeRegistryScope } from "../plugins/runtime/gateway-request-scope.js"; +import { + resolveAgentCredentialMapFromStore, + resolveUsableAgentCredentialModes, +} from "./agent-auth-credentials.js"; +import { resolveAmbientAgentCredentialsForDiscovery } from "./agent-auth-discovery.js"; import { overlayExternalAuthProfiles } from "./auth-profiles/external-auth.js"; import { replaceRuntimeAuthProfileStoreSnapshots } from "./auth-profiles/runtime-snapshots.js"; import { @@ -14,66 +20,114 @@ import { } from "./prepared-model-catalog-worker.js"; import { AuthStorage } from "./sessions/auth-storage.js"; +function refreshAuthStore(params: { + agentDir: string; + inheritedAuthDir?: string; + authStore: PreparedModelCatalogWorkerInput["authStore"]; + config: PreparedModelCatalogWorkerInput["input"]["config"]; + env: NodeJS.ProcessEnv; + providerIds?: readonly string[]; +}) { + const durable = preserveResolvedSecretBackedCredentials({ + next: loadAuthProfileStoreWithoutExternalProfiles(params.agentDir, { + allowKeychainPrompt: false, + ...(params.inheritedAuthDir ? { inheritedAuthDir: params.inheritedAuthDir } : {}), + }), + existing: params.authStore, + }); + const persistedProfileIds = new Set(params.authStore.runtimePersistedProfileIds ?? []); + const externalProfileIds = new Set(params.authStore.runtimeExternalProfileIds ?? []); + for (const [profileId, credential] of Object.entries(params.authStore.profiles)) { + if ( + !persistedProfileIds.has(profileId) && + !externalProfileIds.has(profileId) && + durable.profiles[profileId] === undefined + ) { + durable.profiles[profileId] = credential; + } + } + return overlayExternalAuthProfiles(durable, { + agentDir: params.agentDir, + config: params.config, + env: params.env, + ...(params.providerIds ? { externalCliProviderIds: params.providerIds } : {}), + allowKeychainPrompt: false, + }); +} + export async function runPreparedModelCatalogWorkerInput( value: PreparedModelCatalogWorkerInput | PreparedModelAuthRefreshWorkerInput, ): Promise { try { if (value.kind === "auth-refresh") { - // Durable profiles may be changed by another CLI process after this generation was built. - // Reload them before adding current external overlays, while retaining only literals whose - // unchanged SecretRefs were materialized by the owning generation. - const authStore = preserveResolvedSecretBackedCredentials({ - next: loadAuthProfileStoreWithoutExternalProfiles(value.agentDir, { - allowKeychainPrompt: false, - ...(value.inheritedAuthDir ? { inheritedAuthDir: value.inheritedAuthDir } : {}), - }), - existing: value.authStore, - }); return { status: "ok", kind: "auth-refresh", generationFingerprint: value.generationFingerprint, - authStore: overlayExternalAuthProfiles(authStore, { + authStore: refreshAuthStore({ + agentDir: value.agentDir, + inheritedAuthDir: value.inheritedAuthDir, + authStore: value.authStore, config: value.config, env: value.env, - externalCliProviderIds: value.providerIds, - allowKeychainPrompt: false, + providerIds: value.providerIds, }), }; } const { prepareAgentCatalogSource, prepareFullCatalogFacts, prepareWorkspaceBuildGroup } = await import("./prepared-model-runtime.facts.js"); - replaceRuntimeAuthProfileStoreSnapshots([ - { agentDir: value.input.agentDir, store: value.authStore }, - ]); const prepared = await prepareWorkspaceBuildGroup([value.input], "live"); const agentFacts = prepared.agentFacts[0]; if (!agentFacts) { throw new Error("prepared model catalog worker produced no agent facts"); } - const exactAgentFacts = { - ...agentFacts, - authStore: value.authStore, - templateAuthStorage: AuthStorage.inMemory({ ...value.credentials }), - credentials: value.credentials, - providerIds: [...value.providerIds], - }; const reconstructedFingerprint = fingerprintPreparedModelCatalogGeneration({ input: value.input, authStore: value.authStore, - credentials: value.credentials, providerIds: value.providerIds, pluginMetadataSnapshot: prepared.pluginGeneration.pluginMetadataSnapshot, }); if (reconstructedFingerprint !== value.generationFingerprint) { throw new Error("prepared model catalog worker reconstructed a different runtime generation"); } + // Full discovery is one point-in-time operation: refresh first, then let every provider hook + // and the returned availability projection consume the same exact store. + const authStore = refreshAuthStore({ + agentDir: value.input.agentDir, + inheritedAuthDir: value.input.inheritedAuthDir, + authStore: value.authStore, + config: value.input.config, + env: value.input.env ?? process.env, + }); + replaceRuntimeAuthProfileStoreSnapshots([{ agentDir: value.input.agentDir, store: authStore }]); + const ambientCredentials = withPluginRuntimeRegistryScope( + prepared.pluginGeneration.pluginRegistry, + () => + resolveAmbientAgentCredentialsForDiscovery({ + config: value.input.config, + env: value.input.env, + ...(value.input.workspaceDir ? { workspaceDir: value.input.workspaceDir } : {}), + }), + ); + const credentials = { + ...ambientCredentials, + ...resolveAgentCredentialMapFromStore(authStore, { config: value.input.config }), + }; + const exactAgentFacts = { + ...agentFacts, + authStore, + templateAuthStorage: AuthStorage.inMemory(credentials), + credentials, + providerIds: [...new Set([...value.providerIds, ...Object.keys(credentials)])].toSorted( + (left, right) => left.localeCompare(right), + ), + }; const source = await prepareAgentCatalogSource( exactAgentFacts, prepared.pluginGeneration, "live", false, - { authStore: value.authStore }, + { authStore }, ); const facts = await prepareFullCatalogFacts( exactAgentFacts, @@ -86,6 +140,8 @@ export async function runPreparedModelCatalogWorkerInput( kind: "catalog", generationFingerprint: value.generationFingerprint, snapshot: facts.modelCatalog, + authStore, + authModes: resolveUsableAgentCredentialModes(credentials), }; } catch (error) { return { status: "failed", error: error instanceof Error ? error.message : String(error) }; diff --git a/src/agents/prepared-model-runtime.build.ts b/src/agents/prepared-model-runtime.build.ts index 4d2a2c424904..3196186f4f79 100644 --- a/src/agents/prepared-model-runtime.build.ts +++ b/src/agents/prepared-model-runtime.build.ts @@ -118,9 +118,7 @@ function createFullModelCatalogAccess(params: { pluginGeneration: PreparedModelRuntimePluginGeneration; agentBuildCompletions: Map>; isCurrent: () => boolean; - eagerCatalog?: ModelCatalogSnapshot; }): PreparedModelRuntimeCatalogAccess { - const eagerCatalog = params.eagerCatalog; // Concurrent readers share discovery, but completed results are discarded so // refreshable providers can publish changed inventory on the next explicit read. let pending: Promise | undefined; @@ -168,9 +166,6 @@ function createFullModelCatalogAccess(params: { return promise; }, loadFullModelCatalog: () => { - if (eagerCatalog) { - return Promise.resolve(eagerCatalog); - } if (!pending) { pending = runSerializedPreparedModelRuntimeTask({ agentDir: params.agentFacts.input.agentDir, @@ -501,7 +496,6 @@ async function buildSnapshotBatch( pluginGeneration, agentBuildCompletions, isCurrent: generationGuards.get(input) ?? (() => false), - ...(catalogMode === "live" ? { eagerCatalog: catalogFacts.modelCatalog } : {}), }), ), pluginGeneration, diff --git a/src/agents/prepared-model-runtime.owner-selection.test.ts b/src/agents/prepared-model-runtime.owner-selection.test.ts index cc66fc99682a..9a42232f0f22 100644 --- a/src/agents/prepared-model-runtime.owner-selection.test.ts +++ b/src/agents/prepared-model-runtime.owner-selection.test.ts @@ -582,7 +582,7 @@ describe("prepared model runtime owner selection", () => { } }); - it("serializes on-demand full catalogs while preserving agent credentials", async () => { + it("serializes on-demand full catalogs across prepared owners", async () => { mocks.configuredAgentIds = ["agent-a", "agent-b"]; mocks.configuredWorkspaces.set("agent-a", "/tmp/shared-prepared-runtime-workspace"); mocks.configuredWorkspaces.set("agent-b", "/tmp/shared-prepared-runtime-workspace"); @@ -624,16 +624,6 @@ describe("prepared model runtime owner selection", () => { expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled(); expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledTimes(2); expect(peakActivePlans).toBe(1); - expect( - mocks.runPreparedModelCatalogWorker.mock.calls.map((call) => { - const credential = (call[0] as { input: { credentials: Record } }).input - .credentials.custom as { type?: string; key?: string } | undefined; - if (credential?.type !== "api_key") { - throw new Error("expected prepared custom API key"); - } - return credential.key; - }), - ).toEqual(["test-key:/tmp/configured-agent-a", "test-key:/tmp/configured-agent-b"]); }); it("serializes a lazy catalog plan before a superseding generation", async () => { diff --git a/src/agents/prepared-model-runtime.test-harness.ts b/src/agents/prepared-model-runtime.test-harness.ts index 2ea42927663d..789628d86c9a 100644 --- a/src/agents/prepared-model-runtime.test-harness.ts +++ b/src/agents/prepared-model-runtime.test-harness.ts @@ -102,7 +102,6 @@ vi.mock("./prepared-model-catalog-worker.js", () => ({ agentFacts: { input: unknown; authStore: unknown; - credentials: unknown; providerIds: unknown; }; }) => ({ @@ -110,7 +109,6 @@ vi.mock("./prepared-model-catalog-worker.js", () => ({ generationFingerprint: "test-generation", input: agentFacts.input, authStore: agentFacts.authStore, - credentials: agentFacts.credentials, providerIds: agentFacts.providerIds, }), runPreparedModelCatalogWorker: (...args: unknown[]) => diff --git a/src/gateway/server-methods/chat-metadata-runtime.test.ts b/src/gateway/server-methods/chat-metadata-runtime.test.ts index 101a1a8d77b7..b7f991b523f1 100644 --- a/src/gateway/server-methods/chat-metadata-runtime.test.ts +++ b/src/gateway/server-methods/chat-metadata-runtime.test.ts @@ -7,7 +7,11 @@ import { } from "../../agents/agent-auth-credentials.js"; import type { AuthProfileStore } from "../../agents/auth-profiles.js"; import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js"; -import { setPreparedModelRuntimeAuthStore } from "../../agents/prepared-model-runtime-auth.js"; +import { setPreparedModelFullCatalogAuth } from "../../agents/prepared-model-catalog-worker.js"; +import { + getPreparedModelRuntimeAuthStore, + setPreparedModelRuntimeAuthStore, +} from "../../agents/prepared-model-runtime-auth.js"; import type { PreparedModelRuntimeSnapshot } from "../../agents/prepared-model-runtime.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js"; @@ -490,10 +494,15 @@ describe("gateway chat metadata runtime", () => { "openai", "openai-chatgpt-responses", ); - const loadFullModelCatalog = vi.fn(async () => ({ + const fullCatalog = { ...owner.modelCatalog, providerOutcomes: [{ provider: "openai", status: "auth-rejected" as const }], - })); + }; + setPreparedModelFullCatalogAuth(fullCatalog, { + authStore: getPreparedModelRuntimeAuthStore(owner)!, + authModes: owner.authModes, + }); + const loadFullModelCatalog = vi.fn(async () => fullCatalog); harness.setOwner({ ...owner, loadFullModelCatalog,