diff --git a/config/knip.all-exports.config.ts b/config/knip.all-exports.config.ts index 1a82261d5d24..5add1ff7d882 100644 --- a/config/knip.all-exports.config.ts +++ b/config/knip.all-exports.config.ts @@ -123,6 +123,8 @@ const config = { // This fixture deliberately mixes used, aliased, and unused exports so the // topology analyzer can prove each classification. ignoreIssues: { + // The memory-state compatibility facade must retain its pre-registry-bundle type export. + "src/plugins/memory-state.ts": ["types"], // Cache-busting dynamic imports are real consumers, but Knip cannot map // their query-suffixed module ids back to these named test-support exports. "test/helpers/config/bundled-channel-config-runtime.ts": ["exports"], diff --git a/config/knip.config.ts b/config/knip.config.ts index c47e2fe2deed..42b4850af5b9 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -370,6 +370,17 @@ const config = { "src/gateway/board-view-ticket.ts": ["exports"], // Focused startup tests consume this explicit seam; production imports only the bootstrap. "src/gateway/server-startup-bootstrap.ts": ["exports"], + // Registry facades retain direct registration/reset compatibility seams used by focused + // tests; the full-tree scan still audits every named export against those consumers. + "src/agents/harness/registry.ts": ["exports"], + "src/context-engine/registry.ts": ["exports", "types"], + "src/plugins/command-registration.ts": ["exports"], + "src/plugins/compaction-provider.ts": ["exports"], + "src/plugins/interactive-registry.ts": ["exports"], + "src/plugins/loader-module-runtime.ts": ["exports"], + "src/plugins/memory-state.ts": ["exports", "types"], + "src/plugins/session-discussion-registry.ts": ["exports"], + "src/tasks/detached-task-runtime-state.ts": ["exports"], // Focused media tests consume these explicit seams; production uses the helpers in-module. "src/agents/embedded-agent-subscribe.handlers.lifecycle.ts": ["exports"], "src/gateway/server-methods/chat-webchat-media.ts": ["exports"], diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 4a5fc28d938d..4f0893ce2621 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -58,7 +58,7 @@ eb4c757fe0086c1dbfa4c3f3caf3dcff0d3cab3924c608237f08f740a6ee5f59 module/command 20f3f8042de53e4eee61b64de9102c8c202b9299e6a29235647a4729f70145f2 module/config-mutation 189fa5a240cad0404cd281ad0a14a105a8f3231278d87b71cfbc4f96fb8e48ef module/config-runtime c1ea9510dfda047609a99d5d2cd1f1560f5d469a36e6b695766213d695c25b0f module/conversation-runtime -9782f7c9fdaec5887dea5f1ef1e2ee4e2dbcb98d7a3dd76726925962d3c2e527 module/core +8994adeb5aec2799c1a6d492f4f69db38ab15381038525666463580f520c5cfc module/core 4af19d59c2f18674e7d7f7dc1b358b644dc707e6bd601dc47168bd9e4a669940 module/dedupe-runtime f70c93d28053ca2e8353e45e6515ce7acef188097c6117d1545965d0699c8004 module/device-bootstrap 6215d3af5923bf5a616d73062534968b69f448e3e30adc64ae9caebdd1a46d71 module/diagnostic-runtime @@ -88,7 +88,7 @@ c5e3eb1a584f4b8126d9d6c177a840ec9103671e8d1242634ee67db9b5b9e573 module/media-u c0ffaed532578cf33493992e1ff806b2268b8e3774a92edbaede5cf5bda162a6 module/media-understanding-runtime bebd2931dc51d67c063ff19fa1c278f8dcfe00ab23cfbd480d47329ea8e5088e module/meeting-runtime aec2225e0341aea994c2d5dd0e642e9c44281ba0aee445574e2355eda4225945 module/memory-core-host-engine-foundation -5d4d709d5ae573186459462fe5119bd253c13104554eb94aefbdab5f5c7ad46f module/memory-host-core +00d6f8bc78256558972d431b7983d7b774e0a98d802a1eec533d2cae1e1f1986 module/memory-host-core 87b7a3206346c0d4b294fb3a2395cbaabc3e73ff8b1b9ea925bc3aade3e52687 module/messaging-targets 5011823e5530df577d800e2b910c4b4f49b59084aa1df3bc03e3962ad3aee279 module/model-session-runtime 44655a08ce111a036c837b8d2e796cf0036941379ab317e5764c6c01e73b39f3 module/models-provider-runtime @@ -122,7 +122,7 @@ aa8a411ad37c1d1143b67376bf2d20255b9eedff61d80815f42e4f8ed7bd8e58 module/secret- e576b537880f63b3a91f3608f7e84c873bce6c6a3d9a0ba98c247f46de788d25 module/secret-ref-runtime 62ccaafc8e0677e850339f4a4333f9f16ae9fed979bcef003890b2a47507147f module/security-runtime 673c64502fdffb2d6361a7cf2ad0c33ffe15707b5e5027de1d88701ce3d8ade1 module/session-catalog -31b785e74f1f8f56241b7756ef6a5d86199c5ce177cbb1c234a261866972f270 module/session-discussion +50f5e344f98c27570b7a30e32a906b612e2383d21f102e88cd93e1d5425a6de9 module/session-discussion f112bdabc51ba8659b37d0a6f6a32a2b1d471e5b49b56e108bf750ec55a7ea71 module/session-store-runtime 36affbe151431a6141664b6838e20f2d121ff210d57a3c1b4b41a8818b5c81d8 module/setup 21071e8c2ef020685aba09b5661e37e7415938ff6602053fa831ac9d58673248 module/setup-runtime diff --git a/extensions/llama-cpp/index.test.ts b/extensions/llama-cpp/index.test.ts index 6fb998b05ec5..416777893918 100644 --- a/extensions/llama-cpp/index.test.ts +++ b/extensions/llama-cpp/index.test.ts @@ -9,9 +9,12 @@ import { import { clearEmbeddingProviders, clearMemoryEmbeddingProviders, + createEmptyPluginRegistry, + getActivePluginRegistry, getRegisteredEmbeddingProvider, + setActivePluginRegistry, } from "openclaw/plugin-sdk/plugin-test-runtime"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const memoryHostEmbeddingMocks = vi.hoisted(() => ({ createLocalEmbeddingProvider: vi.fn(), @@ -32,6 +35,11 @@ type MemoryCreateTestOptions = AdapterCreateOptions & { fallback?: "none"; outputDimensionality?: number; }; +let previousPluginRegistry: ReturnType; + +beforeEach(() => { + previousPluginRegistry = getActivePluginRegistry(); +}); async function createLlamaCppMemoryEmbeddingProvider(options: MemoryCreateTestOptions) { const { fallback: _fallback, outputDimensionality, ...adapterOptions } = options; @@ -44,6 +52,7 @@ async function createLlamaCppMemoryEmbeddingProvider(options: MemoryCreateTestOp afterEach(() => { clearEmbeddingProviders(); clearMemoryEmbeddingProviders(); + setActivePluginRegistry(previousPluginRegistry ?? createEmptyPluginRegistry()); memoryHostEmbeddingMocks.createLocalEmbeddingProvider.mockReset(); }); @@ -88,6 +97,7 @@ describe("llama.cpp provider plugin", () => { }, register: llamaCppPlugin.register, }); + setActivePluginRegistry(registry.registry); const provider = getRegisteredEmbeddingProvider("local"); expect(provider?.ownerPluginId).toBe("llama-cpp"); diff --git a/extensions/memory-core/src/memory/generic-embedding-provider.bridge.test.ts b/extensions/memory-core/src/memory/generic-embedding-provider.bridge.test.ts index af626ee1a03d..c5dfffa275e7 100644 --- a/extensions/memory-core/src/memory/generic-embedding-provider.bridge.test.ts +++ b/extensions/memory-core/src/memory/generic-embedding-provider.bridge.test.ts @@ -10,6 +10,8 @@ import { } from "openclaw/plugin-sdk/plugin-test-contracts"; import { clearEmbeddingProviders, + createEmptyPluginRegistry, + getActivePluginRegistry, getRegisteredEmbeddingProvider, listRegisteredEmbeddingProviders, type RegisteredEmbeddingProvider, @@ -18,6 +20,7 @@ import { listRegisteredMemoryEmbeddingProviders, type RegisteredMemoryEmbeddingProvider, restoreRegisteredMemoryEmbeddingProviders, + setActivePluginRegistry, } from "openclaw/plugin-sdk/plugin-test-runtime"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createEmbeddingProvider, resolveEmbeddingProviderIndexIdentity } from "./embeddings.js"; @@ -30,6 +33,7 @@ type CapturedCall = { let embeddingProvidersSnapshot: RegisteredEmbeddingProvider[]; let memoryEmbeddingProvidersSnapshot: RegisteredMemoryEmbeddingProvider[]; +let previousPluginRegistry: ReturnType; function createOptions(config: OpenClawConfig) { return { @@ -43,6 +47,7 @@ function createOptions(config: OpenClawConfig) { } beforeEach(() => { + previousPluginRegistry = getActivePluginRegistry(); embeddingProvidersSnapshot = listRegisteredEmbeddingProviders(); memoryEmbeddingProvidersSnapshot = listRegisteredMemoryEmbeddingProviders(); clearEmbeddingProviders(); @@ -50,6 +55,9 @@ beforeEach(() => { }); afterEach(() => { + clearEmbeddingProviders(); + clearMemoryEmbeddingProviders(); + setActivePluginRegistry(previousPluginRegistry ?? createEmptyPluginRegistry()); restoreRegisteredEmbeddingProviders(embeddingProvidersSnapshot); restoreRegisteredMemoryEmbeddingProviders(memoryEmbeddingProvidersSnapshot); }); @@ -140,6 +148,7 @@ describe("memory-core generic embedding provider bridge", () => { }); }, }); + setActivePluginRegistry(registry.registry); expect(getRegisteredEmbeddingProvider("virtual-generic")?.ownerPluginId).toBe( "virtual-generic-plugin", diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index aa1d45ad64fe..f41442825574 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -10,7 +10,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildGroupChatContext, buildGroupIntro } from "../../auto-reply/reply/groups.js"; import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { registerLegacyContextEngine } from "../../context-engine/legacy.registration.js"; import { registerContextEngineForOwner } from "../../context-engine/registry.js"; import type { ContextEngine } from "../../context-engine/types.js"; import type { CliBackendPlugin } from "../../plugins/cli-backend.types.js"; @@ -19,7 +18,9 @@ import { clearMemoryPluginState, registerTestMemoryPromptBuilder, } from "../../plugins/memory-state.test-fixtures.js"; +import { createPluginRegistry } from "../../plugins/registry.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import type { PluginRuntime } from "../../plugins/runtime/types.js"; import { createChannelTestPluginBase, createTestRegistry, @@ -66,6 +67,21 @@ function registerTestContextEngine( }); } +function installTestPluginRegistry() { + const builder = createPluginRegistry({ + logger: { + info() {}, + warn() {}, + error() {}, + debug() {}, + }, + runtime: {} as PluginRuntime, + activateGlobalSideEffects: true, + }); + setActivePluginRegistry(builder.registry); + return builder; +} + const getRuntimeConfigMock = vi.hoisted(() => vi.fn(() => ({}))); const ensureSandboxWorkspaceForSessionMock = vi.hoisted(() => vi.fn<() => Promise>(async () => null), @@ -1945,16 +1961,28 @@ describe("prepareCliRunContext", () => { expect(dispose).not.toHaveBeenCalled(); }); - it("cleans up prepared CLI backend when context-engine resolution fails", async () => { + it("cleans up prepared CLI backend when context-engine host validation fails", async () => { + installTestPluginRegistry(); + const engineId = `cli-cleanup-engine-${Date.now().toString(36)}`; const cleanup = vi.fn(async () => {}); const prepareExecution = vi.fn(async () => ({ cleanup })); - registerContextEngineForOwner( - "legacy", - () => { - throw new Error("context engine failed"); - }, - "core", - { allowSameOwnerRefresh: true }, + registerTestContextEngine( + engineId, + (): ContextEngine => ({ + info: { + id: engineId, + name: "CLI cleanup engine", + hostRequirements: { + "agent-run": { + requiredCapabilities: ["assemble-before-prompt"], + unsupportedMessage: "context engine failed", + }, + }, + }, + ingest: vi.fn(async () => ({ ingested: true })), + assemble: vi.fn(async ({ messages }) => ({ messages, estimatedTokens: 0 })), + compact: vi.fn(async () => ({ ok: true, compacted: false })), + }), ); setRawCliBackendForPrepareTest({ id: "test-cli", @@ -1972,14 +2000,14 @@ describe("prepareCliRunContext", () => { }, }); - try { - await expect(fixture.prepare({})).rejects.toThrow("context engine failed"); + await expect( + fixture.prepare({ + config: { plugins: { slots: { contextEngine: engineId } } }, + }), + ).rejects.toThrow("context engine failed"); - expect(prepareExecution).toHaveBeenCalledOnce(); - expect(cleanup).toHaveBeenCalledOnce(); - } finally { - registerLegacyContextEngine(); - } + expect(prepareExecution).toHaveBeenCalledOnce(); + expect(cleanup).toHaveBeenCalledOnce(); }); it("rejects CLI runs for context engines that require pre-prompt assembly", async () => { diff --git a/src/agents/harness/registry.test-support.ts b/src/agents/harness/registry.test-support.ts new file mode 100644 index 000000000000..870eacea27cf --- /dev/null +++ b/src/agents/harness/registry.test-support.ts @@ -0,0 +1,9 @@ +import { clearAgentHarnesses, registerAgentHarness } from "./registry.js"; +import type { RegisteredAgentHarness } from "./types.js"; + +export function restoreRegisteredAgentHarnesses(entries: RegisteredAgentHarness[]): void { + clearAgentHarnesses(); + for (const entry of entries) { + registerAgentHarness(entry.harness, { ownerPluginId: entry.ownerPluginId }); + } +} diff --git a/src/agents/harness/registry.test.ts b/src/agents/harness/registry.test.ts index 6a7a476b765a..140b3efd8b0b 100644 --- a/src/agents/harness/registry.test.ts +++ b/src/agents/harness/registry.test.ts @@ -1,6 +1,8 @@ // Exercises agent harness registration, ownership metadata, and selection handoff. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js"; +import { withPluginRegistrationContext } from "../../plugins/runtime.js"; import { clearAgentHarnesses, disposeRegisteredAgentHarnesses, @@ -8,7 +10,6 @@ import { listRegisteredAgentHarnesses, registerAgentHarness, resetRegisteredAgentHarnessSessions, - restoreRegisteredAgentHarnesses, } from "./registry.js"; import { selectAgentHarness } from "./selection.js"; import type { AgentHarness } from "./types.js"; @@ -77,14 +78,39 @@ describe("agent harness registry", () => { expect(listRegisteredAgentHarnesses().map((entry) => entry.harness.id)).toEqual(["custom"]); }); - it("restores a registry snapshot", () => { - registerAgentHarness(makeHarness("a")); - const snapshot = listRegisteredAgentHarnesses(); - registerAgentHarness(makeHarness("b")); + it("keeps explicit ownership distinct from harness metadata", () => { + const harness = { ...makeHarness("custom"), pluginId: "harness-declared" }; + registerAgentHarness(harness, { ownerPluginId: "registry-owner" }); - restoreRegisteredAgentHarnesses(snapshot); + expect(getRegisteredAgentHarness("custom")).toEqual({ + harness, + ownerPluginId: "registry-owner", + }); + expect(listRegisteredAgentHarnesses()).toEqual([{ harness, ownerPluginId: "registry-owner" }]); + }); - expect(listRegisteredAgentHarnesses().map((entry) => entry.harness.id)).toEqual(["a"]); + it("uses builder ownership and preserves a harness registered by another plugin", () => { + const building = createEmptyPluginRegistry(); + const original = makeHarness("shared"); + building.agentHarnesses.push({ + pluginId: "first-plugin", + source: "runtime", + harness: original, + }); + + expect(() => + withPluginRegistrationContext(building, "failing-plugin", () => { + registerAgentHarness(makeHarness("shared")); + }), + ).toThrow("agent harness shared already registered by first-plugin"); + expect(building.agentHarnesses).toEqual([ + { pluginId: "first-plugin", source: "runtime", harness: original }, + ]); + + withPluginRegistrationContext(building, "builder-plugin", () => { + registerAgentHarness(makeHarness("owned")); + }); + expect(building.agentHarnesses[1]?.pluginId).toBe("builder-plugin"); }); it("dispatches generic session reset to registered harnesses", async () => { diff --git a/src/agents/harness/registry.ts b/src/agents/harness/registry.ts index 1e995afaeb2c..d0f826af78c5 100644 --- a/src/agents/harness/registry.ts +++ b/src/agents/harness/registry.ts @@ -2,29 +2,17 @@ * Registry for native agent harness implementations and lifecycle cleanup. */ import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { + assertDirectPluginRegistrationReplacement, + requireActivePluginRegistry, + resolveDirectPluginRegistrationOwner, +} from "../../plugins/runtime.js"; import type { AgentHarness, AgentHarnessResetParams, RegisteredAgentHarness } from "./types.js"; -/** - * Process-wide registry for agent harnesses contributed by core and runtime plugins. - * - * The registry is global-symbol backed so repeated imports, test module resets, and plugin lazy - * loads share one harness table inside a running gateway process. - */ -const AGENT_HARNESS_REGISTRY_STATE = Symbol.for("openclaw.agentHarnessRegistryState"); const log = createSubsystemLogger("agents/harness"); -type AgentHarnessRegistryState = { - harnesses: Map; -}; - -function getAgentHarnessRegistryState(): AgentHarnessRegistryState { - const globalState = globalThis as typeof globalThis & { - [AGENT_HARNESS_REGISTRY_STATE]?: AgentHarnessRegistryState; - }; - globalState[AGENT_HARNESS_REGISTRY_STATE] ??= { - harnesses: new Map(), - }; - return globalState[AGENT_HARNESS_REGISTRY_STATE]; +function getAgentHarnesses() { + return requireActivePluginRegistry().agentHarnesses; } /** Registers or replaces an agent harness under its trimmed id. */ @@ -33,38 +21,53 @@ export function registerAgentHarness( options?: { ownerPluginId?: string }, ): void { const id = harness.id.trim(); - getAgentHarnessRegistryState().harnesses.set(id, { + const harnesses = getAgentHarnesses(); + const pluginId = resolveDirectPluginRegistrationOwner(options?.ownerPluginId) ?? "core"; + const entry = { + pluginId, + source: "runtime", harness: { ...harness, id, - pluginId: harness.pluginId ?? options?.ownerPluginId, + pluginId: harness.pluginId ?? (pluginId === "core" ? undefined : pluginId), }, - ownerPluginId: options?.ownerPluginId, - }); + }; + const existingIndex = harnesses.findIndex((registration) => registration.harness.id === id); + if (existingIndex !== -1) { + assertDirectPluginRegistrationReplacement( + harnesses[existingIndex]?.pluginId, + `agent harness ${id}`, + ); + } + if (existingIndex === -1) { + harnesses.push(entry); + } else { + harnesses.splice(existingIndex, 1, entry); + } } /** Returns the harness plus plugin ownership metadata for registry diagnostics. */ export function getRegisteredAgentHarness(id: string): RegisteredAgentHarness | undefined { - return getAgentHarnessRegistryState().harnesses.get(id.trim()); + const registration = getAgentHarnesses().find((entry) => entry.harness.id === id.trim()); + return registration + ? { + harness: registration.harness, + ownerPluginId: registration.pluginId === "core" ? undefined : registration.pluginId, + } + : undefined; } /** Lists registered harness records for selection and lifecycle fan-out. */ export function listRegisteredAgentHarnesses(): RegisteredAgentHarness[] { - return Array.from(getAgentHarnessRegistryState().harnesses.values()); + return getAgentHarnesses().map((entry) => ({ + harness: entry.harness, + ownerPluginId: entry.pluginId === "core" ? undefined : entry.pluginId, + })); } /** Clears all harnesses; intended for tests and controlled registry reloads. */ export function clearAgentHarnesses(): void { - getAgentHarnessRegistryState().harnesses.clear(); -} - -/** Restores a prior harness snapshot after tests temporarily replace the registry. */ -export function restoreRegisteredAgentHarnesses(entries: RegisteredAgentHarness[]): void { - const map = getAgentHarnessRegistryState().harnesses; - map.clear(); - for (const entry of entries) { - map.set(entry.harness.id, entry); - } + getAgentHarnesses().length = 0; } /** Calls each registered harness session-reset hook without letting one failure stop the fan-out. */ diff --git a/src/agents/thinking-runtime.test.ts b/src/agents/thinking-runtime.test.ts index ee386fc4b235..c45af50a74dd 100644 --- a/src/agents/thinking-runtime.test.ts +++ b/src/agents/thinking-runtime.test.ts @@ -4,8 +4,8 @@ import { clearAgentHarnesses, listRegisteredAgentHarnesses, registerAgentHarness, - restoreRegisteredAgentHarnesses, } from "./harness/registry.js"; +import { restoreRegisteredAgentHarnesses } from "./harness/registry.test-support.js"; import type { AgentHarness } from "./harness/types.js"; import { hasResolvedThinkingCatalogEntry, diff --git a/src/auto-reply/reply/commands-diagnostics.test.ts b/src/auto-reply/reply/commands-diagnostics.test.ts index 9b09c0b1ade3..021d81c799b6 100644 --- a/src/auto-reply/reply/commands-diagnostics.test.ts +++ b/src/auto-reply/reply/commands-diagnostics.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js"; import { createPluginRegistry } from "../../plugins/registry.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; import type { PluginRuntime } from "../../plugins/runtime/types.js"; import { createBundledPluginRecord } from "../../plugins/status.test-fixtures.js"; import type { OpenClawPluginCommandDefinition, PluginCommandContext } from "../../plugins/types.js"; @@ -154,6 +155,7 @@ function registerHostTrustedReservedCommandForTest( activateGlobalSideEffects: true, }); pluginRegistry.registerCommand(createBundledPluginRecord(command.name), command); + setActivePluginRegistry(pluginRegistry.registry); } function registerCodexDiagnosticsCommandForTest( diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index 88c91026f261..5a1c201100b0 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -12,6 +12,12 @@ import { registerMemoryPromptPreparation, registerTestMemoryPromptBuilder, } from "../plugins/memory-state.test-fixtures.js"; +import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; +import { + requireActivePluginRegistry, + setActivePluginRegistry, + withPluginRegistrationContext, +} from "../plugins/runtime.js"; import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; // --------------------------------------------------------------------------- // We dynamically import the registry so we can get a fresh module per test @@ -25,9 +31,11 @@ import { import { LegacyContextEngine } from "./legacy.js"; import { registerLegacyContextEngine } from "./legacy.registration.js"; import { - registerContextEngineForOwner, + activateContextEngineRegistrations, getContextEngineRegistration, listContextEngineQuarantines, + registerContextEngineForOwner, + registerContextEngineInRegistry, resolveContextEngine, resolveContextEngineOwnerPluginId, } from "./registry.js"; @@ -148,13 +156,7 @@ function requireFactoryContext( } function requireRegistryState() { - const registryState = (globalThis as Record)[ - Symbol.for("openclaw.contextEngineRegistryState") - ] as { engines: Map } | undefined; - if (!registryState) { - throw new Error("expected context engine registry state"); - } - return registryState; + return { engines: requireActivePluginRegistry().contextEngines }; } /** A minimal mock engine that satisfies the ContextEngine interface. */ @@ -633,6 +635,20 @@ describe("Registry tests", () => { expect(getContextEngineRegistration("reg-owner-guard")?.factory).toBe(factory1); }); + it("reserves the default engine id even in an empty builder registry", () => { + const building = createEmptyPluginRegistry(); + + expect( + registerContextEngineInRegistry( + building, + "legacy", + () => new MockContextEngine(), + "plugin:shadow", + ), + ).toEqual({ ok: false, existingOwner: "core" }); + expect(building.contextEngines.size).toBe(0); + }); + it("exposes the trusted plugin owner for a resolved registered engine", async () => { const engineId = `owner-policy-${Date.now().toString(36)}`; registerContextEngineForOwner(engineId, () => new MockContextEngine(), "plugin:lossless-claw", { @@ -1134,6 +1150,31 @@ describe("Invalid engine fallback", () => { expect(registeredEngine.info.id).toBe(engineId); }); + it("defers quarantine clearing for builder-context direct registrations", async () => { + const engineId = uniqueEngineId("builder-register"); + await resolveContextEngine(configWithSlot(engineId)); + const builder = createEmptyPluginRegistry(); + + withPluginRegistrationContext(builder, "context-builder", () => { + registerContextEngineForOwner( + engineId, + () => new MockContextEngine(), + "plugin:context-builder", + { allowSameOwnerRefresh: true }, + ); + }); + + expect(builder.contextEngines.has(engineId)).toBe(true); + expect(getContextEngineRegistration(engineId)).toBeUndefined(); + expect(listContextEngineQuarantines()).toEqual([ + expect.objectContaining({ engineId, reason: "not registered" }), + ]); + + setActivePluginRegistry(builder); + activateContextEngineRegistrations(builder); + expect(listContextEngineQuarantines()).toEqual([]); + }); + it("does not quarantine abort rejections from lifecycle methods", async () => { const engineId = uniqueEngineId("abort-rejection"); const abortError = new Error("compaction aborted"); diff --git a/src/context-engine/init.ts b/src/context-engine/init.ts index c858d3f5016b..e0e5da0e67a3 100644 --- a/src/context-engine/init.ts +++ b/src/context-engine/init.ts @@ -2,7 +2,7 @@ import { registerLegacyContextEngine } from "./legacy.registration.js"; /** - * Ensures all built-in context engines are registered exactly once. + * Ensures all built-in context engines are registered in the active registry. * * The legacy engine is always registered as a safe fallback so that * `resolveContextEngine()` can resolve the default "legacy" slot without @@ -11,14 +11,7 @@ import { registerLegacyContextEngine } from "./legacy.registration.js"; * Additional engines are registered by their own plugins via * `api.registerContextEngine()` during plugin load. */ -let initialized = false; - export function ensureContextEnginesInitialized(): void { - if (initialized) { - return; - } - initialized = true; - // Always available – safe fallback for the "legacy" slot default. registerLegacyContextEngine(); } diff --git a/src/context-engine/registry.test-support.ts b/src/context-engine/registry.test-support.ts index 8feef6218e48..6e0c64403320 100644 --- a/src/context-engine/registry.test-support.ts +++ b/src/context-engine/registry.test-support.ts @@ -1,3 +1,4 @@ +import { requireActivePluginRegistry } from "../plugins/runtime.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { clearPersistedContextEngineQuarantineForProcess, @@ -13,7 +14,6 @@ type ContextEngineRuntimeQuarantineForTests = { }; type ContextEngineRegistryStateForTests = { - engines: Map; quarantinedEngines: Map; }; @@ -22,19 +22,20 @@ const CONTEXT_ENGINE_REGISTRY_STATE = Symbol.for("openclaw.contextEngineRegistry function getContextEngineRegistryStateForTests(): ContextEngineRegistryStateForTests { return resolveGlobalSingleton( CONTEXT_ENGINE_REGISTRY_STATE, - () => ({ engines: new Map(), quarantinedEngines: new Map() }), + () => ({ quarantinedEngines: new Map() }), ); } export function captureContextEngineRegistryStateForTests(): () => void { const state = getContextEngineRegistryStateForTests(); - const engines = new Map(state.engines); + const registry = requireActivePluginRegistry(); + const engines = new Map(registry.contextEngines); const quarantinedEngines = new Map(state.quarantinedEngines); return () => { - state.engines.clear(); + registry.contextEngines.clear(); for (const [engineId, registration] of engines) { - state.engines.set(engineId, registration); + registry.contextEngines.set(engineId, registration as never); } state.quarantinedEngines.clear(); diff --git a/src/context-engine/registry.ts b/src/context-engine/registry.ts index a1b205360982..c61438e86e60 100644 --- a/src/context-engine/registry.ts +++ b/src/context-engine/registry.ts @@ -3,6 +3,14 @@ import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js"; import type { OpenClawConfig } from "../config/types.js"; import { createAbortError } from "../infra/abort-signal.js"; import { getPluginCompatRecord } from "../plugins/compat/registry.js"; +import type { + ContextEngineFactory, + ContextEngineFactoryContext, + ContextEngineRegistration, + ContextEngineRegistrationLifecycle, +} from "../plugins/registry-contribution-types.js"; +import type { PluginRegistry } from "../plugins/registry-types.js"; +import { getActivePluginRegistry, requireActivePluginRegistry } from "../plugins/runtime.js"; import { defaultSlotIdForKey } from "../plugins/slots.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { @@ -19,36 +27,14 @@ import type { IngestResult, } from "./types.js"; +export type { ContextEngineFactory } from "../plugins/registry-contribution-types.js"; + /** * Runtime context passed to context engine factories during resolution. * Provides config and path information so plugins can initialize engines * without fragile workarounds. */ -type ContextEngineFactoryContext = { - config?: OpenClawConfig; - agentDir?: string; - workspaceDir?: string; -}; - -/** - * A factory that creates a ContextEngine instance. - * Supports async creation for engines that need DB connections etc. - * - * The factory receives a {@link ContextEngineFactoryContext} with runtime - * environment context (config, paths). Existing no-arg factories remain - * backward compatible because TypeScript permits assigning functions with - * fewer parameters to wider signatures. - */ -export type ContextEngineFactory = ( - ctx: ContextEngineFactoryContext, -) => ContextEngine | Promise; type ContextEngineRegistrationResult = { ok: true } | { ok: false; existingOwner: string }; -type ContextEngineRegistrationLifecycle = "runtime" | "readOnlyDiscovery"; -type ContextEngineRegistration = { - factory: ContextEngineFactory; - owner: string; - lifecycle: ContextEngineRegistrationLifecycle; -}; type RegisterContextEngineForOwnerOptions = { allowSameOwnerRefresh?: boolean; @@ -153,7 +139,6 @@ type ContextEngineRuntimeQuarantine = { }; type ContextEngineRegistryState = { - engines: Map; quarantinedEngines: Map; }; @@ -162,11 +147,12 @@ type ContextEngineRegistryState = { const contextEngineRegistryState = resolveGlobalSingleton( CONTEXT_ENGINE_REGISTRY_STATE, () => ({ - engines: new Map(), quarantinedEngines: new Map(), }), ); +const getContextEngines = () => requireActivePluginRegistry().contextEngines; + function requireContextEngineOwner(owner: string): string { const normalizedOwner = owner.trim(); if (!normalizedOwner) { @@ -253,10 +239,30 @@ export function registerContextEngineForOwner( factory: ContextEngineFactory, owner: string, opts?: RegisterContextEngineForOwnerOptions, +): ContextEngineRegistrationResult { + const targetRegistry = requireActivePluginRegistry(); + const result = registerContextEngineInRegistry(targetRegistry, id, factory, owner, opts); + if ( + result.ok && + (opts?.lifecycle ?? "runtime") === "runtime" && + getActivePluginRegistry() === targetRegistry + ) { + clearContextEngineRuntimeQuarantine(id); + } + return result; +} + +/** Registers an engine in a registry value while that value is being assembled. */ +export function registerContextEngineInRegistry( + pluginRegistry: PluginRegistry, + id: string, + factory: ContextEngineFactory, + owner: string, + opts?: RegisterContextEngineForOwnerOptions, ): ContextEngineRegistrationResult { const normalizedOwner = requireContextEngineOwner(owner); const lifecycle = opts?.lifecycle ?? "runtime"; - const registry = contextEngineRegistryState.engines; + const registry = pluginRegistry.contextEngines; const existing = registry.get(id); if ( id === defaultSlotIdForKey("contextEngine") && @@ -277,27 +283,33 @@ export function registerContextEngineForOwner( return { ok: false, existingOwner: existing.owner }; } registry.set(id, { factory, owner: normalizedOwner, lifecycle }); - if (lifecycle === "runtime") { - clearContextEngineRuntimeQuarantine(id); - } return { ok: true }; } +/** Clear runtime quarantine only after a complete builder-local registry becomes active. */ +export function activateContextEngineRegistrations(pluginRegistry: PluginRegistry): void { + for (const [id, registration] of pluginRegistry.contextEngines) { + if (registration.lifecycle === "runtime") { + clearContextEngineRuntimeQuarantine(id); + } + } +} + /** Returns registration metadata so callers can distinguish discovery snapshots from runtime entries. */ export function getContextEngineRegistration(id: string): ContextEngineRegistration | undefined { - return contextEngineRegistryState.engines.get(id); + return getContextEngines().get(id); } /** * List all registered engine ids. */ function listContextEngineIds(): string[] { - return [...contextEngineRegistryState.engines.keys()]; + return [...getContextEngines().keys()].toSorted(); } export function clearContextEnginesForOwner(owner: string): void { const normalizedOwner = requireContextEngineOwner(owner); - const registry = contextEngineRegistryState.engines; + const registry = getContextEngines(); for (const [id, entry] of registry.entries()) { if (entry.owner === normalizedOwner) { registry.delete(id); @@ -575,7 +587,7 @@ export async function resolveContextEngine( return resolveDefaultContextEngine(defaultEngineId, factoryCtx); } - const entry = contextEngineRegistryState.engines.get(engineId); + const entry = getContextEngines().get(engineId); if (!entry) { if (isDefaultEngine) { throw new Error( @@ -640,7 +652,7 @@ async function resolveDefaultContextEngine( defaultEngineId: string, factoryCtx: ContextEngineFactoryContext, ): Promise { - const defaultEntry = contextEngineRegistryState.engines.get(defaultEngineId); + const defaultEntry = getContextEngines().get(defaultEngineId); if (!defaultEntry) { throw new Error( `[context-engine] fallback failed: default engine "${defaultEngineId}" is not registered. ` + diff --git a/src/gateway/embeddings-http.test.ts b/src/gateway/embeddings-http.test.ts index 9a74c28c4783..13eff394d17c 100644 --- a/src/gateway/embeddings-http.test.ts +++ b/src/gateway/embeddings-http.test.ts @@ -9,16 +9,24 @@ import { } from "node:http"; import type { AddressInfo } from "node:net"; import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { resolveAgentDir } from "../agents/agent-scope.js"; import { createConfigIO, resetConfigRuntimeState } from "../config/config.js"; import type { MemoryEmbeddingProviderAdapter, MemoryEmbeddingProviderCallOptions, } from "../plugins/memory-embedding-providers.js"; +import { createPluginRegistry } from "../plugins/registry.js"; +import type { PluginRuntime } from "../plugins/runtime/types.js"; import { createDeferred } from "../test-utils/deferred.js"; import { startOpenAiCompatGatewayServer } from "./openai-compatible-http.test-helpers.js"; -import { getFreePort, installGatewayTestHooks, testState } from "./test-helpers.js"; +import { + getFreePort, + installGatewayTestHooks, + resetTestPluginRegistry, + setTestPluginRegistry, + testState, +} from "./test-helpers.js"; installGatewayTestHooks({ scope: "suite" }); @@ -142,8 +150,6 @@ beforeAll(async () => { }, }), ); - clearMemoryEmbeddingProviders(); - clearEmbeddingProviders(); genericEmbeddingServer = await startGenericEmbeddingServer(); genericEmbeddingBaseUrl = genericEmbeddingServer.baseUrl; openAiAdapter = { @@ -165,7 +171,6 @@ beforeAll(async () => { return result; }, }; - registerMemoryEmbeddingProvider(openAiAdapter); ({ startGatewayServer } = await import("./server.js")); enabledPort = await getFreePort(); enabledServer = await startOpenAiCompatGatewayServer({ @@ -176,11 +181,30 @@ beforeAll(async () => { }); }); +beforeEach(() => { + const builder = createPluginRegistry({ + logger: { + info() {}, + warn() {}, + error() {}, + debug() {}, + }, + runtime: {} as PluginRuntime, + activateGlobalSideEffects: true, + }); + setTestPluginRegistry(builder.registry); + registerMemoryEmbeddingProvider(openAiAdapter); +}); + +afterEach(() => { + clearMemoryEmbeddingProviders(); + clearEmbeddingProviders(); + resetTestPluginRegistry(); +}); + afterAll(async () => { await enabledServer.close({ reason: "embeddings http enabled suite done" }); await genericEmbeddingServer.close(); - clearMemoryEmbeddingProviders(); - clearEmbeddingProviders(); vi.resetModules(); }); diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index a97a47701927..adaadd44ad3a 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -1,6 +1,11 @@ // Gateway plugin tests cover plugin loading, auto-enable, runtime registry setup, // request-scope injection, diagnostics, and handler dispatch integration. import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import { + getGlobalPluginRegistry, + initializeGlobalHookRunner, + resetGlobalHookRunner, +} from "../plugins/hook-runner-global.js"; import { createPluginRecord } from "../plugins/loader-records.js"; import type { PluginDiagnostic } from "../plugins/manifest-types.js"; import type { PluginLookUpTable } from "../plugins/plugin-lookup-table.js"; @@ -13,7 +18,6 @@ import type { PluginRuntime } from "../plugins/runtime/types.js"; import type { GatewayRequestContext, GatewayRequestOptions } from "./server-methods/types.js"; const loadOpenClawPlugins = vi.hoisted(() => vi.fn()); -const clearActivatedPluginRuntimeState = vi.hoisted(() => vi.fn()); const loadPluginLookUpTable = vi.hoisted(() => vi.fn(() => ({ startup: { @@ -41,7 +45,6 @@ const handleGatewayRequest = vi.hoisted(() => ); vi.mock("../plugins/loader.js", () => ({ - clearActivatedPluginRuntimeState, loadOpenClawPlugins, })); @@ -387,7 +390,6 @@ beforeAll(async () => { }); beforeEach(() => { - clearActivatedPluginRuntimeState.mockClear(); loadOpenClawPlugins.mockReset(); loadPluginLookUpTable.mockReset().mockReturnValue({ startup: { @@ -429,6 +431,7 @@ afterEach(() => { serverPluginsModule.clearFallbackGatewayContext(); clearGatewaySubagentRuntime(); runtimeRegistryModule.resetPluginRuntimeStateForTest(); + resetGlobalHookRunner(); }); describe("loadGatewayPlugins", () => { @@ -768,12 +771,29 @@ describe("loadGatewayPlugins", () => { baseMethods: ["sessions.get"], }); - expect(clearActivatedPluginRuntimeState).toHaveBeenCalledTimes(1); expect(loadOpenClawPlugins).not.toHaveBeenCalled(); expect(result.pluginRegistry.plugins).toStrictEqual([]); expect(result.gatewayMethods).toEqual(["sessions.get"]); }); + test("activates the empty registry in the global hook runner", () => { + const previous = addLoadedPlugin(createRegistry([]), { id: "previous-plugin" }); + runtimeRegistryModule.setActivePluginRegistry(previous); + initializeGlobalHookRunner(previous); + loadPluginLookUpTable.mockReturnValue({ startup: { pluginIds: [] } }); + + const result = serverPluginsModule.loadGatewayPlugins({ + cfg: {}, + workspaceDir: "/tmp", + log: createTestLog(), + coreGatewayHandlers: {}, + baseMethods: [], + }); + + expect(getGlobalPluginRegistry()).toBe(result.pluginRegistry); + expect(result.pluginRegistry.plugins).toStrictEqual([]); + }); + test("stores workspaceDir on the active registry when startup scope is empty", () => { loadPluginLookUpTable.mockReturnValue({ startup: { diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index b1edc077f240..4d7e403fd3d4 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -9,12 +9,13 @@ import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizePluginsConfig } from "../plugins/config-state.js"; import { extractPluginInstallRecordsFromInstalledPluginIndex } from "../plugins/installed-plugin-index-install-records.js"; -import { clearActivatedPluginRuntimeState, loadOpenClawPlugins } from "../plugins/loader.js"; +import { activatePluginRegistry } from "../plugins/loader-shared.js"; +import { loadOpenClawPlugins } from "../plugins/loader.js"; import { loadPluginLookUpTable, type PluginLookUpTable } from "../plugins/plugin-lookup-table.js"; import { getPluginModuleLoaderStats } from "../plugins/plugin-module-loader-cache.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import type { PluginRegistryParams } from "../plugins/registry-types.js"; -import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js"; +import { getActivePluginRegistry } from "../plugins/runtime.js"; import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; import { createPluginRuntimeLoaderLogger } from "../plugins/runtime/load-context.js"; import { @@ -633,9 +634,8 @@ export function loadGatewayPlugins(params: { ]; const pluginIdsMs = performance.now() - started; if (pluginIds.length === 0) { - clearActivatedPluginRuntimeState(); const pluginRegistry = createEmptyPluginRegistry(); - setActivePluginRegistry(pluginRegistry, undefined, "gateway-bindable", params.workspaceDir); + activatePluginRegistry(pluginRegistry, null, "gateway-bindable", params.workspaceDir); params.startupTrace?.detail("plugins.gateway-load", [ ["autoEnableMs", autoEnableMs], ["resolvedConfigMs", resolvedConfigMs], diff --git a/src/gateway/server-startup-plugins.test.ts b/src/gateway/server-startup-plugins.test.ts index 806a4a45aa2a..4985ec2b1635 100644 --- a/src/gateway/server-startup-plugins.test.ts +++ b/src/gateway/server-startup-plugins.test.ts @@ -143,7 +143,8 @@ vi.mock("../plugins/registry.js", () => ({ createEmptyPluginRegistry: () => ({ diagnostics: [], gatewayHandlers: {}, plugins: [] }), })); -vi.mock("../plugins/runtime.js", () => ({ +vi.mock("../plugins/runtime.js", async (importOriginal) => ({ + ...(await importOriginal()), getActivePluginRegistry: () => undefined, setActivePluginRegistry: vi.fn(), })); diff --git a/src/gateway/server.sessions.reset-cleanup.test.ts b/src/gateway/server.sessions.reset-cleanup.test.ts index c951a2ad1f4e..476a4ce42d9c 100644 --- a/src/gateway/server.sessions.reset-cleanup.test.ts +++ b/src/gateway/server.sessions.reset-cleanup.test.ts @@ -6,11 +6,8 @@ import { readAcpSessionMeta, writeAcpSessionMetaForMigration, } from "../acp/runtime/session-meta.js"; -import { - listRegisteredAgentHarnesses, - registerAgentHarness, - restoreRegisteredAgentHarnesses, -} from "../agents/harness/registry.js"; +import { listRegisteredAgentHarnesses, registerAgentHarness } from "../agents/harness/registry.js"; +import { restoreRegisteredAgentHarnesses } from "../agents/harness/registry.test-support.js"; import { loadSessionEntry } from "../config/sessions/session-accessor.js"; import type { SessionAcpMeta } from "../config/sessions/types.js"; import { enqueueSystemEvent, peekSystemEvents } from "../infra/system-events.js"; diff --git a/src/gateway/server.sessions.reset-concurrency.test.ts b/src/gateway/server.sessions.reset-concurrency.test.ts index 7b4d5ca88541..ca57416f89f2 100644 --- a/src/gateway/server.sessions.reset-concurrency.test.ts +++ b/src/gateway/server.sessions.reset-concurrency.test.ts @@ -1,10 +1,7 @@ // Session reset concurrency tests protect newer same-id lifecycle owners. import { afterEach, expect, test } from "vitest"; -import { - listRegisteredAgentHarnesses, - registerAgentHarness, - restoreRegisteredAgentHarnesses, -} from "../agents/harness/registry.js"; +import { listRegisteredAgentHarnesses, registerAgentHarness } from "../agents/harness/registry.js"; +import { restoreRegisteredAgentHarnesses } from "../agents/harness/registry.test-support.js"; import { loadTranscriptEvents } from "../config/sessions/session-accessor.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { writeSessionStore } from "./test-helpers.js"; diff --git a/src/hooks/internal-hooks.test.ts b/src/hooks/internal-hooks.test.ts index 87751a54b77d..a6c2ae211023 100644 --- a/src/hooks/internal-hooks.test.ts +++ b/src/hooks/internal-hooks.test.ts @@ -1,5 +1,11 @@ // Internal hook tests cover dispatch for command, session, agent, and gateway hooks. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; +import { + pinActivePluginChannelRegistry, + resetPluginRuntimeStateForTest, + setActivePluginRegistry, +} from "../plugins/runtime.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { clearInternalHooks, @@ -30,6 +36,7 @@ describe("hooks", () => { afterEach(() => { clearInternalHooks(); setInternalHooksEnabled(true); + resetPluginRuntimeStateForTest(); }); describe("registerInternalHook", () => { @@ -509,5 +516,31 @@ describe("hooks", () => { const keys = getRegisteredEventKeys(); expect(keys).toStrictEqual([]); }); + + it("removes legacy hooks from active and pinned plugin registries", () => { + const pinned = createEmptyPluginRegistry(); + const active = createEmptyPluginRegistry(); + pinned.legacyInternalHooks.push({ + pluginId: "pinned-plugin", + name: "pinned-plugin", + event: "command:new", + handler: vi.fn(), + }); + active.legacyInternalHooks.push({ + pluginId: "active-plugin", + name: "active-plugin", + event: "command:stop", + handler: vi.fn(), + }); + setActivePluginRegistry(pinned); + pinActivePluginChannelRegistry(pinned); + setActivePluginRegistry(active); + + clearInternalHooks(); + + expect(active.legacyInternalHooks).toStrictEqual([]); + expect(pinned.legacyInternalHooks).toStrictEqual([]); + expect(getRegisteredEventKeys()).toStrictEqual([]); + }); }); }); diff --git a/src/hooks/internal-hooks.ts b/src/hooks/internal-hooks.ts index d91c189a1196..446b6975b117 100644 --- a/src/hooks/internal-hooks.ts +++ b/src/hooks/internal-hooks.ts @@ -12,6 +12,11 @@ import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { + clearLegacyPluginInternalHooks, + listLegacyPluginInternalHookEventKeys, + listLegacyPluginInternalHooks, +} from "../plugins/legacy-internal-hook-state.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import type { InternalHookEvent, @@ -265,6 +270,7 @@ export function unregisterInternalHook(eventKey: string, handler: InternalHookHa */ export function clearInternalHooks(): void { handlers.clear(); + clearLegacyPluginInternalHooks(); } export function setInternalHooksEnabled(enabled: boolean): void { @@ -275,12 +281,15 @@ export function setInternalHooksEnabled(enabled: boolean): void { * Get all registered event keys (useful for debugging) */ export function getRegisteredEventKeys(): string[] { - return Array.from(handlers.keys()); + return [...new Set([...handlers.keys(), ...listLegacyPluginInternalHookEventKeys()])]; } export function hasInternalHookListeners(type: InternalHookEventType, action: string): boolean { return ( - (handlers.get(type)?.length ?? 0) > 0 || (handlers.get(`${type}:${action}`)?.length ?? 0) > 0 + (handlers.get(type)?.length ?? 0) + listLegacyPluginInternalHooks(type).length > 0 || + (handlers.get(`${type}:${action}`)?.length ?? 0) + + listLegacyPluginInternalHooks(`${type}:${action}`).length > + 0 ); } @@ -304,8 +313,15 @@ export async function triggerInternalHook(event: InternalHookEvent): Promise[2], ): CommandRegistrationResult { // Prevent registration while commands are being processed - if (isPluginCommandRegistryLocked()) { + if (registry.commandRegistryLocked) { return { ok: false, error: "Cannot register commands while processing is in progress" }; } if (command.ownership === "reserved") { @@ -360,11 +377,9 @@ export function registerPluginCommand( // Check for duplicate registration for (const invocationKey of invocationKeys) { - const existing = - pluginCommands.get(invocationKey) ?? - Array.from(pluginCommands.values()).find((candidate) => - listPluginInvocationKeys(candidate).includes(invocationKey), - ); + const existing = registry.commands.find((entry) => + listPluginInvocationKeys(entry.command).includes(invocationKey), + ); if (existing) { return { ok: false, @@ -373,11 +388,12 @@ export function registerPluginCommand( } } - pluginCommands.set(key, { - ...normalizedCommand, + registry.commands.push({ pluginId, pluginName: opts?.pluginName, - pluginRoot: opts?.pluginRoot, + rootDir: opts?.pluginRoot, + source: opts?.pluginRoot ?? "runtime", + command: normalizedCommand, ...(opts?.allowOwnerStatusExposure === true && normalizedCommand.exposeSenderIsOwner === true ? { trustedOwnerStatusExposure: true as const } : {}), diff --git a/src/plugins/command-registry-state.ts b/src/plugins/command-registry-state.ts index e3bb091a14b5..bfac7d7c96e9 100644 --- a/src/plugins/command-registry-state.ts +++ b/src/plugins/command-registry-state.ts @@ -1,7 +1,7 @@ // Stores plugin command registry state for the current process lifecycle. import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; -import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { normalizeAgentPromptSurfaceKind } from "./agent-prompt-surface-kind.js"; +import { getActivePluginGatewayCommandRegistry, requireActivePluginRegistry } from "./runtime.js"; import type { AgentPromptGuidance, AgentPromptSurfaceKind, @@ -15,48 +15,44 @@ export type RegisteredPluginCommand = OpenClawPluginCommandDefinition & { trustedOwnerStatusExposure?: true; }; -type PluginCommandState = { - pluginCommands: Map; - registryLocked: boolean; -}; +const getCommandRegistry = () => + getActivePluginGatewayCommandRegistry() ?? requireActivePluginRegistry(); -const PLUGIN_COMMAND_STATE_KEY = Symbol.for("openclaw.pluginCommandsState"); - -const getState = () => - resolveGlobalSingleton(PLUGIN_COMMAND_STATE_KEY, () => ({ - pluginCommands: new Map(), - registryLocked: false, - })); - -const getPluginCommandMap = () => getState().pluginCommands; +const getPluginCommandMap = () => + new Map( + getCommandRegistry().commands.map((entry) => [ + `/${normalizeOptionalLowercaseString(entry.command.name) ?? ""}`, + { + ...entry.command, + pluginId: entry.pluginId, + pluginName: entry.pluginName, + pluginRoot: entry.rootDir, + trustedOwnerStatusExposure: entry.trustedOwnerStatusExposure, + }, + ]), + ); export const pluginCommands = new Proxy(new Map(), { get(_target, property) { - const value = Reflect.get(getPluginCommandMap(), property, getPluginCommandMap()); - return typeof value === "function" ? value.bind(getPluginCommandMap()) : value; + if (property === "clear") { + return () => { + getCommandRegistry().commands.length = 0; + }; + } + const map = getPluginCommandMap(); + const value = Reflect.get(map, property, map); + return typeof value === "function" ? value.bind(map) : value; }, }); -export function isPluginCommandRegistryLocked(): boolean { - return getState().registryLocked; -} - export function setPluginCommandRegistryLocked(locked: boolean): void { - getState().registryLocked = locked; + getCommandRegistry().commandRegistryLocked = locked; } export function clearPluginCommands(): void { pluginCommands.clear(); } -export function clearPluginCommandsForPlugin(pluginId: string): void { - for (const [key, cmd] of pluginCommands.entries()) { - if (cmd.pluginId === pluginId) { - pluginCommands.delete(key); - } - } -} - export function isTrustedReservedCommandOwner(command: RegisteredPluginCommand): boolean { return command.ownership === "reserved"; } @@ -68,10 +64,6 @@ export function canExposeSenderIsOwner(command: RegisteredPluginCommand): boolea ); } -export function listRegisteredPluginCommands(): RegisteredPluginCommand[] { - return Array.from(pluginCommands.values()); -} - export function listRegisteredPluginAgentPromptGuidance(params?: { surface?: AgentPromptSurfaceKind; includeLegacyGlobalGuidance?: boolean; @@ -121,14 +113,3 @@ function resolveAgentPromptGuidanceTextForSurface( } return entry.surfaces.includes(params.surface) ? text : undefined; } - -export function restorePluginCommands(commands: readonly RegisteredPluginCommand[]): void { - pluginCommands.clear(); - for (const command of commands) { - const name = normalizeOptionalLowercaseString(command.name); - if (!name) { - continue; - } - pluginCommands.set(`/${name}`, command); - } -} diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index e05dc9411491..e052308a3ab0 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -11,8 +11,9 @@ import { matchPluginCommand, registerPluginCommand, } from "./commands.js"; +import { createEmptyPluginRegistry } from "./registry-empty.js"; import { createPluginRegistry } from "./registry.js"; -import { setActivePluginRegistry } from "./runtime.js"; +import { setActivePluginRegistry, withPluginRegistrationContext } from "./runtime.js"; import type { PluginRuntime } from "./runtime/types.js"; import { createBundledPluginRecord } from "./status.test-fixtures.js"; @@ -61,6 +62,7 @@ function registerHostTrustedReservedCommandForTest( activateGlobalSideEffects: true, }); pluginRegistry.registerCommand(createBundledPluginRecord(command.name), command); + setActivePluginRegistry(pluginRegistry.registry); } function registerVoiceCommandForTest( @@ -284,6 +286,21 @@ afterEach(() => { }); describe("registerPluginCommand", () => { + it("writes direct registrations into the synchronous builder context", () => { + const active = createEmptyPluginRegistry(); + const building = createEmptyPluginRegistry(); + setActivePluginRegistry(active); + + expect( + withPluginRegistrationContext(building, "demo-plugin", () => + registerPluginCommand("spoofed-plugin", createVoiceCommand()), + ), + ).toEqual({ ok: true }); + expect(active.commands).toStrictEqual([]); + expect(building.commands.map((entry) => entry.command.name)).toEqual(["voice"]); + expect(building.commands[0]?.pluginId).toBe("demo-plugin"); + }); + it.each([ { name: "rejects invalid command names", @@ -825,6 +842,7 @@ describe("registerPluginCommand", () => { }, }, ); + setActivePluginRegistry(pluginRegistry.registry); const match = requirePluginCommandMatch("/external"); await executePluginCommand({ @@ -860,6 +878,7 @@ describe("registerPluginCommand", () => { return { text: "ok" }; }, }); + setActivePluginRegistry(pluginRegistry.registry); const match = requirePluginCommandMatch("/pair_test"); await executePluginCommand({ diff --git a/src/plugins/compaction-provider.test.ts b/src/plugins/compaction-provider.test.ts index e793523020f9..81f6978518bf 100644 --- a/src/plugins/compaction-provider.test.ts +++ b/src/plugins/compaction-provider.test.ts @@ -6,16 +6,13 @@ import { getRegisteredCompactionProvider, listRegisteredCompactionProviders, registerCompactionProvider, - restoreRegisteredCompactionProviders, type CompactionProvider, } from "./compaction-provider.js"; +import { createEmptyPluginRegistry } from "./registry-empty.js"; +import { setActivePluginRegistry, withPluginRegistrationContext } from "./runtime.js"; -const REGISTRY_KEY = Symbol.for("openclaw.compactionProviderRegistryState"); - -/** Reset the process-global registry between tests. */ afterEach(() => { - const g = globalThis as Record; - delete g[REGISTRY_KEY]; + clearCompactionProviders(); }); function makeProvider(id: string, label?: string): CompactionProvider { @@ -67,6 +64,35 @@ describe("compaction provider registry", () => { expect(entry?.ownerPluginId).toBe("my-plugin"); }); + it("writes direct registration helpers into the synchronous builder context", () => { + const active = createEmptyPluginRegistry(); + const building = createEmptyPluginRegistry(); + setActivePluginRegistry(active); + const provider = makeProvider("builder-owned"); + + withPluginRegistrationContext(building, "builder-plugin", () => { + registerCompactionProvider(provider); + }); + + expect(active.compactionProviders).toStrictEqual([]); + expect(building.compactionProviders).toEqual([{ provider, ownerPluginId: "builder-plugin" }]); + }); + + it("does not let a registering plugin displace another owner's provider", () => { + const building = createEmptyPluginRegistry(); + const original = makeProvider("shared", "original"); + building.compactionProviders.push({ provider: original, ownerPluginId: "first-plugin" }); + + expect(() => + withPluginRegistrationContext(building, "failing-plugin", () => { + registerCompactionProvider(makeProvider("shared", "replacement")); + }), + ).toThrow("compaction provider shared already registered by first-plugin"); + expect(building.compactionProviders).toEqual([ + { provider: original, ownerPluginId: "first-plugin" }, + ]); + }); + it("lists registered provider ids", () => { registerCompactionProvider(makeProvider("alpha")); registerCompactionProvider(makeProvider("beta")); @@ -118,7 +144,7 @@ describe("compaction provider registry", () => { expect(listCompactionProviderIdsForTest()).toEqual(["dup"]); }); - describe("lifecycle (clear / restore)", () => { + describe("lifecycle", () => { it("clear removes all providers", () => { registerCompactionProvider(makeProvider("a")); registerCompactionProvider(makeProvider("b")); @@ -128,30 +154,5 @@ describe("compaction provider registry", () => { expect(listCompactionProviderIdsForTest()).toStrictEqual([]); expect(getCompactionProvider("a")).toBeUndefined(); }); - - it("restore replaces current entries with snapshot", () => { - const provA = makeProvider("a"); - const provB = makeProvider("b"); - registerCompactionProvider(provA, { ownerPluginId: "p-a" }); - registerCompactionProvider(provB, { ownerPluginId: "p-b" }); - - const snapshot = listRegisteredCompactionProviders(); - - // Register a third provider to change state - registerCompactionProvider(makeProvider("c")); - expect(listCompactionProviderIdsForTest()).toHaveLength(3); - - // Restore from snapshot — should have only a and b - restoreRegisteredCompactionProviders(snapshot); - expect(listCompactionProviderIdsForTest()).toEqual(["a", "b"]); - expect(getCompactionProvider("c")).toBeUndefined(); - expect(getRegisteredCompactionProvider("a")?.ownerPluginId).toBe("p-a"); - }); - - it("restore with empty array clears everything", () => { - registerCompactionProvider(makeProvider("x")); - restoreRegisteredCompactionProviders([]); - expect(listCompactionProviderIdsForTest()).toStrictEqual([]); - }); }); }); diff --git a/src/plugins/compaction-provider.ts b/src/plugins/compaction-provider.ts index 73b7c141edf3..ee6e228033f6 100644 --- a/src/plugins/compaction-provider.ts +++ b/src/plugins/compaction-provider.ts @@ -1,127 +1,55 @@ -/** - * Compaction provider registry — process-global singleton. - * - * Plugins implement the CompactionProvider interface and register via - * `registerCompactionProvider()`. The compaction safeguard checks this - * registry before falling back to the built-in `summarizeInStages()`. - */ +import type { + CompactionProvider, + RegisteredCompactionProvider, +} from "./registry-contribution-types.js"; +import { + assertDirectPluginRegistrationReplacement, + requireActivePluginRegistry, + resolveDirectPluginRegistrationOwner, +} from "./runtime.js"; -// --------------------------------------------------------------------------- -// Provider interface -// --------------------------------------------------------------------------- +export type { CompactionProvider } from "./registry-contribution-types.js"; -/** - * A pluggable compaction provider that can replace the built-in - * summarizeInStages pipeline. - */ -type CompactionProviderSummarizationInstructions = { - identifierPolicy?: "strict" | "off" | "custom"; - identifierInstructions?: string; -}; +const getProviders = () => requireActivePluginRegistry().compactionProviders; -export interface CompactionProvider { - id: string; - label: string; - summarize(params: { - messages: unknown[]; - signal?: AbortSignal; - compressionRatio?: number; - customInstructions?: string; - summarizationInstructions?: CompactionProviderSummarizationInstructions; - /** Summary from a prior compaction round, if re-compacting. */ - previousSummary?: string; - }): Promise; -} - -// --------------------------------------------------------------------------- -// Registered entry (mirrors RegisteredMemoryEmbeddingProvider pattern) -// --------------------------------------------------------------------------- - -/** A compaction provider with its owning plugin id for lifecycle tracking. */ -type RegisteredCompactionProvider = { - provider: CompactionProvider; - ownerPluginId?: string; -}; - -// --------------------------------------------------------------------------- -// Registry (process-global singleton) -// --------------------------------------------------------------------------- - -const COMPACTION_PROVIDER_REGISTRY_STATE = Symbol.for("openclaw.compactionProviderRegistryState"); - -type CompactionProviderRegistryState = { - providers: Map; -}; - -// Keep compaction-provider registrations process-global so duplicated dist -// chunks still share one registry map at runtime. -function getCompactionProviderRegistryState(): CompactionProviderRegistryState { - const globalState = globalThis as typeof globalThis & { - [COMPACTION_PROVIDER_REGISTRY_STATE]?: CompactionProviderRegistryState; - }; - if (!globalState[COMPACTION_PROVIDER_REGISTRY_STATE]) { - globalState[COMPACTION_PROVIDER_REGISTRY_STATE] = { - providers: new Map(), - }; - } - return globalState[COMPACTION_PROVIDER_REGISTRY_STATE]; -} - -// --------------------------------------------------------------------------- -// Registration -// --------------------------------------------------------------------------- - -/** - * Register a compaction provider implementation. - * Pass `ownerPluginId` so the loader can snapshot/restore correctly. - */ export function registerCompactionProvider( provider: CompactionProvider, options?: { ownerPluginId?: string }, ): void { - getCompactionProviderRegistryState().providers.set(provider.id, { + const providers = getProviders(); + const ownerPluginId = resolveDirectPluginRegistrationOwner(options?.ownerPluginId); + const entry = { provider, - ownerPluginId: options?.ownerPluginId, - }); + ownerPluginId, + }; + const index = providers.findIndex((registered) => registered.provider.id === provider.id); + if (index !== -1) { + assertDirectPluginRegistrationReplacement( + providers[index]?.ownerPluginId, + `compaction provider ${provider.id}`, + ); + } + if (index === -1) { + providers.push(entry); + } else { + providers.splice(index, 1, entry); + } } -// --------------------------------------------------------------------------- -// Lookup -// --------------------------------------------------------------------------- - -/** Return the provider for the given id, or undefined. */ export function getCompactionProvider(id: string): CompactionProvider | undefined { - return getCompactionProviderRegistryState().providers.get(id)?.provider; + return getProviders().find((entry) => entry.provider.id === id)?.provider; } -/** Return the registered entry (provider + owner) for the given id. */ export function getRegisteredCompactionProvider( id: string, ): RegisteredCompactionProvider | undefined { - return getCompactionProviderRegistryState().providers.get(id); + return getProviders().find((entry) => entry.provider.id === id); } -/** List all registered entries with owner metadata (for snapshot/restore). */ export function listRegisteredCompactionProviders(): RegisteredCompactionProvider[] { - return Array.from(getCompactionProviderRegistryState().providers.values()); + return [...getProviders()]; } -// --------------------------------------------------------------------------- -// Lifecycle (clear / restore) — mirrors memory-embedding-providers.ts -// --------------------------------------------------------------------------- - -/** Clear all compaction providers. Used by clearPluginLoaderCache() and reload. */ export function clearCompactionProviders(): void { - getCompactionProviderRegistryState().providers.clear(); -} - -/** Restore from a snapshot, replacing all current entries. */ -export function restoreRegisteredCompactionProviders( - entries: RegisteredCompactionProvider[], -): void { - const map = getCompactionProviderRegistryState().providers; - map.clear(); - for (const entry of entries) { - map.set(entry.provider.id, entry); - } + getProviders().length = 0; } diff --git a/src/plugins/contracts/embedding-provider.contract.test.ts b/src/plugins/contracts/embedding-provider.contract.test.ts index 986ce0db9164..0c03f7d72f07 100644 --- a/src/plugins/contracts/embedding-provider.contract.test.ts +++ b/src/plugins/contracts/embedding-provider.contract.test.ts @@ -5,7 +5,6 @@ import { registerVirtualTestPlugin, } from "openclaw/plugin-sdk/plugin-test-contracts"; import { describe, expect, it } from "vitest"; -import { getRegisteredEmbeddingProvider } from "../embedding-providers.js"; describe("embedding provider registration", () => { it("keeps public SDK helpers read-only so plugins cannot bypass manifest ownership", () => { @@ -31,8 +30,7 @@ describe("embedding provider registration", () => { }); }, }); - - expect(getRegisteredEmbeddingProvider("forbidden")).toBeUndefined(); + expect(registry.registry.embeddingProviders).toStrictEqual([]); const diagnostic = registry.registry.diagnostics.find( (entry) => entry.pluginId === "not-embedding", ); @@ -59,10 +57,9 @@ describe("embedding provider registration", () => { }); }, }); - - const provider = getRegisteredEmbeddingProvider("embedding-owner"); - expect(provider?.adapter.id).toBe("embedding-owner"); - expect(provider?.ownerPluginId).toBe("embedding-owner"); + const provider = registry.registry.embeddingProviders[0]; + expect(provider?.provider.id).toBe("embedding-owner"); + expect(provider?.pluginId).toBe("embedding-owner"); expect(registry.registry.embeddingProviders).toHaveLength(1); expect(registry.registry.plugins[0]?.embeddingProviderIds).toContain("embedding-owner"); }); @@ -96,4 +93,32 @@ describe("embedding provider registration", () => { "embedding provider already registered: shared (owner: first-owner)", ); }); + + it("keeps core embedding provider ids reserved", () => { + const { config, registry } = createPluginRegistryFixture(); + + registerVirtualTestPlugin({ + registry, + config, + id: "core-shadow", + name: "Core Shadow", + contracts: { + embeddingProviders: ["openai-compatible"], + }, + register(api) { + api.registerEmbeddingProvider({ + id: "openai-compatible", + create: async () => ({ provider: null }), + }); + }, + }); + + expect(registry.registry.embeddingProviders).toStrictEqual([]); + const diagnostic = registry.registry.diagnostics.find( + (entry) => entry.pluginId === "core-shadow", + ); + expect(diagnostic?.message).toBe( + "embedding provider already registered: openai-compatible (owner: core)", + ); + }); }); diff --git a/src/plugins/contracts/memory-embedding-provider.contract.test.ts b/src/plugins/contracts/memory-embedding-provider.contract.test.ts index 3cfdd3b4ce48..d3b3ffdb665c 100644 --- a/src/plugins/contracts/memory-embedding-provider.contract.test.ts +++ b/src/plugins/contracts/memory-embedding-provider.contract.test.ts @@ -4,10 +4,7 @@ import { registerVirtualTestPlugin, } from "openclaw/plugin-sdk/plugin-test-contracts"; import { describe, expect, it } from "vitest"; -import { - getRegisteredMemoryEmbeddingProvider, - type MemoryEmbeddingBatchOptions, -} from "../memory-embedding-providers.js"; +import type { MemoryEmbeddingBatchOptions } from "../memory-embedding-providers.js"; import { createPluginRecord } from "../status.test-fixtures.js"; describe("memory embedding provider registration", () => { @@ -26,8 +23,7 @@ describe("memory embedding provider registration", () => { }); }, }); - - expect(getRegisteredMemoryEmbeddingProvider("forbidden")).toBeUndefined(); + expect(registry.registry.memoryEmbeddingProviders).toStrictEqual([]); const diagnostic = registry.registry.diagnostics.find( (entry) => entry.pluginId === "not-memory", ); @@ -54,10 +50,9 @@ describe("memory embedding provider registration", () => { }); }, }); - - const provider = getRegisteredMemoryEmbeddingProvider("external-vector"); - expect(provider?.adapter.id).toBe("external-vector"); - expect(provider?.ownerPluginId).toBe("external-vector"); + const provider = registry.registry.memoryEmbeddingProviders[0]; + expect(provider?.provider.id).toBe("external-vector"); + expect(provider?.pluginId).toBe("external-vector"); }); it("records the owning memory plugin id for registered adapters", () => { @@ -76,10 +71,9 @@ describe("memory embedding provider registration", () => { }); }, }); - - const provider = getRegisteredMemoryEmbeddingProvider("demo-embedding"); - expect(provider?.adapter.id).toBe("demo-embedding"); - expect(provider?.ownerPluginId).toBe("memory-core"); + const provider = registry.registry.memoryEmbeddingProviders[0]; + expect(provider?.provider.id).toBe("demo-embedding"); + expect(provider?.pluginId).toBe("memory-core"); }); it("keeps source-wide batch embedding behind an explicit runtime opt-in", async () => { @@ -113,8 +107,7 @@ describe("memory embedding provider registration", () => { }); }, }); - - const adapter = getRegisteredMemoryEmbeddingProvider("source-wide-memory")?.adapter; + const adapter = registry.registry.memoryEmbeddingProviders[0]?.provider; const result = await adapter?.create({ config, model: "test-embedding" }); expect(result?.runtime?.sourceWideBatchEmbed).toBe(true); @@ -159,10 +152,9 @@ describe("memory embedding provider registration", () => { parameters: {}, execute: async () => ({ content: [], details: {} }), }); - - const provider = getRegisteredMemoryEmbeddingProvider("tool-discovery-embedding"); - expect(provider?.adapter.id).toBe("tool-discovery-embedding"); - expect(provider?.ownerPluginId).toBe("tool-discovery-memory"); + const provider = registry.registry.memoryEmbeddingProviders[0]; + expect(provider?.provider.id).toBe("tool-discovery-embedding"); + expect(provider?.pluginId).toBe("tool-discovery-memory"); expect(registry.registry.tools).toHaveLength(1); expect(registry.registry.tools[0]?.pluginId).toBe("tool-discovery-memory"); expect(registry.registry.tools[0]?.names).toEqual(["memory_recall"]); diff --git a/src/plugins/core-embedding-providers.ts b/src/plugins/core-embedding-providers.ts new file mode 100644 index 000000000000..163982543426 --- /dev/null +++ b/src/plugins/core-embedding-providers.ts @@ -0,0 +1,13 @@ +import type { RegisteredEmbeddingProvider } from "./embedding-provider-types.js"; +import { openAICompatibleEmbeddingProviderAdapter } from "./openai-compatible-embedding-provider.js"; + +export const CORE_EMBEDDING_PROVIDERS: RegisteredEmbeddingProvider[] = [ + { + adapter: openAICompatibleEmbeddingProviderAdapter, + ownerPluginId: "core", + }, +]; + +export function getCoreEmbeddingProvider(id: string): RegisteredEmbeddingProvider | undefined { + return CORE_EMBEDDING_PROVIDERS.find((entry) => entry.adapter.id === id); +} diff --git a/src/plugins/embedding-providers.test.ts b/src/plugins/embedding-providers.test.ts index a5da593bf34b..2f8b000dd71c 100644 --- a/src/plugins/embedding-providers.test.ts +++ b/src/plugins/embedding-providers.test.ts @@ -9,8 +9,9 @@ import { restoreRegisteredEmbeddingProviders, type EmbeddingProviderAdapter, } from "./embedding-providers.js"; +import { createEmptyPluginRegistry } from "./registry-empty.js"; +import { withPluginRegistrationContext } from "./runtime.js"; -const EMBEDDING_PROVIDERS_KEY = Symbol.for("openclaw.embeddingProviders"); const INITIAL_REGISTERED_EMBEDDING_PROVIDERS = listRegisteredEmbeddingProviders(); function createAdapter(id: string): EmbeddingProviderAdapter { @@ -62,19 +63,37 @@ describe("embedding provider registry", () => { ); }); - it("stores adapters in a process-global singleton map", () => { + it("stores adapters in the active registry", () => { const adapter = createAdapter("local-protocol"); registerEmbeddingProvider(adapter, { ownerPluginId: "local-protocol" }); - const globalRegistry = (globalThis as Record)[ - EMBEDDING_PROVIDERS_KEY - ] as Map; - - expect(globalRegistry.get("local-protocol")).toEqual({ + expect(getRegisteredEmbeddingProvider("local-protocol")).toEqual({ adapter, ownerPluginId: "local-protocol", }); }); + + it("uses builder ownership without displacing another plugin's adapter", () => { + const building = createEmptyPluginRegistry(); + const original = createAdapter("shared"); + building.embeddingProviders.push({ + pluginId: "first-plugin", + provider: original, + source: "runtime", + }); + + expect(() => + withPluginRegistrationContext(building, "failing-plugin", () => { + registerEmbeddingProvider(createAdapter("shared")); + }), + ).toThrow("embedding provider shared already registered by first-plugin"); + expect(building.embeddingProviders[0]?.provider).toBe(original); + + withPluginRegistrationContext(building, "builder-plugin", () => { + registerEmbeddingProvider(createAdapter("owned")); + }); + expect(building.embeddingProviders[1]?.pluginId).toBe("builder-plugin"); + }); }); describe("collectRegisteredEmbeddingProviderIds", () => { diff --git a/src/plugins/embedding-providers.ts b/src/plugins/embedding-providers.ts index eeca1645ae91..15b9a784fb66 100644 --- a/src/plugins/embedding-providers.ts +++ b/src/plugins/embedding-providers.ts @@ -1,10 +1,14 @@ +import { CORE_EMBEDDING_PROVIDERS, getCoreEmbeddingProvider } from "./core-embedding-providers.js"; /** Registry for plugin-contributed embedding providers. */ -import { resolveGlobalMap } from "../shared/global-singleton.js"; import type { EmbeddingProviderAdapter, RegisteredEmbeddingProvider, } from "./embedding-provider-types.js"; -import { openAICompatibleEmbeddingProviderAdapter } from "./openai-compatible-embedding-provider.js"; +import { + assertDirectPluginRegistrationReplacement, + requireActivePluginRegistry, + resolveDirectPluginRegistrationOwner, +} from "./runtime.js"; export type { EmbeddingInput, @@ -18,21 +22,11 @@ export type { RegisteredEmbeddingProvider, } from "./embedding-provider-types.js"; -const EMBEDDING_PROVIDERS_KEY = Symbol.for("openclaw.embeddingProviders"); -const CORE_EMBEDDING_PROVIDERS: RegisteredEmbeddingProvider[] = [ - { - adapter: openAICompatibleEmbeddingProviderAdapter, - ownerPluginId: "core", - }, -]; - -function getEmbeddingProviders(): Map { - // The registry is global so tests and lazy-loaded plugin modules share one provider table. - return resolveGlobalMap(EMBEDDING_PROVIDERS_KEY); -} - -function getCoreEmbeddingProvider(id: string): RegisteredEmbeddingProvider | undefined { - return CORE_EMBEDDING_PROVIDERS.find((entry) => entry.adapter.id === id); +function getEmbeddingProviders(): RegisteredEmbeddingProvider[] { + return requireActivePluginRegistry().embeddingProviders.map((entry) => ({ + adapter: entry.provider, + ownerPluginId: entry.pluginId || undefined, + })); } /** Registers an embedding provider adapter for plugin and built-in memory callers. */ @@ -45,28 +39,46 @@ export function registerEmbeddingProvider( if (adapter !== coreEntry.adapter) { throw new Error(`embedding provider already registered: ${adapter.id} (owner: core)`); } - getEmbeddingProviders().delete(adapter.id); + const registry = requireActivePluginRegistry(); + registry.embeddingProviders = registry.embeddingProviders.filter( + (entry) => entry.provider.id !== adapter.id, + ); return; } - getEmbeddingProviders().set(adapter.id, { - adapter, - ownerPluginId: options?.ownerPluginId, - }); + const registry = requireActivePluginRegistry(); + const pluginId = resolveDirectPluginRegistrationOwner(options?.ownerPluginId) ?? ""; + const existingIndex = registry.embeddingProviders.findIndex( + (entry) => entry.provider.id === adapter.id, + ); + if (existingIndex !== -1) { + assertDirectPluginRegistrationReplacement( + registry.embeddingProviders[existingIndex]?.pluginId || undefined, + `embedding provider ${adapter.id}`, + ); + } + const entry = { pluginId, provider: adapter, source: "runtime" }; + if (existingIndex === -1) { + registry.embeddingProviders.push(entry); + } else { + registry.embeddingProviders.splice(existingIndex, 1, entry); + } } /** Looks up the registered embedding provider entry, including owner metadata. */ export function getRegisteredEmbeddingProvider( id: string, ): RegisteredEmbeddingProvider | undefined { - return getCoreEmbeddingProvider(id) ?? getEmbeddingProviders().get(id); + return ( + getCoreEmbeddingProvider(id) ?? getEmbeddingProviders().find((entry) => entry.adapter.id === id) + ); } /** Lists registered embedding providers with core defaults merged first. */ export function listRegisteredEmbeddingProviders(): RegisteredEmbeddingProvider[] { const merged = new Map( CORE_EMBEDDING_PROVIDERS.map((entry) => [entry.adapter.id, entry]), ); - for (const entry of getEmbeddingProviders().values()) { + for (const entry of getEmbeddingProviders()) { if (!merged.has(entry.adapter.id)) { merged.set(entry.adapter.id, entry); } @@ -74,7 +86,7 @@ export function listRegisteredEmbeddingProviders(): RegisteredEmbeddingProvider[ return Array.from(merged.values()); } /** Replaces non-core embedding providers while preserving registration metadata. */ export function restoreRegisteredEmbeddingProviders(entries: RegisteredEmbeddingProvider[]): void { - getEmbeddingProviders().clear(); + clearEmbeddingProviders(); for (const entry of entries) { registerEmbeddingProvider(entry.adapter, { ownerPluginId: entry.ownerPluginId, @@ -84,5 +96,5 @@ export function restoreRegisteredEmbeddingProviders(entries: RegisteredEmbedding /** Clears non-core embedding providers from the process registry. */ export function clearEmbeddingProviders(): void { - getEmbeddingProviders().clear(); + requireActivePluginRegistry().embeddingProviders.length = 0; } diff --git a/src/plugins/interactive-registry.test-fixtures.ts b/src/plugins/interactive-registry.test-fixtures.ts deleted file mode 100644 index 0ce959541dac..000000000000 --- a/src/plugins/interactive-registry.test-fixtures.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** Test-only reset for registry-owned interactive handler snapshots. */ -import { clearPluginInteractiveHandlerRegistrationsState } from "./interactive-state.js"; - -export function clearPluginInteractiveHandlerRegistrations(): void { - clearPluginInteractiveHandlerRegistrationsState(); -} diff --git a/src/plugins/interactive-registry.ts b/src/plugins/interactive-registry.ts index 6ec9db4f90d1..3785d92514f3 100644 --- a/src/plugins/interactive-registry.ts +++ b/src/plugins/interactive-registry.ts @@ -6,27 +6,51 @@ import { toPluginInteractiveRegistryKey, validatePluginInteractiveNamespace, } from "./interactive-shared.js"; +import { clearPluginInteractiveHandlersState } from "./interactive-state.js"; +import type { PluginRegistry } from "./registry-types.js"; import { - clearPluginInteractiveHandlerRegistrationsState, - clearPluginInteractiveHandlersState, - getPluginInteractiveHandlersState, - type RegisteredInteractiveHandler, -} from "./interactive-state.js"; + getActivePluginChannelRegistry, + getPluginRegistrationContext, + requireActivePluginChannelRegistry, + resolveDirectPluginRegistrationOwner, +} from "./runtime.js"; import type { PluginInteractiveHandlerRegistration } from "./types.js"; +/** Registered interactive handler with owning plugin metadata. */ +export type RegisteredInteractiveHandler = PluginInteractiveHandlerRegistration & { + pluginId: string; + pluginName?: string; + pluginRoot?: string; +}; + +const getInteractiveHandlers = () => getActivePluginChannelRegistry()?.interactiveHandlers ?? []; + /** Registration result for plugin interactive namespace handlers. */ type InteractiveRegistrationResult = { ok: boolean; error?: string; }; +function requireInteractiveRegistrationRegistry(): PluginRegistry { + return getPluginRegistrationContext()?.registry ?? requireActivePluginChannelRegistry(); +} + +function asInteractiveHandlerLookup(registrations: readonly RegisteredInteractiveHandler[]) { + return { + get: (key: string) => + registrations.find( + (entry) => toPluginInteractiveRegistryKey(entry.channel, entry.namespace) === key, + ), + }; +} + /** Resolves a channel payload to a registered plugin interactive namespace handler. */ export function resolvePluginInteractiveNamespaceMatch( channel: string, data: string, ): { registration: RegisteredInteractiveHandler; namespace: string; payload: string } | null { return resolvePluginInteractiveMatch({ - interactiveHandlers: getPluginInteractiveHandlersState(), + interactiveHandlers: asInteractiveHandlerLookup(getInteractiveHandlers()), channel, data, }); @@ -39,13 +63,7 @@ export function resolvePluginInteractiveRegistrationsMatch( data: string, ): { registration: RegisteredInteractiveHandler; namespace: string; payload: string } | null { return resolvePluginInteractiveMatch({ - interactiveHandlers: { - get: (key) => - registrations.find( - (registration) => - toPluginInteractiveRegistryKey(registration.channel, registration.namespace) === key, - ), - }, + interactiveHandlers: asInteractiveHandlerLookup(registrations), channel, data, }); @@ -53,32 +71,33 @@ export function resolvePluginInteractiveRegistrationsMatch( /** Registers one plugin interactive namespace for a channel. */ function registerPluginInteractiveHandlerWithOptions( + registrations: PluginRegistry["interactiveHandlers"], pluginId: string, registration: PluginInteractiveHandlerRegistration, - opts?: { pluginName?: string; pluginRoot?: string; registryOwned?: true }, + opts?: { pluginName?: string; pluginRoot?: string }, ): InteractiveRegistrationResult { - const interactiveHandlers = getPluginInteractiveHandlersState(); const namespace = normalizePluginInteractiveNamespace(registration.namespace); const validationError = validatePluginInteractiveNamespace(namespace); if (validationError) { return { ok: false, error: validationError }; } const key = toPluginInteractiveRegistryKey(registration.channel, namespace); - const existing = interactiveHandlers.get(key); + const existing = registrations.find( + (entry) => toPluginInteractiveRegistryKey(entry.channel, entry.namespace) === key, + ); if (existing) { return { ok: false, error: `Interactive handler namespace "${namespace}" already registered by plugin "${existing.pluginId}"`, }; } - interactiveHandlers.set(key, { + registrations.push({ ...registration, namespace, channel: normalizeOptionalLowercaseString(registration.channel) ?? "", pluginId, pluginName: opts?.pluginName, pluginRoot: opts?.pluginRoot, - registryOwned: opts?.registryOwned, }); return { ok: true }; } @@ -89,61 +108,45 @@ export function registerPluginInteractiveHandler( registration: PluginInteractiveHandlerRegistration, opts?: { pluginName?: string; pluginRoot?: string }, ): InteractiveRegistrationResult { - return registerPluginInteractiveHandlerWithOptions(pluginId, registration, opts); + return registerPluginInteractiveHandlerWithOptions( + requireInteractiveRegistrationRegistry().interactiveHandlers, + resolveDirectPluginRegistrationOwner(pluginId) ?? pluginId, + registration, + opts, + ); } /** Registers one handler whose lifetime follows its owning plugin registry. */ +export function registerPluginInteractiveHandlerInRegistry( + registry: PluginRegistry, + pluginId: string, + registration: PluginInteractiveHandlerRegistration, + opts?: { pluginName?: string; pluginRoot?: string }, +): InteractiveRegistrationResult { + return registerPluginInteractiveHandlerWithOptions( + registry.interactiveHandlers, + pluginId, + registration, + opts, + ); +} + +/** Registers one compatibility handler in the currently selected channel registry. */ export function registerRegistryPluginInteractiveHandler( pluginId: string, registration: PluginInteractiveHandlerRegistration, opts?: { pluginName?: string; pluginRoot?: string }, ): InteractiveRegistrationResult { - return registerPluginInteractiveHandlerWithOptions(pluginId, registration, { - ...opts, - registryOwned: true, - }); + return registerPluginInteractiveHandlerWithOptions( + requireInteractiveRegistrationRegistry().interactiveHandlers, + resolveDirectPluginRegistrationOwner(pluginId) ?? pluginId, + registration, + opts, + ); } /** Clears all active plugin interactive handlers. */ export function clearPluginInteractiveHandlers(): void { + requireActivePluginChannelRegistry().interactiveHandlers.length = 0; clearPluginInteractiveHandlersState(); } - -/** Clears stored plugin interactive handler registrations. */ -function clearPluginInteractiveHandlerRegistrations(): void { - clearPluginInteractiveHandlerRegistrationsState(); -} - -/** Clears active interactive handlers owned by one plugin. */ -export function clearPluginInteractiveHandlersForPlugin(pluginId: string): void { - const interactiveHandlers = getPluginInteractiveHandlersState(); - for (const [key, value] of interactiveHandlers.entries()) { - if (value.pluginId === pluginId) { - interactiveHandlers.delete(key); - } - } -} - -/** Lists active plugin interactive handlers. */ -export function listPluginInteractiveHandlers(): RegisteredInteractiveHandler[] { - return Array.from(getPluginInteractiveHandlersState().values()); -} - -/** Restores active plugin interactive handlers from a saved registry snapshot. */ -export function restorePluginInteractiveHandlers( - registrations: readonly RegisteredInteractiveHandler[], -): void { - clearPluginInteractiveHandlerRegistrations(); - const interactiveHandlers = getPluginInteractiveHandlersState(); - for (const registration of registrations) { - const namespace = normalizePluginInteractiveNamespace(registration.namespace); - if (!namespace) { - continue; - } - interactiveHandlers.set(toPluginInteractiveRegistryKey(registration.channel, namespace), { - ...registration, - namespace, - channel: normalizeOptionalLowercaseString(registration.channel) ?? "", - }); - } -} diff --git a/src/plugins/interactive-state.ts b/src/plugins/interactive-state.ts index 8d972aba4763..0dc66e2a2866 100644 --- a/src/plugins/interactive-state.ts +++ b/src/plugins/interactive-state.ts @@ -1,18 +1,7 @@ // Stores interactive plugin state and dedupe caches. import { createDedupeCache, resolveGlobalDedupeCache } from "../infra/dedupe.js"; import type { DedupeCache } from "../infra/dedupe.js"; -import type { PluginInteractiveHandlerRegistration } from "./types.js"; - -/** Registered interactive handler with owning plugin metadata. */ -export type RegisteredInteractiveHandler = PluginInteractiveHandlerRegistration & { - pluginId: string; - pluginName?: string; - pluginRoot?: string; - registryOwned?: true; -}; - type InteractiveState = { - interactiveHandlers: Map; callbackDedupe: ReturnType; inflightCallbackDedupe: Set; }; @@ -31,7 +20,6 @@ function createInteractiveCallbackDedupe(): DedupeCache { function createInteractiveState(): InteractiveState { return { - interactiveHandlers: new Map(), callbackDedupe: createInteractiveCallbackDedupe(), inflightCallbackDedupe: new Set(), }; @@ -44,10 +32,6 @@ function hydrateInteractiveState(value: unknown): InteractiveState { : ({} as Partial); return { - interactiveHandlers: - state.interactiveHandlers instanceof Map - ? state.interactiveHandlers - : new Map(), callbackDedupe: createInteractiveCallbackDedupe(), inflightCallbackDedupe: state.inflightCallbackDedupe instanceof Set @@ -70,11 +54,6 @@ function getState() { return created; } -/** Returns the process-global plugin interactive handler registry. */ -export function getPluginInteractiveHandlersState() { - return getState().interactiveHandlers; -} - function getPluginInteractiveCallbackDedupeState() { return getState().callbackDedupe; } @@ -118,12 +97,6 @@ export function releasePluginInteractiveCallbackDedupe(dedupeKey: string | undef /** Clears plugin interactive handlers and callback dedupe state. */ export function clearPluginInteractiveHandlersState(): void { - clearPluginInteractiveHandlerRegistrationsState(); getPluginInteractiveCallbackDedupeState().clear(); getState().inflightCallbackDedupe.clear(); } - -/** Clears only plugin interactive handler registrations. */ -export function clearPluginInteractiveHandlerRegistrationsState(): void { - getPluginInteractiveHandlersState().clear(); -} diff --git a/src/plugins/interactive.test.ts b/src/plugins/interactive.test.ts index e74f8e158397..01eafb0c2523 100644 --- a/src/plugins/interactive.test.ts +++ b/src/plugins/interactive.test.ts @@ -17,7 +17,11 @@ import { registerPluginInteractiveHandler, } from "./interactive.js"; import { createEmptyPluginRegistry } from "./registry-empty.js"; -import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "./runtime.js"; +import { + resetPluginRuntimeStateForTest, + setActivePluginRegistry, + withPluginRegistrationContext, +} from "./runtime.js"; let requestPluginConversationBindingMock: MockInstance< typeof conversationBinding.requestPluginConversationBinding @@ -495,6 +499,25 @@ describe("plugin interactive handlers", () => { resetPluginRuntimeStateForTest(); }); + it("writes direct registrations into the synchronous builder context", () => { + const active = createEmptyPluginRegistry(); + const building = createEmptyPluginRegistry(); + setActivePluginRegistry(active); + + expect( + withPluginRegistrationContext(building, "codex-plugin", () => + registerPluginInteractiveHandler("spoofed-plugin", { + channel: "telegram", + namespace: "builder", + handler: async () => ({ handled: true }), + }), + ), + ).toEqual({ ok: true }); + expect(active.interactiveHandlers).toStrictEqual([]); + expect(building.interactiveHandlers.map((entry) => entry.namespace)).toEqual(["builder"]); + expect(building.interactiveHandlers[0]?.pluginId).toBe("codex-plugin"); + }); + it("hydrates legacy interactive state shapes before clearing handlers", async () => { const globalStore = globalThis as Record; const stateKey = Symbol.for("openclaw.pluginInteractiveState"); @@ -511,7 +534,7 @@ describe("plugin interactive handlers", () => { callbackDedupe?: { clear: () => void }; inflightCallbackDedupe?: Set; }; - expect(hydrated.interactiveHandlers).toBeInstanceOf(Map); + expect(hydrated.interactiveHandlers).toBeUndefined(); if (!hydrated.callbackDedupe) { throw new Error("expected hydrated callback dedupe"); } diff --git a/src/plugins/interactive.ts b/src/plugins/interactive.ts index 7edce05cfaae..c7eb1f108fa1 100644 --- a/src/plugins/interactive.ts +++ b/src/plugins/interactive.ts @@ -1,13 +1,12 @@ // Resolves interactive plugin entries from registry metadata. import { - resolvePluginInteractiveNamespaceMatch, resolvePluginInteractiveRegistrationsMatch, + type RegisteredInteractiveHandler, } from "./interactive-registry.js"; import { claimPluginInteractiveCallbackDedupe, commitPluginInteractiveCallbackDedupe, releasePluginInteractiveCallbackDedupe, - type RegisteredInteractiveHandler, } from "./interactive-state.js"; import { collectLivePluginRegistries } from "./runtime.js"; @@ -33,11 +32,6 @@ export { } from "./interactive-registry.js"; function resolveLivePluginInteractiveNamespaceMatch(channel: string, data: string) { - const existing = resolvePluginInteractiveNamespaceMatch(channel, data); - if (existing && existing.registration.registryOwned !== true) { - return existing; - } - // Registry membership is lifecycle-owned. Resolve registry registrations only // through live owners so a replaced or released registry cannot keep executing. for (const registry of collectLivePluginRegistries()) { diff --git a/src/plugins/legacy-internal-hook-state.ts b/src/plugins/legacy-internal-hook-state.ts index 185b90bbdf70..471c210def9e 100644 --- a/src/plugins/legacy-internal-hook-state.ts +++ b/src/plugins/legacy-internal-hook-state.ts @@ -1,66 +1,38 @@ +import type { InternalHookHandler } from "../hooks/internal-hook-types.js"; import { - registerInternalHook, - unregisterInternalHook, - type InternalHookHandler, -} from "../hooks/internal-hooks.js"; -import { resolveGlobalSingleton } from "../shared/global-singleton.js"; + collectLivePluginRegistries, + getPluginRegistrationContext, + requireActivePluginRegistry, +} from "./runtime.js"; -export type LegacyPluginInternalHookRegistration = { - event: string; - handler: InternalHookHandler; -}; - -export type LegacyPluginInternalHookState = Map; - -const LEGACY_PLUGIN_INTERNAL_HOOKS_KEY = Symbol.for("openclaw.activePluginHookRegistrations"); -const registrations = resolveGlobalSingleton( - LEGACY_PLUGIN_INTERNAL_HOOKS_KEY, - () => new Map(), -); - -function cloneRegistrations( - values: readonly LegacyPluginInternalHookRegistration[], -): LegacyPluginInternalHookRegistration[] { - return values.map((registration) => ({ ...registration })); +function listLiveRegistrations() { + const registrations = [] as ReturnType["legacyInternalHooks"]; + const seenPluginIds = new Set(); + for (const registry of collectLivePluginRegistries()) { + // Ownership is capability-specific: hookless scoped/setup registries must not shadow + // a pinned runtime that actually registered the plugin's legacy hooks. + registrations.push( + ...registry.legacyInternalHooks.filter((entry) => !seenPluginIds.has(entry.pluginId)), + ); + registry.legacyInternalHooks.forEach((entry) => seenPluginIds.add(entry.pluginId)); + } + return registrations; } -export function replaceLegacyPluginInternalHook( - name: string, - nextRegistrations: readonly LegacyPluginInternalHookRegistration[], -): LegacyPluginInternalHookRegistration[] { - const previousRegistrations = cloneRegistrations(registrations.get(name) ?? []); - for (const registration of registrations.get(name) ?? []) { - unregisterInternalHook(registration.event, registration.handler); - } - for (const registration of nextRegistrations) { - registerInternalHook(registration.event, registration.handler); - } - if (nextRegistrations.length === 0) { - registrations.delete(name); - } else { - registrations.set(name, cloneRegistrations(nextRegistrations)); - } - return previousRegistrations; +export function listLegacyPluginInternalHooks(event: string): InternalHookHandler[] { + return listLiveRegistrations() + .filter((registration) => registration.event === event) + .map((registration) => registration.handler); +} + +export function listLegacyPluginInternalHookEventKeys(): string[] { + return [...new Set(listLiveRegistrations().map((registration) => registration.event))]; } export function clearLegacyPluginInternalHooks(): void { - for (const name of registrations.keys()) { - replaceLegacyPluginInternalHook(name, []); - } -} - -export function snapshotLegacyPluginInternalHooks(): LegacyPluginInternalHookState { - return new Map( - [...registrations].map(([name, hookRegistrations]) => [ - name, - cloneRegistrations(hookRegistrations), - ]), - ); -} - -export function restoreLegacyPluginInternalHooks(state: LegacyPluginInternalHookState): void { - clearLegacyPluginInternalHooks(); - for (const [name, hookRegistrations] of state) { - replaceLegacyPluginInternalHook(name, hookRegistrations); + const context = getPluginRegistrationContext(); + const live = context ? [context.registry] : collectLivePluginRegistries(); + for (const registry of live.length > 0 ? live : [requireActivePluginRegistry()]) { + registry.legacyInternalHooks.length = 0; } } diff --git a/src/plugins/loader-cache-instances.ts b/src/plugins/loader-cache-instances.ts index 65aeca3be838..31c5a56f1202 100644 --- a/src/plugins/loader-cache-instances.ts +++ b/src/plugins/loader-cache-instances.ts @@ -1,11 +1,7 @@ import { PluginLoaderCacheState } from "./loader-cache-state.js"; -import type { PluginProcessGlobalState } from "./plugin-registration-transaction.js"; import type { PluginRegistry } from "./registry-types.js"; -export type CachedPluginState = { - registry: PluginRegistry; - processGlobalState: PluginProcessGlobalState; -}; +export type CachedPluginState = PluginRegistry; const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 128; diff --git a/src/plugins/loader-channel-runtime.ts b/src/plugins/loader-channel-runtime.ts index 2227e174136c..aff76c387e63 100644 --- a/src/plugins/loader-channel-runtime.ts +++ b/src/plugins/loader-channel-runtime.ts @@ -11,12 +11,11 @@ import { shouldDeferConfiguredChannelFullRuntimeMerge, } from "./loader-channel-setup.js"; import type { PluginModuleLoader } from "./loader-module-runtime.js"; -import { runPluginRegisterSync } from "./loader-module-runtime.js"; +import { runPluginRegisterSyncInRegistry } from "./loader-module-runtime.js"; import { recordPluginError } from "./loader-records.js"; import type { PluginRegistrationPlan } from "./loader-registration-plan.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; import { withProfile } from "./plugin-load-profile.js"; -import { createPluginRegistrationTransaction } from "./plugin-registration-transaction.js"; import { resolveCanonicalDistRuntimeSource } from "./plugin-runtime-artifact-resolution.js"; import type { createPluginRegistry, PluginRecord } from "./registry.js"; import type { OpenClawPluginModule, PluginLogger } from "./types.js"; @@ -254,18 +253,15 @@ export function loadSetupRuntimeChannelCandidate(params: { } } if (registrationPlan.mode === "setup-runtime" && mergedSetupRegistration.registerSetupRuntime) { - const transaction = createPluginRegistrationTransaction({ - registry: registryBuilder.registry, - activeRecord: record, - }); try { - runPluginRegisterSync( + runPluginRegisterSyncInRegistry( (registrationApi) => mergedSetupRegistration.registerSetupRuntime?.(registrationApi), api, + registryBuilder.registry, + record.id, ); - transaction.commit({ activate: true }); } catch (error) { - transaction.rollback(); + registryBuilder.rollbackPluginGlobalSideEffects(record.id, record); recordPluginError({ logger: params.logger, registry: registryBuilder.registry, diff --git a/src/plugins/loader-cli-registry.ts b/src/plugins/loader-cli-registry.ts index 8f17e2877c75..ca040c39c317 100644 --- a/src/plugins/loader-cli-registry.ts +++ b/src/plugins/loader-cli-registry.ts @@ -17,7 +17,7 @@ import { createPluginModuleLoader, formatBundledChannelWrongLoaderError, resolvePluginModuleExport, - runPluginRegisterSync, + runPluginRegisterSyncInRegistry, } from "./loader-module-runtime.js"; import { formatAutoEnabledActivationReason, @@ -39,7 +39,6 @@ import { import type { PluginLoadOptions } from "./loader-types.js"; import { withProfile } from "./plugin-load-profile.js"; import { normalizePluginPolicyId } from "./plugin-policy-id.js"; -import { createPluginRegistrationTransaction } from "./plugin-registration-transaction.js"; import { createPluginIdScopeSet } from "./plugin-scope.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; import type { PluginRuntime } from "./runtime/types.js"; @@ -63,7 +62,7 @@ export async function loadOpenClawPluginCliRegistry( devSourceRoot: context.devSourceRoot, pluginSdkResolution: options.pluginSdkResolution, }); - const { registry, registerCli } = createPluginRegistry({ + const { registry, registerCli, rollbackPluginGlobalSideEffects } = createPluginRegistry({ logger, runtime: {} as PluginRuntime, coreGatewayHandlers: options.coreGatewayHandlers as Record, @@ -330,16 +329,14 @@ export async function loadOpenClawPluginCliRegistry( registerCli: (registrar, opts) => registerCli(record, registrar, opts), }, }); - const transaction = createPluginRegistrationTransaction({ registry, activeRecord: record }); try { withProfile({ pluginId: record.id, source: record.source }, "cli-metadata:register", () => - runPluginRegisterSync(register, api), + runPluginRegisterSyncInRegistry(register, api, registry, record.id), ); registry.plugins.push(record); seenIds.set(pluginId, candidate.origin); - transaction.commit({ activate: true }); } catch (error) { - transaction.rollback(); + rollbackPluginGlobalSideEffects(record.id, record); recordPluginError({ logger, registry, diff --git a/src/plugins/loader-module-runtime.ts b/src/plugins/loader-module-runtime.ts index dc820bd2401a..8ac2126b37aa 100644 --- a/src/plugins/loader-module-runtime.ts +++ b/src/plugins/loader-module-runtime.ts @@ -9,6 +9,8 @@ import { type PluginModuleLoaderCache, } from "./plugin-module-loader-cache.js"; import { installOpenClawPluginSdkNativeResolver } from "./plugin-sdk-native-resolver.js"; +import type { PluginRegistry } from "./registry-types.js"; +import { withPluginRegistrationContext } from "./runtime.js"; import type { CreatePluginRuntimeOptions, PluginRuntime } from "./runtime/types.js"; import { buildPluginLoaderAliasMap, @@ -95,6 +97,15 @@ export function runPluginRegisterSync( } } +export function runPluginRegisterSyncInRegistry( + register: NonNullable, + api: Parameters>[0], + registry: PluginRegistry, + pluginId: string, +): void { + withPluginRegistrationContext(registry, pluginId, () => runPluginRegisterSync(register, api)); +} + export function createPluginModuleLoader(options: { devSourceRoot?: string | null; pluginSdkResolution?: PluginSdkResolutionPreference; diff --git a/src/plugins/loader-runtime-candidate.ts b/src/plugins/loader-runtime-candidate.ts index 0d47069e8e9d..d0a8044bc304 100644 --- a/src/plugins/loader-runtime-candidate.ts +++ b/src/plugins/loader-runtime-candidate.ts @@ -19,7 +19,7 @@ import { formatBundledChannelWrongLoaderError, type PluginModuleLoader, resolvePluginModuleExport, - runPluginRegisterSync, + runPluginRegisterSyncInRegistry, } from "./loader-module-runtime.js"; import { formatAutoEnabledActivationReason, @@ -49,7 +49,6 @@ import { import type { PluginManifestRecord } from "./manifest-registry.js"; import { withProfile } from "./plugin-load-profile.js"; import { normalizePluginPolicyId } from "./plugin-policy-id.js"; -import { createPluginRegistrationTransaction } from "./plugin-registration-transaction.js"; import { resolveCanonicalDistRuntimeSource, resolvePluginRuntimeArtifact, @@ -224,6 +223,7 @@ export function loadRuntimePluginCandidate(params: { origin: candidate.origin, preferBuiltPluginArtifacts: context.preferBuiltPluginArtifacts, packageManifest: candidate.packageManifest, + registry, }); const runtimeSetupEntry = manifestRecord.setupSource ? resolvePluginRuntimeArtifact({ @@ -234,6 +234,7 @@ export function loadRuntimePluginCandidate(params: { origin: candidate.origin, preferBuiltPluginArtifacts: context.preferBuiltPluginArtifacts, packageManifest: candidate.packageManifest, + registry, }) : undefined; const scopedSetupOnlyChannelPluginRequested = @@ -527,19 +528,13 @@ export function loadRuntimePluginCandidate(params: { hookPolicy: entry?.hooks, registrationMode: registrationPlan.mode, }); - const transaction = createPluginRegistrationTransaction({ - registry, - activeRecord: record, - rollbackGlobalSideEffects: () => - params.registryBuilder.rollbackPluginGlobalSideEffects(record.id), - }); const beforeRegister = performance.now(); let registerFailed = false; try { withProfile( { pluginId: record.id, source: record.source }, `${registrationPlan.mode}:register`, - () => runPluginRegisterSync(register, api), + () => runPluginRegisterSyncInRegistry(register, api, registry, record.id), ); // Dashboard entries stay inside the same registry snapshot as their RPC handlers. // Non-activating snapshots are private until cached activation; rollback restores both. @@ -548,14 +543,13 @@ export function loadRuntimePluginCandidate(params: { } registry.plugins.push(record); state.seenIds.set(pluginId, candidate.origin); - transaction.commit({ activate: context.shouldActivate }); if (clearMismatchedQuarantineAfterLoad) { // Plugin ids can intentionally shadow an installed source via load.paths. // Clear stale install state only after the selected override registers. clearActiveDegradedPlugin(pluginId); } } catch (error) { - transaction.rollback(); + params.registryBuilder.rollbackPluginGlobalSideEffects(record.id, record); recordPluginError({ logger: params.logger, registry, diff --git a/src/plugins/loader-runtime-load.ts b/src/plugins/loader-runtime-load.ts index 1d33ca2551f5..3de05d91da49 100644 --- a/src/plugins/loader-runtime-load.ts +++ b/src/plugins/loader-runtime-load.ts @@ -21,20 +21,15 @@ import { } from "./loader-runtime-candidate.js"; import { activatePluginRegistry, - clearActivatedPluginRuntimeState, createPluginLoaderLogger, maybeThrowOnPluginLoadError, resolveAuthorizedDreamingSidecar, } from "./loader-shared.js"; import type { PluginLoadOptions } from "./loader-types.js"; -import { - createPluginRegistrationTransaction, - restorePluginProcessGlobalState, - snapshotPluginProcessGlobalState, -} from "./plugin-registration-transaction.js"; import { createPluginIdScopeSet, normalizePluginIdScope } from "./plugin-scope.js"; import { createEmptyPluginRegistry } from "./registry-empty.js"; import { createPluginRegistry, type PluginRegistry } from "./registry.js"; +import { getActivePluginRegistry } from "./runtime.js"; export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegistry { const requestedOnlyPluginIds = normalizePluginIdScope(options.onlyPluginIds); @@ -42,7 +37,6 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi if (requestedOnlyPluginIdSet && requestedOnlyPluginIdSet.size === 0) { const emptyRegistry = createEmptyPluginRegistry(); if (options.activate !== false) { - clearActivatedPluginRuntimeState(); const runtimeSubagentMode = resolveRuntimeSubagentMode(options.runtimeOptions); activatePluginRegistry( emptyRegistry, @@ -68,37 +62,20 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi }); if (cached) { if (context.shouldActivate) { - restorePluginProcessGlobalState(cached.state.processGlobalState); activatePluginRegistry( - cached.state.registry, + cached.state, cached.cacheKey, cached.runtimeSubagentMode, options.workspaceDir, ); } - return cached.state.registry; + return cached.state; } } pluginLoaderCacheState.beginLoad(context.cacheKey); let registryBuilder: ReturnType | undefined; - const activatingLoadTransaction = context.shouldActivate - ? createPluginRegistrationTransaction({ - rollbackGlobalSideEffects: () => { - const loadedPluginIds = (registryBuilder?.registry.plugins ?? []) - .filter((plugin) => plugin.status === "loaded") - .map((plugin) => plugin.id); - for (const pluginId of loadedPluginIds.toReversed()) { - registryBuilder?.rollbackPluginGlobalSideEffects(pluginId); - } - }, - }) - : null; try { - // Snapshot loads must not wipe global state registered by the active plugin set. - if (context.shouldActivate) { - clearActivatedPluginRuntimeState(); - } // Module and runtime loading stay lazy for discovery-only or disabled-plugin paths. const loadPluginModule = createPluginModuleLoader({ devSourceRoot: context.devSourceRoot, @@ -231,20 +208,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi ); } } - if (cacheEnabled) { - setCachedPluginRegistry( - context.cacheKey, - { - registry, - processGlobalState: snapshotPluginProcessGlobalState(), - }, - context.onlyPluginIds, - ); - } if (context.shouldActivate) { - // Activation installs the new registry before initializing its hook runner. Commit the - // rollback first so an activation throw cannot restore old globals under the new registry. - activatingLoadTransaction?.commit({ activate: true }); + // Install the complete bundle before hook-runner initialization because hook composition + // reads the active/pinned registry set and must never observe contributions from two loads. activatePluginRegistry( registry, context.cacheKey, @@ -252,13 +218,24 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi options.workspaceDir, ); } + // Publish only complete registries: failed activation restores the prior runtime selection, + // then the catch below can discard this builder without poisoning a reusable cache value. + if (cacheEnabled) { + setCachedPluginRegistry(context.cacheKey, registry, context.onlyPluginIds); + } return registry; } catch (error) { - activatingLoadTransaction?.rollback(); + // Registration failures discard only an inactive builder. Activation is failure-atomic, and + // any later cache failure must not strip the registry already serving runtime consumers. + if (context.shouldActivate && registryBuilder?.registry !== getActivePluginRegistry()) { + for (const plugin of registryBuilder?.registry.plugins.toReversed() ?? []) { + if (plugin.status === "loaded") { + registryBuilder?.rollbackPluginGlobalSideEffects(plugin.id); + } + } + } throw error; } finally { pluginLoaderCacheState.finishLoad(context.cacheKey); } } - -export { clearActivatedPluginRuntimeState } from "./loader-shared.js"; diff --git a/src/plugins/loader-shared.ts b/src/plugins/loader-shared.ts index 5a4385d991bc..fefce332f611 100644 --- a/src/plugins/loader-shared.ts +++ b/src/plugins/loader-shared.ts @@ -2,17 +2,14 @@ import fs from "node:fs"; import path from "node:path"; import { err as resultError, ok, type Result } from "@openclaw/normalization-core/result"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import { clearAgentHarnesses } from "../agents/harness/registry.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { activateContextEngineRegistrations } from "../context-engine/registry.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { DEFAULT_MEMORY_DREAMING_PLUGIN_ID, resolveMemoryDreamingConfig, resolveMemoryDreamingPluginConfig, } from "../memory-host-sdk/dreaming.js"; -import { clearDetachedTaskLifecycleRuntimeRegistration } from "../tasks/detached-task-runtime-state.js"; -import { clearPluginCommands } from "./command-registry-state.js"; -import { clearCompactionProviders } from "./compaction-provider.js"; import { resolveEffectiveEnableState, type NormalizedPluginsConfig, @@ -21,22 +18,24 @@ import { } from "./config-state.js"; import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js"; import type { PluginCandidate } from "./discovery.js"; -import { clearEmbeddingProviders } from "./embedding-providers.js"; -import { initializeGlobalHookRunner } from "./hook-runner-global.js"; +import { + getGlobalPluginRegistry, + initializeGlobalHookRunner, + resetGlobalHookRunner, +} from "./hook-runner-global.js"; import { collectPluginManifestCompatCodes } from "./installed-plugin-index-record-builder.js"; -import { clearPluginInteractiveHandlers } from "./interactive-registry.js"; -import { clearLegacyPluginInternalHooks } from "./legacy-internal-hook-state.js"; import { createPluginRecord } from "./loader-records.js"; import type { PluginLoadOptions, PluginRuntimeSubagentMode } from "./loader-types.js"; import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js"; import type { PluginDiagnostic } from "./manifest-types.js"; -import { clearMemoryEmbeddingProviders } from "./memory-embedding-providers.js"; -import { clearMemoryPluginState } from "./memory-state.js"; -import { clearPluginRuntimeArtifactResolutionMemo } from "./plugin-runtime-artifact-resolution.js"; import type { PluginRecord, PluginRegistry } from "./registry.js"; -import { setActivePluginRegistry } from "./runtime.js"; +import { + captureActivePluginRegistrySnapshot, + commitStagedPluginRegistry, + restoreActivePluginRegistrySnapshot, + stageActivePluginRegistry, +} from "./runtime.js"; import { validateJsonSchemaValue } from "./schema-validator.js"; -import { clearSessionDiscussionProvider } from "./session-discussion-registry.js"; import { hasKind } from "./slots.js"; import { encodeStartupTraceSegment } from "./startup-trace-segment.js"; import type { PluginLogger } from "./types.js"; @@ -166,23 +165,6 @@ export function createPluginCandidatesFromManifestRegistry( })); } -export function clearActivatedPluginRuntimeState(): void { - clearPluginRuntimeArtifactResolutionMemo(); - clearAgentHarnesses(); - clearPluginCommands(); - clearCompactionProviders(); - clearDetachedTaskLifecycleRuntimeRegistration(); - clearPluginInteractiveHandlers(); - // Legacy api.registerHook callbacks are process-global compatibility state. - // Retire them with the active registry so disabled or removed plugins cannot - // keep running. - clearLegacyPluginInternalHooks(); - clearEmbeddingProviders(); - clearMemoryEmbeddingProviders(); - clearMemoryPluginState(); - clearSessionDiscussionProvider(); -} - class PluginLoadFailureError extends Error { readonly pluginIds: string[]; readonly registry: PluginRegistry; @@ -359,14 +341,28 @@ export function maybeThrowOnPluginLoadError( export function activatePluginRegistry( registry: PluginRegistry, - cacheKey: string, + cacheKey: string | null, runtimeSubagentMode: PluginRuntimeSubagentMode, workspaceDir?: string, ): void { - // Reinitialize from the live registry set so activation order and scope cannot - // drop hooks through a stale runner (#91918). - setActivePluginRegistry(registry, cacheKey, runtimeSubagentMode, workspaceDir); - initializeGlobalHookRunner(registry); + const activeSnapshot = captureActivePluginRegistrySnapshot(); + const previousHookRegistry = getGlobalPluginRegistry(); + try { + // Install the complete bundle before hook-runner initialization so hook composition never + // observes contributions from two loads. Activation failure restores the prior selection. + stageActivePluginRegistry(registry, cacheKey, runtimeSubagentMode, workspaceDir); + initializeGlobalHookRunner(registry); + activateContextEngineRegistrations(registry); + commitStagedPluginRegistry(activeSnapshot.activeRegistry, registry); + } catch (error) { + restoreActivePluginRegistrySnapshot(activeSnapshot); + if (previousHookRegistry) { + initializeGlobalHookRunner(previousHookRegistry); + } else { + resetGlobalHookRunner(); + } + throw error; + } } export function safeRealpathOrResolve(value: string): string { diff --git a/src/plugins/loader.base.test-utils.ts b/src/plugins/loader.base.test-utils.ts index ee68509ad486..552a8afdf98f 100644 --- a/src/plugins/loader.base.test-utils.ts +++ b/src/plugins/loader.base.test-utils.ts @@ -7,6 +7,7 @@ import { getRegisteredAgentHarness } from "../agents/harness/registry.js"; import type { WorkspaceBootstrapFile } from "../agents/workspace.js"; import { resolveConfigEnvVars } from "../config/env-substitution.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; +import { getContextEngineRegistration } from "../context-engine/registry.js"; import { clearInternalHooks, createInternalHookEvent, @@ -1778,7 +1779,7 @@ describe("loadOpenClawPlugins", () => { expect(listRegisteredAgentHarnessIdsForTest()).toStrictEqual([]); }); - it("restores cleared runtime registrations when an activating reload throws before activation", async () => { + it("keeps the active registry and prior contributions intact when replacement registration throws", async () => { useNoBundledPlugins(); const plugin = writePlugin({ id: "reload-rollback", @@ -1888,6 +1889,95 @@ describe("loadOpenClawPlugins", () => { await expectRegistrationsIntact(); }); + it("removes gateway method handlers and descriptors when registration throws", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "failing-gateway-method", + filename: "failing-gateway-method.cjs", + body: `module.exports = { + id: "failing-gateway-method", + register(api) { + api.registerGatewayMethod("failing-gateway-method.ping", ({ respond }) => { + respond(true, { ok: true }); + }); + throw new Error("gateway method register failed"); + }, + };`, + }); + + const registry = loadOpenClawPlugins({ + cache: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["failing-gateway-method"], + }, + }, + onlyPluginIds: ["failing-gateway-method"], + }); + + expect(registry.plugins[0]?.status).toBe("error"); + expect(registry.gatewayHandlers["failing-gateway-method.ping"]).toBeUndefined(); + expect(registry.gatewayMethodDescriptors).toStrictEqual([]); + }); + + it("keeps a working context engine when same-owner re-registration throws", async () => { + useNoBundledPlugins(); + const goodPlugin = writePlugin({ + id: "context-engine-reload", + filename: "context-engine-good.cjs", + body: `module.exports = { + id: "context-engine-reload", + register(api) { + api.registerContextEngine("reload-engine", () => ({ marker: "good" })); + }, + };`, + }); + const baseOptions = { + cache: false, + workspaceDir: goodPlugin.dir, + config: { + plugins: { + load: { paths: [goodPlugin.file] }, + allow: ["context-engine-reload"], + }, + }, + onlyPluginIds: ["context-engine-reload"], + }; + const activeRegistry = loadOpenClawPlugins(baseOptions); + const activeRegistration = getContextEngineRegistration("reload-engine"); + + const failingPlugin = writePlugin({ + id: "context-engine-reload", + filename: "context-engine-failing.cjs", + body: `module.exports = { + id: "context-engine-reload", + register(api) { + api.registerContextEngine("reload-engine", () => ({ marker: "bad" })); + throw new Error("context engine reload failed"); + }, + };`, + }); + expect(() => + loadOpenClawPlugins({ + ...baseOptions, + workspaceDir: failingPlugin.dir, + config: { + plugins: { + load: { paths: [failingPlugin.file] }, + allow: ["context-engine-reload"], + }, + }, + throwOnLoadError: true, + }), + ).toThrow("context engine reload failed"); + + expect(getActivePluginRegistry()).toBe(activeRegistry); + expect(getContextEngineRegistration("reload-engine")).toBe(activeRegistration); + expect(await activeRegistration?.factory({})).toMatchObject({ marker: "good" }); + }); + it("rejects malformed plugin agent harness registrations", () => { useNoBundledPlugins(); const plugin = writePlugin({ diff --git a/src/plugins/loader.registration.test-utils.ts b/src/plugins/loader.registration.test-utils.ts index 629b79146614..ff925a12ea9d 100644 --- a/src/plugins/loader.registration.test-utils.ts +++ b/src/plugins/loader.registration.test-utils.ts @@ -1,7 +1,7 @@ // Imported by loader.test.ts to keep its mocked suite in one Vitest module graph. import fs from "node:fs"; import path from "node:path"; -import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { getContextEngineRegistration } from "../context-engine/registry.js"; import { clearInternalHooks, @@ -10,7 +10,6 @@ import { triggerInternalHook, } from "../hooks/internal-hooks.js"; import { - clearDetachedTaskLifecycleRuntimeRegistration, getDetachedTaskLifecycleRuntimeRegistration, registerDetachedTaskLifecycleRuntime, } from "../tasks/detached-task-runtime-state.js"; @@ -22,11 +21,11 @@ import { getGlobalPluginRegistry, resetGlobalHookRunner, } from "./hook-runner-global.js"; +import * as hookRunnerGlobal from "./hook-runner-global.js"; import { clearPluginInteractiveHandlers, resolvePluginInteractiveNamespaceMatch, } from "./interactive-registry.js"; -import { clearPluginInteractiveHandlerRegistrations } from "./interactive-registry.test-fixtures.js"; import { claimPluginInteractiveCallbackDedupe, commitPluginInteractiveCallbackDedupe, @@ -55,7 +54,6 @@ import { } from "./memory-embedding-providers.js"; import { buildMemoryPromptSection, - clearMemoryPluginState, getMemoryCapabilityRegistration, getMemoryRuntime, listActiveMemoryPublicArtifacts, @@ -67,10 +65,16 @@ import { registerMemoryPromptSupplement, resolveMemoryFlushPlan, } from "./memory-state.test-fixtures.js"; +import { isPluginRegistryRetired } from "./registry-lifecycle.js"; +import { createEmptyPluginRegistry } from "./registry.js"; import { getActivePluginRegistry, + getActivePluginRegistryKey, + getActivePluginRegistryWorkspaceDir, + getActivePluginRuntimeSubagentMode, pinActivePluginChannelRegistry, releasePinnedPluginChannelRegistry, + setActivePluginRegistry, } from "./runtime.js"; import { ensurePluginRegistryLoaded } from "./runtime/runtime-registry-loader.js"; @@ -78,6 +82,43 @@ afterEach(globalAfterEach0); afterAll(globalAfterAll1); describe("loadOpenClawPlugins", () => { + it("rejects a repeated named legacy hook before adding another executable handler", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "duplicate-legacy-hook", + filename: "duplicate-legacy-hook.cjs", + body: `module.exports = { + id: "duplicate-legacy-hook", + register(api) { + api.registerHook("gateway:startup", () => {}, { name: "shared-name" }); + api.registerHook("command:new", () => {}, { name: "shared-name" }); + }, + };`, + }); + + const registry = loadOpenClawPlugins({ + cache: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["duplicate-legacy-hook"], + }, + }, + onlyPluginIds: ["duplicate-legacy-hook"], + }); + + expect( + registry.legacyInternalHooks.map((entry) => ({ name: entry.name, event: entry.event })), + ).toEqual([{ name: "shared-name", event: "gateway:startup" }]); + expect(registry.diagnostics).toContainEqual( + expect.objectContaining({ + pluginId: "duplicate-legacy-hook", + message: "hook already registered: shared-name (duplicate-legacy-hook)", + }), + ); + }); + it("runs consecutive plugin hook handlers with shared mutable context but isolated plugin config", async () => { useNoBundledPlugins(); const first = writePlugin({ @@ -192,6 +233,11 @@ describe("loadOpenClawPlugins", () => { description: "Fail me", handler: async () => ({ text: "nope" }), }); + api.registerProvider({ + id: "failed-provider", + label: "Failed Provider", + auth: [], + }); api.registerReload({ onConfigReload: async () => {}, }); @@ -239,9 +285,9 @@ describe("loadOpenClawPlugins", () => { onlyPluginIds: ["failing-side-effects"], }); - expect(registry.plugins.find((entry) => entry.id === "failing-side-effects")?.status).toBe( - "error", - ); + const failedRecord = registry.plugins.find((entry) => entry.id === "failing-side-effects"); + expect(failedRecord?.status).toBe("error"); + expect(failedRecord?.providerIds).toStrictEqual([]); expect(getRegisteredEventKeys()).toStrictEqual([]); expect(getPluginCommandSpecs()).toStrictEqual([]); expect(registry.reloads).toStrictEqual([]); @@ -261,6 +307,86 @@ describe("loadOpenClawPlugins", () => { clearPluginInteractiveHandlers(); }); + it("restores the prior registry and leaves a failed activation uncached", () => { + useNoBundledPlugins(); + const prior = writePlugin({ + id: "activation-prior", + filename: "activation-prior.cjs", + body: `module.exports = { + id: "activation-prior", + register(api) { + api.registerCommand({ + name: "prior", + description: "Prior command", + handler: async () => ({ text: "prior" }), + }); + }, + };`, + }); + const priorRegistry = loadOpenClawPlugins({ + cache: false, + workspaceDir: prior.dir, + config: { + plugins: { + load: { paths: [prior.file] }, + allow: ["activation-prior"], + }, + }, + onlyPluginIds: ["activation-prior"], + }); + const priorKey = getActivePluginRegistryKey(); + const priorMode = getActivePluginRuntimeSubagentMode(); + const priorWorkspaceDir = getActivePluginRegistryWorkspaceDir(); + + const replacement = writePlugin({ + id: "activation-replacement", + filename: "activation-replacement.cjs", + body: `module.exports = { + id: "activation-replacement", + register(api) { + api.registerCommand({ + name: "replacement", + description: "Replacement command", + handler: async () => ({ text: "replacement" }), + }); + }, + };`, + }); + const replacementOptions = { + workspaceDir: replacement.dir, + config: { + plugins: { + load: { paths: [replacement.file] }, + allow: ["activation-replacement"], + }, + }, + onlyPluginIds: ["activation-replacement"], + } satisfies Parameters[0]; + const hookInit = vi + .spyOn(hookRunnerGlobal, "initializeGlobalHookRunner") + .mockImplementationOnce(() => { + throw new Error("hook activation failed"); + }); + + try { + expect(() => loadOpenClawPlugins(replacementOptions)).toThrow("hook activation failed"); + } finally { + hookInit.mockRestore(); + } + + expect(getActivePluginRegistry()).toBe(priorRegistry); + expect(isPluginRegistryRetired(priorRegistry)).toBe(false); + expect(getGlobalPluginRegistry()).toBe(priorRegistry); + expect(getActivePluginRegistryKey()).toBe(priorKey); + expect(getActivePluginRuntimeSubagentMode()).toBe(priorMode); + expect(getActivePluginRegistryWorkspaceDir()).toBe(priorWorkspaceDir); + expect(getPluginCommandSpecs().map((command) => command.name)).toEqual(["prior"]); + + const activated = loadOpenClawPlugins(replacementOptions); + expect(activated.commands.map((entry) => entry.command.name)).toEqual(["replacement"]); + expect(getPluginCommandSpecs().map((command) => command.name)).toEqual(["replacement"]); + }); + it("fails plugin registration when a hook is missing its required name", () => { useNoBundledPlugins(); const plugin = writePlugin({ @@ -828,6 +954,38 @@ describe("loadOpenClawPlugins", () => { expect(runtimeRegistration?.runtime).toBe(activeRuntime); }); + it("replaces a detached task runtime registered twice by the same plugin", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "detached-runtime-refresh", + filename: "detached-runtime-refresh.cjs", + body: `module.exports = { + id: "detached-runtime-refresh", + register(api) { + api.registerDetachedTaskRuntime({ marker: "first" }); + api.registerDetachedTaskRuntime({ marker: "second" }); + }, + };`, + }); + + const registry = loadOpenClawPlugins({ + cache: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["detached-runtime-refresh"], + }, + }, + onlyPluginIds: ["detached-runtime-refresh"], + }); + + expect(registry.detachedTaskRuntimes).toHaveLength(1); + expect( + (registry.detachedTaskRuntimes[0]?.runtime as { marker?: string } | undefined)?.marker, + ).toBe("second"); + }); + it("clears newly-registered detached task runtimes when plugin register fails", () => { useNoBundledPlugins(); const plugin = writePlugin({ @@ -907,7 +1065,7 @@ describe("loadOpenClawPlugins", () => { loadOpenClawPlugins(loadOptions); expect(getDetachedTaskLifecycleRuntimeRegistration()?.pluginId).toBe("cached-detached-runtime"); - clearDetachedTaskLifecycleRuntimeRegistration(); + setActivePluginRegistry(createEmptyPluginRegistry()); expect(getDetachedTaskLifecycleRuntimeRegistration()).toBeUndefined(); loadOpenClawPlugins(loadOptions); @@ -950,7 +1108,7 @@ describe("loadOpenClawPlugins", () => { await triggerInternalHook(firstEvent); expect(firstEvent.messages).toEqual(["cached-hook-fired"]); - clearInternalHooks(); + setActivePluginRegistry(createEmptyPluginRegistry()); loadOpenClawPlugins(loadOptions); const cachedEvent = createInternalHookEvent("gateway", "startup", "gateway:startup"); @@ -1014,8 +1172,7 @@ describe("loadOpenClawPlugins", () => { loadOpenClawPlugins(loadOptions); expect(claimPluginInteractiveCallbackDedupe(dedupeKey, 1_002)).toBe(false); - clearPluginCommands(); - clearPluginInteractiveHandlerRegistrations(); + setActivePluginRegistry(createEmptyPluginRegistry()); expect(getPluginCommandSpecs()).toStrictEqual([]); expect(resolvePluginInteractiveNamespaceMatch("telegram", "hue:on")).toBeNull(); @@ -1106,7 +1263,7 @@ describe("loadOpenClawPlugins", () => { expectedArtifacts, ); - clearMemoryPluginState(); + setActivePluginRegistry(createEmptyPluginRegistry()); const second = loadOpenClawPlugins(options); expect(second).toBe(first); diff --git a/src/plugins/loader.test-fixtures.ts b/src/plugins/loader.test-fixtures.ts index df5263ebb0c8..15dda2cf78e1 100644 --- a/src/plugins/loader.test-fixtures.ts +++ b/src/plugins/loader.test-fixtures.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { resetDiagnosticEventsForTest } from "../infra/diagnostic-events.js"; import { withEnv } from "../test-utils/env.js"; import { pluginLoaderCacheInstances } from "./loader-cache-instances.js"; -import { clearActivatedPluginRuntimeState, loadOpenClawPlugins } from "./loader.js"; +import { loadOpenClawPlugins } from "./loader.js"; import { resetPluginRuntimeStateForTest } from "./runtime.js"; export { loadOpenClawPlugins }; @@ -161,7 +161,7 @@ export function resetPluginLoaderTestStateForTest() { export function clearPluginLoaderCache(): void { pluginLoaderCacheInstances.scoped.clear(); pluginLoaderCacheInstances.fullWorkspace.clear(); - clearActivatedPluginRuntimeState(); + resetPluginRuntimeStateForTest(); } export function cleanupPluginLoaderFixturesForTest() { diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 9032c2d27f89..ae264b5eb75a 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -10,5 +10,5 @@ export { resolveCompatibleRuntimePluginRegistry, resolveRuntimePluginRegistry, } from "./loader-runtime-registry.js"; -export { clearActivatedPluginRuntimeState, loadOpenClawPlugins } from "./loader-runtime-load.js"; +export { loadOpenClawPlugins } from "./loader-runtime-load.js"; export type { PluginLoadOptions } from "./loader-types.js"; diff --git a/src/plugins/memory-embedding-providers.test.ts b/src/plugins/memory-embedding-providers.test.ts index a5e82e65d343..d7f0f9795b11 100644 --- a/src/plugins/memory-embedding-providers.test.ts +++ b/src/plugins/memory-embedding-providers.test.ts @@ -9,8 +9,9 @@ import { restoreRegisteredMemoryEmbeddingProviders, type MemoryEmbeddingProviderAdapter, } from "./memory-embedding-providers.js"; +import { createEmptyPluginRegistry } from "./registry-empty.js"; +import { withPluginRegistrationContext } from "./runtime.js"; -const MEMORY_EMBEDDING_PROVIDERS_KEY = Symbol.for("openclaw.memoryEmbeddingProviders"); const INITIAL_REGISTERED_MEMORY_EMBEDDING_PROVIDERS = listRegisteredMemoryEmbeddingProviders(); function createAdapter(id: string): MemoryEmbeddingProviderAdapter { @@ -144,17 +145,35 @@ describe("memory embedding provider registry", () => { expectMemoryEmbeddingProviderIds([]); }); - it("stores adapters in a process-global singleton map", () => { + it("stores adapters in the active registry", () => { const alpha = createAdapter("alpha"); registerMemoryEmbeddingProvider(alpha, { ownerPluginId: "memory-core" }); - const globalRegistry = (globalThis as Record)[ - MEMORY_EMBEDDING_PROVIDERS_KEY - ] as Map; - - expect(globalRegistry.get("alpha")).toEqual({ + expect(getRegisteredMemoryEmbeddingProvider("alpha")).toEqual({ adapter: alpha, ownerPluginId: "memory-core", }); }); + + it("uses builder ownership without displacing another plugin's adapter", () => { + const building = createEmptyPluginRegistry(); + const original = createAdapter("shared"); + building.memoryEmbeddingProviders.push({ + pluginId: "first-plugin", + provider: original, + source: "runtime", + }); + + expect(() => + withPluginRegistrationContext(building, "failing-plugin", () => { + registerMemoryEmbeddingProvider(createAdapter("shared")); + }), + ).toThrow("memory embedding provider shared already registered by first-plugin"); + expect(building.memoryEmbeddingProviders[0]?.provider).toBe(original); + + withPluginRegistrationContext(building, "builder-plugin", () => { + registerMemoryEmbeddingProvider(createAdapter("owned")); + }); + expect(building.memoryEmbeddingProviders[1]?.pluginId).toBe("builder-plugin"); + }); }); diff --git a/src/plugins/memory-embedding-providers.ts b/src/plugins/memory-embedding-providers.ts index f3af7c952ef1..94209f40d585 100644 --- a/src/plugins/memory-embedding-providers.ts +++ b/src/plugins/memory-embedding-providers.ts @@ -1,149 +1,77 @@ -import type { EmbeddingInput } from "../../packages/memory-host-sdk/src/engine-embeddings.js"; -// Resolves plugin-provided memory embedding providers from config and registry. -import { resolveGlobalMap } from "../shared/global-singleton.js"; import type { - EmbeddingProvider, - EmbeddingProviderAdapter, - EmbeddingProviderCallOptions, - EmbeddingProviderCreateOptions, - EmbeddingProviderIndexIdentity, - EmbeddingProviderRuntime, -} from "./embedding-provider-types.js"; + MemoryEmbeddingProviderAdapter, + RegisteredMemoryEmbeddingProvider, +} from "./registry-contribution-types.js"; +import { + assertDirectPluginRegistrationReplacement, + requireActivePluginRegistry, + resolveDirectPluginRegistrationOwner, +} from "./runtime.js"; -/** Chunk submitted to memory embedding batch processing. */ -export type MemoryEmbeddingBatchChunk = { - text: string; - embeddingInput?: EmbeddingInput; -}; +export type { + MemoryEmbeddingBatchChunk, + MemoryEmbeddingBatchOptions, + MemoryEmbeddingProvider, + MemoryEmbeddingProviderAdapter, + MemoryEmbeddingProviderCallOptions, + MemoryEmbeddingProviderCreateOptions, + MemoryEmbeddingProviderCreateResult, + MemoryEmbeddingProviderIndexIdentity, + MemoryEmbeddingProviderRuntime, + RegisteredMemoryEmbeddingProvider, +} from "./registry-contribution-types.js"; -/** Options for batch memory embedding work. */ -export type MemoryEmbeddingBatchOptions = { - agentId: string; - chunks: MemoryEmbeddingBatchChunk[]; - wait: boolean; - concurrency: number; - pollIntervalMs: number; - timeoutMs: number; - debug: (message: string, data?: Record) => void; -}; - -/** Per-call options for memory embedding providers. */ -export type MemoryEmbeddingProviderCallOptions = Pick; - -/** Runtime metadata returned with memory embedding providers. */ -export type MemoryEmbeddingProviderRuntime = EmbeddingProviderRuntime & { - sourceWideBatchEmbed?: boolean; - batchEmbed?: (options: MemoryEmbeddingBatchOptions) => Promise; -}; - -/** Provider-owned canonical identity and exact aliases for persisted indexes. */ -export type MemoryEmbeddingProviderIndexIdentity = EmbeddingProviderIndexIdentity; - -/** Created memory embedding provider instance. */ -export type MemoryEmbeddingProvider = Pick< - EmbeddingProvider, - "id" | "model" | "maxInputTokens" | "close" -> & { - embedQuery: (text: string, options?: MemoryEmbeddingProviderCallOptions) => Promise; - embedBatch: ( - texts: string[], - options?: MemoryEmbeddingProviderCallOptions, - ) => Promise; - embedBatchInputs?: ( - inputs: EmbeddingInput[], - options?: MemoryEmbeddingProviderCallOptions, - ) => Promise; -}; - -/** Options passed to memory embedding provider adapters. */ -export type MemoryEmbeddingProviderCreateOptions = Omit< - EmbeddingProviderCreateOptions, - "dimensions" | "local" | "taskType" -> & { - fallback?: string; - local?: { - modelPath?: string; - modelCacheDir?: string; - contextSize?: number | "auto"; - }; - outputDimensionality?: number; - taskType?: - | "RETRIEVAL_QUERY" - | "RETRIEVAL_DOCUMENT" - | "SEMANTIC_SIMILARITY" - | "CLASSIFICATION" - | "CLUSTERING" - | "QUESTION_ANSWERING" - | "FACT_VERIFICATION"; -}; - -/** Result returned by a memory embedding provider adapter. */ -export type MemoryEmbeddingProviderCreateResult = { - provider: MemoryEmbeddingProvider | null; - runtime?: MemoryEmbeddingProviderRuntime; -}; - -/** Adapter contract for registered memory embedding providers. */ -export type MemoryEmbeddingProviderAdapter = Omit< - EmbeddingProviderAdapter, - "create" | "resolveIndexIdentity" -> & { - autoSelectPriority?: number; - allowExplicitWhenConfiguredAuto?: boolean; - supportsMultimodalEmbeddings?: (params: { model: string }) => boolean; - resolveIndexIdentity?: ( - options: MemoryEmbeddingProviderCreateOptions, - ) => MemoryEmbeddingProviderIndexIdentity; - create: ( - options: MemoryEmbeddingProviderCreateOptions, - ) => Promise; - shouldContinueAutoSelection?: (err: unknown) => boolean; -}; - -/** Registered memory embedding provider with optional owning plugin metadata. */ -export type RegisteredMemoryEmbeddingProvider = { - adapter: MemoryEmbeddingProviderAdapter; - ownerPluginId?: string; -}; - -const MEMORY_EMBEDDING_PROVIDERS_KEY = Symbol.for("openclaw.memoryEmbeddingProviders"); - -function getMemoryEmbeddingProviders(): Map { - return resolveGlobalMap(MEMORY_EMBEDDING_PROVIDERS_KEY); +function getMemoryEmbeddingProviders(): RegisteredMemoryEmbeddingProvider[] { + return requireActivePluginRegistry().memoryEmbeddingProviders.map((entry) => ({ + adapter: entry.provider, + ownerPluginId: entry.pluginId || undefined, + })); } -/** Registers a memory embedding provider adapter for the current process. */ export function registerMemoryEmbeddingProvider( adapter: MemoryEmbeddingProviderAdapter, options?: { ownerPluginId?: string }, ): void { - getMemoryEmbeddingProviders().set(adapter.id, { - adapter, - ownerPluginId: options?.ownerPluginId, - }); + const registry = requireActivePluginRegistry(); + const pluginId = resolveDirectPluginRegistrationOwner(options?.ownerPluginId) ?? ""; + const entry = { + pluginId, + provider: adapter, + source: "runtime", + }; + const index = registry.memoryEmbeddingProviders.findIndex( + (registration) => registration.provider.id === adapter.id, + ); + if (index !== -1) { + assertDirectPluginRegistrationReplacement( + registry.memoryEmbeddingProviders[index]?.pluginId || undefined, + `memory embedding provider ${adapter.id}`, + ); + } + if (index === -1) { + registry.memoryEmbeddingProviders.push(entry); + } else { + registry.memoryEmbeddingProviders.splice(index, 1, entry); + } } -/** Returns a registered memory embedding provider entry. */ export function getRegisteredMemoryEmbeddingProvider( id: string, ): RegisteredMemoryEmbeddingProvider | undefined { - return getMemoryEmbeddingProviders().get(id); + return getMemoryEmbeddingProviders().find((entry) => entry.adapter.id === id); } -/** Lists registered memory embedding provider entries. */ export function listRegisteredMemoryEmbeddingProviders(): RegisteredMemoryEmbeddingProvider[] { - return Array.from(getMemoryEmbeddingProviders().values()); + return getMemoryEmbeddingProviders(); } -/** Lists registered memory embedding provider adapters. */ export function listMemoryEmbeddingProviders(): MemoryEmbeddingProviderAdapter[] { return listRegisteredMemoryEmbeddingProviders().map((entry) => entry.adapter); } -/** Replaces registered memory embedding providers while preserving metadata. */ export function restoreRegisteredMemoryEmbeddingProviders( entries: RegisteredMemoryEmbeddingProvider[], ): void { - getMemoryEmbeddingProviders().clear(); + clearMemoryEmbeddingProviders(); for (const entry of entries) { registerMemoryEmbeddingProvider(entry.adapter, { ownerPluginId: entry.ownerPluginId, @@ -151,7 +79,6 @@ export function restoreRegisteredMemoryEmbeddingProviders( } } -/** Clears registered memory embedding providers. */ export function clearMemoryEmbeddingProviders(): void { - getMemoryEmbeddingProviders().clear(); + requireActivePluginRegistry().memoryEmbeddingProviders.length = 0; } diff --git a/src/plugins/memory-state.test.ts b/src/plugins/memory-state.test.ts index c2e66f6f9cf1..4092113c822c 100644 --- a/src/plugins/memory-state.test.ts +++ b/src/plugins/memory-state.test.ts @@ -7,7 +7,6 @@ import { getMemoryRuntime, listMemoryCorpusSupplements, listMemoryPromptPreparations, - listMemoryPromptSupplements, listActiveMemoryPublicArtifacts, prepareMemoryPromptSection, registerMemoryCapability, @@ -16,9 +15,10 @@ import { registerMemoryPromptSupplement, registerTestMemoryPromptBuilder, resolveMemoryFlushPlan, - restoreMemoryPluginState, type MemoryPluginPublicArtifact, } from "./memory-state.test-fixtures.js"; +import { createEmptyPluginRegistry } from "./registry-empty.js"; +import { withPluginRegistrationContext } from "./runtime.js"; function createMemoryRuntime() { return { @@ -51,15 +51,6 @@ function expectClearedMemoryState() { expect(getMemoryRuntime()).toBeUndefined(); } -function createMemoryStateSnapshot() { - return { - capability: getMemoryCapabilityRegistration(), - corpusSupplements: listMemoryCorpusSupplements(), - promptPreparations: listMemoryPromptPreparations(), - promptSupplements: listMemoryPromptSupplements(), - }; -} - function registerMemoryState(params: { promptSection?: string[]; relativePath?: string; @@ -83,6 +74,25 @@ describe("memory plugin state", () => { expectClearedMemoryState(); }); + it("attributes direct builder registrations to the synchronous plugin owner", () => { + const building = createEmptyPluginRegistry(); + + withPluginRegistrationContext(building, "actual-plugin", () => { + registerMemoryCapability("spoofed-plugin", { runtime: createMemoryRuntime() }); + registerMemoryCorpusSupplement("spoofed-plugin", { + search: async () => [], + get: async () => null, + }); + registerMemoryPromptSupplement("spoofed-plugin", () => ["supplement"]); + registerMemoryPromptPreparation("spoofed-plugin", async () => ["prepared"]); + }); + + expect(building.memoryCapabilities[0]?.pluginId).toBe("actual-plugin"); + expect(building.memoryCorpusSupplements[0]?.pluginId).toBe("actual-plugin"); + expect(building.memoryPromptSupplements[0]?.pluginId).toBe("actual-plugin"); + expect(building.memoryPromptPreparations[0]?.pluginId).toBe("actual-plugin"); + }); + it("delegates prompt building to the registered memory plugin", () => { registerTestMemoryPromptBuilder(({ availableTools }) => { if (!availableTools.has("memory_search")) { @@ -265,6 +275,19 @@ describe("memory plugin state", () => { ]); }); + it("preserves runtime fields when the same plugin adds public artifacts", () => { + const runtime = createMemoryRuntime(); + const flushPlanResolver = () => createMemoryFlushPlan("memory/same-owner.md"); + + registerMemoryCapability("memory-core", { runtime, flushPlanResolver }); + registerMemoryCapability("memory-core", { + publicArtifacts: { listArtifacts: async () => [] }, + }); + + expect(getMemoryRuntime()).toBe(runtime); + expect(resolveMemoryFlushPlan({})?.relativePath).toBe("memory/same-owner.md"); + }); + it("passes citations mode through to the prompt builder", () => { registerTestMemoryPromptBuilder(({ citationsMode }) => [ `citations: ${citationsMode ?? "default"}`, @@ -398,33 +421,6 @@ describe("memory plugin state", () => { ).resolves.toEqual([{ corpus: "wiki", path: "sources/alpha.md", score: 1, snippet: "x" }]); }); - it("restoreMemoryPluginState swaps both prompt and flush state", () => { - const runtime = createMemoryRuntime(); - registerMemoryState({ - promptSection: ["first"], - relativePath: "memory/first.md", - runtime, - }); - registerMemoryPromptSupplement("memory-wiki", () => ["wiki supplement"]); - registerMemoryCorpusSupplement("memory-wiki", { - search: async () => [{ corpus: "wiki", path: "sources/alpha.md", score: 1, snippet: "x" }], - get: async () => null, - }); - const snapshot = createMemoryStateSnapshot(); - - clearMemoryPluginState(); - expectClearedMemoryState(); - - restoreMemoryPluginState(snapshot); - expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([ - "first", - "wiki supplement", - ]); - expect(resolveMemoryFlushPlan({})?.relativePath).toBe("memory/first.md"); - expect(listMemoryCorpusSupplements()).toHaveLength(1); - expect(getMemoryRuntime()).toBe(runtime); - }); - it("clearMemoryPluginState resets both registries", () => { registerMemoryState({ promptSection: ["stale section"], diff --git a/src/plugins/memory-state.ts b/src/plugins/memory-state.ts index 4fe656fbf416..4c63ef5b41ab 100644 --- a/src/plugins/memory-state.ts +++ b/src/plugins/memory-state.ts @@ -1,296 +1,137 @@ /** Registry state for plugin memory runtimes, prompt supplements, and flush planning. */ import { AsyncLocalStorage } from "node:async_hooks"; -import type { MemoryCitationsMode } from "../config/types.memory.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import type { MemorySearchManager } from "../memory-host-sdk/host/types.js"; +import type { + MemoryCorpusSupplement, + MemoryCorpusSupplementRegistration, + MemoryFlushPlan, + MemoryPluginCapability, + MemoryPluginCapabilityRegistration, + MemoryPluginPublicArtifact, + MemoryPluginRuntime, + MemoryPromptPreparationRegistration, + MemoryPromptSectionBuilder, + MemoryPromptSectionParams, + MemoryPromptSectionPreparer, + MemoryPromptSupplementRegistration, + PreparedMemoryPromptSection, +} from "./registry-contribution-types.js"; +import { requireActivePluginRegistry, resolveDirectPluginRegistrationOwner } from "./runtime.js"; const log = createSubsystemLogger("plugins/memory-state"); -export type MemoryPromptSectionParams = { - availableTools: Set; - citationsMode?: MemoryCitationsMode; - agentId?: string; - agentSessionKey?: string; - sandboxed?: boolean; -}; +export type { + MemoryCorpusSearchResult, + MemoryCorpusSupplement, + MemoryFlushPlan, + MemoryFlushPlanResolver, + MemoryPluginCapability, + MemoryPluginPublicArtifact, + MemoryPluginPublicArtifactsProvider, + MemoryPluginRuntime, + MemoryPromptSectionBuilder, + MemoryPromptSectionParams, + PreparedMemoryPromptSection, + RegisteredMemorySearchManager, +} from "./registry-contribution-types.js"; -export type MemoryPromptSectionBuilder = (params: MemoryPromptSectionParams) => string[]; - -/** - * Loads and renders prompt state before synchronous prompt assembly. - * Implementations may perform async state reads here, but must validate their - * owner instance before returning lines for the current run. - */ -type MemoryPromptSectionPreparer = ( - params: MemoryPromptSectionParams, -) => Promise; - -export type PreparedMemoryPromptSection = Readonly<{ - context: Readonly<{ - availableTools: readonly string[]; - citationsMode?: MemoryCitationsMode; - agentId?: string; - agentSessionKey?: string; - sandboxed: boolean; - }>; - lines: readonly string[]; -}>; - -export type MemoryCorpusSearchResult = { - corpus: string; - path: string; - title?: string; - kind?: string; - score: number; - snippet: string; - id?: string; - startLine?: number; - endLine?: number; - citation?: string; - source?: string; - provenanceLabel?: string; - sourceType?: string; - sourcePath?: string; - updatedAt?: string; -}; - -type MemoryCorpusGetResult = { - corpus: string; - path: string; - title?: string; - kind?: string; - content: string; - fromLine: number; - lineCount: number; - id?: string; - provenanceLabel?: string; - sourceType?: string; - sourcePath?: string; - updatedAt?: string; -}; - -export type MemoryCorpusSupplement = { - search(params: { - query: string; - maxResults?: number; - agentId?: string; - agentSessionKey?: string; - sandboxed?: boolean; - }): Promise; - get(params: { - lookup: string; - fromLine?: number; - lineCount?: number; - agentId?: string; - agentSessionKey?: string; - sandboxed?: boolean; - }): Promise; -}; - -type MemoryCorpusSupplementRegistration = { - pluginId: string; - supplement: MemoryCorpusSupplement; -}; - -type MemoryPromptSupplementRegistration = { - pluginId: string; - builder: MemoryPromptSectionBuilder; -}; - -type MemoryPromptPreparationRegistration = { - pluginId: string; - prepare: MemoryPromptSectionPreparer; -}; - -export type MemoryFlushPlan = { - softThresholdTokens: number; - forceFlushTranscriptBytes: number; - reserveTokensFloor: number; - model?: string; - prompt: string; - systemPrompt: string; - relativePath: string; - recordWriteProvenance?: (params: { - workspaceDir: string; - relativePath: string; - contentBefore: string; - contentAfter: string; - originClass: "agent" | "untrusted"; - observedAt: number; - }) => Promise<(() => Promise) | void>; - clearWriteProvenance?: (params: { workspaceDir: string; relativePath: string }) => Promise; -}; - -export type MemoryFlushPlanResolver = (params: { - cfg?: OpenClawConfig; - nowMs?: number; -}) => MemoryFlushPlan | null; - -export type RegisteredMemorySearchManager = MemorySearchManager; - -type MemoryRuntimeQmdConfig = { - command?: string; -}; - -type MemoryRuntimeBackendConfig = - | { - backend: "builtin"; - } - | { - backend: "qmd"; - qmd?: MemoryRuntimeQmdConfig; +export function resolveMemoryCapabilityRegistration( + registrations: readonly MemoryPluginCapabilityRegistration[], +): MemoryPluginCapabilityRegistration | undefined { + let effective: MemoryPluginCapabilityRegistration | undefined; + for (const registration of registrations) { + const existing = effective?.capability; + // An artifact bridge layers onto the selected memory runtime without taking ownership of it. + const preserveExisting = + existing && + Boolean(registration.capability.publicArtifacts) && + !registration.capability.promptBuilder && + !registration.capability.flushPlanResolver && + !registration.capability.runtime; + effective = { + pluginId: registration.pluginId, + capability: { + ...(preserveExisting ? existing : {}), + ...registration.capability, + }, }; + } + return effective; +} -export type MemoryPluginRuntime = { - getMemorySearchManager(params: { - cfg: OpenClawConfig; - agentId: string; - purpose?: "default" | "status" | "cli"; - }): Promise<{ - manager: RegisteredMemorySearchManager | null; - debug?: { - backend?: "builtin" | "qmd"; - purpose?: "default" | "status" | "cli"; - managerMs?: number; - managerCacheState?: - | "cached-full-hit" - | "cached-full-miss" - | "transient-cli" - | "transient-status" - | "pending-create-wait" - | "fallback-builtin" - | "recent-failure-cooldown"; - qmdIdentityHash?: string; - failureCode?: "qmd-unavailable"; - }; - error?: string; - }>; - resolveMemoryBackendConfig(params: { - cfg: OpenClawConfig; - agentId: string; - }): MemoryRuntimeBackendConfig; - closeMemorySearchManager?(params: { cfg: OpenClawConfig; agentId: string }): Promise; - closeAllMemorySearchManagers?(): Promise; -}; - -type MemoryPluginPublicArtifactContentType = "markdown" | "json" | "text"; - -export type MemoryPluginPublicArtifact = { - kind: string; - workspaceDir: string; - relativePath: string; - absolutePath: string; - agentIds: string[]; - contentType: MemoryPluginPublicArtifactContentType; -}; - -export type MemoryPluginPublicArtifactsProvider = { - listArtifacts(params: { cfg: OpenClawConfig }): Promise; -}; - -export type MemoryPluginCapability = { - promptBuilder?: MemoryPromptSectionBuilder; - flushPlanResolver?: MemoryFlushPlanResolver; - runtime?: MemoryPluginRuntime; - publicArtifacts?: MemoryPluginPublicArtifactsProvider; -}; - -type MemoryPluginCapabilityRegistration = { - pluginId: string; - capability: MemoryPluginCapability; -}; - -type MemoryPluginState = { - capability?: MemoryPluginCapabilityRegistration; - corpusSupplements: MemoryCorpusSupplementRegistration[]; - promptPreparations: MemoryPromptPreparationRegistration[]; - promptSupplements: MemoryPromptSupplementRegistration[]; -}; - -const memoryPluginState: MemoryPluginState = { - corpusSupplements: [], - promptPreparations: [], - promptSupplements: [], -}; +const getMemoryCapability = () => + resolveMemoryCapabilityRegistration(requireActivePluginRegistry().memoryCapabilities); const preparedMemoryPromptSections = new WeakSet(); const activePreparedMemoryPromptSection = new AsyncLocalStorage(); export function registerMemoryCorpusSupplement( - pluginId: string, + requestedPluginId: string, supplement: MemoryCorpusSupplement, ): void { - const next = memoryPluginState.corpusSupplements.filter( - (registration) => registration.pluginId !== pluginId, - ); - next.push({ pluginId, supplement }); - memoryPluginState.corpusSupplements = next; + const pluginId = resolveDirectPluginRegistrationOwner(requestedPluginId) ?? requestedPluginId; + const registry = requireActivePluginRegistry(); + registry.memoryCorpusSupplements = registry.memoryCorpusSupplements + .filter((registration) => registration.pluginId !== pluginId) + .concat({ pluginId, supplement }); } export function registerMemoryCapability( - pluginId: string, + requestedPluginId: string, capability: MemoryPluginCapability, ): void { - const existingCapability = memoryPluginState.capability?.capability; - // A selected memory plugin can add bridge artifacts while memory-core owns sidecar runtime hooks. - const shouldPreserveExisting = - existingCapability && - Boolean(capability.publicArtifacts) && - !capability.promptBuilder && - !capability.flushPlanResolver && - !capability.runtime; - memoryPluginState.capability = { - pluginId, - capability: { - ...(shouldPreserveExisting ? existingCapability : {}), - ...capability, - }, - }; + const pluginId = resolveDirectPluginRegistrationOwner(requestedPluginId) ?? requestedPluginId; + const registry = requireActivePluginRegistry(); + registry.memoryCapabilities.push({ pluginId, capability }); } export function getMemoryCapabilityRegistration(): MemoryPluginCapabilityRegistration | undefined { - return memoryPluginState.capability + const capability = getMemoryCapability(); + return capability ? { - pluginId: memoryPluginState.capability.pluginId, - capability: { ...memoryPluginState.capability.capability }, + pluginId: capability.pluginId, + capability: { ...capability.capability }, } : undefined; } export function listMemoryCorpusSupplements(): MemoryCorpusSupplementRegistration[] { - return [...memoryPluginState.corpusSupplements]; + return [...requireActivePluginRegistry().memoryCorpusSupplements]; } export function registerMemoryPromptSupplement( - pluginId: string, + requestedPluginId: string, builder: MemoryPromptSectionBuilder, ): void { - const next = memoryPluginState.promptSupplements.filter( - (registration) => registration.pluginId !== pluginId, - ); - next.push({ pluginId, builder }); - memoryPluginState.promptSupplements = next; + const pluginId = resolveDirectPluginRegistrationOwner(requestedPluginId) ?? requestedPluginId; + const registry = requireActivePluginRegistry(); + registry.memoryPromptSupplements = registry.memoryPromptSupplements + .filter((registration) => registration.pluginId !== pluginId) + .concat({ pluginId, builder }); } export function registerMemoryPromptPreparation( - pluginId: string, + requestedPluginId: string, prepare: MemoryPromptSectionPreparer, ): void { - const next = memoryPluginState.promptPreparations.filter( - (registration) => registration.pluginId !== pluginId, - ); - next.push({ pluginId, prepare }); - memoryPluginState.promptPreparations = next; + const pluginId = resolveDirectPluginRegistrationOwner(requestedPluginId) ?? requestedPluginId; + const registry = requireActivePluginRegistry(); + registry.memoryPromptPreparations = registry.memoryPromptPreparations + .filter((registration) => registration.pluginId !== pluginId) + .concat({ pluginId, prepare }); } function buildSynchronousMemoryPromptSection(params: MemoryPromptSectionParams): { primary: string[]; supplements: Array<{ pluginId: string; lines: string[] }>; } { + const registry = requireActivePluginRegistry(); const primary = normalizeMemoryPromptLines( - memoryPluginState.capability?.capability.promptBuilder?.(params) ?? [], + resolveMemoryCapabilityRegistration(registry.memoryCapabilities)?.capability.promptBuilder?.( + params, + ) ?? [], ); - const supplements = memoryPluginState.promptSupplements + const supplements = registry.memoryPromptSupplements // Keep supplement order stable even if plugin registration order changes. .toSorted((left, right) => left.pluginId.localeCompare(right.pluginId)) .map((registration) => ({ @@ -348,7 +189,7 @@ export async function prepareMemoryPromptSection( const synchronous = buildSynchronousMemoryPromptSection( cloneMemoryPromptSectionParams(runParams), ); - const preparationRegistrations = [...memoryPluginState.promptPreparations]; + const preparationRegistrations = [...requireActivePluginRegistry().memoryPromptPreparations]; const preparedSupplements = await Promise.all( preparationRegistrations.map(async (registration) => ({ pluginId: registration.pluginId, @@ -410,19 +251,19 @@ function normalizeMemoryPromptLines(value: unknown): string[] { } export function listMemoryPromptSupplements(): MemoryPromptSupplementRegistration[] { - return [...memoryPluginState.promptSupplements]; + return [...requireActivePluginRegistry().memoryPromptSupplements]; } export function listMemoryPromptPreparations(): MemoryPromptPreparationRegistration[] { - return [...memoryPluginState.promptPreparations]; + return [...requireActivePluginRegistry().memoryPromptPreparations]; } export function resolveMemoryFlushPlan(params: { cfg?: OpenClawConfig; nowMs?: number; }): MemoryFlushPlan | null { - return memoryPluginState.capability?.capability.flushPlanResolver?.(params) ?? null; + return getMemoryCapability()?.capability.flushPlanResolver?.(params) ?? null; } export function getMemoryRuntime(): MemoryPluginRuntime | undefined { - return memoryPluginState.capability?.capability.runtime; + return getMemoryCapability()?.capability.runtime; } export function hasMemoryRuntime(): boolean { @@ -456,9 +297,9 @@ function isValidMemoryPublicArtifact( export async function listActiveMemoryPublicArtifacts(params: { cfg: OpenClawConfig; }): Promise { - const pluginId = memoryPluginState.capability?.pluginId; - const listed = - (await memoryPluginState.capability?.capability.publicArtifacts?.listArtifacts(params)) ?? []; + const capability = getMemoryCapability(); + const pluginId = capability?.pluginId; + const listed = (await capability?.capability.publicArtifacts?.listArtifacts(params)) ?? []; if (!Array.isArray(listed)) { log.warn(`ignoring public memory artifacts from plugin "${pluginId}": not an array`); return []; @@ -494,21 +335,10 @@ export async function listActiveMemoryPublicArtifacts(params: { }); } -export function restoreMemoryPluginState(state: MemoryPluginState): void { - memoryPluginState.capability = state.capability - ? { - pluginId: state.capability.pluginId, - capability: { ...state.capability.capability }, - } - : undefined; - memoryPluginState.corpusSupplements = [...state.corpusSupplements]; - memoryPluginState.promptPreparations = [...state.promptPreparations]; - memoryPluginState.promptSupplements = [...state.promptSupplements]; -} - export function clearMemoryPluginState(): void { - memoryPluginState.capability = undefined; - memoryPluginState.corpusSupplements = []; - memoryPluginState.promptPreparations = []; - memoryPluginState.promptSupplements = []; + const registry = requireActivePluginRegistry(); + registry.memoryCapabilities = []; + registry.memoryCorpusSupplements = []; + registry.memoryPromptPreparations = []; + registry.memoryPromptSupplements = []; } diff --git a/src/plugins/plugin-api.types.ts b/src/plugins/plugin-api.types.ts index 0afd8134a683..e31f5704c093 100644 --- a/src/plugins/plugin-api.types.ts +++ b/src/plugins/plugin-api.types.ts @@ -1,7 +1,6 @@ import type { AgentHarness } from "../agents/harness/types.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import type { ContextEngineFactory } from "../context-engine/registry.js"; import type { OperatorScope } from "../gateway/operator-scopes.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import type { InternalHookHandler } from "../hooks/internal-hook-types.js"; @@ -53,7 +52,6 @@ import type { PluginTrustedToolPolicyRegistration, } from "./host-hooks.js"; import type { PluginLogger } from "./logger-types.js"; -import type { MemoryCorpusSupplement } from "./memory-state.js"; import type { MigrationProviderPlugin, PluginConfigMigration, @@ -77,6 +75,14 @@ import type { } from "./plugin-registration.types.js"; import type { UnifiedModelCatalogProviderPlugin } from "./provider-catalog.types.js"; import type { ProviderPlugin } from "./provider-plugin.types.js"; +import type { + ContextEngineFactory, + MemoryCorpusSupplement, + MemoryEmbeddingProviderAdapter, + MemoryPluginCapability, + MemoryPromptSectionBuilder, + MemoryPromptSectionParams, +} from "./registry-contribution-types.js"; import type { PluginRuntime } from "./runtime/types.js"; import type { SessionCatalogProvider } from "./session-catalog.js"; import type { @@ -426,18 +432,12 @@ export type OpenClawPluginApi = { /** Register the active detached task runtime for this plugin (exclusive slot). */ registerDetachedTaskRuntime: (runtime: DetachedTaskLifecycleRuntime) => void; /** Register the active memory capability for this memory plugin (exclusive slot). */ - registerMemoryCapability: ( - capability: import("./memory-state.js").MemoryPluginCapability, - ) => void; + registerMemoryCapability: (capability: MemoryPluginCapability) => void; /** Register an additive memory-adjacent prompt section (non-exclusive). */ - registerMemoryPromptSupplement: ( - builder: import("./memory-state.js").MemoryPromptSectionBuilder, - ) => void; + registerMemoryPromptSupplement: (builder: MemoryPromptSectionBuilder) => void; /** Register an async memory prompt preparation step (non-exclusive). */ registerMemoryPromptPreparation: ( - prepare: ( - params: import("./memory-state.js").MemoryPromptSectionParams, - ) => Promise, + prepare: (params: MemoryPromptSectionParams) => Promise, ) => void; /** Register an additive memory-adjacent search/read corpus supplement (non-exclusive). */ registerMemoryCorpusSupplement: (supplement: MemoryCorpusSupplement) => void; @@ -447,9 +447,7 @@ export type OpenClawPluginApi = { * and `contracts.embeddingProviders`. This memory-specific seam is retained * while existing memory providers migrate. */ - registerMemoryEmbeddingProvider: ( - adapter: import("./memory-embedding-providers.js").MemoryEmbeddingProviderAdapter, - ) => void; + registerMemoryEmbeddingProvider: (adapter: MemoryEmbeddingProviderAdapter) => void; resolvePath: (input: string) => string; /** Register a lifecycle hook handler */ on: ( diff --git a/src/plugins/plugin-registration-transaction.test.ts b/src/plugins/plugin-registration-transaction.test.ts deleted file mode 100644 index 52bb677e2139..000000000000 --- a/src/plugins/plugin-registration-transaction.test.ts +++ /dev/null @@ -1,574 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { clearPluginHostRuntimeState } from "./host-hook-runtime.js"; -import { listPluginSessionSchedulerJobs } from "./host-hook-runtime.test-fixtures.js"; -import { clearActivatedPluginRuntimeState } from "./loader-shared.js"; -import { - getMemoryCapabilityRegistration, - registerMemoryCapability, -} from "./memory-state.test-fixtures.js"; -import { - createPluginRegistrationTransaction, - type PluginProcessGlobalState, - restorePluginProcessGlobalState, - snapshotPluginProcessGlobalState, -} from "./plugin-registration-transaction.js"; -import { createEmptyPluginRegistry } from "./registry-empty.js"; -import { createPluginRegistry } from "./registry.js"; -import type { PluginRuntime } from "./runtime/types.js"; -import { - getSessionDiscussionProvider, - registerSessionDiscussionProvider, - type SessionDiscussionProvider, -} from "./session-discussion-registry.js"; -import { createPluginRecord } from "./status.test-helpers.js"; - -function discussionProvider(id: string): SessionDiscussionProvider { - return { - id, - info: vi.fn().mockResolvedValue({ state: "available" }), - open: vi.fn().mockResolvedValue({ state: "open" }), - }; -} - -function createSchedulerPlugin(pluginId: string) { - const pluginRegistry = createPluginRegistry({ - logger: { info() {}, warn() {}, error() {}, debug() {} }, - runtime: {} as PluginRuntime, - }); - const api = pluginRegistry.createApi(createPluginRecord({ id: pluginId }), { - config: {} as OpenClawConfig, - }); - return { api, pluginRegistry }; -} - -describe("plugin registration transaction", () => { - let initialProcessGlobalState: PluginProcessGlobalState; - - beforeEach(() => { - initialProcessGlobalState = snapshotPluginProcessGlobalState(); - }); - - afterEach(() => { - clearPluginHostRuntimeState(); - restorePluginProcessGlobalState(initialProcessGlobalState); - }); - - it("rolls back registry writes and restores prior process-global capability state", () => { - const registry = createEmptyPluginRegistry(); - const activePromptBuilder = () => ["active"]; - const failedResolver = () => "failed"; - const rollbackGlobalSideEffects = vi.fn(); - registerMemoryCapability("active-memory", { promptBuilder: activePromptBuilder }); - - const transaction = createPluginRegistrationTransaction({ - registry, - rollbackGlobalSideEffects, - }); - registry.hostedMediaResolvers.push({ - pluginId: "failed-plugin", - resolver: failedResolver, - source: "failed-plugin", - }); - registry.gatewayHandlers.failed = async () => {}; - registerMemoryCapability("failed-memory", { promptBuilder: () => ["failed"] }); - - transaction.rollback(); - - expect(rollbackGlobalSideEffects).toHaveBeenCalledOnce(); - expect(registry.hostedMediaResolvers).toStrictEqual([]); - expect(registry.gatewayHandlers).toStrictEqual({}); - expect(getMemoryCapabilityRegistration()).toEqual({ - pluginId: "active-memory", - capability: { promptBuilder: activePromptBuilder }, - }); - }); - - it("isolates PluginRecord metadata mutations in arrays through shallow record cloning (#106647)", () => { - const registry = createEmptyPluginRegistry(); - registry.plugins.push({ - id: "test-plugin", - name: "Test Plugin", - source: "test-source", - origin: "global" as const, - enabled: true, - status: "loaded" as const, - toolNames: [], - hookNames: [], - channelIds: [], - cliBackendIds: [], - providerIds: [], - embeddingProviderIds: [], - speechProviderIds: [], - realtimeTranscriptionProviderIds: [], - realtimeVoiceProviderIds: [], - mediaUnderstandingProviderIds: [], - transcriptSourceProviderIds: [], - imageGenerationProviderIds: [], - videoGenerationProviderIds: [], - musicGenerationProviderIds: [], - webFetchProviderIds: [], - webSearchProviderIds: [], - migrationProviderIds: [], - memoryEmbeddingProviderIds: [], - agentHarnessIds: [], - cliCommands: [], - services: [], - gatewayDiscoveryServiceIds: [], - commands: [], - httpRoutes: 0, - hookCount: 0, - configSchema: false, - }); - - const transaction = createPluginRegistrationTransaction({ registry }); - - // Mutate a nested property on the PluginRecord that was captured in the snapshot - registry.plugins[0]!.status = "error"; - registry.plugins[0]!.enabled = false; - - transaction.rollback(); - - // After rollback, the original values should be restored - expect(registry.plugins[0]!.status).toBe("loaded"); - expect(registry.plugins[0]!.enabled).toBe(true); - }); - - it("isolates Map entry metadata mutations through shallow record cloning (#106647)", () => { - const registry = createEmptyPluginRegistry(); - registry.workerProviders.set("test-worker", { - pluginId: "test-plugin", - pluginName: "Test", - provider: { id: "worker-1" } as import("./types.js").WorkerProvider, - source: "test-source", - }); - - const transaction = createPluginRegistrationTransaction({ registry }); - - // Mutate a property on the Map value - const entry = registry.workerProviders.get("test-worker")!; - entry.pluginName = "Mutated"; - - transaction.rollback(); - - // After rollback, the original value should be restored - expect(registry.workerProviders.get("test-worker")!.pluginName).toBe("Test"); - }); - - it("preserves functions by reference in cloned objects (#106647)", () => { - const registry = createEmptyPluginRegistry(); - const myResolver = () => "resolved"; - registry.hostedMediaResolvers.push({ - pluginId: "test-plugin", - resolver: myResolver, - source: "test-source", - }); - - const transaction = createPluginRegistrationTransaction({ registry }); - - // Push a new entry during the transaction - registry.hostedMediaResolvers.push({ - pluginId: "another-plugin", - resolver: () => "another", - source: "another-source", - }); - - transaction.rollback(); - - // After rollback, the array should be restored to the snapshot - expect(registry.hostedMediaResolvers).toHaveLength(1); - // The original resolver function reference should be preserved - expect(registry.hostedMediaResolvers[0]!.resolver).toBe(myResolver); - }); - - it("preserves class instances and their prototypes by reference in cloned records (#106647)", () => { - const registry = createEmptyPluginRegistry(); - - class TestProvider { - id = "provider-1"; - label = "Test Provider"; - chat(model: string) { - return `chat-${model}`; - } - listModels() { - return ["model-a"]; - } - start() {} - stop() {} - } - const providerInstance = new TestProvider(); - - registry.providers.push({ - pluginId: "test-plugin", - pluginName: "Test", - provider: providerInstance as unknown as import("./types.js").ProviderPlugin, - source: "test-source", - }); - - const transaction = createPluginRegistrationTransaction({ registry }); - - // Mutate metadata during transaction - registry.providers[0]!.pluginName = "Mutated"; - - transaction.rollback(); - - // After rollback, metadata is restored - expect(registry.providers[0]!.pluginName).toBe("Test"); - // The provider instance is the same object reference (not a plain-object copy) - expect(registry.providers[0]!.provider).toBe(providerInstance); - // Class prototype is intact — methods are callable - expect(registry.providers[0]!.provider).toBeInstanceOf(TestProvider); - expect(providerInstance.chat("gpt-5")).toBe("chat-gpt-5"); - }); - - it("preserves earlier class-backed plugin registrations when a later plugin fails and rolls back (loader scenario #106647)", () => { - const registry = createEmptyPluginRegistry(); - - // Simulate a real plugin provider with class-backed state (methods, prototypes) - class GoodProvider { - id = "good-provider"; - label = "Good Provider"; - chat(model: string) { - return `good-${model}`; - } - listModels() { - return ["good-model"]; - } - start() {} - stop() {} - } - const goodProviderInstance = new GoodProvider(); - - // Transaction 1: "good-plugin" registers successfully (mirrors loader-runtime-candidate L492-507) - const tx1 = createPluginRegistrationTransaction({ registry }); - - registry.providers.push({ - pluginId: "good-plugin", - pluginName: "Good Plugin", - provider: goodProviderInstance as unknown as import("./types.js").ProviderPlugin, - source: "good-source", - }); - registry.plugins.push({ - id: "good-plugin", - name: "Good Plugin", - source: "good-source", - origin: "global" as const, - enabled: true, - status: "loaded" as const, - toolNames: [], - hookNames: [], - channelIds: [], - cliBackendIds: [], - providerIds: ["good-provider"], - embeddingProviderIds: [], - speechProviderIds: [], - realtimeTranscriptionProviderIds: [], - realtimeVoiceProviderIds: [], - mediaUnderstandingProviderIds: [], - transcriptSourceProviderIds: [], - imageGenerationProviderIds: [], - videoGenerationProviderIds: [], - musicGenerationProviderIds: [], - webFetchProviderIds: [], - webSearchProviderIds: [], - migrationProviderIds: [], - memoryEmbeddingProviderIds: [], - agentHarnessIds: [], - cliCommands: [], - services: [], - gatewayDiscoveryServiceIds: [], - commands: [], - httpRoutes: 0, - hookCount: 0, - configSchema: false, - }); - - tx1.commit({ activate: true }); - - // Transaction 2: "bad-plugin" writes to registry then fails (mirrors loader-runtime-candidate L508-522) - const rollbackSideEffects = vi.fn(); - const tx2 = createPluginRegistrationTransaction({ - registry, - rollbackGlobalSideEffects: rollbackSideEffects, - }); - - registry.providers.push({ - pluginId: "bad-plugin", - pluginName: "Bad Plugin", - provider: { id: "bad-provider" } as unknown as import("./types.js").ProviderPlugin, - source: "bad-source", - }); - // Bad plugin's registration mutates the good plugin's metadata (simulating side effects) - registry.plugins[0]!.status = "error"; - registry.providers[0]!.pluginName = "Corrupted"; - - // Bad plugin fails, loader calls rollback - tx2.rollback(); - - // After rollback: bad plugin's provider is gone - expect(registry.providers).toHaveLength(1); - expect(registry.providers[0]!.pluginId).toBe("good-plugin"); - - // Good plugin's metadata is restored - expect(registry.plugins[0]!.status).toBe("loaded"); - expect(registry.providers[0]!.pluginName).toBe("Good Plugin"); - - // Good plugin's class-backed provider instance preserved by reference - expect(registry.providers[0]!.provider).toBe(goodProviderInstance); - expect(registry.providers[0]!.provider).toBeInstanceOf(GoodProvider); - expect(goodProviderInstance.chat("gpt-5")).toBe("good-gpt-5"); - - // rollbackGlobalSideEffects was called (loader contract) - expect(rollbackSideEffects).toHaveBeenCalledOnce(); - }); - - it("restores the active PluginRecord's arrays and scalars after a failed registration (loader #106647)", () => { - const registry = createEmptyPluginRegistry(); - const initialFailureDate = new Date(123); - - // Simulate the loader pattern: record exists before transaction, - // register() mutates its id-collection arrays, scalars, and the registry. - const record = { - id: "test-plugin", - name: "Test Plugin", - source: "test-source", - origin: "global" as const, - enabled: true, - status: "loaded" as const, - toolNames: [] as string[], - hookNames: [] as string[], - providerIds: [] as string[], - channelIds: [] as string[], - cliBackendIds: [] as string[], - embeddingProviderIds: [] as string[], - speechProviderIds: [] as string[], - realtimeTranscriptionProviderIds: [] as string[], - realtimeVoiceProviderIds: [] as string[], - mediaUnderstandingProviderIds: [] as string[], - transcriptSourceProviderIds: [] as string[], - imageGenerationProviderIds: [] as string[], - videoGenerationProviderIds: [] as string[], - musicGenerationProviderIds: [] as string[], - webFetchProviderIds: [] as string[], - webSearchProviderIds: [] as string[], - migrationProviderIds: [] as string[], - memoryEmbeddingProviderIds: [] as string[], - agentHarnessIds: [] as string[], - cliCommands: [] as string[], - services: [] as string[], - gatewayDiscoveryServiceIds: [] as string[], - commands: [] as string[], - httpRoutes: 0, - hookCount: 0, - configSchema: false, - memorySlotSelected: false, - failedAt: initialFailureDate, - }; - - const transaction = createPluginRegistrationTransaction({ - registry, - activeRecord: record, - }); - - // During register(), plugin API mutates record arrays, scalars, and the registry - record.toolNames.push("bad-tool"); - record.hookNames.push("bad-hook"); - record.providerIds.push("bad-provider"); - record.httpRoutes = 5; - record.hookCount = 3; - record.configSchema = true; - record.memorySlotSelected = true; - record.enabled = false; - initialFailureDate.setTime(456); - (record as Record).transientMetadata = "leaked"; - registry.tools.push({ - pluginId: "test-plugin", - factory: () => ({}) as unknown as import("./types.js").AnyAgentTool, - names: ["bad-tool"], - optional: false, - source: "test-source", - }); - - // Loader pushes record to registry.plugins (loader-runtime-candidate L505) - registry.plugins.push(record); - - // Plugin fails, loader calls rollback (L509) - transaction.rollback(); - - // Registry snapshot correctly removes the record from plugins - expect(registry.plugins).toHaveLength(0); - expect(registry.tools).toHaveLength(0); - - // Active record's array fields are restored - expect(record.toolNames).toEqual([]); - expect(record.hookNames).toEqual([]); - expect(record.providerIds).toEqual([]); - - // Active record's scalar fields are restored - expect(record.httpRoutes).toBe(0); - expect(record.hookCount).toBe(0); - expect(record.configSchema).toBe(false); - expect(record.memorySlotSelected).toBe(false); - expect(record.enabled).toBe(true); - expect(record.failedAt?.getTime()).toBe(123); - expect(record.failedAt).not.toBe(initialFailureDate); - expect(record).not.toHaveProperty("transientMetadata"); - }); - - it("preserves runtime object identity on the active PluginRecord through rollback (#106647)", () => { - const registry = createEmptyPluginRegistry(); - - const record = { - id: "test-plugin", - name: "Test Plugin", - source: "test-source", - origin: "global" as const, - enabled: true, - status: "loaded" as const, - toolNames: [] as string[], - hookNames: [] as string[], - providerIds: [] as string[], - channelIds: [] as string[], - cliBackendIds: [] as string[], - embeddingProviderIds: [] as string[], - speechProviderIds: [] as string[], - realtimeTranscriptionProviderIds: [] as string[], - realtimeVoiceProviderIds: [] as string[], - mediaUnderstandingProviderIds: [] as string[], - transcriptSourceProviderIds: [] as string[], - imageGenerationProviderIds: [] as string[], - videoGenerationProviderIds: [] as string[], - musicGenerationProviderIds: [] as string[], - webFetchProviderIds: [] as string[], - webSearchProviderIds: [] as string[], - migrationProviderIds: [] as string[], - memoryEmbeddingProviderIds: [] as string[], - agentHarnessIds: [] as string[], - cliCommands: [] as string[], - services: [] as string[], - gatewayDiscoveryServiceIds: [] as string[], - commands: [] as string[], - httpRoutes: 0, - hookCount: 0, - configSchema: false, - }; - - // Plugin-owned runtime objects on the record must survive rollback by reference - const configUiHints = { myHint: { kind: "select" } }; - (record as Record)["configUiHints"] = configUiHints; - - const transaction = createPluginRegistrationTransaction({ - registry, - activeRecord: record, - }); - - // Mutate scalars and arrays during register() - record.httpRoutes = 5; - record.toolNames.push("tool-1"); - - transaction.rollback(); - - // Scalars and arrays restored - expect(record.httpRoutes).toBe(0); - expect(record.toolNames).toEqual([]); - - // Runtime object preserved by reference identity - expect((record as Record)["configUiHints"]).toBe(configUiHints); - }); - - it("keeps snapshot registry writes while restoring globals for non-activating commits", () => { - const registry = createEmptyPluginRegistry(); - const activePromptBuilder = () => ["active"]; - const snapshotResolver = () => "snapshot"; - registerMemoryCapability("active-memory", { promptBuilder: activePromptBuilder }); - - const transaction = createPluginRegistrationTransaction({ registry }); - registry.hostedMediaResolvers.push({ - pluginId: "snapshot-plugin", - resolver: snapshotResolver, - source: "snapshot-plugin", - }); - registerMemoryCapability("snapshot-memory", { promptBuilder: () => ["snapshot"] }); - - transaction.commit({ activate: false }); - - expect(registry.hostedMediaResolvers).toEqual([ - { - pluginId: "snapshot-plugin", - resolver: snapshotResolver, - source: "snapshot-plugin", - }, - ]); - expect(getMemoryCapabilityRegistration()).toEqual({ - pluginId: "active-memory", - capability: { promptBuilder: activePromptBuilder }, - }); - }); - - it("clears the discussion provider before repeated active plugin activation", () => { - registerSessionDiscussionProvider(discussionProvider("clickclack")); - - clearActivatedPluginRuntimeState(); - - expect(getSessionDiscussionProvider()).toBeUndefined(); - }); - - it("restores the prior discussion provider when plugin activation rolls back", () => { - const activeProvider = discussionProvider("clickclack"); - registerSessionDiscussionProvider(activeProvider); - const transaction = createPluginRegistrationTransaction({}); - registerSessionDiscussionProvider(discussionProvider("replacement")); - - transaction.rollback(); - - expect(getSessionDiscussionProvider()).toBe(activeProvider); - }); - - it("rolls back only scheduler jobs owned by the failed registry", async () => { - const pluginId = "scheduler-plugin"; - const failedCleanup = vi.fn(); - const activeCleanup = vi.fn(); - const active = createSchedulerPlugin(pluginId); - const failed = createSchedulerPlugin(pluginId); - - active.api.registerSessionSchedulerJob({ - id: "active-job", - sessionKey: "agent:main:main", - kind: "monitor", - cleanup: activeCleanup, - }); - const transaction = createPluginRegistrationTransaction({ - registry: failed.pluginRegistry.registry, - rollbackGlobalSideEffects: () => - failed.pluginRegistry.rollbackPluginGlobalSideEffects(pluginId), - }); - failed.api.registerSessionSchedulerJob({ - id: "failed-job", - sessionKey: "agent:main:main", - kind: "monitor", - cleanup: failedCleanup, - }); - - transaction.rollback(); - await vi.waitFor(() => { - expect(failedCleanup).toHaveBeenCalledOnce(); - }); - - expect(failedCleanup).toHaveBeenCalledWith({ - reason: "disable", - sessionKey: "agent:main:main", - jobId: "failed-job", - }); - expect(activeCleanup).not.toHaveBeenCalled(); - expect(listPluginSessionSchedulerJobs(pluginId)).toStrictEqual([ - { - id: "active-job", - pluginId, - sessionKey: "agent:main:main", - kind: "monitor", - }, - ]); - expect(failed.pluginRegistry.registry.sessionSchedulerJobs).toStrictEqual([]); - expect(active.pluginRegistry.registry.sessionSchedulerJobs).toHaveLength(1); - }); -}); diff --git a/src/plugins/plugin-registration-transaction.ts b/src/plugins/plugin-registration-transaction.ts deleted file mode 100644 index 2ccfefec1cbc..000000000000 --- a/src/plugins/plugin-registration-transaction.ts +++ /dev/null @@ -1,233 +0,0 @@ -// Owns atomic plugin registration state across registry and process-global capabilities. -import { - listRegisteredAgentHarnesses, - restoreRegisteredAgentHarnesses, -} from "../agents/harness/registry.js"; -import { - getDetachedTaskLifecycleRuntimeRegistration, - restoreDetachedTaskLifecycleRuntimeRegistration, -} from "../tasks/detached-task-runtime-state.js"; -import { listRegisteredPluginCommands, restorePluginCommands } from "./command-registry-state.js"; -import { - listRegisteredCompactionProviders, - restoreRegisteredCompactionProviders, -} from "./compaction-provider.js"; -import { - listRegisteredEmbeddingProviders, - restoreRegisteredEmbeddingProviders, -} from "./embedding-providers.js"; -import { - listPluginInteractiveHandlers, - restorePluginInteractiveHandlers, -} from "./interactive-registry.js"; -import { - restoreLegacyPluginInternalHooks, - snapshotLegacyPluginInternalHooks, - type LegacyPluginInternalHookState, -} from "./legacy-internal-hook-state.js"; -import { - listRegisteredMemoryEmbeddingProviders, - restoreRegisteredMemoryEmbeddingProviders, -} from "./memory-embedding-providers.js"; -import { - getMemoryCapabilityRegistration, - listMemoryCorpusSupplements, - listMemoryPromptPreparations, - listMemoryPromptSupplements, - restoreMemoryPluginState, -} from "./memory-state.js"; -import type { PluginRecord, PluginRegistry } from "./registry-types.js"; -import { - getSessionDiscussionProvider, - restoreSessionDiscussionProvider, -} from "./session-discussion-registry.js"; - -export type PluginProcessGlobalState = { - agentHarnesses: ReturnType; - commands: ReturnType; - compactionProviders: ReturnType; - detachedTaskRuntimeRegistration: ReturnType; - embeddingProviders: ReturnType; - interactiveHandlers: ReturnType; - legacyInternalHooks: LegacyPluginInternalHookState; - memoryCapability: ReturnType; - memoryCorpusSupplements: ReturnType; - memoryEmbeddingProviders: ReturnType; - memoryPromptPreparations: ReturnType; - memoryPromptSupplements: ReturnType; - sessionDiscussionProvider: ReturnType; -}; - -export function snapshotPluginProcessGlobalState(): PluginProcessGlobalState { - return { - agentHarnesses: listRegisteredAgentHarnesses(), - commands: listRegisteredPluginCommands(), - compactionProviders: listRegisteredCompactionProviders(), - detachedTaskRuntimeRegistration: getDetachedTaskLifecycleRuntimeRegistration(), - embeddingProviders: listRegisteredEmbeddingProviders(), - interactiveHandlers: listPluginInteractiveHandlers(), - legacyInternalHooks: snapshotLegacyPluginInternalHooks(), - memoryCapability: getMemoryCapabilityRegistration(), - memoryCorpusSupplements: listMemoryCorpusSupplements(), - memoryEmbeddingProviders: listRegisteredMemoryEmbeddingProviders(), - memoryPromptPreparations: listMemoryPromptPreparations(), - memoryPromptSupplements: listMemoryPromptSupplements(), - sessionDiscussionProvider: getSessionDiscussionProvider(), - }; -} - -export function restorePluginProcessGlobalState(state: PluginProcessGlobalState): void { - restoreRegisteredAgentHarnesses(state.agentHarnesses); - restorePluginCommands(state.commands); - restoreRegisteredCompactionProviders(state.compactionProviders); - restoreDetachedTaskLifecycleRuntimeRegistration(state.detachedTaskRuntimeRegistration); - restoreRegisteredEmbeddingProviders(state.embeddingProviders); - restorePluginInteractiveHandlers(state.interactiveHandlers); - restoreLegacyPluginInternalHooks(state.legacyInternalHooks); - restoreRegisteredMemoryEmbeddingProviders(state.memoryEmbeddingProviders); - restoreMemoryPluginState({ - capability: state.memoryCapability, - corpusSupplements: state.memoryCorpusSupplements, - promptPreparations: state.memoryPromptPreparations, - promptSupplements: state.memoryPromptSupplements, - }); - restoreSessionDiscussionProvider(state.sessionDiscussionProvider); -} - -/** - * Shallow-clone a registration record so metadata mutations do not leak - * through rollback, while preserving opaque plugin-owned instances - * (providers, services, channels, harnesses, resolvers) by reference. - * - * A generic recursive deep-clone would convert every plugin-owned object - * into a plain object, losing prototypes, internal slots, symbols, and - * shared identity. Shallow-cloning is correct here because the mutable - * fields on registration records are primitives (strings, numbers, - * booleans, Dates), and the opaque fields are class instances that the - * registry must not reconstitute. - */ -function cloneRegistryEntry(value: unknown): unknown { - if (value === null || value === undefined) { - return value; - } - if (typeof value !== "object") { - return value; - } - if (Array.isArray(value)) { - return value.map((item) => cloneRegistryEntry(item)); - } - if (value instanceof Map) { - return new Map([...value].map(([k, v]) => [k, cloneRegistryEntry(v)])); - } - if (value instanceof Date) { - return new Date(value); - } - // Shallow-clone so primitive metadata fields become independent copies - // while opaque plugin-owned objects stay by reference. - const cloned: Record = {}; - for (const key of Object.keys(value)) { - const val = (value as Record)[key]; - if (val instanceof Date) { - cloned[key] = new Date(val); - } else if (Array.isArray(val)) { - cloned[key] = [...val]; - } else { - cloned[key] = val; - } - } - return cloned; -} - -function snapshotPluginRegistry(registry: PluginRegistry): PluginRegistry { - return Object.fromEntries( - Object.entries(registry).map(([key, value]) => { - if (Array.isArray(value)) { - return [key, value.map((item) => cloneRegistryEntry(item))]; - } - if (value instanceof Map) { - return [key, new Map([...value].map(([k, v]) => [k, cloneRegistryEntry(v)]))]; - } - if (value && typeof value === "object") { - return [key, cloneRegistryEntry(value)]; - } - return [key, value]; - }), - ) as PluginRegistry; -} - -function restorePluginRegistry(registry: PluginRegistry, snapshot: PluginRegistry): void { - Object.assign(registry, snapshot); -} - -/** - * Snapshot of the active PluginRecord's transaction-owned mutable fields. - * Captured with cloneRegistryEntry so arrays, Dates, and primitives become - * independent copies while runtime objects (configUiHints, configJsonSchema, - * contracts) stay by reference. - */ -type ActiveRecordSnapshot = Record; - -function snapshotActiveRecord(record: PluginRecord): ActiveRecordSnapshot { - return cloneRegistryEntry(record) as ActiveRecordSnapshot; -} - -function restoreActiveRecord(record: PluginRecord, snapshot: ActiveRecordSnapshot): void { - // Registration may create optional metadata that did not exist when the - // transaction began. Remove the live shape before restoring the snapshot. - for (const key of Object.keys(record)) { - Reflect.deleteProperty(record, key); - } - Object.assign(record, snapshot); -} - -type PluginRegistrationTransaction = { - commit: (params: { activate: boolean }) => void; - rollback: () => void; -}; - -export function createPluginRegistrationTransaction(params: { - registry?: PluginRegistry; - rollbackGlobalSideEffects?: () => void; - /** Active PluginRecord being registered by the caller (mutated during - * register() before being pushed to registry.plugins). When set, its - * mutable metadata fields (arrays, scalars, flags, Dates) are snapshotted - * and restored on rollback while runtime objects keep their identity. */ - activeRecord?: PluginRecord; -}): PluginRegistrationTransaction { - const registrySnapshot = params.registry ? snapshotPluginRegistry(params.registry) : undefined; - const processGlobalState = snapshotPluginProcessGlobalState(); - const activeRecordSnapshot = params.activeRecord - ? snapshotActiveRecord(params.activeRecord) - : null; - let settled = false; - - const settle = (action: () => void): void => { - if (settled) { - return; - } - action(); - settled = true; - }; - - return { - commit: ({ activate }) => { - settle(() => { - if (!activate) { - restorePluginProcessGlobalState(processGlobalState); - } - }); - }, - rollback: () => { - settle(() => { - params.rollbackGlobalSideEffects?.(); - if (params.registry && registrySnapshot) { - restorePluginRegistry(params.registry, registrySnapshot); - } - restorePluginProcessGlobalState(processGlobalState); - if (activeRecordSnapshot && params.activeRecord) { - restoreActiveRecord(params.activeRecord, activeRecordSnapshot); - } - }); - }, - }; -} diff --git a/src/plugins/plugin-runtime-artifact-resolution.test.ts b/src/plugins/plugin-runtime-artifact-resolution.test.ts index bbce0b09b640..c528f7b96db0 100644 --- a/src/plugins/plugin-runtime-artifact-resolution.test.ts +++ b/src/plugins/plugin-runtime-artifact-resolution.test.ts @@ -3,17 +3,13 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { withEnv } from "../test-utils/env.js"; -import { - clearActivatedPluginRuntimeState, - clearPluginRegistryLoadCache, - loadOpenClawPlugins, -} from "./loader.js"; +import { clearPluginRegistryLoadCache, loadOpenClawPlugins } from "./loader.js"; import { resetPluginLoaderTestStateForTest } from "./loader.test-fixtures.js"; import { clearPluginRuntimeArtifactResolutionMemo, resolvePluginRuntimeArtifact, } from "./plugin-runtime-artifact-resolution.js"; -import { pinActivePluginChannelRegistry } from "./runtime.js"; +import { getActivePluginChannelRegistry, pinActivePluginChannelRegistry } from "./runtime.js"; const tempDirs: string[] = []; @@ -130,14 +126,14 @@ describe("resolvePluginRuntimeArtifact", () => { expect(setup.source).toBe(fs.realpathSync(setupSource)); }); - it("re-resolves after activated runtime state is cleared", () => { + it("re-resolves after the active registry memo is cleared", () => { const fixture = createBundledPluginFixture(); const sourceResolution = resolveFixture({ ...fixture, preferBuiltPluginArtifacts: false, }); - clearActivatedPluginRuntimeState(); + clearPluginRuntimeArtifactResolutionMemo(); const builtResolution = resolveFixture({ ...fixture, @@ -164,7 +160,7 @@ describe("resolvePluginRuntimeArtifact", () => { expect(builtResolution.source).toBe(fixture.builtSource); }); - it("keeps one physical entry across activating registry assemblies", () => { + it("resolves replacement artifacts independently while pinned consumers keep their registry", () => { const fixture = createBundledPluginFixture(); const config = { plugins: { @@ -197,8 +193,13 @@ describe("resolvePluginRuntimeArtifact", () => { }, ); - expect(first.plugins.find((plugin) => plugin.id === "fixture")?.source).toBe(fixture.source); - expect(second.plugins.find((plugin) => plugin.id === "fixture")?.source).toBe(fixture.source); + expect([...first.pluginRuntimeArtifacts.values()].map((entry) => entry.source)).toEqual([ + fixture.source, + ]); + expect([...second.pluginRuntimeArtifacts.values()].map((entry) => entry.source)).toEqual([ + fixture.builtSource, + ]); + expect(getActivePluginChannelRegistry()).toBe(first); }); it("leaves dist-only installs unchanged because both preferences resolve the built entry", () => { diff --git a/src/plugins/plugin-runtime-artifact-resolution.ts b/src/plugins/plugin-runtime-artifact-resolution.ts index b0d2ce33d7e3..709f41a86ab5 100644 --- a/src/plugins/plugin-runtime-artifact-resolution.ts +++ b/src/plugins/plugin-runtime-artifact-resolution.ts @@ -3,15 +3,13 @@ import fs from "node:fs"; import path from "node:path"; import type { OpenClawPackageManifest } from "./manifest.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; +import type { PluginRegistry } from "./registry-types.js"; +import { collectLivePluginRegistries, requireActivePluginRegistry } from "./runtime.js"; -type ResolvedPluginRuntimeArtifact = { source: string; rootDir: string }; type PluginRuntimeArtifactEntryKind = "runtime" | "setup"; -// Pin one physical path per plugin id and logical entry for this runtime lifecycle. -// Registry surfaces may disagree on artifact preference, but hooks and tools must -// share one evaluated module instance so register() runs once. -const resolvedPluginRuntimeArtifacts = new Map(); - +// Pin one physical path per plugin id and logical entry within one installed registry. +// Pinned surfaces retain their registry while replacement builders resolve independently. function safeRealpathOrResolve(value: string): string { try { return fs.realpathSync(value); @@ -21,7 +19,9 @@ function safeRealpathOrResolve(value: string): string { } export function clearPluginRuntimeArtifactResolutionMemo(): void { - resolvedPluginRuntimeArtifacts.clear(); + for (const registry of collectLivePluginRegistries()) { + registry.pluginRuntimeArtifacts.clear(); + } } /** Canonical packaged runtime replaces staging-only dist-runtime artifacts. */ @@ -175,12 +175,15 @@ export function resolvePluginRuntimeArtifact(params: { origin: PluginOrigin; preferBuiltPluginArtifacts: boolean; packageManifest?: OpenClawPackageManifest; + registry?: PluginRegistry; }): { source: string; rootDir: string } { const rootDir = resolveCanonicalDistRuntimeSource(safeRealpathOrResolve(params.rootDir)); const source = resolveCanonicalDistRuntimeSource(safeRealpathOrResolve(params.source)); const memoKey = JSON.stringify([params.pluginId, rootDir, params.entryKind]); - const cached = resolvedPluginRuntimeArtifacts.get(memoKey); + const targetRegistry = params.registry ?? requireActivePluginRegistry(); + const cached = targetRegistry.pluginRuntimeArtifacts.get(memoKey); if (cached) { + targetRegistry.pluginRuntimeArtifacts.set(memoKey, cached); return { ...cached }; } @@ -189,6 +192,6 @@ export function resolvePluginRuntimeArtifact(params: { source: resolveCanonicalDistRuntimeSource(preferred.source), rootDir: resolveCanonicalDistRuntimeSource(preferred.rootDir), }; - resolvedPluginRuntimeArtifacts.set(memoKey, resolved); + targetRegistry.pluginRuntimeArtifacts.set(memoKey, resolved); return { ...resolved }; } diff --git a/src/plugins/registry-contribution-types.ts b/src/plugins/registry-contribution-types.ts new file mode 100644 index 000000000000..7d5bc0c87fe6 --- /dev/null +++ b/src/plugins/registry-contribution-types.ts @@ -0,0 +1,337 @@ +/** Acyclic contracts for capabilities stored in the installed plugin registry. */ +import type { EmbeddingInput } from "../../packages/memory-host-sdk/src/engine-embeddings.js"; +import type { MemoryCitationsMode } from "../config/types.memory.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { ContextEngine } from "../context-engine/types.js"; +import type { MemorySearchManager } from "../memory-host-sdk/host/types.js"; +import type { + EmbeddingProvider, + EmbeddingProviderAdapter, + EmbeddingProviderCallOptions, + EmbeddingProviderCreateOptions, + EmbeddingProviderIndexIdentity, + EmbeddingProviderRuntime, +} from "./embedding-provider-types.js"; + +export type ContextEngineFactoryContext = { + config?: OpenClawConfig; + agentDir?: string; + workspaceDir?: string; +}; +export type ContextEngineFactory = ( + ctx: ContextEngineFactoryContext, +) => ContextEngine | Promise; +export type ContextEngineRegistrationLifecycle = "runtime" | "readOnlyDiscovery"; +export type ContextEngineRegistration = { + factory: ContextEngineFactory; + owner: string; + lifecycle: ContextEngineRegistrationLifecycle; +}; + +type CompactionProviderSummarizationInstructions = { + identifierPolicy?: "strict" | "off" | "custom"; + identifierInstructions?: string; +}; + +export interface CompactionProvider { + id: string; + label: string; + summarize(params: { + messages: unknown[]; + signal?: AbortSignal; + compressionRatio?: number; + customInstructions?: string; + summarizationInstructions?: CompactionProviderSummarizationInstructions; + previousSummary?: string; + }): Promise; +} + +export type RegisteredCompactionProvider = { + provider: CompactionProvider; + ownerPluginId?: string; +}; + +export type MemoryEmbeddingBatchChunk = { + text: string; + embeddingInput?: EmbeddingInput; +}; + +export type MemoryEmbeddingBatchOptions = { + agentId: string; + chunks: MemoryEmbeddingBatchChunk[]; + wait: boolean; + concurrency: number; + pollIntervalMs: number; + timeoutMs: number; + debug: (message: string, data?: Record) => void; +}; + +export type MemoryEmbeddingProviderCallOptions = Pick; + +export type MemoryEmbeddingProviderRuntime = EmbeddingProviderRuntime & { + sourceWideBatchEmbed?: boolean; + batchEmbed?: (options: MemoryEmbeddingBatchOptions) => Promise; +}; + +export type MemoryEmbeddingProviderIndexIdentity = EmbeddingProviderIndexIdentity; + +export type MemoryEmbeddingProvider = Pick< + EmbeddingProvider, + "id" | "model" | "maxInputTokens" | "close" +> & { + embedQuery: (text: string, options?: MemoryEmbeddingProviderCallOptions) => Promise; + embedBatch: ( + texts: string[], + options?: MemoryEmbeddingProviderCallOptions, + ) => Promise; + embedBatchInputs?: ( + inputs: EmbeddingInput[], + options?: MemoryEmbeddingProviderCallOptions, + ) => Promise; +}; + +export type MemoryEmbeddingProviderCreateOptions = Omit< + EmbeddingProviderCreateOptions, + "dimensions" | "local" | "taskType" +> & { + fallback?: string; + local?: { + modelPath?: string; + modelCacheDir?: string; + contextSize?: number | "auto"; + }; + outputDimensionality?: number; + taskType?: + | "RETRIEVAL_QUERY" + | "RETRIEVAL_DOCUMENT" + | "SEMANTIC_SIMILARITY" + | "CLASSIFICATION" + | "CLUSTERING" + | "QUESTION_ANSWERING" + | "FACT_VERIFICATION"; +}; + +export type MemoryEmbeddingProviderCreateResult = { + provider: MemoryEmbeddingProvider | null; + runtime?: MemoryEmbeddingProviderRuntime; +}; + +export type MemoryEmbeddingProviderAdapter = Omit< + EmbeddingProviderAdapter, + "create" | "resolveIndexIdentity" +> & { + autoSelectPriority?: number; + allowExplicitWhenConfiguredAuto?: boolean; + supportsMultimodalEmbeddings?: (params: { model: string }) => boolean; + resolveIndexIdentity?: ( + options: MemoryEmbeddingProviderCreateOptions, + ) => MemoryEmbeddingProviderIndexIdentity; + create: ( + options: MemoryEmbeddingProviderCreateOptions, + ) => Promise; + shouldContinueAutoSelection?: (err: unknown) => boolean; +}; + +export type RegisteredMemoryEmbeddingProvider = { + adapter: MemoryEmbeddingProviderAdapter; + ownerPluginId?: string; +}; + +export type MemoryPromptSectionParams = { + availableTools: Set; + citationsMode?: MemoryCitationsMode; + agentId?: string; + agentSessionKey?: string; + sandboxed?: boolean; +}; + +export type MemoryPromptSectionBuilder = (params: MemoryPromptSectionParams) => string[]; + +export type MemoryPromptSectionPreparer = ( + params: MemoryPromptSectionParams, +) => Promise; + +export type PreparedMemoryPromptSection = Readonly<{ + context: Readonly<{ + availableTools: readonly string[]; + citationsMode?: MemoryCitationsMode; + agentId?: string; + agentSessionKey?: string; + sandboxed: boolean; + }>; + lines: readonly string[]; +}>; + +export type MemoryCorpusSearchResult = { + corpus: string; + path: string; + title?: string; + kind?: string; + score: number; + snippet: string; + id?: string; + startLine?: number; + endLine?: number; + citation?: string; + source?: string; + provenanceLabel?: string; + sourceType?: string; + sourcePath?: string; + updatedAt?: string; +}; + +type MemoryCorpusGetResult = { + corpus: string; + path: string; + title?: string; + kind?: string; + content: string; + fromLine: number; + lineCount: number; + id?: string; + provenanceLabel?: string; + sourceType?: string; + sourcePath?: string; + updatedAt?: string; +}; + +export type MemoryCorpusSupplement = { + search(params: { + query: string; + maxResults?: number; + agentId?: string; + agentSessionKey?: string; + sandboxed?: boolean; + }): Promise; + get(params: { + lookup: string; + fromLine?: number; + lineCount?: number; + agentId?: string; + agentSessionKey?: string; + sandboxed?: boolean; + }): Promise; +}; + +export type MemoryCorpusSupplementRegistration = { + pluginId: string; + supplement: MemoryCorpusSupplement; +}; + +export type MemoryPromptSupplementRegistration = { + pluginId: string; + builder: MemoryPromptSectionBuilder; +}; + +export type MemoryPromptPreparationRegistration = { + pluginId: string; + prepare: MemoryPromptSectionPreparer; +}; + +export type MemoryFlushPlan = { + softThresholdTokens: number; + forceFlushTranscriptBytes: number; + reserveTokensFloor: number; + model?: string; + prompt: string; + systemPrompt: string; + relativePath: string; + recordWriteProvenance?: (params: { + workspaceDir: string; + relativePath: string; + contentBefore: string; + contentAfter: string; + originClass: "agent" | "untrusted"; + observedAt: number; + }) => Promise<(() => Promise) | void>; + clearWriteProvenance?: (params: { workspaceDir: string; relativePath: string }) => Promise; +}; + +export type MemoryFlushPlanResolver = (params: { + cfg?: OpenClawConfig; + nowMs?: number; +}) => MemoryFlushPlan | null; + +export type RegisteredMemorySearchManager = MemorySearchManager; + +type MemoryRuntimeQmdConfig = { + command?: string; +}; + +type MemoryRuntimeBackendConfig = + | { backend: "builtin" } + | { backend: "qmd"; qmd?: MemoryRuntimeQmdConfig }; + +export type MemoryPluginRuntime = { + getMemorySearchManager(params: { + cfg: OpenClawConfig; + agentId: string; + purpose?: "default" | "status" | "cli"; + }): Promise<{ + manager: RegisteredMemorySearchManager | null; + debug?: { + backend?: "builtin" | "qmd"; + purpose?: "default" | "status" | "cli"; + managerMs?: number; + managerCacheState?: + | "cached-full-hit" + | "cached-full-miss" + | "transient-cli" + | "transient-status" + | "pending-create-wait" + | "fallback-builtin" + | "recent-failure-cooldown"; + qmdIdentityHash?: string; + failureCode?: "qmd-unavailable"; + }; + error?: string; + }>; + resolveMemoryBackendConfig(params: { + cfg: OpenClawConfig; + agentId: string; + }): MemoryRuntimeBackendConfig; + closeMemorySearchManager?(params: { cfg: OpenClawConfig; agentId: string }): Promise; + closeAllMemorySearchManagers?(): Promise; +}; + +type MemoryPluginPublicArtifactContentType = "markdown" | "json" | "text"; + +export type MemoryPluginPublicArtifact = { + kind: string; + workspaceDir: string; + relativePath: string; + absolutePath: string; + agentIds: string[]; + contentType: MemoryPluginPublicArtifactContentType; +}; + +export type MemoryPluginPublicArtifactsProvider = { + listArtifacts(params: { cfg: OpenClawConfig }): Promise; +}; + +export type MemoryPluginCapability = { + promptBuilder?: MemoryPromptSectionBuilder; + flushPlanResolver?: MemoryFlushPlanResolver; + runtime?: MemoryPluginRuntime; + publicArtifacts?: MemoryPluginPublicArtifactsProvider; +}; + +export type MemoryPluginCapabilityRegistration = { + pluginId: string; + capability: MemoryPluginCapability; +}; + +export type SessionDiscussionState = "none" | "available" | "open"; +export type SessionDiscussionInfo = { + state: SessionDiscussionState; + embedUrl?: string; + openUrl?: string; +}; + +export type SessionDiscussionProvider = { + id: string; + info(params: { sessionKey: string }): Promise; + open(params: { sessionKey: string }): Promise; +}; + +export type ResolvedPluginRuntimeArtifact = { source: string; rootDir: string }; diff --git a/src/plugins/registry-empty.ts b/src/plugins/registry-empty.ts index 6fc30d3028ae..215ac998d990 100644 --- a/src/plugins/registry-empty.ts +++ b/src/plugins/registry-empty.ts @@ -32,6 +32,17 @@ export function createEmptyPluginRegistry(): PluginRegistry { agentToolResultMiddlewares: [], memoryEmbeddingProviders: [], agentHarnesses: [], + pluginRuntimeArtifacts: new Map(), + compactionProviders: [], + detachedTaskRuntimes: [], + legacyInternalHooks: [], + memoryCapabilities: [], + memoryCorpusSupplements: [], + memoryPromptPreparations: [], + memoryPromptSupplements: [], + sessionDiscussionProviders: new Map(), + contextEngines: new Map(), + commandRegistryLocked: false, gatewayHandlers: {}, gatewayMethodDescriptors: [], dashboardDataBindings: new Map(), diff --git a/src/plugins/registry-registrars-capabilities.ts b/src/plugins/registry-registrars-capabilities.ts index 0b93f6f54146..5213e44a46cb 100644 --- a/src/plugins/registry-registrars-capabilities.ts +++ b/src/plugins/registry-registrars-capabilities.ts @@ -1,14 +1,6 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { registerContextEngineForOwner } from "../context-engine/registry.js"; -import { - getDetachedTaskLifecycleRuntimeRegistration, - registerDetachedTaskLifecycleRuntime, -} from "../tasks/detached-task-runtime-state.js"; -import { - getRegisteredCompactionProvider, - registerCompactionProvider as registerGlobalCompactionProvider, -} from "./compaction-provider.js"; -import { registerRegistryPluginInteractiveHandler } from "./interactive-registry.js"; +import { registerContextEngineInRegistry } from "../context-engine/registry.js"; +import { registerPluginInteractiveHandlerInRegistry } from "./interactive-registry.js"; import type { PluginRegistryState } from "./registry-state.js"; import type { PluginRecord } from "./registry-types.js"; import { defaultSlotIdForKey } from "./slots.js"; @@ -21,7 +13,7 @@ export function createCapabilityRegistrars(state: PluginRegistryState) { record: PluginRecord, runtime: Parameters[0], ) => { - const existing = getDetachedTaskLifecycleRuntimeRegistration(); + const existing = registry.detachedTaskRuntimes[0]; if (existing && existing.pluginId !== record.id) { pushDiagnostic({ level: "error", @@ -31,14 +23,19 @@ export function createCapabilityRegistrars(state: PluginRegistryState) { }); return; } - registerDetachedTaskLifecycleRuntime(record.id, runtime); + const next = { pluginId: record.id, runtime }; + if (existing) { + registry.detachedTaskRuntimes.splice(0, 1, next); + } else { + registry.detachedTaskRuntimes.push(next); + } }; const registerInteractiveHandler = ( record: PluginRecord, registration: Parameters[0], ) => { - const result = registerRegistryPluginInteractiveHandler(record.id, registration, { + const result = registerPluginInteractiveHandlerInRegistry(registry, record.id, registration, { pluginName: record.name, pluginRoot: record.rootDir, }); @@ -49,14 +46,7 @@ export function createCapabilityRegistrars(state: PluginRegistryState) { source: record.source, message: result.error ?? "interactive handler registration failed", }); - return; } - registry.interactiveHandlers.push({ - ...registration, - pluginId: record.id, - pluginName: record.name, - pluginRoot: record.rootDir, - }); }; const registerContextEngine = ( @@ -93,10 +83,16 @@ export function createCapabilityRegistrars(state: PluginRegistryState) { }); return; } - const result = registerContextEngineForOwner(normalizedId, factory, `plugin:${record.id}`, { - allowSameOwnerRefresh: true, - lifecycle: registrationMode === "full" ? "runtime" : "readOnlyDiscovery", - }); + const result = registerContextEngineInRegistry( + registry, + normalizedId, + factory, + `plugin:${record.id}`, + { + allowSameOwnerRefresh: true, + lifecycle: registrationMode === "full" ? "runtime" : "readOnlyDiscovery", + }, + ); if (!result.ok) { pushDiagnostic({ level: "error", @@ -137,7 +133,7 @@ export function createCapabilityRegistrars(state: PluginRegistryState) { }); return; } - const existing = getRegisteredCompactionProvider(id); + const existing = registry.compactionProviders.find((entry) => entry.provider.id === id); if (existing) { const ownerDetail = existing.ownerPluginId ? ` (owner: ${existing.ownerPluginId})` : ""; pushDiagnostic({ @@ -148,7 +144,7 @@ export function createCapabilityRegistrars(state: PluginRegistryState) { }); return; } - registerGlobalCompactionProvider(provider, { ownerPluginId: record.id }); + registry.compactionProviders.push({ provider, ownerPluginId: record.id }); }; return { diff --git a/src/plugins/registry-registrars-memory.ts b/src/plugins/registry-registrars-memory.ts index f29321144afc..550f63521c07 100644 --- a/src/plugins/registry-registrars-memory.ts +++ b/src/plugins/registry-registrars-memory.ts @@ -1,13 +1,3 @@ -import { - getRegisteredMemoryEmbeddingProvider, - registerMemoryEmbeddingProvider as registerGlobalMemoryEmbeddingProvider, -} from "./memory-embedding-providers.js"; -import { - registerMemoryCapability as registerGlobalMemoryCapability, - registerMemoryCorpusSupplement as registerGlobalMemoryCorpusSupplement, - registerMemoryPromptPreparation as registerGlobalMemoryPromptPreparation, - registerMemoryPromptSupplement as registerGlobalMemoryPromptSupplement, -} from "./memory-state.js"; import type { PluginRegistryState } from "./registry-state.js"; import type { PluginRecord } from "./registry-types.js"; import { hasKind } from "./slots.js"; @@ -37,7 +27,7 @@ export function createMemoryRegistrars(state: PluginRegistryState) { capability: Parameters[0], ) => { if (requireMemorySlot(record, "capability")) { - registerGlobalMemoryCapability(record.id, capability); + registry.memoryCapabilities.push({ pluginId: record.id, capability }); } }; @@ -54,7 +44,10 @@ export function createMemoryRegistrars(state: PluginRegistryState) { }); return; } - registerGlobalMemoryPromptSupplement(record.id, builder); + registry.memoryPromptSupplements = registry.memoryPromptSupplements.filter( + (entry) => entry.pluginId !== record.id, + ); + registry.memoryPromptSupplements.push({ pluginId: record.id, builder }); }; const registerMemoryPromptPreparation = ( @@ -70,14 +63,20 @@ export function createMemoryRegistrars(state: PluginRegistryState) { }); return; } - registerGlobalMemoryPromptPreparation(record.id, prepare); + registry.memoryPromptPreparations = registry.memoryPromptPreparations.filter( + (entry) => entry.pluginId !== record.id, + ); + registry.memoryPromptPreparations.push({ pluginId: record.id, prepare }); }; const registerMemoryCorpusSupplement = ( record: PluginRecord, supplement: Parameters[0], ) => { - registerGlobalMemoryCorpusSupplement(record.id, supplement); + registry.memoryCorpusSupplements = registry.memoryCorpusSupplements.filter( + (entry) => entry.pluginId !== record.id, + ); + registry.memoryCorpusSupplements.push({ pluginId: record.id, supplement }); }; const registerMemoryEmbeddingProvider = ( @@ -97,9 +96,11 @@ export function createMemoryRegistrars(state: PluginRegistryState) { }); return; } - const existing = getRegisteredMemoryEmbeddingProvider(adapter.id); + const existing = registry.memoryEmbeddingProviders.find( + (entry) => entry.provider.id === adapter.id, + ); if (existing) { - const ownerDetail = existing.ownerPluginId ? ` (owner: ${existing.ownerPluginId})` : ""; + const ownerDetail = existing.pluginId ? ` (owner: ${existing.pluginId})` : ""; pushDiagnostic({ level: "error", pluginId: record.id, @@ -108,7 +109,6 @@ export function createMemoryRegistrars(state: PluginRegistryState) { }); return; } - registerGlobalMemoryEmbeddingProvider(adapter, { ownerPluginId: record.id }); registry.memoryEmbeddingProviders.push({ pluginId: record.id, pluginName: record.name, diff --git a/src/plugins/registry-registrars-operations.ts b/src/plugins/registry-registrars-operations.ts index 887174785b9a..3020a6626cb1 100644 --- a/src/plugins/registry-registrars-operations.ts +++ b/src/plugins/registry-registrars-operations.ts @@ -16,12 +16,7 @@ import { NODE_SYSTEM_NOTIFY_COMMAND, NODE_SYSTEM_RUN_COMMANDS, } from "../infra/node-commands.js"; -import { - isReservedCommandName, - registerPluginCommand, - validatePluginCommandDefinition, -} from "./command-registration.js"; -import { pluginCommands } from "./command-registry-state.js"; +import { isReservedCommandName, registerPluginCommandInRegistry } from "./command-registration.js"; import type { PluginRegistryState } from "./registry-state.js"; import type { PluginRecord } from "./registry-types.js"; import type { @@ -60,7 +55,7 @@ function canClaimReservedCommandOwnership( } export function createOperationRegistrars(state: PluginRegistryState) { - const { registry, registryParams, pushDiagnostic } = state; + const { registry, pushDiagnostic } = state; const registerCli = ( record: PluginRecord, @@ -403,56 +398,36 @@ export function createOperationRegistrars(state: PluginRegistryState) { }); return; } - if (!registryParams.activateGlobalSideEffects) { - const validationError = validatePluginCommandDefinition(command, { + const { ownership: _ownership, ...commandForRegistration } = command; + void _ownership; + const result = registerPluginCommandInRegistry( + registry, + record.id, + allowReservedCommandNames ? commandForRegistration : command, + { + pluginName: record.name, + pluginRoot: record.rootDir, allowReservedCommandNames, + allowOwnerStatusExposure: canClaimReservedCommandOwnership(record), + }, + ); + if (!result.ok) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `command registration failed: ${result.error}`, }); - if (validationError) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: `command registration failed: ${validationError}`, - }); - return; - } - } else { - const { ownership: _ownership, ...commandForRegistration } = command; - void _ownership; - const result = registerPluginCommand( - record.id, - allowReservedCommandNames ? commandForRegistration : command, - { - pluginName: record.name, - pluginRoot: record.rootDir, - allowReservedCommandNames, - allowOwnerStatusExposure: canClaimReservedCommandOwnership(record), - }, - ); - if (!result.ok) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: `command registration failed: ${result.error}`, - }); - return; - } + return; + } + const registered = registry.commands.at(-1); + if (registered?.pluginId === record.id) { + registered.source = record.source; if (allowReservedCommandNames) { - const registeredCommand = pluginCommands.get(`/${name.toLowerCase()}`); - if (registeredCommand?.pluginId === record.id) { - registeredCommand.ownership = "reserved"; - } + registered.command.ownership = "reserved"; } } record.commands.push(name); - registry.commands.push({ - pluginId: record.id, - pluginName: record.name, - command, - source: record.source, - rootDir: record.rootDir, - }); }; return { diff --git a/src/plugins/registry-registrars-providers.ts b/src/plugins/registry-registrars-providers.ts index 9498289d277f..129f149419d0 100644 --- a/src/plugins/registry-registrars-providers.ts +++ b/src/plugins/registry-registrars-providers.ts @@ -1,14 +1,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { - getRegisteredAgentHarness, - registerAgentHarness as registerGlobalAgentHarness, -} from "../agents/harness/registry.js"; import type { AgentHarness } from "../agents/harness/types.js"; -import { - getRegisteredEmbeddingProvider, - registerEmbeddingProvider as registerGlobalEmbeddingProvider, - type EmbeddingProviderAdapter, -} from "./embedding-providers.js"; +import { getCoreEmbeddingProvider } from "./core-embedding-providers.js"; +import type { EmbeddingProviderAdapter } from "./embedding-providers.js"; import { normalizeRegisteredProvider } from "./provider-validation.js"; import type { PluginRegistryState } from "./registry-state.js"; import type { PluginRecord, PluginTextTransformsRegistration } from "./registry-types.js"; @@ -41,7 +34,6 @@ type PluginOwnedProviderRegistration = { export function createProviderRegistrars(state: PluginRegistryState) { const { registry, - registryParams, pushDiagnostic, registerSynthesizedTextModelCatalogProvider, registerSynthesizedMediaModelCatalogProvider, @@ -102,17 +94,9 @@ export function createProviderRegistrars(state: PluginRegistryState) { }); return; } - const existing = - registryParams.activateGlobalSideEffects === false - ? registry.agentHarnesses.find((entry) => entry.harness.id === id) - : getRegisteredAgentHarness(id); + const existing = registry.agentHarnesses.find((entry) => entry.harness.id === id); if (existing) { - const ownerPluginId = - "ownerPluginId" in existing - ? existing.ownerPluginId - : "pluginId" in existing - ? existing.pluginId - : undefined; + const ownerPluginId = "pluginId" in existing ? existing.pluginId : undefined; const ownerDetail = ownerPluginId ? ` (owner: ${ownerPluginId})` : ""; pushDiagnostic({ level: "error", @@ -123,9 +107,6 @@ export function createProviderRegistrars(state: PluginRegistryState) { return; } const normalizedHarness = { ...harness, id, pluginId: harness.pluginId ?? record.id }; - if (registryParams.activateGlobalSideEffects !== false) { - registerGlobalAgentHarness(normalizedHarness, { ownerPluginId: record.id }); - } record.agentHarnessIds.push(id); registry.agentHarnesses.push({ pluginId: record.id, @@ -213,10 +194,9 @@ export function createProviderRegistrars(state: PluginRegistryState) { }); return; } + const coreEntry = getCoreEmbeddingProvider(id); const existing = - registryParams.activateGlobalSideEffects === false - ? registry.embeddingProviders.find((entry) => entry.provider.id === id) - : getRegisteredEmbeddingProvider(id); + coreEntry ?? registry.embeddingProviders.find((entry) => entry.provider.id === id); if (existing) { const ownerPluginId = "ownerPluginId" in existing @@ -233,9 +213,6 @@ export function createProviderRegistrars(state: PluginRegistryState) { }); return; } - if (registryParams.activateGlobalSideEffects !== false) { - registerGlobalEmbeddingProvider(adapter, { ownerPluginId: record.id }); - } registry.embeddingProviders.push({ pluginId: record.id, pluginName: record.name, diff --git a/src/plugins/registry-registrars-tools-hooks.ts b/src/plugins/registry-registrars-tools-hooks.ts index 2a2fbcdf7e4f..8c69b3169890 100644 --- a/src/plugins/registry-registrars-tools-hooks.ts +++ b/src/plugins/registry-registrars-tools-hooks.ts @@ -4,7 +4,7 @@ import { uniqueValues, } from "@openclaw/normalization-core/string-normalization"; import type { AnyAgentTool } from "../agents/tools/common.js"; -import type { InternalHookHandler } from "../hooks/internal-hooks.js"; +import type { InternalHookHandler } from "../hooks/internal-hook-types.js"; import type { HookEntry } from "../hooks/types.js"; import { withTimeout } from "../utils/with-timeout.js"; import type { AgentToolResultMiddleware } from "./agent-tool-result-middleware-types.js"; @@ -17,10 +17,6 @@ import { import { CODEX_APP_SERVER_EXTENSION_RUNTIME_ID } from "./codex-app-server-extension-factory.js"; import type { CodexAppServerExtensionFactory } from "./codex-app-server-extension-types.js"; import { getPluginCompatRecord } from "./compat/registry.js"; -import { - replaceLegacyPluginInternalHook, - type LegacyPluginInternalHookRegistration, -} from "./legacy-internal-hook-state.js"; import { resolveTypedHookTimeoutMs, type PluginRegistryState, @@ -100,13 +96,8 @@ function canRegisterInstalledTrustedHook(record: PluginRecord): boolean { } export function createToolHookRegistrars(state: PluginRegistryState) { - const { - registry, - registryParams, - pluginHookRollback, - pluginsWithChannelRegistrationConflict, - pushDiagnostic, - } = state; + const { registry, registryParams, pluginsWithChannelRegistrationConflict, pushDiagnostic } = + state; const registerCodexAppServerExtensionFactory = ( record: PluginRecord, @@ -373,14 +364,9 @@ export function createToolHookRegistrars(state: PluginRegistryState) { source: record.source, }); const hookSystemEnabled = config?.hooks?.internal?.enabled !== false; - if ( - !registryParams.activateGlobalSideEffects || - !hookSystemEnabled || - opts?.register === false - ) { + if (!hookSystemEnabled || opts?.register === false) { return; } - const nextRegistrations: LegacyPluginInternalHookRegistration[] = []; for (const event of normalizedEvents) { const wrappedHandler: typeof handler = async (evt) => { const context = evt.context; @@ -398,12 +384,13 @@ export function createToolHookRegistrars(state: PluginRegistryState) { } } }; - nextRegistrations.push({ event, handler: wrappedHandler }); + registry.legacyInternalHooks.push({ + pluginId: record.id, + name: hookName, + event, + handler: wrappedHandler, + }); } - const previousRegistrations = replaceLegacyPluginInternalHook(hookName, nextRegistrations); - const rollbackEntries = pluginHookRollback.get(record.id) ?? []; - rollbackEntries.push({ name: hookName, previousRegistrations }); - pluginHookRollback.set(record.id, rollbackEntries); }; const registerTypedHook = ( @@ -509,20 +496,11 @@ export function createToolHookRegistrars(state: PluginRegistryState) { } as TypedPluginHookRegistration); }; - const rollbackHooks = (pluginId: string) => { - const hookRollbackEntries = pluginHookRollback.get(pluginId) ?? []; - for (const entry of hookRollbackEntries.toReversed()) { - replaceLegacyPluginInternalHook(entry.name, entry.previousRegistrations); - } - pluginHookRollback.delete(pluginId); - }; - return { registerCodexAppServerExtensionFactory, registerAgentToolResultMiddleware, registerTool, registerHook, registerTypedHook, - rollbackHooks, }; } diff --git a/src/plugins/registry-state.ts b/src/plugins/registry-state.ts index bcb61b483a82..572a81712e81 100644 --- a/src/plugins/registry-state.ts +++ b/src/plugins/registry-state.ts @@ -1,4 +1,3 @@ -import type { LegacyPluginInternalHookRegistration } from "./legacy-internal-hook-state.js"; import type { PluginDiagnostic } from "./manifest-types.js"; import { createModelCatalogRegistrationHandlers } from "./model-catalog-registration.js"; import { createEmptyPluginRegistry } from "./registry-empty.js"; @@ -79,13 +78,6 @@ export function createPluginRegistryState(registryParams: PluginRegistryParams) registryParams, coreGatewayMethods: new Set(coreGatewayMethodNames), getHostCronService: () => registryParams.hostServices?.cron, - pluginHookRollback: new Map< - string, - Array<{ - name: string; - previousRegistrations: LegacyPluginInternalHookRegistration[]; - }> - >(), pluginsWithChannelRegistrationConflict: new Set(), pluginSideEffectGuards: new Map>(), pushDiagnostic, diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index c7dc6c9d5f4f..160bf97fbbf5 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -2,8 +2,10 @@ import type { AgentHarness } from "../agents/harness/types.js"; import type { GatewayMethodDescriptor } from "../gateway/methods/descriptor.js"; import type { GatewayRequestHandlers } from "../gateway/server-methods/types.js"; +import type { InternalHookHandler } from "../hooks/internal-hook-types.js"; import type { HookEntry } from "../hooks/types.js"; import type { JsonSchemaObject } from "../shared/json-schema.types.js"; +import type { DetachedTaskLifecycleRuntimeRegistration } from "../tasks/detached-task-runtime-contract.js"; import type { AgentToolResultMiddleware, AgentToolResultMiddlewareRuntime, @@ -12,7 +14,7 @@ import type { import type { CodexAppServerExtensionFactory } from "./codex-app-server-extension-types.js"; import type { PluginCompatCode } from "./compat/registry.js"; import type { PluginActivationSource } from "./config-state.js"; -import type { EmbeddingProviderAdapter } from "./embedding-providers.js"; +import type { EmbeddingProviderAdapter } from "./embedding-provider-types.js"; import type { PluginAgentEventSubscriptionRegistration, PluginControlUiDescriptor, @@ -37,8 +39,18 @@ import type { PluginManifestDashboardDataBinding, PluginManifestMcpServer, } from "./manifest.js"; -import type { MemoryEmbeddingProviderAdapter } from "./memory-embedding-providers.js"; import type { PluginKind } from "./plugin-kind.types.js"; +import type { + ContextEngineRegistration, + MemoryCorpusSupplementRegistration, + MemoryEmbeddingProviderAdapter, + MemoryPluginCapabilityRegistration, + MemoryPromptPreparationRegistration, + MemoryPromptSupplementRegistration, + RegisteredCompactionProvider, + ResolvedPluginRuntimeArtifact, + SessionDiscussionProvider, +} from "./registry-contribution-types.js"; import type { PluginRuntime } from "./runtime/types.js"; import type { SessionCatalogProvider } from "./session-catalog.js"; import type { PluginDependencyStatus } from "./status-dependencies-core.js"; @@ -337,6 +349,19 @@ export type PluginCommandRegistration = { command: OpenClawPluginCommandDefinition; source: string; rootDir?: string; + trustedOwnerStatusExposure?: true; +}; + +type PluginLegacyInternalHookRegistration = { + pluginId: string; + name: string; + event: string; + handler: InternalHookHandler; +}; + +type PluginSessionDiscussionRegistration = { + pluginId: string; + provider: SessionDiscussionProvider; }; type PluginInteractiveHandlerRegistryRegistration = PluginInteractiveHandlerRegistration & { @@ -515,6 +540,17 @@ export type PluginRegistry = { agentToolResultMiddlewares: PluginAgentToolResultMiddlewareRegistration[]; memoryEmbeddingProviders: PluginMemoryEmbeddingProviderRegistration[]; agentHarnesses: PluginAgentHarnessRegistration[]; + pluginRuntimeArtifacts: Map; + compactionProviders: RegisteredCompactionProvider[]; + detachedTaskRuntimes: DetachedTaskLifecycleRuntimeRegistration[]; + legacyInternalHooks: PluginLegacyInternalHookRegistration[]; + memoryCapabilities: MemoryPluginCapabilityRegistration[]; + memoryCorpusSupplements: MemoryCorpusSupplementRegistration[]; + memoryPromptPreparations: MemoryPromptPreparationRegistration[]; + memoryPromptSupplements: MemoryPromptSupplementRegistration[]; + sessionDiscussionProviders: Map; + contextEngines: Map; + commandRegistryLocked: boolean; gatewayHandlers: GatewayRequestHandlers; gatewayMethodDescriptors: GatewayMethodDescriptor[]; dashboardDataBindings: Map; diff --git a/src/plugins/registry.dual-kind-memory-gate.test.ts b/src/plugins/registry.dual-kind-memory-gate.test.ts index 3ef7bba7eb82..553b5e0d627c 100644 --- a/src/plugins/registry.dual-kind-memory-gate.test.ts +++ b/src/plugins/registry.dual-kind-memory-gate.test.ts @@ -4,20 +4,10 @@ import { registerTestPlugin, registerVirtualTestPlugin, } from "openclaw/plugin-sdk/plugin-test-contracts"; -import { afterEach, describe, expect, it } from "vitest"; -import { clearMemoryEmbeddingProviders } from "./memory-embedding-providers.js"; -import { - clearMemoryPluginState, - getMemoryCapabilityRegistration, - getMemoryRuntime, -} from "./memory-state.test-fixtures.js"; +import { describe, expect, it } from "vitest"; +import { resolveMemoryCapabilityRegistration } from "./memory-state.js"; import { createPluginRecord } from "./status.test-fixtures.js"; -afterEach(() => { - clearMemoryPluginState(); - clearMemoryEmbeddingProviders(); -}); - function createStubMemoryRuntime() { return { async getMemorySearchManager() { @@ -29,8 +19,10 @@ function createStubMemoryRuntime() { }; } -function requireMemoryRuntime() { - const runtime = getMemoryRuntime(); +function requireMemoryRuntime( + registry: ReturnType["registry"], +) { + const runtime = registry.registry.memoryCapabilities.at(-1)?.capability.runtime; if (!runtime) { throw new Error("expected memory runtime registration"); } @@ -51,8 +43,7 @@ describe("dual-kind memory registration gate", () => { api.registerMemoryCapability({ runtime: createStubMemoryRuntime() }); }, }); - - expect(getMemoryRuntime()).toBeUndefined(); + expect(registry.registry.memoryCapabilities).toStrictEqual([]); expect(registry.registry.diagnostics).toEqual([ { pluginId: "dual-plugin", @@ -80,9 +71,11 @@ describe("dual-kind memory registration gate", () => { api.registerMemoryCapability({ runtime: createStubMemoryRuntime() }); }, }); - expect( - requireMemoryRuntime().resolveMemoryBackendConfig({ cfg: {} as never, agentId: "main" }), + requireMemoryRuntime(registry).resolveMemoryBackendConfig({ + cfg: {} as never, + agentId: "main", + }), ).toEqual({ backend: "builtin" }); expect( registry.registry.diagnostics.filter( @@ -104,9 +97,11 @@ describe("dual-kind memory registration gate", () => { api.registerMemoryCapability({ runtime: createStubMemoryRuntime() }); }, }); - expect( - requireMemoryRuntime().resolveMemoryBackendConfig({ cfg: {} as never, agentId: "main" }), + requireMemoryRuntime(registry).resolveMemoryBackendConfig({ + cfg: {} as never, + agentId: "main", + }), ).toEqual({ backend: "builtin" }); }); @@ -131,16 +126,93 @@ describe("dual-kind memory registration gate", () => { }); }, }); - - expect(getMemoryCapabilityRegistration()).toEqual({ - pluginId: "dual-plugin", - capability: { - runtime, - promptBuilder, + expect(registry.registry.memoryCapabilities).toEqual([ + { + pluginId: "dual-plugin", + capability: { + runtime, + promptBuilder, + }, }, - }); + ]); expect( - requireMemoryRuntime().resolveMemoryBackendConfig({ cfg: {} as never, agentId: "main" }), + requireMemoryRuntime(registry).resolveMemoryBackendConfig({ + cfg: {} as never, + agentId: "main", + }), ).toEqual({ backend: "builtin" }); }); + + it("preserves an earlier memory capability when an artifact bridge fails", () => { + const { config, registry } = createPluginRegistryFixture(); + const runtime = createStubMemoryRuntime(); + const flushPlanResolver = () => null; + const coreRecord = createPluginRecord({ + id: "memory-core", + name: "Memory Core", + kind: "memory", + }); + registerTestPlugin({ + registry, + config, + record: coreRecord, + register(api) { + api.registerMemoryCapability({ runtime, flushPlanResolver }); + }, + }); + + const bridgeRecord = createPluginRecord({ + id: "memory-bridge", + name: "Memory Bridge", + kind: "memory", + }); + expect(() => + registerTestPlugin({ + registry, + config, + record: bridgeRecord, + register(api) { + api.registerMemoryCapability({ + publicArtifacts: { listArtifacts: async () => [] }, + }); + throw new Error("bridge failed"); + }, + }), + ).toThrow("bridge failed"); + registry.rollbackPluginGlobalSideEffects(bridgeRecord.id, bridgeRecord); + + expect(registry.registry.memoryCapabilities).toEqual([ + { pluginId: "memory-core", capability: { runtime, flushPlanResolver } }, + ]); + expect(resolveMemoryCapabilityRegistration(registry.registry.memoryCapabilities)).toEqual({ + pluginId: "memory-core", + capability: { runtime, flushPlanResolver }, + }); + }); + + it("layers same-plugin public artifacts over its runtime capability", () => { + const { config, registry } = createPluginRegistryFixture(); + const runtime = createStubMemoryRuntime(); + const flushPlanResolver = () => null; + const record = createPluginRecord({ id: "memory-core", name: "Memory Core", kind: "memory" }); + + registerTestPlugin({ + registry, + config, + record, + register(api) { + api.registerMemoryCapability({ runtime, flushPlanResolver }); + api.registerMemoryCapability({ publicArtifacts: { listArtifacts: async () => [] } }); + }, + }); + + expect(resolveMemoryCapabilityRegistration(registry.registry.memoryCapabilities)).toEqual({ + pluginId: "memory-core", + capability: { + runtime, + flushPlanResolver, + publicArtifacts: expect.any(Object), + }, + }); + }); }); diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 83a99840b594..b403c349a317 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -1,14 +1,12 @@ /** In-memory plugin registry builder and mutation API for plugin runtime registration. */ -import { clearContextEnginesForOwner } from "../context-engine/registry.js"; -import { clearPluginCommandsForPlugin } from "./command-registry-state.js"; import { cleanupPluginSessionSchedulerJobs } from "./host-hook-runtime.js"; -import { clearPluginInteractiveHandlersForPlugin } from "./interactive-registry.js"; import { createPluginApiFactory } from "./registry-api.js"; import { createPluginRegistrars } from "./registry-registrars.js"; import { createPluginRuntimeResolver } from "./registry-runtime.js"; import { createPluginRegistryState } from "./registry-state.js"; import type { PluginHttpRouteRegistration as RegistryTypesPluginHttpRouteRegistration, + PluginRecord as RegistryPluginRecord, PluginRegistryParams, } from "./registry-types.js"; import type { OpenClawPluginGatewayRuntimeScopeSurface } from "./types.js"; @@ -20,6 +18,17 @@ export type PluginHttpRouteRegistration = RegistryTypesPluginHttpRouteRegistrati export type { PluginRecord, PluginRegistry } from "./registry-types.js"; export { createEmptyPluginRegistry } from "./registry-empty.js"; +function clonePluginRecord(record: RegistryPluginRecord): RegistryPluginRecord { + return Object.fromEntries( + Object.entries(record).map(([key, value]) => [key, Array.isArray(value) ? [...value] : value]), + ) as RegistryPluginRecord; +} + +function restorePluginRecord(record: RegistryPluginRecord, snapshot: RegistryPluginRecord): void { + Object.keys(record).forEach((key) => Reflect.deleteProperty(record, key)); + Object.assign(record, snapshot); +} + /** * Compose the registry state, domain registrars, scoped runtime, and plugin API. * Domain modules own validation and mutation; this function owns lifecycle wiring only. @@ -28,30 +37,65 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { const state = createPluginRegistryState(registryParams); const registrars = createPluginRegistrars(state); const runtimeResolver = createPluginRuntimeResolver(state); - const { createApi, deactivatePluginSideEffectGuards } = createPluginApiFactory( + const { createApi: createPluginApi, deactivatePluginSideEffectGuards } = createPluginApiFactory( state, registrars, runtimeResolver, ); + const registrationRecordSnapshots = new WeakMap(); + const createApi: typeof createPluginApi = (record, params) => { + registrationRecordSnapshots.set(record, clonePluginRecord(record)); + return createPluginApi(record, params); + }; - const rollbackPluginGlobalSideEffects = (pluginId: string) => { + const rollbackPluginGlobalSideEffects = (pluginId: string, record?: RegistryPluginRecord) => { deactivatePluginSideEffectGuards(pluginId); - if (registryParams.activateGlobalSideEffects === false) { - return; - } - clearPluginCommandsForPlugin(pluginId); - clearPluginInteractiveHandlersForPlugin(pluginId); - clearContextEnginesForOwner(`plugin:${pluginId}`); - registrars.rollbackHooks(pluginId); - - // Roll back live session-scheduler records created during a failed registration. - // registry.sessionSchedulerJobs metadata is restored by the snapshot above; this - // removes the module-global live records and invokes their cleanup callbacks so - // external plugin-owned work is cancelled at the rollback boundary. const schedulerRecords = state.registry.sessionSchedulerJobs.filter( (r) => r.pluginId === pluginId, ); - if (schedulerRecords.length > 0) { + const gatewayMethods = state.registry.gatewayMethodDescriptors + .filter((entry) => entry.owner.kind === "plugin" && entry.owner.pluginId === pluginId) + .map((entry) => entry.name); + for (const value of Object.values(state.registry)) { + if (Array.isArray(value)) { + for (let index = value.length - 1; index >= 0; index -= 1) { + const entry = value[index] as + | { pluginId?: string; ownerPluginId?: string; owner?: { pluginId?: string } } + | undefined; + if ( + entry?.pluginId === pluginId || + entry?.ownerPluginId === pluginId || + entry?.owner?.pluginId === pluginId + ) { + value.splice(index, 1); + } + } + } else if (value instanceof Map) { + for (const [key, entry] of value) { + const owner = entry as { pluginId?: string; owner?: string } | undefined; + if (owner?.pluginId === pluginId || owner?.owner === `plugin:${pluginId}`) { + value.delete(key); + } + } + } + } + for (const method of gatewayMethods) { + delete state.registry.gatewayHandlers[method]; + } + for (const key of state.registry.pluginRuntimeArtifacts.keys()) { + if ((JSON.parse(key) as unknown[])[0] === pluginId) { + state.registry.pluginRuntimeArtifacts.delete(key); + } + } + const recordSnapshot = record ? registrationRecordSnapshots.get(record) : undefined; + if (record && recordSnapshot) { + restorePluginRecord(record, recordSnapshot); + registrationRecordSnapshots.delete(record); + } + + // Scheduler jobs still have a live process registration; contribution rollback + // drops registry rows above, then cancels external work created before register threw. + if (registryParams.activateGlobalSideEffects !== false && schedulerRecords.length > 0) { void cleanupPluginSessionSchedulerJobs({ pluginId, reason: "disable", diff --git a/src/plugins/runtime-state.ts b/src/plugins/runtime-state.ts index 8c84633dc0da..188ec0d3f8b3 100644 --- a/src/plugins/runtime-state.ts +++ b/src/plugins/runtime-state.ts @@ -25,6 +25,7 @@ export type RegistryState = { workspaceDir: string | null; runtimeSubagentMode: "default" | "explicit" | "gateway-bindable"; importedPluginIds: Set; + registrationContext?: { registry: PluginRegistry; pluginId: string }; }; type GlobalRegistryState = typeof globalThis & { diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index 3d235c3e3a80..8333bdb5fa73 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -202,22 +202,90 @@ export function setActivePluginRegistry( runtimeSubagentMode: "default" | "explicit" | "gateway-bindable" = "default", workspaceDir?: string, ) { - const previousRegistry = asPluginRegistry(state.activeRegistry); - state.activeRegistry = registry; - markPluginRegistryActive(registry); - state.activeVersion += 1; - syncTrackedSurface(state.httpRoute, registry, true); - syncTrackedSurface(state.channel, registry, true); - settlePreparedMessageToolCatalog( + installActivePluginRegistry({ registry, - state.channel.pinned ? state.activeVersion : state.channel.version, - ); - syncTrackedSurface(state.sessionExtension, registry, true); - state.key = cacheKey ?? null; - state.workspaceDir = workspaceDir ?? null; - state.runtimeSubagentMode = runtimeSubagentMode; + key: cacheKey ?? null, + runtimeSubagentMode, + workspaceDir: workspaceDir ?? null, + }); +} + +export function stageActivePluginRegistry( + registry: PluginRegistry, + cacheKey: string | null, + runtimeSubagentMode: RegistryState["runtimeSubagentMode"], + workspaceDir?: string, +): void { + installActivePluginRegistry({ + registry, + key: cacheKey, + runtimeSubagentMode, + workspaceDir: workspaceDir ?? null, + retirePrevious: false, + }); +} + +export function commitStagedPluginRegistry( + previousRegistry: PluginRegistry | null, + registry: PluginRegistry, +): void { + if (state.activeRegistry !== registry || !retirePluginRegistryIfUnused(previousRegistry)) { + return; + } + cleanupRetiredPluginHostRegistry(previousRegistry!); +} + +export function captureActivePluginRegistrySnapshot() { + return { + activeRegistry: state.activeRegistry, + key: state.key, + runtimeSubagentMode: state.runtimeSubagentMode, + workspaceDir: state.workspaceDir, + }; +} + +export function restoreActivePluginRegistrySnapshot( + snapshot: ReturnType, +): void { + installActivePluginRegistry({ + registry: snapshot.activeRegistry, + key: snapshot.key, + runtimeSubagentMode: snapshot.runtimeSubagentMode, + workspaceDir: snapshot.workspaceDir, + }); +} + +function installActivePluginRegistry(params: { + registry: PluginRegistry | null; + key: string | null; + runtimeSubagentMode: RegistryState["runtimeSubagentMode"]; + workspaceDir: string | null; + retirePrevious?: boolean; +}): void { + const previousRegistry = asPluginRegistry(state.activeRegistry); + state.activeRegistry = params.registry; + markPluginRegistryActive(params.registry); + state.activeVersion += 1; + syncTrackedSurface(state.httpRoute, params.registry, true); + syncTrackedSurface(state.channel, params.registry, true); + if (params.registry) { + settlePreparedMessageToolCatalog( + params.registry, + state.channel.pinned ? state.activeVersion : state.channel.version, + ); + } else { + settlePreparedMessageToolCatalog(); + } + syncTrackedSurface(state.sessionExtension, params.registry, true); + state.key = params.key; + state.workspaceDir = params.workspaceDir; + state.runtimeSubagentMode = params.runtimeSubagentMode; syncPluginAgentEventBridge(); - if (!previousRegistry || previousRegistry === registry) { + if ( + params.retirePrevious === false || + !previousRegistry || + previousRegistry === params.registry + ) { return; } if (!retirePluginRegistryIfUnused(previousRegistry)) { @@ -235,6 +303,9 @@ export function getActivePluginRegistryWorkspaceDir(): string | undefined { } export function requireActivePluginRegistry(): PluginRegistry { + if (state.registrationContext) { + return state.registrationContext.registry; + } if (!state.activeRegistry) { state.activeRegistry = createEmptyPluginRegistry(); markPluginRegistryActive(state.activeRegistry); @@ -247,6 +318,41 @@ export function requireActivePluginRegistry(): PluginRegistry { return asPluginRegistry(state.activeRegistry)!; } +/** Binds unchanged direct SDK facades to the registry currently running synchronous register(). */ +export function withPluginRegistrationContext( + registry: PluginRegistry, + pluginId: string, + run: () => T, +): T { + const previous = state.registrationContext; + state.registrationContext = { registry, pluginId }; + try { + return run(); + } finally { + state.registrationContext = previous; + } +} + +export function getPluginRegistrationContext() { + return state.registrationContext; +} + +/** Keeps direct registration facades owned by the plugin whose synchronous register() is running. */ +export function resolveDirectPluginRegistrationOwner(ownerPluginId?: string): string | undefined { + return state.registrationContext?.pluginId ?? ownerPluginId; +} + +/** A failed plugin must not displace an earlier plugin's builder-local contribution. */ +export function assertDirectPluginRegistrationReplacement( + existingOwnerPluginId: string | undefined, + capability: string, +): void { + const pluginId = state.registrationContext?.pluginId; + if (pluginId && existingOwnerPluginId !== pluginId) { + throw new Error(`${capability} already registered by ${existingOwnerPluginId || "core"}`); + } +} + export function pinActivePluginHttpRouteRegistry(registry: PluginRegistry) { const previousRegistry = asPluginRegistry(state.httpRoute.registry); installSurfaceRegistry(state.httpRoute, registry, true); @@ -453,6 +559,7 @@ export function listImportedRuntimePluginIds(): string[] { } export function resetPluginRuntimeStateForTest(): void { + state.registrationContext = undefined; state.activeRegistry = null; state.activeVersion += 1; installSurfaceRegistry(state.httpRoute, null, false); diff --git a/src/plugins/session-discussion-registry.test.ts b/src/plugins/session-discussion-registry.test.ts index e6d770558ba3..0ccdbf2817ff 100644 --- a/src/plugins/session-discussion-registry.test.ts +++ b/src/plugins/session-discussion-registry.test.ts @@ -6,7 +6,9 @@ vi.mock("../logging/subsystem.js", () => ({ createSubsystemLogger: () => ({ warn: mocks.warn }), })); +import { requireActivePluginChannelRegistry } from "./runtime.js"; import { + clearSessionDiscussionProvider, getSessionDiscussionProvider, registerSessionDiscussionProvider, type SessionDiscussionProvider, @@ -23,9 +25,10 @@ function provider(id: string): SessionDiscussionProvider { describe("session discussion provider registry", () => { beforeEach(() => { mocks.warn.mockClear(); + clearSessionDiscussionProvider(); }); - it("returns the registered provider and warns when replacing it", () => { + it("keeps providers owner-keyed without silently displacing the first owner", () => { const first = provider("first"); const second = provider("second"); @@ -34,9 +37,13 @@ describe("session discussion provider registry", () => { expect(mocks.warn).not.toHaveBeenCalled(); registerSessionDiscussionProvider(second); - expect(getSessionDiscussionProvider()).toBe(second); + expect(getSessionDiscussionProvider()).toBe(first); + expect([...requireActivePluginChannelRegistry().sessionDiscussionProviders.keys()]).toEqual([ + "first", + "second", + ]); expect(mocks.warn).toHaveBeenCalledWith( - "replacing session discussion provider first with second", + "session discussion provider second registered alongside first; retaining first as the default", ); }); }); diff --git a/src/plugins/session-discussion-registry.ts b/src/plugins/session-discussion-registry.ts index 2bdb10bd5ed1..5cb9d0be0986 100644 --- a/src/plugins/session-discussion-registry.ts +++ b/src/plugins/session-discussion-registry.ts @@ -1,53 +1,41 @@ import { createSubsystemLogger } from "../logging/subsystem.js"; +import type { SessionDiscussionProvider } from "./registry-contribution-types.js"; +import { + getActivePluginChannelRegistry, + getPluginRegistrationContext, + requireActivePluginChannelRegistry, +} from "./runtime.js"; -export type SessionDiscussionState = "none" | "available" | "open"; -export type SessionDiscussionInfo = { - state: SessionDiscussionState; - embedUrl?: string; - openUrl?: string; -}; -export type SessionDiscussionProvider = { - id: string; - info(params: { sessionKey: string }): Promise; - open(params: { sessionKey: string }): Promise; -}; +export type { + SessionDiscussionInfo, + SessionDiscussionProvider, + SessionDiscussionState, +} from "./registry-contribution-types.js"; const log = createSubsystemLogger("plugins/session-discussion"); -const SESSION_DISCUSSION_REGISTRY = Symbol.for("openclaw.sessionDiscussionRegistry"); - -type SessionDiscussionRegistry = { - provider?: SessionDiscussionProvider; -}; - -function getRegistry(): SessionDiscussionRegistry { - // The public SDK entrypoint and lazy gateway chunks may load separately; - // a global symbol keeps them on the same process-wide provider slot. - const globalStore = globalThis as typeof globalThis & { - [SESSION_DISCUSSION_REGISTRY]?: SessionDiscussionRegistry; - }; - return (globalStore[SESSION_DISCUSSION_REGISTRY] ??= {}); -} - export function registerSessionDiscussionProvider(provider: SessionDiscussionProvider): void { - const registry = getRegistry(); - if (registry.provider) { - log.warn(`replacing session discussion provider ${registry.provider.id} with ${provider.id}`); + const context = getPluginRegistrationContext(); + const registry = context?.registry ?? requireActivePluginChannelRegistry(); + const pluginId = context?.pluginId ?? provider.id; + const existing = registry.sessionDiscussionProviders.get(pluginId); + if (existing) { + log.warn(`replacing session discussion provider ${existing.provider.id} with ${provider.id}`); + } else { + const selected = registry.sessionDiscussionProviders.values().next().value; + if (selected) { + log.warn( + `session discussion provider ${provider.id} registered alongside ${selected.provider.id}; retaining ${selected.provider.id} as the default`, + ); + } } - registry.provider = provider; + registry.sessionDiscussionProviders.set(pluginId, { pluginId, provider }); } export function getSessionDiscussionProvider(): SessionDiscussionProvider | undefined { - return getRegistry().provider; + return getActivePluginChannelRegistry()?.sessionDiscussionProviders.values().next().value + ?.provider; } -/** Clears the process-wide provider before a new active plugin registry is assembled. */ export function clearSessionDiscussionProvider(): void { - getRegistry().provider = undefined; -} - -/** Restores the provider when a plugin registration transaction does not become active. */ -export function restoreSessionDiscussionProvider( - provider: SessionDiscussionProvider | undefined, -): void { - getRegistry().provider = provider; + requireActivePluginChannelRegistry().sessionDiscussionProviders.clear(); } diff --git a/src/tasks/detached-task-runtime-state.ts b/src/tasks/detached-task-runtime-state.ts index f58800b27191..dd061324dccd 100644 --- a/src/tasks/detached-task-runtime-state.ts +++ b/src/tasks/detached-task-runtime-state.ts @@ -1,52 +1,48 @@ +import { + assertDirectPluginRegistrationReplacement, + requireActivePluginRegistry, + resolveDirectPluginRegistrationOwner, +} from "../plugins/runtime.js"; // Tracks detached task runtime state and spawned process handles. import type { DetachedTaskLifecycleRuntime, DetachedTaskLifecycleRuntimeRegistration, } from "./detached-task-runtime-contract.js"; -// Process-wide detached task runtime registration, owned by plugin activation. -let detachedTaskLifecycleRuntimeRegistration: DetachedTaskLifecycleRuntimeRegistration | undefined; +const getRegistrations = () => requireActivePluginRegistry().detachedTaskRuntimes; /** Registers the active detached task lifecycle runtime implementation. */ export function registerDetachedTaskLifecycleRuntime( - pluginId: string, + requestedPluginId: string, runtime: DetachedTaskLifecycleRuntime, ): void { - detachedTaskLifecycleRuntimeRegistration = { - pluginId, - runtime, - }; + const registrations = getRegistrations(); + const pluginId = resolveDirectPluginRegistrationOwner(requestedPluginId) ?? requestedPluginId; + if (registrations[0]) { + assertDirectPluginRegistrationReplacement(registrations[0].pluginId, "detached task runtime"); + } + registrations.splice(0, registrations.length, { pluginId, runtime }); } export function getDetachedTaskLifecycleRuntimeRegistration(): | DetachedTaskLifecycleRuntimeRegistration | undefined { - if (!detachedTaskLifecycleRuntimeRegistration) { + const registration = getRegistrations()[0]; + if (!registration) { return undefined; } return { - pluginId: detachedTaskLifecycleRuntimeRegistration.pluginId, - runtime: detachedTaskLifecycleRuntimeRegistration.runtime, + pluginId: registration.pluginId, + runtime: registration.runtime, }; } export function getRegisteredDetachedTaskLifecycleRuntime(): | DetachedTaskLifecycleRuntime | undefined { - return detachedTaskLifecycleRuntimeRegistration?.runtime; -} - -export function restoreDetachedTaskLifecycleRuntimeRegistration( - registration: DetachedTaskLifecycleRuntimeRegistration | undefined, -): void { - detachedTaskLifecycleRuntimeRegistration = registration - ? { - pluginId: registration.pluginId, - runtime: registration.runtime, - } - : undefined; + return getRegistrations()[0]?.runtime; } export function clearDetachedTaskLifecycleRuntimeRegistration(): void { - detachedTaskLifecycleRuntimeRegistration = undefined; + getRegistrations().length = 0; } diff --git a/src/tasks/detached-task-runtime.test.ts b/src/tasks/detached-task-runtime.test.ts index 6be1ba62f78a..89b802105d6b 100644 --- a/src/tasks/detached-task-runtime.test.ts +++ b/src/tasks/detached-task-runtime.test.ts @@ -1,5 +1,7 @@ // Covers detached task runtime spawning, events, and cancellation handling. import { afterEach, describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; +import { withPluginRegistrationContext } from "../plugins/runtime.js"; import { getDetachedTaskLifecycleRuntimeRegistration, registerDetachedTaskLifecycleRuntime, @@ -345,6 +347,35 @@ describe("detached-task-runtime", () => { expect(getDetachedTaskLifecycleRuntime()).toBe(runtime); }); + it("replaces the active detached runtime registration", () => { + const first = { ...getDetachedTaskLifecycleRuntime() }; + const second = { ...getDetachedTaskLifecycleRuntime() }; + + registerDetachedTaskLifecycleRuntime("first", first); + registerDetachedTaskLifecycleRuntime("second", second); + + expect(getDetachedTaskLifecycleRuntimeRegistration()).toEqual({ + pluginId: "second", + runtime: second, + }); + }); + + it("does not let a registering plugin displace another owner's detached runtime", () => { + const building = createEmptyPluginRegistry(); + const original = { ...getDetachedTaskLifecycleRuntime() }; + const replacement = { ...getDetachedTaskLifecycleRuntime() }; + building.detachedTaskRuntimes.push({ pluginId: "first-plugin", runtime: original }); + + expect(() => + withPluginRegistrationContext(building, "failing-plugin", () => { + registerDetachedTaskLifecycleRuntime("spoofed-plugin", replacement); + }), + ).toThrow("detached task runtime already registered by first-plugin"); + expect(building.detachedTaskRuntimes).toEqual([ + { pluginId: "first-plugin", runtime: original }, + ]); + }); + it("falls back to legacy complete and fail hooks when a runtime has no finalizer", () => { const defaultRuntime = getDetachedTaskLifecycleRuntime(); const completeTaskRunByRunIdSpy = vi.fn(