Skip to content

Commit cab0abf

Browse files
fix(sessions): resolve transcript paths with explicit agent context (#16288)
Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 7cbe9de Co-authored-by: robbyczgw-cla <[email protected]> Co-authored-by: gumadeiras <[email protected]> Reviewed-by: @gumadeiras
1 parent 77b8971 commit cab0abf

10 files changed

Lines changed: 252 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
1313

1414
### Fixes
1515

16+
- Sessions/Agents: harden transcript path resolution for mismatched agent context by preserving explicit store roots and adding safe absolute-path fallback to the correct agent sessions directory. (#16288) Thanks @robbyczgw-cla.
1617
- BlueBubbles: include sender identity in group chat envelopes and pass clean message text to the agent prompt, aligning with iMessage/Signal formatting. (#16210) Thanks @zerone0x.
1718
- WhatsApp: honor per-account `dmPolicy` overrides (account-level settings now take precedence over channel defaults for inbound DMs). (#10082) Thanks @mcaxtr.
1819
- Security/Node Host: enforce `system.run` rawCommand/argv consistency to prevent allowlist/approval bypass. Thanks @christos-eth.

src/agents/subagent-announce.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,10 +230,12 @@ async function buildSubagentStatsLine(params: {
230230
});
231231

232232
const sessionId = entry?.sessionId;
233+
const agentId = resolveAgentIdFromSessionKey(params.sessionKey);
233234
let transcriptPath: string | undefined;
234235
if (sessionId && storePath) {
235236
try {
236237
transcriptPath = resolveSessionFilePath(sessionId, entry, {
238+
agentId,
237239
sessionsDir: path.dirname(storePath),
238240
});
239241
} catch {

src/agents/tools/sessions-list-tool.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,10 @@ export function createSessionsListTool(opts?: {
161161
transcriptPath = resolveSessionFilePath(
162162
sessionId,
163163
sessionFile ? { sessionFile } : undefined,
164-
{ sessionsDir: path.dirname(storePath) },
164+
{
165+
agentId: resolveAgentIdFromSessionKey(key),
166+
sessionsDir: path.dirname(storePath),
167+
},
165168
);
166169
} catch {
167170
transcriptPath = undefined;

src/config/sessions/paths.test.ts

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,30 +96,96 @@ describe("session path safety", () => {
9696
expect(resolved).toBe(path.resolve(sessionsDir, "abc-123-topic-42.jsonl"));
9797
});
9898

99-
it("rejects absolute sessionFile paths outside the sessions dir", () => {
99+
it("rejects absolute sessionFile paths outside known agent sessions dirs", () => {
100100
const sessionsDir = "/tmp/openclaw/agents/main/sessions";
101101

102102
expect(() =>
103103
resolveSessionFilePath(
104104
"sess-1",
105-
{ sessionFile: "/tmp/openclaw/agents/work/sessions/abc-123.jsonl" },
105+
{ sessionFile: "/tmp/openclaw/agents/work/not-sessions/abc-123.jsonl" },
106106
{ sessionsDir },
107107
),
108108
).toThrow(/within sessions directory/);
109109
});
110110

111+
it("uses explicit agentId fallback for absolute sessionFile outside sessionsDir", () => {
112+
const mainSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "main" }));
113+
const opsSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "ops" }));
114+
const opsSessionFile = path.join(opsSessionsDir, "abc-123.jsonl");
115+
116+
const resolved = resolveSessionFilePath(
117+
"sess-1",
118+
{ sessionFile: opsSessionFile },
119+
{ sessionsDir: mainSessionsDir, agentId: "ops" },
120+
);
121+
122+
expect(resolved).toBe(path.resolve(opsSessionFile));
123+
});
124+
125+
it("uses absolute path fallback when sessionFile includes a different agent dir", () => {
126+
const mainSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "main" }));
127+
const opsSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "ops" }));
128+
const opsSessionFile = path.join(opsSessionsDir, "abc-123.jsonl");
129+
130+
const resolved = resolveSessionFilePath(
131+
"sess-1",
132+
{ sessionFile: opsSessionFile },
133+
{ sessionsDir: mainSessionsDir },
134+
);
135+
136+
expect(resolved).toBe(path.resolve(opsSessionFile));
137+
});
138+
139+
it("uses sibling fallback for custom per-agent store roots", () => {
140+
const mainSessionsDir = "/srv/custom/agents/main/sessions";
141+
const opsSessionFile = "/srv/custom/agents/ops/sessions/abc-123.jsonl";
142+
143+
const resolved = resolveSessionFilePath(
144+
"sess-1",
145+
{ sessionFile: opsSessionFile },
146+
{ sessionsDir: mainSessionsDir, agentId: "ops" },
147+
);
148+
149+
expect(resolved).toBe(path.resolve(opsSessionFile));
150+
});
151+
152+
it("uses extracted agent fallback for custom per-agent store roots", () => {
153+
const mainSessionsDir = "/srv/custom/agents/main/sessions";
154+
const opsSessionFile = "/srv/custom/agents/ops/sessions/abc-123.jsonl";
155+
156+
const resolved = resolveSessionFilePath(
157+
"sess-1",
158+
{ sessionFile: opsSessionFile },
159+
{ sessionsDir: mainSessionsDir },
160+
);
161+
162+
expect(resolved).toBe(path.resolve(opsSessionFile));
163+
});
164+
111165
it("uses agent sessions dir fallback for transcript path", () => {
112166
const resolved = resolveSessionTranscriptPath("sess-1", "main");
113167
expect(resolved.endsWith(path.join("agents", "main", "sessions", "sess-1.jsonl"))).toBe(true);
114168
});
115169

116-
it("prefers storePath when resolving session file options", () => {
170+
it("keeps storePath and agentId when resolving session file options", () => {
117171
const opts = resolveSessionFilePathOptions({
118172
storePath: "/tmp/custom/agent-store/sessions.json",
119173
agentId: "ops",
120174
});
121175
expect(opts).toEqual({
122176
sessionsDir: path.resolve("/tmp/custom/agent-store"),
177+
agentId: "ops",
178+
});
179+
});
180+
181+
it("keeps custom per-agent store roots when agentId is provided", () => {
182+
const opts = resolveSessionFilePathOptions({
183+
storePath: "/srv/custom/agents/ops/sessions/sessions.json",
184+
agentId: "ops",
185+
});
186+
expect(opts).toEqual({
187+
sessionsDir: path.resolve("/srv/custom/agents/ops/sessions"),
188+
agentId: "ops",
123189
});
124190
});
125191

src/config/sessions/paths.ts

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,12 @@ export function resolveSessionFilePathOptions(params: {
4242
agentId?: string;
4343
storePath?: string;
4444
}): SessionFilePathOptions | undefined {
45+
const agentId = params.agentId?.trim();
4546
const storePath = params.storePath?.trim();
4647
if (storePath) {
47-
return { sessionsDir: path.dirname(path.resolve(storePath)) };
48+
const sessionsDir = path.dirname(path.resolve(storePath));
49+
return agentId ? { sessionsDir, agentId } : { sessionsDir };
4850
}
49-
const agentId = params.agentId?.trim();
5051
if (agentId) {
5152
return { agentId };
5253
}
@@ -71,7 +72,51 @@ function resolveSessionsDir(opts?: SessionFilePathOptions): string {
7172
return resolveAgentSessionsDir(opts?.agentId);
7273
}
7374

74-
function resolvePathWithinSessionsDir(sessionsDir: string, candidate: string): string {
75+
function resolvePathFromAgentSessionsDir(
76+
agentSessionsDir: string,
77+
candidateAbsPath: string,
78+
): string | undefined {
79+
const agentBase = path.resolve(agentSessionsDir);
80+
const relative = path.relative(agentBase, candidateAbsPath);
81+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
82+
return undefined;
83+
}
84+
return path.resolve(agentBase, relative);
85+
}
86+
87+
function resolveSiblingAgentSessionsDir(
88+
baseSessionsDir: string,
89+
agentId: string,
90+
): string | undefined {
91+
const resolvedBase = path.resolve(baseSessionsDir);
92+
if (path.basename(resolvedBase) !== "sessions") {
93+
return undefined;
94+
}
95+
const baseAgentDir = path.dirname(resolvedBase);
96+
const baseAgentsDir = path.dirname(baseAgentDir);
97+
if (path.basename(baseAgentsDir) !== "agents") {
98+
return undefined;
99+
}
100+
const rootDir = path.dirname(baseAgentsDir);
101+
return path.join(rootDir, "agents", normalizeAgentId(agentId), "sessions");
102+
}
103+
104+
function extractAgentIdFromAbsoluteSessionPath(candidateAbsPath: string): string | undefined {
105+
const normalized = path.normalize(path.resolve(candidateAbsPath));
106+
const parts = normalized.split(path.sep).filter(Boolean);
107+
const sessionsIndex = parts.lastIndexOf("sessions");
108+
if (sessionsIndex < 2 || parts[sessionsIndex - 2] !== "agents") {
109+
return undefined;
110+
}
111+
const agentId = parts[sessionsIndex - 1];
112+
return agentId || undefined;
113+
}
114+
115+
function resolvePathWithinSessionsDir(
116+
sessionsDir: string,
117+
candidate: string,
118+
opts?: { agentId?: string },
119+
): string {
75120
const trimmed = candidate.trim();
76121
if (!trimmed) {
77122
throw new Error("Session file path must not be empty");
@@ -81,6 +126,34 @@ function resolvePathWithinSessionsDir(sessionsDir: string, candidate: string): s
81126
// Older versions stored absolute sessionFile paths in sessions.json;
82127
// convert them to relative so the containment check passes.
83128
const normalized = path.isAbsolute(trimmed) ? path.relative(resolvedBase, trimmed) : trimmed;
129+
if (normalized.startsWith("..") && path.isAbsolute(trimmed)) {
130+
const tryAgentFallback = (agentId: string): string | undefined => {
131+
const normalizedAgentId = normalizeAgentId(agentId);
132+
const siblingSessionsDir = resolveSiblingAgentSessionsDir(resolvedBase, normalizedAgentId);
133+
if (siblingSessionsDir) {
134+
const siblingResolved = resolvePathFromAgentSessionsDir(siblingSessionsDir, trimmed);
135+
if (siblingResolved) {
136+
return siblingResolved;
137+
}
138+
}
139+
return resolvePathFromAgentSessionsDir(resolveAgentSessionsDir(normalizedAgentId), trimmed);
140+
};
141+
142+
const explicitAgentId = opts?.agentId?.trim();
143+
if (explicitAgentId) {
144+
const resolvedFromAgent = tryAgentFallback(explicitAgentId);
145+
if (resolvedFromAgent) {
146+
return resolvedFromAgent;
147+
}
148+
}
149+
const extractedAgentId = extractAgentIdFromAbsoluteSessionPath(trimmed);
150+
if (extractedAgentId) {
151+
const resolvedFromPath = tryAgentFallback(extractedAgentId);
152+
if (resolvedFromPath) {
153+
return resolvedFromPath;
154+
}
155+
}
156+
}
84157
if (!normalized || normalized.startsWith("..") || path.isAbsolute(normalized)) {
85158
throw new Error("Session file path must be within sessions directory");
86159
}
@@ -122,7 +195,7 @@ export function resolveSessionFilePath(
122195
const sessionsDir = resolveSessionsDir(opts);
123196
const candidate = entry?.sessionFile?.trim();
124197
if (candidate) {
125-
return resolvePathWithinSessionsDir(sessionsDir, candidate);
198+
return resolvePathWithinSessionsDir(sessionsDir, candidate, { agentId: opts?.agentId });
126199
}
127200
return resolveSessionTranscriptPathInDir(sessionId, sessionsDir);
128201
}

src/config/sessions/transcript.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export async function appendAssistantMessageToSessionTranscript(params: {
106106
let sessionFile: string;
107107
try {
108108
sessionFile = resolveSessionFilePath(entry.sessionId, entry, {
109+
agentId: params.agentId,
109110
sessionsDir: path.dirname(storePath),
110111
});
111112
} catch (err) {

src/gateway/server-methods/chat.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,9 @@ function resolveTranscriptPath(params: {
5353
sessionId: string;
5454
storePath: string | undefined;
5555
sessionFile?: string;
56+
agentId?: string;
5657
}): string | null {
57-
const { sessionId, storePath, sessionFile } = params;
58+
const { sessionId, storePath, sessionFile, agentId } = params;
5859
if (!storePath && !sessionFile) {
5960
return null;
6061
}
@@ -63,7 +64,7 @@ function resolveTranscriptPath(params: {
6364
return resolveSessionFilePath(
6465
sessionId,
6566
sessionFile ? { sessionFile } : undefined,
66-
sessionsDir ? { sessionsDir } : undefined,
67+
sessionsDir || agentId ? { sessionsDir, agentId } : undefined,
6768
);
6869
} catch {
6970
return null;
@@ -99,12 +100,14 @@ function appendAssistantTranscriptMessage(params: {
99100
sessionId: string;
100101
storePath: string | undefined;
101102
sessionFile?: string;
103+
agentId?: string;
102104
createIfMissing?: boolean;
103105
}): TranscriptAppendResult {
104106
const transcriptPath = resolveTranscriptPath({
105107
sessionId: params.sessionId,
106108
storePath: params.storePath,
107109
sessionFile: params.sessionFile,
110+
agentId: params.agentId,
108111
});
109112
if (!transcriptPath) {
110113
return { ok: false, error: "transcript path not resolved" };
@@ -572,6 +575,7 @@ export const chatHandlers: GatewayRequestHandlers = {
572575
sessionId,
573576
storePath: latestStorePath,
574577
sessionFile: latestEntry?.sessionFile,
578+
agentId,
575579
createIfMissing: true,
576580
});
577581
if (appended.ok) {
@@ -666,7 +670,7 @@ export const chatHandlers: GatewayRequestHandlers = {
666670

667671
// Load session to find transcript file
668672
const rawSessionKey = p.sessionKey;
669-
const { storePath, entry } = loadSessionEntry(rawSessionKey);
673+
const { cfg, storePath, entry } = loadSessionEntry(rawSessionKey);
670674
const sessionId = entry?.sessionId;
671675
if (!sessionId || !storePath) {
672676
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "session not found"));
@@ -679,6 +683,7 @@ export const chatHandlers: GatewayRequestHandlers = {
679683
sessionId,
680684
storePath,
681685
sessionFile: entry?.sessionFile,
686+
agentId: resolveSessionAgentId({ sessionKey: rawSessionKey, config: cfg }),
682687
createIfMissing: false,
683688
});
684689
if (!appended.ok || !appended.messageId || !appended.message) {

src/gateway/session-utils.fs.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,58 @@ describe("readSessionMessages", () => {
475475
expect(marker.__openclaw?.id).toBe("comp-1");
476476
expect(typeof marker.timestamp).toBe("number");
477477
});
478+
479+
test("reads cross-agent absolute sessionFile when storePath points to another agent dir", () => {
480+
const sessionId = "cross-agent-default-root";
481+
const sessionFile = path.join(tmpDir, "agents", "ops", "sessions", `${sessionId}.jsonl`);
482+
fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
483+
fs.writeFileSync(
484+
sessionFile,
485+
[
486+
JSON.stringify({ type: "session", version: 1, id: sessionId }),
487+
JSON.stringify({ message: { role: "user", content: "from-ops" } }),
488+
].join("\n"),
489+
"utf-8",
490+
);
491+
492+
const wrongStorePath = path.join(tmpDir, "agents", "main", "sessions", "sessions.json");
493+
const out = readSessionMessages(sessionId, wrongStorePath, sessionFile);
494+
495+
expect(out).toEqual([{ role: "user", content: "from-ops" }]);
496+
});
497+
498+
test("reads cross-agent absolute sessionFile for custom per-agent store roots", () => {
499+
const sessionId = "cross-agent-custom-root";
500+
const sessionFile = path.join(
501+
tmpDir,
502+
"custom",
503+
"agents",
504+
"ops",
505+
"sessions",
506+
`${sessionId}.jsonl`,
507+
);
508+
fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
509+
fs.writeFileSync(
510+
sessionFile,
511+
[
512+
JSON.stringify({ type: "session", version: 1, id: sessionId }),
513+
JSON.stringify({ message: { role: "assistant", content: "from-custom-ops" } }),
514+
].join("\n"),
515+
"utf-8",
516+
);
517+
518+
const wrongStorePath = path.join(
519+
tmpDir,
520+
"custom",
521+
"agents",
522+
"main",
523+
"sessions",
524+
"sessions.json",
525+
);
526+
const out = readSessionMessages(sessionId, wrongStorePath, sessionFile);
527+
528+
expect(out).toEqual([{ role: "assistant", content: "from-custom-ops" }]);
529+
});
478530
});
479531

480532
describe("readSessionPreviewItemsFromTranscript", () => {
@@ -594,6 +646,22 @@ describe("resolveSessionTranscriptCandidates", () => {
594646
});
595647

596648
describe("resolveSessionTranscriptCandidates safety", () => {
649+
test("keeps cross-agent absolute sessionFile when storePath agent context differs", () => {
650+
const storePath = "/tmp/openclaw/agents/main/sessions/sessions.json";
651+
const sessionFile = "/tmp/openclaw/agents/ops/sessions/sess-safe.jsonl";
652+
const candidates = resolveSessionTranscriptCandidates("sess-safe", storePath, sessionFile);
653+
654+
expect(candidates.map((value) => path.resolve(value))).toContain(path.resolve(sessionFile));
655+
});
656+
657+
test("keeps cross-agent absolute sessionFile for custom per-agent store roots", () => {
658+
const storePath = "/srv/custom/agents/main/sessions/sessions.json";
659+
const sessionFile = "/srv/custom/agents/ops/sessions/sess-safe.jsonl";
660+
const candidates = resolveSessionTranscriptCandidates("sess-safe", storePath, sessionFile);
661+
662+
expect(candidates.map((value) => path.resolve(value))).toContain(path.resolve(sessionFile));
663+
});
664+
597665
test("drops unsafe session IDs instead of producing traversal paths", () => {
598666
const candidates = resolveSessionTranscriptCandidates(
599667
"../etc/passwd",

0 commit comments

Comments
 (0)