fix: persist session entry slot ownership

This commit is contained in:
Eva
2026-05-04 01:51:10 +07:00
committed by Josh Lehman
parent d9c6e262a5
commit 799ae6e700
5 changed files with 147 additions and 13 deletions

View File

@@ -165,6 +165,8 @@ export type SessionEntry = {
heartbeatTaskState?: Record<string, number>;
/** Plugin-owned session state, grouped by plugin id then extension namespace. */
pluginExtensions?: Record<string, Record<string, SessionPluginJsonValue>>;
/** Top-level SessionEntry mirror slots owned by plugin session extensions. */
pluginExtensionSlotKeys?: Record<string, Record<string, string>>;
/** Durable one-shot prompt additions drained before the next agent turn. */
pluginNextTurnInjections?: Record<string, SessionPluginNextTurnInjection[]>;
sessionId: string;

View File

@@ -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<string, unknown>;
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<string, unknown>;
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[] = [];

View File

@@ -31,15 +31,49 @@ function shouldCleanPlugin(pluginId: string, filterPluginId?: string): boolean {
return !filterPluginId || pluginId === filterPluginId;
}
function collectStoredSessionEntrySlotKeys(entry: SessionEntry, pluginId?: string): Set<string> {
const slotKeys = new Set<string>();
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<string>,
): Set<string> {
const slotKeys = collectStoredSessionEntrySlotKeys(entry, pluginId);
for (const slotKey of sessionEntrySlotKeys ?? []) {
slotKeys.add(slotKey);
}
return slotKeys;
}
function clearPromotedSessionEntrySlots(
entry: SessionEntry,
pluginId?: string,
sessionEntrySlotKeys?: ReadonlySet<string>,
): void {
if (!sessionEntrySlotKeys || sessionEntrySlotKeys.size === 0) {
return;
}
const slotKeys = collectPromotedSessionEntrySlotKeys(entry, pluginId, sessionEntrySlotKeys);
const entryRecord = entry as Record<string, unknown>;
for (const slotKey of sessionEntrySlotKeys) {
for (const slotKey of slotKeys) {
delete entryRecord[slotKey];
}
}
@@ -49,9 +83,10 @@ export function clearPluginOwnedSessionState(
pluginId?: string,
sessionEntrySlotKeys?: ReadonlySet<string>,
): 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<string>,
): boolean {
if (!sessionEntrySlotKeys || sessionEntrySlotKeys.size === 0) {
const slotKeys = collectPromotedSessionEntrySlotKeys(entry, pluginId, sessionEntrySlotKeys);
if (slotKeys.size === 0) {
return false;
}
const entryRecord = entry as Record<string, unknown>;
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<string>,
): 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;
}

View File

@@ -486,6 +486,7 @@ export async function patchPluginSessionExtension(params: {
if (!entry) {
return undefined;
}
const entryRecord = entry as Record<string, unknown>;
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<string, unknown>;
if (projected === undefined) {
delete entryRecord[slotKey];
} else {

View File

@@ -9,6 +9,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
"heartbeatIsolatedBaseSessionKey",
"heartbeatTaskState",
"pluginExtensions",
"pluginExtensionSlotKeys",
"pluginNextTurnInjections",
"sessionId",
"updatedAt",