diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index bb753a74d1a..114f7e6eb58 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -6,7 +6,7 @@ e5e67ddf3cab38fcbf9220bc3160715897e2709d9a9ff6ff36f1ecc9453c2367 module/agent-c 74daa746deb548379d3f0d6eac3c4d082df1034c4360cc03bf51fee0f10a2e4d module/agent-harness 240429e28a6f2db286421605e6cf4b65c2e4faf654b4b994e1d509e69b0e18f8 module/agent-harness-runtime 5168648cd946abad8a92822889f13ceacc87ed502314a66190d0b1eb8ebe76ea module/agent-media-payload -6ee8bb70cd7b8a5a976ee84cd0c6e632dbfa5e4a047f616cdc00fa5b27879b27 module/agent-runtime +7a5d703261cef5a2f6273ecb8467946365d34760e98c549c5d8856653fc6ff3b module/agent-runtime 56b6d5fb6af3d95af1200065aca2e7d4f59e5fa59740505fe6ff433077ef6646 module/allow-from 55cea5390d68839ca7768b4a0cc570b17b65fa0fa3bc4d76130ef0f16cb79ede module/allowlist-config-edit 7ddd81bd5f55de9adf64bf4d92d012f24b37b6da0a72805a3a220d8feff24ca3 module/approval-auth-runtime @@ -94,7 +94,7 @@ ea56ea0455c62f292e1c7b3e56cac6eb3fea00e548f9ab2f4a69e0b41b0a2d96 module/memory- 987648ebe317cc4d6c0505a52d344b12ce9c3a8261a5bd969cee3423c0c53529 module/plugin-config-runtime d7dd3c82a4b1df9e4144b078caf0e6ab95e7b11c5046b53a38f01143a7fa0eec module/plugin-entry 8bc85c9f7df434c87cd7d35cc33080e2577a1893353f92bff8a0cc63d2c4eb0e module/plugin-runtime -de508df6d9cfa9d21c4524989dd8bd142fb60cb4ae980193e0907767d76e6022 module/provider-auth +4a505605103d4ff47f824d80a48268f163273db4d2286b4c97f9406d37555b6d module/provider-auth 71bebeac51e701cd7c8e63d22754b9bcbd82b024aced303aa055d229781129d7 module/provider-catalog-runtime 56151035047a69e6163d5578023d00f51a2413b777f3784af88e06261c039345 module/proxy-capture aa2a56b4448c8ebdec9d06aac95d809995f533093d42fa32cd75e1d852967245 module/question-gateway-runtime diff --git a/extensions/comfy/image-generation-provider.ts b/extensions/comfy/image-generation-provider.ts index 8cf23bf71d6..42acd67992d 100644 --- a/extensions/comfy/image-generation-provider.ts +++ b/extensions/comfy/image-generation-provider.ts @@ -15,10 +15,11 @@ export function buildComfyImageGenerationProvider(): ImageGenerationProvider { label: "ComfyUI", defaultModel: DEFAULT_COMFY_MODEL, models: [DEFAULT_COMFY_MODEL], - isConfigured: ({ cfg, agentDir }) => + isConfigured: ({ cfg, agentDir, workspaceDir }) => isComfyCapabilityConfigured({ cfg, agentDir, + workspaceDir, capability: "image", }), capabilities: { diff --git a/extensions/comfy/music-generation-provider.ts b/extensions/comfy/music-generation-provider.ts index a5dcebfbe43..1e24012cc1b 100644 --- a/extensions/comfy/music-generation-provider.ts +++ b/extensions/comfy/music-generation-provider.ts @@ -44,10 +44,11 @@ export function buildComfyMusicGenerationProvider(): MusicGenerationProvider { label: "ComfyUI", defaultModel: DEFAULT_COMFY_MODEL, models: [DEFAULT_COMFY_MODEL], - isConfigured: ({ cfg, agentDir }) => + isConfigured: ({ cfg, agentDir, workspaceDir }) => isComfyCapabilityConfigured({ cfg, agentDir, + workspaceDir, capability: "music", }), capabilities: { diff --git a/extensions/comfy/video-generation-provider.ts b/extensions/comfy/video-generation-provider.ts index e57c1bc1b49..5a2335740c6 100644 --- a/extensions/comfy/video-generation-provider.ts +++ b/extensions/comfy/video-generation-provider.ts @@ -30,10 +30,11 @@ export function buildComfyVideoGenerationProvider(): VideoGenerationProvider { label: "ComfyUI", defaultModel: DEFAULT_COMFY_MODEL, models: [DEFAULT_COMFY_MODEL], - isConfigured: ({ cfg, agentDir }) => + isConfigured: ({ cfg, agentDir, workspaceDir }) => isComfyCapabilityConfigured({ cfg, agentDir, + workspaceDir, capability: "video", }), capabilities: { diff --git a/extensions/comfy/workflow-runtime.ts b/extensions/comfy/workflow-runtime.ts index 58156e562bc..99ef06d6455 100644 --- a/extensions/comfy/workflow-runtime.ts +++ b/extensions/comfy/workflow-runtime.ts @@ -623,6 +623,7 @@ async function downloadOutputFile(params: { export function isComfyCapabilityConfigured(params: { cfg?: OpenClawConfig; agentDir?: string; + workspaceDir?: string; capability: ComfyCapability; }): boolean { const config = getComfyConfig(params.cfg); @@ -649,6 +650,7 @@ export function isComfyCapabilityConfigured(params: { provider: "comfy", cfg: params.cfg, agentDir: params.agentDir, + workspaceDir: params.workspaceDir, }); } diff --git a/extensions/openai/image-generation-provider.ts b/extensions/openai/image-generation-provider.ts index b44effc48eb..148a85fbf91 100644 --- a/extensions/openai/image-generation-provider.ts +++ b/extensions/openai/image-generation-provider.ts @@ -347,8 +347,10 @@ function shouldAllowPrivateImageEndpoint(req: { } function resolveRequestAuthStore(req: { + cfg?: OpenClawConfig; authStore?: AuthProfileStore; agentDir?: string; + workspaceDir?: string; }): AuthProfileStore | undefined { if (req.authStore) { return req.authStore; @@ -359,12 +361,15 @@ function resolveRequestAuthStore(req: { } return ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false, + config: req.cfg, + workspaceDir: req.workspaceDir, }); } function hasDirectOpenAIImageApiKeyAuth(params: { cfg?: OpenClawConfig; agentDir?: string; + workspaceDir?: string; }): boolean { if (hasExplicitOpenAIImageApiKeyConfig(params.cfg)) { return true; @@ -375,6 +380,8 @@ function hasDirectOpenAIImageApiKeyAuth(params: { const store = params.agentDir ? ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false, + config: params.cfg, + workspaceDir: params.workspaceDir, }) : undefined; if (!store) { @@ -388,8 +395,10 @@ function hasDirectOpenAIImageApiKeyAuth(params: { } function hasCodexResponseTransportProfileConfigured(req: { + cfg?: OpenClawConfig; authStore?: AuthProfileStore; agentDir?: string; + workspaceDir?: string; }): boolean { const store = resolveRequestAuthStore(req); if (!store) { @@ -900,7 +909,7 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider { return createOpenAIImageGenerationProviderBase({ id: "openai", label: "OpenAI", - isConfigured: ({ cfg, agentDir }) => { + isConfigured: ({ cfg, agentDir, workspaceDir }) => { // generateImage already authenticates from a config apiKey; count a // usable one (non-blank literal or secret ref) as configured here too, // so image gen works from config alone, like chat. @@ -913,19 +922,22 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider { if ( isProviderApiKeyConfigured({ provider: "openai", + cfg, agentDir, + workspaceDir, }) ) { return ( hasPublicOpenAIBaseUrl || - hasDirectOpenAIImageApiKeyAuth({ cfg, agentDir }) || - (hasChatGPTRouteConfig && hasCodexResponseTransportProfileConfigured({ agentDir })) + hasDirectOpenAIImageApiKeyAuth({ cfg, agentDir, workspaceDir }) || + (hasChatGPTRouteConfig && + hasCodexResponseTransportProfileConfigured({ cfg, agentDir, workspaceDir })) ); } if (!hasPublicOpenAIBaseUrl && !hasChatGPTRouteConfig) { return false; } - return hasCodexResponseTransportProfileConfigured({ agentDir }); + return hasCodexResponseTransportProfileConfigured({ cfg, agentDir, workspaceDir }); }, async generateImage(req) { const inputImages = req.inputImages ?? []; diff --git a/src/agents/agent-auth-discovery.ts b/src/agents/agent-auth-discovery.ts index e75463c7171..d555c320731 100644 --- a/src/agents/agent-auth-discovery.ts +++ b/src/agents/agent-auth-discovery.ts @@ -91,6 +91,7 @@ export function resolveAgentCredentialsForDiscovery( const storeOptions = { allowKeychainPrompt: false, ...(options?.config ? { config: options.config } : {}), + ...(options?.workspaceDir ? { workspaceDir: options.workspaceDir } : {}), ...(options?.externalCli ? { externalCli: options.externalCli } : {}), ...(options?.inheritedAuthDir ? { inheritedAuthDir: options.inheritedAuthDir } : {}), }; diff --git a/src/agents/auth-profiles/external-auth.ts b/src/agents/auth-profiles/external-auth.ts index d20807e7af4..450edf50761 100644 --- a/src/agents/auth-profiles/external-auth.ts +++ b/src/agents/auth-profiles/external-auth.ts @@ -22,6 +22,7 @@ type ResolveExternalAuthProfiles = typeof resolveExternalAuthProfilesWithPlugins type ExternalCliOverlayOptions = { allowKeychainPrompt?: boolean; config?: OpenClawConfig; + workspaceDir?: string; externalCliProviderIds?: Iterable; externalCliProfileIds?: Iterable; }; @@ -67,6 +68,7 @@ function isExternalAuthProfileAllowed( config: OpenClawConfig | undefined, explicitProfileIds: ReadonlySet | undefined, env: NodeJS.ProcessEnv, + workspaceDir?: string, ): boolean { // A provider pin protects its declared auth from ambient takeover. Stored and explicitly // bound profiles deliberately keep precedence; see runtime-plan/prepare-auth.test.ts. @@ -75,7 +77,7 @@ function isExternalAuthProfileAllowed( } return isAmbientCredentialAllowedByProviderAuthPin({ config, - authAliasLookupParams: { env }, + authAliasLookupParams: { env, workspaceDir }, provider: profile.credential.provider, type: profile.credential.type, }); @@ -93,10 +95,11 @@ function resolveExternalAuthProfileMap(params: { const profiles = resolveProfiles({ env, config: params.externalCli?.config, + workspaceDir: params.externalCli?.workspaceDir, context: { config: params.externalCli?.config, agentDir: params.agentDir, - workspaceDir: undefined, + workspaceDir: params.externalCli?.workspaceDir, env, store: params.store, }, @@ -118,6 +121,7 @@ function resolveExternalAuthProfileMap(params: { params.externalCli?.config, explicitProfileIds, env, + params.externalCli?.workspaceDir, ) ) { continue; @@ -140,6 +144,7 @@ function resolveExternalAuthProfileMap(params: { params.externalCli?.config, explicitProfileIds, env, + params.externalCli?.workspaceDir, ) ) { continue; @@ -221,7 +226,14 @@ export function syncPersistedExternalCliAuthProfiles( const persistedProfiles = cliProfiles.filter( (profile) => profile.persistence === "persisted" && - isExternalAuthProfileAllowed(profile, store, params?.config, explicitProfileIds, env), + isExternalAuthProfileAllowed( + profile, + store, + params?.config, + explicitProfileIds, + env, + params?.workspaceDir, + ), ); if (persistedProfiles.length === 0) { return store; diff --git a/src/agents/auth-profiles/external-auth.workspace.test.ts b/src/agents/auth-profiles/external-auth.workspace.test.ts new file mode 100644 index 00000000000..a6ed658a766 --- /dev/null +++ b/src/agents/auth-profiles/external-auth.workspace.test.ts @@ -0,0 +1,112 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { testing as externalAuthTesting } from "./external-auth.test-support.js"; +import type { ExternalCliAuthDiscovery } from "./external-cli-discovery.js"; +import { + clearRuntimeAuthProfileStoreSnapshots, + ensureAuthProfileStore, + loadAuthProfileStoreForSecretsRuntime, + saveAuthProfileStore, +} from "./store.js"; + +vi.mock("../cli-credentials.js", () => ({ + readClaudeCliCredentialsCached: () => null, + readCodexCliCredentialsCached: () => null, + readMiniMaxCliCredentialsCached: () => null, +})); + +describe("workspace-owned external auth overlays", () => { + afterEach(() => { + externalAuthTesting.resetResolveExternalAuthProfilesForTest(); + clearRuntimeAuthProfileStoreSnapshots(); + }); + + it.each([ + { label: "default discovery", externalCli: undefined }, + { label: "disabled CLI discovery", externalCli: { mode: "none" } }, + { label: "existing CLI discovery", externalCli: { mode: "existing" } }, + { label: "scoped CLI discovery", externalCli: { mode: "scoped", providerIds: [] } }, + ] satisfies Array<{ label: string; externalCli: ExternalCliAuthDiscovery | undefined }>)( + "keeps $label scoped while re-reading current external credentials", + async ({ externalCli }) => { + const root = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-external-workspace-")), + ); + const agentDir = path.join(root, "agent"); + const workspaceA = path.join(root, "project-a"); + const workspaceB = path.join(root, "project-b"); + const profileId = "workspace-provider:stored"; + const config: OpenClawConfig = { plugins: { enabled: false } }; + let generation = 1; + let available = true; + externalAuthTesting.setResolveExternalAuthProfilesForTest((params) => { + expect(params.config).toBe(config); + expect(params.context.config).toBe(config); + expect(params.workspaceDir).toBe(params.context.workspaceDir); + expect(params.context.agentDir).toBe(agentDir); + if (!available) { + return []; + } + return [ + { + profileId, + credential: { + type: "oauth", + provider: "workspace-provider", + access: `${params.context.workspaceDir}:${generation}`, + refresh: "external-refresh", + expires: Date.now() + 60_000, + }, + }, + ]; + }); + try { + saveAuthProfileStore( + { + version: 1, + profiles: { + [profileId]: { + type: "oauth", + provider: "workspace-provider", + access: "persisted-fallback", + refresh: "persisted-refresh", + expires: Date.now() + 60_000, + }, + }, + }, + agentDir, + { filterExternalAuthProfiles: false, syncExternalCli: false }, + ); + const read = (workspaceDir: string) => + ensureAuthProfileStore(agentDir, { + config, + workspaceDir, + inheritedAuthDir: agentDir, + externalCli, + readOnly: true, + syncExternalCli: false, + }).profiles[profileId]; + + expect(read(workspaceA)).toMatchObject({ access: `${workspaceA}:1` }); + expect(read(workspaceB)).toMatchObject({ access: `${workspaceB}:1` }); + generation += 1; + expect(read(workspaceA)).toMatchObject({ access: `${workspaceA}:2` }); + expect( + loadAuthProfileStoreForSecretsRuntime(agentDir, { + config, + workspaceDir: workspaceB, + inheritedAuthDir: agentDir, + externalCli, + }).profiles[profileId], + ).toMatchObject({ access: `${workspaceB}:2` }); + available = false; + expect(read(workspaceA)).toMatchObject({ access: "persisted-fallback" }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index d8fdc2cf710..8d3c9568612 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -70,6 +70,7 @@ const resolveAuthStorePath = resolveAuthProfileDatabasePath; type LoadAuthProfileStoreOptions = { allowKeychainPrompt?: boolean; config?: OpenClawConfig; + workspaceDir?: string; database?: OpenClawAgentDatabase; externalCli?: ExternalCliAuthDiscovery; inheritedAuthDir?: string; @@ -183,6 +184,7 @@ function preserveLegacyOAuthRefsOnSave(params: { type ResolvedExternalCliOverlayOptions = { allowKeychainPrompt?: boolean; config?: OpenClawConfig; + workspaceDir?: string; externalCliProviderIds?: Iterable; externalCliProfileIds?: Iterable; }; @@ -347,6 +349,7 @@ function resolveExternalCliOverlayOptions( const discovery = options?.externalCli; if (!discovery) { return { + workspaceDir: options?.workspaceDir, ...(options?.allowKeychainPrompt !== undefined ? { allowKeychainPrompt: options.allowKeychainPrompt } : {}), @@ -362,6 +365,7 @@ function resolveExternalCliOverlayOptions( if (discovery.mode === "none") { const config = discovery.config ?? options?.config; return { + workspaceDir: options?.workspaceDir, allowKeychainPrompt: false, ...(config ? { config } : {}), externalCliProviderIds: [], @@ -372,6 +376,7 @@ function resolveExternalCliOverlayOptions( const allowKeychainPrompt = discovery.allowKeychainPrompt ?? options?.allowKeychainPrompt; const config = discovery.config ?? options?.config; return { + workspaceDir: options?.workspaceDir, ...(allowKeychainPrompt !== undefined ? { allowKeychainPrompt } : {}), ...(config ? { config } : {}), }; @@ -379,6 +384,7 @@ function resolveExternalCliOverlayOptions( const allowKeychainPrompt = discovery.allowKeychainPrompt ?? options?.allowKeychainPrompt; const config = discovery.config ?? options?.config; return { + workspaceDir: options?.workspaceDir, ...(allowKeychainPrompt !== undefined ? { allowKeychainPrompt } : {}), ...(config ? { config } : {}), ...(discovery.providerIds ? { externalCliProviderIds: discovery.providerIds } : {}), @@ -1094,6 +1100,7 @@ export function loadAuthProfileStoreForSecretsRuntime( options?: Pick< LoadAuthProfileStoreOptions, | "config" + | "workspaceDir" | "externalCli" | "externalCliProviderIds" | "externalCliProfileIds" @@ -1148,6 +1155,7 @@ export function ensureAuthProfileStore( options?: { allowKeychainPrompt?: boolean; config?: OpenClawConfig; + workspaceDir?: string; externalCli?: ExternalCliAuthDiscovery; externalCliProviderIds?: Iterable; externalCliProfileIds?: Iterable; diff --git a/src/agents/embedded-agent-runner/compact.abort-signal.test.ts b/src/agents/embedded-agent-runner/compact.abort-signal.test.ts index 549d9c78827..ac3ee1af75e 100644 --- a/src/agents/embedded-agent-runner/compact.abort-signal.test.ts +++ b/src/agents/embedded-agent-runner/compact.abort-signal.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPluginMetadataSnapshot } from "../../config/plugin-auto-enable.test-helpers.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; vi.mock("../model-fallback-candidates.js", () => ({ @@ -35,6 +36,11 @@ vi.mock("../prepared-model-runtime.js", () => ({ agentId: input.agentId, agentDir: input.agentDir, workspaceDir: input.workspaceDir, + metadataSnapshot: createPluginMetadataSnapshot({ + config: input.config, + workspaceDir: input.workspaceDir, + manifestRegistry: { plugins: [], diagnostics: [] }, + }), createStores: () => ({}), }, release: vi.fn(), diff --git a/src/agents/embedded-agent-runner/compact.hooks.harness.ts b/src/agents/embedded-agent-runner/compact.hooks.harness.ts index 5a1274ccf09..3bd5f587ede 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.harness.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.harness.ts @@ -2,11 +2,12 @@ * Test harness mocks for embedded-agent compaction hook coverage. */ import { vi, type Mock } from "vitest"; -import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js"; +import { createPluginMetadataSnapshot } from "../../config/plugin-auto-enable.test-helpers.js"; import type { AuthProfileStore } from "../auth-profiles/types.js"; import { clearAgentHarnesses } from "../harness/registry.js"; import type { AgentHarness } from "../harness/types.js"; import type { ModelAuthMode } from "../model-auth.js"; +import type { PreparedModelRuntimeInput } from "../prepared-model-runtime.types.js"; import type { AgentRuntimePlan, BuildAgentRuntimePlanParams } from "../runtime-plan/types.js"; import { agentSessionAutomaticCompaction } from "../sessions/agent-session-compaction.js"; @@ -394,57 +395,30 @@ export const buildAgentRuntimePlanMock = vi.fn((params: BuildAgentRuntimePlanPar createCompactHooksRuntimePlan(params), ); -const emptyPluginMetadataSnapshot: PluginMetadataSnapshot = { - policyHash: "", - index: { - version: 1, - hostContractVersion: "test", - compatRegistryVersion: "test", - migrationVersion: 1, - policyHash: "", - generatedAtMs: 1, - installRecords: {}, - plugins: [], - diagnostics: [], - }, - registryDiagnostics: [], +const emptyPluginMetadataSnapshot = createPluginMetadataSnapshot({ manifestRegistry: { plugins: [], diagnostics: [] }, - plugins: [], - diagnostics: [], - byPluginId: new Map(), - normalizePluginId: (pluginId: string) => pluginId, - owners: { - channels: new Map(), - channelConfigs: new Map(), - providers: new Map(), - modelCatalogProviders: new Map(), - cliBackends: new Map(), - setupProviders: new Map(), - commandAliases: new Map(), - contracts: new Map(), - }, - metrics: { - registrySnapshotMs: 0, - manifestRegistryMs: 0, - ownerMapsMs: 0, - totalMs: 0, - indexPluginCount: 0, - manifestPluginCount: 0, - }, -}; +}); -export const acquireAgentRunPreparedModelRuntimeMock = vi.fn( - async (input: Record) => ({ - snapshot: { - agentId: input.agentId, - agentDir: input.agentDir, +function createCompactPreparedModelRuntimeSnapshot(input: PreparedModelRuntimeInput) { + return { + agentId: input.agentId, + agentDir: input.agentDir, + config: input.config, + workspaceDir: input.workspaceDir, + metadataSnapshot: createPluginMetadataSnapshot({ config: input.config, workspaceDir: input.workspaceDir, - metadataSnapshot: { ...emptyPluginMetadataSnapshot, workspaceDir: input.workspaceDir }, - configuredRuntimeModels: [], - inlineProviderModels: [], - createStores: () => ({ authStorage: {}, modelRegistry: {} }), - }, + manifestRegistry: { plugins: [], diagnostics: [] }, + }), + configuredRuntimeModels: [], + inlineProviderModels: [], + createStores: () => ({ authStorage: {}, modelRegistry: {} }), + }; +} + +export const acquireAgentRunPreparedModelRuntimeMock = vi.fn( + async (input: PreparedModelRuntimeInput) => ({ + snapshot: createCompactPreparedModelRuntimeSnapshot(input), release: vi.fn(), }), ); @@ -654,11 +628,17 @@ export async function loadCompactHooksHarness(): Promise<{ runGlobalGatewayStopSafely: vi.fn(async () => undefined), })); - vi.doMock("../../plugins/current-plugin-metadata-snapshot.js", () => ({ - getCurrentPluginMetadataSnapshot: () => emptyPluginMetadataSnapshot, - resolvePluginMetadataControlPlaneFingerprint: vi.fn(() => "test-plugin-fingerprint"), - setCurrentPluginMetadataSnapshot: vi.fn(), - })); + vi.doMock("../../plugins/current-plugin-metadata-snapshot.js", async () => { + const { withPluginMetadataSnapshotScope } = await vi.importActual< + typeof import("../../plugins/current-plugin-metadata-snapshot.js") + >("../../plugins/current-plugin-metadata-snapshot.js"); + return { + getCurrentPluginMetadataSnapshot: () => emptyPluginMetadataSnapshot, + resolvePluginMetadataControlPlaneFingerprint: vi.fn(() => "test-plugin-fingerprint"), + setCurrentPluginMetadataSnapshot: vi.fn(), + withPluginMetadataSnapshotScope, + }; + }); vi.doMock("../../plugins/command-registry-state.js", () => { const pluginCommands = new Map(); @@ -765,12 +745,12 @@ export async function loadCompactHooksHarness(): Promise<{ vi.doMock("../prepared-model-runtime.js", () => ({ activateStandalonePreparedModelRuntime: vi.fn(async () => {}), acquireAgentRunPreparedModelRuntime: acquireAgentRunPreparedModelRuntimeMock, - prepareModelRuntimeSnapshot: vi.fn(async () => ({ - createStores: () => ({ authStorage: {}, modelRegistry: {} }), - })), - loadPreparedModelRuntimeSnapshot: vi.fn(async () => ({ - createStores: () => ({ authStorage: {}, modelRegistry: {} }), - })), + prepareModelRuntimeSnapshot: vi.fn(async (input: PreparedModelRuntimeInput) => + createCompactPreparedModelRuntimeSnapshot(input), + ), + loadPreparedModelRuntimeSnapshot: vi.fn(async (input: PreparedModelRuntimeInput) => + createCompactPreparedModelRuntimeSnapshot(input), + ), })); vi.doMock("../model-auth.js", () => ({ diff --git a/src/agents/embedded-agent-runner/compact.queued.ts b/src/agents/embedded-agent-runner/compact.queued.ts index 08ea86cf2a9..32f6474f632 100644 --- a/src/agents/embedded-agent-runner/compact.queued.ts +++ b/src/agents/embedded-agent-runner/compact.queued.ts @@ -19,7 +19,6 @@ import { formatErrorMessage } from "../../infra/errors.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; import { requireActivePluginRegistry } from "../../plugins/runtime.js"; -import { withPluginRuntimeRegistryScope } from "../../plugins/runtime/gateway-request-scope.js"; import { enqueueCommandInLane } from "../../process/command-queue.js"; import { resolveUserPath } from "../../utils.js"; import { normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js"; @@ -32,6 +31,7 @@ import { acquireAgentRunPreparedModelRuntime, type PreparedModelRuntimeSnapshot, } from "../prepared-model-runtime.js"; +import { withPreparedModelRuntimeScope } from "../prepared-model-runtime.plugin-context.js"; import { resolveAgentRunSessionTarget } from "../run-session-target.js"; import { materializePreparedRuntimeModel } from "../runtime-plan/materialize-model.js"; import { SessionManager } from "../sessions/index.js"; @@ -373,7 +373,7 @@ async function compactEmbeddedAgentSessionImpl( } }; try { - return await withPluginRuntimeRegistryScope(lease.snapshot.pluginRegistry, run); + return await withPreparedModelRuntimeScope(lease.snapshot, run); } finally { lease.release(); } diff --git a/src/agents/embedded-agent-runner/compact.ts b/src/agents/embedded-agent-runner/compact.ts index b05bfa1a780..8289299e24b 100644 --- a/src/agents/embedded-agent-runner/compact.ts +++ b/src/agents/embedded-agent-runner/compact.ts @@ -3,7 +3,6 @@ */ import { resolveAgentModelFallbackValues } from "../../config/model-input.js"; import { formatErrorMessage } from "../../infra/errors.js"; -import { withPluginRuntimeRegistryScope } from "../../plugins/runtime/gateway-request-scope.js"; import { resolveUserPath } from "../../utils.js"; import { normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js"; import { @@ -21,6 +20,7 @@ import { isFallbackSummaryError } from "../model-fallback-attempt.js"; import { resolveModelCandidateChain } from "../model-fallback-candidates.js"; import { runWithModelFallback } from "../model-fallback-runner.js"; import { acquireAgentRunPreparedModelRuntime } from "../prepared-model-runtime.js"; +import { withPreparedModelRuntimeScope } from "../prepared-model-runtime.plugin-context.js"; import { resolveProjectKey } from "../project-memory-scope.js"; import { applyAgentRunSessionTargetIdentity, @@ -324,10 +324,7 @@ export async function compactEmbeddedAgentSessionDirect( }); return fallbackResult.result; }; - return await withPluginRuntimeRegistryScope( - preparedModelRuntime.pluginRegistry, - compactPrepared, - ); + return await withPreparedModelRuntimeScope(preparedModelRuntime, compactPrepared); } catch (err) { return fallbackFailureToCompactionResult(err); } finally { diff --git a/src/agents/embedded-agent-runner/model.registry-resolution.ts b/src/agents/embedded-agent-runner/model.registry-resolution.ts index 81d1b3cc753..f4cb934ef14 100644 --- a/src/agents/embedded-agent-runner/model.registry-resolution.ts +++ b/src/agents/embedded-agent-runner/model.registry-resolution.ts @@ -204,6 +204,7 @@ export function resolveDynamicModelAuthProfile(params: { modelId: string; cfg?: OpenClawConfig; agentDir?: string; + workspaceDir?: string; authProfileId?: string; authProfileMode?: AuthProfileCredential["type"] | "aws-sdk"; preferredProfile?: string; @@ -212,7 +213,11 @@ export function resolveDynamicModelAuthProfile(params: { authProfileMode?: AuthProfileCredential["type"] | "aws-sdk"; } { const explicitProfileId = params.authProfileId?.trim() || undefined; - const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }); + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + config: params.cfg, + workspaceDir: params.workspaceDir, + }); if (explicitProfileId) { const credential = store.profiles[explicitProfileId]; const configuredMode = params.cfg?.auth?.profiles?.[explicitProfileId]?.mode; @@ -285,6 +290,7 @@ function resolvePluginDynamicModelWithRegistry(params: { modelId, cfg, agentDir, + workspaceDir, authProfileId: params.authProfileId, authProfileMode: params.authProfileMode, preferredProfile: params.preferredProfile, diff --git a/src/agents/embedded-agent-runner/model.ts b/src/agents/embedded-agent-runner/model.ts index 49e027ad579..a922606c4ec 100644 --- a/src/agents/embedded-agent-runner/model.ts +++ b/src/agents/embedded-agent-runner/model.ts @@ -307,6 +307,7 @@ export async function resolveModelAsync( modelId: normalizedRef.model, cfg, agentDir: resolvedAgentDir, + workspaceDir, authProfileId: options?.authProfileId, authProfileMode: options?.authProfileMode, preferredProfile: options?.preferredProfile, diff --git a/src/agents/embedded-agent-runner/run-orchestrator.ts b/src/agents/embedded-agent-runner/run-orchestrator.ts index 19a56d48e3f..1e3db58e0f4 100644 --- a/src/agents/embedded-agent-runner/run-orchestrator.ts +++ b/src/agents/embedded-agent-runner/run-orchestrator.ts @@ -20,7 +20,6 @@ import { buildAgentHookContextIdentityFields, } from "../../plugins/hook-agent-context.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; -import { withPluginRuntimeRegistryScope } from "../../plugins/runtime/gateway-request-scope.js"; import { resolveUserPath } from "../../utils.js"; import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js"; import { @@ -34,6 +33,7 @@ import { acquireAgentRunPreparedModelRuntime, acquireReadOnlyPreparedModelRuntime, } from "../prepared-model-runtime.js"; +import { withPreparedModelRuntimeScope } from "../prepared-model-runtime.plugin-context.js"; import { resolveProjectKey } from "../project-memory-scope.js"; import { applyAgentRunSessionTargetIdentity, @@ -413,10 +413,7 @@ async function runEmbeddedAgentInternal( preparedModelRuntime, }); }; - return await withPluginRuntimeRegistryScope( - preparedModelRuntime.pluginRegistry, - runPrepared, - ); + return await withPreparedModelRuntimeScope(preparedModelRuntime, runPrepared); } finally { preparedModelRuntimeLease.release(); } diff --git a/src/agents/embedded-agent-runner/run/auth-plan.auth-pin.test.ts b/src/agents/embedded-agent-runner/run/auth-plan.auth-pin.test.ts index ac1b6c0344c..a20d11b75fe 100644 --- a/src/agents/embedded-agent-runner/run/auth-plan.auth-pin.test.ts +++ b/src/agents/embedded-agent-runner/run/auth-plan.auth-pin.test.ts @@ -51,6 +51,7 @@ describe("embedded run auth plan provider pin", () => { const authProfileStore = authPlanTesting.loadEmbeddedRunAuthProfileStore({ agentDir, + workspaceDir: agentDir, config, externalCliProviderIds: ["openai"], }); diff --git a/src/agents/embedded-agent-runner/run/auth-plan.test-support.ts b/src/agents/embedded-agent-runner/run/auth-plan.test-support.ts index 76e5b91c35b..a87893d3e85 100644 --- a/src/agents/embedded-agent-runner/run/auth-plan.test-support.ts +++ b/src/agents/embedded-agent-runner/run/auth-plan.test-support.ts @@ -5,6 +5,7 @@ import type { RunEmbeddedAgentParams } from "./params.js"; type EmbeddedRunAuthPlanTestApi = { loadEmbeddedRunAuthProfileStore(params: { agentDir: string; + workspaceDir: string; config: RunEmbeddedAgentParams["config"]; externalCliProviderIds: Iterable; }): AuthProfileStore; diff --git a/src/agents/embedded-agent-runner/run/auth-plan.ts b/src/agents/embedded-agent-runner/run/auth-plan.ts index 52a6cad8c28..4db78385a85 100644 --- a/src/agents/embedded-agent-runner/run/auth-plan.ts +++ b/src/agents/embedded-agent-runner/run/auth-plan.ts @@ -24,6 +24,7 @@ type RuntimeModel = NonNullable; function loadEmbeddedRunAuthProfileStore(params: { agentDir: string; + workspaceDir: string; config: RunEmbeddedAgentParams["config"]; externalCliProviderIds: Iterable; }): AuthProfileStore { @@ -31,6 +32,7 @@ function loadEmbeddedRunAuthProfileStore(params: { // explicit bindings remain available for the cross-class contracts in prepare-auth.test.ts. return ensureAuthProfileStore(params.agentDir, { config: params.config, + workspaceDir: params.workspaceDir, externalCliProviderIds: params.externalCliProviderIds, allowKeychainPrompt: false, }); @@ -111,6 +113,7 @@ export async function prepareEmbeddedRunAuthPlan(params: { const attemptAuthProfileStore = usesOpenAIAuthRouting ? loadEmbeddedRunAuthProfileStore({ agentDir: params.agentDir, + workspaceDir: params.workspaceDir, config: runParams.config, externalCliProviderIds: [OPENAI_PROVIDER_ID], }) @@ -121,6 +124,7 @@ export async function prepareEmbeddedRunAuthPlan(params: { : externalCliAuthScope.providerIds ? loadEmbeddedRunAuthProfileStore({ agentDir: params.agentDir, + workspaceDir: params.workspaceDir, config: runParams.config, externalCliProviderIds: externalCliAuthScope.providerIds, }) diff --git a/src/agents/isolated-completion.test.ts b/src/agents/isolated-completion.test.ts index 52e5cf5fbf4..d238198bad1 100644 --- a/src/agents/isolated-completion.test.ts +++ b/src/agents/isolated-completion.test.ts @@ -1,8 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPluginMetadataSnapshot } from "../config/plugin-auto-enable.test-helpers.js"; import type { AssistantMessage } from "../llm/types.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { mintSecretSentinel } from "../secrets/sentinel.js"; import type { AgentHarness } from "./harness/types.js"; +import type { PreparedModelRuntimeInput } from "./prepared-model-runtime.types.js"; const mocks = vi.hoisted(() => ({ acquireAgentRunPreparedModelRuntime: vi.fn(), @@ -99,10 +101,21 @@ function request() { beforeEach(() => { vi.clearAllMocks(); - mocks.acquireAgentRunPreparedModelRuntime.mockResolvedValue({ - snapshot: { pluginRegistry: createEmptyPluginRegistry() }, - release: vi.fn(), - }); + mocks.acquireAgentRunPreparedModelRuntime.mockImplementation( + async (input: PreparedModelRuntimeInput) => ({ + snapshot: { + config: input.config, + workspaceDir: input.workspaceDir, + pluginRegistry: createEmptyPluginRegistry(), + metadataSnapshot: createPluginMetadataSnapshot({ + config: input.config, + workspaceDir: input.workspaceDir, + manifestRegistry: { plugins: [], diagnostics: [] }, + }), + }, + release: vi.fn(), + }), + ); mocks.isCliRuntimeAliasForProvider.mockReturnValue(false); mocks.resolveCliRuntimeExecutionProvider.mockReturnValue(undefined); mocks.resolveEmbeddedCliBackendDispatchEligibility.mockReturnValue(undefined); diff --git a/src/agents/isolated-completion.ts b/src/agents/isolated-completion.ts index 7eef0d29a64..d6d8e0f692c 100644 --- a/src/agents/isolated-completion.ts +++ b/src/agents/isolated-completion.ts @@ -11,7 +11,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { withTempWorkspace } from "../infra/private-temp-workspace.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import type { AssistantMessage } from "../llm/types.js"; -import { withPluginRuntimeRegistryScope } from "../plugins/runtime/gateway-request-scope.js"; import { resolveAgentDir, resolveAgentWorkspaceDir, resolveDefaultAgentId } from "./agent-scope.js"; import { resolveCliBackendConfig, resolveCliRuntimeCanonicalProvider } from "./cli-backends.js"; import { normalizeCliModel } from "./cli-runner/helpers.js"; @@ -24,6 +23,7 @@ import { resolveCliRuntimeExecutionProvider, } from "./model-runtime-aliases.js"; import { acquireAgentRunPreparedModelRuntime } from "./prepared-model-runtime.js"; +import { withPreparedModelRuntimeScope } from "./prepared-model-runtime.plugin-context.js"; import { unwrapModelHeaderSentinelsForProviderEgress, unwrapSecretSentinelsForProviderEgress, @@ -428,7 +428,7 @@ export async function runIsolatedCompletion( usage: result.assistant.usage, }; }; - return await withPluginRuntimeRegistryScope(pluginRegistry, run); + return await withPreparedModelRuntimeScope(lease.snapshot, run); } finally { lease.release(); } diff --git a/src/agents/model-auth.workspace-plugin.test.ts b/src/agents/model-auth.workspace-plugin.test.ts index aeeef35affa..99fa4be9e08 100644 --- a/src/agents/model-auth.workspace-plugin.test.ts +++ b/src/agents/model-auth.workspace-plugin.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isProviderApiKeyConfigured } from "../plugin-sdk/provider-auth.js"; import { withEnvAsync } from "../test-utils/env.js"; import type { AuthProfileStore } from "./auth-profiles.js"; import { resolveEnvApiKey } from "./model-auth-env.js"; @@ -49,7 +50,9 @@ describe("workspace plugin model auth evidence", () => { it("uses trusted workspace plugin auth evidence across runtime and picker auth checks", async () => { // Without workspace scope the same env var is ignored; with scope, the // plugin-owned marker is accepted across all auth surfaces. - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-auth-")); + const tempRoot = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-auth-")), + ); const workspaceDir = path.join(tempRoot, "workspace"); const bundledDir = path.join(tempRoot, "bundled"); const stateDir = path.join(tempRoot, "state"); @@ -84,6 +87,17 @@ describe("workspace plugin model auth evidence", () => { apiKey: "workspace-cloud-local-credentials", source: "workspace cloud credentials", }); + const readiness = (selectedWorkspace: string, config = cfg) => + isProviderApiKeyConfigured({ + provider: "workspace-cloud", + cfg: config, + workspaceDir: selectedWorkspace, + }); + expect(readiness(workspaceDir)).toBe(true); + expect(readiness(path.join(tempRoot, "other-project"))).toBe(false); + expect(readiness(workspaceDir)).toBe(true); + expect(readiness(workspaceDir, { plugins: { enabled: false } })).toBe(false); + expect(readiness(workspaceDir)).toBe(true); await expect( resolveApiKeyForProvider({ provider: "workspace-cloud", @@ -115,6 +129,8 @@ describe("workspace plugin model auth evidence", () => { store, }), ).resolves.toBe(true); + await fs.rm(credentialsPath); + expect(readiness(workspaceDir)).toBe(false); }, ); } finally { diff --git a/src/agents/model-catalog.test.ts b/src/agents/model-catalog.test.ts index 0d1602bdcc6..7eff353e9b4 100644 --- a/src/agents/model-catalog.test.ts +++ b/src/agents/model-catalog.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPluginMetadataSnapshot } from "../config/plugin-auto-enable.test-helpers.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; import { resolveOAuthApiKeyMarker } from "./model-auth-markers.js"; @@ -91,6 +92,61 @@ describe("prepared model catalog builder", () => { mocks.augmentModelCatalogWithProviderPlugins.mockResolvedValue([]); }); + it("filters registry rows with the prepared manifest generation and current route", async () => { + const provider = "fixture"; + const config: OpenClawConfig = { + models: { + providers: { + fixture: { api: "openai-responses", baseUrl: "https://fixture.test/v1", models: [] }, + }, + }, + }; + const preparedMetadata = createPluginMetadataSnapshot({ + config, + workspaceDir: "/workspace/catalog", + manifestRegistry: { + diagnostics: [], + plugins: [ + { + id: provider, + origin: "bundled", + rootDir: "/plugins/fixture", + source: "/plugins/fixture/index.js", + manifestPath: "/plugins/fixture/openclaw.plugin.json", + channels: [], + providers: [provider], + cliBackends: [], + skills: [], + hooks: [], + modelCatalog: { + suppressions: [ + { + provider, + model: "blocked", + when: { baseUrlHosts: ["fixture.test"] }, + }, + ], + }, + }, + ], + }, + }); + const snapshot = await buildPreparedModelCatalogSnapshot({ + agentDir: "/tmp/model-catalog-test", + workspaceDir: "/workspace/catalog", + authCredentials: {}, + config, + metadataSnapshot: preparedMetadata, + modelRegistry: registry([ + { provider, id: "blocked", name: "Blocked", baseUrl: "https://fixture.test/v1" }, + { provider, id: "visible", name: "Visible" }, + ]), + readOnly: true, + }); + + expect(snapshot.entries.map((entry) => entry.id)).toEqual(["visible"]); + }); + it("projects and sorts one lifecycle registry generation", async () => { const snapshot = await build({ entries: [ diff --git a/src/agents/model-catalog.ts b/src/agents/model-catalog.ts index 9d140d97cfc..f26feaabf9f 100644 --- a/src/agents/model-catalog.ts +++ b/src/agents/model-catalog.ts @@ -478,7 +478,12 @@ export async function buildPreparedModelCatalogSnapshot( }); logStage("registry-read", `entries=${entries.length}`); - const shouldSuppressBuiltInModel = buildShouldSuppressBuiltInModel({ config: cfg }); + const shouldSuppressBuiltInModel = buildShouldSuppressBuiltInModel({ + config: cfg, + env, + workspaceDir, + metadataSnapshot: manifestMetadataSnapshot, + }); logStage("suppress-resolver-ready"); for (const entry of entries) { diff --git a/src/agents/model-suppression.test.ts b/src/agents/model-suppression.test.ts index 1145420ae00..3092b95a2dd 100644 --- a/src/agents/model-suppression.test.ts +++ b/src/agents/model-suppression.test.ts @@ -3,6 +3,7 @@ * Verifies plugin manifest suppression rules, cache reuse, and lifecycle clears. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createPluginMetadataSnapshot } from "../config/plugin-auto-enable.test-helpers.js"; const mocks = vi.hoisted(() => ({ buildManifestBuiltInModelSuppressionResolver: vi.fn(), @@ -12,7 +13,11 @@ vi.mock("../plugins/manifest-model-suppression.js", () => ({ buildManifestBuiltInModelSuppressionResolver: mocks.buildManifestBuiltInModelSuppressionResolver, })); -import { setCurrentPluginMetadataSnapshotState } from "../plugins/current-plugin-metadata-state.js"; +import { + setCurrentPluginMetadataSnapshot, + withPluginMetadataSnapshotScope, +} from "../plugins/current-plugin-metadata-snapshot.js"; +import { clearCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-state.js"; import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js"; import { buildShouldSuppressBuiltInModel, @@ -28,6 +33,7 @@ describe("model suppression", () => { }); afterEach(() => { + clearCurrentPluginMetadataSnapshot(); if (originalBundledPluginsDir === undefined) { delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; } else { @@ -99,10 +105,16 @@ describe("model suppression", () => { .mockReturnValueOnce(firstResolver) .mockReturnValueOnce(secondResolver); - setCurrentPluginMetadataSnapshotState({ id: "first" }, undefined); + setCurrentPluginMetadataSnapshot( + createPluginMetadataSnapshot({ config, manifestRegistry: { plugins: [], diagnostics: [] } }), + { config }, + ); expect(shouldSuppressBuiltInModel({ provider: "openai", id: "gpt-5.3", config })).toBe(false); - setCurrentPluginMetadataSnapshotState({ id: "second" }, undefined); + setCurrentPluginMetadataSnapshot( + createPluginMetadataSnapshot({ config, manifestRegistry: { plugins: [], diagnostics: [] } }), + { config }, + ); expect(shouldSuppressBuiltInModel({ provider: "openai", id: "gpt-5.3", config })).toBe(false); expect(mocks.buildManifestBuiltInModelSuppressionResolver).toHaveBeenCalledTimes(2); @@ -110,6 +122,48 @@ describe("model suppression", () => { expect(secondResolver).toHaveBeenCalledOnce(); }); + it("isolates concurrent suppression generations with the same config and workspace", async () => { + const config = {}; + const workspaceDir = "/workspace/shared"; + const first = createPluginMetadataSnapshot({ + config, + workspaceDir, + manifestRegistry: { plugins: [], diagnostics: [] }, + }); + const second = createPluginMetadataSnapshot({ + config, + workspaceDir, + manifestRegistry: { plugins: [], diagnostics: [] }, + }); + const firstResolver = vi.fn(() => ({ suppress: true })); + const secondResolver = vi.fn(() => undefined); + mocks.buildManifestBuiltInModelSuppressionResolver + .mockReturnValueOnce(firstResolver) + .mockReturnValueOnce(secondResolver) + .mockReturnValueOnce(firstResolver); + const check = () => shouldSuppressBuiltInModel({ provider: "fixture", id: "model", config }); + + const results = await Promise.all([ + withPluginMetadataSnapshotScope( + first, + async () => { + const initial = check(); + await Promise.resolve(); + return [initial, check()]; + }, + { config, workspaceDir }, + ), + withPluginMetadataSnapshotScope(second, async () => check(), { config, workspaceDir }), + ]); + + expect(results).toEqual([[true, true], false]); + expect( + mocks.buildManifestBuiltInModelSuppressionResolver.mock.calls.map( + ([params]) => params.metadataSnapshot, + ), + ).toEqual([first, second, first]); + }); + it("refreshes manifest suppression resolver when process env plugin metadata inputs change", () => { const firstResolver = vi.fn().mockReturnValue(undefined); const secondResolver = vi.fn().mockReturnValue(undefined); diff --git a/src/agents/model-suppression.ts b/src/agents/model-suppression.ts index 07e58a8d2cc..1d8ab526aed 100644 --- a/src/agents/model-suppression.ts +++ b/src/agents/model-suppression.ts @@ -6,11 +6,12 @@ import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { normalizeLowercaseStringOrEmpty } from "../../packages/normalization-core/src/string-coerce.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { getCurrentPluginMetadataSnapshotState } from "../plugins/current-plugin-metadata-state.js"; +import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js"; import { buildManifestBuiltInModelSuppressionResolver } from "../plugins/manifest-model-suppression.js"; import { resolvePluginControlPlaneFingerprint } from "../plugins/plugin-control-plane-context.js"; import { registerPluginMetadataProcessMemoLifecycleClear } from "../plugins/plugin-metadata-lifecycle.js"; import { resolvePluginMetadataEnvFingerprint } from "../plugins/plugin-metadata-snapshot.js"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; type ManifestSuppressionResolver = ReturnType; @@ -19,7 +20,7 @@ type CachedManifestSuppressionResolver = { controlPlaneFingerprint: string; cwd: string; envFingerprint: string; - metadataSnapshot: unknown; + metadataSnapshot: PluginMetadataSnapshot | undefined; resolver: ManifestSuppressionResolver; workspaceDir: string | undefined; }; @@ -46,7 +47,14 @@ function resolveCachedManifestSuppressionResolver(params: { }); const cwd = process.cwd(); const envFingerprint = resolvePluginMetadataEnvFingerprint(params.env); - const metadataSnapshot = getCurrentPluginMetadataSnapshotState().snapshot; + // The same config can be leased by concurrent owner generations. Cache the + // effective scoped snapshot, never the unrelated Gateway-global generation. + const metadataSnapshot = getCurrentPluginMetadataSnapshot({ + config: params.config, + env: params.env, + workspaceDir: params.workspaceDir, + allowWorkspaceScopedSnapshot: params.workspaceDir === undefined, + }); if ( cached !== undefined && cached.config === params.config && @@ -60,6 +68,7 @@ function resolveCachedManifestSuppressionResolver(params: { } const resolver = buildManifestBuiltInModelSuppressionResolver({ env: params.env, + ...(metadataSnapshot ? { metadataSnapshot } : {}), ...(params.config ? { config: params.config } : {}), ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), }); @@ -175,9 +184,12 @@ export function buildSuppressedBuiltInModelError(params: { export function buildShouldSuppressBuiltInModel(params: { config?: OpenClawConfig; workspaceDir?: string; + env?: NodeJS.ProcessEnv; + metadataSnapshot?: PluginMetadataSnapshot; }): (input: { provider?: string | null; id?: string | null; baseUrl?: string | null }) => boolean { const resolver = buildManifestBuiltInModelSuppressionResolver({ - env: process.env, + env: params.env ?? process.env, + ...(params.metadataSnapshot ? { metadataSnapshot: params.metadataSnapshot } : {}), ...(params.config ? { config: params.config } : {}), ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), }); diff --git a/src/agents/prepared-model-runtime.build.ts b/src/agents/prepared-model-runtime.build.ts index b35e81fce15..d12ca288cc6 100644 --- a/src/agents/prepared-model-runtime.build.ts +++ b/src/agents/prepared-model-runtime.build.ts @@ -3,20 +3,22 @@ import pLimit from "p-limit"; import { withTimeout } from "../node-host/with-timeout.js"; import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js"; import type { ModelCatalogSnapshot } from "./model-catalog.types.js"; +import { + prepareAgentCatalogSource, + type PreparedModelRuntimeCatalogSource, +} from "./prepared-model-runtime.catalog-source.js"; import { PreparedModelRuntimePublicationSupersededError, toPreparedModelRuntimeError, } from "./prepared-model-runtime.errors.js"; import { fingerprintPreparedRuntimeFacts, - prepareAgentCatalogSource, prepareConfiguredRuntimeFactsBatch, prepareFullCatalogFacts, preparedModelRuntimeWorkspaceFactsKey, prepareWorkspaceBuildGroup, type PreparedModelRuntimeAgentFacts, type PreparedModelRuntimeCatalogFacts, - type PreparedModelRuntimeCatalogSource, type PreparedModelRuntimeWorkspaceFacts, } from "./prepared-model-runtime.facts.js"; import type { diff --git a/src/agents/prepared-model-runtime.catalog-source.ts b/src/agents/prepared-model-runtime.catalog-source.ts new file mode 100644 index 00000000000..9cc1f5c8431 --- /dev/null +++ b/src/agents/prepared-model-runtime.catalog-source.ts @@ -0,0 +1,98 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; +import type { PreparedProviderStaticCatalog } from "../plugins/provider-discovery.js"; +import type { PluginRegistry } from "../plugins/registry-types.js"; +import { ensureOpenClawModelsJson, planOpenClawModelsJsonSource } from "./models-config.js"; +import { + loadPersistedPluginModelCatalogsReadOnly, + type PersistedPluginModelCatalog, +} from "./plugin-model-catalog.js"; +import { withPreparedModelRuntimeScope } from "./prepared-model-runtime.plugin-context.js"; +import type { + PreparedModelRuntimeCatalogMode, + PreparedModelRuntimeInput, +} from "./prepared-model-runtime.types.js"; + +const MODEL_RUNTIME_PROVIDER_DISCOVERY_TIMEOUT_MS = 5_000; + +export type PreparedModelRuntimeCatalogSource = Readonly<{ + modelsJsonContents: string | null; + pluginCatalogs: readonly PersistedPluginModelCatalog[]; +}>; + +export function captureModelsJsonContents(agentDir: string): string | null { + try { + return fs.readFileSync(path.join(agentDir, "models.json"), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw error; + } +} + +export async function prepareAgentCatalogSource( + agentFacts: { + input: PreparedModelRuntimeInput; + env: NodeJS.ProcessEnv; + providerIds: readonly string[]; + }, + workspaceFacts: { + pluginMetadataSnapshot: PluginMetadataSnapshot; + pluginRegistry?: PluginRegistry; + preparedStaticProviderCatalog?: PreparedProviderStaticCatalog; + }, + catalogMode: PreparedModelRuntimeCatalogMode, + persist = true, + sourceOptions: { providerDiscoveryProviderIds?: readonly string[] } = {}, +): Promise { + const { env, input, providerIds } = agentFacts; + return await withPreparedModelRuntimeScope( + { + config: input.config, + workspaceDir: input.workspaceDir, + metadataSnapshot: workspaceFacts.pluginMetadataSnapshot, + pluginRegistry: workspaceFacts.pluginRegistry, + }, + async () => { + const options = { + pluginMetadataSnapshot: workspaceFacts.pluginMetadataSnapshot, + ...(workspaceFacts.preparedStaticProviderCatalog + ? { preparedStaticProviderCatalog: workspaceFacts.preparedStaticProviderCatalog } + : {}), + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + ...(input.env ? { env } : {}), + ...(catalogMode === "static" + ? { + providerDiscoveryEntriesOnly: true as const, + providerDiscoveryProviderIds: + sourceOptions.providerDiscoveryProviderIds ?? providerIds, + } + : { + providerDiscoveryTimeoutMs: MODEL_RUNTIME_PROVIDER_DISCOVERY_TIMEOUT_MS, + ...(sourceOptions.providerDiscoveryProviderIds + ? { providerDiscoveryProviderIds: sourceOptions.providerDiscoveryProviderIds } + : {}), + }), + }; + if (!persist) { + const source = await planOpenClawModelsJsonSource(input.config, input.agentDir, options); + return { + modelsJsonContents: source.modelsJsonContents, + pluginCatalogs: source.pluginCatalogs, + }; + } + if (!input.readOnly) { + await ensureOpenClawModelsJson(input.config, input.agentDir, options); + } + // Capture immediately after the serialized write. Another owner may share this directory and + // publish a different workspace generation before full-catalog parsing begins. + return { + modelsJsonContents: captureModelsJsonContents(input.agentDir), + pluginCatalogs: loadPersistedPluginModelCatalogsReadOnly(input.agentDir), + }; + }, + { env }, + ); +} diff --git a/src/agents/prepared-model-runtime.facts.ts b/src/agents/prepared-model-runtime.facts.ts index fa9a64628ee..be9d216e60b 100644 --- a/src/agents/prepared-model-runtime.facts.ts +++ b/src/agents/prepared-model-runtime.facts.ts @@ -1,5 +1,3 @@ -import fs from "node:fs"; -import path from "node:path"; import { performance } from "node:perf_hooks"; import type { ConfiguredModelRef } from "@openclaw/model-catalog-core/configured-model-refs"; import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; @@ -37,13 +35,16 @@ import { createStaticModelIdMatcher } from "./embedded-agent-runner/model.static import { buildPreparedModelCatalogSnapshot, type ModelCatalogEntry } from "./model-catalog.js"; import type { ModelCatalogSnapshot } from "./model-catalog.types.js"; import { buildConfiguredModelCatalog } from "./model-selection-shared.js"; -import { ensureOpenClawModelsJson, planOpenClawModelsJsonSource } from "./models-config.js"; import { prepareImplicitProviderStaticCatalog } from "./models-config.providers.implicit.js"; import { loadPersistedPluginModelCatalogsReadOnly, resolvePluginModelCatalogOwnerPluginId, type PersistedPluginModelCatalog, } from "./plugin-model-catalog.js"; +import { + captureModelsJsonContents, + type PreparedModelRuntimeCatalogSource, +} from "./prepared-model-runtime.catalog-source.js"; import { collectPreparedModelRuntimeConfiguredRefs, collectConfiguredProviderIdsNeedingStaticCatalog, @@ -52,7 +53,10 @@ import { toStaticCatalogEntry, type PreparedConfiguredRuntimeModel, } from "./prepared-model-runtime.configured.js"; -import { prepareOwnedPluginLoadContext } from "./prepared-model-runtime.plugin-context.js"; +import { + prepareOwnedPluginLoadContext, + withPreparedModelRuntimeScope, +} from "./prepared-model-runtime.plugin-context.js"; import type { PreparedModelRuntimeBuildStats, PreparedModelRuntimeCatalogMode, @@ -62,7 +66,6 @@ import { loadAgentRuntimePluginRegistryHandle } from "./runtime-plugins.js"; import type { AuthStorage, AuthStorageData } from "./sessions/auth-storage.js"; import type { ModelRegistry } from "./sessions/model-registry.js"; -const MODEL_RUNTIME_PROVIDER_DISCOVERY_TIMEOUT_MS = 5_000; const fullModelCatalogSnapshots = new WeakSet(); type PreparedModelRuntimeAgentBaseFacts = { @@ -98,11 +101,6 @@ export type PreparedModelRuntimeCatalogFacts = { inlineProviderModels: readonly InlineModelEntry[]; }; -export type PreparedModelRuntimeCatalogSource = Readonly<{ - modelsJsonContents: string | null; - pluginCatalogs: readonly PersistedPluginModelCatalog[]; -}>; - type PreparedConfiguredRegistryGroup = { agentFacts: PreparedModelRuntimeAgentFacts[]; modelsJsonContents: string | null; @@ -235,202 +233,213 @@ export async function prepareWorkspaceBuildGroup( }) : undefined; const runtimePluginMs = performance.now() - runtimePluginStartedAt; - return await withPluginRuntimeRegistryScope(runtimePluginRegistry, async () => { - const pluginMetadataStartedAt = performance.now(); - const pluginMetadataSnapshot = prepareOwnedPluginLoadContext(input, env, runtimePluginRegistry); - const pluginMetadataMs = performance.now() - pluginMetadataStartedAt; - const matchesStaticModelId = createStaticModelIdMatcher({ - manifestPlugins: pluginMetadataSnapshot.plugins, - }); - const mediaCapabilityProviders = - input.readOnly || !runtimePluginRegistry - ? undefined - : prepareMediaCapabilityProviders({ - cfg: input.config, - pluginMetadataSnapshot, - registry: runtimePluginRegistry, - }); - const messageToolCatalog = runtimePluginRegistry - ? getPreparedMessageToolCatalogForRegistry(runtimePluginRegistry) - : catalogMode === "live" - ? getPreparedMessageToolCatalog() - : undefined; - const resolveManifestStaticCatalogModel = createBundledStaticCatalogModelResolver({ - cfg: input.config, - env, - includeRuntimeDiscovery: true, + const pluginMetadataStartedAt = performance.now(); + const pluginMetadataSnapshot = withPluginRuntimeRegistryScope(runtimePluginRegistry, () => + prepareOwnedPluginLoadContext(input, env, runtimePluginRegistry), + ); + const pluginMetadataMs = performance.now() - pluginMetadataStartedAt; + return await withPreparedModelRuntimeScope( + { + config: input.config, + workspaceDir: input.workspaceDir, metadataSnapshot: pluginMetadataSnapshot, - ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), - }); - const configuredManifestModels = new Map(); - const resolveConfiguredManifestModel = (lookup: { provider: string; modelId: string }) => { - const key = `${normalizeProviderId(lookup.provider)}\0${lookup.modelId.trim().toLowerCase()}`; - if (configuredManifestModels.has(key)) { - return configuredManifestModels.get(key); - } - const model = resolveManifestStaticCatalogModel(lookup); - configuredManifestModels.set(key, model); - return model; - }; - const configuredProviderIds = [ - ...new Set([ - ...collectPreparedModelRuntimeProviderIds(input.config, {}, false), - ...(options.providerDiscoveryProviderIds ?? []).map(normalizeProviderId).filter(Boolean), - ]), - ].toSorted((left, right) => left.localeCompare(right)); - const staticCatalogProviderIds = [ - ...new Set([ - ...collectConfiguredProviderIdsNeedingStaticCatalog({ - config: input.config, - matchesStaticModelId, - resolveStaticCatalogModel: resolveConfiguredManifestModel, - }), - ...(options.providerDiscoveryProviderIds ?? []).map(normalizeProviderId).filter(Boolean), - ]), - ].toSorted((left, right) => left.localeCompare(right)); - const staticProviderCatalogStartedAt = performance.now(); - const preparedStaticProviderCatalog = - catalogMode === "static" - ? await prepareImplicitProviderStaticCatalog({ + pluginRegistry: runtimePluginRegistry, + }, + async () => { + const matchesStaticModelId = createStaticModelIdMatcher({ + manifestPlugins: pluginMetadataSnapshot.plugins, + }); + const mediaCapabilityProviders = + input.readOnly || !runtimePluginRegistry + ? undefined + : prepareMediaCapabilityProviders({ + cfg: input.config, + pluginMetadataSnapshot, + registry: runtimePluginRegistry, + }); + const messageToolCatalog = runtimePluginRegistry + ? getPreparedMessageToolCatalogForRegistry(runtimePluginRegistry) + : catalogMode === "live" + ? getPreparedMessageToolCatalog() + : undefined; + const resolveManifestStaticCatalogModel = createBundledStaticCatalogModelResolver({ + cfg: input.config, + env, + includeRuntimeDiscovery: true, + metadataSnapshot: pluginMetadataSnapshot, + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + }); + const configuredManifestModels = new Map(); + const resolveConfiguredManifestModel = (lookup: { provider: string; modelId: string }) => { + const key = `${normalizeProviderId(lookup.provider)}\0${lookup.modelId.trim().toLowerCase()}`; + if (configuredManifestModels.has(key)) { + return configuredManifestModels.get(key); + } + const model = resolveManifestStaticCatalogModel(lookup); + configuredManifestModels.set(key, model); + return model; + }; + const configuredProviderIds = [ + ...new Set([ + ...collectPreparedModelRuntimeProviderIds(input.config, {}, false), + ...(options.providerDiscoveryProviderIds ?? []).map(normalizeProviderId).filter(Boolean), + ]), + ].toSorted((left, right) => left.localeCompare(right)); + const staticCatalogProviderIds = [ + ...new Set([ + ...collectConfiguredProviderIdsNeedingStaticCatalog({ config: input.config, - env, - pluginMetadataSnapshot, - providerDiscoveryProviderIds: configuredProviderIds, - staticCatalogProviderIds, - ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), - }) - : undefined; - const staticProviderCatalogMs = performance.now() - staticProviderCatalogStartedAt; - const preparedSyntheticAuthProviders = preparedStaticProviderCatalog?.providers ?? []; - // Static Gateway publication consumes provider discovery entrypoints without activating plugin - // runtimes. The run boundary already owns runtime activation for its exact workspace. - const ambientCredentialsStartedAt = performance.now(); - const ambientCredentials = resolveAmbientAgentCredentialsForDiscovery({ - config: input.config, - env, - syntheticAuthProviderRefs: + matchesStaticModelId, + resolveStaticCatalogModel: resolveConfiguredManifestModel, + }), + ...(options.providerDiscoveryProviderIds ?? []).map(normalizeProviderId).filter(Boolean), + ]), + ].toSorted((left, right) => left.localeCompare(right)); + const staticProviderCatalogStartedAt = performance.now(); + const preparedStaticProviderCatalog = catalogMode === "static" - ? listPreparedSyntheticAuthProviderRefs(preparedSyntheticAuthProviders) - : resolveRuntimeSyntheticAuthProviderRefs({ + ? await prepareImplicitProviderStaticCatalog({ config: input.config, env, - index: pluginMetadataSnapshot.index, - registryDiagnostics: pluginMetadataSnapshot.registryDiagnostics, + pluginMetadataSnapshot, + providerDiscoveryProviderIds: configuredProviderIds, + staticCatalogProviderIds, ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), - }), - ...(catalogMode === "static" - ? { - resolveSyntheticAuth: (provider: string) => - resolvePreparedSyntheticAuth({ + }) + : undefined; + const staticProviderCatalogMs = performance.now() - staticProviderCatalogStartedAt; + const preparedSyntheticAuthProviders = preparedStaticProviderCatalog?.providers ?? []; + // Static Gateway publication consumes provider discovery entrypoints without activating plugin + // runtimes. The run boundary already owns runtime activation for its exact workspace. + const ambientCredentialsStartedAt = performance.now(); + const ambientCredentials = resolveAmbientAgentCredentialsForDiscovery({ + config: input.config, + env, + syntheticAuthProviderRefs: + catalogMode === "static" + ? listPreparedSyntheticAuthProviderRefs(preparedSyntheticAuthProviders) + : resolveRuntimeSyntheticAuthProviderRefs({ config: input.config, - provider, - providers: preparedSyntheticAuthProviders, + env, + index: pluginMetadataSnapshot.index, + registryDiagnostics: pluginMetadataSnapshot.registryDiagnostics, + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), }), - } - : {}), - ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), - }); - const ambientCredentialsMs = performance.now() - ambientCredentialsStartedAt; - const agentFactsStartedAt = performance.now(); - const agentBaseFacts = inputs.map((candidate) => - prepareAgentFacts( - candidate, - catalogMode, - ambientCredentials, - options.providerDiscoveryProviderIds, - ), - ); - const agentFactsMs = performance.now() - agentFactsStartedAt; - const configuredProjectionStartedAt = performance.now(); - const providerStaticModels = - catalogMode === "static" - ? [] - : await loadBundledProviderStaticCatalogContextModels({ - cfg: input.config, - env, - metadataSnapshot: pluginMetadataSnapshot, - ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), - }); - // Provider definitions are process/config facts. Which refs are admitted remains agent-owned. - const inlineProviderModels = buildInlineProviderModels(input.config.models?.providers ?? {}, { - providerMetadataOwners: pluginMetadataSnapshot.owners, - }); - const configuredCatalogEntries = buildConfiguredModelCatalog({ - cfg: input.config, - manifestPlugins: pluginMetadataSnapshot.plugins, - ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), - }); - const agentFacts: PreparedModelRuntimeAgentFacts[] = []; - for (const facts of agentBaseFacts) { - const configuredRuntimeModels = prepareConfiguredRuntimeModels({ - config: facts.input.config, - configuredModelRefs: facts.configuredModelRefs, - metadataSnapshot: pluginMetadataSnapshot, - ...(preparedStaticProviderCatalog ? { preparedStaticProviderCatalog } : {}), - providerStaticModels, - matchesStaticModelId, - resolveStaticCatalogModel: resolveConfiguredManifestModel, - }); - const configuredEntryKeys = new Set(configuredCatalogEntries.map(modelCatalogEntryKey)); - for (const configured of configuredRuntimeModels) { - configuredEntryKeys.add( - modelCatalogEntryKey({ provider: configured.provider, id: configured.modelId }), - ); - } - const configuredGeneratedCatalogPluginIds = [ - ...new Set( - facts.configuredModelRefs.flatMap(({ value }) => { - const separator = value.indexOf("/"); - if (separator <= 0 || separator >= value.length - 1) { - return []; - } - const provider = normalizeProviderId(value.slice(0, separator)); - const modelId = value.slice(separator + 1).trim(); - if ( - !provider || - !modelId || - configuredEntryKeys.has(modelCatalogEntryKey({ provider, id: modelId })) - ) { - return []; + ...(catalogMode === "static" + ? { + resolveSyntheticAuth: (provider: string) => + resolvePreparedSyntheticAuth({ + config: input.config, + provider, + providers: preparedSyntheticAuthProviders, + }), } - const pluginId = resolvePluginModelCatalogOwnerPluginId({ - providerId: provider, - pluginMetadataSnapshot, - }); - return pluginId ? [pluginId] : []; - }), + : {}), + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + }); + const ambientCredentialsMs = performance.now() - ambientCredentialsStartedAt; + const agentFactsStartedAt = performance.now(); + const agentBaseFacts = inputs.map((candidate) => + prepareAgentFacts( + candidate, + catalogMode, + ambientCredentials, + options.providerDiscoveryProviderIds, ), - ].toSorted((left, right) => left.localeCompare(right)); - agentFacts.push({ - ...facts, - configuredRuntimeModels, - configuredGeneratedCatalogPluginIds, + ); + const agentFactsMs = performance.now() - agentFactsStartedAt; + const configuredProjectionStartedAt = performance.now(); + const providerStaticModels = + catalogMode === "static" + ? [] + : await loadBundledProviderStaticCatalogContextModels({ + cfg: input.config, + env, + metadataSnapshot: pluginMetadataSnapshot, + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + }); + // Provider definitions are process/config facts. Which refs are admitted remains agent-owned. + const inlineProviderModels = buildInlineProviderModels(input.config.models?.providers ?? {}, { + providerMetadataOwners: pluginMetadataSnapshot.owners, }); - } - const configuredProjectionMs = performance.now() - configuredProjectionStartedAt; - return { - agentFacts, - buildStats: { - runtimePluginMs, - pluginMetadataMs, - staticProviderCatalogMs, - ambientCredentialsMs, - agentFactsMs, - configuredProjectionMs, - }, - workspaceFacts: { - pluginMetadataSnapshot, - messageToolCatalog, - providerStaticModelsComplete: catalogMode === "live", - inlineProviderModels, - configuredCatalogEntries, - ...(runtimePluginRegistry ? { pluginRegistry: runtimePluginRegistry } : {}), - ...(mediaCapabilityProviders ? { mediaCapabilityProviders } : {}), - ...(preparedStaticProviderCatalog ? { preparedStaticProviderCatalog } : {}), - ...(providerStaticModels ? { providerStaticModels } : {}), - }, - }; - }); + const configuredCatalogEntries = buildConfiguredModelCatalog({ + cfg: input.config, + manifestPlugins: pluginMetadataSnapshot.plugins, + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + }); + const agentFacts: PreparedModelRuntimeAgentFacts[] = []; + for (const facts of agentBaseFacts) { + const configuredRuntimeModels = prepareConfiguredRuntimeModels({ + config: facts.input.config, + configuredModelRefs: facts.configuredModelRefs, + metadataSnapshot: pluginMetadataSnapshot, + ...(preparedStaticProviderCatalog ? { preparedStaticProviderCatalog } : {}), + providerStaticModels, + matchesStaticModelId, + resolveStaticCatalogModel: resolveConfiguredManifestModel, + }); + const configuredEntryKeys = new Set(configuredCatalogEntries.map(modelCatalogEntryKey)); + for (const configured of configuredRuntimeModels) { + configuredEntryKeys.add( + modelCatalogEntryKey({ provider: configured.provider, id: configured.modelId }), + ); + } + const configuredGeneratedCatalogPluginIds = [ + ...new Set( + facts.configuredModelRefs.flatMap(({ value }) => { + const separator = value.indexOf("/"); + if (separator <= 0 || separator >= value.length - 1) { + return []; + } + const provider = normalizeProviderId(value.slice(0, separator)); + const modelId = value.slice(separator + 1).trim(); + if ( + !provider || + !modelId || + configuredEntryKeys.has(modelCatalogEntryKey({ provider, id: modelId })) + ) { + return []; + } + const pluginId = resolvePluginModelCatalogOwnerPluginId({ + providerId: provider, + pluginMetadataSnapshot, + }); + return pluginId ? [pluginId] : []; + }), + ), + ].toSorted((left, right) => left.localeCompare(right)); + agentFacts.push({ + ...facts, + configuredRuntimeModels, + configuredGeneratedCatalogPluginIds, + }); + } + const configuredProjectionMs = performance.now() - configuredProjectionStartedAt; + return { + agentFacts, + buildStats: { + runtimePluginMs, + pluginMetadataMs, + staticProviderCatalogMs, + ambientCredentialsMs, + agentFactsMs, + configuredProjectionMs, + }, + workspaceFacts: { + pluginMetadataSnapshot, + messageToolCatalog, + providerStaticModelsComplete: catalogMode === "live", + inlineProviderModels, + configuredCatalogEntries, + ...(runtimePluginRegistry ? { pluginRegistry: runtimePluginRegistry } : {}), + ...(mediaCapabilityProviders ? { mediaCapabilityProviders } : {}), + ...(preparedStaticProviderCatalog ? { preparedStaticProviderCatalog } : {}), + ...(providerStaticModels ? { providerStaticModels } : {}), + }, + }; + }, + { env }, + ); } export async function prepareFullCatalogFacts( @@ -441,63 +450,74 @@ export async function prepareFullCatalogFacts( ): Promise { const { credentials, env, input, templateAuthStorage } = agentFacts; const { pluginMetadataSnapshot, preparedStaticProviderCatalog } = workspaceFacts; - const templateModelRegistry = discoverModels(templateAuthStorage, input.agentDir, { - config: input.config, - ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), - pluginMetadataSnapshot, - ...(catalogMode === "static" ? { normalizeModels: false } : {}), - ...(catalogSource - ? { - includePluginCatalogs: true, - modelsJsonContents: catalogSource.modelsJsonContents, - pluginCatalogs: catalogSource.pluginCatalogs, - } - : {}), - }); - const modelCatalog = await buildPreparedModelCatalogSnapshot({ - agentDir: input.agentDir, - authCredentials: credentials, - config: input.config, - modelRegistry: templateModelRegistry, - metadataSnapshot: pluginMetadataSnapshot, - includeProviderPluginAugmentation: catalogMode === "live", - ...(input.env ? { env } : {}), - ...(input.readOnly ? { readOnly: true } : {}), - ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), - }); - const providerStaticModels = - (workspaceFacts.providerStaticModelsComplete - ? workspaceFacts.providerStaticModels - : undefined) ?? - (await loadBundledProviderStaticCatalogContextModels({ - cfg: input.config, - env, + return await withPreparedModelRuntimeScope( + { + config: input.config, + workspaceDir: input.workspaceDir, metadataSnapshot: pluginMetadataSnapshot, - ...(preparedStaticProviderCatalog ? { preparedStaticProviderCatalog } : {}), - ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), - })); - const configuredRuntimeModels = agentFacts.configuredRuntimeModels; - const staticModels = new Map(); - for (const model of [ - ...configuredRuntimeModels.map((configured) => configured.model), - ...providerStaticModels, - ]) { - const modelKey = `${normalizeProviderId(model.provider)}\0${model.id.trim().toLowerCase()}`; - if (!staticModels.has(modelKey)) { - staticModels.set(modelKey, model); - } - } - const staticEntries = [...staticModels.values()].map(toStaticCatalogEntry); - const completeModelCatalog = { ...modelCatalog, staticEntries }; - if (catalogMode === "live") { - fullModelCatalogSnapshots.add(completeModelCatalog); - } - return { - templateModelRegistry, - modelCatalog: completeModelCatalog, - configuredRuntimeModels, - inlineProviderModels: workspaceFacts.inlineProviderModels, - }; + pluginRegistry: workspaceFacts.pluginRegistry, + }, + async () => { + const templateModelRegistry = discoverModels(templateAuthStorage, input.agentDir, { + config: input.config, + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + pluginMetadataSnapshot, + ...(catalogMode === "static" ? { normalizeModels: false } : {}), + ...(catalogSource + ? { + includePluginCatalogs: true, + modelsJsonContents: catalogSource.modelsJsonContents, + pluginCatalogs: catalogSource.pluginCatalogs, + } + : {}), + }); + const modelCatalog = await buildPreparedModelCatalogSnapshot({ + agentDir: input.agentDir, + authCredentials: credentials, + config: input.config, + modelRegistry: templateModelRegistry, + metadataSnapshot: pluginMetadataSnapshot, + includeProviderPluginAugmentation: catalogMode === "live", + ...(input.env ? { env } : {}), + ...(input.readOnly ? { readOnly: true } : {}), + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + }); + const providerStaticModels = + (workspaceFacts.providerStaticModelsComplete + ? workspaceFacts.providerStaticModels + : undefined) ?? + (await loadBundledProviderStaticCatalogContextModels({ + cfg: input.config, + env, + metadataSnapshot: pluginMetadataSnapshot, + ...(preparedStaticProviderCatalog ? { preparedStaticProviderCatalog } : {}), + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + })); + const configuredRuntimeModels = agentFacts.configuredRuntimeModels; + const staticModels = new Map(); + for (const model of [ + ...configuredRuntimeModels.map((configured) => configured.model), + ...providerStaticModels, + ]) { + const modelKey = `${normalizeProviderId(model.provider)}\0${model.id.trim().toLowerCase()}`; + if (!staticModels.has(modelKey)) { + staticModels.set(modelKey, model); + } + } + const staticEntries = [...staticModels.values()].map(toStaticCatalogEntry); + const completeModelCatalog = { ...modelCatalog, staticEntries }; + if (catalogMode === "live") { + fullModelCatalogSnapshots.add(completeModelCatalog); + } + return { + templateModelRegistry, + modelCatalog: completeModelCatalog, + configuredRuntimeModels, + inlineProviderModels: workspaceFacts.inlineProviderModels, + }; + }, + { env }, + ); } /** Reports whether a catalog came from the complete prepared-catalog build path. */ @@ -575,17 +595,6 @@ function prepareConfiguredRuntimeFacts( }; } -function captureModelsJsonContents(agentDir: string): string | null { - try { - return fs.readFileSync(path.join(agentDir, "models.json"), "utf8"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return null; - } - throw error; - } -} - export function fingerprintPreparedRuntimeFacts(value: unknown): string { return sha256Base64Url(stableStringify(value)); } @@ -690,48 +699,3 @@ export function prepareConfiguredRuntimeFactsBatch(params: { } return { catalogs, registryCount }; } - -export async function prepareAgentCatalogSource( - agentFacts: PreparedModelRuntimeAgentFacts, - workspaceFacts: PreparedModelRuntimeWorkspaceFacts, - catalogMode: PreparedModelRuntimeCatalogMode, - persist = true, - sourceOptions: { providerDiscoveryProviderIds?: readonly string[] } = {}, -): Promise { - const { env, input, providerIds } = agentFacts; - const options = { - pluginMetadataSnapshot: workspaceFacts.pluginMetadataSnapshot, - ...(workspaceFacts.preparedStaticProviderCatalog - ? { preparedStaticProviderCatalog: workspaceFacts.preparedStaticProviderCatalog } - : {}), - ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), - ...(input.env ? { env } : {}), - ...(catalogMode === "static" - ? { - providerDiscoveryEntriesOnly: true as const, - providerDiscoveryProviderIds: sourceOptions.providerDiscoveryProviderIds ?? providerIds, - } - : { - providerDiscoveryTimeoutMs: MODEL_RUNTIME_PROVIDER_DISCOVERY_TIMEOUT_MS, - ...(sourceOptions.providerDiscoveryProviderIds - ? { providerDiscoveryProviderIds: sourceOptions.providerDiscoveryProviderIds } - : {}), - }), - }; - if (!persist) { - const source = await planOpenClawModelsJsonSource(input.config, input.agentDir, options); - return { - modelsJsonContents: source.modelsJsonContents, - pluginCatalogs: source.pluginCatalogs, - }; - } - if (!input.readOnly) { - await ensureOpenClawModelsJson(input.config, input.agentDir, options); - } - // Capture immediately after the serialized write. Another owner may share this directory and - // publish a different workspace generation before full-catalog parsing begins. - return { - modelsJsonContents: captureModelsJsonContents(input.agentDir), - pluginCatalogs: loadPersistedPluginModelCatalogsReadOnly(input.agentDir), - }; -} diff --git a/src/agents/prepared-model-runtime.plugin-context.test.ts b/src/agents/prepared-model-runtime.plugin-context.test.ts new file mode 100644 index 00000000000..14cc650e31b --- /dev/null +++ b/src/agents/prepared-model-runtime.plugin-context.test.ts @@ -0,0 +1,401 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js"; +import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js"; +import * as pluginMetadata from "../plugins/plugin-metadata-snapshot.js"; +import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; +import { requireActivePluginRegistry } from "../plugins/runtime.js"; +import { resolveProviderAuthLookupMaps } from "../secrets/provider-env-vars.js"; +import { createDeferred } from "../test-utils/deferred.js"; +import { isAmbientCredentialAllowedByProviderAuthPin } from "./auth-profiles/ambient-auth.js"; +import { resolveEnvApiKey } from "./model-auth-env.js"; +import { + prepareOwnedPluginLoadContext, + withPreparedModelRuntimeScope, +} from "./prepared-model-runtime.plugin-context.js"; +import { resolveProviderIdForAuth } from "./provider-auth-aliases.js"; +import { projectProviderModelRouteConfig } from "./provider-model-route.js"; + +const PLUGIN_ID = "prepared-owner-env"; +const PROVIDER_ID = "prepared-owner-provider"; +const tempRoots: string[] = []; + +function createFixture() { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "prepared-owner-metadata-"))); + tempRoots.push(root); + const home = path.join(root, "home"); + const stateDir = path.join(root, "state"); + const bundledDir = path.join(root, "bundled"); + for (const dir of [home, stateDir, bundledDir]) { + fs.mkdirSync(dir, { recursive: true }); + } + const env: NodeJS.ProcessEnv = { + HOME: home, + USERPROFILE: home, + OPENCLAW_STATE_DIR: stateDir, + // The fixture has no bundled runtime: only its generated workspace manifests participate. + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, + OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1", + PREPARED_OWNER_A_KEY: "credential-a", + PREPARED_OWNER_B_KEY: "credential-b", + PREPARED_OWNER_REPLACEMENT_KEY: "credential-replacement", + }; + const config: OpenClawConfig = { plugins: { allow: [PLUGIN_ID] } }; + return { root, env, config }; +} + +function writeProviderManifest( + pluginRoot: string, + envVar: string, + providerAuthAliases?: Record, +) { + fs.mkdirSync(pluginRoot, { recursive: true }); + fs.writeFileSync( + path.join(pluginRoot, "package.json"), + JSON.stringify({ + name: `@fixture/${PLUGIN_ID}`, + version: "1.0.0", + type: "module", + openclaw: { extensions: ["./index.js"] }, + }), + ); + fs.writeFileSync(path.join(pluginRoot, "index.js"), "export default {};\n"); + fs.writeFileSync( + path.join(pluginRoot, "openclaw.plugin.json"), + JSON.stringify({ + id: PLUGIN_ID, + providers: [PROVIDER_ID], + setup: { providers: [{ id: PROVIDER_ID, envVars: [envVar] }] }, + ...(providerAuthAliases ? { providerAuthAliases } : {}), + configSchema: { type: "object", additionalProperties: false, properties: {} }, + }), + ); +} + +function prepareOwner( + fixture: ReturnType, + name: string, + envVar: string, + providerAuthAliases?: Record, +) { + const workspaceDir = path.join(fixture.root, name); + const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", PLUGIN_ID); + writeProviderManifest(pluginRoot, envVar, providerAuthAliases); + const pluginRegistry = createEmptyPluginRegistry(); + const metadataSnapshot = prepareOwnedPluginLoadContext( + { + agentId: name, + agentDir: path.join(fixture.root, `agent-${name}`), + config: fixture.config, + workspaceDir, + }, + fixture.env, + pluginRegistry, + ); + expect(metadataSnapshot.plugins.map((plugin) => plugin.id)).toEqual([PLUGIN_ID]); + const owner = Object.freeze({ + config: fixture.config, + workspaceDir, + metadataSnapshot, + pluginRegistry, + }); + return { owner, pluginRoot }; +} + +describe("prepared model runtime plugin context", () => { + beforeEach(() => { + clearPluginMetadataLifecycleCaches(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + clearPluginMetadataLifecycleCaches(); + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("reuses owned metadata for real env-auth reads without freezing current credentials", () => { + const fixture = createFixture(); + const { owner } = prepareOwner(fixture, "first", "PREPARED_OWNER_A_KEY"); + // Call through the actual materializer; counting a facade mock would miss nested rescans. + const materializations = vi.spyOn(pluginMetadata, "loadPluginMetadataSnapshot"); + const lookup = () => + resolveEnvApiKey(PROVIDER_ID, fixture.env, { + config: fixture.config, + workspaceDir: owner.workspaceDir, + }); + + for (let read = 0; read < 3; read += 1) { + expect(lookup()).toEqual({ apiKey: "credential-a", source: "env: PREPARED_OWNER_A_KEY" }); + } + expect(materializations).toHaveBeenCalledTimes(3); + materializations.mockClear(); + + withPreparedModelRuntimeScope(owner, () => { + expect(requireActivePluginRegistry()).toBe(owner.pluginRegistry); + for (let read = 0; read < 3; read += 1) { + expect(lookup()).toEqual({ apiKey: "credential-a", source: "env: PREPARED_OWNER_A_KEY" }); + } + fixture.env.PREPARED_OWNER_A_KEY = "credential-updated"; + expect(lookup()).toEqual({ + apiKey: "credential-updated", + source: "env: PREPARED_OWNER_A_KEY", + }); + delete fixture.env.PREPARED_OWNER_A_KEY; + expect(lookup()).toBeNull(); + }); + + expect(materializations).not.toHaveBeenCalled(); + expect( + getCurrentPluginMetadataSnapshot({ + config: fixture.config, + env: fixture.env, + workspaceDir: owner.workspaceDir, + }), + ).toBeUndefined(); + }); + + it("keeps interleaved owners on their workspace metadata and registry", async () => { + const fixture = createFixture(); + const first = prepareOwner(fixture, "first", "PREPARED_OWNER_A_KEY").owner; + const second = prepareOwner(fixture, "second", "PREPARED_OWNER_B_KEY").owner; + const materializations = vi.spyOn(pluginMetadata, "loadPluginMetadataSnapshot"); + const firstEntered = createDeferred(); + const secondEntered = createDeferred(); + const releaseFirst = createDeferred(); + const releaseSecond = createDeferred(); + const checkFirst = () => { + expect(requireActivePluginRegistry()).toBe(first.pluginRegistry); + expect(resolveEnvApiKey(PROVIDER_ID, fixture.env)).toEqual({ + apiKey: "credential-a", + source: "env: PREPARED_OWNER_A_KEY", + }); + }; + const checkSecond = () => { + expect(requireActivePluginRegistry()).toBe(second.pluginRegistry); + expect(resolveEnvApiKey(PROVIDER_ID, fixture.env)).toEqual({ + apiKey: "credential-b", + source: "env: PREPARED_OWNER_B_KEY", + }); + }; + const firstRun = withPreparedModelRuntimeScope(first, async () => { + firstEntered.resolve(); + await releaseFirst.promise; + checkFirst(); + }); + await firstEntered.promise; + const secondRun = withPreparedModelRuntimeScope(second, async () => { + secondEntered.resolve(); + await releaseSecond.promise; + checkSecond(); + }); + await secondEntered.promise; + releaseFirst.resolve(); + await firstRun; + releaseSecond.resolve(); + await secondRun; + + expect(materializations).not.toHaveBeenCalled(); + }); + + it("shares route-projected metadata while applying current policy to runtime fallbacks", () => { + const fixture = createFixture(); + const { owner } = prepareOwner(fixture, "first", "PREPARED_OWNER_A_KEY"); + const routeConfig = projectProviderModelRouteConfig({ + config: fixture.config, + provider: PROVIDER_ID, + route: { + api: "openai-completions", + baseUrl: "https://provider.invalid/api", + authRequirement: "api-key", + requestTransportOverrides: "none", + }, + }); + const deniedConfig: OpenClawConfig = { + ...routeConfig, + plugins: { ...routeConfig.plugins, deny: [PLUGIN_ID] }, + }; + const materializations = vi.spyOn(pluginMetadata, "loadPluginMetadataSnapshot"); + + expect(routeConfig).not.toEqual(fixture.config); + withPreparedModelRuntimeScope(owner, () => { + expect( + resolveEnvApiKey(PROVIDER_ID, fixture.env, { + config: routeConfig, + workspaceDir: owner.workspaceDir, + }), + ).toEqual({ apiKey: "credential-a", source: "env: PREPARED_OWNER_A_KEY" }); + expect(materializations).not.toHaveBeenCalled(); + expect( + resolveProviderAuthLookupMaps({ + config: routeConfig, + env: fixture.env, + workspaceDir: owner.workspaceDir, + }).setupProviderFallbackRefs, + ).toContain(PROVIDER_ID); + expect( + getCurrentPluginMetadataSnapshot({ + config: deniedConfig, + env: fixture.env, + workspaceDir: owner.workspaceDir, + }), + ).toBeUndefined(); + // Env-var names are discovery hints even for disabled plugins. Runtime fallback + // eligibility is policy-sensitive and must be recomputed from the caller's config. + expect( + resolveProviderAuthLookupMaps({ + config: deniedConfig, + env: fixture.env, + workspaceDir: owner.workspaceDir, + }).setupProviderFallbackRefs, + ).not.toContain(PROVIDER_ID); + }); + }); + + it("keeps a leased generation stable while a replacement owner becomes available", async () => { + const fixture = createFixture(); + const first = prepareOwner(fixture, "first", "PREPARED_OWNER_A_KEY"); + const entered = createDeferred(); + const resume = createDeferred(); + const oldRun = withPreparedModelRuntimeScope(first.owner, async () => { + entered.resolve(); + await resume.promise; + expect(resolveEnvApiKey(PROVIDER_ID, fixture.env)).toEqual({ + apiKey: "credential-a", + source: "env: PREPARED_OWNER_A_KEY", + }); + }); + await entered.promise; + clearPluginMetadataLifecycleCaches(); + const replacement = prepareOwner(fixture, "first", "PREPARED_OWNER_REPLACEMENT_KEY"); + expect(replacement.owner.metadataSnapshot).not.toBe(first.owner.metadataSnapshot); + const materializations = vi.spyOn(pluginMetadata, "loadPluginMetadataSnapshot"); + await withPreparedModelRuntimeScope(replacement.owner, async () => { + await Promise.resolve(); + expect(resolveEnvApiKey(PROVIDER_ID, fixture.env)).toEqual({ + apiKey: "credential-replacement", + source: "env: PREPARED_OWNER_REPLACEMENT_KEY", + }); + }); + resume.resolve(); + await oldRun; + + expect(materializations).not.toHaveBeenCalled(); + }); + + it.each(["old-first", "new-first"])( + "keeps auth aliases and pins within overlapping generations (%s)", + async (order) => { + const fixture = createFixture(); + fixture.config.models = { + providers: { + "old-target": { baseUrl: "https://old.invalid/v1", auth: "oauth", models: [] }, + "new-target": { baseUrl: "https://new.invalid/v1", auth: "api-key", models: [] }, + }, + }; + const first = prepareOwner(fixture, "first", "PREPARED_OWNER_A_KEY", { + "generation-alias": "old-target", + }).owner; + const entered = createDeferred(); + const resume = createDeferred(); + const lookup = () => { + const params = { + config: fixture.config, + env: fixture.env, + workspaceDir: first.workspaceDir, + }; + const allowed = (type: "oauth" | "api_key") => + isAmbientCredentialAllowedByProviderAuthPin({ + config: fixture.config, + authAliasLookupParams: { env: fixture.env, workspaceDir: first.workspaceDir }, + provider: "generation-alias", + type, + }); + return { + alias: resolveProviderIdForAuth("generation-alias", params), + oauthAllowed: allowed("oauth"), + apiKeyAllowed: allowed("api_key"), + }; + }; + const oldRun = withPreparedModelRuntimeScope(first, async () => { + entered.resolve(); + await resume.promise; + return lookup(); + }); + await entered.promise; + clearPluginMetadataLifecycleCaches(); + const replacement = prepareOwner(fixture, "first", "PREPARED_OWNER_REPLACEMENT_KEY", { + "generation-alias": "new-target", + }).owner; + const materializations = vi.spyOn(pluginMetadata, "loadPluginMetadataSnapshot"); + let oldResult: ReturnType; + let newResult: ReturnType; + if (order === "old-first") { + resume.resolve(); + oldResult = await oldRun; + newResult = withPreparedModelRuntimeScope(replacement, lookup); + } else { + newResult = withPreparedModelRuntimeScope(replacement, lookup); + resume.resolve(); + oldResult = await oldRun; + } + expect(oldResult).toEqual({ alias: "old-target", oauthAllowed: true, apiKeyAllowed: false }); + expect(newResult).toEqual({ alias: "new-target", oauthAllowed: false, apiKeyAllowed: true }); + expect(materializations).not.toHaveBeenCalled(); + }, + ); + + it("keeps implicit-workspace auth aliases within the active owner", () => { + const fixture = createFixture(); + const first = prepareOwner(fixture, "first", "PREPARED_OWNER_A_KEY", { + "workspace-alias": "first-target", + }).owner; + const second = prepareOwner(fixture, "second", "PREPARED_OWNER_B_KEY", { + "workspace-alias": "second-target", + }).owner; + const materializations = vi.spyOn(pluginMetadata, "loadPluginMetadataSnapshot"); + const lookup = () => + resolveProviderIdForAuth("workspace-alias", { config: fixture.config, env: fixture.env }); + + expect(withPreparedModelRuntimeScope(first, lookup)).toBe("first-target"); + expect(withPreparedModelRuntimeScope(second, lookup)).toBe("second-target"); + expect(withPreparedModelRuntimeScope(first, lookup)).toBe("first-target"); + expect(materializations).not.toHaveBeenCalled(); + }); + + it("restores the parent owner and cleans up after an asynchronous failure", async () => { + const fixture = createFixture(); + const first = prepareOwner(fixture, "first", "PREPARED_OWNER_A_KEY").owner; + const second = prepareOwner(fixture, "second", "PREPARED_OWNER_B_KEY").owner; + const materializations = vi.spyOn(pluginMetadata, "loadPluginMetadataSnapshot"); + const failure = new Error("nested preparation failed"); + + await withPreparedModelRuntimeScope(first, async () => { + await expect( + withPreparedModelRuntimeScope(second, async () => { + await Promise.resolve(); + expect(requireActivePluginRegistry()).toBe(second.pluginRegistry); + expect(resolveEnvApiKey(PROVIDER_ID, fixture.env)?.apiKey).toBe("credential-b"); + throw failure; + }), + ).rejects.toBe(failure); + expect(requireActivePluginRegistry()).toBe(first.pluginRegistry); + expect(resolveEnvApiKey(PROVIDER_ID, fixture.env)?.apiKey).toBe("credential-a"); + }); + + expect(materializations).not.toHaveBeenCalled(); + for (const owner of [first, second]) { + expect( + getCurrentPluginMetadataSnapshot({ + config: fixture.config, + env: fixture.env, + workspaceDir: owner.workspaceDir, + }), + ).toBeUndefined(); + } + }); +}); diff --git a/src/agents/prepared-model-runtime.plugin-context.ts b/src/agents/prepared-model-runtime.plugin-context.ts index 12e2357379a..98bff345c81 100644 --- a/src/agents/prepared-model-runtime.plugin-context.ts +++ b/src/agents/prepared-model-runtime.plugin-context.ts @@ -1,13 +1,18 @@ +import { withPluginMetadataSnapshotScope } from "../plugins/current-plugin-metadata-snapshot.js"; import type { PluginDiscoveryResult } from "../plugins/discovery.js"; import { extractPluginInstallRecordsFromInstalledPluginIndex } from "../plugins/installed-plugin-index-install-records.js"; import { resolvePluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; import type { PluginRegistry } from "../plugins/registry-types.js"; +import { withPluginRuntimeRegistryScope } from "../plugins/runtime/gateway-request-scope.js"; import { resolvePluginRuntimeLoadContext, type PluginRuntimeLoadContext, } from "../plugins/runtime/load-context.js"; -import type { PreparedModelRuntimeInput } from "./prepared-model-runtime.types.js"; +import type { + PreparedModelRuntimeInput, + PreparedModelRuntimeSnapshot, +} from "./prepared-model-runtime.types.js"; const preparedPluginRuntimeLoadContext = Symbol("preparedPluginRuntimeLoadContext"); const emptyPluginDiscovery: PluginDiscoveryResult = { candidates: [], diagnostics: [] }; @@ -73,3 +78,24 @@ export const getPreparedPluginRuntimeLoadContext = ( registry: PluginRegistry | undefined, ): PluginRuntimeLoadContext | undefined => (registry as PreparedPluginRegistry | undefined)?.[preparedPluginRuntimeLoadContext]; + +/** Carries one leased owner's runtime and metadata through nested preparation. */ +export function withPreparedModelRuntimeScope( + snapshot: Pick< + PreparedModelRuntimeSnapshot, + "config" | "workspaceDir" | "metadataSnapshot" | "pluginRegistry" + >, + run: () => T, + options: { env?: NodeJS.ProcessEnv } = {}, +): T { + const env = options.env ?? getPreparedPluginRuntimeLoadContext(snapshot.pluginRegistry)?.env; + // Keep metadata request-local: publishing this workspace globally would let + // concurrent owners replace one another. Readers still enforce their scope/policy. + return withPluginRuntimeRegistryScope(snapshot.pluginRegistry, () => + withPluginMetadataSnapshotScope(snapshot.metadataSnapshot, run, { + config: snapshot.config, + workspaceDir: snapshot.workspaceDir, + env, + }), + ); +} diff --git a/src/agents/prepared-model-runtime.scoped-catalog.ts b/src/agents/prepared-model-runtime.scoped-catalog.ts index ea6569d65a9..48b7e19198e 100644 --- a/src/agents/prepared-model-runtime.scoped-catalog.ts +++ b/src/agents/prepared-model-runtime.scoped-catalog.ts @@ -1,6 +1,6 @@ import type { ModelCatalogSnapshot } from "./model-catalog.types.js"; +import { prepareAgentCatalogSource } from "./prepared-model-runtime.catalog-source.js"; import { - prepareAgentCatalogSource, prepareFullCatalogFacts, prepareWorkspaceBuildGroup, } from "./prepared-model-runtime.facts.js"; diff --git a/src/agents/provider-auth-aliases.ts b/src/agents/provider-auth-aliases.ts index a0b181eec29..5483065f9ec 100644 --- a/src/agents/provider-auth-aliases.ts +++ b/src/agents/provider-auth-aliases.ts @@ -38,8 +38,10 @@ const PROVIDER_AUTH_ALIAS_ORIGIN_PRIORITY: Readonly global: 2, workspace: 3, }; +type ProviderAuthAliasCacheOwner = NodeJS.ProcessEnv | Pick; + let providerAuthAliasMapCache = new WeakMap< - NodeJS.ProcessEnv, + ProviderAuthAliasCacheOwner, Map> >(); @@ -60,7 +62,10 @@ function buildProviderAuthAliasMapCacheKey( /** Clears auth aliases when their process-scoped plugin metadata is retired. */ function clearProviderAuthAliasMapCache(): void { - providerAuthAliasMapCache = new WeakMap>>(); + providerAuthAliasMapCache = new WeakMap< + ProviderAuthAliasCacheOwner, + Map> + >(); } // Reloads can replace plugin metadata without changing the config or env cache keys. @@ -132,23 +137,7 @@ export function resolveProviderAuthAliasMap( ): Record { const env = params?.env ?? process.env; const config = params?.config; - let cacheKey: string | undefined; - let envCache: Map> | undefined; - if (!params?.metadataSnapshot) { - // Plugin metadata is process-stable for a control-plane fingerprint, so - // cache per env object without hiding explicit test snapshots. - cacheKey = buildProviderAuthAliasMapCacheKey(params, env); - envCache = providerAuthAliasMapCache.get(env); - if (!envCache) { - envCache = new Map>(); - providerAuthAliasMapCache.set(env, envCache); - } - const cached = envCache.get(cacheKey); - if (cached) { - return cached; - } - } - const snapshot = + const currentSnapshot = params?.metadataSnapshot ?? (config ? getCurrentPluginMetadataSnapshot({ @@ -167,14 +156,33 @@ export function resolveProviderAuthAliasMap( if (!config || normalizePluginsConfig(config.plugins).loadPaths.length !== 0) { return undefined; } - const currentSnapshot = getCurrentPluginMetadataSnapshot({ + const defaultSnapshot = getCurrentPluginMetadataSnapshot({ ...(params?.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}), env, allowWorkspaceScopedSnapshot: true, requireDefaultDiscoveryContext: true, }); - return currentSnapshot; - })() ?? + return defaultSnapshot; + })(); + let cacheKey: string | undefined; + let envCache: Map> | undefined; + if (!params?.metadataSnapshot) { + // Old leased generations can outlive a reload. Scope fingerprints alone + // cannot separate their aliases from a concurrent replacement owner's aliases. + const cacheOwner = currentSnapshot ?? env; + cacheKey = buildProviderAuthAliasMapCacheKey(params, env); + envCache = providerAuthAliasMapCache.get(cacheOwner); + if (!envCache) { + envCache = new Map>(); + providerAuthAliasMapCache.set(cacheOwner, envCache); + } + const cached = envCache.get(cacheKey); + if (cached) { + return cached; + } + } + const snapshot = + currentSnapshot ?? loadPluginMetadataSnapshot({ config: config ?? {}, ...(params?.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}), diff --git a/src/agents/tools/media-tool-shared.test.ts b/src/agents/tools/media-tool-shared.test.ts index 61fb693a7b8..e159837f09a 100644 --- a/src/agents/tools/media-tool-shared.test.ts +++ b/src/agents/tools/media-tool-shared.test.ts @@ -1,5 +1,6 @@ // Shared media tool tests cover root separation, provider availability, and // model-registry normalization for generation/understanding tools. +import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { describe, expect, it, vi } from "vitest"; @@ -182,6 +183,33 @@ describe("resolveModelFromRegistry", () => { }); describe("hasGenerationToolAvailability", () => { + it("checks provider readiness in the selected project rather than the agent default", () => { + const workspaceDir = path.join(os.tmpdir(), "selected-media-project"); + const cfg = { agents: { defaults: { workspace: path.join(os.tmpdir(), "agent-default") } } }; + const provider = { + id: "workspace-image", + defaultModel: "workflow", + isConfigured: (context: { workspaceDir?: string }) => context.workspaceDir === workspaceDir, + }; + + expect( + hasGenerationToolAvailability({ + cfg, + workspaceDir, + providerKey: "imageGenerationProviders", + providers: [provider], + }), + ).toBe(true); + expect( + hasGenerationToolAvailability({ + cfg, + workspaceDir: cfg.agents.defaults.workspace, + providerKey: "imageGenerationProviders", + providers: [provider], + }), + ).toBe(false); + }); + it("accepts config-backed custom provider auth for generation providers", () => { const cfg = { models: { diff --git a/src/agents/tools/media-tool-shared.ts b/src/agents/tools/media-tool-shared.ts index f3c24046b30..fd65a02fedc 100644 --- a/src/agents/tools/media-tool-shared.ts +++ b/src/agents/tools/media-tool-shared.ts @@ -177,7 +177,11 @@ type CapabilityProvider = { aliases?: string[]; defaultModel?: string; models?: readonly string[]; - isConfigured?: (ctx: { cfg?: OpenClawConfig; agentDir?: string }) => boolean; + isConfigured?: (ctx: { + cfg?: OpenClawConfig; + agentDir?: string; + workspaceDir?: string; + }) => boolean; }; type CapabilityProviderSource = CapabilityProvider[] | (() => CapabilityProvider[]); @@ -235,6 +239,7 @@ export function isCapabilityProviderConfigured(par return provider.isConfigured({ cfg: params.cfg, agentDir: params.agentDir, + workspaceDir: params.workspaceDir, }); } return hasProviderAuthForTool({ diff --git a/src/agents/tools/model-config.helpers.ts b/src/agents/tools/model-config.helpers.ts index 275ad153749..e023341b097 100644 --- a/src/agents/tools/model-config.helpers.ts +++ b/src/agents/tools/model-config.helpers.ts @@ -87,6 +87,8 @@ export function hasAuthForProvider(params: { } return hasAuthProfileForProvider({ provider: params.provider, + cfg: params.cfg, + workspaceDir: params.workspaceDir, agentDir: params.agentDir, authStore: params.authStore, includeExternalCli: true, @@ -96,6 +98,8 @@ export function hasAuthForProvider(params: { /** Returns whether an auth profile exists for a provider, optionally filtered by type. */ export function hasAuthProfileForProvider(params: { provider: string; + cfg?: OpenClawConfig; + workspaceDir?: string; agentDir?: string; authStore?: AuthProfileStore; includeExternalCli?: boolean; @@ -114,7 +118,12 @@ export function hasAuthProfileForProvider(params: { // provider availability, not when checking stored profile shape. store = params.includeExternalCli ? ensureAuthProfileStore(agentDir, { - externalCli: externalCliDiscoveryForProviderAuth({ provider: params.provider }), + config: params.cfg, + workspaceDir: params.workspaceDir, + externalCli: externalCliDiscoveryForProviderAuth({ + provider: params.provider, + cfg: params.cfg, + }), }) : ensureAuthProfileStoreWithoutExternalProfiles(agentDir, { allowKeychainPrompt: false, @@ -148,6 +157,7 @@ export function hasProviderAuthForTool(params: { store: loadAuthStoreForProvider({ provider: params.provider, cfg: params.cfg, + workspaceDir: params.workspaceDir, agentDir: params.agentDir, authStore: params.authStore, }), @@ -172,6 +182,7 @@ function formatProviderModelRef(provider: string, model: string): string { function loadAuthStoreForProvider(params: { provider: string; cfg?: OpenClawConfig; + workspaceDir?: string; agentDir?: string; authStore?: AuthProfileStore; includeExternalCli?: boolean; @@ -185,6 +196,8 @@ function loadAuthStoreForProvider(params: { } return params.includeExternalCli ? ensureAuthProfileStore(agentDir, { + config: params.cfg, + workspaceDir: params.workspaceDir, externalCli: externalCliDiscoveryForProviderAuth({ provider: params.provider, cfg: params.cfg, @@ -226,6 +239,7 @@ function hasAuthProfileTypeInStore(params: { function hasAuthProfileTypeForProvider(params: { provider: string; cfg?: OpenClawConfig; + workspaceDir?: string; agentDir?: string; authStore?: AuthProfileStore; includeExternalCli?: boolean; @@ -273,6 +287,7 @@ function hasDirectProviderApiKeyAuthForTool(params: { store: loadAuthStoreForProvider({ provider: params.provider, cfg: params.cfg, + workspaceDir: params.workspaceDir, agentDir: params.agentDir, authStore: params.authStore, }), @@ -283,6 +298,7 @@ function hasDirectProviderApiKeyAuthForTool(params: { return hasAuthProfileTypeForProvider({ provider: params.provider, cfg: params.cfg, + workspaceDir: params.workspaceDir, agentDir: params.agentDir, authStore: params.authStore, type: "api_key", @@ -297,12 +313,14 @@ if (process.env.VITEST || process.env.NODE_ENV === "test") { function hasCanonicalOpenAiCodexAuthSignal(params: { cfg?: OpenClawConfig; + workspaceDir?: string; agentDir?: string; authStore?: AuthProfileStore; }): boolean { return hasAuthProfileTypeForProvider({ provider: OPENAI_PROVIDER_ID, cfg: params.cfg, + workspaceDir: params.workspaceDir, agentDir: params.agentDir, authStore: params.authStore, includeExternalCli: true, @@ -313,6 +331,7 @@ function hasCanonicalOpenAiCodexAuthSignal(params: { function resolveDirectProviderEntryAuthFromProfileReference(params: { provider: string; cfg?: OpenClawConfig; + workspaceDir?: string; agentDir?: string; authStore?: AuthProfileStore; }): boolean | undefined { @@ -337,6 +356,7 @@ function resolveDirectProviderEntryAuthFromProfileReference(params: { const store = loadAuthStoreForProvider({ provider: params.provider, cfg: params.cfg, + workspaceDir: params.workspaceDir, agentDir: params.agentDir, authStore: params.authStore, includeExternalCli: true, diff --git a/src/agents/tools/video-generate-tool.test.ts b/src/agents/tools/video-generate-tool.test.ts index 4c131059592..dfb3e7dbe1b 100644 --- a/src/agents/tools/video-generate-tool.test.ts +++ b/src/agents/tools/video-generate-tool.test.ts @@ -18,6 +18,8 @@ import * as videoGenerationRuntime from "../../video-generation/runtime.js"; import type { AuthProfileStore } from "../auth-profiles/types.js"; import { formatAgentInternalEventsForPrompt } from "../internal-events.js"; import { resetRecentMediaGenerationDuplicateGuardsForTests } from "../media-generation-task-status-shared.test-support.js"; +import type { PreparedModelRuntimeSnapshot } from "../prepared-model-runtime.js"; +import * as capabilityAvailability from "./manifest-capability-availability.js"; import { canonicalizeMediaGenerationTestConfig } from "./media-generation-config.test-support.js"; import * as videoGenerateBackground from "./video-generate-background.js"; import { createVideoGenerateTool as createVideoGenerateToolImpl } from "./video-generate-tool.js"; @@ -243,6 +245,26 @@ function createVideoProviderSnapshot(params: { }; } +function createPreparedVideoRuntime( + config: OpenClawConfig, + metadataSnapshot: PluginMetadataSnapshot, +): PreparedModelRuntimeSnapshot { + return { + agentDir: "/tmp/video-tool-agent", + workspaceDir: metadataSnapshot.workspaceDir, + activeProjectKeys: [], + config, + metadataSnapshot, + allowGatewaySubagentBinding: false, + modelCatalog: { entries: [], routeVariants: [] }, + configuredRuntimeModels: [], + inlineProviderModels: [], + createStores: () => { + throw new Error("schema construction must not create model stores"); + }, + }; +} + function mockVideoPluginProvider(capabilities: Record = {}) { vi.spyOn(videoGenerationRuntime, "listRuntimeVideoGenerationProviders").mockReturnValue([ { @@ -505,6 +527,89 @@ describe("createVideoGenerateTool", () => { expect(properties.audioRoles).toBeUndefined(); }); + it.each([false, true])( + "reuses prepared audio schema metadata (%s) with equivalent output", + (referenceAudioInputs) => { + const config: OpenClawConfig = { + agents: { defaults: { mediaModels: { video: { primary: "fixture/vid-v1" } } } }, + }; + const workspaceDir = "/workspace/video"; + const metadataSnapshot = createVideoProviderSnapshot({ + config, + id: "fixture", + origin: "bundled", + referenceAudioInputs, + workspaceDir, + }); + setCurrentPluginMetadataSnapshot(metadataSnapshot, { config, workspaceDir }); + const expected = toolParameterProperties(createVideoGenerateTool({ config, workspaceDir })); + setCurrentPluginMetadataSnapshot( + createVideoProviderSnapshot({ + config, + id: "fixture", + origin: "bundled", + referenceAudioInputs: !referenceAudioInputs, + workspaceDir, + }), + { config, workspaceDir }, + ); + const loadMetadata = vi.spyOn(capabilityAvailability, "loadCapabilityMetadataSnapshot"); + + const actual = toolParameterProperties( + createVideoGenerateTool({ + config, + workspaceDir, + preparedModelRuntime: createPreparedVideoRuntime(config, metadataSnapshot), + }), + ); + + expect(actual).toEqual(expected); + expect(Boolean(actual.audioRef)).toBe(referenceAudioInputs); + expect(loadMetadata).not.toHaveBeenCalled(); + }, + ); + + it.each(["workspace", "policy", "subset"] as const)( + "rejects prepared schema metadata with a different %s", + (mismatch) => { + const config: OpenClawConfig = { + agents: { defaults: { mediaModels: { video: { primary: "fixture/vid-v1" } } } }, + }; + const workspaceDir = "/workspace/video"; + setCurrentPluginMetadataSnapshot( + createVideoProviderSnapshot({ + config, + id: "fixture", + origin: "bundled", + referenceAudioInputs: false, + workspaceDir, + }), + { config, workspaceDir }, + ); + const metadataSnapshot = createVideoProviderSnapshot({ + config: mismatch === "policy" ? { plugins: { allow: ["other"] } } : config, + id: "fixture", + origin: "bundled", + referenceAudioInputs: true, + workspaceDir: mismatch === "workspace" ? "/workspace/other" : workspaceDir, + }); + if (mismatch === "subset") { + metadataSnapshot.pluginIds = ["fixture"]; + } + + const properties = toolParameterProperties( + createVideoGenerateTool({ + config, + workspaceDir, + preparedModelRuntime: createPreparedVideoRuntime(config, metadataSnapshot), + }), + ); + + expect(properties.audioRef).toBeUndefined(); + expect(properties.audioRefs).toBeUndefined(); + }, + ); + it("exposes reference-audio params for configured audio-capable model overrides", () => { vi.stubEnv("FAL_KEY", "test-fal-key"); diff --git a/src/agents/tools/video-generate-tool.ts b/src/agents/tools/video-generate-tool.ts index eab94ec0fe0..280c213bbba 100644 --- a/src/agents/tools/video-generate-tool.ts +++ b/src/agents/tools/video-generate-tool.ts @@ -14,6 +14,7 @@ import { saveMediaBuffer } from "../../media/store.js"; import { loadWebMedia } from "../../media/web-media.js"; import { readSnakeCaseParamRaw } from "../../param-key.js"; import { isManifestPluginAvailableForControlPlane } from "../../plugins/manifest-contract-eligibility.js"; +import { isPluginMetadataSnapshotCompatible } from "../../plugins/plugin-metadata-snapshot.js"; import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import { resolveUserPath } from "../../utils.js"; import type { DeliveryContext } from "../../utils/delivery-context.js"; @@ -305,11 +306,14 @@ function shouldExposeVideoReferenceAudioParams(params: { agentDir?: string; authStore?: AuthProfileStore; workspaceDir?: string; + metadataSnapshot?: PluginMetadataSnapshot; }): boolean { - const snapshot = loadCapabilityMetadataSnapshot({ - config: params.cfg, - workspaceDir: params.workspaceDir, - }); + const snapshot = + params.metadataSnapshot ?? + loadCapabilityMetadataSnapshot({ + config: params.cfg, + workspaceDir: params.workspaceDir, + }); const knownProviderIds = new Set(); const audioCandidateProviderIds = new Set(); const explicitProviderIds = collectVideoGenerationModelProviderIds({ @@ -930,11 +934,24 @@ export function createVideoGenerateTool(options?: { : null; const scheduleBackgroundWork = options?.scheduleBackgroundWork ?? defaultScheduleVideoGenerateBackgroundWork; + const preparedMetadata = options?.preparedModelRuntime?.metadataSnapshot; + const metadataSnapshot = + preparedMetadata && + preparedMetadata.pluginIds === undefined && + isPluginMetadataSnapshotCompatible({ + snapshot: preparedMetadata, + config: cfg, + env: process.env, + workspaceDir: options?.workspaceDir, + }) + ? preparedMetadata + : undefined; const includeAudioReferences = shouldExposeVideoReferenceAudioParams({ cfg, agentDir: options?.agentDir, authStore: options?.authProfileStore, workspaceDir: options?.workspaceDir, + metadataSnapshot, }); return { diff --git a/src/image-generation/types.ts b/src/image-generation/types.ts index dff49428ed5..25f42e760c8 100644 --- a/src/image-generation/types.ts +++ b/src/image-generation/types.ts @@ -59,6 +59,7 @@ export type ImageGenerationSourceImage = { export type ImageGenerationProviderConfiguredContext = { cfg?: OpenClawConfig; agentDir?: string; + workspaceDir?: string; }; /** Runtime request passed to an image-generation provider implementation. */ diff --git a/src/music-generation/types.ts b/src/music-generation/types.ts index 1d7b4847822..d429e70cc23 100644 --- a/src/music-generation/types.ts +++ b/src/music-generation/types.ts @@ -32,6 +32,7 @@ export type MusicGenerationSourceImage = { type MusicGenerationProviderConfiguredContext = { cfg?: OpenClawConfig; agentDir?: string; + workspaceDir?: string; }; /** Provider request after runtime fallback and override normalization. */ diff --git a/src/plugin-sdk/provider-auth.test.ts b/src/plugin-sdk/provider-auth.test.ts index b80c47d7889..a382c4ec088 100644 --- a/src/plugin-sdk/provider-auth.test.ts +++ b/src/plugin-sdk/provider-auth.test.ts @@ -641,20 +641,43 @@ describe("provider auth profile helpers", () => { updateAuthProfileStoreWithLock: vi.fn(), })); - const { isProviderAuthProfileConfigured } = await import("./provider-auth.js"); + const { isProviderAuthProfileConfigured, resolveProviderAuthProfileApiKey } = + await import("./provider-auth.js"); - expect(isProviderAuthProfileConfigured({ provider: "openai" })).toBe(false); + const cfg: OpenClawConfig = { plugins: { allow: ["openai"] } }; + const workspaceDir = "/tmp/selected-project"; + expect(isProviderAuthProfileConfigured({ provider: "openai", cfg, workspaceDir })).toBe(false); expect( isProviderAuthProfileConfigured({ provider: "openai", includeExternalCliAuth: true, + cfg, + workspaceDir, }), ).toBe(true); - expect(loadAuthProfileStoreForSecretsRuntime).toHaveBeenNthCalledWith(1, "/tmp/openclaw-agent"); + expect(loadAuthProfileStoreForSecretsRuntime).toHaveBeenNthCalledWith( + 1, + "/tmp/openclaw-agent", + { + config: cfg, + workspaceDir, + }, + ); expect(loadAuthProfileStoreForSecretsRuntime).toHaveBeenNthCalledWith( 2, "/tmp/openclaw-agent", - { externalCli }, + { externalCli, config: cfg, workspaceDir }, + ); + await expect( + resolveProviderAuthProfileApiKey({ provider: "openai", cfg, workspaceDir }), + ).resolves.toBeUndefined(); + expect(loadAuthProfileStoreForSecretsRuntime).toHaveBeenNthCalledWith( + 3, + "/tmp/openclaw-agent", + { + config: cfg, + workspaceDir, + }, ); }); diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index 60608bad4d0..035f9aab4c1 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -418,6 +418,8 @@ export function isProviderApiKeyConfigured(params: { cfg?: OpenClawConfig; /** Agent directory containing auth profiles. */ agentDir?: string; + /** Workspace that owns provider metadata for this readiness check. */ + workspaceDir?: string; /** Optional allowed profile credential types. */ profileTypes?: readonly AuthProfileCredential["type"][]; /** Optional provider-owned acceptance predicate for a known selected credential. */ @@ -433,7 +435,11 @@ export function isProviderApiKeyConfigured(params: { const providerConfig = resolveProviderConfig(params.cfg, params.provider); const authoredApiKey = providerConfig?.apiKey; const store = agentDir - ? ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false }) + ? ensureAuthProfileStore(agentDir, { + allowKeychainPrompt: false, + config: params.cfg, + workspaceDir: params.workspaceDir, + }) : undefined; let profile = typeof authoredApiKey === "string" ? store?.profiles[authoredApiKey.trim()] : undefined; @@ -461,8 +467,10 @@ export function isProviderApiKeyConfigured(params: { const selectedKey = providerConfig?.auth === "api-key" && authoredApiKey !== undefined ? configKey - : (resolveEnvApiKey(params.provider, process.env, { config: params.cfg })?.apiKey ?? - configKey); + : (resolveEnvApiKey(params.provider, process.env, { + config: params.cfg, + workspaceDir: params.workspaceDir, + })?.apiKey ?? configKey); return selectedKey === undefined || acceptsApiKey(selectedKey); } @@ -522,7 +530,12 @@ export function isProviderApiKeyConfigured(params: { return true; } } - if (resolveEnvApiKey(params.provider)?.apiKey) { + if ( + resolveEnvApiKey(params.provider, process.env, { + config: params.cfg, + workspaceDir: params.workspaceDir, + })?.apiKey + ) { return true; } if (!agentDir) { @@ -530,6 +543,8 @@ export function isProviderApiKeyConfigured(params: { } const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false, + config: params.cfg, + workspaceDir: params.workspaceDir, }); const profileIds = listProfilesForProvider(store, params.provider); if (!params.profileTypes?.length) { @@ -552,6 +567,8 @@ export function listUsableProviderAuthProfileIds(params: { cfg?: OpenClawConfig; /** Agent directory containing auth profiles. */ agentDir?: string; + /** Workspace that owns provider metadata for this lookup. */ + workspaceDir?: string; /** Optional allowed profile credential types. */ profileTypes?: readonly AuthProfileCredential["type"][]; /** Whether profile store reads may prompt for keychain-backed credentials. */ @@ -577,6 +594,8 @@ export function isProviderAuthProfileConfigured(params: { cfg?: OpenClawConfig; /** Agent directory containing auth profiles. */ agentDir?: string; + /** Workspace that owns provider metadata for this readiness check. */ + workspaceDir?: string; /** Optional allowed profile credential types. */ profileTypes?: readonly AuthProfileCredential["type"][]; /** Whether profile store reads may prompt for keychain-backed credentials. */ @@ -597,6 +616,8 @@ export async function resolveProviderAuthProfileApiKey(params: { cfg?: OpenClawConfig; /** Agent directory containing auth profiles. */ agentDir?: string; + /** Workspace that owns provider metadata for this lookup. */ + workspaceDir?: string; /** Optional allowed profile credential types. */ profileTypes?: readonly AuthProfileCredential["type"][]; /** Whether profile store reads may prompt for keychain-backed credentials. */ @@ -626,6 +647,7 @@ function resolveUsableProviderAuthProfiles(params: { provider: string; cfg?: OpenClawConfig; agentDir?: string; + workspaceDir?: string; allowKeychainPrompt?: boolean; includeExternalCliAuth?: boolean; }): { agentDir: string; profileIds: string[]; store: AuthProfileStore } { @@ -637,9 +659,11 @@ function resolveUsableProviderAuthProfiles(params: { allowKeychainPrompt: params.allowKeychainPrompt, }) : undefined; - const store = externalCli - ? loadAuthProfileStoreForSecretsRuntime(agentDir, { externalCli }) - : loadAuthProfileStoreForSecretsRuntime(agentDir); + const store = loadAuthProfileStoreForSecretsRuntime(agentDir, { + config: params.cfg, + workspaceDir: params.workspaceDir, + ...(externalCli ? { externalCli } : {}), + }); const profileIds = resolveAuthProfileOrder({ cfg: params.cfg, store, diff --git a/src/plugin-sdk/video-generation.ts b/src/plugin-sdk/video-generation.ts index 8dcae467ad4..580cddec39e 100644 --- a/src/plugin-sdk/video-generation.ts +++ b/src/plugin-sdk/video-generation.ts @@ -90,6 +90,7 @@ export type VideoGenerationSourceAsset = { export type VideoGenerationProviderConfiguredContext = { cfg?: OpenClawConfig; agentDir?: string; + workspaceDir?: string; }; /** Context passed when resolving model-specific video generation capabilities. */ diff --git a/src/plugins/manifest-model-suppression.test.ts b/src/plugins/manifest-model-suppression.test.ts index 982e8bfc40c..fe42e121236 100644 --- a/src/plugins/manifest-model-suppression.test.ts +++ b/src/plugins/manifest-model-suppression.test.ts @@ -1,5 +1,6 @@ // Verifies manifest-driven model suppression behavior. import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPluginMetadataSnapshot } from "../config/plugin-auto-enable.test-helpers.js"; const mocks = vi.hoisted(() => ({ loadPluginMetadataSnapshot: vi.fn(), @@ -24,6 +25,7 @@ function createMetadataSnapshot(plugins: Record[]) { describe("manifest model suppression", () => { beforeEach(() => { mocks.loadPluginMetadataSnapshot.mockReset(); + mocks.resolvePluginMetadataSnapshot.mockClear(); mocks.loadPluginMetadataSnapshot.mockReturnValue( createMetadataSnapshot([ { @@ -57,6 +59,55 @@ describe("manifest model suppression", () => { }); describe("buildManifestBuiltInModelSuppressionResolver", () => { + it("uses prepared metadata without discovery and still checks current policy and route", () => { + const metadataSnapshot = createPluginMetadataSnapshot({ + manifestRegistry: { + diagnostics: [], + plugins: [ + { + id: "fixture", + origin: "bundled", + rootDir: "/plugins/fixture", + source: "/plugins/fixture/index.js", + manifestPath: "/plugins/fixture/openclaw.plugin.json", + channels: [], + providers: ["fixture"], + cliBackends: [], + skills: [], + hooks: [], + modelCatalog: { + suppressions: [ + { + provider: "fixture", + model: "blocked", + when: { baseUrlHosts: ["fixture.test"] }, + }, + ], + }, + }, + ], + }, + }); + const prepared = buildManifestBuiltInModelSuppressionResolver({ metadataSnapshot }); + const disabled = buildManifestBuiltInModelSuppressionResolver({ + metadataSnapshot, + config: { plugins: { entries: { fixture: { enabled: false } } } }, + }); + + expect( + prepared({ provider: "fixture", id: "blocked", baseUrl: "https://fixture.test/v1" }) + ?.suppress, + ).toBe(true); + expect( + prepared({ provider: "fixture", id: "blocked", baseUrl: "https://custom.test/v1" }), + ).toBeUndefined(); + expect( + disabled({ provider: "fixture", id: "blocked", baseUrl: "https://fixture.test/v1" }), + ).toBeUndefined(); + expect(mocks.resolvePluginMetadataSnapshot).not.toHaveBeenCalled(); + expect(mocks.loadPluginMetadataSnapshot).not.toHaveBeenCalled(); + }); + it("reads planned manifest suppressions once per resolver creation", () => { const config = { plugins: { entries: { openai: { enabled: true } } } }; diff --git a/src/plugins/manifest-model-suppression.ts b/src/plugins/manifest-model-suppression.ts index 5cf79a0c438..a86abd7b2bb 100644 --- a/src/plugins/manifest-model-suppression.ts +++ b/src/plugins/manifest-model-suppression.ts @@ -10,17 +10,21 @@ import { isManifestPluginAvailableForControlPlane, loadManifestMetadataSnapshot, } from "./manifest-contract-eligibility.js"; +import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js"; function listManifestModelCatalogSuppressions(params: { config?: OpenClawConfig; workspaceDir?: string; env: NodeJS.ProcessEnv; + metadataSnapshot?: PluginMetadataSnapshot; }): readonly ManifestModelCatalogSuppressionEntry[] { - const snapshot = loadManifestMetadataSnapshot({ - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - }); + const snapshot = + params.metadataSnapshot ?? + loadManifestMetadataSnapshot({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); const registry = { diagnostics: snapshot.diagnostics, plugins: snapshot.plugins.filter((plugin) => @@ -123,11 +127,13 @@ export function buildManifestBuiltInModelSuppressionResolver(params: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; + metadataSnapshot?: PluginMetadataSnapshot; }) { const suppressions = listManifestModelCatalogSuppressions({ config: params.config, workspaceDir: params.workspaceDir, env: params.env ?? process.env, + metadataSnapshot: params.metadataSnapshot, }); return (input: { diff --git a/src/plugins/setup-registry.scope.test.ts b/src/plugins/setup-registry.scope.test.ts new file mode 100644 index 00000000000..8e087d088a4 --- /dev/null +++ b/src/plugins/setup-registry.scope.test.ts @@ -0,0 +1,185 @@ +// Verifies setup caches against real manifests and overlapping metadata owners. +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { createDeferred } from "../test-utils/deferred.js"; +import { + setCurrentPluginMetadataSnapshot, + withPluginMetadataSnapshotScope, +} from "./current-plugin-metadata-snapshot.js"; +import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js"; +import { loadPluginMetadataSnapshot } from "./plugin-metadata-snapshot.js"; +import { resolvePluginSetupProvider, resolvePluginSetupRegistry } from "./setup-registry.js"; +import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js"; + +const PLUGIN_ID = "setup-scope-owner"; +const PROVIDER_IDS = ["old-provider", "new-provider"] as const; +const tempRoots: string[] = []; + +function createFixture() { + const root = fs.realpathSync(makeTrackedTempDir("setup-registry-scope", tempRoots)); + const directories = { + HOME: path.join(root, "home"), + USERPROFILE: path.join(root, "home"), + OPENCLAW_STATE_DIR: path.join(root, "state"), + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(root, "bundled"), + }; + for (const [key, directory] of Object.entries(directories)) { + fs.mkdirSync(directory, { recursive: true }); + vi.stubEnv(key, directory); + } + vi.stubEnv("OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR", "1"); + const config: OpenClawConfig = { plugins: { allow: [PLUGIN_ID] } }; + return { root, config }; +} + +function prepareOwner( + fixture: ReturnType, + workspaceName: string, + providerId: (typeof PROVIDER_IDS)[number], +) { + const workspaceDir = path.join(fixture.root, workspaceName); + const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", PLUGIN_ID); + fs.mkdirSync(pluginRoot, { recursive: true }); + fs.writeFileSync( + path.join(pluginRoot, "package.json"), + JSON.stringify({ + name: `@fixture/${PLUGIN_ID}`, + version: "1.0.0", + type: "module", + openclaw: { extensions: ["./index.js"] }, + }), + ); + fs.writeFileSync(path.join(pluginRoot, "index.js"), "export default {};\n"); + // Keep executable code identical: the owner change is the manifest descriptor. + fs.writeFileSync( + path.join(pluginRoot, "setup-api.js"), + `export default { + id: ${JSON.stringify(PLUGIN_ID)}, + register(api) { + for (const id of ${JSON.stringify(PROVIDER_IDS)}) { + api.registerProvider({ id, label: id, auth: [] }); + } + } + };\n`, + ); + fs.writeFileSync( + path.join(pluginRoot, "openclaw.plugin.json"), + JSON.stringify({ + id: PLUGIN_ID, + providers: [providerId], + setup: { providers: [{ id: providerId }], requiresRuntime: true }, + configSchema: { type: "object", additionalProperties: false, properties: {} }, + }), + ); + const snapshot = loadPluginMetadataSnapshot({ + config: fixture.config, + workspaceDir, + env: process.env, + preferPersisted: false, + }); + expect(snapshot.plugins.map((plugin) => plugin.id)).toEqual([PLUGIN_ID]); + return { config: fixture.config, workspaceDir, snapshot, providerId }; +} + +function withOwner(owner: ReturnType, run: () => T): T { + return withPluginMetadataSnapshotScope(owner.snapshot, run, { + config: owner.config, + workspaceDir: owner.workspaceDir, + env: process.env, + }); +} + +function expectOwnerSetup(owner: ReturnType) { + // process.env is intentional: custom env objects disable both setup caches. + const params = { + config: owner.config, + workspaceDir: owner.workspaceDir, + env: process.env, + pluginIds: [PLUGIN_ID], + }; + const otherProviderId = owner.providerId === "old-provider" ? "new-provider" : "old-provider"; + expect(resolvePluginSetupProvider({ ...params, provider: owner.providerId })?.id).toBe( + owner.providerId, + ); + expect(resolvePluginSetupProvider({ ...params, provider: otherProviderId })).toBeUndefined(); + // Exercise the full setup registry result cache as well as its manifest cache. + expect( + resolvePluginSetupRegistry(params).diagnostics.map(({ code, runtimeId }) => ({ + code, + runtimeId, + })), + ).toEqual([ + { + code: "setup-descriptor-provider-runtime-undeclared", + runtimeId: otherProviderId, + }, + ]); +} + +describe("setup registry metadata owner scopes", () => { + beforeEach(() => { + clearPluginMetadataLifecycleCaches(); + }); + + afterEach(() => { + clearPluginMetadataLifecycleCaches(); + vi.unstubAllEnvs(); + cleanupTrackedTempDirs(tempRoots); + }); + + it.each(["old-first", "new-first"] as const)( + "isolates overlapping generations at the same workspace (%s)", + async (order) => { + const fixture = createFixture(); + const oldOwner = prepareOwner(fixture, "workspace", "old-provider"); + const resumeOld = createDeferred(); + const oldRun = withOwner(oldOwner, async () => { + await resumeOld.promise; + expectOwnerSetup(oldOwner); + }); + + clearPluginMetadataLifecycleCaches(); + const newOwner = prepareOwner(fixture, "workspace", "new-provider"); + setCurrentPluginMetadataSnapshot(newOwner.snapshot, { + config: newOwner.config, + workspaceDir: newOwner.workspaceDir, + env: process.env, + }); + try { + if (order === "new-first") { + withOwner(newOwner, () => expectOwnerSetup(newOwner)); + } + } finally { + resumeOld.resolve(); + await oldRun; + } + withOwner(newOwner, () => expectOwnerSetup(newOwner)); + }, + ); + + it("keeps explicit workspace selection and omitted-workspace discovery separate", () => { + const fixture = createFixture(); + const ownerA = prepareOwner(fixture, "workspace-a", "old-provider"); + const ownerB = prepareOwner(fixture, "workspace-b", "new-provider"); + setCurrentPluginMetadataSnapshot(ownerB.snapshot, { + config: ownerB.config, + workspaceDir: ownerB.workspaceDir, + env: process.env, + }); + + withOwner(ownerA, () => { + expectOwnerSetup(ownerB); + expectOwnerSetup(ownerA); + expect( + resolvePluginSetupProvider({ + provider: ownerA.providerId, + config: fixture.config, + env: process.env, + }), + ).toBeUndefined(); + expectOwnerSetup(ownerA); + }); + }); +}); diff --git a/src/plugins/setup-registry.ts b/src/plugins/setup-registry.ts index 9e8749105ae..77020808ee2 100644 --- a/src/plugins/setup-registry.ts +++ b/src/plugins/setup-registry.ts @@ -10,7 +10,7 @@ import { import type { OpenClawConfig } from "../config/types.openclaw.js"; import { buildPluginApi } from "./api-builder.js"; import { collectPluginConfigContractMatches } from "./config-contracts.js"; -import { getCurrentPluginMetadataSnapshotState } from "./current-plugin-metadata-state.js"; +import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js"; import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js"; import { createPluginCacheKey, PluginLruCache } from "./plugin-cache-primitives.js"; import { resolvePluginControlPlaneFingerprint } from "./plugin-control-plane-context.js"; @@ -361,15 +361,25 @@ function resolveSetupRegistryCacheKey(params?: { workspaceDir: params?.workspaceDir, }), resolvePluginMetadataEnvFingerprint(env), - resolveCurrentSetupSnapshotCacheId(), + resolveCurrentSetupSnapshotCacheId(params), process.cwd(), params?.pluginIds ? [...params.pluginIds].toSorted() : null, ]); } -function resolveCurrentSetupSnapshotCacheId(): string { - const { snapshot } = getCurrentPluginMetadataSnapshotState(); - if (!snapshot || typeof snapshot !== "object") { +function resolveCurrentSetupSnapshotCacheId(params?: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): string { + // Match the manifest loader's exact discovery scope. Concurrent leases can + // retain different metadata generations while the Gateway-global owner changes. + const snapshot = getCurrentPluginMetadataSnapshot({ + config: params?.config, + env: params?.env ?? process.env, + ...(params?.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + }); + if (!snapshot) { return "nosnap"; } let id = setupRegistrySnapshotIds.get(snapshot); diff --git a/src/video-generation/types.ts b/src/video-generation/types.ts index 7845b429409..20844965938 100644 --- a/src/video-generation/types.ts +++ b/src/video-generation/types.ts @@ -56,6 +56,7 @@ export type VideoGenerationSourceAsset = { export type VideoGenerationProviderConfiguredContext = { cfg?: OpenClawConfig; agentDir?: string; + workspaceDir?: string; }; export type VideoGenerationRequest = {