diff --git a/src/agents/agent-command.compaction-rotation.test.ts b/src/agents/agent-command.compaction-rotation.test.ts index f8b029c8ad48..88c2559a0f83 100644 --- a/src/agents/agent-command.compaction-rotation.test.ts +++ b/src/agents/agent-command.compaction-rotation.test.ts @@ -60,6 +60,11 @@ vi.mock("./agent-runtime-config.js", () => ({ }), })); +vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({ + isPluginMetadataSnapshotCompatible: () => false, + resolvePluginMetadataSnapshot: () => ({ plugins: [] }), +})); + vi.mock("./agent-scope.js", async () => { const actual = await vi.importActual("./agent-scope.js"); return { @@ -79,10 +84,6 @@ vi.mock("./agent-scope.js", async () => { }; }); -vi.mock("../plugins/manifest-contract-eligibility.js", () => ({ - loadManifestMetadataSnapshot: () => ({ plugins: [] }), -})); - vi.mock("./model-catalog.js", () => ({ loadManifestModelCatalog: (params: LoadManifestModelCatalogParams) => state.loadManifestModelCatalogMock(params), diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index c6e4db658460..4f07c3ea53ee 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -282,13 +282,13 @@ vi.mock("./agent-runtime-config.js", () => { }; }); -vi.mock("../config/runtime-snapshot.js", () => ({ - setRuntimeConfigSnapshot: vi.fn(), +vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({ + isPluginMetadataSnapshotCompatible: () => false, + resolvePluginMetadataSnapshot: () => ({ plugins: [] }), })); -// Model selection is mocked below, so plugin discovery cannot affect these assertions. -vi.mock("../plugins/manifest-contract-eligibility.js", () => ({ - loadManifestMetadataSnapshot: () => ({ plugins: [] }), +vi.mock("../config/runtime-snapshot.js", () => ({ + setRuntimeConfigSnapshot: vi.fn(), })); vi.mock("../config/sessions.js", () => ({ diff --git a/src/agents/agent-runtime-config.ts b/src/agents/agent-runtime-config.ts index 07249a4c227b..9901bc08b47e 100644 --- a/src/agents/agent-runtime-config.ts +++ b/src/agents/agent-runtime-config.ts @@ -7,6 +7,8 @@ import { getRuntimeConfig, readConfigFileSnapshotForWrite } from "../config/io.j import { setRuntimeConfigSnapshot } from "../config/runtime-snapshot.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isSecretRef } from "../config/types.secrets.js"; +import { resolvePluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; import type { RuntimeEnv } from "../runtime.js"; import { discoverConfigSecretTargetsByIds } from "../secrets/target-registry.js"; import { listAgentEntries } from "./agent-scope.js"; @@ -22,6 +24,7 @@ export async function resolveAgentRuntimeConfig( loadedRaw: OpenClawConfig; sourceConfig: OpenClawConfig; cfg: OpenClawConfig; + pluginMetadataSnapshot?: PluginMetadataSnapshot; }> { const loadedRaw = getRuntimeConfig(); const includeChannelTargets = params?.runtimeTargetsChannelSecrets === true; @@ -31,15 +34,18 @@ export async function resolveAgentRuntimeConfig( includeChannelTargets, channel: channelSecretScope?.channel, }); + let pluginMetadataSnapshot: PluginMetadataSnapshot | undefined; const sourceConfig = await (async () => { try { - const { snapshot } = await readConfigFileSnapshotForWrite(); + const { snapshot, writeOptions } = await readConfigFileSnapshotForWrite(); if (snapshot.valid) { + pluginMetadataSnapshot = writeOptions.basePluginMetadataSnapshot; return snapshot.resolved; } } catch { // Fall back to runtime-loaded config when source snapshot is unavailable. } + pluginMetadataSnapshot = resolvePluginMetadataSnapshot({ config: loadedRaw }); return loadedRaw; })(); const cfg = hasRuntimeSecretRefs @@ -77,7 +83,12 @@ export async function resolveAgentRuntimeConfig( }); secretsRuntime.activateSecretsRuntimeSnapshot(snapshot); } - return { loadedRaw, sourceConfig, cfg }; + return { + loadedRaw, + sourceConfig, + cfg, + ...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}), + }; } function hasNestedSecretRef(value: unknown): boolean { diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index 83faba551233..ed7f2aaf7660 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -1131,6 +1131,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions) auditLogLevel: options?.toolPolicyAuditLogLevel, declaredToolAllowlist: buildDeclaredToolAllowlistContext({ config: options?.config, + metadataSnapshot: options?.preparedModelRuntime?.metadataSnapshot, workspaceDir: workspaceRoot, toolDenylist: pluginToolDenylist, }), diff --git a/src/agents/command/prepare.ts b/src/agents/command/prepare.ts index 05f01631f95c..d27bbbccb3e5 100644 --- a/src/agents/command/prepare.ts +++ b/src/agents/command/prepare.ts @@ -10,7 +10,10 @@ import { resolveAgentExplicitRecipientSession } from "../../infra/outbound/agent import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; import { parseStrictNonNegativeInteger } from "../../infra/parse-finite-number.js"; import { normalizePluginsConfig } from "../../plugins/config-state.js"; -import { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js"; +import { + isPluginMetadataSnapshotCompatible, + resolvePluginMetadataSnapshot, +} from "../../plugins/plugin-metadata-snapshot.js"; import { classifySessionKeyShape, isUnscopedSessionKeySentinel, @@ -133,7 +136,7 @@ export async function prepareAgentCommandExecution(opts: AgentCommandOpts, runti ); } - const { cfg } = await resolveAgentRuntimeConfig(runtime, { + const { cfg, pluginMetadataSnapshot } = await resolveAgentRuntimeConfig(runtime, { runtimeTargetsChannelSecrets: opts.deliver === true, runtimeChannelSecretScope: opts.deliver !== true && shouldResolveExplicitRecipientSession && recipientChannel @@ -291,7 +294,16 @@ export async function prepareAgentCommandExecution(opts: AgentCommandOpts, runti const agentDir = resolveAgentDir(cfg, sessionAgentId); const pluginsEnabled = normalizePluginsConfig(cfg.plugins).enabled; const manifestMetadataSnapshot = pluginsEnabled - ? loadManifestMetadataSnapshot({ config: cfg, workspaceDir, env: process.env }) + ? pluginMetadataSnapshot && + pluginMetadataSnapshot.pluginIds === undefined && + isPluginMetadataSnapshotCompatible({ + snapshot: pluginMetadataSnapshot, + config: cfg, + env: process.env, + workspaceDir, + }) + ? pluginMetadataSnapshot + : resolvePluginMetadataSnapshot({ config: cfg, env: process.env, workspaceDir }) : undefined; const modelManifestContext = { manifestPlugins: manifestMetadataSnapshot?.plugins ?? [], diff --git a/src/agents/embedded-agent-runner/compact.hooks.harness.ts b/src/agents/embedded-agent-runner/compact.hooks.harness.ts index a4472003a625..5924cd3973be 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.harness.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.harness.ts @@ -743,6 +743,10 @@ export async function loadCompactHooksHarness(): Promise<{ agentDir: input.agentDir, config: input.config, workspaceDir: input.workspaceDir, + metadataSnapshot: { + ...emptyPluginMetadataSnapshot, + workspaceDir: input.workspaceDir as string | undefined, + }, createStores: () => ({ authStorage: {}, modelRegistry: {} }), }, release: vi.fn(), diff --git a/src/agents/embedded-agent-runner/effective-tool-policy.ts b/src/agents/embedded-agent-runner/effective-tool-policy.ts index f0e04369cfca..774ee1d3a983 100644 --- a/src/agents/embedded-agent-runner/effective-tool-policy.ts +++ b/src/agents/embedded-agent-runner/effective-tool-policy.ts @@ -2,6 +2,7 @@ * Applies final effective tool policy to embedded-agent runtime settings. */ import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import { getPluginToolMeta } from "../../plugins/tools.js"; import type { ResolvedConversationCapabilityProfile } from "../conversation-capability-profile.js"; import { buildDeclaredToolAllowlistContext } from "../tool-policy-declared-context.js"; @@ -30,6 +31,8 @@ type FinalEffectiveToolPolicyParams = { // metadata no longer survives core-tool wrapping/normalization. bundledTools: AnyAgentTool[]; config?: OpenClawConfig; + workspaceDir?: string; + metadataSnapshot?: PluginMetadataSnapshot; conversationCapabilityProfile: ResolvedConversationCapabilityProfile; warn: (message: string) => void; toolPolicyAuditLogLevel?: "info" | "debug"; @@ -113,6 +116,8 @@ export function applyFinalEffectiveToolPolicy( onFilter: params.onFilter, declaredToolAllowlist: buildDeclaredToolAllowlistContext({ config: params.config, + workspaceDir: params.workspaceDir, + metadataSnapshot: params.metadataSnapshot, toolDenylist: collectExplicitDenylist(pipelineSteps.map((step) => step.policy)), }), }); diff --git a/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts b/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts index 9f588ed3626c..56ae03b3eb35 100644 --- a/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts +++ b/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts @@ -64,7 +64,7 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { beforeAll(async () => { ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); await warmRunOverflowCompactionHarness(runEmbeddedAgent); - }); + }, 300_000); beforeEach(() => { resetRunOverflowCompactionHarnessMocks(); diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts index 0d1b8a0304be..0244935db864 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts @@ -10,6 +10,7 @@ import type { PluginHookBeforeAgentFinalizeEvent, PluginHookBeforeAgentFinalizeResult, } from "../../plugins/hook-types.js"; +import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import type { PluginHookAgentContext, PluginHookBeforeAgentReplyResult, @@ -65,6 +66,45 @@ type MockResolvedModel = { reasoning?: boolean; }; +const emptyPluginMetadataSnapshot: PluginMetadataSnapshot = { + policyHash: "", + index: { + version: 1, + hostContractVersion: "test", + compatRegistryVersion: "test", + migrationVersion: 1, + policyHash: "", + generatedAtMs: 1, + installRecords: {}, + plugins: [], + diagnostics: [], + }, + registryDiagnostics: [], + 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, + }, +}; + type MockAgentDiscoveryStores = { authStorage: { setRuntimeApiKey: ReturnType; @@ -887,6 +927,10 @@ export async function loadRunOverflowCompactionHarness(): Promise<{ agentDir: input.agentDir, config: input.config, workspaceDir: input.workspaceDir, + metadataSnapshot: { + ...emptyPluginMetadataSnapshot, + workspaceDir: input.workspaceDir as string | undefined, + }, createStores: () => ({ authStorage: {}, modelRegistry: {} }), }, release: vi.fn(), diff --git a/src/agents/embedded-agent-runner/run/attempt-bundle-tools.test.ts b/src/agents/embedded-agent-runner/run/attempt-bundle-tools.test.ts index 131888673dc5..dced1df5cbfd 100644 --- a/src/agents/embedded-agent-runner/run/attempt-bundle-tools.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-bundle-tools.test.ts @@ -80,6 +80,9 @@ describe("prepareEmbeddedAttemptBundleTools", () => { } as unknown as Parameters[0]; await expect(prepareEmbeddedAttemptBundleTools(input)).rejects.toThrow("bundle policy failed"); + expect(mocks.applyFinalEffectiveToolPolicy).toHaveBeenCalledWith( + expect.objectContaining({ workspaceDir: "/tmp/workspace" }), + ); expect(disposeMcp).toHaveBeenCalledOnce(); expect(disposeLsp).toHaveBeenCalledOnce(); }); diff --git a/src/agents/embedded-agent-runner/run/attempt-bundle-tools.ts b/src/agents/embedded-agent-runner/run/attempt-bundle-tools.ts index 97eaa9f9adc1..f45ab9404be9 100644 --- a/src/agents/embedded-agent-runner/run/attempt-bundle-tools.ts +++ b/src/agents/embedded-agent-runner/run/attempt-bundle-tools.ts @@ -138,6 +138,8 @@ export async function prepareEmbeddedAttemptBundleTools(params: { const filteredBundledTools = applyFinalEffectiveToolPolicy({ bundledTools: [...allowedBundleMcpTools, ...allowedBundleLspTools], config: params.attempt.config, + workspaceDir: params.effectiveWorkspace, + metadataSnapshot: bundleMetadataSnapshot, conversationCapabilityProfile: runtimeCapabilityProfile, warn: (message) => log.warn(message), }); @@ -150,6 +152,8 @@ export async function prepareEmbeddedAttemptBundleTools(params: { const allowedAppTools = applyFinalEffectiveToolPolicy({ bundledTools: runtimeAllowedAppTools, config: params.attempt.config, + workspaceDir: params.effectiveWorkspace, + metadataSnapshot: bundleMetadataSnapshot, conversationCapabilityProfile: runtimeCapabilityProfile, warn: (message) => log.warn(message), }); diff --git a/src/agents/embedded-agent-runner/run/attempt-dispatch-preparation.ts b/src/agents/embedded-agent-runner/run/attempt-dispatch-preparation.ts index 44bee8fbd32e..acc0254469f4 100644 --- a/src/agents/embedded-agent-runner/run/attempt-dispatch-preparation.ts +++ b/src/agents/embedded-agent-runner/run/attempt-dispatch-preparation.ts @@ -127,6 +127,8 @@ export async function prepareAndDispatchEmbeddedRunAttempt(input: { harnessId: runtime.agentHarness.id, harnessRuntime: runtime.agentHarness.id, preparedAuthPlan: runtime.activePreparedAuthPlan, + metadataSnapshot: runtime.pluginMetadataSnapshot, + providerRuntimeHandle: runtime.providerRuntimeHandle, config: params.config, workspaceDir, agentDir, diff --git a/src/agents/embedded-agent-runner/run/attempt-setup.test.ts b/src/agents/embedded-agent-runner/run/attempt-setup.test.ts new file mode 100644 index 000000000000..5cd5c5772da8 --- /dev/null +++ b/src/agents/embedded-agent-runner/run/attempt-setup.test.ts @@ -0,0 +1,79 @@ +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ProviderRuntimePluginHandle } from "../../../plugins/provider-hook-runtime.js"; +import type { EmbeddedRunAttemptParams } from "./types.js"; + +const resolveProviderRuntimePluginHandle = vi.hoisted(() => vi.fn()); + +vi.mock("../../../plugins/provider-hook-runtime.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolveProviderRuntimePluginHandle, +})); + +import { prepareEmbeddedAttemptSetup } from "./attempt-setup.js"; + +describe("prepareEmbeddedAttemptSetup", () => { + beforeEach(() => { + resolveProviderRuntimePluginHandle.mockReset(); + }); + + it("reuses lifecycle metadata and the provider handle from the runtime plan", async () => { + const metadataSnapshot = { plugins: [] } as never; + const workspaceDir = path.join(os.tmpdir(), "openclaw-attempt-setup-prepared"); + const providerRuntimeHandle: ProviderRuntimePluginHandle & { prepared: true } = { + provider: "openai", + modelId: "gpt-5.4", + prepared: true, + workspaceDir, + plugin: {} as never, + }; + const setup = await prepareEmbeddedAttemptSetup({ + config: {}, + modelId: "gpt-5.4", + provider: "openai", + runId: "run-prepared", + sessionId: "session-prepared", + thinkLevel: "high", + timeoutMs: 30_000, + workspaceDir, + preparedModelRuntime: { metadataSnapshot } as never, + runtimePlan: { providerRuntimeHandle } as never, + } as unknown as EmbeddedRunAttemptParams); + + expect(setup.getCurrentAttemptPluginMetadataSnapshot()).toBe(metadataSnapshot); + expect(setup.getProviderRuntimeHandle()).toBe(providerRuntimeHandle); + expect(resolveProviderRuntimePluginHandle).not.toHaveBeenCalled(); + }); + + it("resolves partial handles without trusting scoped metadata", async () => { + const resolvedHandle: ProviderRuntimePluginHandle = { + provider: "openai", + modelId: "gpt-5.4", + }; + resolveProviderRuntimePluginHandle.mockReturnValue(resolvedHandle); + const setup = await prepareEmbeddedAttemptSetup({ + config: {}, + modelId: "gpt-5.4", + provider: "openai", + runId: "run-partial", + sessionId: "session-partial", + thinkLevel: "high", + timeoutMs: 30_000, + workspaceDir: path.join(os.tmpdir(), "openclaw-attempt-setup-partial"), + preparedModelRuntime: { + metadataSnapshot: { pluginIds: ["other"] }, + } as never, + runtimePlan: { providerRuntimeHandle: { provider: "openai" } } as never, + } as unknown as EmbeddedRunAttemptParams); + + const preparedHandle = setup.getProviderRuntimeHandle(); + expect(preparedHandle).toMatchObject(resolvedHandle); + expect(preparedHandle.modelId).toBe("gpt-5.4"); + expect(setup.getProviderRuntimeHandle()).toBe(preparedHandle); + expect(resolveProviderRuntimePluginHandle).toHaveBeenCalledOnce(); + const call = resolveProviderRuntimePluginHandle.mock.calls[0]?.[0]; + expect(call).toMatchObject({ provider: "openai", modelId: "gpt-5.4" }); + expect(call).not.toHaveProperty("pluginMetadataSnapshot"); + }); +}); diff --git a/src/agents/embedded-agent-runner/run/attempt-setup.ts b/src/agents/embedded-agent-runner/run/attempt-setup.ts index d1de78a5720b..1fc56353d21e 100644 --- a/src/agents/embedded-agent-runner/run/attempt-setup.ts +++ b/src/agents/embedded-agent-runner/run/attempt-setup.ts @@ -2,8 +2,7 @@ * Resolves workspace, sandbox, provider runtime, and phase reporting for an embedded attempt. */ import fs from "node:fs/promises"; -import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; -import { getCurrentPluginMetadataSnapshot } from "../../../plugins/current-plugin-metadata-snapshot.js"; +import { isPluginMetadataSnapshotCompatible } from "../../../plugins/plugin-metadata-snapshot.js"; import type { PluginMetadataSnapshot } from "../../../plugins/plugin-metadata-snapshot.types.js"; import { resolveProviderRuntimePluginHandle, @@ -24,30 +23,10 @@ import { import { resolveAttemptFsWorkspaceOnly } from "./attempt.prompt-helpers.js"; import type { EmbeddedRunAttemptParams } from "./types.js"; -function pluginMetadataSnapshotCoversProvider( - snapshot: PluginMetadataSnapshot | undefined, - provider: string, -): snapshot is PluginMetadataSnapshot { - const normalizedProvider = normalizeProviderId(provider); - if (!snapshot || !normalizedProvider) { - return false; - } - return snapshot.manifestRegistry.plugins.some((plugin) => { - const ownsProvider = plugin.providers.some( - (providerId) => normalizeProviderId(providerId) === normalizedProvider, - ); - if (ownsProvider) { - return true; - } - const modelCatalogProviderIds = [ - ...Object.keys(plugin.modelCatalog?.providers ?? {}), - ...Object.keys(plugin.modelCatalog?.aliases ?? {}), - ]; - return modelCatalogProviderIds.some( - (providerId) => normalizeProviderId(providerId) === normalizedProvider, - ); - }); -} +type PreparedProviderRuntimePluginHandle = ProviderRuntimePluginHandle & { + modelId: string; + prepared: true; +}; export async function prepareEmbeddedAttemptSetup(params: EmbeddedRunAttemptParams) { const resolvedWorkspace = resolveUserPath(params.workspaceDir); @@ -117,40 +96,50 @@ export async function prepareEmbeddedAttemptSetup(params: EmbeddedRunAttemptPara const effectiveCwd = sandbox?.enabled ? effectiveWorkspace : (requestedCwd ?? effectiveWorkspace); await fs.mkdir(effectiveWorkspace, { recursive: true }); - let currentPluginMetadataSnapshotResolved = false; - let currentPluginMetadataSnapshot: PluginMetadataSnapshot | undefined; - const getCurrentAttemptPluginMetadataSnapshot = () => { - if (!currentPluginMetadataSnapshotResolved) { - currentPluginMetadataSnapshot = getCurrentPluginMetadataSnapshot({ - allowScopedSnapshot: true, - config: params.config, - env: process.env, - workspaceDir: effectiveWorkspace, - }); - currentPluginMetadataSnapshotResolved = true; - } - return currentPluginMetadataSnapshot; - }; - let providerRuntimeHandle: ProviderRuntimePluginHandle | undefined; - const getProviderRuntimeHandle = () => { - if (providerRuntimeHandle?.plugin) { + const getCurrentAttemptPluginMetadataSnapshot = (): PluginMetadataSnapshot | undefined => + params.preparedModelRuntime?.metadataSnapshot; + let providerRuntimeHandle = params.runtimePlan?.providerRuntimeHandle as + | PreparedProviderRuntimePluginHandle + | undefined; + const getProviderRuntimeHandle = (): PreparedProviderRuntimePluginHandle => { + if ( + providerRuntimeHandle && + providerRuntimeHandle.prepared && + providerRuntimeHandle.provider === params.provider && + providerRuntimeHandle.modelId === params.modelId && + providerRuntimeHandle.workspaceDir === effectiveWorkspace + ) { return providerRuntimeHandle; } const pluginMetadataSnapshot = getCurrentAttemptPluginMetadataSnapshot(); - const resolvedHandle = resolveProviderRuntimePluginHandle({ + const compatibleMetadataSnapshot = + pluginMetadataSnapshot && + pluginMetadataSnapshot.pluginIds === undefined && + isPluginMetadataSnapshotCompatible({ + snapshot: pluginMetadataSnapshot, + config: params.config, + env: process.env, + workspaceDir: effectiveWorkspace, + }) + ? pluginMetadataSnapshot + : undefined; + providerRuntimeHandle = { + ...resolveProviderRuntimePluginHandle({ + provider: params.provider, + modelId: params.modelId, + config: params.config, + workspaceDir: effectiveWorkspace, + env: process.env, + ...(compatibleMetadataSnapshot + ? { pluginMetadataSnapshot: compatibleMetadataSnapshot } + : {}), + }), provider: params.provider, modelId: params.modelId, - config: params.config, + prepared: true, workspaceDir: effectiveWorkspace, - env: process.env, - ...(pluginMetadataSnapshotCoversProvider(pluginMetadataSnapshot, params.provider) - ? { pluginMetadataSnapshot } - : {}), - }); - if (resolvedHandle.plugin) { - providerRuntimeHandle = resolvedHandle; - } - return resolvedHandle; + }; + return providerRuntimeHandle; }; const { sessionAgentId } = resolveSessionAgentIds({ sessionKey: params.sessionKey, diff --git a/src/agents/embedded-agent-runner/run/runtime-preparation.ts b/src/agents/embedded-agent-runner/run/runtime-preparation.ts index 455703eb6c0b..4cc4eac6fcf6 100644 --- a/src/agents/embedded-agent-runner/run/runtime-preparation.ts +++ b/src/agents/embedded-agent-runner/run/runtime-preparation.ts @@ -1,4 +1,6 @@ import type { ThinkLevel } from "../../../auto-reply/thinking.js"; +import { isPluginMetadataSnapshotCompatible } from "../../../plugins/plugin-metadata-snapshot.js"; +import { resolveProviderRuntimePluginHandle } from "../../../plugins/provider-hook-runtime.js"; import type { AuthProfileStore } from "../../auth-profiles.js"; import { isProfileInCooldown } from "../../auth-profiles.js"; import type { ResolvedProviderAuth } from "../../model-auth.js"; @@ -70,6 +72,7 @@ export async function prepareEmbeddedRunRuntime(input: { }); provider = modelSetup.provider; modelId = modelSetup.modelId; + const pluginMetadataSnapshot = input.preparedModelRuntime?.metadataSnapshot; const { requestedModelId, modelSelectionChangedByHook, @@ -462,6 +465,29 @@ export async function prepareEmbeddedRunRuntime(input: { } input.markStartupStage("auth"); input.notifyExecutionPhase("auth", { provider, model: modelId }); + const compatibleMetadataSnapshot = + pluginMetadataSnapshot && + pluginMetadataSnapshot.pluginIds === undefined && + isPluginMetadataSnapshotCompatible({ + snapshot: pluginMetadataSnapshot, + config: params.config, + env: process.env, + workspaceDir: input.workspaceDir, + }) + ? pluginMetadataSnapshot + : undefined; + const providerRuntimeHandle = { + ...resolveProviderRuntimePluginHandle({ + provider, + modelId, + config: params.config, + workspaceDir: input.workspaceDir, + env: process.env, + ...(compatibleMetadataSnapshot ? { pluginMetadataSnapshot: compatibleMetadataSnapshot } : {}), + }), + modelId, + prepared: true as const, + }; return { provider, @@ -514,6 +540,8 @@ export async function prepareEmbeddedRunRuntime(input: { apiKeyInfo, lastProfileId, runtimeAuthState, + pluginMetadataSnapshot, + providerRuntimeHandle, }), }; } diff --git a/src/agents/model-selection-shared.ts b/src/agents/model-selection-shared.ts index ac9eef20ce3b..ad241200da29 100644 --- a/src/agents/model-selection-shared.ts +++ b/src/agents/model-selection-shared.ts @@ -98,15 +98,11 @@ function resolveManifestPluginsForModelIdNormalization(params: { if (currentManifestPlugins) { return currentManifestPlugins; } - return loadManifestMetadataSnapshot({ - config: params.cfg, - env: process.env, - }).plugins; } return loadManifestMetadataSnapshot({ config: params.cfg, - workspaceDir, env: process.env, + ...(workspaceDir ? { workspaceDir } : {}), }).plugins; } @@ -1356,17 +1352,15 @@ function resolveConfiguredModelManifestPlugins(params: { } const workspaceDir = params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState(); if (!workspaceDir) { - return ( - getCurrentPluginMetadataSnapshot({ - config: params.cfg, - env: process.env, - })?.plugins ?? [] - ); + return getCurrentPluginMetadataSnapshot({ + config: params.cfg, + env: process.env, + })?.plugins; } return loadManifestMetadataSnapshot({ config: params.cfg, - workspaceDir, env: process.env, + ...(workspaceDir ? { workspaceDir } : {}), }).plugins; } diff --git a/src/agents/model-suppression.ts b/src/agents/model-suppression.ts index bb898d687709..07e58a8d2cc3 100644 --- a/src/agents/model-suppression.ts +++ b/src/agents/model-suppression.ts @@ -10,7 +10,7 @@ import { getCurrentPluginMetadataSnapshotState } from "../plugins/current-plugin 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 { resolvePluginMetadataSnapshotMemoEnvFingerprint } from "../plugins/plugin-metadata-snapshot.js"; +import { resolvePluginMetadataEnvFingerprint } from "../plugins/plugin-metadata-snapshot.js"; type ManifestSuppressionResolver = ReturnType; @@ -45,7 +45,7 @@ function resolveCachedManifestSuppressionResolver(params: { ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), }); const cwd = process.cwd(); - const envFingerprint = resolvePluginMetadataSnapshotMemoEnvFingerprint(params.env); + const envFingerprint = resolvePluginMetadataEnvFingerprint(params.env); const metadataSnapshot = getCurrentPluginMetadataSnapshotState().snapshot; if ( cached !== undefined && diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index b4a2a90043c9..15c2ae3175ce 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -320,6 +320,11 @@ vi.mock("../agents/provider-model-normalization.runtime.js", () => ({ vi.mock("../plugins/current-plugin-metadata-snapshot.js", () => ({ getCurrentPluginMetadataSnapshot: () => emptyPluginMetadataSnapshot, })); +vi.mock("../plugins/plugin-metadata-snapshot.js", async (importOriginal) => ({ + ...(await importOriginal()), + isPluginMetadataSnapshotCompatible: () => true, + resolvePluginMetadataSnapshot: () => emptyPluginMetadataSnapshot, +})); vi.mock("../plugins/provider-thinking.js", () => ({ resolveProviderBinaryThinking: () => undefined, resolveProviderDefaultThinkingLevel: () => undefined, diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 1374911cbab1..c8fd26db8510 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -255,9 +255,6 @@ export function createOpenClawTools( const spawnWorkspaceDir = resolveWorkspaceRoot( options?.spawnWorkspaceDir ?? options?.workspaceDir ?? inferredWorkspaceDir, ); - const runtimeCwd = resolveWorkspaceRoot( - options?.cwd ?? options?.workspaceDir ?? inferredWorkspaceDir, - ); options?.recordToolPrepStage?.("openclaw-tools:session-workspace"); const deliveryContext = normalizeDeliveryContext({ channel: options?.agentChannel, @@ -530,7 +527,7 @@ export function createOpenClawTools( ? createTaskSuggestionTools({ sessionKey: taskKey, agentId: sessionAgentId, - cwd: runtimeCwd, + cwd: resolveWorkspaceRoot(options?.cwd ?? options?.workspaceDir ?? inferredWorkspaceDir), }) : []), ...(messageTool && includeMessageTool ? [messageTool] : []), @@ -708,6 +705,7 @@ export function createOpenClawTools( sandboxed: options?.sandboxed, activeModelProvider: options?.modelProvider, activeModelId: options?.modelId, + metadataSnapshot: options?.preparedModelRuntime?.metadataSnapshot, activeDeliveryContext: { channel: options?.agentChannel, to: options?.currentChannelId ?? options?.agentTo, diff --git a/src/agents/runtime-plan/build.test.ts b/src/agents/runtime-plan/build.test.ts index b3eda5ae88ee..bd4aade0aae0 100644 --- a/src/agents/runtime-plan/build.test.ts +++ b/src/agents/runtime-plan/build.test.ts @@ -4,20 +4,14 @@ import { createParameterFreeTool } from "openclaw/plugin-sdk/agent-runtime-test- import { afterEach, describe, expect, it, vi } from "vitest"; import { resetConfigRuntimeState, setRuntimeConfigSnapshot } from "../../config/config.js"; import { - resolveProviderRuntimePluginHandle, prepareProviderExtraParams, resolveProviderFollowupFallbackRoute, + resolveProviderRuntimePluginHandle, type ProviderRuntimePluginHandle, } from "../../plugins/provider-hook-runtime.js"; import { buildAgentRuntimeDeliveryPlan, buildAgentRuntimePlan } from "./build.js"; -const manifestMocks = vi.hoisted(() => ({ - loadManifestMetadataSnapshot: vi.fn(() => ({}) as never), -})); - -vi.mock("../../plugins/manifest-contract-eligibility.js", () => ({ - loadManifestMetadataSnapshot: manifestMocks.loadManifestMetadataSnapshot, -})); +const isPluginMetadataSnapshotCompatible = vi.hoisted(() => vi.fn(() => true)); vi.mock("../../plugins/provider-hook-runtime.js", () => ({ clearProviderRuntimePluginCacheForTest: vi.fn(), @@ -37,6 +31,11 @@ vi.mock("../../plugins/provider-hook-runtime.js", () => ({ wrapProviderStreamFn: vi.fn(() => undefined), })); +vi.mock("../../plugins/plugin-metadata-snapshot.js", async (importOriginal) => ({ + ...(await importOriginal()), + isPluginMetadataSnapshotCompatible, +})); + const gpt54Model = { id: "gpt-5.4", name: "GPT-5.4", @@ -90,8 +89,6 @@ function latestFollowupRouteCall(): { describe("AgentRuntimePlan", () => { afterEach(() => { resetConfigRuntimeState(); - manifestMocks.loadManifestMetadataSnapshot.mockClear(); - vi.mocked(resolveProviderRuntimePluginHandle).mockClear(); }); it("defers default transport extra params until they are read", () => { @@ -292,8 +289,13 @@ describe("AgentRuntimePlan", () => { route: "dispatcher" as const, reason: "prepared-route", }); - const providerRuntimeHandle: ProviderRuntimePluginHandle = { + const providerRuntimeHandle: ProviderRuntimePluginHandle & { + modelId: string; + prepared: true; + } = { provider: "openai", + modelId: "gpt-5.4", + prepared: true, }; const plan = buildAgentRuntimePlan({ @@ -323,27 +325,22 @@ describe("AgentRuntimePlan", () => { expect(followupCall.context?.dispatcherAvailable).toBe(true); }); - it("resolves incomplete supplied provider handles before invoking runtime hooks", () => { - const resolveProviderRuntimePluginHandleMock = vi.mocked(resolveProviderRuntimePluginHandle); + it("reuses the provider handle prepared before plan construction", () => { const resolveProviderFollowupFallbackRouteMock = vi.mocked( resolveProviderFollowupFallbackRoute, ); - resolveProviderRuntimePluginHandleMock.mockClear(); resolveProviderFollowupFallbackRouteMock.mockClear(); - const suppliedHandle = { + const suppliedHandle: ProviderRuntimePluginHandle & { modelId: string; prepared: true } = { provider: "openai", + modelId: "gpt-5.4", + prepared: true, config: { plugins: { allow: ["openai"] } }, - }; - const resolvedHandle: ProviderRuntimePluginHandle = { - ...suppliedHandle, workspaceDir: "/tmp/openclaw-runtime-plan", env: process.env, plugin: {} as never, }; - resolveProviderRuntimePluginHandleMock.mockReturnValueOnce(resolvedHandle); - const plan = buildAgentRuntimePlan({ provider: "openai", modelId: "gpt-5.4", @@ -352,7 +349,7 @@ describe("AgentRuntimePlan", () => { providerRuntimeHandle: suppliedHandle, }); - expect(plan.providerRuntimeHandle).toBe(resolvedHandle); + expect(plan.providerRuntimeHandle).toBe(suppliedHandle); plan.delivery.resolveFollowupRoute({ payload: { text: "hello" }, @@ -360,39 +357,25 @@ describe("AgentRuntimePlan", () => { dispatcherAvailable: true, }); - expect(resolveProviderRuntimePluginHandleMock).toHaveBeenCalledWith({ - provider: "openai", - modelId: "gpt-5.4", - config: suppliedHandle.config, - workspaceDir: "/tmp/openclaw-runtime-plan", - env: process.env, - applyAutoEnable: undefined, - bundledProviderVitestCompat: undefined, - }); const followupCall = latestFollowupRouteCall(); - expect(followupCall.runtimeHandle).toBe(resolvedHandle); + expect(followupCall.runtimeHandle).toBe(suppliedHandle); }); - it("resolves incomplete supplied delivery handles before follow-up routing", () => { - const resolveProviderRuntimePluginHandleMock = vi.mocked(resolveProviderRuntimePluginHandle); + it("reuses a delivery-only provider handle", () => { const resolveProviderFollowupFallbackRouteMock = vi.mocked( resolveProviderFollowupFallbackRoute, ); - resolveProviderRuntimePluginHandleMock.mockClear(); resolveProviderFollowupFallbackRouteMock.mockClear(); - const suppliedHandle = { - provider: "openai", - }; - const resolvedHandle: ProviderRuntimePluginHandle = { + const suppliedHandle: ProviderRuntimePluginHandle & { modelId: string; prepared: true } = { provider: "openai", + modelId: "gpt-5.4", + prepared: true, workspaceDir: "/tmp/openclaw-runtime-plan", env: process.env, plugin: {} as never, }; - resolveProviderRuntimePluginHandleMock.mockReturnValueOnce(resolvedHandle); - const delivery = buildAgentRuntimeDeliveryPlan({ provider: "openai", modelId: "gpt-5.4", @@ -407,42 +390,42 @@ describe("AgentRuntimePlan", () => { dispatcherAvailable: true, }); - expect(resolveProviderRuntimePluginHandleMock).toHaveBeenCalledWith({ - provider: "openai", - modelId: "gpt-5.4", - config: {}, - workspaceDir: "/tmp/openclaw-runtime-plan", - env: process.env, - applyAutoEnable: undefined, - bundledProviderVitestCompat: undefined, - }); const followupCall = latestFollowupRouteCall(); - expect(followupCall.runtimeHandle).toBe(resolvedHandle); + expect(followupCall.runtimeHandle).toBe(suppliedHandle); }); - it("plans tool metadata against the runtime source snapshot lazily", () => { - const sourceConfig = { channels: { telegram: { botToken: "token" } } }; - const runtimeConfig = { - ...sourceConfig, - plugins: { allow: ["telegram"] }, - }; - setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); - + it("threads prepared tool metadata without discovery", () => { + const metadataSnapshot = { plugins: [] }; + vi.mocked(resolveProviderRuntimePluginHandle).mockClear(); const plan = buildAgentRuntimePlan({ + provider: "openai", + modelId: "gpt-5.4", + metadataSnapshot, + }); + + expect(plan.tools.preparedPlanning?.metadataSnapshot).toBe(metadataSnapshot); + expect(resolveProviderRuntimePluginHandle).toHaveBeenCalledWith( + expect.objectContaining({ pluginMetadataSnapshot: metadataSnapshot }), + ); + }); + + it("validates threaded tool metadata against the source config projection", () => { + const runtimeConfig = { plugins: { entries: { runtimeOnly: { enabled: true } } } }; + const sourceConfig = { plugins: { entries: {} } }; + const metadataSnapshot = { plugins: [] }; + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + isPluginMetadataSnapshotCompatible.mockClear(); + + buildAgentRuntimePlan({ provider: "openai", modelId: "gpt-5.4", config: runtimeConfig, - workspaceDir: "/tmp/openclaw-runtime-plan", + metadataSnapshot, }); - expect(manifestMocks.loadManifestMetadataSnapshot).not.toHaveBeenCalled(); - - plan.tools.preparedPlanning?.loadMetadataSnapshot?.(); - - expect(manifestMocks.loadManifestMetadataSnapshot).toHaveBeenCalledWith({ - config: sourceConfig, - workspaceDir: "/tmp/openclaw-runtime-plan", - env: process.env, - }); + expect(isPluginMetadataSnapshotCompatible).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ config: sourceConfig }), + ); }); }); diff --git a/src/agents/runtime-plan/build.ts b/src/agents/runtime-plan/build.ts index deb8df0346cf..555be57f0215 100644 --- a/src/agents/runtime-plan/build.ts +++ b/src/agents/runtime-plan/build.ts @@ -8,7 +8,10 @@ import { isSilentReplyPayloadText, SILENT_REPLY_TOKEN } from "../../auto-reply/t import { projectConfigOntoRuntimeSourceSnapshot } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { hasReplyPayloadContent } from "../../interactive/payload.js"; -import { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js"; +import { + isPluginMetadataSnapshotCompatible, + resolvePluginMetadataSnapshot, +} from "../../plugins/plugin-metadata-snapshot.js"; import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import { resolveProviderRuntimePluginHandle, @@ -54,40 +57,54 @@ function asProviderRuntimeModel( return value !== undefined ? (value as ProviderRuntimeModel) : undefined; } -function isProviderRuntimePluginHandle( - value: BuildAgentRuntimePlanParams["providerRuntimeHandle"] | ProviderRuntimePluginHandle, -): value is ProviderRuntimePluginHandle { - return value !== undefined && "plugin" in value; +type RuntimePlanMetadataParams = BuildAgentRuntimeDeliveryPlanParams & { + metadataSnapshot?: BuildAgentRuntimePlanParams["metadataSnapshot"]; +}; + +function resolveCompatibleMetadataSnapshot( + params: RuntimePlanMetadataParams, + config: OpenClawConfig | undefined = asOpenClawConfig(params.config), +): PluginMetadataSnapshot | undefined { + const metadataSnapshot = params.metadataSnapshot as PluginMetadataSnapshot | undefined; + return metadataSnapshot && + metadataSnapshot.pluginIds === undefined && + isPluginMetadataSnapshotCompatible({ + snapshot: metadataSnapshot, + config, + env: process.env, + workspaceDir: params.workspaceDir, + }) + ? metadataSnapshot + : undefined; } -function resolveProviderRuntimeHandleForPlugins(params: { - provider: string; - modelId?: string; - config?: OpenClawConfig; - workspaceDir?: string; - runtimeHandle?: BuildAgentRuntimePlanParams["providerRuntimeHandle"]; - resolveWhenMissing?: boolean; -}): ProviderRuntimePluginHandle | undefined { +function resolvePreparedProviderRuntimeHandle( + params: RuntimePlanMetadataParams, +): ProviderRuntimePluginHandle & { modelId: string; prepared: true } { if ( - isProviderRuntimePluginHandle(params.runtimeHandle) && - (params.runtimeHandle.plugin || - !params.modelId || - params.runtimeHandle.modelId === params.modelId) + params.providerRuntimeHandle?.prepared === true && + params.providerRuntimeHandle.provider === params.provider && + params.providerRuntimeHandle.modelId === params.modelId && + params.providerRuntimeHandle.workspaceDir === params.workspaceDir ) { - return params.runtimeHandle; + return params.providerRuntimeHandle as ProviderRuntimePluginHandle & { + modelId: string; + prepared: true; + }; } - if (!params.runtimeHandle && !params.resolveWhenMissing) { - return undefined; - } - return resolveProviderRuntimePluginHandle({ - provider: params.runtimeHandle?.provider ?? params.provider, + const compatibleMetadataSnapshot = resolveCompatibleMetadataSnapshot(params); + return { + ...resolveProviderRuntimePluginHandle({ + provider: params.provider, + modelId: params.modelId, + config: asOpenClawConfig(params.config), + workspaceDir: params.workspaceDir, + env: process.env, + ...(compatibleMetadataSnapshot ? { pluginMetadataSnapshot: compatibleMetadataSnapshot } : {}), + }), modelId: params.modelId, - config: asOpenClawConfig(params.runtimeHandle?.config) ?? params.config, - workspaceDir: params.runtimeHandle?.workspaceDir ?? params.workspaceDir, - env: params.runtimeHandle?.env ?? process.env, - applyAutoEnable: params.runtimeHandle?.applyAutoEnable, - bundledProviderVitestCompat: params.runtimeHandle?.bundledProviderVitestCompat, - }); + prepared: true, + }; } /** Build delivery-specific runtime decisions for one provider/model. */ @@ -95,13 +112,7 @@ export function buildAgentRuntimeDeliveryPlan( params: BuildAgentRuntimeDeliveryPlanParams, ): AgentRuntimeDeliveryPlan { const config = asOpenClawConfig(params.config); - const providerRuntimeHandle = resolveProviderRuntimeHandleForPlugins({ - provider: params.provider, - modelId: params.modelId, - config, - workspaceDir: params.workspaceDir, - runtimeHandle: params.providerRuntimeHandle, - }); + const providerRuntimeHandle = resolvePreparedProviderRuntimeHandle(params); return { isSilentPayload(payload): boolean { return ( @@ -146,25 +157,21 @@ export function buildAgentRuntimePlan(params: BuildAgentRuntimePlanParams): Agen const modelApi = params.modelApi ?? params.model?.api ?? undefined; const transport = params.resolvedTransport; const toolPlanningConfig = config ? projectConfigOntoRuntimeSourceSnapshot(config) : undefined; - let toolPlanningMetadataSnapshot: PluginMetadataSnapshot | undefined; - const loadToolPlanningMetadataSnapshot = () => { - // Metadata is process-stable for one run; load lazily because many attempts - // never need prepared tool planning. - toolPlanningMetadataSnapshot ??= loadManifestMetadataSnapshot({ - config: toolPlanningConfig, - ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), - env: process.env, - }); - return toolPlanningMetadataSnapshot; - }; - const providerRuntimeHandleForPlugins = resolveProviderRuntimeHandleForPlugins({ - provider: params.provider, - modelId: params.modelId, - config, - workspaceDir: params.workspaceDir, - runtimeHandle: params.providerRuntimeHandle, - resolveWhenMissing: true, - }); + const toolPlanningMetadataSnapshot = resolveCompatibleMetadataSnapshot( + params, + toolPlanningConfig, + ); + const preparedPlanning = toolPlanningMetadataSnapshot + ? { metadataSnapshot: toolPlanningMetadataSnapshot } + : { + loadMetadataSnapshot: () => + resolvePluginMetadataSnapshot({ + config: toolPlanningConfig, + ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + env: process.env, + }), + }; + const providerRuntimeHandleForPlugins = resolvePreparedProviderRuntimeHandle(params); const auth = params.preparedAuthPlan ?? buildAgentRuntimeAuthPlan({ @@ -294,9 +301,7 @@ export function buildAgentRuntimePlan(params: BuildAgentRuntimePlanParams): Agen }, }, tools: { - preparedPlanning: { - loadMetadataSnapshot: loadToolPlanningMetadataSnapshot, - }, + preparedPlanning, normalize( tools: AgentTool[], overrides?: { diff --git a/src/agents/runtime-plan/types.ts b/src/agents/runtime-plan/types.ts index 4979818ec1a0..3b5deec27396 100644 --- a/src/agents/runtime-plan/types.ts +++ b/src/agents/runtime-plan/types.ts @@ -88,6 +88,7 @@ type AgentRuntimeTextTransforms = { /** Resolved provider runtime handle forwarded to plugin-owned hooks. */ type AgentRuntimeProviderHandle = { provider: string; + modelId?: string | null; config?: AgentRuntimeConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; @@ -95,6 +96,11 @@ type AgentRuntimeProviderHandle = { bundledProviderVitestCompat?: boolean; }; +type PreparedAgentRuntimeProviderHandle = AgentRuntimeProviderHandle & { + modelId: string | null; + prepared: true; +}; + type AgentRuntimeInteractiveButtonStyle = "primary" | "secondary" | "success" | "danger"; type AgentRuntimeMessagePresentationAction = @@ -513,7 +519,7 @@ type AgentRuntimeTransportPlan = { /** Complete prepared runtime plan consumed by embedded-agent attempts. */ export type AgentRuntimePlan = { resolvedRef: AgentRuntimeResolvedRef; - providerRuntimeHandle?: AgentRuntimeProviderHandle; + providerRuntimeHandle?: PreparedAgentRuntimeProviderHandle; auth: AgentRuntimeAuthPlan; prompt: AgentRuntimePromptPlan; tools: AgentRuntimeToolPlan; @@ -546,7 +552,7 @@ export type BuildAgentRuntimeDeliveryPlanParams = { agentDir?: string; provider: string; modelId: string; - providerRuntimeHandle?: AgentRuntimeProviderHandle; + providerRuntimeHandle?: PreparedAgentRuntimeProviderHandle; }; /** Inputs needed to build the full prepared runtime plan. */ @@ -574,5 +580,8 @@ export type BuildAgentRuntimePlanParams = { thinkingLevel?: AgentRuntimeThinkLevel; extraParamsOverride?: Record; resolvedTransport?: AgentRuntimeTransport; - providerRuntimeHandle?: AgentRuntimeProviderHandle; + /** Omit only when a standalone caller intentionally resolves provider hooks lazily. */ + providerRuntimeHandle?: PreparedAgentRuntimeProviderHandle; + /** Lifecycle-owned plugin metadata prepared before the attempt starts. */ + metadataSnapshot?: AgentRuntimePreparedMetadataSnapshot; }; diff --git a/src/agents/tool-policy-declared-context.ts b/src/agents/tool-policy-declared-context.ts index 9f7648ca65f4..1ea03b0cc7f5 100644 --- a/src/agents/tool-policy-declared-context.ts +++ b/src/agents/tool-policy-declared-context.ts @@ -3,12 +3,12 @@ import { uniqueStrings } from "@openclaw/normalization-core/string-normalization import { normalizeConfiguredMcpServers } from "../config/mcp-config-normalize.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizePluginsConfig } from "../plugins/config-state.js"; -import { - isManifestPluginAvailableForControlPlane, - loadManifestMetadataSnapshot, -} from "../plugins/manifest-contract-eligibility.js"; +import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js"; +import { isManifestPluginAvailableForControlPlane } from "../plugins/manifest-contract-eligibility.js"; import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; import { hasManifestToolAvailability } from "../plugins/manifest-tool-availability.js"; +import { isPluginMetadataSnapshotCompatible } from "../plugins/plugin-metadata-snapshot.js"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; import { sanitizeServerName, TOOL_NAME_SEPARATOR } from "./agent-bundle-mcp-names.js"; import { compileGlobPatterns, matchesAnyGlobPattern } from "./glob-pattern.js"; import type { DeclaredToolAllowlistContext } from "./tool-policy.js"; @@ -126,16 +126,33 @@ function collectDeclaredPluginContext(params: { workspaceDir?: string; toolDenylist?: string[]; env?: NodeJS.ProcessEnv; + metadataSnapshot?: PluginMetadataSnapshot; }): Pick { if (params.config?.plugins?.enabled === false) { return {}; } const env = params.env ?? process.env; - const snapshot = loadManifestMetadataSnapshot({ - config: params.config, - ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), - env, - }); + const preparedSnapshot = + params.metadataSnapshot && + params.metadataSnapshot.pluginIds === undefined && + isPluginMetadataSnapshotCompatible({ + snapshot: params.metadataSnapshot, + config: params.config, + env, + workspaceDir: params.workspaceDir, + }) + ? params.metadataSnapshot + : undefined; + const snapshot = + preparedSnapshot ?? + getCurrentPluginMetadataSnapshot({ + config: params.config, + ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + env, + }); + if (!snapshot) { + return {}; + } const normalizedPlugins = normalizePluginsConfig(params.config?.plugins); const denylist = normalizeToolDenylist(params.toolDenylist); const pluginIds = new Set(); @@ -175,6 +192,7 @@ export function buildDeclaredToolAllowlistContext(params: { workspaceDir?: string; toolDenylist?: string[]; env?: NodeJS.ProcessEnv; + metadataSnapshot?: PluginMetadataSnapshot; }): DeclaredToolAllowlistContext | undefined { const mcpServerNames = uniqueStrings( collectConfiguredMcpServerNames({ diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index 854752b3b2b6..f210cb4ae5f6 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -30,10 +30,10 @@ import { type MediaUnderstandingProvider, } from "../../plugin-sdk/media-understanding.js"; import { resolvePluginCapabilityProvider } from "../../plugins/capability-provider-runtime.js"; -import { - isManifestPluginAvailableForControlPlane, - loadManifestMetadataSnapshot, -} from "../../plugins/manifest-contract-eligibility.js"; +import { getCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.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 type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; import { resolveUserPath } from "../../utils.js"; import type { AuthProfileStore } from "../auth-profiles/types.js"; @@ -490,6 +490,7 @@ function providerUsesRuntimeModelAugment(params: { cfg?: OpenClawConfig; provider: string; workspaceDir?: string; + metadataSnapshot?: PluginMetadataSnapshot; }): boolean { const provider = normalizeMediaProviderId(params.provider); if (!provider) { @@ -499,11 +500,27 @@ function providerUsesRuntimeModelAugment(params: { return true; } const config = params.cfg ?? {}; - const snapshot = loadManifestMetadataSnapshot({ - config, - env: process.env, - ...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}), - }); + const preparedSnapshot = + params.metadataSnapshot && + params.metadataSnapshot.pluginIds === undefined && + isPluginMetadataSnapshotCompatible({ + snapshot: params.metadataSnapshot, + config, + env: process.env, + workspaceDir: params.workspaceDir, + }) + ? params.metadataSnapshot + : undefined; + const snapshot = + preparedSnapshot ?? + getCurrentPluginMetadataSnapshot({ + config, + env: process.env, + ...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}), + }); + if (!snapshot) { + return false; + } return snapshot.plugins.some((plugin) => { const ownsProvider = plugin.providers.some((candidate) => normalizeMediaProviderId(candidate) === provider) || @@ -559,6 +576,7 @@ async function resolveCompressionModelPolicy(params: { model: string; agentDir?: string; workspaceDir?: string; + metadataSnapshot?: PluginMetadataSnapshot; }): Promise { const configuredStaticPolicy = await resolveCompressionModelPolicyWithHooks({ ...params, @@ -574,6 +592,7 @@ async function resolveCompressionModelPolicy(params: { cfg: params.cfg, provider: params.provider, workspaceDir: params.workspaceDir, + metadataSnapshot: params.metadataSnapshot, }) ) { return staticPolicy; @@ -592,6 +611,7 @@ async function resolveImageCompressionPolicy(params: { imageCount: number; agentDir?: string; workspaceDir?: string; + metadataSnapshot?: PluginMetadataSnapshot; }): Promise { const modelCandidates = resolveCompressionModelCandidates(params); const quality = params.cfg?.agents?.defaults?.imageQuality; @@ -603,6 +623,7 @@ async function resolveImageCompressionPolicy(params: { model: candidate.model, agentDir: params.agentDir, workspaceDir: params.workspaceDir, + metadataSnapshot: params.metadataSnapshot, }); }), ); @@ -974,6 +995,7 @@ export function createImageTool(options?: { imageCount: imageInputs.length, agentDir, workspaceDir: options?.workspaceDir, + metadataSnapshot: options?.preparedModelRuntime?.metadataSnapshot, }); imageRoute = { kind: "fallback", imageModelConfig, imageCompression }; } diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index 1b9953007ce5..e74f305e0292 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -20,7 +20,11 @@ import { } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { triggerSessionPatchHook } from "../../gateway/session-patch-hooks.js"; -import { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js"; +import { + isPluginMetadataSnapshotCompatible, + resolvePluginMetadataSnapshot, +} from "../../plugins/plugin-metadata-snapshot.js"; +import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import { buildAgentMainSessionKey, parseAgentSessionKey, @@ -441,6 +445,7 @@ async function resolveModelOverride(params: { agentId: string; agentDir: string; workspaceDir: string; + metadataSnapshot?: PluginMetadataSnapshot; }): Promise< | { kind: "reset" } | { @@ -475,13 +480,24 @@ async function resolveModelOverride(params: { ? { workspaceDir: params.sessionEntry.spawnedWorkspaceDir } : {}), }); - const manifestMetadataSnapshot = loadManifestMetadataSnapshot({ - config: params.cfg, - workspaceDir: params.sessionEntry?.spawnedWorkspaceDir, - env: process.env, - }); + const workspaceDir = params.sessionEntry?.spawnedWorkspaceDir ?? params.workspaceDir; + const manifestMetadataSnapshot = + params.metadataSnapshot && + params.metadataSnapshot.pluginIds === undefined && + isPluginMetadataSnapshotCompatible({ + snapshot: params.metadataSnapshot, + config: params.cfg, + env: process.env, + workspaceDir, + }) + ? params.metadataSnapshot + : resolvePluginMetadataSnapshot({ + config: params.cfg, + ...(workspaceDir ? { workspaceDir } : {}), + env: process.env, + }); const modelManifestContext = { - manifestPlugins: manifestMetadataSnapshot.plugins, + manifestPlugins: manifestMetadataSnapshot?.plugins, }; const policy = createModelVisibilityPolicy({ cfg: params.cfg, @@ -532,6 +548,7 @@ export function createSessionStatusTool(opts?: { sandboxed?: boolean; activeModelProvider?: string; activeModelId?: string; + metadataSnapshot?: PluginMetadataSnapshot; /** Active live-run route, kept separate from the persisted/origin delivery route. */ activeDeliveryContext?: DeliveryContext; }): AnyAgentTool { @@ -845,6 +862,7 @@ export function createSessionStatusTool(opts?: { agentId, agentDir: selectedAgentDir, workspaceDir: selectedWorkspaceDir, + metadataSnapshot: opts?.metadataSnapshot, }); const modelSelection = selection.kind === "reset" diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index af06ebb05782..e8568b707952 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -31,6 +31,7 @@ const resolveCommandSecretRefsViaGatewayMock = vi.fn(); const resolveQueuedReplyExecutionConfigMock = vi.fn(); const resolveProviderFollowupFallbackRouteMock = vi.fn(); const resolveProviderThinkingProfileMock = vi.fn(); +const admitReplyTurnMock = vi.fn(); let resolveQueuedReplyExecutionConfigActual: | (typeof import("./agent-runner-utils.js"))["resolveQueuedReplyExecutionConfig"] | undefined; @@ -428,6 +429,18 @@ async function loadFreshFollowupRunnerModuleForTest() { refreshQueuedFollowupSession: refreshQueuedFollowupSessionForFollowupTest, resolveQueueSettings: (): QueueSettings => ({ mode: "followup" }), })); + vi.doMock("./reply-turn-admission.js", async () => { + const actual = await vi.importActual( + "./reply-turn-admission.js", + ); + return { + ...actual, + admitReplyTurn: (...args: Parameters) => + admitReplyTurnMock.getMockImplementation() + ? admitReplyTurnMock(...args) + : actual.admitReplyTurn(...args), + }; + }); vi.doMock("./session-run-accounting.js", () => ({ persistRunSessionUsage: persistRunSessionUsageForFollowupTest, incrementRunCompactionCount: incrementRunCompactionCountForFollowupTest, @@ -620,6 +633,7 @@ beforeEach(() => { resolveProviderFollowupFallbackRouteMock.mockReturnValue(undefined); resolveProviderThinkingProfileMock.mockReset(); resolveProviderThinkingProfileMock.mockReturnValue(undefined); + admitReplyTurnMock.mockReset(); const resolveQueuedReplyExecutionConfig = resolveQueuedReplyExecutionConfigActual; if (!resolveQueuedReplyExecutionConfig) { throw new Error("resolveQueuedReplyExecutionConfig mock not initialized"); @@ -692,7 +706,10 @@ function createQueuedRun( describe("createFollowupRunner reply-lane admission", () => { it("drops stale active-goal context after the persisted goal completes", async () => { runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: {} }); - const storePath = "/tmp/openclaw-followup-completed-goal.json"; + const storePath = path.join( + tmpdir(), + `openclaw-followup-completed-goal-${crypto.randomUUID()}.json`, + ); const activeEntry: SessionEntry = { sessionId: "session-completed-goal", updatedAt: 1, @@ -714,6 +731,14 @@ describe("createFollowupRunner reply-lane admission", () => { goal: { ...activeEntry.goal!, status: "complete", updatedAt: 2 }, }; registerFollowupTestSessionStore(storePath, { main: completedEntry }); + admitReplyTurnMock.mockResolvedValueOnce({ + status: "admitted", + operation: createReplyOperationForTest({ + sessionKey: "main", + sessionId: completedEntry.sessionId, + resetTriggered: false, + }), + }); const runner = createFollowupRunner({ typing: createMockTypingController(), typingMode: "instant", @@ -749,7 +774,7 @@ describe("createFollowupRunner reply-lane admission", () => { const context = requireRecord(call.currentInboundContext, "current inbound context"); expect(context.text).toContain("Current message:\nmessage_id=next-turn"); expect(context.text).not.toContain("Active goal:"); - }); + }, 300_000); it("keeps the originating client caps on queued embedded runs", async () => { // Regression: the queued path built runEmbeddedAgent params inline and diff --git a/src/commands/models/list.auth-index.test.ts b/src/commands/models/list.auth-index.test.ts index d11723dfdc10..1e65bfa955a6 100644 --- a/src/commands/models/list.auth-index.test.ts +++ b/src/commands/models/list.auth-index.test.ts @@ -116,6 +116,34 @@ describe("createModelListAuthIndex", () => { expect(index.evaluateModelAuth("disabled-provider").availability).toBeUndefined(); }); + it("uses enabled synthetic refs from prepared metadata without reloading the registry", () => { + const metadataSnapshot = { + registrySource: "persisted", + registryDiagnostics: [], + plugins: [], + index: { + plugins: [ + { enabled: true, syntheticAuthRefs: ["codex"] }, + { enabled: false, syntheticAuthRefs: ["disabled-provider"] }, + ], + }, + } as unknown as PluginMetadataSnapshot; + const index = createModelListAuthIndex({ + cfg: {}, + authStore: emptyStore, + env: {}, + metadataSnapshot, + routeResolverFactory: dualRouteResolverFactory, + }); + + expect(index.evaluateModelAuth("openai", { modelId: "gpt-5.5" })).toMatchObject({ + availability: undefined, + evidence: "synthetic", + }); + expect(index.evaluateModelAuth("disabled-provider").availability).toBeUndefined(); + expect(pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata).not.toHaveBeenCalled(); + }); + it.each(["derived" as const, "persisted" as const])( "does not trust unusable synthetic refs from a %s snapshot", (source) => { @@ -140,10 +168,15 @@ describe("createModelListAuthIndex", () => { ); it("uses explicit synthetic refs without loading plugin metadata", () => { + const metadataSnapshot = { + registrySource: "persisted", + plugins: [], + } as unknown as PluginMetadataSnapshot; const index = createModelListAuthIndex({ cfg: {}, authStore: emptyStore, env: {}, + metadataSnapshot, syntheticAuthProviderRefs: ["codex"], routeResolverFactory: dualRouteResolverFactory, }); diff --git a/src/commands/models/list.auth-index.ts b/src/commands/models/list.auth-index.ts index c703dd8078a8..10427546a9bb 100644 --- a/src/commands/models/list.auth-index.ts +++ b/src/commands/models/list.auth-index.ts @@ -35,16 +35,27 @@ function listValidatedSyntheticAuthProviderRefs(params: { env: NodeJS.ProcessEnv; metadataSnapshot?: PluginMetadataSnapshot; }): readonly string[] { - if (params.metadataSnapshot && (params.metadataSnapshot.registryDiagnostics?.length ?? 0) > 0) { - return []; + if (params.metadataSnapshot) { + if ( + params.metadataSnapshot.registryDiagnostics.length > 0 || + (params.metadataSnapshot.registrySource !== "persisted" && + params.metadataSnapshot.registrySource !== "provided") + ) { + return []; + } + return params.metadataSnapshot.index.plugins + .filter((plugin) => plugin.enabled) + .flatMap((plugin) => plugin.syntheticAuthRefs ?? []); } const result = loadPluginRegistrySnapshotWithMetadata({ config: params.cfg, workspaceDir: params.workspaceDir, env: params.env, - index: params.metadataSnapshot?.index, }); - if (result.source !== "persisted" && result.source !== "provided") { + if ( + result.diagnostics.length > 0 || + (result.source !== "persisted" && result.source !== "provided") + ) { return []; } return result.snapshot.plugins diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 39edace08b84..7b1d8368ae60 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -7,7 +7,6 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest import { startGatewayConfigReloader } from "../gateway/config-reload.js"; import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely } from "../infra/kysely-sync.js"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; -import { clearLoadPluginMetadataSnapshotMemo } from "../plugins/plugin-metadata-snapshot.js"; import { readConfigMachineState } from "../state/config-machine-state.js"; import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; import { @@ -128,7 +127,6 @@ describe("config io write", () => { afterEach(() => { resetConfigRuntimeState(); - clearLoadPluginMetadataSnapshotMemo(); mockMaintainConfigBackups.mockReset(); mockMaintainConfigBackups.mockResolvedValue(undefined); }); diff --git a/src/gateway/http-utils.ts b/src/gateway/http-utils.ts index 5b1749b62f07..dc1884e350f2 100644 --- a/src/gateway/http-utils.ts +++ b/src/gateway/http-utils.ts @@ -11,7 +11,8 @@ import { modelKey, parseModelRef, resolveDefaultModelForAgent } from "../agents/ import { createModelVisibilityPolicy } from "../agents/model-visibility-policy.js"; import { getRuntimeConfig } from "../config/io.js"; import { resolveSessionEntryAccessTarget } from "../config/sessions/session-accessor.js"; -import { loadManifestMetadataSnapshot } from "../plugins/manifest-contract-eligibility.js"; +import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js"; +import { getActivePluginRegistryWorkspaceDirFromState } from "../plugins/runtime-state.js"; import { buildAgentMainSessionKey, isAcpSessionKey, @@ -138,12 +139,14 @@ export async function resolveOpenAiCompatModelOverride(params: { const cfg = getRuntimeConfig(); const defaultModelRef = resolveDefaultModelForAgent({ cfg, agentId: params.agentId }); const defaultProvider = defaultModelRef.provider; - const manifestMetadataSnapshot = loadManifestMetadataSnapshot({ + const workspaceDir = getActivePluginRegistryWorkspaceDirFromState(); + const manifestMetadataSnapshot = getCurrentPluginMetadataSnapshot({ config: cfg, env: process.env, + ...(workspaceDir ? { workspaceDir } : {}), }); const modelManifestContext = { - manifestPlugins: manifestMetadataSnapshot.plugins, + manifestPlugins: manifestMetadataSnapshot?.plugins, }; const parsed = parseModelRef(raw, defaultProvider, { allowManifestNormalization: true, diff --git a/src/gateway/model-pricing-cache.test.ts b/src/gateway/model-pricing-cache.test.ts index 408a7febc837..7723ad964808 100644 --- a/src/gateway/model-pricing-cache.test.ts +++ b/src/gateway/model-pricing-cache.test.ts @@ -8,7 +8,6 @@ import type { OpenClawConfig } from "../config/config.js"; import { resetLogger, setLoggerOverride } from "../logging/logger.js"; import { loggingState } from "../logging/state.js"; import type { PluginManifestRecord, PluginManifestRegistry } from "../plugins/manifest-registry.js"; -import { clearLoadPluginMetadataSnapshotMemo } from "../plugins/plugin-metadata-snapshot.js"; import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; const normalizeProviderModelIdWithRuntimeMock = vi.hoisted(() => @@ -138,7 +137,6 @@ function requireAbortSignal(signal: RequestInit["signal"] | undefined): AbortSig describe("model-pricing-cache", () => { beforeEach(() => { clearGatewayModelPricingState(); - clearLoadPluginMetadataSnapshotMemo(); pluginManifestRegistryMocks.manifestRegistry = undefined; pluginManifestRegistryMocks.loadPluginManifestRegistryForInstalledIndex.mockClear(); pluginManifestRegistryMocks.listOpenClawPluginManifestMetadata.mockClear(); @@ -147,7 +145,6 @@ describe("model-pricing-cache", () => { afterEach(() => { clearGatewayModelPricingState(); - clearLoadPluginMetadataSnapshotMemo(); loggingState.rawConsole = null; resetLogger(); }); diff --git a/src/plugins/capability-provider-runtime.test.ts b/src/plugins/capability-provider-runtime.test.ts index 818a850db86c..d5730e796559 100644 --- a/src/plugins/capability-provider-runtime.test.ts +++ b/src/plugins/capability-provider-runtime.test.ts @@ -129,7 +129,6 @@ let prepareMediaCapabilityProviders: typeof import("./capability-provider-runtim let clearCurrentPluginMetadataSnapshot: typeof import("./current-plugin-metadata-snapshot.js").clearCurrentPluginMetadataSnapshot; let setCurrentPluginMetadataSnapshot: typeof import("./current-plugin-metadata-snapshot.js").setCurrentPluginMetadataSnapshot; let clearPluginMetadataLifecycleCaches: typeof import("./plugin-metadata-lifecycle.js").clearPluginMetadataLifecycleCaches; -let clearLoadPluginMetadataSnapshotMemo: typeof import("./plugin-metadata-snapshot.js").clearLoadPluginMetadataSnapshotMemo; function expectResolvedCapabilityProviderIds(providers: Array<{ id: string }>, expected: string[]) { expect(providers.map((provider) => provider.id)).toEqual(expected); @@ -292,11 +291,9 @@ describe("resolvePluginCapabilityProviders", () => { ({ clearCurrentPluginMetadataSnapshot, setCurrentPluginMetadataSnapshot } = await import("./current-plugin-metadata-snapshot.js")); ({ clearPluginMetadataLifecycleCaches } = await import("./plugin-metadata-lifecycle.js")); - ({ clearLoadPluginMetadataSnapshotMemo } = await import("./plugin-metadata-snapshot.js")); }); beforeEach(() => { - clearLoadPluginMetadataSnapshotMemo(); clearCurrentPluginMetadataSnapshot(); clearPluginMetadataLifecycleCaches(); mocks.resolveRuntimePluginRegistry.mockReset(); @@ -319,7 +316,6 @@ describe("resolvePluginCapabilityProviders", () => { afterEach(() => { clearCurrentPluginMetadataSnapshot(); - clearLoadPluginMetadataSnapshotMemo(); }); it("resolves bundled capability plugins from the current metadata snapshot", () => { diff --git a/src/plugins/plugin-lookup-table.test.ts b/src/plugins/plugin-lookup-table.test.ts index a002a61e6e65..7b004ec38ca5 100644 --- a/src/plugins/plugin-lookup-table.test.ts +++ b/src/plugins/plugin-lookup-table.test.ts @@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js"; import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js"; -import { clearLoadPluginMetadataSnapshotMemo } from "./plugin-metadata-snapshot.js"; import type { PluginRegistrySnapshot } from "./plugin-registry.js"; const listPotentialConfiguredChannelIds = vi.hoisted(() => vi.fn()); @@ -160,7 +159,6 @@ async function expectStaleMetadataSnapshotRebuild(params: { describe("loadPluginLookUpTable", () => { beforeEach(() => { - clearLoadPluginMetadataSnapshotMemo(); listPotentialConfiguredChannelIds .mockReset() .mockImplementation((config: OpenClawConfig) => Object.keys(config.channels ?? {})); diff --git a/src/plugins/plugin-metadata-snapshot.memo.test.ts b/src/plugins/plugin-metadata-snapshot.memo.test.ts deleted file mode 100644 index 3e30391638c6..000000000000 --- a/src/plugins/plugin-metadata-snapshot.memo.test.ts +++ /dev/null @@ -1,1070 +0,0 @@ -// Verifies plugin metadata snapshot memoization behavior. -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 { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js"; -import { - clearCurrentPluginMetadataSnapshot, - setCurrentPluginMetadataSnapshot, -} from "./current-plugin-metadata-snapshot.js"; -import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js"; -import { writePersistedInstalledPluginIndexSync } from "./installed-plugin-index-store.js"; -import type { - InstalledPluginIndex, - InstalledPluginInstallRecordInfo, -} from "./installed-plugin-index.js"; -import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js"; -import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js"; -import { - clearLoadPluginMetadataSnapshotMemo, - loadPluginMetadataSnapshot, - resolvePluginMetadataSnapshot, -} from "./plugin-metadata-snapshot.js"; - -const loadPluginRegistrySnapshotWithMetadata = vi.hoisted(() => vi.fn()); -const loadPluginManifestRegistryForInstalledIndex = vi.hoisted(() => vi.fn()); - -vi.mock("./plugin-registry.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadPluginRegistrySnapshotWithMetadata: (params: unknown) => - loadPluginRegistrySnapshotWithMetadata(params), - }; -}); - -vi.mock("./manifest-registry-installed.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadPluginManifestRegistryForInstalledIndex: (params: unknown) => - loadPluginManifestRegistryForInstalledIndex(params), - }; -}); - -const tempDirs: string[] = []; - -function tempStateDir(): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-metadata-memo-")); - tempDirs.push(dir); - return dir; -} - -function touchPersistedIndex(stateDir: string, value = 1): void { - runOpenClawStateWriteTransaction( - ({ db }) => { - db.prepare( - ` - INSERT OR REPLACE INTO installed_plugin_index ( - index_key, version, host_contract_version, compat_registry_version, - migration_version, policy_hash, generated_at_ms, refresh_reason, - install_records_json, plugins_json, diagnostics_json, warning, updated_at_ms - ) VALUES ( - 'installed-plugin-index', 1, 'test', 'test', - 1, @policy_hash, @generated_at_ms, NULL, - '{}', '[]', '[]', NULL, @generated_at_ms - ) - `, - ).run({ - policy_hash: `test-${value}`, - generated_at_ms: value, - }); - }, - { env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } }, - ); -} - -function writeJson(filePath: string, value: unknown): void { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`); -} - -function writePersistedIndex(params: { - manifestPath?: string; - packageJsonPath?: string; - pluginId: string; - source?: string; - setupSource?: string; - stateDir: string; -}): void { - const pluginDir = path.join(params.stateDir, "extensions", params.pluginId); - const manifestPath = params.manifestPath ?? path.join(pluginDir, "openclaw.plugin.json"); - const packageJsonPath = params.packageJsonPath ?? path.join(pluginDir, "package.json"); - writePersistedInstalledPluginIndexSync( - { - version: 1, - hostContractVersion: "test", - compatRegistryVersion: "test", - migrationVersion: 1, - policyHash: "test", - generatedAtMs: 1, - installRecords: {}, - diagnostics: [], - plugins: [ - { - pluginId: params.pluginId, - manifestPath, - manifestHash: `${params.pluginId}-manifest`, - rootDir: pluginDir, - ...(params.source ? { source: params.source } : {}), - ...(params.setupSource ? { setupSource: params.setupSource } : {}), - origin: "global", - enabled: true, - packageJson: { path: "package.json", hash: `${params.pluginId}-package` }, - startup: { - sidecar: false, - memory: false, - deferConfiguredChannelFullLoadUntilAfterListen: false, - agentHarnesses: [], - }, - compat: [], - }, - ], - }, - { stateDir: params.stateDir }, - ); - writeJson(manifestPath, { id: params.pluginId }); - writeJson(packageJsonPath, { name: params.pluginId }); -} - -function writeRecoverableNpmPlugin(params: { - packageName: string; - pluginId: string; - stateDir: string; - version: string; - writeRootManifest?: boolean; -}): void { - const packageDir = path.join(params.stateDir, "npm", "node_modules", params.packageName); - if (params.writeRootManifest !== false) { - writeJson(path.join(params.stateDir, "npm", "package.json"), { - dependencies: { - [params.packageName]: "1.0.0", - }, - }); - } - writeJson(path.join(packageDir, "package.json"), { - name: params.packageName, - version: params.version, - openclaw: { - extensions: ["."], - }, - }); - writeJson(path.join(packageDir, "openclaw.plugin.json"), { id: params.pluginId }); -} - -function writePersistedInstallRecords( - stateDir: string, - installRecords: Record, -): void { - writePersistedInstalledPluginIndexSync( - { - version: 1, - hostContractVersion: "test", - compatRegistryVersion: "test", - migrationVersion: 1, - policyHash: "test", - generatedAtMs: 1, - installRecords, - diagnostics: [], - plugins: [], - }, - { stateDir }, - ); -} - -function makeIndex( - pluginId = "demo", - options: { - manifestPath?: string; - rootDir?: string; - } = {}, -): InstalledPluginIndex { - const rootDir = options.rootDir ?? `/plugins/${pluginId}`; - const manifestPath = options.manifestPath ?? path.join(rootDir, "openclaw.plugin.json"); - return { - version: 1, - hostContractVersion: "test", - compatRegistryVersion: "test", - migrationVersion: 1, - policyHash: "test", - generatedAtMs: 1, - installRecords: {}, - diagnostics: [], - plugins: [ - { - pluginId, - manifestPath, - manifestHash: `${pluginId}-manifest`, - rootDir, - origin: "global", - enabled: true, - startup: { - sidecar: false, - memory: false, - deferConfiguredChannelFullLoadUntilAfterListen: false, - agentHarnesses: [], - }, - compat: [], - }, - ], - }; -} - -function makeManifestRegistry(pluginId = "demo"): PluginManifestRegistry { - const plugin: PluginManifestRecord = { - id: pluginId, - name: pluginId, - channels: [], - providers: [pluginId], - cliBackends: [], - skills: [], - hooks: [], - commandAliases: [{ name: `${pluginId}-command` }], - rootDir: `/plugins/${pluginId}`, - source: `/plugins/${pluginId}/index.js`, - manifestPath: `/plugins/${pluginId}/openclaw.plugin.json`, - origin: "global", - }; - return { plugins: [plugin], diagnostics: [] }; -} - -describe("loadPluginMetadataSnapshot process memo", () => { - beforeEach(() => { - clearLoadPluginMetadataSnapshotMemo(); - loadPluginRegistrySnapshotWithMetadata.mockReset(); - loadPluginManifestRegistryForInstalledIndex.mockReset(); - loadPluginManifestRegistryForInstalledIndex.mockReturnValue(makeManifestRegistry()); - }); - - afterEach(() => { - clearLoadPluginMetadataSnapshotMemo(); - clearCurrentPluginMetadataSnapshot(); - for (const dir of tempDirs.splice(0)) { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - it("reuses persisted metadata snapshots for repeated process lookups", () => { - const stateDir = tempStateDir(); - touchPersistedIndex(stateDir); - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "persisted", - snapshot: makeIndex(), - diagnostics: [], - }); - - const first = loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - const second = loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - const third = loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - - expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce(); - expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce(); - expect(second).toBe(first); - expect(third).toBe(first); - expect(() => third.plugins[0]?.providers.push("mutated")).toThrow(); - expect(() => { - if (third.plugins[0]?.commandAliases?.[0]) { - third.plugins[0].commandAliases[0].name = "mutated"; - } - }).toThrow(); - expect(third.plugins[0]?.providers).toEqual(["demo"]); - expect(third.plugins[0]?.commandAliases?.[0]?.name).toBe("demo-command"); - expect(second.manifestRegistry.plugins[0]).toBe(second.plugins[0]); - expect(second.byPluginId.get("demo")).toBe(second.plugins[0]); - }); - - it("does not emit metadata scan spans for hot memo hits", () => { - const stateDir = tempStateDir(); - const timelinePath = path.join(stateDir, "timeline", "metadata.jsonl"); - const env = { - OPENCLAW_DIAGNOSTICS: "timeline", - OPENCLAW_DIAGNOSTICS_TIMELINE_PATH: timelinePath, - }; - touchPersistedIndex(stateDir); - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "persisted", - snapshot: makeIndex(), - diagnostics: [], - }); - - loadPluginMetadataSnapshot({ config: {}, env, stateDir }); - loadPluginMetadataSnapshot({ config: {}, env, stateDir }); - loadPluginMetadataSnapshot({ config: {}, env, stateDir }); - - const events = fs - .readFileSync(timelinePath, "utf8") - .trim() - .split("\n") - .map((line) => JSON.parse(line) as { name?: unknown; type?: unknown }); - expect(events.map((event) => [event.type, event.name])).toEqual([ - ["span.start", "plugins.metadata.scan"], - ["span.end", "plugins.metadata.scan"], - ]); - expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce(); - }); - - it("skips persisted registry filesystem fingerprints after a process memo hit", () => { - const stateDir = tempStateDir(); - touchPersistedIndex(stateDir); - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "persisted", - snapshot: makeIndex(), - diagnostics: [], - }); - - const first = loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - const statSpy = vi.spyOn(fs, "statSync"); - const readdirSpy = vi.spyOn(fs, "readdirSync"); - try { - const second = loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - - expect(second).toBe(first); - expect(statSpy).not.toHaveBeenCalled(); - expect(readdirSpy).not.toHaveBeenCalled(); - } finally { - statSpy.mockRestore(); - readdirSpy.mockRestore(); - } - }); - - it("clears the process memo at plugin metadata lifecycle boundaries", () => { - const stateDir = tempStateDir(); - touchPersistedIndex(stateDir); - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "persisted", - snapshot: makeIndex(), - diagnostics: [], - }); - - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - clearPluginMetadataLifecycleCaches(); - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - - expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledTimes(2); - expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledTimes(2); - }); - - it("prepares provider endpoint and request facts with the metadata snapshot", () => { - const index = makeIndex("demo"); - const registry = makeManifestRegistry("demo"); - const plugin = registry.plugins[0]; - if (!plugin) { - throw new Error("expected manifest plugin fixture"); - } - plugin.providerEndpoints = [ - { - endpointClass: "openai-public", - hosts: [" API.EXAMPLE.COM "], - baseUrls: ["https://api.example.com/v1/"], - googleVertexRegion: " global ", - googleVertexRegionHostSuffix: " -AIPLATFORM.GOOGLEAPIS.COM ", - }, - ]; - plugin.providerRequest = { - providers: { - demo: { - family: " demo-family ", - compatibilityFamily: " moonshot " as never, - openAICompletions: { supportsStreamingUsage: true }, - }, - }, - }; - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "provided", - snapshot: index, - diagnostics: [], - }); - loadPluginManifestRegistryForInstalledIndex.mockReturnValue(registry); - - const snapshot = loadPluginMetadataSnapshot({ config: {}, env: {}, index }); - - expect(snapshot.owners.providerEndpoints).toContainEqual({ - endpointClass: "openai-public", - hosts: ["api.example.com"], - hostSuffixes: [], - baseUrls: ["https://api.example.com/v1"], - googleVertexRegion: "global", - googleVertexRegionHostSuffix: "-aiplatform.googleapis.com", - }); - expect(snapshot.owners.providerRequests?.get("demo")).toEqual({ - family: "demo-family", - compatibilityFamily: "moonshot", - openAICompletions: { supportsStreamingUsage: true }, - }); - }); - - it("ignores malformed optional provider facts", () => { - const index = makeIndex("demo"); - const registry = makeManifestRegistry("demo"); - const plugin = registry.plugins[0]; - if (!plugin) { - throw new Error("expected manifest plugin fixture"); - } - plugin.providerEndpoints = [ - { endpointClass: "openai-public", hosts: { invalid: true } } as never, - null as never, - ]; - plugin.providerRequest = { providers: { demo: null } } as never; - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "provided", - snapshot: index, - diagnostics: [], - }); - loadPluginManifestRegistryForInstalledIndex.mockReturnValue(registry); - - const snapshot = loadPluginMetadataSnapshot({ config: {}, env: {}, index }); - - expect(snapshot.owners.providerRequests?.has("demo")).toBe(false); - expect( - snapshot.owners.providerEndpoints?.some((endpoint) => (endpoint.hosts ?? []).length === 0), - ).toBe(true); - }); - - it("keeps scoped and unscoped metadata snapshots in separate memo slots", () => { - const index = makeIndex(); - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "provided", - snapshot: index, - diagnostics: [], - }); - - const scoped = loadPluginMetadataSnapshot({ - config: {}, - env: {}, - index, - pluginIds: ["demo"], - }); - const unscoped = loadPluginMetadataSnapshot({ - config: {}, - env: {}, - index, - }); - - expect(scoped.pluginIds).toEqual(["demo"]); - expect(unscoped.pluginIds).toBeUndefined(); - expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledTimes(2); - expect(loadPluginManifestRegistryForInstalledIndex.mock.calls[0]?.[0]).toMatchObject({ - pluginIds: ["demo"], - }); - expect(loadPluginManifestRegistryForInstalledIndex.mock.calls[1]?.[0]).not.toHaveProperty( - "pluginIds", - ); - }); - - it("keeps hot persisted snapshots for alternating config callers", () => { - const stateDir = tempStateDir(); - touchPersistedIndex(stateDir); - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "persisted", - snapshot: makeIndex(), - diagnostics: [], - }); - - loadPluginMetadataSnapshot({ - config: { plugins: { allow: ["demo"] } }, - env: {}, - stateDir, - }); - loadPluginMetadataSnapshot({ - config: { plugins: { allow: ["other"] } }, - env: {}, - stateDir, - }); - loadPluginMetadataSnapshot({ - config: { plugins: { allow: ["demo"] } }, - env: {}, - stateDir, - }); - - expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledTimes(2); - expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledTimes(2); - }); - - it("reuses workspace-scoped current snapshots when the caller opts in", () => { - const index = makeIndex(); - index.policyHash = resolveInstalledPluginIndexPolicyHash({}); - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "runtime", - snapshot: index, - diagnostics: [], - }); - const snapshot = loadPluginMetadataSnapshot({ - config: {}, - env: {}, - index, - workspaceDir: "/workspace/a", - }); - setCurrentPluginMetadataSnapshot(snapshot, { - config: {}, - env: {}, - workspaceDir: "/workspace/a", - }); - loadPluginRegistrySnapshotWithMetadata.mockClear(); - loadPluginManifestRegistryForInstalledIndex.mockClear(); - - expect( - resolvePluginMetadataSnapshot({ - config: {}, - env: {}, - allowWorkspaceScopedCurrent: true, - }), - ).toBe(snapshot); - expect(loadPluginRegistrySnapshotWithMetadata).not.toHaveBeenCalled(); - expect(loadPluginManifestRegistryForInstalledIndex).not.toHaveBeenCalled(); - }); - - it("reuses compatible current snapshots without reloading metadata", () => { - const sourceConfig = { plugins: { allow: ["demo"] } }; - const compatibleConfig = { plugins: { entries: { demo: { enabled: true } } } }; - const index = makeIndex(); - index.policyHash = resolveInstalledPluginIndexPolicyHash(sourceConfig); - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "runtime", - snapshot: index, - diagnostics: [], - }); - const snapshot = loadPluginMetadataSnapshot({ - config: sourceConfig, - env: {}, - index, - workspaceDir: "/workspace/a", - }); - setCurrentPluginMetadataSnapshot(snapshot, { - config: sourceConfig, - compatibleConfigs: [compatibleConfig], - env: {}, - workspaceDir: "/workspace/a", - }); - loadPluginRegistrySnapshotWithMetadata.mockClear(); - loadPluginManifestRegistryForInstalledIndex.mockClear(); - - expect( - resolvePluginMetadataSnapshot({ - config: compatibleConfig, - env: {}, - workspaceDir: "/workspace/a", - }), - ).toBe(snapshot); - expect(loadPluginRegistrySnapshotWithMetadata).not.toHaveBeenCalled(); - expect(loadPluginManifestRegistryForInstalledIndex).not.toHaveBeenCalled(); - }); - - it("does not reuse an unscoped current snapshot for a scoped resolver request", () => { - const index = makeIndex("demo"); - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "runtime", - snapshot: index, - diagnostics: [], - }); - loadPluginManifestRegistryForInstalledIndex.mockImplementation( - ({ pluginIds }: { pluginIds?: readonly string[] }) => ({ - ...makeManifestRegistry(pluginIds?.[0] ?? "demo"), - plugins: pluginIds?.map((pluginId) => makeManifestRegistry(pluginId).plugins[0]) ?? [ - makeManifestRegistry("demo").plugins[0], - ], - }), - ); - const unscoped = loadPluginMetadataSnapshot({ - config: {}, - env: {}, - index, - }); - setCurrentPluginMetadataSnapshot(unscoped, { - config: {}, - env: {}, - }); - loadPluginManifestRegistryForInstalledIndex.mockClear(); - - const scoped = resolvePluginMetadataSnapshot({ - config: {}, - env: {}, - index, - pluginIdScope: { - key: "demo-only", - resolve: () => ["demo"], - }, - }); - - expect(scoped).not.toBe(unscoped); - expect(scoped.pluginIds).toEqual(["demo"]); - expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledWith( - expect.objectContaining({ pluginIds: ["demo"] }), - ); - }); - - it("does not scan persisted registry files when the caller provides an index", () => { - const stateDir = tempStateDir(); - writePersistedIndex({ pluginId: "demo", stateDir }); - const index = makeIndex(); - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "provided", - snapshot: index, - diagnostics: [], - }); - loadPluginMetadataSnapshot({ config: {}, env: {}, index, stateDir }); - const statSpy = vi.spyOn(fs, "statSync"); - const readSpy = vi.spyOn(fs, "readFileSync"); - - try { - loadPluginMetadataSnapshot({ config: {}, env: {}, index, stateDir }); - } finally { - statSpy.mockRestore(); - readSpy.mockRestore(); - } - - expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce(); - expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce(); - expect(statSpy).not.toHaveBeenCalled(); - expect(readSpy).not.toHaveBeenCalled(); - }); - - it("does not freeze caller-owned provided index records", () => { - const stateDir = tempStateDir(); - const index = makeIndex(); - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "provided", - snapshot: index, - diagnostics: [], - }); - - const snapshot = loadPluginMetadataSnapshot({ config: {}, env: {}, index, stateDir }); - const callerRecord = index.plugins[0]; - const snapshotRecord = snapshot.index.plugins[0]; - if (!callerRecord || !snapshotRecord) { - throw new Error("expected metadata records"); - } - - expect(() => { - callerRecord.pluginId = "caller-mutated"; - callerRecord.startup.agentHarnesses = ["caller-mutated"]; - }).not.toThrow(); - expect(snapshot.index.plugins[0]?.pluginId).toBe("demo"); - expect(snapshot.index.plugins[0]?.startup.agentHarnesses).toEqual([]); - expect(() => { - snapshotRecord.pluginId = "snapshot-mutated"; - }).toThrow(); - }); - - it("memoizes policy-stale derived snapshots within the process", () => { - const stateDir = tempStateDir(); - touchPersistedIndex(stateDir); - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "derived", - snapshot: makeIndex(), - diagnostics: [ - { - level: "warn", - code: "persisted-registry-stale-policy", - message: "policy changed", - }, - ], - }); - - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - - expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce(); - }); - - it("memoizes derived snapshots across alternating call shapes", () => { - const stateDir = tempStateDir(); - touchPersistedIndex(stateDir); - const workspaceDir = path.join(stateDir, "workspace"); - // Two call shapes share a persisted index but derive different snapshot - // indexes, like the model-catalog build alternating workspace-scoped and - // global lookups. Store keys re-derived from snapshot.index never match the - // next lookup, so every alternation re-ran the full manifest scan. - loadPluginRegistrySnapshotWithMetadata.mockImplementation( - (params: { workspaceDir?: string }) => ({ - source: "derived", - snapshot: makeIndex(params.workspaceDir ? "alpha" : "beta"), - diagnostics: [], - }), - ); - - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir, workspaceDir }); - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir, workspaceDir }); - - expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledTimes(2); - }); - - it("keeps process-stable derived snapshots when derived plugin files change", () => { - const stateDir = tempStateDir(); - touchPersistedIndex(stateDir); - const pluginDir = path.join(stateDir, "current", "derived"); - const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); - writeJson(manifestPath, { id: "derived", version: "1.0.0" }); - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "derived", - snapshot: makeIndex("derived", { manifestPath, rootDir: pluginDir }), - diagnostics: [ - { - level: "warn", - code: "persisted-registry-stale-policy", - message: "policy changed", - }, - ], - }); - loadPluginManifestRegistryForInstalledIndex.mockReturnValue(makeManifestRegistry("derived")); - - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - writeJson(manifestPath, { id: "derived", version: "2.0.0", commandAliases: [{ name: "new" }] }); - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - - expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce(); - expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce(); - }); - - it.each([ - ["persisted-registry-missing", undefined], - ["persisted-registry-stale-source", undefined], - [undefined, { preferPersisted: false }], - ])("memoizes derived snapshots for %s diagnostics within the process", (code, options) => { - const stateDir = tempStateDir(); - touchPersistedIndex(stateDir); - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "derived", - snapshot: makeIndex(), - diagnostics: code ? [{ level: "warn", code, message: "registry not reusable" }] : [], - }); - - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir, ...options }); - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir, ...options }); - - expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce(); - }); - - it("keeps persisted registry snapshots process-stable until lifecycle clear", () => { - const stateDir = tempStateDir(); - touchPersistedIndex(stateDir, 1); - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "persisted", - snapshot: makeIndex(), - diagnostics: [], - }); - - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - touchPersistedIndex(stateDir, 22); - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - - expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce(); - - clearPluginMetadataLifecycleCaches(); - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - - expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledTimes(2); - }); - - it("reuses the expanded freshness fingerprint on hot cache hits", () => { - const stateDir = tempStateDir(); - const manifestPath = path.join(stateDir, "extensions", "demo", "openclaw.plugin.json"); - writePersistedIndex({ manifestPath, pluginId: "demo", stateDir }); - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "persisted", - snapshot: makeIndex(), - diagnostics: [], - }); - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - const readSpy = vi.spyOn(fs, "readFileSync"); - - try { - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - } finally { - readSpy.mockRestore(); - } - - expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledTimes(1); - expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledTimes(1); - expect(readSpy).not.toHaveBeenCalled(); - }); - - it.each([ - ["manifest", "openclaw.plugin.json", "manifestPath"], - ["source", "index.js", "source"], - ["setup source", "setup.js", "setupSource"], - ["package manifest", "package.json", "packageJsonPath"], - ])("requires reload before persisted plugin %s edits are visible", (_, fileName, field) => { - const stateDir = tempStateDir(); - const filePath = path.join(stateDir, "extensions", "demo", fileName); - writePersistedIndex({ [field]: filePath, pluginId: "demo", stateDir }); - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "persisted", - snapshot: makeIndex(), - diagnostics: [], - }); - - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - writeJson(filePath, { id: "demo", version: "0.2.0" }); - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - - expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce(); - expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce(); - }); - - it.each([ - [ - "install path package manifest", - "~/tracked-plugin", - (recordPath: string) => - ({ source: "path", installPath: recordPath }) satisfies InstalledPluginInstallRecordInfo, - (homeDir: string) => path.join(homeDir, "tracked-plugin", "package.json"), - ], - [ - "source path package manifest", - "~/tracked-plugin", - (recordPath: string) => - ({ source: "path", sourcePath: recordPath }) satisfies InstalledPluginInstallRecordInfo, - (homeDir: string) => path.join(homeDir, "tracked-plugin", "package.json"), - ], - ])( - "requires reload before home-relative install record %s changes are visible", - (_, recordPath, record, targetPath) => { - const stateDir = tempStateDir(); - const homeDir = path.join(stateDir, "home"); - const filePath = targetPath(homeDir); - writePersistedInstallRecords(stateDir, { demo: record(recordPath) }); - writeJson(filePath, { version: "1.0.0" }); - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "persisted", - snapshot: makeIndex(), - diagnostics: [], - }); - - loadPluginMetadataSnapshot({ config: {}, env: { HOME: homeDir }, stateDir }); - writeJson(filePath, { version: "1.0.1000" }); - loadPluginMetadataSnapshot({ config: {}, env: { HOME: homeDir }, stateDir }); - - expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce(); - expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce(); - }, - ); - - it("does not reuse home-relative install record memo state across env changes", () => { - const stateDir = tempStateDir(); - const firstHomeDir = path.join(stateDir, "first-home"); - const secondHomeDir = path.join(stateDir, "second-home"); - const firstPackageJsonPath = path.join(firstHomeDir, "tracked-plugin", "package.json"); - const secondPackageJsonPath = path.join(secondHomeDir, "tracked-plugin", "package.json"); - writePersistedInstallRecords(stateDir, { - demo: { source: "path", installPath: "~/tracked-plugin" }, - }); - writeJson(firstPackageJsonPath, { version: "1.0.0" }); - writeJson(secondPackageJsonPath, { version: "1.0.0" }); - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "persisted", - snapshot: makeIndex(), - diagnostics: [], - }); - - loadPluginMetadataSnapshot({ config: {}, env: { HOME: firstHomeDir }, stateDir }); - loadPluginMetadataSnapshot({ config: {}, env: { HOME: secondHomeDir }, stateDir }); - writeJson(secondPackageJsonPath, { version: "1.0.1000" }); - loadPluginMetadataSnapshot({ config: {}, env: { HOME: secondHomeDir }, stateDir }); - - expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledTimes(2); - expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledTimes(2); - }); - - it("requires reload before recovered managed npm package metadata changes are visible", () => { - const stateDir = tempStateDir(); - writeRecoverableNpmPlugin({ - packageName: "recovered-plugin", - pluginId: "recovered", - stateDir, - version: "1.0.0", - }); - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "persisted", - snapshot: makeIndex(), - diagnostics: [], - }); - - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - writeRecoverableNpmPlugin({ - packageName: "recovered-plugin", - pluginId: "recovered", - stateDir, - version: "1.0.10", - writeRootManifest: false, - }); - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - - expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce(); - expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce(); - }); - - it("requires reload before a declared recovered managed npm package appears", () => { - const stateDir = tempStateDir(); - writeJson(path.join(stateDir, "npm", "package.json"), { - dependencies: { - "late-plugin": "1.0.0", - }, - }); - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "persisted", - snapshot: makeIndex(), - diagnostics: [], - }); - - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - writeRecoverableNpmPlugin({ - packageName: "late-plugin", - pluginId: "late-plugin", - stateDir, - version: "1.0.0", - writeRootManifest: false, - }); - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - - expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce(); - expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce(); - }); - - it("keeps npm project package fingerprints stable across directory order changes", () => { - const stateDir = tempStateDir(); - const projectsDir = path.join(stateDir, "npm", "projects"); - writeJson(path.join(projectsDir, "zeta", "package.json"), { name: "zeta", version: "1.0.0" }); - writeJson(path.join(projectsDir, "alpha", "package.json"), { name: "alpha", version: "1.0.0" }); - touchPersistedIndex(stateDir); - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "persisted", - snapshot: makeIndex(), - diagnostics: [], - }); - const originalReaddirSync = fs.readdirSync.bind(fs); - let reverseProjectEntries = false; - const readdirSpy = vi.spyOn(fs, "readdirSync").mockImplementation((( - directoryPath: fs.PathLike, - options?: Parameters[1], - ): unknown => { - const entries = originalReaddirSync(directoryPath, options as never); - if (directoryPath === projectsDir && reverseProjectEntries && Array.isArray(entries)) { - return entries.toReversed(); - } - return entries; - }) as unknown as typeof fs.readdirSync); - - try { - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - reverseProjectEntries = true; - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - } finally { - readdirSpy.mockRestore(); - } - - expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce(); - expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce(); - }); - - it("requires reload before an in-root package manifest symlink target change is visible", () => { - const stateDir = tempStateDir(); - const pluginDir = path.join(stateDir, "extensions", "demo"); - const packageJsonPath = path.join(pluginDir, "package.json"); - const outsidePackageJsonPath = path.join(stateDir, "outside", "package.json"); - fs.mkdirSync(pluginDir, { recursive: true }); - writeJson(outsidePackageJsonPath, { name: "outside", version: "1.0.0" }); - fs.symlinkSync(outsidePackageJsonPath, packageJsonPath); - writePersistedIndex({ packageJsonPath, pluginId: "demo", stateDir }); - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "persisted", - snapshot: makeIndex(), - diagnostics: [], - }); - - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - writeJson(outsidePackageJsonPath, { name: "outside", version: "1.0.1" }); - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - - expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce(); - expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce(); - }); - - it("does not fingerprint persisted plugin paths outside the plugin root", () => { - const stateDir = tempStateDir(); - const outsideManifestPath = path.join(stateDir, "outside", "openclaw.plugin.json"); - const outsideSourcePath = path.join(stateDir, "outside", "index.js"); - writePersistedIndex({ - manifestPath: outsideManifestPath, - pluginId: "demo", - source: outsideSourcePath, - stateDir, - }); - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "persisted", - snapshot: makeIndex(), - diagnostics: [], - }); - const statSpy = vi.spyOn(fs, "statSync"); - const readSpy = vi.spyOn(fs, "readFileSync"); - - try { - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - } finally { - statSpy.mockRestore(); - readSpy.mockRestore(); - } - - expect(statSpy.mock.calls.some(([filePath]) => filePath === outsideManifestPath)).toBe(false); - expect(statSpy.mock.calls.some(([filePath]) => filePath === outsideSourcePath)).toBe(false); - expect(readSpy.mock.calls.some(([filePath]) => filePath === outsideManifestPath)).toBe(false); - expect(readSpy.mock.calls.some(([filePath]) => filePath === outsideSourcePath)).toBe(false); - }); - - it("does not hash symlinked persisted plugin files that escape the plugin root", () => { - const stateDir = tempStateDir(); - const pluginDir = path.join(stateDir, "extensions", "demo"); - const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); - const outsideManifestPath = path.join(stateDir, "outside", "openclaw.plugin.json"); - fs.mkdirSync(pluginDir, { recursive: true }); - writeJson(outsideManifestPath, { id: "outside" }); - fs.symlinkSync(outsideManifestPath, manifestPath); - writePersistedInstalledPluginIndexSync( - { - version: 1, - hostContractVersion: "test", - compatRegistryVersion: "test", - migrationVersion: 1, - policyHash: "test", - generatedAtMs: 1, - installRecords: {}, - diagnostics: [], - plugins: [ - { - pluginId: "demo", - manifestPath, - manifestHash: "demo-manifest", - rootDir: pluginDir, - origin: "global", - enabled: true, - startup: { - sidecar: false, - memory: false, - deferConfiguredChannelFullLoadUntilAfterListen: false, - agentHarnesses: [], - }, - compat: [], - }, - ], - }, - { stateDir }, - ); - loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "persisted", - snapshot: makeIndex(), - diagnostics: [], - }); - const readSpy = vi.spyOn(fs, "readFileSync"); - - try { - loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); - } finally { - readSpy.mockRestore(); - } - - expect(readSpy.mock.calls.some(([filePath]) => filePath === outsideManifestPath)).toBe(false); - }); -}); diff --git a/src/plugins/plugin-metadata-snapshot.test.ts b/src/plugins/plugin-metadata-snapshot.test.ts new file mode 100644 index 000000000000..0b38d01a3572 --- /dev/null +++ b/src/plugins/plugin-metadata-snapshot.test.ts @@ -0,0 +1,223 @@ +// Verifies lifecycle snapshot loading, ownership facts, and immutable boundaries. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearCurrentPluginMetadataSnapshot, + setCurrentPluginMetadataSnapshot, +} from "./current-plugin-metadata-snapshot.js"; +import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js"; +import type { InstalledPluginIndex } from "./installed-plugin-index.js"; +import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js"; +import { + loadPluginMetadataSnapshot, + resolvePluginMetadataSnapshot, +} from "./plugin-metadata-snapshot.js"; + +const loadPluginRegistrySnapshotWithMetadata = vi.hoisted(() => vi.fn()); +const loadPluginManifestRegistryForInstalledIndex = vi.hoisted(() => vi.fn()); + +vi.mock("./plugin-registry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadPluginRegistrySnapshotWithMetadata: (params: unknown) => + loadPluginRegistrySnapshotWithMetadata(params), + }; +}); + +vi.mock("./manifest-registry-installed.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadPluginManifestRegistryForInstalledIndex: (params: unknown) => + loadPluginManifestRegistryForInstalledIndex(params), + }; +}); + +function makeIndex(pluginId = "demo"): InstalledPluginIndex { + const rootDir = `/plugins/${pluginId}`; + return { + version: 1, + hostContractVersion: "test", + compatRegistryVersion: "test", + migrationVersion: 1, + policyHash: "test", + generatedAtMs: 1, + installRecords: {}, + diagnostics: [], + plugins: [ + { + pluginId, + manifestPath: `${rootDir}/openclaw.plugin.json`, + manifestHash: `${pluginId}-manifest`, + rootDir, + origin: "global", + enabled: true, + startup: { + sidecar: false, + memory: false, + deferConfiguredChannelFullLoadUntilAfterListen: false, + agentHarnesses: [], + }, + compat: [], + }, + ], + }; +} + +function makeManifestRegistry(pluginId = "demo"): PluginManifestRegistry { + const plugin: PluginManifestRecord = { + id: pluginId, + name: pluginId, + channels: [], + providers: [pluginId], + cliBackends: [], + skills: [], + hooks: [], + commandAliases: [{ name: `${pluginId}-command` }], + rootDir: `/plugins/${pluginId}`, + source: `/plugins/${pluginId}/index.js`, + manifestPath: `/plugins/${pluginId}/openclaw.plugin.json`, + origin: "global", + }; + return { plugins: [plugin], diagnostics: [] }; +} + +describe("plugin metadata snapshot", () => { + beforeEach(() => { + loadPluginRegistrySnapshotWithMetadata.mockReset(); + loadPluginManifestRegistryForInstalledIndex.mockReset(); + loadPluginManifestRegistryForInstalledIndex.mockReturnValue(makeManifestRegistry()); + }); + + afterEach(() => { + clearCurrentPluginMetadataSnapshot(); + }); + + it("keeps explicit control-plane loads fresh", () => { + const index = makeIndex(); + loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ + source: "provided", + snapshot: index, + diagnostics: [], + }); + + const first = loadPluginMetadataSnapshot({ config: {}, env: {}, index }); + const second = loadPluginMetadataSnapshot({ config: {}, env: {}, index }); + + expect(second).not.toBe(first); + expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledTimes(2); + expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledTimes(2); + }); + + it("reuses the lifecycle-owned current snapshot", () => { + const config = {}; + const index = makeIndex(); + index.policyHash = resolveInstalledPluginIndexPolicyHash(config); + loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ + source: "provided", + snapshot: index, + diagnostics: [], + }); + const snapshot = loadPluginMetadataSnapshot({ config, env: {}, index }); + setCurrentPluginMetadataSnapshot(snapshot, { config, env: {} }); + loadPluginRegistrySnapshotWithMetadata.mockClear(); + loadPluginManifestRegistryForInstalledIndex.mockClear(); + + expect(resolvePluginMetadataSnapshot({ config, env: {} })).toBe(snapshot); + expect(loadPluginRegistrySnapshotWithMetadata).not.toHaveBeenCalled(); + expect(loadPluginManifestRegistryForInstalledIndex).not.toHaveBeenCalled(); + }); + + it("keeps scoped loads separate without an LRU", () => { + const index = makeIndex(); + loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ + source: "provided", + snapshot: index, + diagnostics: [], + }); + + const scoped = loadPluginMetadataSnapshot({ + config: {}, + env: {}, + index, + pluginIds: ["demo"], + }); + const unscoped = loadPluginMetadataSnapshot({ config: {}, env: {}, index }); + + expect(scoped.pluginIds).toEqual(["demo"]); + expect(unscoped.pluginIds).toBeUndefined(); + expect(loadPluginManifestRegistryForInstalledIndex.mock.calls[0]?.[0]).toMatchObject({ + pluginIds: ["demo"], + }); + expect(loadPluginManifestRegistryForInstalledIndex.mock.calls[1]?.[0]).not.toHaveProperty( + "pluginIds", + ); + }); + + it("prepares provider endpoint and request facts", () => { + const index = makeIndex(); + const registry = makeManifestRegistry(); + const plugin = registry.plugins[0]; + if (!plugin) { + throw new Error("expected manifest plugin fixture"); + } + plugin.providerEndpoints = [ + { + endpointClass: "openai-public", + hosts: [" API.EXAMPLE.COM "], + baseUrls: ["https://api.example.com/v1/"], + }, + ]; + plugin.providerRequest = { + providers: { + demo: { + family: " demo-family ", + compatibilityFamily: " moonshot " as never, + openAICompletions: { supportsStreamingUsage: true }, + }, + }, + }; + loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ + source: "provided", + snapshot: index, + diagnostics: [], + }); + loadPluginManifestRegistryForInstalledIndex.mockReturnValue(registry); + + const snapshot = loadPluginMetadataSnapshot({ config: {}, env: {}, index }); + + expect(snapshot.owners.providerEndpoints).toContainEqual({ + endpointClass: "openai-public", + hosts: ["api.example.com"], + hostSuffixes: [], + baseUrls: ["https://api.example.com/v1"], + }); + expect(snapshot.owners.providerRequests?.get("demo")).toEqual({ + family: "demo-family", + compatibilityFamily: "moonshot", + openAICompletions: { supportsStreamingUsage: true }, + }); + }); + + it("freezes a cloned index instead of caller-owned records", () => { + const index = makeIndex(); + loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ + source: "provided", + snapshot: index, + diagnostics: [], + }); + + const snapshot = loadPluginMetadataSnapshot({ config: {}, env: {}, index }); + const callerRecord = index.plugins[0]; + const snapshotRecord = snapshot.index.plugins[0]; + if (!callerRecord || !snapshotRecord) { + throw new Error("expected metadata records"); + } + + callerRecord.pluginId = "caller-mutated"; + expect(snapshotRecord.pluginId).toBe("demo"); + expect(() => { + snapshotRecord.pluginId = "snapshot-mutated"; + }).toThrow(); + }); +}); diff --git a/src/plugins/plugin-metadata-snapshot.ts b/src/plugins/plugin-metadata-snapshot.ts index 73d0cf7fd803..92e556d8615a 100644 --- a/src/plugins/plugin-metadata-snapshot.ts +++ b/src/plugins/plugin-metadata-snapshot.ts @@ -1,20 +1,12 @@ -import fs from "node:fs"; -import path from "node:path"; import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; -import { isRecord } from "@openclaw/normalization-core/record-coerce"; -import { resolveIsNixMode } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { getActiveDiagnosticsTimelineSpan, measureDiagnosticsTimelineSpanSync, } from "../infra/diagnostics-timeline.js"; -import { resolveUserPath } from "../utils.js"; -import { resolveCompatibilityHostVersion } from "../version.js"; import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js"; -import { resolveDefaultPluginNpmDir, resolvePluginNpmProjectsDir } from "./install-paths.js"; import { hashJson } from "./installed-plugin-index-hash.js"; import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js"; -import { readPersistedInstalledPluginIndexSync } from "./installed-plugin-index-store.js"; import type { InstalledPluginIndex } from "./installed-plugin-index.js"; import { loadPluginManifestRegistryForInstalledIndex, @@ -22,7 +14,6 @@ import { } from "./manifest-registry-installed.js"; import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js"; import { resolvePluginControlPlaneFingerprint } from "./plugin-control-plane-context.js"; -import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js"; import { buildPluginMetadataProviderFacts } from "./plugin-metadata-provider-facts.js"; import type { LoadPluginMetadataSnapshotParams, @@ -31,37 +22,10 @@ import type { ResolvePluginMetadataSnapshotParams, } from "./plugin-metadata-snapshot.types.js"; import { createPluginRegistryIdNormalizer } from "./plugin-registry-id-normalizer.js"; -import { - loadPluginRegistrySnapshotWithMetadata, - type PluginRegistrySnapshotSource, -} from "./plugin-registry.js"; +import { loadPluginRegistrySnapshotWithMetadata } from "./plugin-registry.js"; import { normalizePluginIdScope, serializePluginIdScope } from "./plugin-scope.js"; -import { fileFingerprint } from "./plugin-snapshot-fingerprint.js"; -type PluginMetadataSnapshotMemo = { - key: string; - lookupContextHash: string; - registryState?: PersistedRegistryMemoState; - snapshot: PluginMetadataSnapshot; -}; - -type PersistedRegistryMemoState = { - contextHash: string; - fastHash: string; - fingerprint: unknown; -}; - -const MAX_PLUGIN_METADATA_SNAPSHOT_MEMOS = 8; - -let pluginMetadataSnapshotMemos: PluginMetadataSnapshotMemo[] = []; - -export function clearLoadPluginMetadataSnapshotMemo(): void { - pluginMetadataSnapshotMemos = []; -} - -registerPluginMetadataProcessMemoLifecycleClear(clearLoadPluginMetadataSnapshotMemo); - -const MEMO_RELEVANT_ENV_KEYS = [ +const PLUGIN_METADATA_ENV_KEYS = [ "APPDATA", "HOME", "OPENCLAW_BUNDLED_PLUGINS_DIR", @@ -86,47 +50,17 @@ export type { ResolvePluginMetadataSnapshotParams, } from "./plugin-metadata-snapshot.types.js"; -function directoryChildPackageJsonFingerprint(directoryPath: string): unknown { - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(directoryPath, { withFileTypes: true }); - } catch { - return [directoryPath, "missing"]; - } - return [ - directoryPath, - ...entries - .filter((entry) => entry.isDirectory()) - .toSorted((a, b) => a.name.localeCompare(b.name)) - .map((entry) => fileFingerprint(path.join(directoryPath, entry.name, "package.json"))), - ]; -} - -function stableMemoValue(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(stableMemoValue); - } - if (!isRecord(value)) { - return value; - } +function pickPluginMetadataEnv(env: NodeJS.ProcessEnv): Record { return Object.fromEntries( - Object.entries(value) - .toSorted(([left], [right]) => left.localeCompare(right)) - .map(([key, entry]) => [key, stableMemoValue(entry)]), - ); -} - -function pickMemoRelevantEnv(env: NodeJS.ProcessEnv): Record { - return Object.fromEntries( - MEMO_RELEVANT_ENV_KEYS.flatMap((key) => { + PLUGIN_METADATA_ENV_KEYS.flatMap((key) => { const value = env[key]; return value === undefined ? [] : [[key, value]]; }), ); } -export function resolvePluginMetadataSnapshotMemoEnvFingerprint(env: NodeJS.ProcessEnv): string { - return hashJson(pickMemoRelevantEnv(env)); +export function resolvePluginMetadataEnvFingerprint(env: NodeJS.ProcessEnv): string { + return hashJson(pickPluginMetadataEnv(env)); } function throwReadonlyPluginMetadataMutation(): never { @@ -174,195 +108,6 @@ function freezePluginMetadataSnapshot(snapshot: PluginMetadataSnapshot): PluginM return freezeSnapshotValue(snapshot); } -function resolvePersistedRegistryFastMemoFingerprint(params: { - env: NodeJS.ProcessEnv; - preferPersisted?: boolean; - stateDir?: string; -}): Record { - const disabled = params.preferPersisted === false; - if (disabled) { - return { disabled: true }; - } - const npmRoot = params.stateDir - ? path.join(params.stateDir, "npm") - : resolveDefaultPluginNpmDir(params.env); - return { - index: hashJson( - stableMemoValue( - readPersistedInstalledPluginIndexSync({ - env: params.env, - ...(params.stateDir ? { stateDir: params.stateDir } : {}), - }), - ) ?? null, - ), - npmPackageJson: fileFingerprint(path.join(npmRoot, "package.json")), - npmProjectPackageJsons: directoryChildPackageJsonFingerprint( - resolvePluginNpmProjectsDir(npmRoot), - ), - }; -} - -function resolvePersistedRegistryMemoContextHash(params: { - env: NodeJS.ProcessEnv; - fastFingerprint: unknown; - preferPersisted?: boolean; - stateDir?: string; -}): string { - return hashJson({ - env: pickMemoRelevantEnv(params.env), - fastFingerprint: params.fastFingerprint, - preferPersisted: params.preferPersisted ?? null, - stateDir: params.stateDir ?? null, - }); -} - -function resolvePersistedRegistryMemoLookupContextHash(params: { - env: NodeJS.ProcessEnv; - preferPersisted?: boolean; - stateDir?: string; -}): string { - return hashJson({ - env: pickMemoRelevantEnv(params.env), - preferPersisted: params.preferPersisted ?? null, - stateDir: params.stateDir ?? null, - }); -} - -function resolvePersistedRegistryMemoState(params: { - env: NodeJS.ProcessEnv; - preferPersisted?: boolean; - stateDir?: string; -}): PersistedRegistryMemoState { - const fastFingerprint = resolvePersistedRegistryFastMemoFingerprint(params); - const fastHash = hashJson(fastFingerprint); - const contextHash = resolvePersistedRegistryMemoContextHash({ - ...params, - fastFingerprint, - }); - if (isRecord(fastFingerprint) && fastFingerprint.disabled === true) { - return { - contextHash, - fastHash, - fingerprint: fastFingerprint, - }; - } - const index = readPersistedInstalledPluginIndexSync({ - env: params.env, - ...(params.stateDir ? { stateDir: params.stateDir } : {}), - }); - return { - contextHash, - fastHash, - fingerprint: { - ...fastFingerprint, - indexHash: hashJson(stableMemoValue(index) ?? null), - }, - }; -} - -function resolvePersistedRegistryMemoStateForLookup( - params: { - env: NodeJS.ProcessEnv; - preferPersisted?: boolean; - stateDir?: string; - }, - memos: readonly PluginMetadataSnapshotMemo[], -): PersistedRegistryMemoState { - const lookupContextHash = resolvePersistedRegistryMemoLookupContextHash(params); - for (const memo of memos) { - if (memo.lookupContextHash === lookupContextHash && memo.registryState) { - // Gateway runtime metadata is process-stable. Installs/reloads clear the - // memo lifecycle explicitly, so hot lookups can reuse the prepared - // registry stamp instead of re-statting plugin roots on every turn. - return memo.registryState; - } - } - const fastFingerprint = resolvePersistedRegistryFastMemoFingerprint(params); - const fastHash = hashJson(fastFingerprint); - const contextHash = resolvePersistedRegistryMemoContextHash({ - ...params, - fastFingerprint, - }); - for (const memo of memos) { - const registryState = memo.registryState; - if ( - registryState && - registryState.contextHash === contextHash && - registryState.fastHash === fastHash - ) { - // Plugin files are immutable for a running gateway; plugin edits require - // an explicit reload/restart, so hot lookups only validate the registry envelope. - return registryState; - } - } - return resolvePersistedRegistryMemoState(params); -} - -function resolveProvidedIndexMemoState(index: InstalledPluginIndex): PersistedRegistryMemoState { - const fingerprint = { - providedIndex: resolveInstalledManifestRegistryIndexFingerprint(index), - }; - const fingerprintHash = hashJson(fingerprint); - return { - contextHash: fingerprintHash, - fastHash: fingerprintHash, - fingerprint, - }; -} - -function findPluginMetadataSnapshotMemo(key: string): PluginMetadataSnapshotMemo | undefined { - const index = pluginMetadataSnapshotMemos.findIndex((memo) => memo.key === key); - if (index === -1) { - return undefined; - } - const [memo] = pluginMetadataSnapshotMemos.splice(index, 1); - if (!memo) { - return undefined; - } - pluginMetadataSnapshotMemos.unshift(memo); - return memo; -} - -function rememberPluginMetadataSnapshotMemo(memo: PluginMetadataSnapshotMemo): void { - pluginMetadataSnapshotMemos = [ - memo, - ...pluginMetadataSnapshotMemos.filter((existing) => existing.key !== memo.key), - ].slice(0, MAX_PLUGIN_METADATA_SNAPSHOT_MEMOS); -} - -function computePluginMetadataSnapshotMemoKey(params: { - params: LoadPluginMetadataSnapshotParams; - registryState: PersistedRegistryMemoState; -}): string { - const { params: snapshotParams, registryState } = params; - const env = snapshotParams.env ?? process.env; - const indexFingerprint = snapshotParams.index - ? resolveInstalledManifestRegistryIndexFingerprint(snapshotParams.index) - : undefined; - return hashJson({ - controlPlane: resolvePluginControlPlaneFingerprint({ - config: snapshotParams.config, - env, - workspaceDir: snapshotParams.workspaceDir, - policyHash: resolveInstalledPluginIndexPolicyHash(snapshotParams.config), - ...(indexFingerprint ? { inventoryFingerprint: indexFingerprint } : {}), - }), - cwd: process.cwd(), - env: pickMemoRelevantEnv(env), - index: indexFingerprint ?? null, - pathPolicy: { - compatibilityHostVersion: resolveCompatibilityHostVersion(env), - nixMode: resolveIsNixMode(env), - }, - pluginIds: serializePluginIdScope(normalizePluginIdScope(snapshotParams.pluginIds)), - pluginIdScopeKey: snapshotParams.pluginIdScope?.key ?? null, - preferPersisted: snapshotParams.preferPersisted ?? null, - registry: registryState.fingerprint, - stateDir: snapshotParams.stateDir ? resolveUserPath(snapshotParams.stateDir, env) : null, - workspaceDir: snapshotParams.workspaceDir ?? null, - }); -} - function resolvePluginMetadataControlPlaneFingerprint( params: Pick & { index?: InstalledPluginIndex; @@ -550,80 +295,25 @@ export function listPluginOriginsFromMetadataSnapshot( return new Map(snapshot.plugins.map((record) => [record.id, record.origin])); } -// Process-local memoization keeps the hot snapshot work cached while checking -// the persisted metadata files that the installed-index loader consumes. export function loadPluginMetadataSnapshot( params: LoadPluginMetadataSnapshotParams, ): PluginMetadataSnapshot { const activeTimelineSpan = getActiveDiagnosticsTimelineSpan(); - const env = params.env ?? process.env; - const registryState = params.index - ? resolveProvidedIndexMemoState(params.index) - : resolvePersistedRegistryMemoStateForLookup( - { - env, - ...(params.stateDir ? { stateDir: resolveUserPath(params.stateDir, env) } : {}), - ...(params.preferPersisted !== undefined - ? { preferPersisted: params.preferPersisted } - : {}), + return freezePluginMetadataSnapshot( + measureDiagnosticsTimelineSpanSync( + "plugins.metadata.scan", + () => loadPluginMetadataSnapshotImpl(params), + { + phase: activeTimelineSpan?.phase ?? "startup", + config: params.config, + env: params.env, + attributes: { + hasWorkspaceDir: params.workspaceDir !== undefined, + hasInstalledIndex: params.index !== undefined, }, - pluginMetadataSnapshotMemos, - ); - const memoKey = computePluginMetadataSnapshotMemoKey({ params, registryState }); - const memo = findPluginMetadataSnapshotMemo(memoKey); - if (memo?.key === memoKey) { - return memo.snapshot; - } - - const result = measureDiagnosticsTimelineSpanSync( - "plugins.metadata.scan", - () => loadPluginMetadataSnapshotImpl(params), - { - phase: activeTimelineSpan?.phase ?? "startup", - config: params.config, - env: params.env, - attributes: { - hasWorkspaceDir: params.workspaceDir !== undefined, - hasInstalledIndex: params.index !== undefined, }, - }, + ), ); - const snapshot = freezePluginMetadataSnapshot(result.snapshot); - if (canMemoizePluginMetadataSnapshotResult(result)) { - // Store under the exact key this call looked up by. Derived registries used - // to re-key off the freshly built snapshot.index, so the store key never - // matched the next lookup and every call re-ran the full manifest scan. - rememberPluginMetadataSnapshotMemo({ - key: memoKey, - lookupContextHash: resolvePersistedRegistryMemoLookupContextHash({ - env, - ...(params.stateDir ? { stateDir: resolveUserPath(params.stateDir, env) } : {}), - ...(params.preferPersisted !== undefined - ? { preferPersisted: params.preferPersisted } - : {}), - }), - registryState, - snapshot, - }); - } - return snapshot; -} - -function canMemoizePluginMetadataSnapshotResult(result: { - registrySource: PluginRegistrySnapshotSource; - snapshot: PluginMetadataSnapshot; -}): boolean { - const snapshot = result.snapshot; - const hasCompleteSnapshotShape = - Array.isArray(snapshot.plugins) && - Array.isArray(snapshot.diagnostics) && - Array.isArray(snapshot.registryDiagnostics) && - Array.isArray(snapshot.manifestRegistry.plugins) && - Array.isArray(snapshot.manifestRegistry.diagnostics) && - Array.isArray(snapshot.index.plugins) && - Array.isArray(snapshot.index.diagnostics); - const hasPluginMetadata = snapshot.plugins.length > 0 || snapshot.index.plugins.length > 0; - return hasCompleteSnapshotShape && hasPluginMetadata; } export function resolvePluginMetadataSnapshot( @@ -668,10 +358,9 @@ export function resolvePluginMetadataSnapshot( return loadPluginMetadataSnapshot(params); } -function loadPluginMetadataSnapshotImpl(params: LoadPluginMetadataSnapshotParams): { - snapshot: PluginMetadataSnapshot; - registrySource: PluginRegistrySnapshotSource; -} { +function loadPluginMetadataSnapshotImpl( + params: LoadPluginMetadataSnapshotParams, +): PluginMetadataSnapshot { const totalStartedAt = performance.now(); const registryStartedAt = performance.now(); const registryResult = loadPluginRegistrySnapshotWithMetadata({ @@ -716,36 +405,33 @@ function loadPluginMetadataSnapshotImpl(params: LoadPluginMetadataSnapshotParams const totalMs = performance.now() - totalStartedAt; return { + policyHash: index.policyHash, registrySource: registryResult.source, - snapshot: { - policyHash: index.policyHash, - registrySource: registryResult.source, - configFingerprint: resolvePluginMetadataControlPlaneFingerprint({ - config: params.config, - env: params.env, - index, - policyHash: index.policyHash, - workspaceDir: params.workspaceDir, - }), - ...(pluginIds !== undefined ? { pluginIds } : {}), - ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + configFingerprint: resolvePluginMetadataControlPlaneFingerprint({ + config: params.config, + env: params.env, index, - registryDiagnostics: registryResult.diagnostics, - manifestRegistry, - plugins: manifestRegistry.plugins, - diagnostics: manifestRegistry.diagnostics, - byPluginId, - normalizePluginId, - owners, - metrics: { - registrySnapshotMs, - manifestRegistryMs, - ownerMapsMs, - totalMs, - indexPluginCount: index.plugins.length, - manifestPluginCount: manifestRegistry.plugins.length, - }, - discovery: registryResult.discovery, + policyHash: index.policyHash, + workspaceDir: params.workspaceDir, + }), + ...(pluginIds !== undefined ? { pluginIds } : {}), + ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + index, + registryDiagnostics: registryResult.diagnostics, + manifestRegistry, + plugins: manifestRegistry.plugins, + diagnostics: manifestRegistry.diagnostics, + byPluginId, + normalizePluginId, + owners, + metrics: { + registrySnapshotMs, + manifestRegistryMs, + ownerMapsMs, + totalMs, + indexPluginCount: index.plugins.length, + manifestPluginCount: manifestRegistry.plugins.length, }, + discovery: registryResult.discovery, }; } diff --git a/src/plugins/providers.runtime.consult-current-snapshot.test.ts b/src/plugins/providers.runtime.consult-current-snapshot.test.ts index 4f4a15dc9fc6..6cfbb2cd181f 100644 --- a/src/plugins/providers.runtime.consult-current-snapshot.test.ts +++ b/src/plugins/providers.runtime.consult-current-snapshot.test.ts @@ -9,10 +9,7 @@ import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index- import type { InstalledPluginIndex } from "./installed-plugin-index.js"; import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js"; import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js"; -import { - clearLoadPluginMetadataSnapshotMemo, - loadPluginMetadataSnapshot, -} from "./plugin-metadata-snapshot.js"; +import { loadPluginMetadataSnapshot } from "./plugin-metadata-snapshot.js"; import { resetPluginRuntimeStateForTest } from "./runtime.js"; // Mock the persisted-registry loaders so direct metadata loads are observable. @@ -124,7 +121,6 @@ describe("provider runtime consults the current plugin metadata snapshot", () => beforeEach(() => { resetPluginRuntimeStateForTest(); clearPluginMetadataLifecycleCaches(); - clearLoadPluginMetadataSnapshotMemo(); clearCurrentPluginMetadataSnapshot(); loadPluginRegistrySnapshotWithMetadata.mockReset(); loadPluginManifestRegistryForInstalledIndex.mockReset(); @@ -134,7 +130,6 @@ describe("provider runtime consults the current plugin metadata snapshot", () => afterEach(() => { clearCurrentPluginMetadataSnapshot(); clearPluginMetadataLifecycleCaches(); - clearLoadPluginMetadataSnapshotMemo(); resetPluginRuntimeStateForTest(); }); diff --git a/src/plugins/setup-registry.ts b/src/plugins/setup-registry.ts index 250545c92992..9e8749105ae1 100644 --- a/src/plugins/setup-registry.ts +++ b/src/plugins/setup-registry.ts @@ -15,7 +15,7 @@ import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-re import { createPluginCacheKey, PluginLruCache } from "./plugin-cache-primitives.js"; import { resolvePluginControlPlaneFingerprint } from "./plugin-control-plane-context.js"; import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js"; -import { resolvePluginMetadataSnapshotMemoEnvFingerprint } from "./plugin-metadata-snapshot.js"; +import { resolvePluginMetadataEnvFingerprint } from "./plugin-metadata-snapshot.js"; import { getCachedPluginModuleLoader } from "./plugin-module-loader-cache.js"; import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js"; import type { PluginRuntime } from "./runtime/types.js"; @@ -360,7 +360,7 @@ function resolveSetupRegistryCacheKey(params?: { env, workspaceDir: params?.workspaceDir, }), - resolvePluginMetadataSnapshotMemoEnvFingerprint(env), + resolvePluginMetadataEnvFingerprint(env), resolveCurrentSetupSnapshotCacheId(), process.cwd(), params?.pluginIds ? [...params.pluginIds].toSorted() : null, diff --git a/src/plugins/web-fetch-providers.runtime.test.ts b/src/plugins/web-fetch-providers.runtime.test.ts index 451ac4890c0a..38585fd58d5d 100644 --- a/src/plugins/web-fetch-providers.runtime.test.ts +++ b/src/plugins/web-fetch-providers.runtime.test.ts @@ -15,7 +15,6 @@ let loadOpenClawPluginsMock: ReturnType; let setActivePluginRegistry: RuntimeModule["setActivePluginRegistry"]; let resetPluginRuntimeStateForTest: RuntimeModule["resetPluginRuntimeStateForTest"]; let resolvePluginWebFetchProviders: WebFetchProvidersRuntimeModule["resolvePluginWebFetchProviders"]; -let clearLoadPluginMetadataSnapshotMemo: typeof import("./plugin-metadata-snapshot.js").clearLoadPluginMetadataSnapshotMemo; const DEFAULT_WORKSPACE = "/tmp/workspace"; @@ -121,12 +120,10 @@ describe("resolvePluginWebFetchProviders", () => { manifestRegistryModule = await import("./manifest-registry.js"); webFetchProvidersSharedModule = await import("./web-fetch-providers.shared.js"); ({ resetPluginRuntimeStateForTest, setActivePluginRegistry } = await import("./runtime.js")); - ({ clearLoadPluginMetadataSnapshotMemo } = await import("./plugin-metadata-snapshot.js")); ({ resolvePluginWebFetchProviders } = await import("./web-fetch-providers.runtime.js")); }); beforeEach(() => { - clearLoadPluginMetadataSnapshotMemo(); vi.spyOn(manifestRegistryModule, "loadPluginManifestRegistry").mockReturnValue( createManifestRegistryFixture() as ManifestRegistryModule["loadPluginManifestRegistry"] extends ( ...args: unknown[] @@ -146,7 +143,6 @@ describe("resolvePluginWebFetchProviders", () => { afterEach(() => { resetPluginRuntimeStateForTest(); - clearLoadPluginMetadataSnapshotMemo(); vi.restoreAllMocks(); }); diff --git a/src/plugins/web-search-providers.runtime.test.ts b/src/plugins/web-search-providers.runtime.test.ts index 6f16966ad22b..8d7c16e38d74 100644 --- a/src/plugins/web-search-providers.runtime.test.ts +++ b/src/plugins/web-search-providers.runtime.test.ts @@ -38,7 +38,6 @@ let loaderModule: typeof import("./loader.js"); let pluginAutoEnableModule: PluginAutoEnableModule; let applyPluginAutoEnableSpy: ReturnType; let resetPluginRuntimeStateForTest: RuntimeModule["resetPluginRuntimeStateForTest"]; -let clearLoadPluginMetadataSnapshotMemo: typeof import("./plugin-metadata-snapshot.js").clearLoadPluginMetadataSnapshotMemo; const DEFAULT_WEB_SEARCH_WORKSPACE = "/tmp/workspace"; const EXPECTED_BUNDLED_RUNTIME_WEB_SEARCH_PROVIDER_KEYS = [ @@ -325,12 +324,10 @@ describe("resolvePluginWebSearchProviders", () => { loaderModule = await import("./loader.js"); pluginAutoEnableModule = await import("../config/plugin-auto-enable.js"); ({ resetPluginRuntimeStateForTest, setActivePluginRegistry } = await import("./runtime.js")); - ({ clearLoadPluginMetadataSnapshotMemo } = await import("./plugin-metadata-snapshot.js")); ({ resolvePluginWebSearchProviders } = await import("./web-search-providers.runtime.js")); }); beforeEach(() => { - clearLoadPluginMetadataSnapshotMemo(); applyPluginAutoEnableSpy?.mockRestore(); applyPluginAutoEnableSpy = vi .spyOn(pluginAutoEnableModule, "applyPluginAutoEnable") @@ -359,7 +356,6 @@ describe("resolvePluginWebSearchProviders", () => { afterEach(() => { resetPluginRuntimeStateForTest(); - clearLoadPluginMetadataSnapshotMemo(); vi.restoreAllMocks(); }); diff --git a/test/vitest/vitest.agents-embedded-agent.config.ts b/test/vitest/vitest.agents-embedded-agent.config.ts index b8b325a0924d..26182bbb4238 100644 --- a/test/vitest/vitest.agents-embedded-agent.config.ts +++ b/test/vitest/vitest.agents-embedded-agent.config.ts @@ -6,6 +6,7 @@ export function createAgentsEmbeddedVitestConfig(env?: Record