diff --git a/src/plugins/contracts/session-entry-projection.contract.test.ts b/src/plugins/contracts/session-entry-projection.contract.test.ts index 95364fdba32..c6ec858ed6d 100644 --- a/src/plugins/contracts/session-entry-projection.contract.test.ts +++ b/src/plugins/contracts/session-entry-projection.contract.test.ts @@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { loadSessionStore, updateSessionStore, type SessionEntry } from "../../config/sessions.js"; import { withTempConfig } from "../../gateway/test-temp-config.js"; import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js"; -import { runPluginHostCleanup } from "../host-hook-cleanup.js"; +import { cleanupReplacedPluginHostRegistry, runPluginHostCleanup } from "../host-hook-cleanup.js"; import { clearPluginHostRuntimeState } from "../host-hook-runtime.js"; import { patchPluginSessionExtension } from "../host-hook-state.js"; import type { PluginJsonValue } from "../host-hooks.js"; @@ -499,6 +499,89 @@ describe("plugin session extension SessionEntry projection", () => { } }); + it("clears stale promoted SessionEntry slots on plugin restart without deleting extension state", async () => { + const previousFixture = createPluginRegistryFixture(); + registerTestPlugin({ + registry: previousFixture.registry, + config: previousFixture.config, + record: createPluginRecord({ id: "restart-promoted-plugin", name: "Restart" }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "promoted workflow", + sessionEntrySlotKey: "approvalSnapshot", + }); + }, + }); + const nextFixture = createPluginRegistryFixture(); + registerTestPlugin({ + registry: nextFixture.registry, + config: nextFixture.config, + record: createPluginRecord({ id: "restart-promoted-plugin", name: "Restart" }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "promoted workflow", + }); + }, + }); + setActivePluginRegistry(previousFixture.registry.registry); + + const stateDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-slot-restart-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(), + } as unknown as SessionEntry; + }); + await expect( + patchPluginSessionExtension({ + cfg: tempConfig as never, + sessionKey: "agent:main:main", + pluginId: "restart-promoted-plugin", + namespace: "workflow", + value: { state: "waiting" }, + }), + ).resolves.toMatchObject({ ok: true }); + + await expect( + cleanupReplacedPluginHostRegistry({ + cfg: tempConfig as never, + previousRegistry: previousFixture.registry.registry, + nextRegistry: nextFixture.registry.registry, + }), + ).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.pluginExtensions).toEqual({ + "restart-promoted-plugin": { + workflow: { state: "waiting" }, + }, + }); + }, + }); + } 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 3fa2613a214..7840daf6ff6 100644 --- a/src/plugins/host-hook-cleanup.ts +++ b/src/plugins/host-hook-cleanup.ts @@ -150,6 +150,42 @@ async function clearPluginOwnedSessionStores(params: { return cleared; } +async function clearPromotedSessionEntrySlotStores(params: { + cfg: OpenClawConfig; + pluginId?: string; + sessionKey?: string; + sessionEntrySlotKeys: ReadonlySet; +}): Promise { + if ((!params.pluginId && !params.sessionKey) || params.sessionEntrySlotKeys.size === 0) { + return 0; + } + const storePaths = new Set( + resolveAllAgentSessionStoreTargetsSync(params.cfg) + .map((target) => target.storePath) + .filter((storePath) => fs.existsSync(storePath)), + ); + let cleared = 0; + for (const storePath of storePaths) { + cleared += await updateSessionStore(storePath, (store) => { + let clearedInStore = 0; + const now = Date.now(); + for (const [entryKey, entry] of Object.entries(store)) { + if ( + !matchesCleanupSession(entryKey, entry, params.sessionKey) || + !hasPromotedSessionEntrySlot(entry, params.sessionEntrySlotKeys) + ) { + continue; + } + clearPromotedSessionEntrySlots(entry, params.sessionEntrySlotKeys); + entry.updatedAt = now; + clearedInStore += 1; + } + return clearedInStore; + }); + } + return cleared; +} + function collectSessionEntrySlotKeys( registry: PluginRegistry | null | undefined, pluginId?: string, @@ -192,14 +228,22 @@ export async function runPluginHostCleanup(params: { params.pluginId, ); let persistentCleanupCount = 0; - if (params.reason !== "restart" && shouldCleanup()) { + if (shouldCleanup()) { try { - persistentCleanupCount = await clearPluginOwnedSessionStores({ - cfg: params.cfg ?? getRuntimeConfig(), - pluginId: params.pluginId, - sessionKey: params.sessionKey, - sessionEntrySlotKeys, - }); + persistentCleanupCount = + params.reason === "restart" + ? await clearPromotedSessionEntrySlotStores({ + cfg: params.cfg ?? getRuntimeConfig(), + pluginId: params.pluginId, + sessionKey: params.sessionKey, + sessionEntrySlotKeys, + }) + : await clearPluginOwnedSessionStores({ + cfg: params.cfg ?? getRuntimeConfig(), + pluginId: params.pluginId, + sessionKey: params.sessionKey, + sessionEntrySlotKeys, + }); } catch (error) { failures.push({ pluginId: params.pluginId ?? "plugin-host",