From 6fe535cb87dfda6be6285523fe23910c81220d02 Mon Sep 17 00:00:00 2001 From: Eva Date: Fri, 1 May 2026 20:02:06 +0700 Subject: [PATCH] fix: reject duplicate session entry slots --- .../session-entry-projection.contract.test.ts | 50 +++++++++++++++++++ src/plugins/registry.ts | 28 +++++++++++ 2 files changed, 78 insertions(+) diff --git a/src/plugins/contracts/session-entry-projection.contract.test.ts b/src/plugins/contracts/session-entry-projection.contract.test.ts index 08634a6cfaa..bc49ad0b425 100644 --- a/src/plugins/contracts/session-entry-projection.contract.test.ts +++ b/src/plugins/contracts/session-entry-projection.contract.test.ts @@ -142,6 +142,56 @@ describe("plugin session extension SessionEntry projection", () => { ); }); + it("rejects duplicate promoted SessionEntry slot keys across registrations", () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ id: "slot-owner", name: "Slot Owner" }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "first promoted slot", + sessionEntrySlotKey: "approvalSnapshot", + }); + api.registerSessionExtension({ + namespace: "recovery", + description: "same plugin duplicate slot", + sessionEntrySlotKey: " approvalSnapshot ", + }); + }, + }); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ id: "slot-colliding-plugin", name: "Slot Colliding" }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "cross-plugin duplicate slot", + sessionEntrySlotKey: "approvalSnapshot", + }); + }, + }); + + expect(registry.registry.sessionExtensions ?? []).toHaveLength(1); + expect(registry.registry.sessionExtensions?.[0]?.extension.sessionEntrySlotKey).toBe( + "approvalSnapshot", + ); + expect(registry.registry.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId: "slot-owner", + message: "sessionEntrySlotKey already registered: approvalSnapshot", + }), + expect.objectContaining({ + pluginId: "slot-colliding-plugin", + message: "sessionEntrySlotKey already registered: approvalSnapshot", + }), + ]), + ); + }); + it("clears promoted SessionEntry slots with plugin-owned session state", async () => { const { config, registry } = createPluginRegistryFixture(); registerTestPlugin({ diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index c9a710a7bf5..497e1d37661 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -1626,6 +1626,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { const namespace = normalizeHostHookString(extension.namespace); const description = normalizeHostHookString(extension.description); const project = extension.project; + let normalizedSessionEntrySlotKey: string | undefined; let invalidMessage: string | undefined; if (!namespace || !description) { invalidMessage = "session extension registration requires namespace and description"; @@ -1639,6 +1640,8 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { const slotKey = normalizeSessionEntrySlotKey(extension.sessionEntrySlotKey); if (!slotKey.ok) { invalidMessage = slotKey.error; + } else { + normalizedSessionEntrySlotKey = slotKey.key; } } if (invalidMessage) { @@ -1662,6 +1665,28 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); return; } + if (normalizedSessionEntrySlotKey) { + const existingSlot = (registry.sessionExtensions ?? []).find((entry) => { + const existingSlotKey = entry.extension.sessionEntrySlotKey; + if (existingSlotKey === undefined) { + return false; + } + const normalizedExistingSlotKey = normalizeSessionEntrySlotKey(existingSlotKey); + return ( + normalizedExistingSlotKey.ok && + normalizedExistingSlotKey.key === normalizedSessionEntrySlotKey + ); + }); + if (existingSlot) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `sessionEntrySlotKey already registered: ${normalizedSessionEntrySlotKey}`, + }); + return; + } + } (registry.sessionExtensions ??= []).push({ pluginId: record.id, pluginName: record.name, @@ -1669,6 +1694,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { ...extension, namespace, description, + ...(normalizedSessionEntrySlotKey + ? { sessionEntrySlotKey: normalizedSessionEntrySlotKey } + : {}), }, source: record.source, rootDir: record.rootDir,