diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index caf7f917f4ea..227a93e824b2 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -2012,7 +2012,6 @@ src/agents/run-timeout-attribution.ts 1 src/agents/run-wait.ts 8 src/agents/runtime-plan/build.ts 4 src/agents/runtime-plan/tools.ts 5 -src/agents/runtime-plugins.ts 1 src/agents/runtime/proxy.ts 7 src/agents/sandbox-paths.ts 1 src/agents/sandbox-tool-policy.ts 1 diff --git a/extensions/qa-lab/src/test-file-scenario-command-lifecycle.test.ts b/extensions/qa-lab/src/test-file-scenario-command-lifecycle.test.ts index bebf843a59f3..3a517de617ec 100644 --- a/extensions/qa-lab/src/test-file-scenario-command-lifecycle.test.ts +++ b/extensions/qa-lab/src/test-file-scenario-command-lifecycle.test.ts @@ -153,7 +153,7 @@ describe.skipIf(process.platform === "win32")("qa scenario command real POSIX li if (descendantPid === undefined) { throw new Error("scenario command descendant did not expose its pid"); } - expect(isProcessRunning(descendantPid)).toBe(false); + await waitForProcessExit(descendantPid); } finally { if (descendantPid && isProcessRunning(descendantPid)) { process.kill(descendantPid, "SIGKILL"); diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index a7a619ca9186..32a911cd4f94 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -112,6 +112,9 @@ const state = vi.hoisted(() => ({ resolveSupportedThinkingLevelMock: vi.fn(({ level }: { level?: string }) => level), resolveThinkingDefaultMock: vi.fn((_args: unknown) => "low"), loadManifestModelCatalogMock: vi.fn(() => []), + manifestMetadataSnapshot: { plugins: [] }, + resolvePluginMetadataSnapshotMock: vi.fn(), + listSkillCommandsForWorkspaceMock: vi.fn((_params: unknown) => []), loadProviderScopedThinkingCatalogMock: vi.fn( async (_params: unknown): Promise => undefined, ), @@ -374,7 +377,16 @@ vi.mock("./agent-runtime-config.js", () => { vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({ isPluginMetadataSnapshotCompatible: () => false, - resolvePluginMetadataSnapshot: () => ({ plugins: [] }), + resolvePluginMetadataSnapshot: (...args: unknown[]) => + state.resolvePluginMetadataSnapshotMock(...args), +})); + +vi.mock("../skills/discovery/chat-commands.runtime.js", () => ({ + expandExplicitSkillReferences: ({ text }: { text: string }) => ({ body: text, skills: [] }), + hasSkillReferenceCandidate: () => true, + listSkillCommandsForWorkspace: (params: unknown) => + state.listSkillCommandsForWorkspaceMock(params), + resolveEffectiveAgentSkillFilter: () => undefined, })); vi.mock("../config/runtime-snapshot.js", () => ({ @@ -727,11 +739,13 @@ vi.mock("../acp/control-plane/manager.js", () => ({ let agentCommand: typeof import("./agent-command.js").agentCommand; let agentCommandFromSystem: typeof import("./agent-command.js").agentCommandFromSystem; +let prepareAgentCommandExecution: typeof import("./command/prepare.js").prepareAgentCommandExecution; beforeAll(async () => { const mod = await import("./agent-command.js"); agentCommand ??= mod.agentCommand; agentCommandFromSystem ??= mod.agentCommandFromSystem; + ({ prepareAgentCommandExecution } = await import("./command/prepare.js")); }); type FallbackRunnerParams = { @@ -950,6 +964,7 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { state.resolveThinkingDefaultMock.mockReturnValue("low"); state.resolveAgentSkillsFilterMock.mockReturnValue(undefined); state.loadManifestModelCatalogMock.mockReturnValue([]); + state.resolvePluginMetadataSnapshotMock.mockReturnValue(state.manifestMetadataSnapshot); state.loadProviderScopedThinkingCatalogMock.mockReset().mockResolvedValue(undefined); state.loadFullModelCatalogMock.mockClear(); state.loadPreparedModelCatalogSnapshotMock.mockResolvedValue({ @@ -1120,6 +1135,25 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { vi.restoreAllMocks(); }); + it("uses Gateway command metadata without resolving the agent workspace", async () => { + const pluginGeneration = { + pluginMetadataSnapshot: state.manifestMetadataSnapshot, + } as never; + + const prepared = await prepareAgentCommandExecution( + { message: "/demo", to: "+1234567890" }, + {} as never, + { config: {}, pluginGeneration }, + ); + + expect(prepared.manifestMetadataSnapshot).toBe(state.manifestMetadataSnapshot); + expect(prepared.commandRuntimeContext?.pluginGeneration).toBe(pluginGeneration); + expect(state.listSkillCommandsForWorkspaceMock).toHaveBeenCalledWith( + expect.objectContaining({ pluginMetadataSnapshot: state.manifestMetadataSnapshot }), + ); + expect(state.resolvePluginMetadataSnapshotMock).not.toHaveBeenCalled(); + }); + it("retries with the switched provider/model when LiveSessionModelSwitchError is thrown", async () => { setupModelSwitchRetry({ provider: "openai", @@ -3706,6 +3740,9 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { if (allowlisted) { expect(state.loadManifestModelCatalogMock).toHaveBeenCalledTimes(1); + expect(state.loadManifestModelCatalogMock).toHaveBeenCalledWith( + expect.objectContaining({ metadataSnapshot: state.manifestMetadataSnapshot }), + ); } const thinkingArgs = requireRecord( mockCallArg(state.isThinkingLevelSupportedMock), diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 6fab701f6a91..c83f6ee5290c 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -19,6 +19,7 @@ import { import { clearAgentRunContext } from "../infra/agent-run-registry.js"; import { formatErrorMessage } from "../infra/errors.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { withPluginRuntimeGenerationScope } from "../plugins/runtime/generation-scope.js"; import { isSubagentSessionKey } from "../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { isAgentMediatedCompletionSourceTool } from "../sessions/input-provenance.js"; @@ -46,7 +47,10 @@ import { persistAgentSession } from "./command/attempt-execution.shared.js"; import { emitIngressModelUsageDiagnostic } from "./command/ingress-diagnostics.js"; import { resolveEmbeddedModelSelection } from "./command/model-selection.js"; import { finalizeEmbeddedAgentCommand } from "./command/post-run.js"; -import { prepareAgentCommandExecution } from "./command/prepare.js"; +import { + prepareAgentCommandExecution, + type PreparedAgentCommandRuntimeContext, +} from "./command/prepare.js"; import { runEmbeddedAgentAttempt } from "./command/run-embedded-attempt.js"; import { loadSessionStoreRuntime, resolveAgentCommandDeps } from "./command/runtime-loaders.js"; import { prepareCurrentRunDelivery } from "./command/session-helpers.js"; @@ -457,6 +461,9 @@ async function agentCommandInternal( persistedVerbose, verboseDefault: agentCfg?.verboseDefault as VerboseLevel | undefined, sessionStateActor, + ...(manifestMetadataSnapshot + ? { pluginMetadataSnapshot: manifestMetadataSnapshot } + : {}), }), { config: cfg }, ); @@ -634,47 +641,64 @@ async function agentCommandFromIngressInternal( recovery?: { restoreAdmittedRecovery?: () => Promise; }, + runtimeContext?: PreparedAgentCommandRuntimeContext, ) { if (typeof opts.allowModelOverride !== "boolean") { throw new Error("allowModelOverride must be explicitly set for ingress agent runs."); } const lifecycleGeneration = opts.lifecycleGeneration ?? captureAgentRunLifecycleGeneration(opts.runId ?? ""); - return await withAgentRunLifecycleGeneration(lifecycleGeneration, async () => { - let preparedAgentDir: string | undefined; - const result = await runWithAgentCommandRecoveryOwner({ - lifecycleGeneration, - mode: "claim", - opts: { - ...opts, + const generation = runtimeContext?.pluginGeneration; + const executeIngress = () => + withAgentRunLifecycleGeneration(lifecycleGeneration, async () => { + let preparedAgentDir: string | undefined; + const result = await runWithAgentCommandRecoveryOwner({ lifecycleGeneration, - senderIsOwner: opts.senderIsOwner === true, - }, - prepare: async (preparedOpts) => await prepareAgentCommandExecution(preparedOpts, runtime), - restoreAdmittedRecovery: recovery?.restoreAdmittedRecovery, - run: async (prepared) => { - preparedAgentDir = prepared.agentDir; - return await withAgentPluginRegistry({ - config: prepared.cfg, - workspaceDir: prepared.workspaceDir, - run: async () => + mode: "claim", + opts: { + ...opts, + lifecycleGeneration, + senderIsOwner: opts.senderIsOwner === true, + }, + prepare: async (preparedOpts) => + await prepareAgentCommandExecution(preparedOpts, runtime, runtimeContext), + restoreAdmittedRecovery: recovery?.restoreAdmittedRecovery, + run: async (prepared) => { + preparedAgentDir = prepared.agentDir; + const run = async () => await agentCommandInternal( prepared, prepared.opts, { kind: "api", boundary: "agent-command.from-ingress", state: "unknown" }, runtime, deps, - ), - }); - }, + ); + return generation + ? await run() + : await withAgentPluginRegistry({ + config: prepared.cfg, + workspaceDir: prepared.workspaceDir, + run, + }); + }, + }); + + if (result && preparedAgentDir) { + emitIngressModelUsageDiagnostic(result, opts, preparedAgentDir); + } + + return result; }); - - if (result && preparedAgentDir) { - emitIngressModelUsageDiagnostic(result, opts, preparedAgentDir); - } - - return result; - }); + return generation && runtimeContext + ? await withPluginRuntimeGenerationScope( + { + config: runtimeContext.config, + metadataSnapshot: generation.pluginMetadataSnapshot, + pluginRegistry: generation.pluginRegistry, + }, + executeIngress, + ) + : await executeIngress(); } /** Runs an agent turn from an inbound channel/gateway ingress context. */ @@ -700,6 +724,7 @@ export async function agentCommandFromGatewayIngress( recovery: { restoreAdmittedRecovery?: () => Promise; }, + runtimeContext?: PreparedAgentCommandRuntimeContext, ) { - return await agentCommandFromIngressInternal(opts, runtime, deps, recovery); + return await agentCommandFromIngressInternal(opts, runtime, deps, recovery, runtimeContext); } diff --git a/src/agents/agent-project-settings-snapshot.ts b/src/agents/agent-project-settings-snapshot.ts index d42c247a2b48..c2f04f67294c 100644 --- a/src/agents/agent-project-settings-snapshot.ts +++ b/src/agents/agent-project-settings-snapshot.ts @@ -9,9 +9,7 @@ import { normalizePluginsConfigWithResolver, resolvePolicyPluginActivationState, } from "../plugins/config-policy.js"; -import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js"; import { - isPluginMetadataSnapshotCompatible, loadPluginMetadataSnapshot, type PluginMetadataSnapshot, } from "../plugins/plugin-metadata-snapshot.js"; @@ -47,28 +45,6 @@ function sanitizeProjectSettings(settings: AgentSettingsSnapshot): AgentSettings return sanitizeAgentSettingsSnapshot(settings); } -function canReuseUnscopedCurrentPluginMetadataSnapshot(config: OpenClawConfig): boolean { - // Unscoped snapshots are only reusable when config does not introduce - // workspace-local plugin load paths that would change the registry contents. - return normalizePluginsConfigWithResolver(config.plugins).loadPaths.length === 0; -} - -function resolveUnscopedCurrentPluginMetadataSnapshot(params: { - config: OpenClawConfig; - env: NodeJS.ProcessEnv; - workspaceDir?: string; -}): PluginMetadataSnapshot | undefined { - if (!canReuseUnscopedCurrentPluginMetadataSnapshot(params.config)) { - return undefined; - } - return getCurrentPluginMetadataSnapshot({ - env: params.env, - workspaceDir: params.workspaceDir, - allowWorkspaceScopedSnapshot: true, - requireDefaultDiscoveryContext: true, - }); -} - function loadBundleSettingsFile(params: { rootDir: string; relativePath: string; @@ -111,29 +87,12 @@ export function loadEnabledBundleAgentSettingsSnapshot(params: { const env = params.env ?? process.env; const providedSnapshot = params.pluginMetadataSnapshot; const metadataSnapshot = - providedSnapshot && - isPluginMetadataSnapshotCompatible({ - snapshot: providedSnapshot, + providedSnapshot ?? + loadPluginMetadataSnapshot({ + workspaceDir, config, env, - workspaceDir, - }) - ? providedSnapshot - : (getCurrentPluginMetadataSnapshot({ - config, - env, - workspaceDir, - }) ?? - resolveUnscopedCurrentPluginMetadataSnapshot({ - config, - env, - workspaceDir, - }) ?? - loadPluginMetadataSnapshot({ - workspaceDir, - config, - env, - })); + }); const registry = metadataSnapshot.manifestRegistry; if (registry.plugins.length === 0) { return {}; diff --git a/src/agents/agent-project-settings.bundle.test.ts b/src/agents/agent-project-settings.bundle.test.ts index fa92df7feb1a..0fb0353596ba 100644 --- a/src/agents/agent-project-settings.bundle.test.ts +++ b/src/agents/agent-project-settings.bundle.test.ts @@ -194,11 +194,10 @@ describe("loadEnabledBundleAgentSettingsSnapshot", () => { }); expect(snapshot.hideThinkingBlock).toBe(true); - expect(pluginMetadataSnapshotMocks.isPluginMetadataSnapshotCompatible).toHaveBeenCalledOnce(); expect(pluginMetadataSnapshotMocks.loadPluginMetadataSnapshot).not.toHaveBeenCalled(); }); - it("falls back to a fresh plugin metadata load for an incompatible snapshot", async () => { + it("treats a supplied lifecycle snapshot as authoritative across workspaces", async () => { const workspaceDir = await tempDirs.make("openclaw-workspace-"); const pluginRoot = await createWorkspaceBundle({ workspaceDir }); await fs.writeFile( @@ -207,7 +206,7 @@ describe("loadEnabledBundleAgentSettingsSnapshot", () => { "utf-8", ); - pluginMetadataSnapshotMocks.isPluginMetadataSnapshotCompatible.mockReturnValueOnce(false); + pluginMetadataSnapshotMocks.getCurrentPluginMetadataSnapshot.mockClear(); pluginMetadataSnapshotMocks.loadPluginMetadataSnapshot.mockClear(); const snapshot = loadEnabledBundleAgentSettingsSnapshot({ @@ -220,7 +219,20 @@ describe("loadEnabledBundleAgentSettingsSnapshot", () => { }, }, pluginMetadataSnapshot: { - manifestRegistry: { diagnostics: [], plugins: [] }, + workspaceDir: "/tmp/gateway-plugin-workspace", + manifestRegistry: { + diagnostics: [], + plugins: [ + { + id: "claude-bundle", + format: "bundle", + origin: "global", + enabledByDefault: true, + settingsFiles: ["settings.json"], + rootDir: pluginRoot, + }, + ], + }, normalizePluginId: (id: string) => id.trim(), } as unknown as Parameters< typeof loadEnabledBundleAgentSettingsSnapshot @@ -228,147 +240,10 @@ describe("loadEnabledBundleAgentSettingsSnapshot", () => { }); expect(snapshot.hideThinkingBlock).toBe(true); - expect(pluginMetadataSnapshotMocks.isPluginMetadataSnapshotCompatible).toHaveBeenCalledOnce(); - expect(pluginMetadataSnapshotMocks.loadPluginMetadataSnapshot).toHaveBeenCalledOnce(); - }); - - it("reuses the current plugin metadata snapshot for bundle settings", async () => { - const workspaceDir = await tempDirs.make("openclaw-workspace-"); - const pluginRoot = await createWorkspaceBundle({ workspaceDir }); - const resolvedPluginRoot = await fs.realpath(pluginRoot); - await fs.writeFile( - path.join(pluginRoot, "settings.json"), - JSON.stringify({ hideThinkingBlock: true }), - "utf-8", - ); - - pluginMetadataSnapshotMocks.getCurrentPluginMetadataSnapshot.mockReturnValueOnce({ - manifestRegistry: { - diagnostics: [], - plugins: [ - { - id: "claude-bundle", - origin: "workspace", - format: "bundle", - bundleFormat: "claude", - settingsFiles: ["settings.json"], - rootDir: resolvedPluginRoot, - }, - ], - }, - normalizePluginId: (id: string) => id.trim(), - }); - pluginMetadataSnapshotMocks.loadPluginMetadataSnapshot.mockClear(); - - const snapshot = loadEnabledBundleAgentSettingsSnapshot({ - cwd: workspaceDir, - cfg: { - plugins: { - entries: { - "claude-bundle": { enabled: true }, - }, - }, - }, - }); - - expect(snapshot.hideThinkingBlock).toBe(true); + expect(pluginMetadataSnapshotMocks.getCurrentPluginMetadataSnapshot).not.toHaveBeenCalled(); expect(pluginMetadataSnapshotMocks.loadPluginMetadataSnapshot).not.toHaveBeenCalled(); }); - it("does not reuse an unscoped current snapshot when plugin load paths change", async () => { - const workspaceDir = await tempDirs.make("openclaw-workspace-"); - const pluginRoot = await createWorkspaceBundle({ workspaceDir }); - await fs.writeFile( - path.join(pluginRoot, "settings.json"), - JSON.stringify({ hideThinkingBlock: true }), - "utf-8", - ); - - pluginMetadataSnapshotMocks.getCurrentPluginMetadataSnapshot.mockReturnValueOnce(undefined); - pluginMetadataSnapshotMocks.loadPluginMetadataSnapshot.mockClear(); - - const snapshot = loadEnabledBundleAgentSettingsSnapshot({ - cwd: workspaceDir, - cfg: { - plugins: { - load: { paths: ["/tmp/changed-plugin-root"] }, - entries: { - "claude-bundle": { enabled: true }, - }, - }, - }, - }); - - expect(snapshot.hideThinkingBlock).toBe(true); - expect(pluginMetadataSnapshotMocks.getCurrentPluginMetadataSnapshot).toHaveBeenCalledOnce(); - const [snapshotLookup] = - pluginMetadataSnapshotMocks.getCurrentPluginMetadataSnapshot.mock.calls[0] ?? []; - expect(snapshotLookup?.config?.plugins?.load).toEqual({ - paths: ["/tmp/changed-plugin-root"], - }); - expect(snapshotLookup?.env).toBe(process.env); - expect(snapshotLookup?.workspaceDir).toBe(workspaceDir); - expect(pluginMetadataSnapshotMocks.loadPluginMetadataSnapshot).toHaveBeenCalledOnce(); - }); - - it("does not reuse a load-path current snapshot for a config with default load paths", async () => { - const workspaceDir = await tempDirs.make("openclaw-workspace-"); - const pluginRoot = await createWorkspaceBundle({ workspaceDir }); - const resolvedPluginRoot = await fs.realpath(pluginRoot); - await fs.writeFile( - path.join(pluginRoot, "settings.json"), - JSON.stringify({ hideThinkingBlock: true }), - "utf-8", - ); - const staleSnapshot = { - policyHash: "policy", - manifestRegistry: { - diagnostics: [], - plugins: [ - { - id: "claude-bundle", - origin: "workspace", - format: "bundle", - bundleFormat: "claude", - settingsFiles: ["settings.json"], - rootDir: resolvedPluginRoot, - }, - ], - }, - normalizePluginId: (id: string) => id.trim(), - }; - pluginMetadataSnapshotMocks.getCurrentPluginMetadataSnapshot.mockImplementation( - (params: { config?: unknown; requireDefaultDiscoveryContext?: boolean }) => { - if (params.config || params.requireDefaultDiscoveryContext) { - return undefined; - } - return staleSnapshot; - }, - ); - pluginMetadataSnapshotMocks.loadPluginMetadataSnapshot.mockClear(); - - const snapshot = loadEnabledBundleAgentSettingsSnapshot({ - cwd: workspaceDir, - cfg: { - plugins: { - entries: { - "claude-bundle": { enabled: true }, - }, - }, - }, - }); - - expect(snapshot.hideThinkingBlock).toBe(true); - expect(pluginMetadataSnapshotMocks.getCurrentPluginMetadataSnapshot).toHaveBeenCalledTimes(2); - expect(pluginMetadataSnapshotMocks.getCurrentPluginMetadataSnapshot).toHaveBeenLastCalledWith({ - env: process.env, - workspaceDir, - allowWorkspaceScopedSnapshot: true, - requireDefaultDiscoveryContext: true, - }); - expect(pluginMetadataSnapshotMocks.loadPluginMetadataSnapshot).toHaveBeenCalledOnce(); - }); - it("loads sanitized settings and MCP defaults from enabled bundle plugins", async () => { const workspaceDir = await tempDirs.make("openclaw-workspace-"); const pluginRoot = await createWorkspaceBundle({ workspaceDir }); diff --git a/src/agents/command/attempt-execution.cli.test.ts b/src/agents/command/attempt-execution.cli.test.ts index 7af3b728c2de..d2b5685ce89e 100644 --- a/src/agents/command/attempt-execution.cli.test.ts +++ b/src/agents/command/attempt-execution.cli.test.ts @@ -317,6 +317,7 @@ function makeRunAgentAttemptParams(overrides: RunAgentAttemptOverrides): RunAgen authProfileProvider: provider, sessionHasHistory: false, ...overrides, + pluginGeneration: overrides.pluginGeneration, preparedRunAdmission: overrides.preparedRunAdmission ?? createTestPreparedRunAdmission(runId), lifecycleGeneration: overrides.lifecycleGeneration ?? "test-generation", opts: { ...overrides.opts } as RunAgentAttemptParams["opts"], diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index dafe5612664e..3e411245c4a4 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -76,6 +76,7 @@ import { } from "../cli-session.js"; import { resolveConversationCapabilityProfile } from "../conversation-capability-profile.js"; import { resolveConversationToolPolicies } from "../conversation-tool-policy-pipeline.js"; +import type { RunEmbeddedAgentInternalParams } from "../embedded-agent-runner/run/internal-params.js"; import { runEmbeddedAgent, type EmbeddedAgentRunResult } from "../embedded-agent.js"; import { appendGitCoauthorContext } from "../git-coauthor-attribution.js"; import type { ContextEngineLogicalTurnLease } from "../harness/context-engine-logical-turn.js"; @@ -85,6 +86,7 @@ import { resolveAvailableAgentHarnessPolicy } from "../harness/selection.js"; import { resolveCliRuntimeExecutionProvider } from "../model-runtime-aliases.js"; import { isCliProvider } from "../model-selection.js"; import { resolveOpenAIRuntimeProvider } from "../openai-routing.js"; +import type { PreparedModelRuntimePluginGeneration } from "../prepared-model-runtime.types.js"; import { hasVerifiedRequesterCompletionHandoff } from "../requester-tool-policy.js"; import { resolveAgentRunAbortLifecycleFields } from "../run-termination.js"; import { buildAgentRuntimeAuthPlan } from "../runtime-plan/auth.js"; @@ -531,6 +533,7 @@ export function runAgentAttempt(params: { storePath?: string; pluginsEnabled?: boolean; metadataSnapshot?: PluginMetadataSnapshot; + pluginGeneration: PreparedModelRuntimePluginGeneration | undefined; allowTransientCooldownProbe?: boolean; modelFallbacksOverride?: string[]; sessionHasHistory?: boolean; @@ -1126,7 +1129,7 @@ export function runAgentAttempt(params: { const embeddedPersistencePrompt = params.opts.gitCoauthorAttribution ? (continuationTranscriptBody ?? effectivePrompt) : continuationTranscriptBody; - const embeddedRunParams: Parameters[0] = { + const embeddedRunParams: RunEmbeddedAgentInternalParams = { preparedRunAdmission: params.preparedRunAdmission, sessionId: params.sessionId, sessionKey: params.sessionKey, @@ -1159,6 +1162,7 @@ export function runAgentAttempt(params: { permissionMode: params.sessionEntry?.permissionMode, sessionRoot: params.sessionEntry?.sessionRoot, config: params.cfg, + ...(params.pluginGeneration ? { pluginGeneration: params.pluginGeneration } : {}), agentHarnessId: embeddedAgentHarnessOverride, modelSelectionLocked: !isRawModelRun && params.sessionEntry?.modelSelectionLocked === true, agentHarnessRuntimeOverride: embeddedAgentHarnessOverride, diff --git a/src/agents/command/model-selection.ts b/src/agents/command/model-selection.ts index 05c64d8766f2..f49712893cb9 100644 --- a/src/agents/command/model-selection.ts +++ b/src/agents/command/model-selection.ts @@ -8,6 +8,7 @@ import { import { resolveChannelModelOverride } from "../../channels/model-overrides.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import { requireActivePluginRegistry } from "../../plugins/runtime.js"; import { isSubagentSessionKey } from "../../routing/session-key.js"; import { isValidAgentHarnessSessionStoreEntry } from "../../sessions/agent-harness-session-key.js"; @@ -83,9 +84,7 @@ export async function resolveEmbeddedModelSelection(params: { sessionAgentId: string; workspaceDir: string; pluginsEnabled: boolean; - manifestMetadataSnapshot?: NonNullable< - Parameters[1] - >["metadataSnapshot"]; + manifestMetadataSnapshot?: PluginMetadataSnapshot; modelManifestContext: ModelManifestNormalizationContext; configuredThinkingCatalog: ReturnType; requestedThinkLevel?: ThinkLevel; @@ -155,7 +154,11 @@ export async function resolveEmbeddedModelSelection(params: { Object.keys(agentModels ?? {}).length > 0; if (hasAllowlist || hasConfiguredModels) { modelCatalog = params.pluginsEnabled - ? loadManifestModelCatalog({ config: params.cfg, workspaceDir: params.workspaceDir }) + ? loadManifestModelCatalog({ + config: params.cfg, + workspaceDir: params.workspaceDir, + metadataSnapshot: params.manifestMetadataSnapshot, + }) : []; visibilityPolicy = createModelVisibilityPolicy({ cfg: params.cfg, diff --git a/src/agents/command/post-run.ts b/src/agents/command/post-run.ts index 0cc888040014..cdfc403056d6 100644 --- a/src/agents/command/post-run.ts +++ b/src/agents/command/post-run.ts @@ -24,7 +24,7 @@ import { throwAgentRunRestartAbortReason } from "../run-termination.js"; import { persistAssistantTranscriptRepairRecord } from "./assistant-transcript-repair.js"; import { persistAgentSession } from "./attempt-execution.shared.js"; import type { PreparedAgentCommandExecution } from "./prepare.js"; -import type { EmbeddedAgentAttempt } from "./run-embedded-attempt.js"; +import type { runEmbeddedAgentAttempt } from "./run-embedded-attempt.js"; import { loadAgentRunnerMemoryRuntime, loadCliCompactionRuntime, @@ -35,6 +35,8 @@ import { clearPendingFinalDelivery } from "./session-helpers.js"; import type { EmbeddedSessionState } from "./session-preparation.js"; import type { AgentCommandOpts } from "./types.js"; +type EmbeddedAgentAttempt = Awaited>; + const log = createSubsystemLogger("agents/agent-command"); export async function finalizeEmbeddedAgentCommand(params: { diff --git a/src/agents/command/prepare.ts b/src/agents/command/prepare.ts index b9bb24309109..b407e64ae187 100644 --- a/src/agents/command/prepare.ts +++ b/src/agents/command/prepare.ts @@ -8,13 +8,11 @@ import { normalizeVerboseLevel, } from "../../auto-reply/thinking.js"; import { formatCliCommand } from "../../cli/command-format.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { resolveAgentExplicitRecipientSession } from "../../infra/outbound/agent-delivery.js"; import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; import { normalizePluginsConfig } from "../../plugins/config-state.js"; -import { - isPluginMetadataSnapshotCompatible, - resolvePluginMetadataSnapshot, -} from "../../plugins/plugin-metadata-snapshot.js"; +import { resolvePluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js"; import { classifySessionKeyShape, isUnscopedSessionKeySentinel, @@ -39,6 +37,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; import { AGENT_LANE_SUBAGENT } from "../lanes.js"; import type { ModelManifestNormalizationContext } from "../model-ref-shared.js"; import { buildConfiguredModelCatalog, resolveConfiguredModelRef } from "../model-selection.js"; +import type { PreparedModelRuntimePluginGeneration } from "../prepared-model-runtime.types.js"; import { normalizeSpawnedRunMetadata } from "../spawned-context.js"; import { resolveEffectiveAgentRuntime } from "../thinking-runtime.js"; import { resolveAgentTimeoutMs } from "../timeout.js"; @@ -85,7 +84,16 @@ export function normalizeExplicitOverrideInput(raw: string, kind: "provider" | " return trimmed; } -export async function prepareAgentCommandExecution(opts: AgentCommandOpts, runtime: RuntimeEnv) { +export type PreparedAgentCommandRuntimeContext = Readonly<{ + config: OpenClawConfig; + pluginGeneration: PreparedModelRuntimePluginGeneration; +}>; + +export async function prepareAgentCommandExecution( + opts: AgentCommandOpts, + runtime: RuntimeEnv, + runtimeContext?: PreparedAgentCommandRuntimeContext, +) { const isRawModelRun = opts.modelRun === true || opts.promptMode === "none"; const message = opts.message ?? ""; if (!message.trim()) { @@ -114,7 +122,7 @@ export async function prepareAgentCommandExecution(opts: AgentCommandOpts, runti ); } - const { cfg, pluginMetadataSnapshot } = await resolveAgentRuntimeConfig(runtime, { + const { cfg } = await resolveAgentRuntimeConfig(runtime, { runtimeTargetsChannelSecrets: opts.deliver === true, runtimeChannelSecretScope: opts.deliver !== true && shouldResolveExplicitRecipientSession && recipientChannel @@ -271,17 +279,10 @@ export async function prepareAgentCommandExecution(opts: AgentCommandOpts, runti normalizeOptionalString(opts.cwd) ?? normalizeOptionalString(sessionEntryRaw?.spawnedCwd); const agentDir = resolveAgentDir(cfg, sessionAgentId); const pluginsEnabled = normalizePluginsConfig(cfg.plugins).enabled; + const preparedMetadataSnapshot = runtimeContext?.pluginGeneration.pluginMetadataSnapshot; const manifestMetadataSnapshot = pluginsEnabled - ? pluginMetadataSnapshot && - pluginMetadataSnapshot.pluginIds === undefined && - isPluginMetadataSnapshotCompatible({ - snapshot: pluginMetadataSnapshot, - config: cfg, - env: process.env, - workspaceDir, - }) - ? pluginMetadataSnapshot - : resolvePluginMetadataSnapshot({ config: cfg, env: process.env, workspaceDir }) + ? (preparedMetadataSnapshot ?? + resolvePluginMetadataSnapshot({ config: cfg, env: process.env, workspaceDir })) : undefined; const modelManifestContext = { manifestPlugins: manifestMetadataSnapshot?.plugins ?? [], @@ -376,6 +377,7 @@ export async function prepareAgentCommandExecution(opts: AgentCommandOpts, runti agentId: sessionAgentId, sessionEntry: sessionEntryRaw, sessionKey, + ...(preparedMetadataSnapshot ? { pluginMetadataSnapshot: preparedMetadataSnapshot } : {}), ...(skillFilter ? { skillFilter } : {}), }; const skillCommands = listSkillCommandsForWorkspace(commandParams); @@ -429,6 +431,7 @@ export async function prepareAgentCommandExecution(opts: AgentCommandOpts, runti agentDir, pluginsEnabled, manifestMetadataSnapshot, + ...(runtimeContext ? { commandRuntimeContext: runtimeContext } : {}), modelManifestContext, runId, isSubagentLane, diff --git a/src/agents/command/run-embedded-attempt.ts b/src/agents/command/run-embedded-attempt.ts index abff3f50682b..3a9d64daa42c 100644 --- a/src/agents/command/run-embedded-attempt.ts +++ b/src/agents/command/run-embedded-attempt.ts @@ -61,7 +61,6 @@ import { loadAttemptExecutionRuntime, type AgentAttemptResult } from "./runtime- import { resolveInternalSessionEffectsSource } from "./session-helpers.js"; import type { EmbeddedSessionState } from "./session-preparation.js"; import type { AgentCommandOpts } from "./types.js"; - const log = createSubsystemLogger("agents/agent-command"); const MAX_LIVE_SWITCH_RETRIES = 5; @@ -495,6 +494,7 @@ export async function runEmbeddedAgentAttempt(params: { storePath: params.suppressVisibleSessionEffects ? undefined : storePath, pluginsEnabled, ...(manifestMetadataSnapshot ? { metadataSnapshot: manifestMetadataSnapshot } : {}), + pluginGeneration: params.prepared.commandRuntimeContext?.pluginGeneration, allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe, sessionHasHistory: !isNewSession || @@ -511,7 +511,6 @@ export async function runEmbeddedAgentAttempt(params: { onUserMessagePersisted: attemptLifecycleCallbacks.onUserMessagePersisted, onLifecycleGenerationChanged: (nextLifecycleGeneration) => { lifecycleGeneration = nextLifecycleGeneration; - // Outer cleanup owns the run context, so publish before the attempt can reject. params.onLifecycleGenerationChanged(nextLifecycleGeneration); }, onAgentEvent: attemptLifecycleCallbacks.onAgentEvent, @@ -705,5 +704,3 @@ export async function runEmbeddedAgentAttempt(params: { terminal, }; } - -export type EmbeddedAgentAttempt = Awaited>; diff --git a/src/agents/command/session-preparation.ts b/src/agents/command/session-preparation.ts index 9fac7266d4f0..17426179bcad 100644 --- a/src/agents/command/session-preparation.ts +++ b/src/agents/command/session-preparation.ts @@ -3,6 +3,7 @@ import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { assertAgentRunLifecycleGenerationCurrent } from "../../infra/agent-events.js"; import { registerAgentRunContext } from "../../infra/agent-run-registry.js"; +import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import { applyVerboseOverride } from "../../sessions/level-overrides.js"; import { recordSessionHumanDirectMessage } from "../../sessions/session-state-events.js"; import { resolveEffectiveAgentSkillFilter } from "../../skills/discovery/agent-filter.js"; @@ -34,6 +35,7 @@ export async function prepareEmbeddedSessionState(params: { persistedVerbose?: VerboseLevel; verboseDefault?: VerboseLevel; sessionStateActor: Parameters[0]["actor"]; + pluginMetadataSnapshot?: PluginMetadataSnapshot; }) { const requestedThinkLevel = params.thinkOnce ?? params.thinkOverride ?? params.persistedThinking; const resolvedVerboseLevel = @@ -77,6 +79,9 @@ export async function prepareEmbeddedSessionState(params: { }), }, watch: false, + ...(params.pluginMetadataSnapshot + ? { pluginMetadataSnapshot: params.pluginMetadataSnapshot } + : {}), }); const needsSkillsSnapshot = params.isNewSession || !currentSkillsSnapshot || skillSnapshotState.shouldRefresh; diff --git a/src/agents/embedded-agent-runner/compact.hooks.harness.ts b/src/agents/embedded-agent-runner/compact.hooks.harness.ts index 1a1f905351eb..f8c40b157f88 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.harness.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.harness.ts @@ -365,9 +365,6 @@ function createCompactHooksRuntimePlan(params: BuildAgentRuntimePlanParams): Age transformSystemPrompt: vi.fn((context: { systemPrompt: string }) => context.systemPrompt), }, tools: { - preparedPlanning: { - loadMetadataSnapshot: () => ({}), - }, normalize: vi.fn((tools) => tools), logDiagnostics: vi.fn(), }, diff --git a/src/agents/embedded-agent-runner/run-orchestrator.ts b/src/agents/embedded-agent-runner/run-orchestrator.ts index 3dd528893326..aaa4f3fff6f7 100644 --- a/src/agents/embedded-agent-runner/run-orchestrator.ts +++ b/src/agents/embedded-agent-runner/run-orchestrator.ts @@ -15,12 +15,12 @@ import { buildHandledBeforeAgentReplyPayloads, runBeforeAgentReplyForTurn, } from "../../plugins/before-agent-reply.js"; -import { getCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js"; import { buildAgentHookContextChannelFields, buildAgentHookContextIdentityFields, } from "../../plugins/hook-agent-context.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; +import { loadPluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js"; import { withPluginRuntimeGenerationScope } from "../../plugins/runtime/generation-scope.js"; import { resolveUserPath } from "../../utils.js"; import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js"; @@ -230,15 +230,16 @@ async function runEmbeddedAgentInternal( agentId: requestedWorkspaceResolution.agentId, sessionKey: params.sessionKey, }); - const currentPluginMetadataSnapshot = getCurrentPluginMetadataSnapshot({ - config, - workspaceDir: requestedWorkspaceResolution.workspaceDir, - env: process.env, - allowWorkspaceScopedSnapshot: true, - }); + const pluginMetadataSnapshot = + params.pluginGeneration?.pluginMetadataSnapshot ?? + loadPluginMetadataSnapshot({ + config, + workspaceDir: requestedWorkspaceResolution.workspaceDir, + env: process.env, + }); const runtimePluginSelections = resolveModelCandidateChain({ cfg: config, - manifestPlugins: currentPluginMetadataSnapshot?.plugins ?? [], + manifestPlugins: pluginMetadataSnapshot.plugins, provider: requestedRuntimeSelection.provider, model: requestedRuntimeSelection.modelId, requestedRouteResolution: "resolved", @@ -287,11 +288,19 @@ async function runEmbeddedAgentInternal( // Turns need only configured admission facts. Full live model inventory remains // available through the snapshot's lazy control-plane loader. catalogMode: "static", + ...(params.pluginGeneration ? { pluginGeneration: params.pluginGeneration } : {}), }), ); startupStages.mark("prepared-runtime"); const preparedModelRuntimeOwnerSnapshot = preparedModelRuntimeLease.snapshot; try { + if ( + params.pluginGeneration && + preparedModelRuntimeOwnerSnapshot.metadataSnapshot !== + params.pluginGeneration.pluginMetadataSnapshot + ) { + throw new Error("prepared model runtime replaced the admitted plugin generation"); + } // A reload may complete while admission waits. The committed generation owns config, // directories, model selection, hooks, fallbacks, and every later run projection. const rebound = bindRunToPreparedModelRuntime({ diff --git a/src/agents/embedded-agent-runner/run/attempt-setup.test.ts b/src/agents/embedded-agent-runner/run/attempt-setup.test.ts index 6447953be7a7..37ab5724b896 100644 --- a/src/agents/embedded-agent-runner/run/attempt-setup.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-setup.test.ts @@ -195,12 +195,13 @@ describe("prepareEmbeddedAttemptSetup", () => { expect(resolveProviderRuntimePluginHandle).not.toHaveBeenCalled(); }); - it("resolves partial handles without trusting scoped metadata", async () => { + it("resolves partial handles with the exact lifecycle metadata", async () => { const resolvedHandle: ProviderRuntimePluginHandle = { provider: "openai", modelId: "gpt-5.4", }; resolveProviderRuntimePluginHandle.mockReturnValue(resolvedHandle); + const metadataSnapshot = { pluginIds: ["other"] }; const setup = await prepareEmbeddedAttemptSetup({ config: {}, modelId: "gpt-5.4", @@ -211,7 +212,7 @@ describe("prepareEmbeddedAttemptSetup", () => { timeoutMs: 30_000, workspaceDir: path.join(os.tmpdir(), "openclaw-attempt-setup-partial"), preparedModelRuntime: { - metadataSnapshot: { pluginIds: ["other"] }, + metadataSnapshot, } as never, runtimePlan: { providerRuntimeHandle: { provider: "openai" } } as never, } as unknown as EmbeddedRunAttemptParams); @@ -223,7 +224,7 @@ describe("prepareEmbeddedAttemptSetup", () => { expect(resolveProviderRuntimePluginHandle).toHaveBeenCalledOnce(); const call = resolveProviderRuntimePluginHandle.mock.calls[0]?.[0]; expect(call).toMatchObject({ provider: "openai", modelId: "gpt-5.4" }); - expect(call).not.toHaveProperty("pluginMetadataSnapshot"); + expect(call?.pluginMetadataSnapshot).toBe(metadataSnapshot); }); }); diff --git a/src/agents/embedded-agent-runner/run/attempt-setup.ts b/src/agents/embedded-agent-runner/run/attempt-setup.ts index bec4d31cf00f..1c85f33fe52f 100644 --- a/src/agents/embedded-agent-runner/run/attempt-setup.ts +++ b/src/agents/embedded-agent-runner/run/attempt-setup.ts @@ -23,7 +23,6 @@ import { getActiveDiagnosticTraceContext, } from "../../../infra/diagnostic-trace-context.js"; import { getAgentScopedMediaLocalRoots } from "../../../media/local-roots.js"; -import { isPluginMetadataSnapshotCompatible } from "../../../plugins/plugin-metadata-snapshot.js"; import type { PluginMetadataSnapshot } from "../../../plugins/plugin-metadata-snapshot.types.js"; import { resolveProviderRuntimePluginHandle, @@ -224,17 +223,6 @@ export async function prepareEmbeddedAttemptSetup(params: EmbeddedRunAttemptPara return providerRuntimeHandle; } const pluginMetadataSnapshot = getCurrentAttemptPluginMetadataSnapshot(); - const compatibleMetadataSnapshot = - pluginMetadataSnapshot && - pluginMetadataSnapshot.pluginIds === undefined && - isPluginMetadataSnapshotCompatible({ - snapshot: pluginMetadataSnapshot, - config: params.config, - env: process.env, - workspaceDir: effectiveWorkspace, - }) - ? pluginMetadataSnapshot - : undefined; providerRuntimeHandle = { ...resolveProviderRuntimePluginHandle({ provider: params.provider, @@ -242,9 +230,7 @@ export async function prepareEmbeddedAttemptSetup(params: EmbeddedRunAttemptPara config: params.config, workspaceDir: effectiveWorkspace, env: process.env, - ...(compatibleMetadataSnapshot - ? { pluginMetadataSnapshot: compatibleMetadataSnapshot } - : {}), + ...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}), }), provider: params.provider, modelId: params.modelId, diff --git a/src/agents/embedded-agent-runner/run/internal-params.ts b/src/agents/embedded-agent-runner/run/internal-params.ts index e53adb256064..449afebd2c76 100644 --- a/src/agents/embedded-agent-runner/run/internal-params.ts +++ b/src/agents/embedded-agent-runner/run/internal-params.ts @@ -1,4 +1,5 @@ import type { AgentExecutionAuthBinding } from "../../execution-auth-binding.js"; +import type { PreparedModelRuntimePluginGeneration } from "../../prepared-model-runtime.types.js"; import type { SystemAgentToolOptions } from "../../tools/system-agent-tool.js"; import type { RunEmbeddedAgentParams } from "./params.js"; @@ -9,6 +10,8 @@ export type RunEmbeddedAgentInternalParams = RunEmbeddedAgentParams & { preparedModelRuntimeMode?: "isolated-read-only"; /** Ring-zero tool override, supplied only by the OpenClaw orchestrator. */ systemAgentTool?: SystemAgentToolOptions; + /** Gateway-private lifecycle generation selected before command admission. */ + pluginGeneration?: PreparedModelRuntimePluginGeneration; }; export type RunEmbeddedAgentParamsWithSessionFile = RunEmbeddedAgentInternalParams & { diff --git a/src/agents/embedded-agent-runner/run/runtime-preparation.ts b/src/agents/embedded-agent-runner/run/runtime-preparation.ts index 1cabadbba7d6..798c7f8a6242 100644 --- a/src/agents/embedded-agent-runner/run/runtime-preparation.ts +++ b/src/agents/embedded-agent-runner/run/runtime-preparation.ts @@ -1,6 +1,5 @@ import { readSourceReplyDeliveryRuntime } from "../../../auto-reply/reply/source-reply-delivery-runtime.js"; 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 { resolvePreparedRunAdmission } from "../../admitted-run-context.js"; import type { AuthProfileStore } from "../../auth-profiles.js"; @@ -484,22 +483,10 @@ 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 routeFacts = getModelProviderRequestRouteFacts(effectiveModel); const fallbackEndpointClass = routeFacts ? undefined - : resolveProviderEndpoint(effectiveModel.baseUrl, compatibleMetadataSnapshot?.owners) - .endpointClass; + : resolveProviderEndpoint(effectiveModel.baseUrl, pluginMetadataSnapshot?.owners).endpointClass; const providerOwner = routeFacts?.providerOwner ?? (fallbackEndpointClass && @@ -514,7 +501,7 @@ export async function prepareEmbeddedRunRuntime(input: { config: params.config, workspaceDir: input.workspaceDir, env: process.env, - ...(compatibleMetadataSnapshot ? { pluginMetadataSnapshot: compatibleMetadataSnapshot } : {}), + ...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}), }), modelId, prepared: true as const, diff --git a/src/agents/harness/runtime-plugin-load-plan.ts b/src/agents/harness/runtime-plugin-load-plan.ts index 1fbf916c12a8..e9f52a513217 100644 --- a/src/agents/harness/runtime-plugin-load-plan.ts +++ b/src/agents/harness/runtime-plugin-load-plan.ts @@ -8,6 +8,7 @@ import { resolveSelectedContextEnginePluginId, } from "../../plugins/config-state.js"; import { isPluginEnabledByDefaultForPlatform } from "../../plugins/default-enablement.js"; +import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import { loadPluginRegistrySnapshot, normalizePluginsConfigWithRegistry, @@ -51,12 +52,17 @@ function restrictiveAllowlistOmitsPlugin(config: OpenClawConfig | undefined, plu function resolveSelectedMemoryPluginIds(params: { config: OpenClawConfig | undefined; workspaceDir: string; + metadataSnapshot?: PluginMetadataSnapshot; }): string[] { // Honor config-owned test defaults before discovery forces an implicit memory owner. if (isTestDefaultMemorySlotDisabled(params.config ?? {})) { return []; } - const registry = loadPluginRegistrySnapshot(params); + const registry = loadPluginRegistrySnapshot({ + config: params.config, + workspaceDir: params.metadataSnapshot?.workspaceDir ?? params.workspaceDir, + ...(params.metadataSnapshot ? { index: params.metadataSnapshot.index } : {}), + }); const plugins = normalizePluginsConfigWithRegistry(params.config?.plugins, registry); const memorySlot = plugins.slots.memory; if ( @@ -86,6 +92,7 @@ function resolveSelectedProviderOwnerPluginIds(params: { provider: string; config?: OpenClawConfig; workspaceDir: string; + metadataSnapshot?: PluginMetadataSnapshot; }): string[] { const providerOwnerPluginIds = dedupePluginIds( resolveOwningPluginIdsForProviderRef(params) ?? [], @@ -98,11 +105,18 @@ function resolveSelectedProviderOwnerPluginIds(params: { config: params.config, workspaceDir: params.workspaceDir, onlyPluginIds: providerOwnerPluginIds, + manifestRegistry: params.metadataSnapshot?.manifestRegistry, }), ...resolveActivatableProviderOwnerPluginIds({ pluginIds: providerOwnerPluginIds, config: params.config, workspaceDir: params.workspaceDir, + ...(params.metadataSnapshot + ? { + registry: params.metadataSnapshot.index, + manifestRegistry: params.metadataSnapshot.manifestRegistry, + } + : {}), }), ]); return providerOwnerPluginIds.filter((pluginId) => safeProviderOwnerPluginIds.includes(pluginId)); @@ -115,12 +129,14 @@ export function resolveAgentHarnessOwnerPluginIds(params: { config?: OpenClawConfig; workspaceDir: string; providerOwnerPluginIds?: readonly string[]; + metadataSnapshot?: PluginMetadataSnapshot; }): string[] { const harnessPluginIds = resolveManifestActivationPlan({ trigger: { kind: "agentHarness", runtime: params.runtime }, config: params.config, workspaceDir: params.workspaceDir, requireExplicitManifestOwnerTrust: true, + manifestRecords: params.metadataSnapshot?.plugins, }).entries.map((entry) => entry.pluginId); if ( harnessPluginIds.length === 0 || @@ -197,11 +213,13 @@ export function resolveAgentRuntimePluginLoadPlan(params: { workspaceDir: string; basePluginIds?: readonly string[]; selections: readonly AgentHarnessPluginSelection[]; + metadataSnapshot?: PluginMetadataSnapshot; }): { config?: OpenClawConfig; pluginIds?: string[] } { let config = params.config; const memoryPluginIds = resolveSelectedMemoryPluginIds({ config: params.config, workspaceDir: params.workspaceDir, + metadataSnapshot: params.metadataSnapshot, }); const contextEnginePluginId = resolveSelectedContextEnginePluginId(params.config); const contextEnginePluginIds = contextEnginePluginId ? [contextEnginePluginId] : []; @@ -216,6 +234,7 @@ export function resolveAgentRuntimePluginLoadPlan(params: { provider: selection.provider, config, workspaceDir: params.workspaceDir, + metadataSnapshot: params.metadataSnapshot, }); pluginIds.push(...providerOwnerPluginIds); forceActivatedPluginIds.push(...providerOwnerPluginIds); @@ -228,6 +247,7 @@ export function resolveAgentRuntimePluginLoadPlan(params: { config, workspaceDir: params.workspaceDir, providerOwnerPluginIds, + metadataSnapshot: params.metadataSnapshot, }); pluginIds.push(...harnessPluginIds); const allowedHarnessPluginIds = diff --git a/src/agents/openclaw-tools.browser-plugin.integration.test.ts b/src/agents/openclaw-tools.browser-plugin.integration.test.ts index f09468af8857..febf4804bf17 100644 --- a/src/agents/openclaw-tools.browser-plugin.integration.test.ts +++ b/src/agents/openclaw-tools.browser-plugin.integration.test.ts @@ -8,7 +8,6 @@ import { createPluginMetadataSnapshot, makeRegistry, } from "../config/plugin-auto-enable.test-helpers.js"; -import * as pluginMetadata from "../plugins/plugin-metadata-snapshot.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; import { activateSecretsRuntimeSnapshot, clearSecretsRuntimeSnapshot } from "../secrets/runtime.js"; @@ -191,20 +190,14 @@ describe("createOpenClawTools browser plugin integration", () => { manifestRegistry: makeRegistry([]), workspaceDir: "/tmp", }); - const resolveMetadata = vi - .spyOn(pluginMetadata, "resolvePluginMetadataSnapshot") - .mockReturnValue(metadataSnapshot); - try { - expect( - prepareOwnedPluginLoadContext( - { agentDir: "/tmp/agent", config, workspaceDir: "/tmp" }, - process.env, - pluginRegistry, - ), - ).toBe(metadataSnapshot); - } finally { - resolveMetadata.mockRestore(); - } + expect( + prepareOwnedPluginLoadContext( + { agentDir: "/tmp/agent", config, workspaceDir: "/tmp" }, + process.env, + pluginRegistry, + metadataSnapshot, + ), + ).toBe(metadataSnapshot); const loadContext = getPreparedPluginRuntimeLoadContext(pluginRegistry); if (!loadContext) { throw new Error("expected prepared plugin load context"); diff --git a/src/agents/prepared-model-catalog.worker.ts b/src/agents/prepared-model-catalog.worker.ts index 53a593039058..c317e75ecf68 100644 --- a/src/agents/prepared-model-catalog.worker.ts +++ b/src/agents/prepared-model-catalog.worker.ts @@ -61,7 +61,6 @@ function refreshAuthStore(params: { config: params.config, metadataSnapshot: params.pluginGeneration.pluginMetadataSnapshot, pluginRegistry: params.pluginGeneration.pluginRegistry, - workspaceDir: params.pluginGeneration.pluginMetadataSnapshot.workspaceDir, }, () => overlayExternalAuthProfiles(prepared, { @@ -141,7 +140,6 @@ export async function runPreparedModelCatalogWorkerRequest( config: value.input.config, metadataSnapshot: prepared.pluginGeneration.pluginMetadataSnapshot, pluginRegistry: prepared.pluginGeneration.pluginRegistry, - workspaceDir: value.input.workspaceDir, }, () => resolveAmbientAgentCredentialsForDiscovery({ diff --git a/src/agents/prepared-model-runtime-lease.ts b/src/agents/prepared-model-runtime-lease.ts index 712412455ccc..00db56515919 100644 --- a/src/agents/prepared-model-runtime-lease.ts +++ b/src/agents/prepared-model-runtime-lease.ts @@ -99,6 +99,7 @@ export async function acquirePreparedModelRuntimeLeaseFromOwners( options: { retainIdleRunOwner?: boolean; catalogMode?: PreparedModelRuntimeCatalogMode; + pluginGeneration?: PreparedModelRuntimeOwner["pluginGeneration"]; } = {}, ): Promise { let normalizedInput = normalizePreparedModelRuntimeInput({ @@ -109,6 +110,7 @@ export async function acquirePreparedModelRuntimeLeaseFromOwners( if ( provenance === "run" && context.getGatewayLifecycleActive() && + !options.pluginGeneration && !context.getPendingReplacement() ) { try { @@ -143,7 +145,7 @@ export async function acquirePreparedModelRuntimeLeaseFromOwners( if (context.getPendingReplacement()) { continue; } - if (provenance === "run") { + if (provenance === "run" && !options.pluginGeneration) { input = rebindInputToCommittedConfiguredOwner(context.owners, input); key = ownerKey(input); } @@ -157,6 +159,7 @@ export async function acquirePreparedModelRuntimeLeaseFromOwners( if ( context.getGatewayLifecycleActive() && provenance === "run" && + !options.pluginGeneration && (!existing || staleDynamicOwner) ) { // Dynamic workspaces still inherit the committed agent/config generation. Only their @@ -184,11 +187,9 @@ export async function acquirePreparedModelRuntimeLeaseFromOwners( } } try { - const reusablePluginGeneration = resolveReusableConfiguredPluginGeneration( - input, - workspacePluginRootPresent, - context, - ); + const reusablePluginGeneration = + options.pluginGeneration ?? + resolveReusableConfiguredPluginGeneration(input, workspacePluginRootPresent, context); if (existing && !staleDynamicOwner) { snapshot = await context.prepareSnapshot(input); } else { diff --git a/src/agents/prepared-model-runtime.build.ts b/src/agents/prepared-model-runtime.build.ts index 414632d28167..b9e9d7c6ea17 100644 --- a/src/agents/prepared-model-runtime.build.ts +++ b/src/agents/prepared-model-runtime.build.ts @@ -34,7 +34,6 @@ import { createPreparedInboundRegistryLoader, preparedModelRuntimeWorkspaceFactsKey, } from "./prepared-model-runtime.inbound-registry.js"; -import { projectPreparedPluginGeneration } from "./prepared-model-runtime.plugin-generation.js"; import type { PreparedModelRuntimeBuildStats, PreparedModelRuntimeCatalogMode, @@ -281,6 +280,7 @@ async function buildSnapshotBatch( PreparedModelRuntimeInput, PreparedModelRuntimePluginGeneration >, + pluginMetadataSnapshot?: PreparedModelRuntimePluginGeneration["pluginMetadataSnapshot"], onBuildStats?: (stats: PreparedModelRuntimeBuildStats) => void, ): Promise { const freshGroups = new Map(); @@ -346,6 +346,7 @@ async function buildSnapshotBatch( {}, prepareInboundPluginRegistry ? loadInboundPluginRegistry : undefined, pluginGeneration, + pluginMetadataSnapshot, ); assertPreparedModelRuntimeInputsCurrent(groupInputs, buildGuards); runtimePluginMs += prepared.buildStats.runtimePluginMs; @@ -356,13 +357,7 @@ async function buildSnapshotBatch( configuredProjectionMs += prepared.buildStats.configuredProjectionMs; for (const agentFacts of prepared.agentFacts) { preparedInputs.set(agentFacts.input, agentFacts); - pluginGenerations.set( - agentFacts.input, - projectPreparedPluginGeneration({ - input: agentFacts.input, - pluginGeneration: prepared.pluginGeneration, - }), - ); + pluginGenerations.set(agentFacts.input, prepared.pluginGeneration); } } const workspaceFactsMs = performance.now() - workspaceFactsStartedAt; @@ -552,6 +547,7 @@ export function startSerializedSnapshotBuildBatch( PreparedModelRuntimeInput, PreparedModelRuntimePluginGeneration > = new Map(), + pluginMetadataSnapshot?: PreparedModelRuntimePluginGeneration["pluginMetadataSnapshot"], ): { pending: Promise; completion: Promise; @@ -579,6 +575,7 @@ export function startSerializedSnapshotBuildBatch( buildGuards, inboundPluginRegistryInputs, reusablePluginGenerations, + pluginMetadataSnapshot, onBuildStats, ), }; @@ -618,6 +615,7 @@ export function startSerializedSnapshotBuild( generationGuard: () => boolean = () => true, prepareInboundPluginRegistry = false, reusablePluginGeneration?: PreparedModelRuntimePluginGeneration, + pluginMetadataSnapshot?: PreparedModelRuntimePluginGeneration["pluginMetadataSnapshot"], ): { pending: Promise; completion: Promise; @@ -632,6 +630,7 @@ export function startSerializedSnapshotBuild( undefined, prepareInboundPluginRegistry ? new Set([input]) : undefined, reusablePluginGeneration ? new Map([[input, reusablePluginGeneration]]) : undefined, + pluginMetadataSnapshot, ); return { pending: build.pending.then((results) => results[0]!), diff --git a/src/agents/prepared-model-runtime.facts.ts b/src/agents/prepared-model-runtime.facts.ts index 9e22ac75fb93..a6109aa6aed3 100644 --- a/src/agents/prepared-model-runtime.facts.ts +++ b/src/agents/prepared-model-runtime.facts.ts @@ -175,6 +175,7 @@ export async function prepareWorkspaceBuildGroup( options: { providerDiscoveryProviderIds?: readonly string[] } = {}, loadInboundPluginRegistry?: PreparedInboundRegistryLoader, reusablePluginGeneration?: PreparedModelRuntimePluginGeneration, + preparedPluginMetadataSnapshot?: PreparedModelRuntimePluginGeneration["pluginMetadataSnapshot"], ): Promise<{ agentFacts: PreparedModelRuntimeAgentFacts[]; pluginGeneration: PreparedModelRuntimePluginGeneration; @@ -193,23 +194,24 @@ export async function prepareWorkspaceBuildGroup( throw new Error("prepared model runtime workspace group is empty"); } const env = input.env ?? process.env; + const pluginMetadataStartedAt = performance.now(); + const pluginMetadataSnapshot = + preparedPluginMetadataSnapshot ?? + reusablePluginGeneration?.pluginMetadataSnapshot ?? + prepareOwnedPluginLoadContext(input, env, undefined); + const pluginMetadataMs = reusablePluginGeneration + ? 0 + : performance.now() - pluginMetadataStartedAt; const runtimePluginStartedAt = performance.now(); const { inboundPluginRegistry, runtimePluginRegistry } = reusablePluginGeneration ? { inboundPluginRegistry: reusablePluginGeneration.inboundPluginRegistry, runtimePluginRegistry: reusablePluginGeneration.pluginRegistry, } - : prepareWorkspacePluginRegistries(input, loadInboundPluginRegistry); + : prepareWorkspacePluginRegistries(input, pluginMetadataSnapshot, loadInboundPluginRegistry); const runtimePluginMs = reusablePluginGeneration ? 0 : performance.now() - runtimePluginStartedAt; - const prepare = async ( - preparedMetadataSnapshot?: PreparedModelRuntimePluginGeneration["pluginMetadataSnapshot"], - ) => { - const pluginMetadataStartedAt = performance.now(); - const pluginMetadataSnapshot = - preparedMetadataSnapshot ?? prepareOwnedPluginLoadContext(input, env, runtimePluginRegistry); - const pluginMetadataMs = reusablePluginGeneration - ? 0 - : performance.now() - pluginMetadataStartedAt; + prepareOwnedPluginLoadContext(input, env, runtimePluginRegistry, pluginMetadataSnapshot); + const prepare = async () => { const matchesStaticModelId = createStaticModelIdMatcher({ manifestPlugins: pluginMetadataSnapshot.plugins, }); @@ -421,7 +423,7 @@ export async function prepareWorkspaceBuildGroup( return reusablePluginGeneration ? await withPreparedPluginGenerationScope( { input, pluginGeneration: reusablePluginGeneration }, - prepare, + () => prepare(), ) : await withPluginRuntimeRegistryScope(runtimePluginRegistry, prepare); } @@ -489,18 +491,14 @@ export async function prepareFullCatalogFacts( inlineProviderModels: pluginGeneration.inlineProviderModels, }; } - /** Reports whether a catalog came from the complete prepared-catalog build path. */ -export function isPreparedModelCatalogFull(snapshot: ModelCatalogSnapshot): boolean { - return fullModelCatalogSnapshots.has(snapshot); -} - +export const isPreparedModelCatalogFull = (snapshot: ModelCatalogSnapshot): boolean => + fullModelCatalogSnapshots.has(snapshot); /** Restores process-local provenance after a complete catalog crosses a worker boundary. */ export function markPreparedModelCatalogFull(snapshot: ModelCatalogSnapshot): ModelCatalogSnapshot { fullModelCatalogSnapshots.add(snapshot); return snapshot; } - function captureModelsJsonContents(agentDir: string): string | null { try { return fs.readFileSync(path.join(agentDir, "models.json"), "utf8"); @@ -511,17 +509,13 @@ function captureModelsJsonContents(agentDir: string): string | null { throw error; } } - -export function fingerprintPreparedRuntimeFacts(value: unknown): string { - return sha256Base64Url(stableStringify(value)); -} - +export const fingerprintPreparedRuntimeFacts = (value: unknown): string => + sha256Base64Url(stableStringify(value)); function hasSameOAuthProviderGeneration( left: ReturnType, right: ReturnType, ): boolean { - // OAuth descriptors carry executable hooks. Match those hooks by identity so equivalent - // AuthStorage instances share built-ins without merging distinct closure generations. + // Match executable hooks by identity so distinct AuthStorage closure generations never merge. return ( left.length === right.length && left.every((provider, index) => { @@ -547,8 +541,7 @@ function groupConfiguredRegistrySources( for (const facts of agentFacts) { const modelsJsonContents = captureModelsJsonContents(facts.input.agentDir); const oauthProviders = facts.templateAuthStorage.getOAuthProviders(); - // Generated catalogs are agent-owned. Capture only plugins needed by unresolved configured - // refs, then group exact bytes and OAuth behavior so publication never mixes generations. + // Capture only unresolved configured catalogs, then group exact bytes and OAuth behavior. const pluginCatalogs = loadPersistedPluginModelCatalogsReadOnly( facts.input.agentDir, facts.configuredGeneratedCatalogPluginIds, diff --git a/src/agents/prepared-model-runtime.inbound-registry.test.ts b/src/agents/prepared-model-runtime.inbound-registry.test.ts index 22e705ee652b..39b533bcb265 100644 --- a/src/agents/prepared-model-runtime.inbound-registry.test.ts +++ b/src/agents/prepared-model-runtime.inbound-registry.test.ts @@ -48,6 +48,7 @@ describe("prepared reply dispatch runtime", () => { gatewayLifecycle: true, catalogMode: "static", allowGatewaySubagentBinding: true, + pluginMetadataSnapshot: mocks.pluginMetadataSnapshot as never, }); const input = { agentId: "default", @@ -59,7 +60,7 @@ describe("prepared reply dispatch runtime", () => { }; const firstSnapshot = getPreparedModelRuntimeSnapshot(input); const firstRuntime = await loadPublishedGatewayReplyDispatchRuntime({ agentId: "default" }); - expect(firstRuntime).toEqual({ + expect(firstRuntime).toMatchObject({ agentId: "default", agentDir: "/tmp/unused-agent", workspaceDir: "/tmp/unused-workspace", @@ -67,6 +68,10 @@ describe("prepared reply dispatch runtime", () => { modelCatalog: firstSnapshot?.modelCatalog, inboundPluginRegistry: firstRegistry, }); + expect(firstRuntime?.pluginGeneration?.pluginMetadataSnapshot).toBe( + mocks.pluginMetadataSnapshot, + ); + expect(firstSnapshot?.metadataSnapshot).toBe(mocks.pluginMetadataSnapshot); expect(Object.isFrozen(firstRuntime)).toBe(true); const replacementCatalog = createDeferred<{ entries: [] }>(); @@ -74,6 +79,7 @@ describe("prepared reply dispatch runtime", () => { const refresh = refreshPreparedModelRuntimeSnapshots(replacementConfig, { catalogMode: "static", allowGatewaySubagentBinding: true, + pluginMetadataSnapshot: mocks.pluginMetadataSnapshot as never, }); await vi.waitFor(() => expect(mocks.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledTimes(4), @@ -138,28 +144,6 @@ describe("prepared reply dispatch runtime", () => { expect(mocks.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledTimes(publicationLoadCount); }); - it("projects shared plugin metadata into each configured runtime workspace", async () => { - mocks.configuredAgentIds = ["default"]; - const workspaceDir = "/tmp/configured-metadata-workspace"; - const config = retainLegacyDefaultAgentId({ agents: { entries: { default: {} } } }, "default"); - await refreshPreparedModelRuntimeSnapshots(config, { - gatewayLifecycle: true, - catalogMode: "static", - defaultWorkspaceDir: workspaceDir, - }); - - const snapshot = getPreparedModelRuntimeSnapshot({ - agentId: "default", - agentDir: "/tmp/unused-agent", - inheritedAuthDir: "/tmp/unused-agent", - config, - workspaceDir, - }); - - expect(snapshot?.metadataSnapshot.workspaceDir).toBe(workspaceDir); - expect(snapshot?.metadataSnapshot.index.workspaceDir).toBe(workspaceDir); - }); - it("reuses configured and retained dynamic plugin generations during auth refresh", async () => { mocks.configuredAgentIds = ["default"]; const workspaceDir = "/tmp/dynamic-auth-workspace"; diff --git a/src/agents/prepared-model-runtime.inbound-registry.ts b/src/agents/prepared-model-runtime.inbound-registry.ts index 728fb73ecef0..ba34e3273527 100644 --- a/src/agents/prepared-model-runtime.inbound-registry.ts +++ b/src/agents/prepared-model-runtime.inbound-registry.ts @@ -1,9 +1,13 @@ import { hashRuntimeConfigValue } from "../config/runtime-snapshot.js"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; import type { PluginRegistry } from "../plugins/registry-types.js"; import type { PreparedModelRuntimeInput } from "./prepared-model-runtime.types.js"; import { loadAgentRuntimePluginRegistryHandle } from "./runtime-plugins.js"; -export type PreparedInboundRegistryLoader = (input: PreparedModelRuntimeInput) => PluginRegistry; +export type PreparedInboundRegistryLoader = ( + input: PreparedModelRuntimeInput, + metadataSnapshot: PluginMetadataSnapshot, +) => PluginRegistry; function inboundRegistryIdentity(input: PreparedModelRuntimeInput): string { return JSON.stringify({ @@ -30,7 +34,7 @@ export function preparedModelRuntimeWorkspaceFactsKey(input: PreparedModelRuntim /** Creates one lifecycle-batch loader that shares exact generic registry identities. */ export function createPreparedInboundRegistryLoader(): PreparedInboundRegistryLoader { const registries = new Map(); - return (input) => { + return (input, metadataSnapshot) => { const key = inboundRegistryIdentity(input); const existing = registries.get(key); if (existing) { @@ -41,6 +45,7 @@ export function createPreparedInboundRegistryLoader(): PreparedInboundRegistryLo env: input.env ?? process.env, ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), ...(input.allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true } : {}), + metadataSnapshot, }); registries.set(key, registry); return registry; @@ -50,6 +55,7 @@ export function createPreparedInboundRegistryLoader(): PreparedInboundRegistryLo /** Prepares distinct generic-inbound and model-selected registries for one workspace generation. */ export function prepareWorkspacePluginRegistries( input: PreparedModelRuntimeInput, + metadataSnapshot: PluginMetadataSnapshot, loadInboundRegistry?: PreparedInboundRegistryLoader, ): { runtimePluginRegistry?: PluginRegistry; @@ -60,7 +66,9 @@ export function prepareWorkspacePluginRegistries( if (input.readOnly && !input.loadRuntimePlugins && !input.runtimePluginSelections) { return {}; } - const inboundPluginRegistry = input.readOnly ? undefined : loadInboundRegistry?.(input); + const inboundPluginRegistry = input.readOnly + ? undefined + : loadInboundRegistry?.(input, metadataSnapshot); const runtimePluginRegistry = input.runtimePluginSelections || !inboundPluginRegistry ? loadAgentRuntimePluginRegistryHandle({ @@ -68,6 +76,7 @@ export function prepareWorkspacePluginRegistries( env: input.env ?? process.env, ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), ...(input.allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true } : {}), + metadataSnapshot, selections: input.runtimePluginSelections, }) : inboundPluginRegistry; diff --git a/src/agents/prepared-model-runtime.owner.ts b/src/agents/prepared-model-runtime.owner.ts index feab2c44bf20..f9d38b238f72 100644 --- a/src/agents/prepared-model-runtime.owner.ts +++ b/src/agents/prepared-model-runtime.owner.ts @@ -405,6 +405,7 @@ export async function publishPreparedModelRuntimeOwnerBatch(params: { onBuildStats?: (stats: PreparedModelRuntimeBuildStats) => void; registerEntriesAfterBuildStart?: boolean; reusePluginGenerations?: boolean; + pluginMetadataSnapshot?: PreparedModelRuntimePluginGeneration["pluginMetadataSnapshot"]; }): Promise { const candidates = params.entries.map(({ owner, input }) => { owner.input = input; @@ -486,6 +487,7 @@ export async function publishPreparedModelRuntimeOwnerBatch(params: { ), ) : undefined, + params.pluginMetadataSnapshot, ); for (const candidate of currentGroup) { if (params.registerEntriesAfterBuildStart === true) { @@ -575,6 +577,7 @@ export async function publishModelRuntimeSnapshot( provenance: PreparedModelRuntimeOwner["provenance"] = "explicit", catalogMode: PreparedModelRuntimeCatalogMode = existing?.catalogMode ?? "live", reusablePluginGeneration?: PreparedModelRuntimePluginGeneration, + pluginMetadataSnapshot?: PreparedModelRuntimePluginGeneration["pluginMetadataSnapshot"], ): Promise { const key = ownerKey(input); const owner = existing ?? createPreparedModelRuntimeOwner(input, provenance, catalogMode); @@ -595,6 +598,7 @@ export async function publishModelRuntimeSnapshot( () => owner.generation === generation && owners.get(key) === owner, provenance === "configured", reusablePluginGeneration, + pluginMetadataSnapshot, ); owner.buildCompletion = build.completion; void build.completion.then(() => { diff --git a/src/agents/prepared-model-runtime.plugin-context.test.ts b/src/agents/prepared-model-runtime.plugin-context.test.ts new file mode 100644 index 000000000000..562f3e3d8add --- /dev/null +++ b/src/agents/prepared-model-runtime.plugin-context.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createPluginMetadataSnapshot, + makeRegistry, +} from "../config/plugin-auto-enable.test-helpers.js"; +import * as currentPluginMetadata from "../plugins/current-plugin-metadata-snapshot.js"; +import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js"; +import * as pluginMetadata from "../plugins/plugin-metadata-snapshot.js"; +import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; +import { + getPreparedPluginRuntimeLoadContext, + prepareOwnedPluginLoadContext, +} from "./prepared-model-runtime.plugin-context.js"; +import { withPreparedPluginGenerationScope } from "./prepared-model-runtime.plugin-generation.js"; + +describe("prepared model runtime plugin metadata ownership", () => { + afterEach(() => { + clearPluginMetadataLifecycleCaches(); + }); + + it("uses one explicit Gateway metadata generation across agent workspaces", () => { + const config = { plugins: { allow: ["synthetic"] } }; + const gatewayWorkspace = "/tmp/gateway-plugin-workspace"; + const gatewaySnapshot = createPluginMetadataSnapshot({ + config, + manifestRegistry: makeRegistry([{ id: "synthetic", channels: [] }]), + workspaceDir: gatewayWorkspace, + }); + const inputs = ["first", "second"].map((name) => ({ + agentDir: `/tmp/${name}-agent`, + config, + workspaceDir: `/tmp/${name}-workspace`, + workspacePluginRootPresent: false, + })); + const pluginGeneration = { + configuredCatalogEntries: [], + inlineProviderModels: [], + pluginMetadataSnapshot: gatewaySnapshot, + }; + const resolveMetadata = vi.spyOn(pluginMetadata, "loadPluginMetadataSnapshot"); + const getCurrentMetadata = vi.spyOn(currentPluginMetadata, "getCurrentPluginMetadataSnapshot"); + + try { + for (const input of inputs) { + const registry = createEmptyPluginRegistry(); + expect(prepareOwnedPluginLoadContext(input, process.env, registry, gatewaySnapshot)).toBe( + gatewaySnapshot, + ); + expect(getPreparedPluginRuntimeLoadContext(registry)?.metadataSnapshot).toBe( + gatewaySnapshot, + ); + expect( + withPreparedPluginGenerationScope({ input, pluginGeneration }, (snapshot) => snapshot), + ).toBe(gatewaySnapshot); + } + expect(getCurrentMetadata).not.toHaveBeenCalled(); + expect(resolveMetadata).not.toHaveBeenCalled(); + } finally { + getCurrentMetadata.mockRestore(); + resolveMetadata.mockRestore(); + } + }); + + it("keeps direct no-current preparation on the requested workspace", () => { + const config = { plugins: { allow: ["synthetic"] } }; + const workspaceDir = "/tmp/direct-plugin-workspace"; + const directSnapshot = createPluginMetadataSnapshot({ + config, + manifestRegistry: makeRegistry([{ id: "synthetic", channels: [] }]), + workspaceDir, + }); + const resolveMetadata = vi + .spyOn(pluginMetadata, "loadPluginMetadataSnapshot") + .mockReturnValue(directSnapshot); + + try { + expect( + prepareOwnedPluginLoadContext( + { + agentDir: "/tmp/direct-agent", + config, + workspaceDir, + workspacePluginRootPresent: false, + }, + process.env, + undefined, + ), + ).toBe(directSnapshot); + expect(resolveMetadata).toHaveBeenCalledWith({ + config, + env: process.env, + workspaceDir, + }); + } finally { + resolveMetadata.mockRestore(); + } + }); +}); diff --git a/src/agents/prepared-model-runtime.plugin-context.ts b/src/agents/prepared-model-runtime.plugin-context.ts index 4b2e8b9c5286..99084d5debc2 100644 --- a/src/agents/prepared-model-runtime.plugin-context.ts +++ b/src/agents/prepared-model-runtime.plugin-context.ts @@ -1,9 +1,6 @@ import type { PluginDiscoveryResult } from "../plugins/discovery.js"; import { extractPluginInstallRecordsFromInstalledPluginIndex } from "../plugins/installed-plugin-index-install-records.js"; -import { - projectPluginMetadataSnapshotWorkspace, - resolvePluginMetadataSnapshot, -} from "../plugins/plugin-metadata-snapshot.js"; +import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; import type { PluginRegistry } from "../plugins/registry-types.js"; import { @@ -32,9 +29,10 @@ function preparePluginLoadContext( registry: PluginRegistry | undefined, metadataSnapshot: PluginMetadataSnapshot, ): PluginRuntimeLoadContext & { metadataSnapshot: PluginMetadataSnapshot } { - const { config, workspaceDir } = input; - // The prepared owner already resolved metadata for this exact config/env/workspace tuple. - // Missing discovery facts stay empty here instead of reopening cold channel discovery. + const { config } = input; + const workspaceDir = metadataSnapshot.workspaceDir ?? input.workspaceDir; + // The prepared owner already selected the exact metadata generation for this runtime. + // Missing discovery facts stay empty here instead of reopening cold plugin discovery. const preparedMetadataSnapshot = metadataSnapshot.discovery ? metadataSnapshot : { ...metadataSnapshot, discovery: emptyPluginDiscovery }; @@ -61,25 +59,23 @@ export function prepareOwnedPluginLoadContext( input: PreparedModelRuntimeInput, env: NodeJS.ProcessEnv, registry: PluginRegistry | undefined, + preparedMetadataSnapshot?: PluginMetadataSnapshot, ): PluginMetadataSnapshot { - const resolvedMetadataSnapshot = resolvePluginMetadataSnapshot({ + const metadataSnapshot = preparedMetadataSnapshot ?? resolveColdMetadataSnapshot(input, env); + preparePluginLoadContext(input, env, registry, metadataSnapshot); + return metadataSnapshot; +} + +function resolveColdMetadataSnapshot( + input: PreparedModelRuntimeInput, + env: NodeJS.ProcessEnv, +): PluginMetadataSnapshot { + const resolvedMetadataSnapshot = loadPluginMetadataSnapshot({ config: input.config, env, ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), - ...(input.workspacePluginRootPresent === undefined - ? {} - : { workspacePluginRootPresent: input.workspacePluginRootPresent }), }); - const metadataSnapshot = input.workspaceDir - ? projectPluginMetadataSnapshotWorkspace({ - snapshot: resolvedMetadataSnapshot, - config: input.config, - env, - workspaceDir: input.workspaceDir, - }) - : resolvedMetadataSnapshot; - preparePluginLoadContext(input, env, registry, metadataSnapshot); - return metadataSnapshot; + return resolvedMetadataSnapshot; } /** Reads plugin facts carried by a lifecycle-owned prepared runtime snapshot. */ diff --git a/src/agents/prepared-model-runtime.plugin-generation.ts b/src/agents/prepared-model-runtime.plugin-generation.ts index f44c65fad877..62bdc040c000 100644 --- a/src/agents/prepared-model-runtime.plugin-generation.ts +++ b/src/agents/prepared-model-runtime.plugin-generation.ts @@ -1,4 +1,3 @@ -import { projectPluginMetadataSnapshotWorkspace } from "../plugins/plugin-metadata-snapshot.js"; import { withPluginRuntimeGenerationScope } from "../plugins/runtime/generation-scope.js"; import { augmentPreparedModelCatalogWithAgentHarness } from "./harness/model-catalog.js"; import { buildPreparedModelCatalogSnapshot } from "./model-catalog.js"; @@ -48,25 +47,6 @@ export function createPreparedPluginGeneration(params: { }); } -export function projectPreparedPluginGeneration(params: { - input: PreparedModelRuntimeInput; - pluginGeneration: PreparedModelRuntimePluginGeneration; -}): PreparedModelRuntimePluginGeneration { - const { input, pluginGeneration } = params; - if (!input.workspaceDir) { - return pluginGeneration; - } - const pluginMetadataSnapshot = projectPluginMetadataSnapshotWorkspace({ - snapshot: pluginGeneration.pluginMetadataSnapshot, - config: input.config, - env: input.env ?? process.env, - workspaceDir: input.workspaceDir, - }); - return pluginMetadataSnapshot === pluginGeneration.pluginMetadataSnapshot - ? pluginGeneration - : Object.freeze({ ...pluginGeneration, pluginMetadataSnapshot }); -} - export async function buildPreparedPluginModelCatalog(params: { agentFacts: { credentials: Parameters[0]["authCredentials"]; @@ -110,15 +90,13 @@ export function withPreparedPluginGenerationScope( }, run: (metadataSnapshot: PreparedModelRuntimePluginGeneration["pluginMetadataSnapshot"]) => T, ): T { - const { input } = params; - const pluginGeneration = projectPreparedPluginGeneration(params); + const { input, pluginGeneration } = params; const metadataSnapshot = pluginGeneration.pluginMetadataSnapshot; return withPluginRuntimeGenerationScope( { config: input.config, metadataSnapshot, pluginRegistry: pluginGeneration.pluginRegistry, - ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), }, () => run(metadataSnapshot), ); diff --git a/src/agents/prepared-model-runtime.startup-static.test.ts b/src/agents/prepared-model-runtime.startup-static.test.ts index 27107c0788c4..065dfc6bf6ba 100644 --- a/src/agents/prepared-model-runtime.startup-static.test.ts +++ b/src/agents/prepared-model-runtime.startup-static.test.ts @@ -395,23 +395,17 @@ describe("prepared model runtime Gateway catalog mode", () => { expect(mocks.discoverModels).toHaveBeenLastCalledWith( mocks.authStorage, expect.objectContaining({ + config, includePluginCatalogs: true, modelsJsonContents: null, pluginCatalogs: [], - pluginMetadataSnapshot: expect.objectContaining({ - ...mocks.metadataSnapshot, - index: expect.objectContaining({ - ...mocks.metadataSnapshot.index, - workspaceDir: "/tmp/prepared-static-workspace", - }), - workspaceDir: "/tmp/prepared-static-workspace", - }), + pluginMetadataSnapshot: mocks.metadataSnapshot, workspaceDir: "/tmp/prepared-static-workspace", }), ); expect(mocks.buildPreparedModelCatalogSnapshot).not.toHaveBeenCalled(); expect(mocks.loadStaticCatalog).not.toHaveBeenCalled(); - expect(mocks.resolvePluginMetadataSnapshot).toHaveBeenCalledTimes(2); + expect(mocks.resolvePluginMetadataSnapshot).toHaveBeenCalledOnce(); expect(configuredRuntimeModelCount).toBe(1); expect(generatedCatalogReadCount).toBe(0); const snapshot = getPreparedModelRuntimeSnapshot({ diff --git a/src/agents/prepared-model-runtime.test-harness.ts b/src/agents/prepared-model-runtime.test-harness.ts index 5c16a00954ce..dfeabd209588 100644 --- a/src/agents/prepared-model-runtime.test-harness.ts +++ b/src/agents/prepared-model-runtime.test-harness.ts @@ -85,13 +85,6 @@ const preparedModelRuntimeMocks = vi.hoisted(() => ({ vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({ isPluginMetadataSnapshotCompatible: () => true, loadPluginMetadataSnapshot: () => preparedModelRuntimeMocks.pluginMetadataSnapshot, - projectPluginMetadataSnapshotWorkspace: ({ - snapshot, - workspaceDir, - }: { - snapshot: typeof preparedModelRuntimeMocks.pluginMetadataSnapshot & { workspaceDir?: string }; - workspaceDir: string; - }) => ({ ...snapshot, index: { ...snapshot.index, workspaceDir }, workspaceDir }), resolvePluginMetadataSnapshot: () => preparedModelRuntimeMocks.pluginMetadataSnapshot, })); diff --git a/src/agents/prepared-model-runtime.test.ts b/src/agents/prepared-model-runtime.test.ts index 61a6e6575b51..7690622ec33b 100644 --- a/src/agents/prepared-model-runtime.test.ts +++ b/src/agents/prepared-model-runtime.test.ts @@ -111,12 +111,15 @@ describe("prepared model runtime snapshots", () => { mocks.loadAgentRuntimePluginRegistryHandle.mockReturnValue(pluginRegistry); expect( - prepareWorkspacePluginRegistries({ - config: {}, - agentDir: "/tmp/native-provider-probe", - readOnly: true, - loadRuntimePlugins: true, - }).runtimePluginRegistry, + prepareWorkspacePluginRegistries( + { + config: {}, + agentDir: "/tmp/native-provider-probe", + readOnly: true, + loadRuntimePlugins: true, + }, + mocks.pluginMetadataSnapshot as never, + ).runtimePluginRegistry, ).toBe(pluginRegistry); expect(mocks.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledWith( expect.objectContaining({ selections: undefined }), @@ -180,6 +183,7 @@ describe("prepared model runtime snapshots", () => { expect(mocks.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledWith({ config: {}, env: process.env, + metadataSnapshot: mocks.pluginMetadataSnapshot, workspaceDir: "/tmp/prepared-model-runtime-plugin-workspace", selections: undefined, }); diff --git a/src/agents/prepared-model-runtime.ts b/src/agents/prepared-model-runtime.ts index 65ff6ee41fa4..26589dcbcc18 100644 --- a/src/agents/prepared-model-runtime.ts +++ b/src/agents/prepared-model-runtime.ts @@ -290,6 +290,7 @@ export async function acquireAgentRunPreparedModelRuntime( options: { retainIdleRunOwner?: boolean; catalogMode?: PreparedModelRuntimeCatalogMode; + pluginGeneration?: PreparedModelRuntimeOwner["pluginGeneration"]; } = {}, ): Promise { return await acquirePreparedModelRuntimeLeaseFromOwners( @@ -490,6 +491,7 @@ async function refreshPreparedModelRuntimeSnapshotsNow( // candidates, while a newer config epoch stops every remaining build in this publication. isBuildCurrent: () => publicationEpoch === refreshRequestEpoch, onBuildStats: options.onBuildStats, + pluginMetadataSnapshot: options.pluginMetadataSnapshot, registerEntriesAfterBuildStart: true, }); } diff --git a/src/agents/prepared-model-runtime.types.ts b/src/agents/prepared-model-runtime.types.ts index b33c8c95f106..208ca658e29a 100644 --- a/src/agents/prepared-model-runtime.types.ts +++ b/src/agents/prepared-model-runtime.types.ts @@ -72,6 +72,7 @@ export type PreparedReplyDispatchRuntime = Readonly<{ config: OpenClawConfig; modelCatalog: ModelCatalogSnapshot; inboundPluginRegistry: PluginRegistry; + pluginGeneration?: PreparedModelRuntimePluginGeneration; }>; export type PreparedModelRuntimeStores = { @@ -114,6 +115,7 @@ export type PreparedModelRuntimeRefreshOptions = { catalogMode?: PreparedModelRuntimeCatalogMode; onBuildStats?: (stats: PreparedModelRuntimeBuildStats) => void; allowGatewaySubagentBinding?: boolean; + pluginMetadataSnapshot?: PluginMetadataSnapshot; }; export type PreparedModelRuntimeBuildStats = Readonly<{ diff --git a/src/agents/prepared-reply-dispatch-runtime.ts b/src/agents/prepared-reply-dispatch-runtime.ts index e73a6e6b428d..3eed837f04e2 100644 --- a/src/agents/prepared-reply-dispatch-runtime.ts +++ b/src/agents/prepared-reply-dispatch-runtime.ts @@ -18,8 +18,9 @@ function createReplyDispatchRuntime( ): PreparedReplyDispatchRuntime { const snapshot = runtimeOwner.snapshot!; const owner = resolvePublishedModelCatalogOwner(snapshot); - const inboundPluginRegistry = runtimeOwner.pluginGeneration?.inboundPluginRegistry; - if (!inboundPluginRegistry) { + const pluginGeneration = runtimeOwner.pluginGeneration; + const inboundPluginRegistry = pluginGeneration?.inboundPluginRegistry; + if (!pluginGeneration || !inboundPluginRegistry) { throw new PreparedModelRuntimeOwnerNotPublishedError( `prepared inbound plugin registry was not published for ${snapshot.agentDir}`, ); @@ -31,6 +32,7 @@ function createReplyDispatchRuntime( config: owner.config, modelCatalog: owner.modelCatalog, inboundPluginRegistry, + pluginGeneration, }); } diff --git a/src/agents/runtime-plan/build.test.ts b/src/agents/runtime-plan/build.test.ts index 06b1d6dd3e9e..75ff9021eb7b 100644 --- a/src/agents/runtime-plan/build.test.ts +++ b/src/agents/runtime-plan/build.test.ts @@ -2,7 +2,7 @@ // auth, transport, tools, prompt, delivery, transcript, and observability. import { createParameterFreeTool } from "openclaw/plugin-sdk/agent-runtime-test-contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { resetConfigRuntimeState, setRuntimeConfigSnapshot } from "../../config/config.js"; +import { resetConfigRuntimeState } from "../../config/config.js"; import { prepareProviderExtraParams, resolveProviderFollowupFallbackRoute, @@ -11,7 +11,6 @@ import { } from "../../plugins/provider-hook-runtime.js"; import { buildAgentRuntimeDeliveryPlan, buildAgentRuntimePlan } from "./build.js"; -const isPluginMetadataSnapshotCompatible = vi.hoisted(() => vi.fn(() => true)); const resolveProviderIdForAuth = vi.hoisted(() => vi.fn((provider: string) => provider)); vi.mock("../provider-auth-aliases.js", async (importOriginal) => ({ @@ -37,11 +36,6 @@ 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", @@ -419,24 +413,4 @@ describe("AgentRuntimePlan", () => { expect.objectContaining({ 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, - metadataSnapshot, - }); - - 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 e40212fef12d..8afdfc66c9cc 100644 --- a/src/agents/runtime-plan/build.ts +++ b/src/agents/runtime-plan/build.ts @@ -6,13 +6,8 @@ import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; */ import type { TSchema } from "typebox"; import { isSilentReplyPayloadText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; -import { projectConfigOntoRuntimeSourceSnapshot } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { hasReplyPayloadContent } from "../../interactive/payload.js"; -import { - isPluginMetadataSnapshotCompatible, - resolvePluginMetadataSnapshot, -} from "../../plugins/plugin-metadata-snapshot.js"; import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import { resolveProviderRuntimePluginHandle, @@ -60,21 +55,10 @@ type RuntimePlanMetadataParams = BuildAgentRuntimeDeliveryPlanParams & { metadataSnapshot?: BuildAgentRuntimePlanParams["metadataSnapshot"]; }; -function resolveCompatibleMetadataSnapshot( +function resolvePreparedMetadataSnapshot( 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; + return params.metadataSnapshot as PluginMetadataSnapshot | undefined; } function resolvePreparedProviderRuntimeHandle( @@ -91,7 +75,7 @@ function resolvePreparedProviderRuntimeHandle( prepared: true; }; } - const compatibleMetadataSnapshot = resolveCompatibleMetadataSnapshot(params); + const metadataSnapshot = resolvePreparedMetadataSnapshot(params); return { ...resolveProviderRuntimePluginHandle({ provider: params.provider, @@ -99,7 +83,7 @@ function resolvePreparedProviderRuntimeHandle( config: asOpenClawConfig(params.config), workspaceDir: params.workspaceDir, env: process.env, - ...(compatibleMetadataSnapshot ? { pluginMetadataSnapshot: compatibleMetadataSnapshot } : {}), + ...(metadataSnapshot ? { pluginMetadataSnapshot: metadataSnapshot } : {}), }), modelId: params.modelId, prepared: true, @@ -155,21 +139,10 @@ export function buildAgentRuntimePlan(params: BuildAgentRuntimePlanParams): Agen const model = asProviderRuntimeModel(params.model); const modelApi = params.modelApi ?? params.model?.api ?? undefined; const transport = params.resolvedTransport; - const toolPlanningConfig = config ? projectConfigOntoRuntimeSourceSnapshot(config) : undefined; - const toolPlanningMetadataSnapshot = resolveCompatibleMetadataSnapshot( - params, - toolPlanningConfig, - ); + const toolPlanningMetadataSnapshot = resolvePreparedMetadataSnapshot(params); const preparedPlanning = toolPlanningMetadataSnapshot ? { metadataSnapshot: toolPlanningMetadataSnapshot } - : { - loadMetadataSnapshot: () => - resolvePluginMetadataSnapshot({ - config: toolPlanningConfig, - ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), - env: process.env, - }), - }; + : undefined; const providerRuntimeHandleForPlugins = resolvePreparedProviderRuntimeHandle(params); const auth = params.preparedAuthPlan ?? diff --git a/src/agents/runtime-plan/types.ts b/src/agents/runtime-plan/types.ts index 9d206f60cf7f..f33275bd8b3f 100644 --- a/src/agents/runtime-plan/types.ts +++ b/src/agents/runtime-plan/types.ts @@ -434,7 +434,6 @@ type AgentRuntimePreparedMetadataSnapshot = object; /** Prepared metadata loader used by tool planning without eager manifest reads. */ type PreparedOpenClawToolPlanning = { metadataSnapshot?: AgentRuntimePreparedMetadataSnapshot; - loadMetadataSnapshot?: () => AgentRuntimePreparedMetadataSnapshot; }; /** Tool normalization and diagnostics hooks for one runtime attempt. */ diff --git a/src/agents/runtime-plugins.test.ts b/src/agents/runtime-plugins.test.ts index 37b790cd4015..0fffcd0716ba 100644 --- a/src/agents/runtime-plugins.test.ts +++ b/src/agents/runtime-plugins.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const hoisted = vi.hoisted(() => ({ - getCurrentPluginMetadataSnapshot: vi.fn(), + loadPluginMetadataSnapshot: vi.fn(), getActivePluginRegistry: vi.fn(), loadPluginRegistryHandle: vi.fn(), adoptRuntimeContextEngineRegistrations: vi.fn((target: unknown) => target), @@ -17,8 +17,8 @@ vi.mock("../plugins/runtime.js", () => ({ getActivePluginRegistry: hoisted.getActivePluginRegistry, })); -vi.mock("../plugins/current-plugin-metadata-snapshot.js", () => ({ - getCurrentPluginMetadataSnapshot: hoisted.getCurrentPluginMetadataSnapshot, +vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({ + loadPluginMetadataSnapshot: hoisted.loadPluginMetadataSnapshot, })); vi.mock("../plugins/loader.js", () => ({ @@ -38,9 +38,27 @@ import { withAgentPluginRegistry, } from "./runtime-plugins.js"; +function createMetadataSnapshot( + workspaceDir = "/tmp/gateway-workspace", + pluginIds: string[] | undefined = ["telegram", "memory-core"], +) { + return { + workspaceDir, + index: { installRecords: {}, plugins: [] }, + manifestRegistry: { diagnostics: [], plugins: [] }, + discovery: { candidates: [], diagnostics: [] }, + pluginIds, + }; +} + describe("agent runtime plugin registries", () => { beforeEach(() => { - hoisted.getCurrentPluginMetadataSnapshot.mockReset().mockReturnValue(undefined); + hoisted.loadPluginMetadataSnapshot + .mockReset() + .mockImplementation((params: { workspaceDir?: string }) => ({ + ...createMetadataSnapshot(params.workspaceDir), + pluginIds: undefined, + })); hoisted.getActivePluginRegistry.mockReset().mockReturnValue(undefined); hoisted.loadPluginRegistryHandle.mockReset().mockReturnValue({ handle: true }); hoisted.adoptRuntimeContextEngineRegistrations @@ -67,7 +85,7 @@ describe("agent runtime plugin registries", () => { ); }); - it("returns a non-activating handle for a prepared runtime", () => { + it("keeps direct no-current loads on the requested workspace", () => { const config = {} as never; const env = { OPENCLAW_STATE_DIR: "/tmp/openclaw-state" }; const selections = [{ provider: "openai", modelId: "gpt-5.5", runtime: "codex" }]; @@ -81,7 +99,8 @@ describe("agent runtime plugin registries", () => { selections, }), ).toEqual({ handle: true }); - expect(hoisted.getCurrentPluginMetadataSnapshot).toHaveBeenCalledWith({ + const metadataSnapshot = hoisted.loadPluginMetadataSnapshot.mock.results[0]?.value; + expect(hoisted.loadPluginMetadataSnapshot).toHaveBeenCalledWith({ config, env, workspaceDir: "/tmp/workspace", @@ -90,12 +109,16 @@ describe("agent runtime plugin registries", () => { config, workspaceDir: "/tmp/workspace", selections, + metadataSnapshot, }); expect(hoisted.loadPluginRegistryHandle).toHaveBeenCalledWith({ activate: false, config, activationSourceConfig: config, env, + discovery: metadataSnapshot.discovery, + installRecords: {}, + manifestRegistry: metadataSnapshot.manifestRegistry, workspaceDir: "/tmp/workspace", runtimeOptions: { allowGatewaySubagentBinding: true }, }); @@ -120,17 +143,20 @@ describe("agent runtime plugin registries", () => { it("preserves the gateway startup scope and ordering", () => { const config = {} as never; - hoisted.getCurrentPluginMetadataSnapshot.mockReturnValue({ - startup: { pluginIds: ["telegram", "memory-core"] }, - }); + const metadataSnapshot = createMetadataSnapshot(); - loadAgentRuntimePluginRegistryHandle({ config, workspaceDir: "/tmp/workspace" }); + loadAgentRuntimePluginRegistryHandle({ + config, + workspaceDir: "/tmp/workspace", + metadataSnapshot: metadataSnapshot as never, + }); expect(hoisted.resolveAgentRuntimePluginLoadPlan).toHaveBeenCalledWith({ config, - workspaceDir: "/tmp/workspace", + workspaceDir: "/tmp/gateway-workspace", basePluginIds: ["telegram", "memory-core"], selections: [], + metadataSnapshot, }); expect(hoisted.loadPluginRegistryHandle).toHaveBeenCalledWith( expect.objectContaining({ @@ -141,9 +167,7 @@ describe("agent runtime plugin registries", () => { it("inherits the current request registry before process-wide startup metadata", () => { const config = {} as never; - hoisted.getCurrentPluginMetadataSnapshot.mockReturnValue({ - startup: { pluginIds: ["telegram", "memory-core"] }, - }); + const metadataSnapshot = createMetadataSnapshot(); const requestRegistry = { plugins: [ { id: "memory-core", status: "loaded" }, @@ -152,14 +176,19 @@ describe("agent runtime plugin registries", () => { } as never; withPluginRuntimeRegistryScope(requestRegistry, () => - loadAgentRuntimePluginRegistryHandle({ config, workspaceDir: "/tmp/workspace" }), + loadAgentRuntimePluginRegistryHandle({ + config, + workspaceDir: "/tmp/workspace", + metadataSnapshot: metadataSnapshot as never, + }), ); expect(hoisted.resolveAgentRuntimePluginLoadPlan).toHaveBeenCalledWith({ config, - workspaceDir: "/tmp/workspace", + workspaceDir: "/tmp/gateway-workspace", basePluginIds: ["memory-core"], selections: [], + metadataSnapshot, }); }); @@ -172,12 +201,47 @@ describe("agent runtime plugin registries", () => { workspaceDir: "/tmp/workspace", }); - expect(hoisted.getCurrentPluginMetadataSnapshot).not.toHaveBeenCalled(); + const metadataSnapshot = hoisted.loadPluginMetadataSnapshot.mock.results[0]?.value; expect(hoisted.resolveAgentRuntimePluginLoadPlan).toHaveBeenCalledWith({ config, workspaceDir: "/tmp/workspace", basePluginIds: [], selections: [], + metadataSnapshot, + }); + }); + + it("loads selected runtimes from the Gateway metadata workspace", () => { + const config = {} as never; + const env = { OPENCLAW_STATE_DIR: "/tmp/openclaw-state" }; + const snapshot = createMetadataSnapshot(); + + loadAgentRuntimePluginRegistryHandle({ + config, + env, + workspaceDir: "/tmp/agent-workspace", + metadataSnapshot: snapshot as never, + }); + + expect(hoisted.resolveAgentRuntimePluginLoadPlan).toHaveBeenCalledWith({ + config, + workspaceDir: snapshot.workspaceDir, + basePluginIds: ["telegram", "memory-core"], + selections: [], + metadataSnapshot: snapshot, + }); + expect(hoisted.loadPluginRegistryHandle).toHaveBeenCalledWith({ + activate: false, + activationSourceConfig: config, + channelPluginLoadIntent: "full", + config, + discovery: snapshot.discovery, + env, + installRecords: {}, + manifestRegistry: snapshot.manifestRegistry, + onlyPluginIds: ["codex", "memory-core"], + runtimeOptions: undefined, + workspaceDir: snapshot.workspaceDir, }); }); @@ -200,6 +264,7 @@ describe("agent runtime plugin registries", () => { workspaceDir: "/tmp/workspace", basePluginIds: [], selections: [], + metadataSnapshot: expect.any(Object), }); }); diff --git a/src/agents/runtime-plugins.ts b/src/agents/runtime-plugins.ts index b5e92ec3f7e8..efe829450aad 100644 --- a/src/agents/runtime-plugins.ts +++ b/src/agents/runtime-plugins.ts @@ -2,8 +2,10 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { adoptRuntimeContextEngineRegistrations } from "../context-engine/registry.js"; import { listRuntimePluginIdsFromRegistry } from "../plugins/active-runtime-registry.js"; import { normalizePluginsConfig } from "../plugins/config-state.js"; -import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js"; +import { extractPluginInstallRecordsFromInstalledPluginIndex } from "../plugins/installed-plugin-index-install-records.js"; import { loadPluginRegistryHandle } from "../plugins/loader.js"; +import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; import type { PluginRegistry } from "../plugins/registry-types.js"; import { getActivePluginRegistry } from "../plugins/runtime.js"; import { @@ -17,31 +19,6 @@ import { type AgentHarnessPluginSelection, } from "./harness/runtime-plugin-load-plan.js"; -type StartupScopedPluginSnapshot = NonNullable< - ReturnType -> & { - startup?: { - pluginIds?: readonly unknown[]; - }; -}; - -function resolveStartupPluginIdsFromCurrentSnapshot(params: { - config?: OpenClawConfig; - env?: NodeJS.ProcessEnv; - workspaceDir?: string; -}): string[] | undefined { - const snapshot = getCurrentPluginMetadataSnapshot({ - config: params.config, - env: params.env, - workspaceDir: params.workspaceDir, - }) as StartupScopedPluginSnapshot | undefined; - const pluginIds = snapshot?.startup?.pluginIds; - if (!Array.isArray(pluginIds)) { - return undefined; - } - return pluginIds.filter((pluginId): pluginId is string => typeof pluginId === "string"); -} - type AgentRuntimePluginRegistryParams = { config?: OpenClawConfig; env?: NodeJS.ProcessEnv; @@ -50,10 +27,12 @@ type AgentRuntimePluginRegistryParams = { /** Explicit base scope for hosts without a Gateway startup registry. */ basePluginIds?: readonly string[]; selections?: readonly AgentHarnessPluginSelection[]; + /** Lifecycle-selected metadata. Omission selects one standalone cold generation. */ + metadataSnapshot?: PluginMetadataSnapshot; }; function resolveAgentRuntimePluginRegistryLoad(params: AgentRuntimePluginRegistryParams) { - const workspaceDir = + const requestedWorkspaceDir = typeof params.workspaceDir === "string" && params.workspaceDir.trim() ? resolveUserPath(params.workspaceDir) : undefined; @@ -63,7 +42,7 @@ function resolveAgentRuntimePluginRegistryLoad(params: AgentRuntimePluginRegistr config: params.config, activationSourceConfig: params.config, ...(params.env ? { env: params.env } : {}), - workspaceDir, + workspaceDir: requestedWorkspaceDir, onlyPluginIds: [], runtimeOptions: params.allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true } @@ -71,18 +50,30 @@ function resolveAgentRuntimePluginRegistryLoad(params: AgentRuntimePluginRegistr }, }; } + const metadataSnapshot = + params.metadataSnapshot ?? + loadPluginMetadataSnapshot({ + config: params.config ?? {}, + env: params.env ?? process.env, + ...(requestedWorkspaceDir ? { workspaceDir: requestedWorkspaceDir } : {}), + }); + const workspaceDir = metadataSnapshot.workspaceDir ?? requestedWorkspaceDir; + const metadataLoadOptions = { + ...(metadataSnapshot.discovery ? { discovery: metadataSnapshot.discovery } : {}), + installRecords: extractPluginInstallRecordsFromInstalledPluginIndex(metadataSnapshot.index), + manifestRegistry: metadataSnapshot.manifestRegistry, + ...(workspaceDir ? { workspaceDir } : {}), + }; const requestPluginRegistry = getPluginRuntimeGatewayRequestScope()?.pluginRegistry; const startupPluginIds = params.basePluginIds !== undefined ? [...params.basePluginIds] : requestPluginRegistry ? listRuntimePluginIdsFromRegistry(requestPluginRegistry) - : resolveStartupPluginIdsFromCurrentSnapshot({ - config: params.config, - env: params.env, - workspaceDir, - }); - const plan = resolveAgentRuntimePluginLoadPlan({ + : metadataSnapshot.pluginIds + ? [...metadataSnapshot.pluginIds] + : undefined; + const planParams = { config: params.config, workspaceDir: workspaceDir ?? process.cwd(), ...(startupPluginIds === undefined ? {} : { basePluginIds: startupPluginIds }), @@ -94,13 +85,15 @@ function resolveAgentRuntimePluginRegistryLoad(params: AgentRuntimePluginRegistr })), ...(params.selections ?? []), ], - }); + metadataSnapshot, + }; + const plan = resolveAgentRuntimePluginLoadPlan(planParams); return { loadOptions: { config: plan.config, ...(plan.config ? { activationSourceConfig: plan.config } : {}), ...(params.env ? { env: params.env } : {}), - workspaceDir, + ...metadataLoadOptions, ...(startupPluginIds === undefined || plan.pluginIds === undefined ? {} : { onlyPluginIds: plan.pluginIds }), diff --git a/src/gateway/agent-turn/agent-run-dispatch.ts b/src/gateway/agent-turn/agent-run-dispatch.ts index 5bc3f88fc48f..fc6dd16fa861 100644 --- a/src/gateway/agent-turn/agent-run-dispatch.ts +++ b/src/gateway/agent-turn/agent-run-dispatch.ts @@ -5,6 +5,7 @@ import { classifyAgentRunTerminalOutcome, type AgentRunTerminalOutcome, } from "../../agents/agent-run-terminal-outcome.js"; +import type { PreparedAgentCommandRuntimeContext } from "../../agents/command/prepare.js"; import { createCronCreatorAuthorityCapability, runWithCronCreatorAuthorityCapability, @@ -127,6 +128,7 @@ export function dispatchAgentRunFromGateway(params: { taskTrackingMode: Exclude; canonicalSkillWorkspaceDir?: string; restoreAdmittedRecovery?: () => Promise; + commandRuntimeContext?: PreparedAgentCommandRuntimeContext; onSettled?: (outcome: { terminalOutcome: AgentRunTerminalOutcome; onRecovered?: () => void; @@ -197,6 +199,7 @@ export function dispatchAgentRunFromGateway(params: { { restoreAdmittedRecovery: params.restoreAdmittedRecovery, }, + params.commandRuntimeContext, ), ); const agentRun = cronCreatorAuthorityCapability diff --git a/src/gateway/agent-turn/agent-run-execution-phase.ts b/src/gateway/agent-turn/agent-run-execution-phase.ts index e9967ea3bdae..a8e3cc6a0391 100644 --- a/src/gateway/agent-turn/agent-run-execution-phase.ts +++ b/src/gateway/agent-turn/agent-run-execution-phase.ts @@ -10,6 +10,7 @@ import { type MainSessionRecoveryPendingTarget, type MainSessionRecoveryOwnerLease, } from "../../agents/main-session-recovery/main-session-recovery-store.js"; +import { loadPublishedGatewayReplyDispatchRuntime } from "../../agents/prepared-model-runtime.js"; import { resolveScheduledToolPolicyContext } from "../../agents/scheduled-tool-policy.js"; import { resolveIngressWorkspaceOverrideForSessionRun } from "../../agents/spawned-context.js"; import { isExecutionIdentityCollectionEnabled } from "../../audit/audit-config.js"; @@ -196,6 +197,14 @@ export function startAgentRunExecution(params: { const ingressAgentId = params.resolvedSessionKey ? params.activeSessionAgentId : params.agentId; + const replyDispatchRuntime = await loadPublishedGatewayReplyDispatchRuntime({ + agentId: params.activeSessionAgentId, + }); + if (!replyDispatchRuntime?.pluginGeneration) { + throw new Error( + `prepared reply dispatch runtime was not published for ${params.activeSessionAgentId}`, + ); + } // Plugin-owned additive grants stay internal to the authenticated in-process run. // Public agent params cannot supply them, and normal tool policy still filters them. const runtimePluginToolGrant = @@ -252,6 +261,10 @@ export function startAgentRunExecution(params: { dispatchAgentRunFromGateway( withAgentRunDispatchExecutionIdentity( { + commandRuntimeContext: { + config: replyDispatchRuntime.config, + pluginGeneration: replyDispatchRuntime.pluginGeneration, + }, cronCreatorAuthority: prepared.cronCreatorAuthority, ingressOpts: { message, diff --git a/src/gateway/server-core-runtime.ts b/src/gateway/server-core-runtime.ts index 77de871844b9..d8d1263f4069 100644 --- a/src/gateway/server-core-runtime.ts +++ b/src/gateway/server-core-runtime.ts @@ -153,6 +153,7 @@ export async function startGatewayCoreRuntime(input: { residentRegistry, shutdownRuntime, } = runtime; + let currentPluginMetadataSnapshot = runtime.pluginMetadataSnapshot; if (desktopSessionRegistry) { kernel.addGatewayLifetimeSidecar({ stop: () => desktopSessionRegistry.stopAll() }); } @@ -612,6 +613,7 @@ export async function startGatewayCoreRuntime(input: { env: params.env, workspaceDir: pluginWorkspaceDir, }); + currentPluginMetadataSnapshot = nextPluginMetadataSnapshot; replaceAttachedPluginRuntime(loaded); kernel.setPluginServices(null); if (previousPluginServices) { @@ -673,5 +675,6 @@ export async function startGatewayCoreRuntime(input: { loadGatewayModelCatalog, loadGatewayModelCatalogSnapshot, readPreparedGatewayModelCatalog, + getPluginMetadataSnapshot: () => currentPluginMetadataSnapshot, }; } diff --git a/src/gateway/server-methods/agent.test-harness.ts b/src/gateway/server-methods/agent.test-harness.ts index 58b5bf238c41..16e6cae55525 100644 --- a/src/gateway/server-methods/agent.test-harness.ts +++ b/src/gateway/server-methods/agent.test-harness.ts @@ -151,6 +151,16 @@ vi.mock("../../commands/agent.js", () => ({ agentCommandFromIngress: mocks.agentCommand, })); +vi.mock("../../agents/prepared-model-runtime.js", () => ({ + // Direct handler tests bypass Gateway startup, so provide the lifecycle fact + // that production publishes before admitting agent RPCs. + loadPublishedGatewayReplyDispatchRuntime: async ({ agentId }: { agentId: string }) => ({ + agentId, + config: mocks.loadConfigReturn, + pluginGeneration: { pluginMetadataSnapshot: {} }, + }), +})); + vi.mock("../../acp/runtime/session-meta.js", async () => { const actual = await vi.importActual( "../../acp/runtime/session-meta.js", diff --git a/src/gateway/server-reload-contracts.ts b/src/gateway/server-reload-contracts.ts index 78378491313e..2c6bddda496c 100644 --- a/src/gateway/server-reload-contracts.ts +++ b/src/gateway/server-reload-contracts.ts @@ -2,6 +2,7 @@ import type { CliDeps } from "../cli/deps.types.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js"; import type { HeartbeatRunner } from "../infra/heartbeat-runner.js"; import type { GatewayRestartEmitter } from "../infra/restart.js"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; import type { ChannelHealthMonitor } from "./channel-health-monitor.js"; import type { ChannelKind } from "./config-reload-plan.js"; import type { GatewayReloadPlan } from "./config-reload.js"; @@ -143,6 +144,7 @@ export type GatewayReloadHandlerParams = { broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void; getState: () => GatewayHotReloadState; setState: (state: GatewayHotReloadState) => void; + getPluginMetadataSnapshot?: () => PluginMetadataSnapshot | undefined; startChannel: GatewayChannelManager["startChannel"]; stopChannel: GatewayChannelManager["stopChannel"]; getChannelAutostartSuppression?: GatewayChannelManager["getAutostartSuppression"]; diff --git a/src/gateway/server-reload-hot.ts b/src/gateway/server-reload-hot.ts index 65026eb61df4..cbfcf08b74c8 100644 --- a/src/gateway/server-reload-hot.ts +++ b/src/gateway/server-reload-hot.ts @@ -576,9 +576,11 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams) } try { + const pluginMetadataSnapshot = params.getPluginMetadataSnapshot?.(); await refreshPreparedModelRuntimeSnapshots(nextConfig, { catalogMode: "static", allowGatewaySubagentBinding: true, + ...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}), }); } catch (err) { scheduleRecoveryRestart("prepared model runtime reload", err); diff --git a/src/gateway/server-reload-managed.ts b/src/gateway/server-reload-managed.ts index f9f77066a0c5..7d63270289d3 100644 --- a/src/gateway/server-reload-managed.ts +++ b/src/gateway/server-reload-managed.ts @@ -135,6 +135,7 @@ export function startManagedGatewayConfigReloader( broadcast: params.broadcast, getState: params.getState, setState: params.setState, + getPluginMetadataSnapshot: params.getPluginMetadataSnapshot, startChannel: params.startChannel, stopChannel: params.stopChannel, getChannelAutostartSuppression: params.getChannelAutostartSuppression, diff --git a/src/gateway/server-startup-bootstrap.ts b/src/gateway/server-startup-bootstrap.ts index 84286e20317d..434b1dbd2709 100644 --- a/src/gateway/server-startup-bootstrap.ts +++ b/src/gateway/server-startup-bootstrap.ts @@ -551,7 +551,7 @@ export async function prepareGatewayServerBootstrap(input: { pluginWorkspaceDir, startupPluginIds, pluginManifestRecords, - pluginMetadataSnapshot, + pluginMetadataSnapshot: currentPluginMetadataSnapshot, pluginLookUpTable, baseMethods, ambientAutostartSuppressedChannelIds, diff --git a/src/gateway/server-startup-finish.ts b/src/gateway/server-startup-finish.ts index 2cbd706080ef..259a008b4585 100644 --- a/src/gateway/server-startup-finish.ts +++ b/src/gateway/server-startup-finish.ts @@ -91,6 +91,7 @@ export async function finishGatewayStartup(params: { baseMethods, startupPluginIds, pluginManifestRecords, + pluginMetadataSnapshot, pluginLookUpTable, ambientEnvTriggers, replaceAttachedPluginRuntime, @@ -126,6 +127,7 @@ export async function finishGatewayStartup(params: { gatewayRequestContext, gatewayInstanceRuntime, residentRegistry, + getPluginMetadataSnapshot, } = runtime; const unregisterGatewayLifetimeSidecar = (sidecar: GatewayPostReadySidecarHandle) => { kernel.setGatewayLifetimeSidecars( @@ -255,6 +257,7 @@ export async function finishGatewayStartup(params: { gatewayPluginConfigAtStart, activationSourceConfig: startupActivationSourceConfig, pluginManifestRecords, + ...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}), ambientEnvTriggers, pluginRegistry: pluginRuntime.registry, defaultWorkspaceDir, @@ -393,6 +396,7 @@ export async function finishGatewayStartup(params: { cronStartState.handled = true; } }, + getPluginMetadataSnapshot, startChannel, stopChannel, getChannelAutostartSuppression: channelManager.getAutostartSuppression, diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index 66ad51710c7f..5df2e9a6b6f2 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -17,6 +17,7 @@ import type { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; import type { PluginHookGatewayCronService } from "../plugins/hook-types.js"; import type { loadOpenClawPlugins } from "../plugins/loader.js"; import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; import { getPluginModuleLoaderStats } from "../plugins/plugin-module-loader-cache.js"; import type { PluginRegistry } from "../plugins/registry.js"; import type { PluginServicesHandle } from "../plugins/services.js"; @@ -429,6 +430,7 @@ async function waitForAcpRuntimeBackendReady(params: { async function prewarmConfiguredPrimaryModel(params: { cfg: OpenClawConfig; + pluginMetadataSnapshot?: PluginMetadataSnapshot; workspaceDir?: string; log: { warn: (msg: string) => void }; startupTrace?: GatewayStartupTrace; @@ -499,6 +501,7 @@ async function hydrateConfiguredExternalCliAuth(params: { async function publishConfiguredModelRuntimeSnapshots(params: { cfg: OpenClawConfig; + pluginMetadataSnapshot?: PluginMetadataSnapshot; workspaceDir?: string; log: { warn: (msg: string) => void }; startupTrace?: GatewayStartupTrace; @@ -509,6 +512,9 @@ async function publishConfiguredModelRuntimeSnapshots(params: { gatewayLifecycle: true, catalogMode: "static", allowGatewaySubagentBinding: true, + ...(params.pluginMetadataSnapshot + ? { pluginMetadataSnapshot: params.pluginMetadataSnapshot } + : {}), ...(params.workspaceDir ? { defaultWorkspaceDir: params.workspaceDir } : {}), ...(params.startupTrace ? { @@ -544,6 +550,7 @@ async function publishConfiguredModelRuntimeSnapshots(params: { async function publishStartupModelRuntime( params: { cfg: OpenClawConfig; + pluginMetadataSnapshot?: PluginMetadataSnapshot; workspaceDir?: string; log: { warn: (msg: string) => void }; startupTrace?: GatewayStartupTrace; @@ -559,6 +566,7 @@ async function publishStartupModelRuntime( /** Start post-ready sidecars such as channels, hooks, plugin services, and cleanup tasks. */ export async function startGatewaySidecars(params: { cfg: OpenClawConfig; + pluginMetadataSnapshot?: PluginMetadataSnapshot; pluginRegistry: ReturnType; defaultWorkspaceDir: string; deps: CliDeps; @@ -648,6 +656,9 @@ export async function startGatewaySidecars(params: { publishStartupModelRuntime( { cfg: params.cfg, + ...(params.pluginMetadataSnapshot + ? { pluginMetadataSnapshot: params.pluginMetadataSnapshot } + : {}), workspaceDir: params.defaultWorkspaceDir, log: params.log, startupTrace: params.startupTrace, @@ -1097,6 +1108,7 @@ export async function startGatewayPostAttachRuntime( gatewayPluginConfigAtStart: OpenClawConfig; activationSourceConfig: OpenClawConfig; pluginManifestRecords: readonly PluginManifestRecord[]; + pluginMetadataSnapshot?: PluginMetadataSnapshot; ambientEnvTriggers?: AmbientEnvTriggerPolicy; pluginRegistry: ReturnType; defaultWorkspaceDir: string; @@ -1339,6 +1351,9 @@ export async function startGatewayPostAttachRuntime( return await measureStartup(params.startupTrace, "sidecars.total", () => runtimeDeps.startGatewaySidecars({ cfg: params.gatewayPluginConfigAtStart, + ...(params.pluginMetadataSnapshot + ? { pluginMetadataSnapshot: params.pluginMetadataSnapshot } + : {}), pluginRegistry, defaultWorkspaceDir: params.defaultWorkspaceDir, deps: params.deps, diff --git a/src/gateway/server.agent.gateway-server-agent-b.test.ts b/src/gateway/server.agent.gateway-server-agent-b.test.ts index 741e708b07f1..bd92dc017139 100644 --- a/src/gateway/server.agent.gateway-server-agent-b.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-b.test.ts @@ -26,6 +26,7 @@ import { connectWebchatClient, installGatewayTestHooks, onceMessage, + prepareGatewayReplyRuntimeForTest, rpcReq, startConnectedServerWithClient, startServerWithClient, @@ -153,10 +154,11 @@ async function writeMainSessionEntry(params: { }); } -function sendAgentWsRequest( +async function sendAgentWsRequest( socket: WebSocket, params: { reqId: string; message: string; idempotencyKey: string; sessionKey?: string }, ) { + await prepareGatewayReplyRuntimeForTest(); socket.send( JSON.stringify({ type: "req", @@ -180,7 +182,7 @@ async function sendAgentWsRequestAndWaitFinal( (o) => o.type === "res" && o.id === params.reqId && o.payload?.status !== "accepted", params.timeoutMs, ); - sendAgentWsRequest(socket, params); + await sendAgentWsRequest(socket, params); return await finalP; } @@ -473,7 +475,7 @@ describe("gateway server agent", () => { ws, (o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted", ); - sendAgentWsRequest(ws, { + await sendAgentWsRequest(ws, { reqId: "ag1", message: "hi", idempotencyKey: "idem-ag", @@ -508,7 +510,7 @@ describe("gateway server agent", () => { message.type === "res" && message.id === runId && message.payload?.status !== "accepted", ); - sendAgentWsRequest(ws, { + await sendAgentWsRequest(ws, { reqId: runId, message: "persist this agent turn before ACK", sessionKey: "main", @@ -569,7 +571,7 @@ describe("gateway server agent", () => { message.type === "res" && message.id === runId && message.payload?.status !== "accepted", ); - sendAgentWsRequest(ws, { + await sendAgentWsRequest(ws, { reqId: runId, message: "keep this aborted agent turn queryable", sessionKey: "main", @@ -666,7 +668,7 @@ describe("gateway server agent", () => { }); const secondP = onceMessage(ws, (o) => o.type === "res" && o.id === "ag2"); - sendAgentWsRequest(ws, { + await sendAgentWsRequest(ws, { reqId: "ag2", message: "hi again", idempotencyKey: "same-agent", diff --git a/src/gateway/server.agent.rpc-contracts.test.ts b/src/gateway/server.agent.rpc-contracts.test.ts index e6fcc66332e8..edbbd183e281 100644 --- a/src/gateway/server.agent.rpc-contracts.test.ts +++ b/src/gateway/server.agent.rpc-contracts.test.ts @@ -4,7 +4,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vit import type { RawData, WebSocket } from "ws"; import { createDeferred } from "../../test/helpers/promise.js"; import { startGatewayServerHarness, type GatewayServerHarness } from "./server.e2e-ws-harness.js"; -import { agentCommandMock, installGatewayTestHooks, onceMessage } from "./test-helpers.js"; +import { + agentCommandMock, + installGatewayTestHooks, + onceMessage, + prepareGatewayReplyRuntimeForTest, +} from "./test-helpers.js"; installGatewayTestHooks({ scope: "suite" }); @@ -32,8 +37,9 @@ beforeAll(async () => { harness = await startGatewayServerHarness(); }); -beforeEach(() => { +beforeEach(async () => { vi.mocked(agentCommandMock).mockReset(); + await prepareGatewayReplyRuntimeForTest(); }); afterAll(async () => { diff --git a/src/gateway/server.sessions-send.test.ts b/src/gateway/server.sessions-send.test.ts index 19454b4f2874..d5a2d071b141 100644 --- a/src/gateway/server.sessions-send.test.ts +++ b/src/gateway/server.sessions-send.test.ts @@ -30,6 +30,7 @@ import { agentCommandMock, getGatewayTestPort, installGatewayTestHooks, + prepareGatewayReplyRuntimeForTest, startTestGatewayServer, setTestPluginRegistry, testState, @@ -154,10 +155,11 @@ beforeAll(async () => { server = await startTestGatewayServer(gatewayPort); }); -beforeEach(() => { +beforeEach(async () => { testState.gatewayAuth = { mode: "token", token: gatewayToken }; process.env.OPENCLAW_GATEWAY_PORT = String(gatewayPort); process.env.OPENCLAW_GATEWAY_TOKEN = gatewayToken; + await prepareGatewayReplyRuntimeForTest(); }); afterAll(async () => { @@ -664,6 +666,7 @@ describe("sessions_send agent targeting", () => { }, }, }); + await prepareGatewayReplyRuntimeForTest({ force: true }); const spy = agentCommandMock as unknown as Mock<(opts: unknown) => Promise>; spy.mockImplementation(async (opts: unknown) => @@ -803,6 +806,7 @@ describe("sessions_send direct-message requester routing", () => { [targetSessionKey]: { sessionId: "dm-scope-orion", updatedAt: Date.now() }, }, }); + await prepareGatewayReplyRuntimeForTest({ force: true }); const spy = agentCommandMock as unknown as Mock<(opts: unknown) => Promise>; spy.mockReset(); diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index 3b1afd504991..924ce46228de 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -103,6 +103,7 @@ let tempControlUiRoot: string | undefined; let suiteConfigRootSeq = 0; let lastSyncedSessionStorePath: string | undefined; let lastSyncedSessionConfigJson: string | undefined; +let gatewayReplyRuntimePrepared = false; let activeSuiteGatewayServerCount = 0; let activeSuiteHookScopeCount = 0; // Gateway tests exercise RPC/server behavior, not production bind auto-detection by default. @@ -478,6 +479,7 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) { resetAgentEventsForTest(); const mod = await getServerModule(); await mod.resetPreparedModelCatalogForTest(); + gatewayReplyRuntimePrepared = false; agentDiscoveryMock.enabled = false; agentDiscoveryMock.discoverCalls = 0; agentDiscoveryMock.models = []; @@ -525,6 +527,28 @@ async function resetGatewayTestRuntimeOnly() { drainSystemEvents(sessionKey); } resetAgentEventsForTest({ preserveListeners: true }); + gatewayReplyRuntimePrepared = false; +} + +export async function prepareGatewayReplyRuntimeForTest(options?: { + force?: boolean; +}): Promise { + if ( + process.env.OPENCLAW_TEST_MINIMAL_GATEWAY !== "1" || + (!options?.force && gatewayReplyRuntimePrepared) + ) { + return; + } + const [preparedRuntime, configRuntime] = await Promise.all([ + import("../agents/prepared-model-runtime.js"), + import("../config/io.js"), + ]); + await preparedRuntime.refreshPreparedModelRuntimeSnapshots(configRuntime.getRuntimeConfig(), { + gatewayLifecycle: true, + catalogMode: "static", + allowGatewaySubagentBinding: true, + }); + gatewayReplyRuntimePrepared = true; } export function installGatewayTestHooks(options?: { scope?: "test" | "suite" }) { @@ -1229,6 +1253,9 @@ export async function rpcReq>( // observes the updated test fixture state. resetConfigRuntimeState(); clearSessionStoreCacheForTest(); + if (method === "agent" || method === "chat.send") { + await prepareGatewayReplyRuntimeForTest(); + } const { randomUUID } = await import("node:crypto"); const id = randomUUID(); const responsePromise = onceMessage<{ diff --git a/src/gateway/test-helpers.ts b/src/gateway/test-helpers.ts index da7843ea7074..e9fb601c66fb 100644 --- a/src/gateway/test-helpers.ts +++ b/src/gateway/test-helpers.ts @@ -22,6 +22,7 @@ export { getTrackedConnectChallengeNonce, installGatewayTestHooks, onceMessage, + prepareGatewayReplyRuntimeForTest, readConnectChallengeNonce, rpcReq, startConnectedServerWithClient, diff --git a/src/plugins/current-plugin-metadata-snapshot.test.ts b/src/plugins/current-plugin-metadata-snapshot.test.ts index 8f6ce49a57b7..c83fe827885d 100644 --- a/src/plugins/current-plugin-metadata-snapshot.test.ts +++ b/src/plugins/current-plugin-metadata-snapshot.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest"; import { getCurrentPluginMetadataSnapshot, installTemporaryCurrentPluginMetadataSnapshot, + isCurrentPluginMetadataSnapshotRuntimeGeneration, setCurrentPluginMetadataSnapshot, withPluginMetadataSnapshotScope, } from "./current-plugin-metadata-snapshot.js"; @@ -166,23 +167,39 @@ describe("current plugin metadata snapshot", () => { ).toBe(globalSnapshot); }); - it("carries prepared metadata and registry as one runtime generation", async () => { + it("carries prepared metadata and registry across nested agent workspaces", async () => { const config = { plugins: { allow: ["scoped"] } }; - const workspaceDir = "/workspace/scoped"; - const metadataSnapshot = createSnapshot({ config, workspaceDir }); + const pluginWorkspaceDir = "/workspace/plugins"; + const agentWorkspaceDir = "/workspace/agent-run"; + const metadataSnapshot = createSnapshot({ config, workspaceDir: pluginWorkspaceDir }); const pluginRegistry = createEmptyPluginRegistry(); setCurrentPluginMetadataSnapshot(undefined); await withPluginRuntimeGenerationScope( - { config, metadataSnapshot, pluginRegistry, workspaceDir }, + { config, metadataSnapshot, pluginRegistry }, async () => { await Promise.resolve(); - expect(getCurrentPluginMetadataSnapshot({ config, workspaceDir })).toBe(metadataSnapshot); + expect(getCurrentPluginMetadataSnapshot({ config, workspaceDir: agentWorkspaceDir })).toBe( + metadataSnapshot, + ); + expect(getCurrentPluginMetadataSnapshot({ config, workspaceDir: pluginWorkspaceDir })).toBe( + metadataSnapshot, + ); + expect( + getCurrentPluginMetadataSnapshot({ + config: { plugins: { allow: ["derived-run-policy"] } }, + workspaceDir: agentWorkspaceDir, + }), + ).toBe(metadataSnapshot); + expect(isCurrentPluginMetadataSnapshotRuntimeGeneration(metadataSnapshot)).toBe(true); expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe(pluginRegistry); }, ); - expect(getCurrentPluginMetadataSnapshot({ config, workspaceDir })).toBeUndefined(); + expect(isCurrentPluginMetadataSnapshotRuntimeGeneration(metadataSnapshot)).toBe(false); + expect( + getCurrentPluginMetadataSnapshot({ config, workspaceDir: agentWorkspaceDir }), + ).toBeUndefined(); expect(getPluginRuntimeGatewayRequestScope()).toBeUndefined(); }); @@ -217,7 +234,6 @@ describe("current plugin metadata snapshot", () => { config: outerConfig, metadataSnapshot: outerSnapshot, pluginRegistry: outerRegistry, - workspaceDir: "/workspace/outer", }, async () => { await expect( @@ -225,7 +241,6 @@ describe("current plugin metadata snapshot", () => { { config: innerConfig, metadataSnapshot: innerSnapshot, - workspaceDir: "/workspace/inner", }, async () => { await Promise.resolve(); @@ -414,14 +429,11 @@ describe("current plugin metadata snapshot", () => { const workspaceDir = "/workspace"; const snapshot = createSnapshot({ config: sourceConfig, workspaceDir }); - withPluginRuntimeGenerationScope( - { config: runtimeConfig, metadataSnapshot: snapshot, workspaceDir }, - () => { - expect(getCurrentPluginMetadataSnapshot({ config: runtimeConfig, workspaceDir })).toBe( - snapshot, - ); - }, - ); + withPluginRuntimeGenerationScope({ config: runtimeConfig, metadataSnapshot: snapshot }, () => { + expect(getCurrentPluginMetadataSnapshot({ config: runtimeConfig, workspaceDir })).toBe( + snapshot, + ); + }); }); it("rejects a workspace-scoped snapshot when the caller does not provide workspace scope", () => { diff --git a/src/plugins/current-plugin-metadata-snapshot.ts b/src/plugins/current-plugin-metadata-snapshot.ts index 5e31dadf96c4..fe74b053ad05 100644 --- a/src/plugins/current-plugin-metadata-snapshot.ts +++ b/src/plugins/current-plugin-metadata-snapshot.ts @@ -63,6 +63,7 @@ type PluginMetadataSnapshotCandidate = { compatiblePolicyHashes?: readonly string[]; compatibleConfigFingerprints?: readonly string[]; hasConfigIdentity?: (config: OpenClawConfig) => boolean; + immutableRuntimeGeneration?: boolean; }; type ScopedPluginMetadataSnapshot = PluginMetadataSnapshotCandidate & { @@ -300,6 +301,7 @@ export function withPluginMetadataSnapshotScope( compatiblePolicyHashes, compatibleConfigFingerprints, hasConfigIdentity: (config) => configIdentities.has(config), + immutableRuntimeGeneration: options.trustConfigIdentity === true, parent: scopedPluginMetadataSnapshot.getStore(), }, run, @@ -333,6 +335,11 @@ function resolveCompatiblePluginMetadataSnapshot( ) { return undefined; } + // Immutable runtime generations already selected their executable plugin graph. Nested config + // and workspace projections are run data, not authority to reopen lifecycle-owned discovery. + if (candidate.immutableRuntimeGeneration) { + return snapshot; + } const requestedWorkspaceDir = params.workspaceDir ?? (params.allowWorkspaceScopedSnapshot === true || options.scopedOwnerContext === true @@ -398,6 +405,17 @@ function resolveCompatiblePluginMetadataSnapshot( return snapshot; } +export function isCurrentPluginMetadataSnapshotRuntimeGeneration( + snapshot: PluginMetadataSnapshot, +): boolean { + for (let scoped = scopedPluginMetadataSnapshot.getStore(); scoped; scoped = scoped.parent) { + if (scoped.snapshot === snapshot && scoped.immutableRuntimeGeneration === true) { + return true; + } + } + return false; +} + export function getCurrentPluginMetadataSnapshot( params: CurrentPluginMetadataSnapshotParams = {}, ): PluginMetadataSnapshot | undefined { diff --git a/src/plugins/plugin-metadata-snapshot.ts b/src/plugins/plugin-metadata-snapshot.ts index bfa698705400..7a345e33b47f 100644 --- a/src/plugins/plugin-metadata-snapshot.ts +++ b/src/plugins/plugin-metadata-snapshot.ts @@ -4,7 +4,10 @@ import { getActiveDiagnosticsTimelineSpan, measureDiagnosticsTimelineSpanSync, } from "../infra/diagnostics-timeline.js"; -import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js"; +import { + getCurrentPluginMetadataSnapshot, + isCurrentPluginMetadataSnapshotRuntimeGeneration, +} from "./current-plugin-metadata-snapshot.js"; import { resolveActivePluginInstallRoots } from "./install-root-context.js"; import { hashJson } from "./installed-plugin-index-hash.js"; import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js"; @@ -335,7 +338,7 @@ export function completePluginMetadataSnapshot(params: { } /** Reuses process-stable plugin facts for a workspace proven to have no plugin root. */ -export function projectPluginMetadataSnapshotWorkspace(params: { +function projectPluginMetadataSnapshotWorkspace(params: { snapshot: PluginMetadataSnapshot; config: OpenClawConfig; env?: NodeJS.ProcessEnv; @@ -418,7 +421,7 @@ export function resolvePluginMetadataSnapshot( } return loadPluginMetadataSnapshot(params); } - if (!params.index) { + if (!params.index || isCurrentPluginMetadataSnapshotRuntimeGeneration(current)) { return current; } if ( diff --git a/src/plugins/runtime/generation-scope.ts b/src/plugins/runtime/generation-scope.ts index b5914701287f..5040a27a1045 100644 --- a/src/plugins/runtime/generation-scope.ts +++ b/src/plugins/runtime/generation-scope.ts @@ -21,7 +21,6 @@ export function withPluginRuntimeGenerationScope( config: OpenClawConfig; metadataSnapshot: PluginMetadataSnapshot; pluginRegistry?: PluginRegistry; - workspaceDir?: string; }, run: () => T, ): T { @@ -35,7 +34,9 @@ export function withPluginRuntimeGenerationScope( { config: generation.config, trustConfigIdentity: true, - ...(generation.workspaceDir ? { workspaceDir: generation.workspaceDir } : {}), + ...(generation.metadataSnapshot.workspaceDir + ? { workspaceDir: generation.metadataSnapshot.workspaceDir } + : {}), }, ); } diff --git a/src/plugins/runtime/load-context.current-snapshot.test.ts b/src/plugins/runtime/load-context.current-snapshot.test.ts new file mode 100644 index 000000000000..6dab1a709ed3 --- /dev/null +++ b/src/plugins/runtime/load-context.current-snapshot.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + getCurrentPluginMetadataSnapshot, + setCurrentPluginMetadataSnapshot, +} from "../current-plugin-metadata-snapshot.js"; +import { resolveInstalledPluginIndexPolicyHash } from "../installed-plugin-index-policy.js"; +import { clearPluginMetadataLifecycleCaches } from "../plugin-metadata-lifecycle.js"; +import type { PluginMetadataSnapshot } from "../plugin-metadata-snapshot.types.js"; +import { resolvePluginRuntimeLoadContext } from "./load-context.js"; + +const resolvePluginMetadataSnapshotMock = vi.hoisted(() => vi.fn()); + +vi.mock("../plugin-metadata-snapshot.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolvePluginMetadataSnapshot: resolvePluginMetadataSnapshotMock, +})); + +function createSnapshot(params: { + config: OpenClawConfig; + workspaceDir: string; +}): PluginMetadataSnapshot { + const policyHash = resolveInstalledPluginIndexPolicyHash(params.config); + return { + policyHash, + workspaceDir: params.workspaceDir, + 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) => 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, + }, + discovery: { candidates: [], diagnostics: [] }, + }; +} + +describe("plugin runtime load context current snapshot ownership", () => { + afterEach(() => { + resolvePluginMetadataSnapshotMock.mockReset(); + clearPluginMetadataLifecycleCaches(); + }); + + it("keeps operation-local metadata from replacing the Gateway lifecycle snapshot", () => { + const lifecycleConfig = { plugins: { allow: ["lifecycle"] } }; + const operationConfig = { plugins: { allow: ["operation"] } }; + const lifecycleWorkspace = "/workspace/lifecycle"; + const operationWorkspace = "/workspace/operation"; + const lifecycleSnapshot = createSnapshot({ + config: lifecycleConfig, + workspaceDir: lifecycleWorkspace, + }); + const operationSnapshot = createSnapshot({ + config: operationConfig, + workspaceDir: operationWorkspace, + }); + setCurrentPluginMetadataSnapshot(lifecycleSnapshot, { + config: lifecycleConfig, + workspaceDir: lifecycleWorkspace, + }); + resolvePluginMetadataSnapshotMock.mockReturnValue(operationSnapshot); + + const context = resolvePluginRuntimeLoadContext({ + config: operationConfig, + workspaceDir: operationWorkspace, + }); + + expect(context.metadataSnapshot).toBe(operationSnapshot); + expect( + getCurrentPluginMetadataSnapshot({ + config: lifecycleConfig, + workspaceDir: lifecycleWorkspace, + }), + ).toBe(lifecycleSnapshot); + expect( + getCurrentPluginMetadataSnapshot({ + config: operationConfig, + workspaceDir: operationWorkspace, + }), + ).toBeUndefined(); + }); +}); diff --git a/src/plugins/runtime/load-context.test.ts b/src/plugins/runtime/load-context.test.ts index 0c00de890d0a..896889a314c4 100644 --- a/src/plugins/runtime/load-context.test.ts +++ b/src/plugins/runtime/load-context.test.ts @@ -32,8 +32,6 @@ const rebasePluginMetadataSnapshotManifestRegistryMock = vi.fn( ); const resolveConfigWidePluginManifestRegistryMock = vi.fn(() => manifestRegistry); const isPluginMetadataSnapshotCompatibleMock = vi.fn(() => true); -const getCurrentPluginMetadataSnapshotMock = vi.fn(() => undefined); -const setCurrentPluginMetadataSnapshotMock = vi.fn(); let resolvePluginRuntimeLoadContext: typeof import("./load-context.js").resolvePluginRuntimeLoadContext; let buildPluginRuntimeLoadOptions: typeof import("./load-context.js").buildPluginRuntimeLoadOptions; @@ -70,11 +68,6 @@ vi.mock("../plugin-metadata-snapshot.js", () => ({ resolvePluginMetadataSnapshot: loadPluginMetadataSnapshotMock, })); -vi.mock("../current-plugin-metadata-snapshot.js", () => ({ - getCurrentPluginMetadataSnapshot: getCurrentPluginMetadataSnapshotMock, - setCurrentPluginMetadataSnapshot: setCurrentPluginMetadataSnapshotMock, -})); - describe("resolvePluginRuntimeLoadContext", () => { beforeEach(async () => { vi.resetModules(); @@ -87,15 +80,11 @@ describe("resolvePluginRuntimeLoadContext", () => { applyPluginAutoEnableMock.mockReset(); fingerprintPluginAutoEnableConfigMock.mockClear(); fingerprintPluginAutoEnableEnvMock.mockClear(); - getCurrentPluginMetadataSnapshotMock.mockReset(); - getCurrentPluginMetadataSnapshotMock.mockReturnValue(undefined); isPluginMetadataSnapshotCompatibleMock.mockReset(); isPluginMetadataSnapshotCompatibleMock.mockReturnValue(true); loadPluginMetadataSnapshotMock.mockClear(); rebasePluginMetadataSnapshotManifestRegistryMock.mockClear(); resolveConfigWidePluginManifestRegistryMock.mockClear(); - getCurrentPluginMetadataSnapshotMock.mockClear(); - setCurrentPluginMetadataSnapshotMock.mockClear(); resolvePluginControlPlaneWorkspaceMock.mockClear(); loadConfigMock.mockReturnValue({ plugins: {} }); @@ -156,12 +145,6 @@ describe("resolvePluginRuntimeLoadContext", () => { env, manifestRegistry, }); - expect(setCurrentPluginMetadataSnapshotMock).toHaveBeenCalledWith(metadataSnapshot, { - config: rawConfig, - compatibleConfigs: [resolvedConfig, rawConfig], - env, - workspaceDir: "/resolved-workspace", - }); expect(resolvePluginControlPlaneWorkspaceMock).toHaveBeenNthCalledWith(1, { config: rawConfig, env, @@ -193,24 +176,19 @@ describe("resolvePluginRuntimeLoadContext", () => { expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled(); }); - it("stores derived metadata as the reusable runtime snapshot", () => { + it("keeps derived metadata operation-local", () => { const derivedSnapshot = { ...metadataSnapshot } as typeof metadataSnapshot & { registrySource: "derived"; }; derivedSnapshot.registrySource = "derived"; loadPluginMetadataSnapshotMock.mockReturnValueOnce(derivedSnapshot); - resolvePluginRuntimeLoadContext({ + const context = resolvePluginRuntimeLoadContext({ config: { plugins: {} }, env: { HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv, }); - expect(setCurrentPluginMetadataSnapshotMock).toHaveBeenCalledWith(derivedSnapshot, { - config: { plugins: {} }, - compatibleConfigs: [{ plugins: {} }, { plugins: {} }], - env: { HOME: "/tmp/openclaw-home" }, - workspaceDir: "/resolved-workspace", - }); + expect(context.metadataSnapshot).toBe(derivedSnapshot); }); it("uses the source runtime snapshot for plugin activation source config", () => { @@ -327,7 +305,6 @@ describe("resolvePluginRuntimeLoadContext", () => { pluginIds, workspaceDir: "/resolved-workspace", }); - expect(setCurrentPluginMetadataSnapshotMock).not.toHaveBeenCalled(); }); it("builds plugin load options from the shared runtime context", () => { diff --git a/src/plugins/runtime/load-context.ts b/src/plugins/runtime/load-context.ts index 1a59165a34bc..65ee63766eea 100644 --- a/src/plugins/runtime/load-context.ts +++ b/src/plugins/runtime/load-context.ts @@ -11,7 +11,6 @@ import type { PluginInstallRecord } from "../../config/types.plugins.js"; import { createSubsystemLogger } from "../../logging.js"; import { resolvePluginActivationSourceConfig } from "../activation-source-config.js"; import { resolvePluginControlPlaneWorkspace } from "../control-plane-workspace.js"; -import { setCurrentPluginMetadataSnapshot } from "../current-plugin-metadata-snapshot.js"; import { extractPluginInstallRecordsFromInstalledPluginIndex } from "../installed-plugin-index-install-records.js"; import type { PluginLoadOptions } from "../loader.js"; import type { PluginManifestRegistry } from "../manifest-registry.js"; @@ -252,16 +251,6 @@ export function resolvePluginRuntimeLoadContext( const installRecords = metadataSnapshot ? extractPluginInstallRecordsFromInstalledPluginIndex(metadataSnapshot.index) : undefined; - if (metadataSnapshot && metadataSnapshot.pluginIds === undefined) { - // Scoped graphs are request-local; publishing one would hide other installed - // providers from process-wide model normalization and later runtime loads. - setCurrentPluginMetadataSnapshot(metadataSnapshot, { - config: rawConfig, - compatibleConfigs: [config, activationSourceConfig], - env, - workspaceDir, - }); - } return { rawConfig, config, diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 91543cc026db..2a77c1922156 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -1035,6 +1035,7 @@ describe("resolvePluginTools optional tools", () => { const context = createContext(); const config = context.config; const registry = createToolRegistry([createOptionalDemoEntry()]); + const preparedConfig = structuredClone(config); const metadataSnapshot = installToolManifestSnapshots({ config, plugins: [ @@ -1048,11 +1049,11 @@ describe("resolvePluginTools optional tools", () => { ...createResolveToolsParams({ context, toolAllowlist: ["optional_tool"] }), preparedRuntime: { loadContext: { - rawConfig: config, - config, - activationSourceConfig: config, + rawConfig: preparedConfig, + config: preparedConfig, + activationSourceConfig: preparedConfig, autoEnabledReasons: {}, - workspaceDir: "/tmp", + workspaceDir: "/gateway/plugin-runtime", env: process.env, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, manifestRegistry: metadataSnapshot.manifestRegistry as never, diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index ecb837f541c3..7d8d2c739101 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -1177,11 +1177,9 @@ function resolvePluginToolLoadState(params: { const env = params.env ?? process.env; const baseConfig = applyTestPluginDefaults(params.context.config ?? {}, env); const preparedLoadContext = params.preparedRuntime?.loadContext; - const usePreparedRuntime = - preparedLoadContext !== undefined && - (baseConfig === preparedLoadContext.rawConfig || baseConfig === preparedLoadContext.config) && - env === preparedLoadContext.env && - params.context.workspaceDir === preparedLoadContext.workspaceDir; + // The prepared runtime already owns one immutable Gateway plugin generation. Per-turn config + // and workspace projections cannot invalidate that executable graph or reopen discovery. + const usePreparedRuntime = preparedLoadContext !== undefined && env === preparedLoadContext.env; const context = usePreparedRuntime ? preparedLoadContext : resolvePluginRuntimeLoadContext({ diff --git a/src/skills/discovery/chat-commands.test.ts b/src/skills/discovery/chat-commands.test.ts index b5fb1b02ed68..cdbbaca7848c 100644 --- a/src/skills/discovery/chat-commands.test.ts +++ b/src/skills/discovery/chat-commands.test.ts @@ -8,6 +8,7 @@ let listSkillCommandsForAgents: typeof import("./chat-commands.js").listSkillCom let listSkillCommandsForWorkspace: typeof import("./chat-commands.js").listSkillCommandsForWorkspace; let expandExplicitSkillReferences: typeof import("./chat-commands.js").expandExplicitSkillReferences; let resolveSkillCommandInvocation: typeof import("./chat-commands.js").resolveSkillCommandInvocation; +let lastPluginMetadataSnapshot: unknown; function resolveSkillReferenceInvocations( params: Parameters[0], @@ -95,6 +96,7 @@ function buildWorkspaceSkillCommandSpecs( reservedNames?: Set; skillFilter?: string[]; agentId?: string; + pluginMetadataSnapshot?: unknown; config?: { agents?: { defaults?: { skills?: string[] }; @@ -103,6 +105,7 @@ function buildWorkspaceSkillCommandSpecs( }; }, ) { + lastPluginMetadataSnapshot = opts?.pluginMetadataSnapshot; const used = new Set(); for (const reserved of opts?.reservedNames ?? []) { used.add(reserved.toLowerCase()); @@ -176,6 +179,7 @@ afterAll(() => { beforeEach(() => { vi.clearAllMocks(); + lastPluginMetadataSnapshot = undefined; resolveNodeExecEligibilityMock.mockReturnValue({ canExec: false }); }); @@ -645,4 +649,18 @@ describe("listSkillCommandsForWorkspace", () => { }), ); }); + + it("keeps explicit command discovery on the admitted plugin generation", async () => { + const baseDir = tempDirs.make("openclaw-skills-workspace-generation-"); + const workspaceDir = await createWorkspace(baseDir, "main"); + const pluginMetadataSnapshot = { generation: "gateway" } as never; + + listSkillCommandsForWorkspace({ + workspaceDir, + cfg: {}, + pluginMetadataSnapshot, + }); + + expect(lastPluginMetadataSnapshot).toBe(pluginMetadataSnapshot); + }); }); diff --git a/src/skills/discovery/chat-commands.ts b/src/skills/discovery/chat-commands.ts index 4a86ea766d24..a2118adb8721 100644 --- a/src/skills/discovery/chat-commands.ts +++ b/src/skills/discovery/chat-commands.ts @@ -12,6 +12,7 @@ import { } from "../../agents/exec-defaults.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { logVerbose } from "../../globals.js"; +import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import { loadWorkspaceSkills } from "../loading/workspace-skill-loader.js"; import { getRemoteSkillEligibility } from "../runtime/remote.js"; import type { SkillCommandSpec } from "../types.js"; @@ -35,6 +36,7 @@ export function listSkillCommandsForWorkspace(params: { sessionKey?: string; execOverrides?: ExecPolicyOverrides; includeAllowlistHidden?: boolean; + pluginMetadataSnapshot?: PluginMetadataSnapshot; }): SkillCommandSpec[] { const nodeSkills = resolveNodeExecEligibility({ cfg: params.cfg, @@ -48,7 +50,11 @@ export function listSkillCommandsForWorkspace(params: { remote: getRemoteSkillEligibility({ advertiseExecNode: nodeSkills.canExec }), }; const entries = params.includeAllowlistHidden - ? loadWorkspaceSkills(params.workspaceDir, { config: params.cfg, eligibility }) + ? loadWorkspaceSkills(params.workspaceDir, { + config: params.cfg, + eligibility, + pluginMetadataSnapshot: params.pluginMetadataSnapshot, + }) : undefined; return buildWorkspaceSkillCommandSpecs(params.workspaceDir, { config: params.cfg, @@ -56,6 +62,7 @@ export function listSkillCommandsForWorkspace(params: { skillFilter: params.skillFilter, includeAllowlistHidden: params.includeAllowlistHidden, eligibility, + pluginMetadataSnapshot: params.pluginMetadataSnapshot, ...(entries ? { entries } : {}), reservedNames: listReservedChatSlashCommandNames(), }); diff --git a/src/skills/discovery/command-specs.ts b/src/skills/discovery/command-specs.ts index ca618d45384d..72b189c6f8ea 100644 --- a/src/skills/discovery/command-specs.ts +++ b/src/skills/discovery/command-specs.ts @@ -8,6 +8,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createDedupeCache } from "../../infra/dedupe.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { loadEnabledClaudeBundleCommands } from "../../plugins/bundle-commands.js"; +import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import { resolveSkillTelemetrySource } from "../loading/source.js"; import { filterWorkspaceSkills, loadVisibleSkills } from "../loading/workspace-skill-loader.js"; import type { SkillEligibilityContext, SkillCommandSpec, SkillEntry } from "../types.js"; @@ -81,6 +82,7 @@ export function buildWorkspaceSkillCommandSpecs( skillFilter?: string[]; includeAllowlistHidden?: boolean; eligibility?: SkillEligibilityContext; + pluginMetadataSnapshot?: PluginMetadataSnapshot; reservedNames?: Set; }, ): SkillCommandSpec[] { @@ -99,6 +101,7 @@ export function buildWorkspaceSkillCommandSpecs( bundledSkillsDir: opts?.bundledSkillsDir, skillFilter: effectiveSkillFilter, eligibility: opts?.eligibility, + pluginMetadataSnapshot: opts?.pluginMetadataSnapshot, }); const userInvocable = filterUserInvocableSkillEntries(eligible); const used = new Set(); diff --git a/src/skills/loading/plugin-skills.test.ts b/src/skills/loading/plugin-skills.test.ts index 6d79cb10329b..792f171d63ed 100644 --- a/src/skills/loading/plugin-skills.test.ts +++ b/src/skills/loading/plugin-skills.test.ts @@ -47,6 +47,7 @@ vi.mock("../../plugins/plugin-metadata-snapshot.js", () => ({ })); let resolvePluginSkillDirs: typeof import("./plugin-skills.js").resolvePluginSkillDirs; +let resolvePluginSkillDirsFromMetadata: typeof import("./plugin-skills.js").resolvePluginSkillDirsFromMetadata; const tempDirs = createTrackedTempDirs(); @@ -188,7 +189,29 @@ afterEach(async () => { describe("resolvePluginSkillDirs", () => { beforeAll(async () => { - ({ resolvePluginSkillDirs } = await import("./plugin-skills.js")); + ({ resolvePluginSkillDirs, resolvePluginSkillDirsFromMetadata } = + await import("./plugin-skills.js")); + }); + + it("uses supplied lifecycle metadata without a cold load", async () => { + const { workspaceDir, acpxRoot, helperRoot } = await setupAcpxAndHelperRegistry(); + registerHealthyAcpBackend(); + const manifestRegistry = buildRegistry({ acpxRoot, helperRoot }); + + const dirs = resolvePluginSkillDirsFromMetadata({ + workspaceDir, + config: { + acp: { enabled: true }, + plugins: { entries: { acpx: { enabled: true }, helper: { enabled: true } } }, + } as OpenClawConfig, + metadataSnapshot: { + manifestRegistry, + normalizePluginId: (pluginId: string) => pluginId, + } as never, + }); + + expect(dirs).toEqual([path.resolve(acpxRoot, "skills"), path.resolve(helperRoot, "skills")]); + expect(hoisted.loadPluginMetadataSnapshot).not.toHaveBeenCalled(); }); beforeEach(() => { diff --git a/src/skills/loading/plugin-skills.ts b/src/skills/loading/plugin-skills.ts index 0578e9bda46c..0ec928172e42 100644 --- a/src/skills/loading/plugin-skills.ts +++ b/src/skills/loading/plugin-skills.ts @@ -12,7 +12,8 @@ import { } from "../../plugins/config-policy.js"; import { resolveMemorySlotDecision } from "../../plugins/config-state.js"; import { registerPluginMetadataProcessMemoLifecycleClear } from "../../plugins/plugin-metadata-lifecycle.js"; -import { resolvePluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js"; +import { loadPluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js"; +import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import { hasKind } from "../../plugins/slots.js"; import { isPathInsideWithRealpath } from "../../security/scan-paths.js"; import { CONFIG_DIR } from "../../utils.js"; @@ -50,12 +51,27 @@ export function resolvePluginSkillDirs(params: { return []; } const config = params.config ?? {}; - const metadataSnapshot = resolvePluginMetadataSnapshot({ + const metadataSnapshot = loadPluginMetadataSnapshot({ workspaceDir, config, env: process.env, - allowWorkspaceScopedCurrent: true, }); + return resolvePluginSkillDirsFromMetadata({ ...params, metadataSnapshot }); +} + +export function resolvePluginSkillDirsFromMetadata(params: { + workspaceDir: string | undefined; + config?: OpenClawConfig; + pluginSkillsDir?: string; + metadataSnapshot: PluginMetadataSnapshot; +}): string[] { + const workspaceDir = (params.workspaceDir ?? "").trim(); + if (!workspaceDir) { + publishPluginSkills([], { pluginSkillsDir: params.pluginSkillsDir }); + return []; + } + const config = params.config ?? {}; + const metadataSnapshot = params.metadataSnapshot; const registry = metadataSnapshot.manifestRegistry; if (registry.plugins.length === 0) { publishPluginSkills([], { diff --git a/src/skills/loading/workspace-skill-loader.test.ts b/src/skills/loading/workspace-skill-loader.test.ts index 80ed6be16120..045fc189f3e8 100644 --- a/src/skills/loading/workspace-skill-loader.test.ts +++ b/src/skills/loading/workspace-skill-loader.test.ts @@ -7,13 +7,11 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { resetLogger, setLoggerOverride } from "../../logging/logger.js"; import { loggingState } from "../../logging/state.js"; -import { setCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js"; import { resolveInstalledPluginIndexPolicyHash } from "../../plugins/installed-plugin-index-policy.js"; import type { PluginManifestRecord, PluginManifestRegistry, } from "../../plugins/manifest-registry.js"; -import { clearPluginMetadataLifecycleCaches } from "../../plugins/plugin-metadata-lifecycle.js"; import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js"; import { writeSkill, writeWorkspaceSkills } from "../test-support/e2e-test-helpers.js"; import { @@ -145,21 +143,6 @@ function createWorkspacePluginMetadataSnapshot(params: { }; } -function setWorkspacePluginMetadataSnapshot(workspaceDir: string, config?: OpenClawConfig): void { - const manifestRegistry = createWorkspacePluginRegistry(workspaceDir); - setCurrentPluginMetadataSnapshot( - createWorkspacePluginMetadataSnapshot({ - workspaceDir, - manifestRegistry, - ...(config === undefined ? {} : { config }), - }), - { - workspaceDir, - ...(config === undefined ? {} : { config }), - }, - ); -} - async function expectMissingPath(pathToCheck: string) { let thrown: unknown; try { @@ -192,12 +175,17 @@ function loadTestWorkspaceSkills( workspaceDir: string, opts?: Parameters[1], ) { - setWorkspacePluginMetadataSnapshot(workspaceDir, opts?.config); + const pluginMetadataSnapshot = createWorkspacePluginMetadataSnapshot({ + workspaceDir, + manifestRegistry: createWorkspacePluginRegistry(workspaceDir), + ...(opts?.config === undefined ? {} : { config: opts.config }), + }); return loadWorkspaceSkills(workspaceDir, { managedSkillsDir: path.join(workspaceDir, ".managed"), bundledSkillsDir: "", pluginSkillsDir: path.join(workspaceDir, ".plugin-skills"), ...opts, + pluginMetadataSnapshot: opts?.pluginMetadataSnapshot ?? pluginMetadataSnapshot, }); } @@ -209,7 +197,6 @@ beforeAll(async () => { }); afterEach(async () => { - clearPluginMetadataLifecycleCaches(); setLoggerOverride(null); loggingState.rawConsole = null; resetLogger(); diff --git a/src/skills/loading/workspace-skill-loader.ts b/src/skills/loading/workspace-skill-loader.ts index 64634d3e6487..f6d2ba7b5da1 100644 --- a/src/skills/loading/workspace-skill-loader.ts +++ b/src/skills/loading/workspace-skill-loader.ts @@ -8,6 +8,7 @@ import { isDefaultStateDir } from "../../config/paths.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { isPathInside } from "../../infra/path-guards.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import { CONFIG_DIR, resolveUserPath } from "../../utils.js"; import { isSessionSkillEnabled, @@ -34,7 +35,7 @@ import { readSkillFrontmatterSafe, type LocalSkillLoadDiagnostic, } from "./local-loader.js"; -import { resolvePluginSkillDirs } from "./plugin-skills.js"; +import { resolvePluginSkillDirs, resolvePluginSkillDirsFromMetadata } from "./plugin-skills.js"; import type { Skill } from "./skill-contract.js"; import { compactSkillPath, resolveSkillsUserHomeDir } from "./skill-paths.js"; import { @@ -74,6 +75,7 @@ type WorkspaceSkillLoadOptions = { eligibility?: SkillEligibilityContext; workspaceOnly?: boolean; includeArchived?: boolean; + pluginMetadataSnapshot?: PluginMetadataSnapshot; }; export function normalizeWorkspaceSkillRoots(roots: WorkspaceSkillRoots): WorkspaceSkillRoots { @@ -334,6 +336,7 @@ function loadSkillEntries( workspaceSkillsDir?: string; workspaceOnly?: boolean; includeArchived?: boolean; + pluginMetadataSnapshot?: PluginMetadataSnapshot; }, ): SkillEntry[] { const limits = resolveSkillDiscoveryLimits(opts?.config); @@ -352,7 +355,14 @@ function loadSkillEntries( const extraDirs = normalizeTrimmedStringList(extraDirsRaw); const pluginSkillDirs = workspaceOnly ? [] - : resolvePluginSkillDirs({ workspaceDir, config: opts?.config, pluginSkillsDir }); + : opts?.pluginMetadataSnapshot + ? resolvePluginSkillDirsFromMetadata({ + workspaceDir, + config: opts.config, + pluginSkillsDir, + metadataSnapshot: opts.pluginMetadataSnapshot, + }) + : resolvePluginSkillDirs({ workspaceDir, config: opts?.config, pluginSkillsDir }); const mergedExtraDirs = [...extraDirs, ...pluginSkillDirs]; const bundledSkills = bundledSkillsDir @@ -481,6 +491,7 @@ export function resolveWorkspaceSkillPromptEntries( skillFilter?: string[]; skillOverrides?: Record; eligibility?: SkillEligibilityContext; + pluginMetadataSnapshot?: PluginMetadataSnapshot; }, ): { eligible: SkillEntry[]; skillFilter: string[] | undefined } { const skillFilter = resolveEffectiveWorkspaceSkillFilter(opts); @@ -573,6 +584,7 @@ export function loadVisibleSkills( skillOverrides?: Record; agentId?: string; eligibility?: SkillEligibilityContext; + pluginMetadataSnapshot?: PluginMetadataSnapshot; }, ): SkillEntry[] { const entries = mergeRemoteNodeSkillEntries(loadSkillEntries(workspaceDir, opts), { diff --git a/src/skills/loading/workspace-skill-prompt.ts b/src/skills/loading/workspace-skill-prompt.ts index 63669f8ae8db..d1ab3e21bcac 100644 --- a/src/skills/loading/workspace-skill-prompt.ts +++ b/src/skills/loading/workspace-skill-prompt.ts @@ -1,6 +1,7 @@ // Workspace skill prompt helpers render bounded catalogs and reusable snapshots. import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import { resolveEffectiveAgentSkillsLimits } from "../discovery/agent-filter.js"; import { filterPromptVisibleSkillEntries } from "../discovery/skill-index.js"; import type { SkillEligibilityContext, SkillEntry, SkillSnapshot } from "../types.js"; @@ -24,6 +25,7 @@ type WorkspaceSkillBuildOptions = { skillOverrides?: Record; eligibility?: SkillEligibilityContext; preserveEntryOrder?: boolean; + pluginMetadataSnapshot?: PluginMetadataSnapshot; }; function resolveWorkspaceSkillPromptState( diff --git a/src/skills/runtime/session-snapshot.ts b/src/skills/runtime/session-snapshot.ts index 1f2984b8805b..266caaeda98b 100644 --- a/src/skills/runtime/session-snapshot.ts +++ b/src/skills/runtime/session-snapshot.ts @@ -2,6 +2,7 @@ import { stableStringify } from "@openclaw/normalization-core"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { pruneMapToMaxSize } from "../../infra/map-size.js"; +import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import { matchesSkillFilter } from "../discovery/filter.js"; import { loadMergedWorkspaceSkills, @@ -33,6 +34,7 @@ type ReusableSkillSnapshotParams = { snapshotVersion?: number; watch?: boolean; hydrateExisting?: boolean; + pluginMetadataSnapshot?: PluginMetadataSnapshot; }; type ReusableSkillSnapshotResult = { @@ -108,6 +110,7 @@ export function resolveReusableWorkspaceSkillSnapshot( skillFilter: params.skillFilter, skillOverrides: params.skillOverrides, eligibility: params.eligibility, + pluginMetadataSnapshot: params.pluginMetadataSnapshot, snapshotVersion, }); return skillRoots ? { ...snapshot, skillRoots } : snapshot;