Skip to content

Commit 16e967e

Browse files
authored
fix(gateway): reject unknown session agents (#111178)
1 parent 3af3493 commit 16e967e

15 files changed

Lines changed: 1214 additions & 45 deletions

scripts/lib/session-accessor-debt-baseline.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"src/commands/doctor-heartbeat-session-target.ts": 2,
1010
"src/commands/doctor-state-integrity.ts": 2,
1111
"src/commands/doctor/shared/codex-route-session-repair.ts": 3,
12+
"src/config/sessions/legacy-store-readonly.ts": 2,
1213
"src/config/sessions/session-accessor.entry.ts": 2,
1314
"src/config/sessions/store-load.ts": 3,
1415
"src/config/sessions/store.ts": 10,

src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,11 @@ vi.mock("../config/config.js", () => ({
373373
}));
374374

375375
vi.mock("../config/sessions.js", () => ({
376+
isConfiguredSessionStoreAgentId: (
377+
cfg: { agents?: { list?: Array<{ id?: string }> } },
378+
agentId: string,
379+
) => agentId === "main" || cfg.agents?.list?.some((agent) => agent.id === agentId) === true,
380+
isLegacyOnlySessionStoreTarget: () => false,
376381
loadSessionStore: () => hoisted.sessionStore,
377382
mergeSessionEntry: (existing: object | undefined, patch: object) => ({
378383
...existing,
@@ -384,6 +389,8 @@ vi.mock("../config/sessions.js", () => ({
384389
cfg?: { session?: { mainKey?: string } };
385390
agentId: string;
386391
}) => `agent:${params.agentId}:${params.cfg?.session?.mainKey ?? "main"}`,
392+
readLegacySessionStoreTarget: () => undefined,
393+
resolveExistingAgentSessionStoreTargetsSync: () => [],
387394
resolveStorePath: () => "/tmp/openclaw-sessions-spawn-test-store.json",
388395
updateSessionStore: async (
389396
_storePath: string,

src/config/sessions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export * from "./sessions/main-session.js";
99
export type { MainRestartRecoveryState } from "./sessions/main-session-recovery.types.js";
1010
export * from "./sessions/main-session.runtime.js";
1111
export * from "./sessions/lifecycle.js";
12+
export * from "./sessions/legacy-store-readonly.js";
1213
export * from "./sessions/paths.js";
1314
export * from "./sessions/reset.js";
1415
export {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Read-only compatibility for discovered legacy stores that predate the SQLite session store.
2+
// Callers must establish that the target already exists; this seam never provisions or writes it.
3+
import fs from "node:fs";
4+
import { resolveSqliteTargetFromSessionStorePath } from "./session-sqlite-target.js";
5+
import { loadSessionStore } from "./store-load.js";
6+
import type { SessionEntry } from "./types.js";
7+
8+
export function isLegacyOnlySessionStoreTarget(storePath: string, agentId?: string): boolean {
9+
if (!fs.existsSync(storePath)) {
10+
return false;
11+
}
12+
const sqlitePath = resolveSqliteTargetFromSessionStorePath(storePath, { agentId }).path;
13+
return !sqlitePath || !fs.existsSync(sqlitePath);
14+
}
15+
16+
export function readLegacySessionStoreTarget(
17+
storePath: string,
18+
agentId?: string,
19+
): Record<string, SessionEntry> | undefined {
20+
if (!isLegacyOnlySessionStoreTarget(storePath, agentId)) {
21+
return undefined;
22+
}
23+
return loadSessionStore(storePath, {
24+
clone: true,
25+
hydrateSkillPromptRefs: false,
26+
skipCache: true,
27+
});
28+
}

src/config/sessions/session-accessor.sqlite-entry-store.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,15 @@ export function readSqliteSessionEntryCount(database: OpenClawAgentDatabase): nu
229229
return count === undefined || count === null ? 0 : normalizeSqliteNumber(count);
230230
}
231231

232+
/** Lists persisted session keys without materializing their entry payloads. */
233+
export function readSqliteSessionEntryKeys(database: OpenClawAgentDatabaseReader): string[] {
234+
const db = getSessionKysely(database.db);
235+
return executeSqliteQuerySync(
236+
database.db,
237+
db.selectFrom("session_entries").select("session_key").orderBy("session_key", "asc"),
238+
).rows.map((row) => row.session_key);
239+
}
240+
232241
export function resolveSqliteLifecyclePrimaryEntry(
233242
database: OpenClawAgentDatabase,
234243
target: { canonicalKey: string; storeKeys: string[] },

src/config/sessions/session-transcript-search.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// when doctor imports or out-of-band writes leave derived rows behind.
55
import { openOpenClawAgentDatabase } from "../../state/openclaw-agent-db.js";
66
import { truncateUtf16Safe } from "../../utils.js";
7+
import { resolveSqliteTargetFromSessionStorePath } from "./session-sqlite-target.js";
78
import { listSessionsNeedingTranscriptIndexReconcile } from "./session-transcript-index.js";
89
import {
910
isSessionTranscriptIndexReconcileRunning,
@@ -45,6 +46,7 @@ export function searchSessionTranscripts(params: {
4546
limit?: number;
4647
query: string;
4748
sessionKeys?: string[];
49+
storePath?: string;
4850
}): SessionTranscriptSearchResult {
4951
const query = params.query.trim();
5052
if (!query) {
@@ -53,14 +55,20 @@ export function searchSessionTranscripts(params: {
5355
if (query.length > SEARCH_QUERY_MAX_CHARS) {
5456
throw new Error(`query must not exceed ${SEARCH_QUERY_MAX_CHARS} characters`);
5557
}
58+
const databasePath = params.storePath
59+
? resolveSqliteTargetFromSessionStorePath(params.storePath, {
60+
agentId: params.agentId,
61+
}).path
62+
: undefined;
5663
const databaseOptions = {
5764
agentId: params.agentId,
5865
...(params.env ? { env: params.env } : {}),
66+
...(databasePath ? { path: databasePath } : {}),
5967
};
6068
const database = openOpenClawAgentDatabase(databaseOptions);
6169
const dirtySessions = listSessionsNeedingTranscriptIndexReconcile(database.db);
6270
if (dirtySessions.length > 0) {
63-
startSessionTranscriptIndexReconcile(params);
71+
startSessionTranscriptIndexReconcile(databaseOptions);
6472
}
6573
const indexing =
6674
dirtySessions.length > 0 || isSessionTranscriptIndexReconcileRunning(databaseOptions);

src/config/sessions/targets.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
resolveAgentSessionStoreTargetsSync,
1111
resolveAllAgentSessionStoreCandidateTargetsSync,
1212
resolveAllAgentSessionStoreTargetsSync,
13+
resolveExistingAgentSessionStoreTargetsSync,
1314
resolveSessionStoreTargets,
1415
} from "./targets.js";
1516

@@ -247,6 +248,128 @@ describe("resolveAgentSessionStoreTargetsSync", () => {
247248
});
248249
});
249250

251+
describe("resolveExistingAgentSessionStoreTargetsSync", () => {
252+
it("requires agent-specific rows instead of fixed-store file existence", async () => {
253+
await withTempHome(async (home) => {
254+
const storePath = path.join(home, "shared", "sessions.json");
255+
await fs.mkdir(path.dirname(storePath), { recursive: true });
256+
await fs.writeFile(storePath, "{}\n", "utf8");
257+
const cfg: OpenClawConfig = {
258+
agents: { list: [{ id: "main", default: true }] },
259+
session: { store: storePath },
260+
};
261+
262+
expect(resolveExistingAgentSessionStoreTargetsSync(cfg, "ghost")).toEqual([]);
263+
264+
await replaceSessionEntry(
265+
{ agentId: "ghost", sessionKey: "agent:ghost:existing", storePath },
266+
{ sessionId: "session-ghost", updatedAt: 42 },
267+
);
268+
269+
expect(resolveExistingAgentSessionStoreTargetsSync(cfg, "ghost")).toEqual([
270+
{ agentId: "ghost", storePath },
271+
]);
272+
});
273+
});
274+
275+
it("recognizes matching rows in a fixed legacy store without creating SQLite", async () => {
276+
await withTempHome(async (home) => {
277+
const storePath = path.join(home, "shared", "sessions.json");
278+
await fs.mkdir(path.dirname(storePath), { recursive: true });
279+
await fs.writeFile(
280+
storePath,
281+
JSON.stringify({
282+
"agent:retired:existing": { sessionId: "legacy-retired", updatedAt: 42 },
283+
"agent:other:existing": { sessionId: "legacy-other", updatedAt: 41 },
284+
}),
285+
"utf8",
286+
);
287+
const cfg: OpenClawConfig = {
288+
agents: { list: [{ id: "main", default: true }] },
289+
session: { store: storePath },
290+
};
291+
292+
expect(resolveExistingAgentSessionStoreTargetsSync(cfg, "retired")).toEqual([
293+
{ agentId: "retired", storePath },
294+
]);
295+
expect(resolveExistingAgentSessionStoreTargetsSync(cfg, "ghost")).toEqual([]);
296+
expect(await fs.readdir(path.dirname(storePath))).toEqual(["sessions.json"]);
297+
});
298+
});
299+
300+
it("includes existing deterministic template targets outside discoverable agent roots", async () => {
301+
await withTempHome(async (home) => {
302+
const storeTemplate = path.join(home, "external-stores", "sessions-{agentId}.json");
303+
const cfg: OpenClawConfig = {
304+
agents: { list: [{ id: "main", default: true }] },
305+
session: { store: storeTemplate },
306+
};
307+
const legacyStorePath = resolveStorePath(storeTemplate, { agentId: "retired-legacy" });
308+
const sqliteStorePath = resolveStorePath(storeTemplate, { agentId: "retired-sqlite" });
309+
await fs.mkdir(path.dirname(legacyStorePath), { recursive: true });
310+
await fs.writeFile(
311+
legacyStorePath,
312+
JSON.stringify({
313+
"agent:retired-legacy:existing": { sessionId: "legacy-retired", updatedAt: 42 },
314+
}),
315+
"utf8",
316+
);
317+
await replaceSessionEntry(
318+
{
319+
agentId: "retired-sqlite",
320+
sessionKey: "agent:retired-sqlite:existing",
321+
storePath: sqliteStorePath,
322+
},
323+
{ sessionId: "sqlite-retired", updatedAt: 42 },
324+
);
325+
326+
expect(resolveAllAgentSessionStoreTargetsSync(cfg)).not.toContainEqual({
327+
agentId: "retired-legacy",
328+
storePath: legacyStorePath,
329+
});
330+
expect(resolveAllAgentSessionStoreTargetsSync(cfg)).not.toContainEqual({
331+
agentId: "retired-sqlite",
332+
storePath: sqliteStorePath,
333+
});
334+
expect(resolveExistingAgentSessionStoreTargetsSync(cfg, "retired-legacy")).toEqual([
335+
{ agentId: "retired-legacy", storePath: legacyStorePath },
336+
]);
337+
expect(resolveExistingAgentSessionStoreTargetsSync(cfg, "retired-sqlite")).toEqual([
338+
{ agentId: "retired-sqlite", storePath: sqliteStorePath },
339+
]);
340+
expect(resolveExistingAgentSessionStoreTargetsSync(cfg, "ghost")).toEqual([]);
341+
});
342+
});
343+
344+
it("rejects a deterministic target whose store symlink escapes the agents root", async () => {
345+
await withTempHome(async (home) => {
346+
if (process.platform === "win32") {
347+
return;
348+
}
349+
const customRoot = path.join(home, "custom-state");
350+
const escapedSessionsDir = path.join(customRoot, "agents", "escaped", "sessions");
351+
const outsideStorePath = path.join(home, "outside-sessions.json");
352+
const escapedStorePath = path.join(escapedSessionsDir, "sessions.json");
353+
await fs.mkdir(escapedSessionsDir, { recursive: true });
354+
await fs.writeFile(
355+
outsideStorePath,
356+
JSON.stringify({
357+
"agent:escaped:secret": { sessionId: "outside-session", updatedAt: 42 },
358+
}),
359+
"utf8",
360+
);
361+
await fs.symlink(outsideStorePath, escapedStorePath);
362+
363+
const cfg = createCustomRootCfg(customRoot, "main");
364+
expect(resolveAllAgentSessionStoreTargetsSync(cfg)).not.toContainEqual({
365+
agentId: "escaped",
366+
storePath: escapedStorePath,
367+
});
368+
expect(resolveExistingAgentSessionStoreTargetsSync(cfg, "escaped")).toEqual([]);
369+
});
370+
});
371+
});
372+
250373
describe("resolveAllAgentSessionStoreTargetsSync", () => {
251374
it("includes discovered on-disk agent stores alongside configured targets", async () => {
252375
await withTempHome(async (home) => {

0 commit comments

Comments
 (0)