diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index ab042ca5812..fcffc810ab6 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -165,6 +165,8 @@ export type SessionEntry = { heartbeatTaskState?: Record; /** Plugin-owned session state, grouped by plugin id then extension namespace. */ pluginExtensions?: Record>; + /** Top-level SessionEntry mirror slots owned by plugin session extensions. */ + pluginExtensionSlotKeys?: Record>; /** Durable one-shot prompt additions drained before the next agent turn. */ pluginNextTurnInjections?: Record; sessionId: string; diff --git a/src/plugins/contracts/session-entry-projection.contract.test.ts b/src/plugins/contracts/session-entry-projection.contract.test.ts index c6ec858ed6d..9d248daaf5a 100644 --- a/src/plugins/contracts/session-entry-projection.contract.test.ts +++ b/src/plugins/contracts/session-entry-projection.contract.test.ts @@ -565,6 +565,11 @@ describe("plugin session extension SessionEntry projection", () => { const stored = loadSessionStore(storePath, { skipCache: true }); const entry = stored["agent:main:main"] as unknown as Record; expect(entry.approvalSnapshot).toBeUndefined(); + expect(entry.pluginExtensionSlotKeys).toEqual({ + "restart-promoted-plugin": { + workflow: "approvalSnapshot", + }, + }); expect(entry.pluginExtensions).toEqual({ "restart-promoted-plugin": { workflow: { state: "waiting" }, @@ -582,6 +587,62 @@ describe("plugin session extension SessionEntry projection", () => { } }); + it("clears persisted promoted slots when registry metadata is unavailable", async () => { + setActivePluginRegistry(createEmptyPluginRegistry()); + const stateDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-slot-metadata-cleanup-"), + ); + const storePath = path.join(stateDir, "sessions.json"); + const tempConfig = { session: { store: storePath } }; + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + try { + process.env.OPENCLAW_STATE_DIR = stateDir; + await withTempConfig({ + cfg: tempConfig, + run: async () => { + await updateSessionStore(storePath, (store) => { + store["agent:main:main"] = { + sessionId: "session-id", + updatedAt: Date.now(), + pluginExtensions: { + "removed-promoted-plugin": { + workflow: { state: "stale" }, + }, + }, + pluginExtensionSlotKeys: { + "removed-promoted-plugin": { + workflow: "approvalSnapshot", + }, + }, + approvalSnapshot: { state: "stale" }, + } as unknown as SessionEntry; + }); + + await expect( + runPluginHostCleanup({ + cfg: tempConfig as never, + pluginId: "removed-promoted-plugin", + reason: "delete", + }), + ).resolves.toMatchObject({ failures: [] }); + + const stored = loadSessionStore(storePath, { skipCache: true }); + const entry = stored["agent:main:main"] as unknown as Record; + expect(entry.approvalSnapshot).toBeUndefined(); + expect(entry.pluginExtensionSlotKeys).toBeUndefined(); + expect(entry.pluginExtensions).toBeUndefined(); + }, + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + it("exposes scoped session extension reads to trusted tool policies", async () => { const seen: unknown[] = []; const seenConfig: unknown[] = []; diff --git a/src/plugins/host-hook-cleanup.ts b/src/plugins/host-hook-cleanup.ts index 7840daf6ff6..638973f706f 100644 --- a/src/plugins/host-hook-cleanup.ts +++ b/src/plugins/host-hook-cleanup.ts @@ -31,15 +31,49 @@ function shouldCleanPlugin(pluginId: string, filterPluginId?: string): boolean { return !filterPluginId || pluginId === filterPluginId; } +function collectStoredSessionEntrySlotKeys(entry: SessionEntry, pluginId?: string): Set { + const slotKeys = new Set(); + const storedSlotKeys = entry.pluginExtensionSlotKeys; + if (!storedSlotKeys) { + return slotKeys; + } + const records = + pluginId === undefined + ? Object.values(storedSlotKeys) + : storedSlotKeys[pluginId] + ? [storedSlotKeys[pluginId]] + : []; + for (const record of records) { + for (const slotKey of Object.values(record)) { + const normalized = normalizeSessionEntrySlotKey(slotKey); + if (normalized.ok) { + slotKeys.add(normalized.key); + } + } + } + return slotKeys; +} + +function collectPromotedSessionEntrySlotKeys( + entry: SessionEntry, + pluginId?: string, + sessionEntrySlotKeys?: ReadonlySet, +): Set { + const slotKeys = collectStoredSessionEntrySlotKeys(entry, pluginId); + for (const slotKey of sessionEntrySlotKeys ?? []) { + slotKeys.add(slotKey); + } + return slotKeys; +} + function clearPromotedSessionEntrySlots( entry: SessionEntry, + pluginId?: string, sessionEntrySlotKeys?: ReadonlySet, ): void { - if (!sessionEntrySlotKeys || sessionEntrySlotKeys.size === 0) { - return; - } + const slotKeys = collectPromotedSessionEntrySlotKeys(entry, pluginId, sessionEntrySlotKeys); const entryRecord = entry as Record; - for (const slotKey of sessionEntrySlotKeys) { + for (const slotKey of slotKeys) { delete entryRecord[slotKey]; } } @@ -49,9 +83,10 @@ export function clearPluginOwnedSessionState( pluginId?: string, sessionEntrySlotKeys?: ReadonlySet, ): void { - clearPromotedSessionEntrySlots(entry, sessionEntrySlotKeys); + clearPromotedSessionEntrySlots(entry, pluginId, sessionEntrySlotKeys); if (!pluginId) { delete entry.pluginExtensions; + delete entry.pluginExtensionSlotKeys; delete entry.pluginNextTurnInjections; return; } @@ -61,6 +96,12 @@ export function clearPluginOwnedSessionState( delete entry.pluginExtensions; } } + if (entry.pluginExtensionSlotKeys) { + delete entry.pluginExtensionSlotKeys[pluginId]; + if (Object.keys(entry.pluginExtensionSlotKeys).length === 0) { + delete entry.pluginExtensionSlotKeys; + } + } if (entry.pluginNextTurnInjections) { delete entry.pluginNextTurnInjections[pluginId]; if (Object.keys(entry.pluginNextTurnInjections).length === 0) { @@ -71,13 +112,15 @@ export function clearPluginOwnedSessionState( function hasPromotedSessionEntrySlot( entry: SessionEntry, + pluginId?: string, sessionEntrySlotKeys?: ReadonlySet, ): boolean { - if (!sessionEntrySlotKeys || sessionEntrySlotKeys.size === 0) { + const slotKeys = collectPromotedSessionEntrySlotKeys(entry, pluginId, sessionEntrySlotKeys); + if (slotKeys.size === 0) { return false; } const entryRecord = entry as Record; - for (const slotKey of sessionEntrySlotKeys) { + for (const slotKey of slotKeys) { if (Object.prototype.hasOwnProperty.call(entryRecord, slotKey)) { return true; } @@ -90,13 +133,19 @@ function hasPluginOwnedSessionState( pluginId?: string, sessionEntrySlotKeys?: ReadonlySet, ): boolean { - if (hasPromotedSessionEntrySlot(entry, sessionEntrySlotKeys)) { + if (hasPromotedSessionEntrySlot(entry, pluginId, sessionEntrySlotKeys)) { return true; } if (!pluginId) { - return Boolean(entry.pluginExtensions || entry.pluginNextTurnInjections); + return Boolean( + entry.pluginExtensions || entry.pluginExtensionSlotKeys || entry.pluginNextTurnInjections, + ); } - return Boolean(entry.pluginExtensions?.[pluginId] || entry.pluginNextTurnInjections?.[pluginId]); + return Boolean( + entry.pluginExtensions?.[pluginId] || + entry.pluginExtensionSlotKeys?.[pluginId] || + entry.pluginNextTurnInjections?.[pluginId], + ); } function matchesCleanupSession( @@ -172,11 +221,11 @@ async function clearPromotedSessionEntrySlotStores(params: { for (const [entryKey, entry] of Object.entries(store)) { if ( !matchesCleanupSession(entryKey, entry, params.sessionKey) || - !hasPromotedSessionEntrySlot(entry, params.sessionEntrySlotKeys) + !hasPromotedSessionEntrySlot(entry, params.pluginId, params.sessionEntrySlotKeys) ) { continue; } - clearPromotedSessionEntrySlots(entry, params.sessionEntrySlotKeys); + clearPromotedSessionEntrySlots(entry, params.pluginId, params.sessionEntrySlotKeys); entry.updatedAt = now; clearedInStore += 1; } diff --git a/src/plugins/host-hook-state.ts b/src/plugins/host-hook-state.ts index 7e31bce5d78..c3ff95e8ba8 100644 --- a/src/plugins/host-hook-state.ts +++ b/src/plugins/host-hook-state.ts @@ -486,6 +486,7 @@ export async function patchPluginSessionExtension(params: { if (!entry) { return undefined; } + const entryRecord = entry as Record; const pluginExtensions = { ...entry.pluginExtensions }; const pluginState = { ...pluginExtensions[pluginId] }; if (params.unset === true) { @@ -503,6 +504,27 @@ export async function patchPluginSessionExtension(params: { } else { delete entry.pluginExtensions; } + const storedSlotKeys = { ...entry.pluginExtensionSlotKeys }; + const pluginSlotKeys = { ...storedSlotKeys[pluginId] }; + const previousSlotKey = normalizeSessionEntrySlotKey(pluginSlotKeys[namespace]); + if (previousSlotKey.ok && previousSlotKey.key !== slotKey) { + delete entryRecord[previousSlotKey.key]; + } + if (slotKey && params.unset !== true) { + pluginSlotKeys[namespace] = slotKey; + } else { + delete pluginSlotKeys[namespace]; + } + if (Object.keys(pluginSlotKeys).length > 0) { + storedSlotKeys[pluginId] = pluginSlotKeys; + } else { + delete storedSlotKeys[pluginId]; + } + if (Object.keys(storedSlotKeys).length > 0) { + entry.pluginExtensionSlotKeys = storedSlotKeys; + } else { + delete entry.pluginExtensionSlotKeys; + } if (slotKey) { const projected = projectSessionExtensionValueForSlot({ registration, @@ -510,7 +532,6 @@ export async function patchPluginSessionExtension(params: { sessionId: entry.sessionId, nextValue: params.unset === true ? undefined : nextPluginValue, }); - const entryRecord = entry as Record; if (projected === undefined) { delete entryRecord[slotKey]; } else { diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts index 322dd874bc2..8415d82249c 100644 --- a/src/plugins/session-entry-slot-keys.ts +++ b/src/plugins/session-entry-slot-keys.ts @@ -9,6 +9,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "heartbeatIsolatedBaseSessionKey", "heartbeatTaskState", "pluginExtensions", + "pluginExtensionSlotKeys", "pluginNextTurnInjections", "sessionId", "updatedAt",