mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 22:00:44 +00:00
fix: harden session entry projection cleanup
This commit is contained in:
@@ -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]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user