diff --git a/src/plugins/contracts/session-entry-projection.contract.test.ts b/src/plugins/contracts/session-entry-projection.contract.test.ts index 14f23a0d33b..551b1b57bf2 100644 --- a/src/plugins/contracts/session-entry-projection.contract.test.ts +++ b/src/plugins/contracts/session-entry-projection.contract.test.ts @@ -382,6 +382,73 @@ describe("plugin session extension SessionEntry projection", () => { } }); + it("uses the active registry to clear promoted slots when cleanup omits registry", async () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ id: "active-cleanup-promoted-plugin", name: "Cleanup" }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "promoted workflow", + sessionEntrySlotKey: "approvalSnapshot", + }); + }, + }); + setActivePluginRegistry(registry.registry); + + const stateDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-slot-active-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: "active-cleanup-promoted-plugin", + namespace: "workflow", + value: { state: "waiting" }, + }), + ).resolves.toMatchObject({ ok: true }); + + await expect( + runPluginHostCleanup({ + cfg: tempConfig as never, + pluginId: "active-cleanup-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.pluginExtensions).toBeUndefined(); + expect(entry.approvalSnapshot).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[] = []; @@ -399,11 +466,17 @@ describe("plugin session extension SessionEntry projection", () => { namespace: "policy", description: "policy state", }); + api.registerSessionExtension({ + namespace: "second", + description: "second policy state", + }); api.registerTrustedToolPolicy({ id: "inspect-session-state", description: "inspect session extension", evaluate(_event, ctx) { seen.push(ctx.getSessionExtension?.("policy")); + seen.push(ctx.getSessionExtension?.("second")); + seen.push(ctx.getSessionExtension?.("missing")); seenConfig.push((ctx as { config?: unknown }).config); return undefined; }, @@ -438,6 +511,15 @@ describe("plugin session extension SessionEntry projection", () => { value: { gate: "open" }, }), ).resolves.toMatchObject({ ok: true }); + await expect( + patchPluginSessionExtension({ + cfg: tempConfig as never, + sessionKey: "agent:main:main", + pluginId: "policy-plugin", + namespace: "second", + value: { gate: "second" }, + }), + ).resolves.toMatchObject({ ok: true }); await expect( runTrustedToolPolicies( @@ -470,7 +552,14 @@ describe("plugin session extension SessionEntry projection", () => { await fs.rm(stateDir, { recursive: true, force: true }); } - expect(seen).toEqual([{ gate: "open" }, undefined]); + expect(seen).toEqual([ + { gate: "open" }, + { gate: "second" }, + undefined, + undefined, + undefined, + undefined, + ]); expect(seenConfig).toEqual([undefined, undefined]); }); diff --git a/src/plugins/host-hook-cleanup.ts b/src/plugins/host-hook-cleanup.ts index 843b2b18f59..294e3063da8 100644 --- a/src/plugins/host-hook-cleanup.ts +++ b/src/plugins/host-hook-cleanup.ts @@ -13,6 +13,7 @@ import { } from "./host-hook-runtime.js"; import type { PluginHostCleanupReason } from "./host-hooks.js"; import type { PluginRegistry } from "./registry-types.js"; +import { getActivePluginRegistry } from "./runtime.js"; import { normalizeSessionEntrySlotKey } from "./session-entry-slot-keys.js"; export type PluginHostCleanupFailure = { @@ -182,7 +183,10 @@ export async function runPluginHostCleanup(params: { return { cleanupCount: 0, failures }; } const registry = params.registry; - const sessionEntrySlotKeys = collectSessionEntrySlotKeys(registry, params.pluginId); + const sessionEntrySlotKeys = collectSessionEntrySlotKeys( + registry ?? getActivePluginRegistry(), + params.pluginId, + ); let persistentCleanupCount = 0; if (params.reason !== "restart" && shouldCleanup()) { try { diff --git a/src/plugins/host-hook-state.ts b/src/plugins/host-hook-state.ts index 6c6f12f4935..7e31bce5d78 100644 --- a/src/plugins/host-hook-state.ts +++ b/src/plugins/host-hook-state.ts @@ -417,6 +417,23 @@ export function getPluginSessionExtensionSync | undefined { + const pluginId = params.pluginId.trim(); + const sessionKey = normalizeOptionalString(params.sessionKey); + if (!pluginId || !sessionKey) { + return undefined; + } + const loaded = loadPluginHostHookSessionEntry({ cfg: params.cfg, sessionKey }); + const value = loaded.entry?.pluginExtensions?.[pluginId] as + | Record + | undefined; + return value ? (copyJsonValue(value) as Record) : undefined; +} + export async function patchPluginSessionExtension(params: { cfg: OpenClawConfig; sessionKey: string; diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts index 85abd6eca64..5bdc06123a1 100644 --- a/src/plugins/session-entry-slot-keys.ts +++ b/src/plugins/session-entry-slot-keys.ts @@ -1,4 +1,6 @@ -const SESSION_ENTRY_RESERVED_SLOT_KEYS = new Set([ +import type { SessionEntry } from "../config/sessions/types.js"; + +const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "__proto__", "constructor", "prototype", @@ -99,7 +101,9 @@ const SESSION_ENTRY_RESERVED_SLOT_KEYS = new Set([ "systemPromptReport", "pluginDebugEntries", "acp", -]); +] as const satisfies ReadonlyArray; + +const SESSION_ENTRY_RESERVED_SLOT_KEYS = new Set(SESSION_ENTRY_RESERVED_SLOT_KEY_LIST); const SESSION_ENTRY_SLOT_KEY_RE = /^[A-Za-z][A-Za-z0-9_]*$/u; diff --git a/src/plugins/trusted-tool-policy.ts b/src/plugins/trusted-tool-policy.ts index 4577e7bd1b0..9ae07593a4b 100644 --- a/src/plugins/trusted-tool-policy.ts +++ b/src/plugins/trusted-tool-policy.ts @@ -4,7 +4,7 @@ import type { PluginHookBeforeToolCallResult, PluginHookToolContext, } from "./hook-types.js"; -import { getPluginSessionExtensionSync } from "./host-hook-state.js"; +import { getPluginSessionExtensionStateSync } from "./host-hook-state.js"; import type { PluginJsonValue } from "./host-hooks.js"; import { getActivePluginRegistry } from "./runtime.js"; @@ -17,29 +17,31 @@ export async function runTrustedToolPolicies( let adjustedParams = event.params; let hasAdjustedParams = false; let approval: PluginHookBeforeToolCallResult["requireApproval"]; - const sessionExtensionCache = new Map(); + const sessionExtensionStateCache = new Map | undefined>(); for (const registration of policies) { const policyCtx: PluginHookToolContext = { ...ctx, // oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Plugin callers type JSON reads by namespace. getSessionExtension: (namespace: string) => { const normalizedNamespace = namespace.trim(); - const cacheKey = `${registration.pluginId}\0${normalizedNamespace}`; - if (sessionExtensionCache.has(cacheKey)) { - return sessionExtensionCache.get(cacheKey) as T | undefined; + const cacheKey = registration.pluginId; + if (!sessionExtensionStateCache.has(cacheKey)) { + sessionExtensionStateCache.set( + cacheKey, + options?.config + ? getPluginSessionExtensionStateSync({ + cfg: options.config, + pluginId: registration.pluginId, + sessionKey: ctx.sessionKey, + }) + : undefined, + ); } - if (!options?.config) { - sessionExtensionCache.set(cacheKey, undefined); + const pluginState = sessionExtensionStateCache.get(cacheKey); + if (!normalizedNamespace || !pluginState) { return undefined; } - const value = getPluginSessionExtensionSync({ - cfg: options.config, - pluginId: registration.pluginId, - sessionKey: ctx.sessionKey, - namespace: normalizedNamespace, - }); - sessionExtensionCache.set(cacheKey, value); - return value; + return pluginState[normalizedNamespace] as T | undefined; }, }; const decision = await registration.policy.evaluate(