From 242811248ba369cec2f5ecb2df1e15bbf7f04ee2 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 5 Jun 2026 13:50:45 +0200 Subject: [PATCH] fix(plugins): snapshot session extension registrations --- ...n-extensions-registration.contract.test.ts | 122 ++++++++++++++++++ src/plugins/registry.ts | 94 ++++++++++++-- 2 files changed, 207 insertions(+), 9 deletions(-) create mode 100644 src/plugins/contracts/session-extensions-registration.contract.test.ts diff --git a/src/plugins/contracts/session-extensions-registration.contract.test.ts b/src/plugins/contracts/session-extensions-registration.contract.test.ts new file mode 100644 index 000000000000..020e6e8bb408 --- /dev/null +++ b/src/plugins/contracts/session-extensions-registration.contract.test.ts @@ -0,0 +1,122 @@ +// Session extension registration tests cover plugin-owned metadata snapshotting. +import { + createPluginRegistryFixture, + registerTestPlugin, +} from "openclaw/plugin-sdk/plugin-test-contracts"; +import { afterEach, describe, expect, it } from "vitest"; +import { projectPluginSessionExtensionsSync } from "../host-hook-state.js"; +import type { PluginJsonValue, PluginSessionExtensionRegistration } from "../host-hooks.js"; +import { createEmptyPluginRegistry } from "../registry-empty.js"; +import { setActivePluginRegistry } from "../runtime.js"; +import { createPluginRecord } from "../status.test-helpers.js"; + +function diagnosticSummaries(diagnostics: readonly unknown[]) { + return diagnostics.map((entry) => { + const diagnostic = entry as { pluginId?: string; message?: string }; + return { pluginId: diagnostic.pluginId, message: diagnostic.message }; + }); +} + +describe("plugin session extension registration", () => { + afterEach(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); + }); + + it("snapshots extension metadata before session projection", () => { + let projectReads = 0; + let slotSchemaReads = 0; + const slotSchema = { type: "object", properties: { state: { type: "string" } } }; + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "volatile-session-extension", + name: "Volatile Session Extension", + }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "Workflow state", + sessionEntrySlotKey: "approvalSnapshot", + get sessionEntrySlotSchema() { + slotSchemaReads += 1; + if (slotSchemaReads > 1) { + throw new Error("slot schema getter re-read"); + } + return slotSchema; + }, + get project() { + projectReads += 1; + if (projectReads > 1) { + throw new Error("project getter re-read"); + } + return ({ state }) => { + if (!state || typeof state !== "object" || Array.isArray(state)) { + return undefined; + } + return { state: (state as Record).state ?? null }; + }; + }, + } as PluginSessionExtensionRegistration); + }, + }); + setActivePluginRegistry(registry.registry); + + expect(registry.registry.sessionExtensions?.[0]?.extension.sessionEntrySlotSchema).toEqual( + slotSchema, + ); + expect(projectReads).toBe(1); + expect(slotSchemaReads).toBe(1); + expect( + projectPluginSessionExtensionsSync({ + sessionKey: "agent:main:main", + entry: { + sessionId: "session-1", + updatedAt: 1, + pluginExtensions: { + "volatile-session-extension": { + workflow: { state: "waiting" }, + }, + }, + }, + }), + ).toEqual([ + { + pluginId: "volatile-session-extension", + namespace: "workflow", + value: { state: "waiting" }, + }, + ]); + expect(projectReads).toBe(1); + expect(slotSchemaReads).toBe(1); + }); + + it("rejects non-JSON session extension slot schemas", () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "bad-session-extension-schema", + name: "Bad Session Extension Schema", + }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "Workflow state", + sessionEntrySlotKey: "approvalSnapshot", + sessionEntrySlotSchema: new Date(0) as never, + }); + }, + }); + + expect(registry.registry.sessionExtensions ?? []).toHaveLength(0); + expect(diagnosticSummaries(registry.registry.diagnostics)).toEqual([ + { + pluginId: "bad-session-extension-schema", + message: "session extension slot schema must be JSON-compatible: workflow", + }, + ]); + }); +}); diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index dd8da0725eaa..4f8c8a3263b5 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -2030,31 +2030,93 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { } }; + const readSessionExtensionRegistrationFields = ( + record: PluginRecord, + extension: PluginSessionExtensionRegistration, + ): + | { + namespace: unknown; + description: unknown; + project: unknown; + cleanup: unknown; + sessionEntrySlotKey: unknown; + sessionEntrySlotSchema: unknown; + } + | undefined => { + let namespace: unknown; + try { + namespace = extension.namespace; + return { + namespace, + description: extension.description, + project: extension.project, + cleanup: extension.cleanup, + sessionEntrySlotKey: extension.sessionEntrySlotKey, + sessionEntrySlotSchema: extension.sessionEntrySlotSchema, + }; + } catch (error) { + const normalizedNamespace = normalizeOptionalHostHookString(namespace); + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: + `session extension registration has unreadable fields` + + `${normalizedNamespace ? `: ${normalizedNamespace}` : ""}: ${formatErrorMessage(error)}`, + }); + return undefined; + } + }; + const registerSessionExtension = ( record: PluginRecord, extension: PluginSessionExtensionRegistration, ) => { - const namespace = normalizeHostHookString(extension.namespace); - const description = normalizeHostHookString(extension.description); - const project = extension.project; + const fields = readSessionExtensionRegistrationFields(record, extension); + if (!fields) { + return; + } + const namespace = normalizeHostHookString(fields.namespace); + const description = normalizeHostHookString(fields.description); + const project = fields.project; + const cleanup = fields.cleanup; let normalizedSessionEntrySlotKey: string | undefined; let invalidMessage: string | undefined; if (!namespace || !description) { invalidMessage = "session extension registration requires namespace and description"; } else if (project !== undefined && typeof project !== "function") { invalidMessage = "session extension projector must be a function"; - } else if (project?.constructor?.name === "AsyncFunction") { - invalidMessage = "session extension projector must be synchronous"; - } else if (extension.cleanup !== undefined && typeof extension.cleanup !== "function") { + } else if (project !== undefined) { + try { + if (project.constructor?.name === "AsyncFunction") { + invalidMessage = "session extension projector must be synchronous"; + } + } catch (error) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `session extension projector metadata is unreadable: ${namespace}: ${formatErrorMessage(error)}`, + }); + return; + } + } + if (!invalidMessage && cleanup !== undefined && typeof cleanup !== "function") { invalidMessage = "session extension cleanup must be a function"; - } else if (extension.sessionEntrySlotKey !== undefined) { - const slotKey = normalizeSessionEntrySlotKey(extension.sessionEntrySlotKey); + } + if (!invalidMessage && fields.sessionEntrySlotKey !== undefined) { + const slotKey = normalizeSessionEntrySlotKey(fields.sessionEntrySlotKey); if (!slotKey.ok) { invalidMessage = slotKey.error; } else { normalizedSessionEntrySlotKey = slotKey.key; } } + if (!invalidMessage && fields.sessionEntrySlotSchema !== undefined) { + if (!isPluginJsonValue(fields.sessionEntrySlotSchema)) { + invalidMessage = `session extension slot schema must be JSON-compatible: ${namespace}`; + } + } if (invalidMessage) { pushDiagnostic({ level: "error", @@ -2101,13 +2163,27 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { (registry.sessionExtensions ??= []).push({ pluginId: record.id, pluginName: record.name, + // Session extensions are projected into Gateway session rows. Store a + // normalized snapshot so plugin-owned accessors cannot run during reads. extension: { - ...extension, namespace, description, + ...(project !== undefined + ? { project: project as PluginSessionExtensionRegistration["project"] } + : {}), + ...(cleanup !== undefined + ? { cleanup: cleanup as PluginSessionExtensionRegistration["cleanup"] } + : {}), ...(normalizedSessionEntrySlotKey ? { sessionEntrySlotKey: normalizedSessionEntrySlotKey } : {}), + ...(fields.sessionEntrySlotSchema !== undefined + ? { + sessionEntrySlotSchema: fields.sessionEntrySlotSchema as NonNullable< + PluginSessionExtensionRegistration["sessionEntrySlotSchema"] + >, + } + : {}), }, source: record.source, rootDir: record.rootDir,