fix: harden session entry projection cleanup

This commit is contained in:
Eva
2026-05-01 21:03:13 +07:00
committed by Josh Lehman
parent 81505487e7
commit 9d4633c460
5 changed files with 135 additions and 19 deletions

View File

@@ -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<string, unknown>;
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]);
});

View File

@@ -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 {

View File

@@ -417,6 +417,23 @@ export function getPluginSessionExtensionSync<T extends PluginJsonValue = Plugin
return value as T | undefined;
}
export function getPluginSessionExtensionStateSync(params: {
cfg: OpenClawConfig;
pluginId: string;
sessionKey?: string;
}): Record<string, PluginJsonValue> | 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<string, PluginJsonValue>
| undefined;
return value ? (copyJsonValue(value) as Record<string, PluginJsonValue>) : undefined;
}
export async function patchPluginSessionExtension(params: {
cfg: OpenClawConfig;
sessionKey: string;

View File

@@ -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<keyof SessionEntry | "__proto__" | "constructor" | "prototype">;
const SESSION_ENTRY_RESERVED_SLOT_KEYS = new Set<string>(SESSION_ENTRY_RESERVED_SLOT_KEY_LIST);
const SESSION_ENTRY_SLOT_KEY_RE = /^[A-Za-z][A-Za-z0-9_]*$/u;

View File

@@ -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<string, PluginJsonValue | undefined>();
const sessionExtensionStateCache = new Map<string, Record<string, PluginJsonValue> | 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: <T extends PluginJsonValue = PluginJsonValue>(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<T>({
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(