From fc781a6313d582874ebc580c11953a7a55c7c253 Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:40:30 -0500 Subject: [PATCH] fix(gateway): bound model auth refresh by browse deadline --- .../server-methods/models-list-result.ts | 5 +- src/gateway/server-methods/models.test.ts | 102 +++++++++++++++++- src/gateway/server-model-catalog-auth.ts | 27 +++-- src/gateway/server-model-catalog.test.ts | 9 +- 4 files changed, 123 insertions(+), 20 deletions(-) diff --git a/src/gateway/server-methods/models-list-result.ts b/src/gateway/server-methods/models-list-result.ts index 27b1d8322512..d39e527c5949 100644 --- a/src/gateway/server-methods/models-list-result.ts +++ b/src/gateway/server-methods/models-list-result.ts @@ -56,7 +56,7 @@ import { resolveManifestProviderAuthChoices } from "../../plugins/provider-auth- import type { ProviderCatalogOutcome } from "../../plugins/provider-catalog.types.js"; import { normalizeAgentId } from "../../routing/session-key.js"; import type { GatewayAgentRuntime } from "../../shared/session-types.js"; -import { loadDeferredCatalog, resolveDeferredAuthStore } from "../server-model-catalog-auth.js"; +import { loadDeferredCatalog } from "../server-model-catalog-auth.js"; import { resolveGatewayModelThinkingProfile } from "../session-utils-model.js"; import { createModelsListAuthResolver } from "./models-list-auth-resolver.js"; import type { GatewayRequestContext } from "./types.js"; @@ -611,8 +611,7 @@ export async function buildModelsListResult( const outcomeProjection = providerOutcomes?.length ? { providerOutcomes } : {}; const preparedProjectionOwner = ownerSnapshot ?? params.catalogProjector; const metadataSnapshot = preparedProjectionOwner?.metadataSnapshot; - const preparedAuthStore = - (await resolveDeferredAuthStore(ownerSnapshot)) ?? params.catalogProjector?.authStore; + const preparedAuthStore = ownerSnapshot?.authStore ?? params.catalogProjector?.authStore; if (!metadataSnapshot || !preparedAuthStore) { throw new Error("Gateway model catalog owner omitted prepared metadata or auth state"); } diff --git a/src/gateway/server-methods/models.test.ts b/src/gateway/server-methods/models.test.ts index de698da6fec1..feb80192c6ec 100644 --- a/src/gateway/server-methods/models.test.ts +++ b/src/gateway/server-methods/models.test.ts @@ -11,11 +11,13 @@ import { loadAuthProfileStoreWithoutExternalProfiles, replaceRuntimeAuthProfileStoreSnapshots, } from "../../agents/auth-profiles.js"; +import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js"; import { withEnvAsync } from "../../test-utils/env.js"; import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js"; +import { setPendingGatewayModelCatalogAuthStore } from "../server-model-catalog-auth.js"; import { modelsHandlers } from "./models.js"; import type { RespondFn } from "./types.js"; @@ -61,6 +63,7 @@ function requestModelsList(params: { }) => Promise>>; reqId?: string; includeProviderCapabilities?: boolean; + deferredAuthStore?: Promise; }) { const respond = params.respond ?? vi.fn(); const runtimeConfig = params.runtimeConfig ?? ({} as OpenClawConfig); @@ -109,12 +112,16 @@ function requestModelsList(params: { ) => { const entries = await params.loadGatewayModelCatalog(loadParams); const owner = resolveOwnerFacts(); - return { + const snapshot = { ...owner, ...(loadParams?.agentId ? { agentId: loadParams.agentId } : {}), entries, routeVariants: entries, }; + if (params.deferredAuthStore) { + setPendingGatewayModelCatalogAuthStore(snapshot, params.deferredAuthStore); + } + return snapshot; }, readPreparedGatewayModelCatalogSnapshot: async () => ({ ...resolveOwnerFacts(), @@ -507,6 +514,99 @@ describe("models.list", () => { }); }); + it("does not let deferred auth outlive the configured browse deadline", async () => { + await withoutOpenAIEnvAuth(async () => { + const authStore = createDeferred(); + const runtimeConfig = { + models: { + providers: { + openai: { + baseUrl: "https://openai.example.com", + models: [{ id: "gpt-test", name: "GPT Test" }], + }, + }, + }, + } as unknown as OpenClawConfig; + + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + const { request, respond } = requestModelsList({ + view: "configured", + runtimeConfig, + deferredAuthStore: authStore.promise, + loadGatewayModelCatalog: vi.fn(() => + Promise.resolve([{ id: "gpt-test", name: "GPT Test", provider: "openai" }]), + ), + reqId: "req-models-list-slow-auth", + }); + + await vi.advanceTimersByTimeAsync(800); + await vi.runOnlyPendingTimersAsync(); + await request; + + expect(respond).toHaveBeenCalledWith( + true, + { + models: [ + { + id: "gpt-test", + name: "GPT Test", + provider: "openai", + agentRuntime: { id: "openclaw", source: "implicit" }, + available: false, + }, + ], + }, + undefined, + ); + } finally { + vi.useRealTimers(); + } + }); + }); + + it("keeps prepared auth when deferred auth refresh rejects", async () => { + await withoutOpenAIEnvAuth(async () => { + const runtimeConfig = { + models: { + providers: { + openai: { + baseUrl: "https://openai.example.com", + models: [{ id: "gpt-test", name: "GPT Test" }], + }, + }, + }, + } as unknown as OpenClawConfig; + const { request, respond } = requestModelsList({ + view: "configured", + runtimeConfig, + deferredAuthStore: Promise.reject(new Error("auth refresh failed")), + loadGatewayModelCatalog: vi.fn(() => + Promise.resolve([{ id: "gpt-test", name: "GPT Test", provider: "openai" }]), + ), + reqId: "req-models-list-rejected-auth", + }); + + await request; + + expect(respond).toHaveBeenCalledWith( + true, + { + models: [ + { + id: "gpt-test", + name: "GPT Test", + provider: "openai", + agentRuntime: { id: "openclaw", source: "implicit" }, + available: false, + }, + ], + }, + undefined, + ); + }); + }); + it("does not block wildcard provider inventory on slow full discovery", async () => { const catalog = createDeferred(); const loadGatewayModelCatalog = vi.fn(() => catalog.promise); diff --git a/src/gateway/server-model-catalog-auth.ts b/src/gateway/server-model-catalog-auth.ts index b2f6aced9429..7bee50f563f3 100644 --- a/src/gateway/server-model-catalog-auth.ts +++ b/src/gateway/server-model-catalog-auth.ts @@ -13,30 +13,29 @@ export function setPendingGatewayModelCatalogAuthStore( void pending.catch(() => undefined); } -export async function resolveDeferredAuthStore( - snapshot: - | { - authStore?: AuthProfileStore; - } - | undefined, -): Promise { - return snapshot - ? ((await pendingAuthStoreBySnapshot.get(snapshot)) ?? snapshot.authStore) - : undefined; -} - -export function loadDeferredCatalog( +export async function loadDeferredCatalog( context: Pick, agentId: string, readOnly: boolean, ) { // This timing control is Gateway-private; exposing it on GatewayRequestContext would turn an // implementation detail into a Plugin SDK contract. - return context.loadGatewayModelCatalogSnapshot({ + const snapshot = await context.loadGatewayModelCatalogSnapshot({ agentId, deferAuthRefresh: true, readOnly, } as NonNullable[0]> & { deferAuthRefresh: true; }); + const pendingAuthStore = pendingAuthStoreBySnapshot.get(snapshot); + if (!pendingAuthStore) { + return snapshot; + } + try { + return { ...snapshot, authStore: (await pendingAuthStore) ?? snapshot.authStore }; + } catch { + // Auth refresh is opportunistic browse data. Preserve the exact prepared generation when + // external credential discovery fails instead of failing the model catalog response. + return snapshot; + } } diff --git a/src/gateway/server-model-catalog.test.ts b/src/gateway/server-model-catalog.test.ts index b37b83182e9e..8c89e32a3b1a 100644 --- a/src/gateway/server-model-catalog.test.ts +++ b/src/gateway/server-model-catalog.test.ts @@ -3,7 +3,7 @@ import type { ModelCatalogSnapshot } from "../agents/model-catalog.types.js"; import type { PublishedModelCatalogOwnerCandidate } from "../agents/prepared-model-catalog.types.js"; import { setPreparedModelRuntimeAuthStoreLoader } from "../agents/prepared-model-runtime-auth.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { resolveDeferredAuthStore } from "./server-model-catalog-auth.js"; +import { loadDeferredCatalog } from "./server-model-catalog-auth.js"; import { loadGatewayModelCatalog, loadGatewayModelCatalogSnapshot, @@ -120,7 +120,12 @@ describe("gateway prepared model catalog", () => { getConfig: () => config, loadPublishedPreparedModelCatalogOwnerSnapshot, }); - await expect(resolveDeferredAuthStore(deferred)).resolves.toEqual( + const loaded = await loadDeferredCatalog( + { loadGatewayModelCatalogSnapshot: vi.fn(async () => deferred) } as never, + "main", + true, + ); + expect(loaded.authStore).toEqual( expect.objectContaining({ profiles: { "openai:refreshed": expect.any(Object) } }), ); expect(loadAuthStore).toHaveBeenCalledWith(["openai"]);