mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:20:43 +00:00
fix: persist session entry slot ownership
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -9,6 +9,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
|
||||
"heartbeatIsolatedBaseSessionKey",
|
||||
"heartbeatTaskState",
|
||||
"pluginExtensions",
|
||||
"pluginExtensionSlotKeys",
|
||||
"pluginNextTurnInjections",
|
||||
"sessionId",
|
||||
"updatedAt",
|
||||
|
||||
Reference in New Issue
Block a user