diff --git a/config/knip.config.ts b/config/knip.config.ts index 06f02ce4f507..c735c4dcda0b 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -157,6 +157,7 @@ const rootEntries = [ "src/config/sessions/session-accessor.sqlite-archive.worker.ts!", "src/state/openclaw-database-verify.worker.ts!", "src/agents/model-provider-auth.worker.ts!", + "src/agents/prepared-model-catalog.worker.ts!", // Loaded by URL from setup-inference-detection.ts; no static import edge exists. "src/system-agent/setup-inference-detection.worker.ts!", // Split runtime loaded through a path assembled in subagent-registry.ts. diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 9d70073ec125..994d8f46aa71 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -112,6 +112,7 @@ const requiredPathGroups = [ "scripts/postinstall-bundled-plugins.mjs", "dist/agents/compaction-planning.worker.js", "dist/agents/model-provider-auth.worker.js", + "dist/agents/prepared-model-catalog.worker.js", "dist/audit/audit-event-writer.worker.js", "dist/config/sessions/session-accessor.sqlite-archive.worker.js", "dist/config/sessions/session-transcript-reconcile.worker.js", diff --git a/src/agents/agent-auth-discovery.ts b/src/agents/agent-auth-discovery.ts index e75463c7171e..185d75e45464 100644 --- a/src/agents/agent-auth-discovery.ts +++ b/src/agents/agent-auth-discovery.ts @@ -15,6 +15,7 @@ import { ensureAuthProfileStore, ensureAuthProfileStoreWithoutExternalProfiles, } from "./auth-profiles/store.js"; +import type { AuthProfileStore } from "./auth-profiles/types.js"; /** Options for discovering credentials without prompting for secret material. */ export type DiscoverAuthStorageOptions = { @@ -83,11 +84,11 @@ export function resolveAmbientAgentCredentialsForDiscovery( return credentials; } -/** Resolves agent credentials from auth profiles, env, and synthetic auth hooks. */ -export function resolveAgentCredentialsForDiscovery( +/** Resolves the effective auth store and provider credentials for one discovery generation. */ +export function resolveAgentDiscoveryAuthFacts( agentDir: string, options?: DiscoverAuthStorageOptions, -): AgentCredentialMap { +): { store: AuthProfileStore; credentials: AgentCredentialMap } { const storeOptions = { allowKeychainPrompt: false, ...(options?.config ? { config: options.config } : {}), @@ -124,5 +125,13 @@ export function resolveAgentCredentialsForDiscovery( // Ambient auth is a lifecycle-owned fallback. Agent-local profiles remain authoritative. credentials[provider] = credential; } - return credentials; + return { store, credentials }; +} + +/** Resolves agent credentials from auth profiles, env, and synthetic auth hooks. */ +export function resolveAgentCredentialsForDiscovery( + agentDir: string, + options?: DiscoverAuthStorageOptions, +): AgentCredentialMap { + return resolveAgentDiscoveryAuthFacts(agentDir, options).credentials; } diff --git a/src/agents/agent-model-discovery.ts b/src/agents/agent-model-discovery.ts index 2d25a27b97ad..c245b38b6fe5 100644 --- a/src/agents/agent-model-discovery.ts +++ b/src/agents/agent-model-discovery.ts @@ -11,7 +11,7 @@ import { } from "../plugins/provider-runtime.js"; import { isRecord } from "../utils.js"; import { - resolveAgentCredentialsForDiscovery, + resolveAgentDiscoveryAuthFacts, type DiscoverAuthStorageOptions, } from "./agent-auth-discovery.js"; import { resolveModelPluginMetadataSnapshot } from "./model-discovery-context.js"; @@ -196,9 +196,23 @@ export function discoverAuthStorage( agentDir: string, options?: DiscoverAuthStorageOptions, ): AgentAuthStorage { - const credentials = - options?.skipCredentials === true ? {} : resolveAgentCredentialsForDiscovery(agentDir, options); - return AuthStorage.inMemory(credentials); + return discoverAuthStorageFacts(agentDir, options).authStorage; +} + +/** Captures the effective profile store and its AuthStorage projection as one generation. */ +export function discoverAuthStorageFacts( + agentDir: string, + options?: DiscoverAuthStorageOptions, +): { + authStorage: AgentAuthStorage; + store: import("./auth-profiles/types.js").AuthProfileStore; + credentials: import("./agent-auth-credentials.js").AgentCredentialMap; +} { + const facts = + options?.skipCredentials === true + ? { store: { version: 1, profiles: {} }, credentials: {} } + : resolveAgentDiscoveryAuthFacts(agentDir, options); + return { ...facts, authStorage: AuthStorage.inMemory(facts.credentials) }; } /** Creates the model registry used by agent model discovery. */ diff --git a/src/agents/models-config.plan.ts b/src/agents/models-config.plan.ts index 44f2d24d1333..b86632230e35 100644 --- a/src/agents/models-config.plan.ts +++ b/src/agents/models-config.plan.ts @@ -8,6 +8,7 @@ import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot import type { ProviderCatalogOutcome } from "../plugins/provider-catalog.types.js"; import type { PreparedProviderStaticCatalog } from "../plugins/provider-discovery.js"; import { isRecord } from "../utils.js"; +import type { AuthProfileStore } from "./auth-profiles/types.js"; import { mergeProviders, mergeWithExistingProviderSecrets, @@ -32,6 +33,7 @@ type ModelsConfig = NonNullable; /** Dependency hook for resolving implicit model providers while planning models.json. */ type ResolveImplicitProvidersForModelsJson = (params: { agentDir: string; + authStore?: AuthProfileStore; config: OpenClawConfig; discoveryAuthConfig?: OpenClawConfig; env: NodeJS.ProcessEnv; @@ -102,6 +104,7 @@ function buildPluginCatalogWrites( async function resolveProvidersForModelsJsonWithDeps( params: { cfg: OpenClawConfig; + authStore?: AuthProfileStore; discoveryAuthConfig?: OpenClawConfig; agentDir: string; env: NodeJS.ProcessEnv; @@ -131,6 +134,7 @@ async function resolveProvidersForModelsJsonWithDeps( const resolveImplicitProvidersImpl = deps?.resolveImplicitProviders ?? resolveImplicitProviders; const implicitProviders = await resolveImplicitProvidersImpl({ agentDir, + ...(params.authStore ? { authStore: params.authStore } : {}), config: cfg, ...(params.discoveryAuthConfig ? { discoveryAuthConfig: params.discoveryAuthConfig } : {}), env, @@ -220,6 +224,7 @@ function filterWritableProviders( async function planOpenClawModelsJsonWithDeps( params: { cfg: OpenClawConfig; + authStore?: AuthProfileStore; discoveryAuthConfig?: OpenClawConfig; sourceConfigForSecrets?: OpenClawConfig; agentDir: string; @@ -245,6 +250,7 @@ async function planOpenClawModelsJsonWithDeps( const providers = await resolveProvidersForModelsJsonWithDeps( { cfg, + ...(params.authStore ? { authStore: params.authStore } : {}), ...(params.discoveryAuthConfig ? { discoveryAuthConfig: params.discoveryAuthConfig } : {}), agentDir, env, diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index c118014541b9..f3067568b0c7 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -30,6 +30,7 @@ import { resolveDefaultAgentId, } from "./agent-scope.js"; import { resolveAuthProfileDatabasePath } from "./auth-profiles/sqlite.js"; +import type { AuthProfileStore } from "./auth-profiles/types.js"; import { MODELS_JSON_STATE, type ModelsJsonReadyResult, @@ -68,7 +69,9 @@ type EnsureOpenClawModelsJsonOptions = { onProviderCatalogOutcome?: (outcome: ProviderCatalogOutcome) => void; }; -type PlanOpenClawModelsJsonSourceOptions = EnsureOpenClawModelsJsonOptions; +type PlanOpenClawModelsJsonSourceOptions = EnsureOpenClawModelsJsonOptions & { + authStore?: AuthProfileStore; +}; type PlannedOpenClawModelsJsonSource = Readonly<{ agentDir: string; @@ -536,6 +539,7 @@ export async function planOpenClawModelsJsonSource( const env = createConfigRuntimeEnv(cfg, options.env); const plan = await planOpenClawModelsJson({ cfg, + ...(options.authStore ? { authStore: options.authStore } : {}), discoveryAuthConfig: resolved.discoveryAuthConfig, sourceConfigForSecrets: resolved.sourceConfigForSecrets, agentDir, diff --git a/src/agents/prepared-model-catalog-owner.ts b/src/agents/prepared-model-catalog-owner.ts index aa68abe9e922..4500e11abf81 100644 --- a/src/agents/prepared-model-catalog-owner.ts +++ b/src/agents/prepared-model-catalog-owner.ts @@ -42,6 +42,9 @@ export function resolvePublishedModelCatalogOwner( agentDir: snapshot.agentDir, workspaceDir, config: snapshot.config, + authModes: snapshot.authModes, + authStore: snapshot.authStore, + metadataSnapshot: snapshot.metadataSnapshot, modelCatalog: snapshot.modelCatalog, }); } diff --git a/src/agents/prepared-model-catalog-worker.integration.test.ts b/src/agents/prepared-model-catalog-worker.integration.test.ts new file mode 100644 index 000000000000..15c3bdfc4539 --- /dev/null +++ b/src/agents/prepared-model-catalog-worker.integration.test.ts @@ -0,0 +1,192 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; +import { + clearRuntimeAuthProfileStoreSnapshots, + replaceRuntimeAuthProfileStoreSnapshots, +} from "./auth-profiles/runtime-snapshots.js"; +import { + encodePluginModelCatalogRelativePath, + PLUGIN_MODEL_CATALOG_GENERATED_BY, + replacePersistedPluginModelCatalogs, +} from "./plugin-model-catalog.js"; +import { startSerializedSnapshotBuild } from "./prepared-model-runtime.build.js"; + +const PROVIDER_ID = "worker-catalog-fixture"; +const SHARED_AUTH_PROVIDER_ID = `${PROVIDER_ID}-shared-auth`; +const PLUGIN_ID = "worker-catalog-fixture"; +const PROFILE_ID = `${SHARED_AUTH_PROVIDER_ID}:named`; +const MATERIALIZED_SECRET = "materialized-worker-secret-not-real"; +const UNRELATED_SECRET = "unrelated-worker-secret-not-real"; +const tempDirs = useAutoCleanupTempDirTracker((cleanup) => { + afterEach(() => { + clearRuntimeAuthProfileStoreSnapshots(); + closeOpenClawAgentDatabasesForTest(); + cleanup(); + }); +}); + +function writeFixturePlugin(params: { root: string; spinMs: number }): string { + const pluginDir = path.join(params.root, "plugin"); + fs.mkdirSync(pluginDir, { recursive: true }); + const pluginFile = path.join(pluginDir, "index.cjs"); + fs.writeFileSync( + pluginFile, + `const fs = require("node:fs"); +module.exports = { + id: ${JSON.stringify(PLUGIN_ID)}, + register(api) { + api.registerProvider({ + id: ${JSON.stringify(PROVIDER_ID)}, + label: "Worker catalog fixture", + auth: [], + augmentModelCatalog(context) { + fs.appendFileSync(process.env.OPENCLAW_WORKER_CATALOG_MARKER, "start\\n"); + const until = Date.now() + ${params.spinMs}; + while (Date.now() < until) {} + const hasSqlite = context.entries.some((entry) => + entry.provider === ${JSON.stringify(PROVIDER_ID)} && entry.id === "sqlite-model"); + const hasShared = context.resolveProviderApiKey(${JSON.stringify(SHARED_AUTH_PROVIDER_ID)}).apiKey === ${JSON.stringify(MATERIALIZED_SECRET)}; + const hasUnrelated = context.resolveProviderApiKey("unrelated-provider").apiKey === ${JSON.stringify(UNRELATED_SECRET)}; + fs.appendFileSync(process.env.OPENCLAW_WORKER_CATALOG_MARKER, "done\\n"); + return [{ + provider: ${JSON.stringify(PROVIDER_ID)}, + id: \`proof-sqlite-\${hasSqlite}-shared-\${hasShared}-unrelated-\${hasUnrelated}\`, + name: "Worker boundary proof", + }]; + }, + }); + }, +}; +`, + "utf8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify({ + id: PLUGIN_ID, + providers: [PROVIDER_ID], + configSchema: { type: "object", additionalProperties: false, properties: {} }, + modelCatalog: { discovery: { [PROVIDER_ID]: "runtime" }, runtimeAugment: true }, + }), + "utf8", + ); + return pluginFile; +} + +async function createStaticSnapshot(spinMs: number) { + const root = tempDirs.make("openclaw-model-catalog-worker-"); + const stateDir = path.join(root, "state"); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + const workspaceDir = path.join(root, "workspace"); + const marker = path.join(root, "worker-marker.txt"); + fs.mkdirSync(agentDir, { recursive: true }); + fs.mkdirSync(workspaceDir, { recursive: true }); + const pluginFile = writeFixturePlugin({ root, spinMs }); + const env = { + ...process.env, + OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1", + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_WORKER_CATALOG_MARKER: marker, + }; + const config = { + agents: { defaults: { model: `${PROVIDER_ID}/sqlite-model` } }, + plugins: { + allow: [PLUGIN_ID], + load: { paths: [pluginFile] }, + entries: { [PLUGIN_ID]: { enabled: true } }, + }, + } satisfies OpenClawConfig; + replaceRuntimeAuthProfileStoreSnapshots([ + { + agentDir, + store: { + version: 1, + profiles: { + [PROFILE_ID]: { + type: "token", + provider: SHARED_AUTH_PROVIDER_ID, + token: MATERIALIZED_SECRET, + tokenRef: { source: "env", provider: "default", id: "SHARED_SECRET_REF" }, + }, + "unrelated-provider:default": { + type: "api_key", + provider: "unrelated-provider", + key: UNRELATED_SECRET, + keyRef: { source: "env", provider: "default", id: "UNRELATED_SECRET_REF" }, + }, + }, + order: { [SHARED_AUTH_PROVIDER_ID]: [PROFILE_ID] }, + }, + }, + ]); + replacePersistedPluginModelCatalogs({ + agentDir, + pluginCatalogWrites: { + [encodePluginModelCatalogRelativePath(PLUGIN_ID)]: JSON.stringify({ + generatedBy: PLUGIN_MODEL_CATALOG_GENERATED_BY, + providers: { + [PROVIDER_ID]: { + baseUrl: "https://worker-catalog.invalid/v1", + api: "openai-completions", + apiKey: "WORKER_CATALOG_API_KEY", + models: [{ id: "sqlite-model", name: "SQLite model" }], + }, + }, + }), + }, + }); + let current = true; + const build = await startSerializedSnapshotBuild( + { agentId: "main", agentDir, inheritedAuthDir: agentDir, workspaceDir, config, env }, + new Map(), + 30_000, + "static", + () => current, + ).pending; + return { marker, snapshot: build.snapshot, supersede: () => (current = false) }; +} + +async function waitForMarker(marker: string): Promise { + await expect.poll(() => fs.existsSync(marker), { timeout: 30_000 }).toBe(true); +} + +describe("prepared model catalog worker boundary", () => { + it("keeps the event loop responsive and preserves complete prepared auth and SQLite facts", async () => { + const fixture = await createStaticSnapshot(750); + let settled = false; + const first = fixture.snapshot.loadFullModelCatalog?.().finally(() => { + settled = true; + }); + const second = fixture.snapshot.loadFullModelCatalog?.(); + await waitForMarker(fixture.marker); + + expect(settled).toBe(false); + const [catalog, sharedCatalog] = await Promise.all([first, second]); + expect(sharedCatalog).toBe(catalog); + expect(catalog?.entries).toContainEqual( + expect.objectContaining({ + provider: PROVIDER_ID, + id: "proof-sqlite-true-shared-true-unrelated-true", + }), + ); + await expect(fixture.snapshot.loadFullModelCatalog?.()).resolves.toBe(catalog); + expect(fs.readFileSync(fixture.marker, "utf8")).toBe("start\ndone\n"); + }); + + it("terminates discovery when its owning generation is superseded", async () => { + const fixture = await createStaticSnapshot(10_000); + const catalog = fixture.snapshot.loadFullModelCatalog?.(); + await waitForMarker(fixture.marker); + fixture.supersede(); + + await expect(catalog).rejects.toThrow("superseded"); + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + expect(fs.readFileSync(fixture.marker, "utf8")).toBe("start\n"); + }); +}); diff --git a/src/agents/prepared-model-catalog-worker.test.ts b/src/agents/prepared-model-catalog-worker.test.ts new file mode 100644 index 000000000000..b46ad2d75f2f --- /dev/null +++ b/src/agents/prepared-model-catalog-worker.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; +import { createPreparedModelCatalogWorkerInput } from "./prepared-model-catalog-worker.js"; +import type { PreparedModelRuntimeAgentFacts } from "./prepared-model-runtime.facts.js"; + +vi.mock("../plugins/manifest-registry-installed.js", () => ({ + resolveInstalledManifestRegistryIndexFingerprint: () => "test-plugin-index", +})); + +describe("prepared model catalog worker input", () => { + it("preserves the complete materialized auth generation without transferring SecretRefs", () => { + const authStore = { + version: 1, + profiles: { + "shared:named": { + type: "oauth" as const, + provider: "shared", + access: "access-token", + refresh: "refresh-token", + expires: 4_102_444_800_000, + projectId: "project-id", + }, + "unrelated:default": { + type: "api_key" as const, + provider: "unrelated", + key: "materialized-key", + keyRef: { source: "env" as const, provider: "default", id: "UNRELATED_KEY" }, + }, + }, + order: { shared: ["shared:named"] }, + lastGood: { shared: "shared:named" }, + }; + const workerInput = createPreparedModelCatalogWorkerInput({ + agentFacts: { + input: { agentDir: "/tmp/agent", config: {}, workspaceDir: "/tmp/workspace" }, + env: {}, + authStore, + credentials: { shared: { type: "oauth", ...authStore.profiles["shared:named"] } }, + providerIds: ["configured"], + configuredModelRefs: [], + configuredRuntimeModels: [], + configuredGeneratedCatalogPluginIds: [], + templateAuthStorage: {} as never, + } satisfies PreparedModelRuntimeAgentFacts, + pluginMetadataSnapshot: { + policyHash: "test-policy", + configFingerprint: "test-config", + index: {} as never, + plugins: [], + } as unknown as PluginMetadataSnapshot, + }); + + const cloned = structuredClone(workerInput); + expect(cloned.authStore.profiles).toEqual({ + "shared:named": authStore.profiles["shared:named"], + "unrelated:default": { + type: "api_key", + provider: "unrelated", + key: "materialized-key", + }, + }); + expect(cloned.authStore.order).toEqual(authStore.order); + expect(cloned.authStore.lastGood).toEqual(authStore.lastGood); + }); +}); diff --git a/src/agents/prepared-model-catalog-worker.ts b/src/agents/prepared-model-catalog-worker.ts new file mode 100644 index 000000000000..6d14ed8021d0 --- /dev/null +++ b/src/agents/prepared-model-catalog-worker.ts @@ -0,0 +1,232 @@ +/** Runs complete model-catalog discovery outside the Gateway event loop. */ +import path from "node:path"; +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 { AuthProfileCredential, AuthProfileStore } from "./auth-profiles/types.js"; +import type { ModelCatalogSnapshot } from "./model-catalog.types.js"; +import { PreparedModelRuntimePublicationSupersededError } from "./prepared-model-runtime.errors.js"; +import { + fingerprintPreparedRuntimeFacts, + markPreparedModelCatalogFull, + 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<{ + generationFingerprint: string; + input: PreparedModelRuntimeInput; + authStore: AuthProfileStore; + credentials: Readonly; + providerIds: readonly string[]; +}>; + +export type PreparedModelCatalogWorkerResult = + | Readonly<{ + status: "ok"; + generationFingerprint: string; + snapshot: ModelCatalogSnapshot; + }> + | Readonly<{ status: "failed"; error: string }>; + +// 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; +const PREPARED_MODEL_CATALOG_WORKER_GENERATION_POLL_MS = 25; + +function fingerprintPreparedModelCatalogPlugins(snapshot: PluginMetadataSnapshot): string { + return fingerprintPreparedRuntimeFacts({ + config: snapshot.configFingerprint ?? null, + index: resolveInstalledManifestRegistryIndexFingerprint(snapshot.index), + pluginIds: snapshot.pluginIds ?? null, + policy: snapshot.policyHash, + workspaceDir: snapshot.workspaceDir ?? null, + }); +} + +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), + }); +} + +function projectWorkerAuthStore(store: AuthProfileStore): AuthProfileStore { + return { + ...store, + profiles: Object.fromEntries( + Object.entries(store.profiles).map(([profileId, credential]) => { + // SecretRefs have already been materialized by the lifecycle owner. Do not transfer the + // original reference descriptors to a worker that only needs the resolved credential. + const projected = { ...credential } as AuthProfileCredential & Record; + delete projected.keyRef; + delete projected.tokenRef; + return [profileId, projected]; + }), + ), + }; +} + +export function createPreparedModelCatalogWorkerInput(params: { + agentFacts: PreparedModelRuntimeAgentFacts; + pluginMetadataSnapshot: PluginMetadataSnapshot; +}): PreparedModelCatalogWorkerInput { + const source = params.agentFacts.input; + // Registries and closures stay process-local. The worker reconstructs them from this exact + // lifecycle plan and receives only already-materialized auth facts. + const input: PreparedModelRuntimeInput = { + ...(source.agentId ? { agentId: source.agentId } : {}), + agentDir: source.agentDir, + inheritedAuthDir: source.agentDir, + ...(source.workspaceDir ? { workspaceDir: source.workspaceDir } : {}), + ...(source.readOnly ? { readOnly: true } : {}), + skipCredentials: true, + env: { ...params.agentFacts.env }, + ...(source.allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true } : {}), + ...(source.runtimePluginSelections + ? { runtimePluginSelections: source.runtimePluginSelections } + : {}), + config: source.config, + }; + const authStore = projectWorkerAuthStore(params.agentFacts.authStore); + const credentials = { ...params.agentFacts.credentials }; + const providerIds = [...params.agentFacts.providerIds]; + return { + generationFingerprint: fingerprintPreparedModelCatalogGeneration({ + input, + authStore, + credentials, + providerIds, + pluginMetadataSnapshot: params.pluginMetadataSnapshot, + }), + input, + authStore, + credentials, + providerIds, + }; +} + +function resolvePreparedModelCatalogWorkerUrl(currentModuleUrl = import.meta.url): URL { + const currentPath = fileURLToPath(currentModuleUrl); + const normalized = currentPath.replaceAll(path.sep, "/"); + const distMarker = "/dist/"; + const distIndex = normalized.lastIndexOf(distMarker); + if (distIndex >= 0) { + const distRoot = currentPath.slice(0, distIndex + distMarker.length); + return pathToFileURL(path.join(distRoot, "agents", "prepared-model-catalog.worker.js")); + } + const extension = path.extname(currentPath) || ".js"; + return new URL(`./prepared-model-catalog.worker${extension}`, currentModuleUrl); +} + +export function runPreparedModelCatalogWorker(params: { + input: PreparedModelCatalogWorkerInput; + isCurrent: () => boolean; +}): Promise { + const superseded = () => + new PreparedModelRuntimePublicationSupersededError( + `prepared model runtime catalog generation was superseded for ${params.input.input.agentDir}`, + ); + if (!params.isCurrent()) { + return Promise.reject(superseded()); + } + + const workerUrl = resolvePreparedModelCatalogWorkerUrl(); + let worker: Worker; + try { + worker = new Worker(workerUrl, { + workerData: params.input, + ...(workerUrl.pathname.endsWith(".ts") ? { execArgv: ["--import", "tsx"] } : {}), + // Establish state/config environment before worker module initialization reads process.env. + env: { ...process.env, ...params.input.input.env }, + }); + } catch (error) { + return Promise.reject(error instanceof Error ? error : new Error(String(error))); + } + worker.unref(); + + return new Promise((resolve, reject) => { + let settled = false; + type Outcome = + | { status: "resolved"; snapshot: ModelCatalogSnapshot } + | { status: "rejected"; error: Error }; + const settle = (outcome: Outcome, terminate = true) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + clearInterval(generationPoll); + worker.removeAllListeners(); + const finish = () => { + if (outcome.status === "resolved") { + resolve(markPreparedModelCatalogFull(outcome.snapshot)); + } else { + reject(outcome.error); + } + }; + if (!terminate) { + finish(); + return; + } + void worker.terminate().then(finish, (terminationError: unknown) => { + const error = + terminationError instanceof Error + ? terminationError + : new Error(String(terminationError)); + reject( + outcome.status === "rejected" + ? new AggregateError([outcome.error, error], outcome.error.message) + : new Error("prepared model catalog worker termination failed", { cause: error }), + ); + }); + }; + const fail = (error: Error, terminate = true) => + settle({ status: "rejected", error }, terminate); + const timeout = setTimeout( + () => fail(new Error("prepared model catalog worker timed out")), + PREPARED_MODEL_CATALOG_WORKER_TIMEOUT_MS, + ); + timeout.unref(); + const generationPoll = setInterval(() => { + if (!params.isCurrent()) { + fail(superseded()); + } + }, PREPARED_MODEL_CATALOG_WORKER_GENERATION_POLL_MS); + generationPoll.unref(); + + worker.once("message", (message: PreparedModelCatalogWorkerResult) => { + if (!params.isCurrent()) { + fail(superseded()); + } else if (message.status === "failed") { + fail(new Error(message.error)); + } else if (message.generationFingerprint !== params.input.generationFingerprint) { + fail(new Error("prepared model catalog worker returned a stale generation")); + } else { + settle({ status: "resolved", snapshot: message.snapshot }); + } + }); + worker.once("error", (error) => + fail(error instanceof Error ? error : new Error(String(error))), + ); + worker.once("exit", (code) => + fail( + new Error( + `prepared model catalog worker exited with code ${code} before returning a result`, + ), + false, + ), + ); + }); +} diff --git a/src/agents/prepared-model-catalog.types.ts b/src/agents/prepared-model-catalog.types.ts index 8803826bfc88..a26a14e9b55d 100644 --- a/src/agents/prepared-model-catalog.types.ts +++ b/src/agents/prepared-model-catalog.types.ts @@ -1,4 +1,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; +import type { PreparedAgentCredentialModes } from "./agent-auth-credentials.js"; +import type { AuthProfileStore } from "./auth-profiles/types.js"; import type { ModelCatalogSnapshot } from "./model-catalog.types.js"; export type PublishedModelCatalogOwnerCandidate = Readonly<{ @@ -6,6 +9,9 @@ export type PublishedModelCatalogOwnerCandidate = Readonly<{ agentDir: string; workspaceDir?: string; config: OpenClawConfig; + authModes: PreparedAgentCredentialModes; + authStore: AuthProfileStore; + metadataSnapshot: PluginMetadataSnapshot; modelCatalog: ModelCatalogSnapshot; }>; @@ -14,5 +20,8 @@ export type ResolvedPublishedModelCatalogOwner = Readonly<{ agentDir: string; workspaceDir: string; config: OpenClawConfig; + authModes: PreparedAgentCredentialModes; + authStore: AuthProfileStore; + metadataSnapshot: PluginMetadataSnapshot; modelCatalog: ModelCatalogSnapshot; }>; diff --git a/src/agents/prepared-model-catalog.worker.ts b/src/agents/prepared-model-catalog.worker.ts new file mode 100644 index 000000000000..9ec386cbbe29 --- /dev/null +++ b/src/agents/prepared-model-catalog.worker.ts @@ -0,0 +1,72 @@ +/** Worker-thread entrypoint for complete model-catalog discovery. */ +import { parentPort, workerData } from "node:worker_threads"; +import { replaceRuntimeAuthProfileStoreSnapshots } from "./auth-profiles/runtime-snapshots.js"; +import { + fingerprintPreparedModelCatalogGeneration, + type PreparedModelCatalogWorkerInput, + type PreparedModelCatalogWorkerResult, +} from "./prepared-model-catalog-worker.js"; +import { + prepareAgentCatalogSource, + prepareFullCatalogFacts, + prepareWorkspaceBuildGroup, +} from "./prepared-model-runtime.facts.js"; +import { AuthStorage } from "./sessions/auth-storage.js"; + +export async function runPreparedModelCatalogWorkerInput( + value: PreparedModelCatalogWorkerInput, +): Promise { + try { + 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"); + } + const source = await prepareAgentCatalogSource( + exactAgentFacts, + prepared.pluginGeneration, + "live", + false, + { authStore: value.authStore }, + ); + const facts = await prepareFullCatalogFacts( + exactAgentFacts, + prepared.pluginGeneration, + "live", + source, + ); + return { + status: "ok", + generationFingerprint: value.generationFingerprint, + snapshot: facts.modelCatalog, + }; + } catch (error) { + return { status: "failed", error: error instanceof Error ? error.message : String(error) }; + } +} + +if (parentPort) { + const send: (message: PreparedModelCatalogWorkerResult) => void = + parentPort.postMessage.bind(parentPort); + send(await runPreparedModelCatalogWorkerInput(workerData as PreparedModelCatalogWorkerInput)); +} diff --git a/src/agents/prepared-model-runtime.build.ts b/src/agents/prepared-model-runtime.build.ts index 5e6d2b3baece..d7b1ea7a4432 100644 --- a/src/agents/prepared-model-runtime.build.ts +++ b/src/agents/prepared-model-runtime.build.ts @@ -6,6 +6,10 @@ 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 { + createPreparedModelCatalogWorkerInput, + runPreparedModelCatalogWorker, +} from "./prepared-model-catalog-worker.js"; import { setPreparedModelRuntimeAuthMaterializations } from "./prepared-model-runtime-auth.js"; import { PreparedModelRuntimePublicationSupersededError } from "./prepared-model-runtime.errors.js"; import { @@ -131,24 +135,15 @@ function createFullModelCatalogAccess(params: { // Full inventory belongs to explicit control-plane reads. The generation queue // prevents a stale plan from overlapping or following a replacement build. assertCurrent(); - // Agent facts remain bound to the published turn generation. Auth mutations advance - // that owner generation, while plugin facts remain bound to this exact generation. - const fullCatalogMode: PreparedModelRuntimeCatalogMode = "live"; - const catalogSource = await prepareAgentCatalogSource( - params.agentFacts, - params.pluginGeneration, - fullCatalogMode, - false, - ); + const catalog = await runPreparedModelCatalogWorker({ + input: createPreparedModelCatalogWorkerInput({ + agentFacts: params.agentFacts, + pluginMetadataSnapshot: params.pluginGeneration.pluginMetadataSnapshot, + }), + isCurrent: params.isCurrent, + }); assertCurrent(); - const facts = await prepareFullCatalogFacts( - params.agentFacts, - params.pluginGeneration, - fullCatalogMode, - catalogSource, - ); - assertCurrent(); - fullCatalog = facts.modelCatalog; + fullCatalog = catalog; return fullCatalog; }), }).finally(() => { @@ -184,6 +179,7 @@ function createSnapshot( ...(input.inheritedAuthDir ? { inheritedAuthDir: input.inheritedAuthDir } : {}), ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), config: input.config, + authStore: agentFacts.authStore, authModes: resolveUsableAgentCredentialModes(credentials), metadataSnapshot: pluginMetadataSnapshot, allowGatewaySubagentBinding: input.allowGatewaySubagentBinding === true, diff --git a/src/agents/prepared-model-runtime.facts.ts b/src/agents/prepared-model-runtime.facts.ts index b0de82a0ce69..68d6d36686e0 100644 --- a/src/agents/prepared-model-runtime.facts.ts +++ b/src/agents/prepared-model-runtime.facts.ts @@ -21,10 +21,11 @@ import { resolveRuntimeSyntheticAuthProviderRefs } from "../plugins/synthetic-au import type { AgentCredentialMap } from "./agent-auth-credentials.js"; import { resolveAmbientAgentCredentialsForDiscovery } from "./agent-auth-discovery.js"; import { - discoverAuthStorage, + discoverAuthStorageFacts, discoverModels, discoverModelsFromCapturedSources, } from "./agent-model-discovery.js"; +import type { AuthProfileStore } from "./auth-profiles/types.js"; import { buildInlineProviderModels, type InlineModelEntry, @@ -73,7 +74,7 @@ import type { PreparedModelRuntimeInput, PreparedModelRuntimePluginGeneration, } from "./prepared-model-runtime.types.js"; -import type { AuthStorage, AuthStorageData } from "./sessions/auth-storage.js"; +import { AuthStorage, type AuthStorageData } from "./sessions/auth-storage.js"; import type { ModelRegistry } from "./sessions/model-registry.js"; const MODEL_RUNTIME_PROVIDER_DISCOVERY_TIMEOUT_MS = 5_000; @@ -82,6 +83,7 @@ const fullModelCatalogSnapshots = new WeakSet(); type PreparedModelRuntimeAgentBaseFacts = { input: PreparedModelRuntimeInput; env: NodeJS.ProcessEnv; + authStore: AuthProfileStore; templateAuthStorage: AuthStorage; credentials: Readonly; providerIds: string[]; @@ -120,7 +122,7 @@ function prepareAgentFacts( additionalProviderIds: readonly string[] = [], ): PreparedModelRuntimeAgentBaseFacts { const env = input.env ?? process.env; - const templateAuthStorage = discoverAuthStorage(input.agentDir, { + const authFacts = discoverAuthStorageFacts(input.agentDir, { config: input.config, // Prepared owners consume only the already-published runtime auth generation. External CLI // hydration belongs to startup/control-plane and turn-time producers, never rebuilds. @@ -131,7 +133,8 @@ function prepareAgentFacts( ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), ...(input.env ? { env } : {}), }); - const credentials = templateAuthStorage.getAll(); + const credentials = authFacts.credentials; + const templateAuthStorage = authFacts.authStorage; const configuredModelRefs = collectPreparedModelRuntimeConfiguredRefs( input.config, input.agentId, @@ -139,6 +142,7 @@ function prepareAgentFacts( return { input, env, + authStore: authFacts.store, templateAuthStorage, credentials, configuredModelRefs, @@ -486,6 +490,12 @@ export function isPreparedModelCatalogFull(snapshot: ModelCatalogSnapshot): bool return fullModelCatalogSnapshots.has(snapshot); } +/** Restores process-local provenance after a complete catalog crosses a worker boundary. */ +export function markPreparedModelCatalogFull(snapshot: ModelCatalogSnapshot): ModelCatalogSnapshot { + fullModelCatalogSnapshots.add(snapshot); + return snapshot; +} + function captureModelsJsonContents(agentDir: string): string | null { try { return fs.readFileSync(path.join(agentDir, "models.json"), "utf8"); @@ -645,7 +655,10 @@ export async function prepareAgentCatalogSource( pluginGeneration: PreparedModelRuntimePluginGeneration, catalogMode: PreparedModelRuntimeCatalogMode, persist = true, - sourceOptions: { providerDiscoveryProviderIds?: readonly string[] } = {}, + sourceOptions: { + authStore?: AuthProfileStore; + providerDiscoveryProviderIds?: readonly string[]; + } = {}, ): Promise { const { env, input, providerIds } = agentFacts; const providerOutcomes = new Map(); @@ -683,6 +696,7 @@ export async function prepareAgentCatalogSource( if (!persist) { const source = await planOpenClawModelsJsonSource(input.config, input.agentDir, { ...options, + ...(sourceOptions.authStore ? { authStore: sourceOptions.authStore } : {}), ...(catalogMode === "live" ? { onProviderCatalogOutcome: recordProviderOutcome } : {}), }); return { diff --git a/src/agents/prepared-model-runtime.owner-selection.test.ts b/src/agents/prepared-model-runtime.owner-selection.test.ts index d08f457008b5..cc66fc99682a 100644 --- a/src/agents/prepared-model-runtime.owner-selection.test.ts +++ b/src/agents/prepared-model-runtime.owner-selection.test.ts @@ -408,8 +408,7 @@ describe("prepared model runtime owner selection", () => { }); await snapshot?.loadFullModelCatalog?.(); expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled(); - expect(mocks.planOpenClawModelsJsonSource).toHaveBeenCalledOnce(); - expect(mocks.buildPreparedModelCatalogSnapshot).toHaveBeenCalledOnce(); + expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledOnce(); }); it("shares workspace facts while isolating each agent's configured model projection", async () => { @@ -595,12 +594,12 @@ describe("prepared model runtime owner selection", () => { })); let activePlans = 0; let peakActivePlans = 0; - mocks.planOpenClawModelsJsonSource.mockImplementation(async (_config, agentDir) => { + mocks.runPreparedModelCatalogWorker.mockImplementation(async () => { activePlans += 1; peakActivePlans = Math.max(peakActivePlans, activePlans); await Promise.resolve(); activePlans -= 1; - return { agentDir: String(agentDir), modelsJsonContents: null, pluginCatalogs: [] }; + return { entries: [], routeVariants: [] }; }); const config = { agents: { defaults: { model: "openai/gpt-5.5" } } }; @@ -623,12 +622,12 @@ describe("prepared model runtime owner selection", () => { await Promise.all([loadAgentCatalog("agent-a"), loadAgentCatalog("agent-b")]); expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled(); - expect(mocks.planOpenClawModelsJsonSource).toHaveBeenCalledTimes(2); - expect(mocks.buildPreparedModelCatalogSnapshot).toHaveBeenCalledTimes(2); + expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledTimes(2); expect(peakActivePlans).toBe(1); expect( - mocks.buildPreparedModelCatalogSnapshot.mock.calls.map((call) => { - const credential = call[0].authCredentials.custom; + 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"); } @@ -653,13 +652,13 @@ describe("prepared model runtime owner selection", () => { workspaceDir: "/tmp/shared-prepared-runtime-workspace", }); let releaseLazyPlan: (() => void) | undefined; - mocks.planOpenClawModelsJsonSource.mockImplementation(async (_config, agentDir) => { + mocks.runPreparedModelCatalogWorker.mockImplementation(async () => { if (!releaseLazyPlan) { await new Promise((resolve) => { releaseLazyPlan = resolve; }); } - return { agentDir: String(agentDir), modelsJsonContents: null, pluginCatalogs: [] }; + return { entries: [], routeVariants: [] }; }); const staleCatalogLoad = snapshot?.loadFullModelCatalog?.(); @@ -669,13 +668,13 @@ describe("prepared model runtime owner selection", () => { { gatewayLifecycle: true, catalogMode: "live" }, ); await Promise.resolve(); - expect(mocks.planOpenClawModelsJsonSource).toHaveBeenCalledOnce(); + expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledOnce(); expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled(); releaseLazyPlan?.(); await expect(staleCatalogLoad).rejects.toThrow("superseded"); await replacement; - expect(mocks.planOpenClawModelsJsonSource).toHaveBeenCalledOnce(); + expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledOnce(); expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce(); }); diff --git a/src/agents/prepared-model-runtime.startup-static.test.ts b/src/agents/prepared-model-runtime.startup-static.test.ts index 01fd2e07eeba..dad8e36f94f0 100644 --- a/src/agents/prepared-model-runtime.startup-static.test.ts +++ b/src/agents/prepared-model-runtime.startup-static.test.ts @@ -58,6 +58,7 @@ const mocks = vi.hoisted(() => { }), ), buildPreparedModelCatalogSnapshot: vi.fn(async () => ({ entries: [], routeVariants: [] })), + runPreparedModelCatalogWorker: vi.fn(async () => ({ entries: [], routeVariants: [] })), loadAgentRuntimePluginRegistryHandle: vi.fn(), loadStaticCatalog: vi.fn(async () => []), prepareStaticCatalog: vi.fn(async (..._args: unknown[]) => ({ @@ -110,7 +111,32 @@ vi.mock("./agent-auth-discovery.js", () => ({ resolveAmbientAgentCredentialsForDiscovery: mocks.resolveAmbientCredentials, })); +vi.mock("./prepared-model-catalog-worker.js", () => ({ + createPreparedModelCatalogWorkerInput: ({ agentFacts }: { agentFacts: unknown }) => ({ + generationFingerprint: "test-generation", + input: (agentFacts as { input: unknown }).input, + }), + runPreparedModelCatalogWorker: mocks.runPreparedModelCatalogWorker, +})); + vi.mock("./agent-model-discovery.js", () => ({ + discoverAuthStorageFacts: (...args: unknown[]) => { + const authStorage = mocks.discoverAuthStorage(...args); + const credentials = authStorage.getAll(); + return { + authStorage, + store: { + version: 1, + profiles: Object.fromEntries( + Object.entries(credentials).map(([provider, credential]) => [ + `${provider}:default`, + { ...(credential as object), provider }, + ]), + ), + }, + credentials, + }; + }, discoverAuthStorage: mocks.discoverAuthStorage, discoverModels: mocks.discoverModels, discoverModelsFromCapturedSources: mocks.discoverModels, @@ -382,37 +408,18 @@ describe("prepared model runtime Gateway catalog mode", () => { expect(snapshot?.mediaCapabilityProviders).toBeDefined(); const fullCatalog = await snapshot?.loadFullModelCatalog?.(); expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled(); - expect(mocks.planOpenClawModelsJsonSource).toHaveBeenCalledWith( - config, - "/tmp/prepared-static-agent", - expect.objectContaining({ - pluginMetadataSnapshot: mocks.metadataSnapshot, - providerDiscoveryTimeoutMs: 5_000, - }), - ); - const fullCatalogOptions = mocks.planOpenClawModelsJsonSource.mock.calls[0]?.[2]; - expect(fullCatalogOptions).not.toHaveProperty("providerDiscoveryProviderIds"); - expect(mocks.buildPreparedModelCatalogSnapshot).toHaveBeenCalledWith( - expect.objectContaining({ includeProviderPluginAugmentation: true }), - ); + expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledOnce(); expect(mocks.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledOnce(); - expect(mocks.loadAgentRuntimePluginRegistryHandle.mock.invocationCallOrder[0]).toBeLessThan( - mocks.buildPreparedModelCatalogSnapshot.mock.invocationCallOrder[0]!, - ); - expect(mocks.loadStaticCatalog).toHaveBeenCalledWith( - expect.objectContaining({ metadataSnapshot: mocks.metadataSnapshot }), - ); mocks.mutationListener?.({ agentDir: "/tmp/prepared-static-agent", affectsInheritedStores: false, }); await expect(snapshot?.loadFullModelCatalog?.()).resolves.toBe(fullCatalog); - await vi.waitFor(() => expect(mocks.discoverModels).toHaveBeenCalledTimes(3)); expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled(); - expect(mocks.planOpenClawModelsJsonSource).toHaveBeenCalledOnce(); + expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledOnce(); expect(mocks.prepareStaticCatalog).toHaveBeenCalledOnce(); - expect(mocks.discoverModels).toHaveBeenCalledTimes(3); + expect(mocks.discoverModels).toHaveBeenCalledTimes(2); }); it("publishes exact dynamic configured models without building a live catalog", async () => { diff --git a/src/agents/prepared-model-runtime.test-harness.ts b/src/agents/prepared-model-runtime.test-harness.ts index ff992ad36138..50fcd4558bf7 100644 --- a/src/agents/prepared-model-runtime.test-harness.ts +++ b/src/agents/prepared-model-runtime.test-harness.ts @@ -64,6 +64,10 @@ const preparedModelRuntimeMocks = vi.hoisted(() => ({ pluginCatalogs: [], })), prepareStaticCatalog: vi.fn(async (..._args: unknown[]) => ({ entries: [] })), + runPreparedModelCatalogWorker: vi.fn(async (..._args: unknown[]) => ({ + entries: [], + routeVariants: [], + })), resolveAmbientCredentials: vi.fn((..._args: unknown[]) => ({})), resolveStaticCatalogModel: vi.fn(() => undefined), warn: vi.fn(), @@ -84,6 +88,27 @@ vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({ resolvePluginMetadataSnapshot: () => preparedModelRuntimeMocks.pluginMetadataSnapshot, })); +vi.mock("./prepared-model-catalog-worker.js", () => ({ + createPreparedModelCatalogWorkerInput: ({ + agentFacts, + }: { + agentFacts: { + input: unknown; + authStore: unknown; + credentials: unknown; + providerIds: unknown; + }; + }) => ({ + generationFingerprint: "test-generation", + input: agentFacts.input, + authStore: agentFacts.authStore, + credentials: agentFacts.credentials, + providerIds: agentFacts.providerIds, + }), + runPreparedModelCatalogWorker: (...args: unknown[]) => + preparedModelRuntimeMocks.runPreparedModelCatalogWorker(...args), +})); + vi.mock("./model-catalog.js", () => ({ buildPreparedModelCatalogSnapshot: (...args: Parameters) => preparedModelRuntimeMocks.buildPreparedModelCatalogSnapshot(...args), @@ -95,6 +120,34 @@ vi.mock("./agent-auth-discovery.js", () => ({ })); vi.mock("./agent-model-discovery.js", () => ({ + discoverAuthStorageFacts: (...args: unknown[]) => { + if ((args[1] as { skipCredentials?: boolean } | undefined)?.skipCredentials === true) { + return { + authStorage: { getAll: () => ({}), getOAuthProviders: () => [] }, + store: { version: 1, profiles: {} }, + credentials: {}, + }; + } + const authStorage = (preparedModelRuntimeMocks.discoverAuthStorage(...args) ?? + preparedModelRuntimeMocks.authStorage) as { + getAll(): AuthStorageData; + getOAuthProviders(): unknown[]; + }; + const credentials = authStorage.getAll(); + return { + authStorage, + store: preparedModelRuntimeMocks.preparedAuthStore ?? { + version: 1, + profiles: Object.fromEntries( + Object.entries(credentials).map(([provider, credential]) => [ + `${provider}:default`, + { ...credential, provider }, + ]), + ), + }, + credentials, + }; + }, discoverAuthStorage: (...args: unknown[]) => preparedModelRuntimeMocks.discoverAuthStorage(...args) ?? preparedModelRuntimeMocks.authStorage, discoverModels: (...args: unknown[]) => { @@ -307,6 +360,10 @@ export function resetPreparedModelRuntimeHarness(): void { pluginCatalogs: [], })); preparedModelRuntimeMocks.prepareStaticCatalog.mockReset().mockResolvedValue({ entries: [] }); + preparedModelRuntimeMocks.runPreparedModelCatalogWorker.mockReset().mockResolvedValue({ + entries: [], + routeVariants: [], + }); preparedModelRuntimeMocks.resolveAmbientCredentials.mockReset().mockReturnValue({}); preparedModelRuntimeMocks.resolveStaticCatalogModel.mockReset().mockReturnValue(undefined); preparedModelRuntimeMocks.createStaticCatalogResolver diff --git a/src/agents/prepared-model-runtime.test.ts b/src/agents/prepared-model-runtime.test.ts index acacd9058df7..58b60aed565c 100644 --- a/src/agents/prepared-model-runtime.test.ts +++ b/src/agents/prepared-model-runtime.test.ts @@ -557,11 +557,8 @@ describe("prepared model runtime snapshots", () => { }); expect(credentialFree).not.toBe(await prepareModelRuntimeSnapshot({ config, agentDir })); - expect(mocks.discoverAuthStorage).toHaveBeenNthCalledWith( - 2, - agentDir, - expect.objectContaining({ readOnly: true, skipCredentials: true }), - ); + expect(mocks.discoverAuthStorage).toHaveBeenCalledOnce(); + expect(credentialFree.authStore).toEqual({ version: 1, profiles: {} }); }); it("reuses one lifecycle-owned snapshot without rediscovering files", async () => { diff --git a/src/agents/prepared-model-runtime.types.ts b/src/agents/prepared-model-runtime.types.ts index 5290a1bacea1..3321435cb730 100644 --- a/src/agents/prepared-model-runtime.types.ts +++ b/src/agents/prepared-model-runtime.types.ts @@ -6,6 +6,7 @@ import type { PreparedProviderStaticCatalog } from "../plugins/provider-discover import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js"; import type { PluginRegistry } from "../plugins/registry-types.js"; import type { PreparedAgentCredentialModes } from "./agent-auth-credentials.js"; +import type { AuthProfileStore } from "./auth-profiles/types.js"; import type { InlineModelEntry } from "./embedded-agent-runner/model.inline-provider.js"; import type { AgentHarnessPluginSelection } from "./harness/runtime-plugin-load-plan.js"; import type { ModelCatalogEntry, ModelCatalogSnapshot } from "./model-catalog.types.js"; @@ -40,6 +41,8 @@ export type PreparedModelRuntimeSnapshot = Readonly<{ /** Session active project set, ordered most-recent first; empty before run binding. */ activeProjectKeys: readonly string[]; config: OpenClawConfig; + /** Effective materialized auth store captured by this exact lifecycle generation. */ + authStore: AuthProfileStore; /** Secret-free usable auth modes captured by this exact lifecycle generation. */ authModes: PreparedAgentCredentialModes; metadataSnapshot: PluginMetadataSnapshot; diff --git a/src/agents/test-helpers/embedded-agent-runner-e2e-mocks.ts b/src/agents/test-helpers/embedded-agent-runner-e2e-mocks.ts index 1ec91e37fd6a..4e090d2fe7d3 100644 --- a/src/agents/test-helpers/embedded-agent-runner-e2e-mocks.ts +++ b/src/agents/test-helpers/embedded-agent-runner-e2e-mocks.ts @@ -81,6 +81,7 @@ function createEmptyPreparedModelRuntimeSnapshot( ...(input.workspaceDir !== undefined ? { workspaceDir: input.workspaceDir } : {}), activeProjectKeys: [], config: input.config, + authStore: { version: 1, profiles: {} }, authModes: {}, metadataSnapshot: createEmptyPluginMetadataSnapshot(input.workspaceDir), pluginRegistry: createEmptyPluginRegistry(), diff --git a/src/gateway/local-request-context.ts b/src/gateway/local-request-context.ts index 73893a71b9c7..6dae7a519236 100644 --- a/src/gateway/local-request-context.ts +++ b/src/gateway/local-request-context.ts @@ -135,6 +135,10 @@ function createLocalGatewayRequestContext( agentDir: owner.agentDir, workspaceDir: owner.workspaceDir, config: owner.config, + authModes: owner.authModes, + authStore: owner.authStore, + metadataSnapshot: owner.metadataSnapshot, + authMaterializations: [], }; }, readPreparedGatewayModelCatalog: async (loadParams) => diff --git a/src/gateway/server-core-runtime.ts b/src/gateway/server-core-runtime.ts index 5bd00f731111..9ce97141e75e 100644 --- a/src/gateway/server-core-runtime.ts +++ b/src/gateway/server-core-runtime.ts @@ -84,6 +84,7 @@ export async function startGatewayCoreRuntime(input: { loadGatewayPluginBootstrapModule: () => Promise; loadGatewayModelCatalog: typeof import("./server-model-catalog.js").loadGatewayModelCatalog; loadGatewayModelCatalogSnapshot: typeof import("./server-model-catalog.js").loadGatewayModelCatalogSnapshot; + readPreparedGatewayModelCatalogSnapshot: typeof import("./server-model-catalog.js").readPreparedGatewayModelCatalogSnapshot; readPreparedGatewayModelCatalog: typeof import("./server-model-catalog.js").readPreparedGatewayModelCatalog; }) { const { @@ -97,6 +98,7 @@ export async function startGatewayCoreRuntime(input: { loadGatewayPluginBootstrapModule, loadGatewayModelCatalog, loadGatewayModelCatalogSnapshot, + readPreparedGatewayModelCatalogSnapshot, readPreparedGatewayModelCatalog, } = input; const { @@ -653,6 +655,7 @@ export async function startGatewayCoreRuntime(input: { refreshAttachedGatewayDiscovery, loadGatewayModelCatalog, loadGatewayModelCatalogSnapshot, + readPreparedGatewayModelCatalogSnapshot, readPreparedGatewayModelCatalog, }; } diff --git a/src/gateway/server-kernel-request-runtime.ts b/src/gateway/server-kernel-request-runtime.ts index 587d2257b120..bd8ec27461ba 100644 --- a/src/gateway/server-kernel-request-runtime.ts +++ b/src/gateway/server-kernel-request-runtime.ts @@ -44,6 +44,7 @@ export async function prepareGatewayKernelRequestRuntime(params: { startupTrace, loadGatewayModelCatalog, loadGatewayModelCatalogSnapshot, + readPreparedGatewayModelCatalogSnapshot, readPreparedGatewayModelCatalog, refreshGatewayHealthSnapshotWithRuntime, getRuntimeSnapshot, @@ -129,6 +130,7 @@ export async function prepareGatewayKernelRequestRuntime(params: { listSessionPendingApprovals: approvalSessionEvents.replay, loadGatewayModelCatalog, loadGatewayModelCatalogSnapshot, + readPreparedGatewayModelCatalogSnapshot, readPreparedGatewayModelCatalog, readChatMetadata: chatMetadataLifecycle.read, readChatStartupProjection: chatMetadataLifecycle.readStartup, diff --git a/src/gateway/server-kernel.ts b/src/gateway/server-kernel.ts index 3e0d48bafeef..05e541ee0527 100644 --- a/src/gateway/server-kernel.ts +++ b/src/gateway/server-kernel.ts @@ -17,6 +17,8 @@ type LoadGatewayModelCatalogSnapshot = typeof import("./server-model-catalog.js").loadGatewayModelCatalogSnapshot; type ReadPreparedGatewayModelCatalog = typeof import("./server-model-catalog.js").readPreparedGatewayModelCatalog; +type ReadPreparedGatewayModelCatalogSnapshot = + typeof import("./server-model-catalog.js").readPreparedGatewayModelCatalogSnapshot; const loadGatewayModelCatalogModule = createLazyRuntimeModule( () => import("./server-model-catalog.js"), @@ -77,6 +79,12 @@ const readPreparedGatewayModelCatalog: ReadPreparedGatewayModelCatalog = async ( const mod = await loadGatewayModelCatalogModule(); return mod.readPreparedGatewayModelCatalog(...args); }; +const readPreparedGatewayModelCatalogSnapshot: ReadPreparedGatewayModelCatalogSnapshot = async ( + ...args +) => { + const mod = await loadGatewayModelCatalogModule(); + return mod.readPreparedGatewayModelCatalogSnapshot(...args); +}; function formatRuntimeGatewayAuthTokenWarning(): string { const base = @@ -156,6 +164,7 @@ export async function createGatewayKernel(port = 18789, opts: GatewayServerOptio loadGatewayPluginBootstrapModule, loadGatewayModelCatalog, loadGatewayModelCatalogSnapshot, + readPreparedGatewayModelCatalogSnapshot, readPreparedGatewayModelCatalog, }); return await prepareGatewayKernelRequestRuntime({ coreRuntime, log, logHealth }); diff --git a/src/gateway/server-methods/models-list-auth-resolver.ts b/src/gateway/server-methods/models-list-auth-resolver.ts index e7fb241ebda1..f6af1230051c 100644 --- a/src/gateway/server-methods/models-list-auth-resolver.ts +++ b/src/gateway/server-methods/models-list-auth-resolver.ts @@ -1,42 +1,19 @@ 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 { 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"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; -import { loadPluginRegistrySnapshotWithMetadata } from "../../plugins/plugin-registry.js"; -function listEnabledSyntheticAuthProviderRefs(params: { - cfg: OpenClawConfig; - metadataSnapshot?: PluginMetadataSnapshot; - workspaceDir: string; -}): readonly string[] { - if (params.metadataSnapshot) { - return params.metadataSnapshot.index.plugins - .filter((plugin) => plugin.enabled) - .flatMap((plugin) => plugin.syntheticAuthRefs ?? []); - } - const result = loadPluginRegistrySnapshotWithMetadata({ - config: params.cfg, - workspaceDir: params.workspaceDir, - env: process.env, - }); - if (result.source !== "persisted" && result.source !== "provided") { - return []; - } - return result.snapshot.plugins +function listEnabledSyntheticAuthProviderRefs( + metadataSnapshot: PluginMetadataSnapshot, +): readonly string[] { + return metadataSnapshot.index.plugins .filter((plugin) => plugin.enabled) .flatMap((plugin) => plugin.syntheticAuthRefs ?? []); } @@ -44,38 +21,17 @@ function listEnabledSyntheticAuthProviderRefs(params: { export function createModelsListAuthResolver(params: { cfg: OpenClawConfig; agentId: string; - includeOpenAIExternalProfiles: boolean; - metadataSnapshot?: PluginMetadataSnapshot; - preparedAuthStore?: AuthProfileStore; + metadataSnapshot: PluginMetadataSnapshot; + preparedAuthStore: AuthProfileStore; preparedRuntimeAuthModes?: PreparedAgentCredentialModes; preparedRuntimeAuthMaterializations?: readonly RuntimeAuthMaterialization[]; workspaceDir: string; routeResolverFactory?: typeof createOpenAIModelRoutesResolver; }): ModelAuthAvailabilityResolver { const agentDir = resolveAgentDir(params.cfg, params.agentId); - // Browse reads persisted auth because another CLI process may have refreshed - // it after the Gateway execution snapshot was built. - const authStore = - params.preparedAuthStore ?? - loadAuthProfileStoreWithoutExternalProfiles(agentDir, { - allowKeychainPrompt: false, - }); - // 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; - 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({ + return createModelAuthAvailabilityResolver({ cfg: params.cfg, - authStore, + authStore: params.preparedAuthStore, agentDir, workspaceDir: params.workspaceDir, env: process.env, @@ -83,50 +39,9 @@ export function createModelsListAuthResolver(params: { preparedRuntimeAuthModes: params.preparedRuntimeAuthModes, preparedRuntimeAuthMaterializations: params.preparedRuntimeAuthMaterializations, skipSetupProviderFallback: true, - syntheticAuthProviderRefs: listEnabledSyntheticAuthProviderRefs(params), - externalCliProviderIds, - ...(preparedRuntimeAuthStore ? { preparedRuntimeAuthStore } : {}), + syntheticAuthProviderRefs: listEnabledSyntheticAuthProviderRefs(params.metadataSnapshot), + externalCliProviderIds: [], + preparedRuntimeAuthStore: params.preparedAuthStore, 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, - }; } diff --git a/src/gateway/server-methods/models-list-result.openai-routes.test-support.ts b/src/gateway/server-methods/models-list-result.openai-routes.test-support.ts index 49721c1cf5de..5058b24bc370 100644 --- a/src/gateway/server-methods/models-list-result.openai-routes.test-support.ts +++ b/src/gateway/server-methods/models-list-result.openai-routes.test-support.ts @@ -1,6 +1,8 @@ +import { loadAuthProfileStoreWithoutExternalProfiles } from "../../agents/auth-profiles.js"; import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js"; import type { createOpenAIModelRoutesResolver } from "../../agents/openai-model-routes.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js"; import { buildModelsListResult } from "./models-list-result.js"; import type { GatewayRequestContext } from "./types.js"; @@ -35,6 +37,10 @@ export async function listModels(params: { agentId: "main", agentDir: "/tmp/models-list-openai-agent", config, + authStore: loadAuthProfileStoreWithoutExternalProfiles("/tmp/models-list-openai-agent", { + allowKeychainPrompt: false, + }), + metadataSnapshot: loadManifestMetadataSnapshot({ config, env: process.env }), entries: params.catalog, routeVariants: params.catalog, }), @@ -52,10 +58,13 @@ export async function listModels(params: { }, catalogProjector: { metadataSnapshot: { + index: { plugins: [] }, + manifestRegistry: { plugins: [] }, plugins: [ { id: "test-provider", modelCatalog: { discovery: params.discoveryModes } }, ], }, + authStore: { version: 1, profiles: {} }, } as never, } : {}), diff --git a/src/gateway/server-methods/models-list-result.openai-routes.test.ts b/src/gateway/server-methods/models-list-result.openai-routes.test.ts index 0c2a907bcee6..2d59c8727310 100644 --- a/src/gateway/server-methods/models-list-result.openai-routes.test.ts +++ b/src/gateway/server-methods/models-list-result.openai-routes.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { ModelCatalogEntry, ModelCatalogSnapshot } from "../../agents/model-catalog.types.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 { buildModelsListResult } from "./models-list-result.js"; @@ -15,6 +16,13 @@ import type { GatewayRequestContext } from "./types.js"; const IMPLICIT_CODEX_RUNTIME = { id: "codex", source: "implicit" } as const; const IMPLICIT_OPENCLAW_RUNTIME = { id: "openclaw", source: "implicit" } as const; +function preparedOwnerFacts(config: OpenClawConfig) { + return { + authStore: { version: 1, profiles: {} }, + metadataSnapshot: loadManifestMetadataSnapshot({ config, env: process.env }), + } as const; +} + describe("models.list OpenAI routes", () => { it("does not reuse a preloaded catalog owned by another agent", async () => { const config = { @@ -27,6 +35,7 @@ describe("models.list OpenAI routes", () => { Promise.resolve({ agentDir: "/tmp/models-list-openai-agent", config, + ...preparedOwnerFacts(config), entries: [], routeVariants: [], }), @@ -34,6 +43,14 @@ describe("models.list OpenAI routes", () => { const context = { getRuntimeConfig: () => config, loadGatewayModelCatalogSnapshot, + readPreparedGatewayModelCatalogSnapshot: async () => ({ + agentId: "main", + agentDir: "/tmp/models-list-openai-agent", + config, + entries: [], + routeVariants: [], + ...preparedOwnerFacts(config), + }), logGateway: { debug: vi.fn() }, } as unknown as GatewayRequestContext; const preloadedCatalog: ModelCatalogSnapshot = { @@ -55,11 +72,12 @@ describe("models.list OpenAI routes", () => { }); it("does not reuse a preloaded catalog from another config generation", async () => { - const config = {} as OpenClawConfig; + const config = { agents: { defaults: { model: "openai/current" } } } as OpenClawConfig; const loadGatewayModelCatalogSnapshot = vi.fn(() => Promise.resolve({ agentDir: "/tmp/models-list-openai-agent", config, + ...preparedOwnerFacts(config), entries: [], routeVariants: [], }), @@ -76,7 +94,7 @@ describe("models.list OpenAI routes", () => { params: { view: "default" }, preloadedCatalog: { agentId: "main", - config: {} as OpenClawConfig, + config: { agents: { defaults: { model: "openai/stale" } } } as OpenClawConfig, snapshot: { entries: [catalogEntry("stale", "openai-responses")], routeVariants: [] }, }, }), @@ -91,6 +109,7 @@ describe("models.list OpenAI routes", () => { Promise.resolve({ agentDir: "/tmp/models-list-openai-agent", config: replacementConfig, + ...preparedOwnerFacts(replacementConfig), entries: [], routeVariants: [], }), @@ -99,6 +118,14 @@ describe("models.list OpenAI routes", () => { const context = { getRuntimeConfig: () => config, loadGatewayModelCatalogSnapshot, + readPreparedGatewayModelCatalogSnapshot: async () => ({ + agentId: "main", + agentDir: "/tmp/models-list-openai-agent", + config, + entries: [], + routeVariants: [], + ...preparedOwnerFacts(config), + }), logGateway: { debug: vi.fn() }, } as unknown as GatewayRequestContext; @@ -127,6 +154,14 @@ describe("models.list OpenAI routes", () => { const context = { getRuntimeConfig: () => config, loadGatewayModelCatalogSnapshot, + readPreparedGatewayModelCatalogSnapshot: async () => ({ + agentId: "main", + agentDir: "/tmp/models-list-openai-agent", + config, + entries: [], + routeVariants: [], + ...preparedOwnerFacts(config), + }), logGateway: { debug: vi.fn() }, } as unknown as GatewayRequestContext; @@ -170,6 +205,7 @@ describe("models.list OpenAI routes", () => { agentId: "main", agentDir: "/tmp/models-list-openai-agent", config, + ...preparedOwnerFacts(config), entries: [ownerEntry], routeVariants: [ownerEntry], }), @@ -215,6 +251,7 @@ describe("models.list OpenAI routes", () => { agentDir: "/tmp/models-list-main-agent", workspaceDir: "/tmp/models-list-main-workspace", config: replacementConfig, + ...preparedOwnerFacts(replacementConfig), entries: [entry], routeVariants: [entry], }) @@ -223,6 +260,7 @@ describe("models.list OpenAI routes", () => { agentDir: "/tmp/models-list-main-agent", workspaceDir: "/tmp/models-list-main-workspace", config: replacementConfig, + ...preparedOwnerFacts(replacementConfig), entries: [entry], routeVariants: [entry], }); @@ -258,6 +296,7 @@ describe("models.list OpenAI routes", () => { agentDir: "/tmp/models-list-main-agent", workspaceDir: "/tmp/models-list-main-workspace", config: replacementConfig, + ...preparedOwnerFacts(replacementConfig), entries: [entry], routeVariants: [entry], }) @@ -266,6 +305,7 @@ describe("models.list OpenAI routes", () => { agentDir: "/tmp/models-list-worker-agent", workspaceDir: "/tmp/models-list-worker-workspace", config: replacementConfig, + ...preparedOwnerFacts(replacementConfig), entries: [entry], routeVariants: [entry], }); @@ -291,6 +331,7 @@ describe("models.list OpenAI routes", () => { agentDir: "/tmp/models-list-openai-agent", workspaceDir: "/tmp/models-list-openai-workspace", config, + ...preparedOwnerFacts(config), entries: [], routeVariants: [], }), @@ -330,6 +371,7 @@ describe("models.list OpenAI routes", () => { Promise.resolve({ agentDir: "/tmp/models-list-openai-agent", config, + ...preparedOwnerFacts(config), entries: [ownerlessEntry], routeVariants: [ownerlessEntry], }), @@ -361,6 +403,7 @@ describe("models.list OpenAI routes", () => { agentId: "main", agentDir: "/tmp/models-list-main-agent", config, + ...preparedOwnerFacts(config), entries: [mainEntry], routeVariants: [mainEntry], }), @@ -398,6 +441,7 @@ describe("models.list OpenAI routes", () => { agentId: "worker", agentDir: "/tmp/models-list-worker-agent", config, + ...preparedOwnerFacts(config), entries: [workerEntry], routeVariants: [workerEntry], }), diff --git a/src/gateway/server-methods/models-list-result.plugin-runtime.test.ts b/src/gateway/server-methods/models-list-result.plugin-runtime.test.ts index b33dd7de231f..7a3f619c35dd 100644 --- a/src/gateway/server-methods/models-list-result.plugin-runtime.test.ts +++ b/src/gateway/server-methods/models-list-result.plugin-runtime.test.ts @@ -1,26 +1,12 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { ModelCatalogEntry, ModelCatalogSnapshot } from "../../agents/model-catalog.types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js"; -import type { GatewayRequestContext } from "./types.js"; - -const getCurrentPluginMetadataSnapshotMock = vi.hoisted(() => vi.fn()); -const loadPluginRegistrySnapshotWithMetadataMock = vi.hoisted(() => vi.fn()); - -vi.mock("../../plugins/current-plugin-metadata-snapshot.js", async (importOriginal) => ({ - ...(await importOriginal()), - getCurrentPluginMetadataSnapshot: getCurrentPluginMetadataSnapshotMock, -})); - -vi.mock("../../plugins/plugin-registry.js", async (importOriginal) => ({ - ...(await importOriginal()), - loadPluginRegistrySnapshotWithMetadata: loadPluginRegistrySnapshotWithMetadataMock, -})); - import { buildModelsListResult, createGatewayAgentModelCatalogProjector, } from "./models-list-result.js"; +import type { GatewayRequestContext } from "./types.js"; function catalogEntry(id: string): ModelCatalogEntry { return { id, name: id, provider: "custom", api: "openai-responses" }; @@ -53,11 +39,6 @@ function preparedMetadataSnapshot() { } describe("models.list plugin metadata handoff", () => { - beforeEach(() => { - getCurrentPluginMetadataSnapshotMock.mockReset(); - loadPluginRegistrySnapshotWithMetadataMock.mockReset(); - }); - it("reuses one Gateway-owned metadata snapshot across startup projection and browse", async () => { await withOpenClawTestState( { @@ -82,11 +63,12 @@ describe("models.list plugin metadata handoff", () => { entries: [catalogEntry("modern"), catalogEntry("another")], routeVariants: [], }; - getCurrentPluginMetadataSnapshotMock.mockReturnValue(preparedMetadataSnapshot()); const projector = createGatewayAgentModelCatalogProjector({ cfg, agentId: "main", snapshot, + metadataSnapshot: preparedMetadataSnapshot(), + preparedAuthStore: { version: 1, profiles: {} }, }); await projector.projectCatalog(); @@ -103,55 +85,37 @@ describe("models.list plugin metadata handoff", () => { preloadedOnly: true, catalogProjector: projector, }); - - expect(getCurrentPluginMetadataSnapshotMock).toHaveBeenCalledWith({ - config: cfg, - allowWorkspaceScopedSnapshot: true, - }); - expect(loadPluginRegistrySnapshotWithMetadataMock).not.toHaveBeenCalled(); }, ); }); - it("preserves registry fallback when no compatible Gateway snapshot exists", async () => { - await withOpenClawTestState( - { - layout: "state-only", - prefix: "openclaw-models-list-plugin-runtime-fallback-", - agentEnv: "main", - }, - async (state) => { - const cfg = { - agents: { - defaults: { - workspace: state.workspaceDir, - model: { primary: "custom/modern" }, - models: { "custom/modern": {} }, - }, - }, - } as OpenClawConfig; - getCurrentPluginMetadataSnapshotMock.mockReturnValue(undefined); - loadPluginRegistrySnapshotWithMetadataMock.mockReturnValue({ - source: "provided", - snapshot: { - plugins: [ - { - enabled: true, - syntheticAuthRefs: ["custom"], - }, - ], - }, - }); - const projector = createGatewayAgentModelCatalogProjector({ - cfg, - agentId: "main", - snapshot: { entries: [catalogEntry("modern")], routeVariants: [] }, - }); + it("keeps prepared owner facts when preloaded-only browse requires full discovery", async () => { + const cfg = { + agents: { defaults: { models: { "custom/*": {} } } }, + } as OpenClawConfig; + const snapshot: ModelCatalogSnapshot = { entries: [], routeVariants: [] }; + const loadGatewayModelCatalogSnapshot = vi.fn(); + const context = { + getRuntimeConfig: () => cfg, + loadGatewayModelCatalogSnapshot, + logGateway: { debug: vi.fn() }, + } as unknown as GatewayRequestContext; + const projector = createGatewayAgentModelCatalogProjector({ + cfg, + agentId: "main", + snapshot, + metadataSnapshot: preparedMetadataSnapshot(), + preparedAuthStore: { version: 1, profiles: {} }, + }); - await projector.projectCatalog(); + await buildModelsListResult({ + context, + params: { view: "configured" }, + preloadedCatalog: { agentId: "main", config: cfg, snapshot }, + preloadedOnly: true, + catalogProjector: projector, + }); - expect(loadPluginRegistrySnapshotWithMetadataMock).toHaveBeenCalled(); - }, - ); + expect(loadGatewayModelCatalogSnapshot).not.toHaveBeenCalled(); }); }); diff --git a/src/gateway/server-methods/models-list-result.provider-outcomes.test.ts b/src/gateway/server-methods/models-list-result.provider-outcomes.test.ts index 8647a6afffd3..cbf2ba86a700 100644 --- a/src/gateway/server-methods/models-list-result.provider-outcomes.test.ts +++ b/src/gateway/server-methods/models-list-result.provider-outcomes.test.ts @@ -6,6 +6,13 @@ import { } from "./models-list-result.js"; import type { GatewayRequestContext } from "./types.js"; +const metadataSnapshot = { + index: { plugins: [] }, + manifestRegistry: { plugins: [] }, + plugins: [], +} as never; +const emptyAuthStore = { version: 1, profiles: {} } as const; + describe("models.list provider catalog outcomes", () => { it("preserves an auth rejection when no usable models are visible", async () => { const config = {} as OpenClawConfig; @@ -13,6 +20,8 @@ describe("models.list provider catalog outcomes", () => { agentId: "main", agentDir: "/tmp/models-list-provider-outcomes-agent", config, + authStore: emptyAuthStore, + metadataSnapshot, entries: [], routeVariants: [], providerOutcomes: [ @@ -68,6 +77,7 @@ describe("models.list provider catalog outcomes", () => { cfg: config, agentId: "main", snapshot, + metadataSnapshot, preparedAuthStore: { version: 1, profiles: { @@ -143,6 +153,7 @@ describe("models.list provider catalog outcomes", () => { cfg: config, agentId: "main", snapshot, + metadataSnapshot, preferredProfileId: "openai:accepted", preparedAuthStore: { version: 1, diff --git a/src/gateway/server-methods/models-list-result.ts b/src/gateway/server-methods/models-list-result.ts index 526a7f31606d..9914259e6624 100644 --- a/src/gateway/server-methods/models-list-result.ts +++ b/src/gateway/server-methods/models-list-result.ts @@ -46,11 +46,11 @@ import { openAIModelCatalogRoutePolicy, } from "../../agents/openai-model-routes.js"; import { publishedModelCatalogOwnerMatchesAgent } from "../../agents/prepared-model-catalog-owner.js"; +import { preparedModelRuntimeConfigsMatch } from "../../agents/prepared-model-runtime.js"; import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js"; import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; import { getRuntimeConfigSourceSnapshot } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { getCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js"; import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import { resolveManifestProviderAuthChoices } from "../../plugins/provider-auth-choices.js"; import type { ProviderCatalogOutcome } from "../../plugins/provider-catalog.types.js"; @@ -130,6 +130,7 @@ function resolveLegacyEntryAvailability(params: { primaryAvailability: ModelsListAvailability; cfg: OpenClawConfig; agentId: string; + metadataSnapshot: PluginMetadataSnapshot; }): ModelsListAvailability { if (params.primaryAvailability === true) { return true; @@ -140,6 +141,7 @@ function resolveLegacyEntryAvailability(params: { cfg: params.cfg, agentId: params.agentId, modelId: params.entry.id, + metadataSnapshot: params.metadataSnapshot, }); if ( runtimeProvider && @@ -160,6 +162,7 @@ function createModelsListEntryEvaluator(params: { cfg: OpenClawConfig; agentId: string; authResolver: ModelAuthAvailabilityResolver; + metadataSnapshot: PluginMetadataSnapshot; providerOutcomes?: readonly ProviderCatalogOutcome[]; preferredProfileId?: string; lockedProfileId?: string; @@ -195,6 +198,7 @@ function createModelsListEntryEvaluator(params: { primaryAvailability: evaluation.availability, cfg: params.cfg, agentId: params.agentId, + metadataSnapshot: params.metadataSnapshot, }), } : evaluation; @@ -297,32 +301,17 @@ export function createGatewayAgentModelCatalogProjector(params: { cfg: OpenClawConfig; agentId: string; snapshot: ModelCatalogSnapshot; - metadataSnapshot?: PluginMetadataSnapshot; - preparedAuthStore?: AuthProfileStore; + metadataSnapshot: PluginMetadataSnapshot; + preparedAuthStore: AuthProfileStore; preparedRuntimeAuthModes?: PreparedAgentCredentialModes; preparedRuntimeAuthMaterializations?: readonly RuntimeAuthMaterialization[]; preferredProfileId?: string; lockedProfileId?: string; routeResolverFactory?: typeof createOpenAIModelRoutesResolver; }) { - const defaultModel = resolveAgentEffectiveModelPrimary(params.cfg, params.agentId); // The Gateway owns one process-lifecycle plugin metadata snapshot. Carry it // through the whole projection so per-model normalization cannot rediscover it. - const metadataSnapshot = - params.metadataSnapshot ?? - getCurrentPluginMetadataSnapshot({ - config: params.cfg, - allowWorkspaceScopedSnapshot: true, - }); - const visibilityPolicy = createModelVisibilityPolicy({ - cfg: params.cfg, - catalog: params.snapshot.entries, - defaultProvider: DEFAULT_PROVIDER, - defaultModel, - agentId: params.agentId, - ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, - manifestPlugins: metadataSnapshot?.plugins, - }); + const metadataSnapshot = params.metadataSnapshot; const workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId) ?? resolveDefaultAgentWorkspaceDir(); const projectionCatalog = @@ -350,11 +339,8 @@ export function createGatewayAgentModelCatalogProjector(params: { const authResolver = createModelsListAuthResolver({ cfg: params.cfg, agentId: params.agentId, - includeOpenAIExternalProfiles: - projectionCatalog.some((entry) => normalizeProviderId(entry.provider) === "openai") || - [...visibilityPolicy.configuredKeys].some((key) => key.startsWith("openai/")), metadataSnapshot, - ...(params.preparedAuthStore ? { preparedAuthStore: params.preparedAuthStore } : {}), + preparedAuthStore: params.preparedAuthStore, preparedRuntimeAuthModes: params.preparedRuntimeAuthModes, preparedRuntimeAuthMaterializations: params.preparedRuntimeAuthMaterializations, workspaceDir, @@ -364,6 +350,7 @@ export function createGatewayAgentModelCatalogProjector(params: { cfg: params.cfg, agentId: params.agentId, authResolver, + metadataSnapshot, providerOutcomes: params.snapshot.providerOutcomes, ...(params.preferredProfileId ? { preferredProfileId: params.preferredProfileId } : {}), ...(params.lockedProfileId ? { lockedProfileId: params.lockedProfileId } : {}), @@ -372,6 +359,9 @@ export function createGatewayAgentModelCatalogProjector(params: { return { evaluateEntry, metadataSnapshot, + authStore: params.preparedAuthStore, + authModes: params.preparedRuntimeAuthModes, + authMaterializations: params.preparedRuntimeAuthMaterializations, projectCatalog: () => (projectedCatalog ??= Promise.all( logicalEntries.map(async (entry) => { @@ -470,6 +460,7 @@ async function buildPublicModelsListEntries(params: { function apiKeyProviderCapabilities(params: { cfg: OpenClawConfig; + metadataSnapshot: PluginMetadataSnapshot; workspaceDir: string; }): ApiKeyProviderCapabilities { const capabilities = new Map(); @@ -479,12 +470,14 @@ function apiKeyProviderCapabilities(params: { workspaceDir: params.workspaceDir, env: process.env, includeUntrustedWorkspacePlugins: false, + metadataSnapshot: params.metadataSnapshot, }); for (const choice of resolveManifestProviderAuthChoices({ config: params.cfg, workspaceDir: params.workspaceDir, env: process.env, includeUntrustedWorkspacePlugins: false, + metadataSnapshot: params.metadataSnapshot, })) { const provider = resolveProvider(choice.providerId); capabilities.set( @@ -519,9 +512,15 @@ export async function buildModelsListResult( const view = resolveModelsListView(params.params); const preloadedCatalog = params.preloadedCatalog?.agentId === initialAgentId && - params.preloadedCatalog.config === initialConfig + preparedModelRuntimeConfigsMatch(params.preloadedCatalog.config, initialConfig) ? params.preloadedCatalog : undefined; + const preparedOwnerSnapshot = + preloadedCatalog && params.catalogProjector + ? undefined + : await params.context.readPreparedGatewayModelCatalogSnapshot?.({ + agentId: initialAgentId, + }); let loadedSnapshot: | Awaited> | undefined; @@ -606,25 +605,26 @@ export async function buildModelsListResult( ) { return { models: [] }; } - const cfg = loadedSnapshot?.config ?? initialConfig; - const agentId = loadedSnapshot?.agentId ?? initialAgentId; + const ownerSnapshot = loadedSnapshot ?? preparedOwnerSnapshot; + const cfg = ownerSnapshot?.config ?? initialConfig; + const agentId = ownerSnapshot?.agentId ?? initialAgentId; const workspaceDir = - loadedSnapshot?.workspaceDir ?? + ownerSnapshot?.workspaceDir ?? resolveAgentWorkspaceDir(cfg, agentId) ?? resolveDefaultAgentWorkspaceDir(); - const catalog = snapshot.entries; - const routeVariants = snapshot.routeVariants; - const providerOutcomes = snapshot.providerOutcomes; + const { entries: catalog, routeVariants, providerOutcomes } = snapshot; const outcomeProjection = providerOutcomes?.length ? { providerOutcomes } : {}; - const metadataSnapshot = - (usedPreloadedCatalog ? params.catalogProjector?.metadataSnapshot : undefined) ?? - getCurrentPluginMetadataSnapshot({ - config: cfg, - allowWorkspaceScopedSnapshot: true, - }); + const preparedProjectionOwner = ownerSnapshot ?? params.catalogProjector; + const metadataSnapshot = preparedProjectionOwner?.metadataSnapshot; + const preparedAuthStore = preparedProjectionOwner?.authStore; + if (!metadataSnapshot || !preparedAuthStore) { + throw new Error("Gateway model catalog owner omitted prepared metadata or auth state"); + } + const preparedRuntimeAuthModes = preparedProjectionOwner?.authModes; + const preparedRuntimeAuthMaterializations = preparedProjectionOwner?.authMaterializations; const includeProviderCapabilities = params.params.includeProviderCapabilities === true; const capableProviders = includeProviderCapabilities - ? apiKeyProviderCapabilities({ cfg, workspaceDir }) + ? apiKeyProviderCapabilities({ cfg, metadataSnapshot, workspaceDir }) : undefined; if (view === "provider-config") { const sourceConfig = getRuntimeConfigSourceSnapshot() ?? cfg; @@ -648,6 +648,10 @@ export async function buildModelsListResult( cfg, agentId, snapshot: inventorySnapshot, + metadataSnapshot, + preparedAuthStore, + preparedRuntimeAuthModes, + preparedRuntimeAuthMaterializations, ...(params.routeResolverFactory ? { routeResolverFactory: params.routeResolverFactory } : {}), }); const inventory = await inventoryProjector.projectCatalog(); @@ -682,13 +686,14 @@ export async function buildModelsListResult( authResolver: createModelsListAuthResolver({ cfg, agentId, - includeOpenAIExternalProfiles: - catalog.some((entry) => normalizeProviderId(entry.provider) === "openai") || - [...visibilityPolicy.configuredKeys].some((key) => key.startsWith("openai/")), metadataSnapshot, + preparedAuthStore, + preparedRuntimeAuthModes, + preparedRuntimeAuthMaterializations, workspaceDir, routeResolverFactory: params.routeResolverFactory, }), + metadataSnapshot, providerOutcomes, }); const models = await resolveLogicalVisibleModelCatalog({ diff --git a/src/gateway/server-methods/models.test.ts b/src/gateway/server-methods/models.test.ts index 710b75a8476b..de698da6fec1 100644 --- a/src/gateway/server-methods/models.test.ts +++ b/src/gateway/server-methods/models.test.ts @@ -4,13 +4,16 @@ import { expectDefined } from "@openclaw/normalization-core"; import { describe, expect, it, vi } from "vitest"; import { createDeferred } from "../../../test/helpers/promise.js"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { resolveAgentDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { clearRuntimeAuthProfileStoreSnapshots, + getPreparedRuntimeAuthProfileStoreSnapshot, + loadAuthProfileStoreWithoutExternalProfiles, replaceRuntimeAuthProfileStoreSnapshots, } from "../../agents/auth-profiles.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 { modelsHandlers } from "./models.js"; @@ -62,6 +65,20 @@ function requestModelsList(params: { const respond = params.respond ?? vi.fn(); const runtimeConfig = params.runtimeConfig ?? ({} as OpenClawConfig); const getRuntimeConfig = params.getRuntimeConfig ?? (() => runtimeConfig); + const resolveOwnerFacts = () => { + const config = getRuntimeConfig(); + const agentId = params.agentId ?? resolveDefaultAgentId(config); + const agentDir = resolveAgentDir(config, agentId); + return { + agentId, + agentDir, + config, + authStore: + getPreparedRuntimeAuthProfileStoreSnapshot(agentDir) ?? + loadAuthProfileStoreWithoutExternalProfiles(agentDir, { allowKeychainPrompt: false }), + metadataSnapshot: loadManifestMetadataSnapshot({ config, env: process.env }), + }; + }; const request = expectDefined( modelsHandlers["models.list"], 'modelsHandlers["models.list"] test invariant', @@ -91,15 +108,19 @@ function requestModelsList(params: { loadParams: Parameters[0], ) => { const entries = await params.loadGatewayModelCatalog(loadParams); - const config = getRuntimeConfig(); + const owner = resolveOwnerFacts(); return { - agentId: loadParams?.agentId ?? resolveDefaultAgentId(config), - agentDir: "/tmp/models-list-agent", - config, + ...owner, + ...(loadParams?.agentId ? { agentId: loadParams.agentId } : {}), entries, routeVariants: entries, }; }, + readPreparedGatewayModelCatalogSnapshot: async () => ({ + ...resolveOwnerFacts(), + entries: [], + routeVariants: [], + }), logGateway: { debug: vi.fn(), }, @@ -1168,7 +1189,7 @@ describe("models.list", () => { ); }); - it("uses refreshed persisted OAuth when the runtime auth snapshot is stale", async () => { + it("does not mix refreshed persisted OAuth into a stale runtime generation", async () => { await withOpenClawTestState( { layout: "state-only", @@ -1213,7 +1234,7 @@ describe("models.list", () => { id: "demo-model", name: "Demo Model", provider: "demo-provider", - available: true, + available: false, }, ], }, diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 68271f0a3e7c..7868457a215b 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -227,6 +227,11 @@ type GatewayKernelContext = { readOnly?: boolean; workspaceDir?: string; }) => Promise; + readPreparedGatewayModelCatalogSnapshot?: (params?: { + agentId?: string; + agentDir?: string; + workspaceDir?: string; + }) => Promise; readPreparedGatewayModelCatalog?: (params?: { agentId?: string; agentDir?: string; diff --git a/src/gateway/server-model-catalog.ts b/src/gateway/server-model-catalog.ts index 4481134989f8..c20c636c81f1 100644 --- a/src/gateway/server-model-catalog.ts +++ b/src/gateway/server-model-catalog.ts @@ -1,5 +1,7 @@ import { resolvePublishedModelCatalogOwner } from "../agents/prepared-model-catalog-owner.js"; import type { PublishedModelCatalogOwnerCandidate } from "../agents/prepared-model-catalog.types.js"; +import { getPreparedModelRuntimeAuthMaterializations } from "../agents/prepared-model-runtime-auth.js"; +import type { PreparedModelRuntimeSnapshot } from "../agents/prepared-model-runtime.types.js"; // Gateway catalog reads use the atomic prepared runtime generation. import { getRuntimeConfig } from "../config/io.js"; import type { @@ -51,32 +53,51 @@ export async function resetPreparedModelCatalogStateForTest(): Promise { async function loadGatewayModelCatalogOwnerSnapshot( params?: LoadGatewayModelCatalogParams, -): Promise { +): Promise< + GatewayModelCatalogOwnerSnapshot & { + authMaterializations: GatewayModelCatalogSnapshot["authMaterializations"]; + } +> { const loadOwner = await resolveLoader(params); - return resolvePublishedModelCatalogOwner( - await loadOwner({ - ...(params?.agentId ? { agentId: params.agentId } : {}), - ...(params?.agentDir ? { agentDir: params.agentDir } : {}), - config: (params?.getConfig ?? getRuntimeConfig)(), - readOnly: params?.readOnly !== false, - ...(params?.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), - }), - ); + const candidate = await loadOwner({ + ...(params?.agentId ? { agentId: params.agentId } : {}), + ...(params?.agentDir ? { agentDir: params.agentDir } : {}), + config: (params?.getConfig ?? getRuntimeConfig)(), + readOnly: params?.readOnly !== false, + ...(params?.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + }); + return { + ...resolvePublishedModelCatalogOwner(candidate), + authMaterializations: getPreparedModelRuntimeAuthMaterializations( + candidate as PreparedModelRuntimeSnapshot, + ), + }; } -export async function loadGatewayModelCatalogSnapshot( - params?: LoadGatewayModelCatalogParams, -): Promise { - const owner = await loadGatewayModelCatalogOwnerSnapshot(params); +function projectGatewayModelCatalogSnapshot( + owner: GatewayModelCatalogOwnerSnapshot & { + authMaterializations?: GatewayModelCatalogSnapshot["authMaterializations"]; + }, +): GatewayModelCatalogSnapshot { return { ...owner.modelCatalog, agentId: owner.agentId, agentDir: owner.agentDir, workspaceDir: owner.workspaceDir, config: owner.config, + authModes: owner.authModes, + authStore: owner.authStore, + metadataSnapshot: owner.metadataSnapshot, + authMaterializations: owner.authMaterializations, }; } +export async function loadGatewayModelCatalogSnapshot( + params?: LoadGatewayModelCatalogParams, +): Promise { + return projectGatewayModelCatalogSnapshot(await loadGatewayModelCatalogOwnerSnapshot(params)); +} + export async function loadGatewayModelCatalog( params?: LoadGatewayModelCatalogParams, ): Promise { @@ -97,3 +118,25 @@ export async function readPreparedGatewayModelCatalog( ...(params?.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), })?.entries; } + +/** Reads the published owner generation without activating full catalog discovery. */ +export async function readPreparedGatewayModelCatalogSnapshot( + params?: LoadGatewayModelCatalogParams, +): Promise { + const { getPublishedPreparedModelCatalogOwnerSnapshot } = + await import("../agents/prepared-model-catalog.js"); + const config = (params?.getConfig ?? getRuntimeConfig)(); + const candidate = getPublishedPreparedModelCatalogOwnerSnapshot({ + ...(params?.agentId ? { agentId: params.agentId } : {}), + ...(params?.agentDir ? { agentDir: params.agentDir } : {}), + config, + ...(params?.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + }); + if (!candidate) { + return undefined; + } + return projectGatewayModelCatalogSnapshot({ + ...resolvePublishedModelCatalogOwner(candidate), + authMaterializations: getPreparedModelRuntimeAuthMaterializations(candidate), + }); +} diff --git a/src/gateway/server-model-catalog.types.ts b/src/gateway/server-model-catalog.types.ts index 35c3d7f8bde3..e73cb6923ed6 100644 --- a/src/gateway/server-model-catalog.types.ts +++ b/src/gateway/server-model-catalog.types.ts @@ -1,10 +1,14 @@ +import type { RuntimeAuthMaterialization } from "../agents/auth-profiles/runtime-materializations.js"; import type { ModelCatalogSnapshot } from "../agents/model-catalog.types.js"; import type { ResolvedPublishedModelCatalogOwner } from "../agents/prepared-model-catalog.types.js"; -export type GatewayModelCatalogOwnerSnapshot = Omit< +export type GatewayModelCatalogOwnerSnapshot = Pick< ResolvedPublishedModelCatalogOwner, - "pluginRegistry" ->; + "agentId" | "agentDir" | "workspaceDir" | "config" | "modelCatalog" +> & + Partial>; export type GatewayModelCatalogSnapshot = ModelCatalogSnapshot & - Omit; + Omit & { + authMaterializations?: readonly RuntimeAuthMaterialization[]; + }; diff --git a/src/gateway/server-request-context.ts b/src/gateway/server-request-context.ts index 39642374f3f2..cf0c98eb5b59 100644 --- a/src/gateway/server-request-context.ts +++ b/src/gateway/server-request-context.ts @@ -49,6 +49,7 @@ type GatewayRequestContextParams = { listSessionPendingApprovals: GatewayRequestContext["listSessionPendingApprovals"]; loadGatewayModelCatalog: GatewayRequestContext["loadGatewayModelCatalog"]; loadGatewayModelCatalogSnapshot: GatewayRequestContext["loadGatewayModelCatalogSnapshot"]; + readPreparedGatewayModelCatalogSnapshot?: GatewayRequestContext["readPreparedGatewayModelCatalogSnapshot"]; readPreparedGatewayModelCatalog?: GatewayRequestContext["readPreparedGatewayModelCatalog"]; readChatMetadata: GatewayRequestContext["readChatMetadata"]; readChatStartupProjection?: GatewayRequestContext["readChatStartupProjection"]; @@ -201,6 +202,9 @@ export function createGatewayRequestContext( listSessionPendingApprovals: params.listSessionPendingApprovals, loadGatewayModelCatalog: params.loadGatewayModelCatalog, loadGatewayModelCatalogSnapshot: params.loadGatewayModelCatalogSnapshot, + ...(params.readPreparedGatewayModelCatalogSnapshot + ? { readPreparedGatewayModelCatalogSnapshot: params.readPreparedGatewayModelCatalogSnapshot } + : {}), ...(params.readPreparedGatewayModelCatalog ? { readPreparedGatewayModelCatalog: params.readPreparedGatewayModelCatalog } : {}), diff --git a/src/plugins/provider-auth-choices.ts b/src/plugins/provider-auth-choices.ts index 71a11e82a6e2..bca13b7734a2 100644 --- a/src/plugins/provider-auth-choices.ts +++ b/src/plugins/provider-auth-choices.ts @@ -8,6 +8,7 @@ import { getOfficialExternalPluginCatalogManifest, listOfficialExternalProviderCatalogEntries, } from "./official-external-plugin-catalog.js"; +import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; export type ProviderAuthChoiceMetadata = { @@ -57,6 +58,7 @@ type ManifestProviderAuthChoiceParams = { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; + metadataSnapshot?: PluginMetadataSnapshot; includeUntrustedWorkspacePlugins?: boolean; includeWorkspacePlugins?: boolean; }; @@ -193,18 +195,16 @@ function stripChoiceOrigin(choice: ProviderAuthChoiceCandidate): ProviderAuthCho return metadata; } -function resolveManifestProviderAuthChoiceCandidates(params?: { - config?: OpenClawConfig; - workspaceDir?: string; - env?: NodeJS.ProcessEnv; - includeUntrustedWorkspacePlugins?: boolean; - includeWorkspacePlugins?: boolean; -}): ProviderAuthChoiceCandidate[] { - const metadataSnapshot = loadManifestMetadataSnapshot({ - config: params?.config ?? {}, - workspaceDir: params?.workspaceDir, - env: params?.env ?? process.env, - }); +function resolveManifestProviderAuthChoiceCandidates( + params?: ManifestProviderAuthChoiceParams, +): ProviderAuthChoiceCandidate[] { + const metadataSnapshot = + params?.metadataSnapshot ?? + loadManifestMetadataSnapshot({ + config: params?.config ?? {}, + workspaceDir: params?.workspaceDir, + env: params?.env ?? process.env, + }); const registry = metadataSnapshot.manifestRegistry; const normalizedConfig = normalizePluginsConfig(params?.config?.plugins); return registry.plugins.flatMap((plugin) => { diff --git a/test/release-check.test.ts b/test/release-check.test.ts index 78b49163f524..308b40963e5e 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -725,6 +725,7 @@ describe("collectMissingPackPaths", () => { "scripts/postinstall-bundled-plugins.mjs", "dist/agents/compaction-planning.worker.js", "dist/agents/model-provider-auth.worker.js", + "dist/agents/prepared-model-catalog.worker.js", "dist/audit/audit-event-writer.worker.js", "dist/config/sessions/session-accessor.sqlite-archive.worker.js", "dist/config/sessions/session-transcript-reconcile.worker.js", @@ -765,6 +766,7 @@ describe("collectMissingPackPaths", () => { "scripts/postinstall-bundled-plugins.mjs", "dist/agents/compaction-planning.worker.js", "dist/agents/model-provider-auth.worker.js", + "dist/agents/prepared-model-catalog.worker.js", "dist/audit/audit-event-writer.worker.js", "dist/config/sessions/session-accessor.sqlite-archive.worker.js", "dist/config/sessions/session-transcript-reconcile.worker.js", diff --git a/tsdown.config.ts b/tsdown.config.ts index a4bfdae6f66b..2bfab3e1a002 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -292,6 +292,7 @@ function buildCoreDistEntries(): Record { "agents/code-mode.worker": "src/agents/code-mode.worker.ts", "agents/compaction-planning.worker": "src/agents/compaction-planning.worker.ts", "agents/model-provider-auth.worker": "src/agents/model-provider-auth.worker.ts", + "agents/prepared-model-catalog.worker": "src/agents/prepared-model-catalog.worker.ts", "audit/audit-event-writer.worker": "src/audit/audit-event-writer.worker.ts", "config/sessions/session-accessor.sqlite-archive.worker": "src/config/sessions/session-accessor.sqlite-archive.worker.ts",