mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:39:35 +00:00
fix(sessions): resolve transcript paths with explicit agent context (#16288)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 7cbe9deca9
Co-authored-by: robbyczgw-cla <239660374+robbyczgw-cla@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
@@ -96,30 +96,96 @@ describe("session path safety", () => {
|
||||
expect(resolved).toBe(path.resolve(sessionsDir, "abc-123-topic-42.jsonl"));
|
||||
});
|
||||
|
||||
it("rejects absolute sessionFile paths outside the sessions dir", () => {
|
||||
it("rejects absolute sessionFile paths outside known agent sessions dirs", () => {
|
||||
const sessionsDir = "/tmp/openclaw/agents/main/sessions";
|
||||
|
||||
expect(() =>
|
||||
resolveSessionFilePath(
|
||||
"sess-1",
|
||||
{ sessionFile: "/tmp/openclaw/agents/work/sessions/abc-123.jsonl" },
|
||||
{ sessionFile: "/tmp/openclaw/agents/work/not-sessions/abc-123.jsonl" },
|
||||
{ sessionsDir },
|
||||
),
|
||||
).toThrow(/within sessions directory/);
|
||||
});
|
||||
|
||||
it("uses explicit agentId fallback for absolute sessionFile outside sessionsDir", () => {
|
||||
const mainSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "main" }));
|
||||
const opsSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "ops" }));
|
||||
const opsSessionFile = path.join(opsSessionsDir, "abc-123.jsonl");
|
||||
|
||||
const resolved = resolveSessionFilePath(
|
||||
"sess-1",
|
||||
{ sessionFile: opsSessionFile },
|
||||
{ sessionsDir: mainSessionsDir, agentId: "ops" },
|
||||
);
|
||||
|
||||
expect(resolved).toBe(path.resolve(opsSessionFile));
|
||||
});
|
||||
|
||||
it("uses absolute path fallback when sessionFile includes a different agent dir", () => {
|
||||
const mainSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "main" }));
|
||||
const opsSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "ops" }));
|
||||
const opsSessionFile = path.join(opsSessionsDir, "abc-123.jsonl");
|
||||
|
||||
const resolved = resolveSessionFilePath(
|
||||
"sess-1",
|
||||
{ sessionFile: opsSessionFile },
|
||||
{ sessionsDir: mainSessionsDir },
|
||||
);
|
||||
|
||||
expect(resolved).toBe(path.resolve(opsSessionFile));
|
||||
});
|
||||
|
||||
it("uses sibling fallback for custom per-agent store roots", () => {
|
||||
const mainSessionsDir = "/srv/custom/agents/main/sessions";
|
||||
const opsSessionFile = "/srv/custom/agents/ops/sessions/abc-123.jsonl";
|
||||
|
||||
const resolved = resolveSessionFilePath(
|
||||
"sess-1",
|
||||
{ sessionFile: opsSessionFile },
|
||||
{ sessionsDir: mainSessionsDir, agentId: "ops" },
|
||||
);
|
||||
|
||||
expect(resolved).toBe(path.resolve(opsSessionFile));
|
||||
});
|
||||
|
||||
it("uses extracted agent fallback for custom per-agent store roots", () => {
|
||||
const mainSessionsDir = "/srv/custom/agents/main/sessions";
|
||||
const opsSessionFile = "/srv/custom/agents/ops/sessions/abc-123.jsonl";
|
||||
|
||||
const resolved = resolveSessionFilePath(
|
||||
"sess-1",
|
||||
{ sessionFile: opsSessionFile },
|
||||
{ sessionsDir: mainSessionsDir },
|
||||
);
|
||||
|
||||
expect(resolved).toBe(path.resolve(opsSessionFile));
|
||||
});
|
||||
|
||||
it("uses agent sessions dir fallback for transcript path", () => {
|
||||
const resolved = resolveSessionTranscriptPath("sess-1", "main");
|
||||
expect(resolved.endsWith(path.join("agents", "main", "sessions", "sess-1.jsonl"))).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers storePath when resolving session file options", () => {
|
||||
it("keeps storePath and agentId when resolving session file options", () => {
|
||||
const opts = resolveSessionFilePathOptions({
|
||||
storePath: "/tmp/custom/agent-store/sessions.json",
|
||||
agentId: "ops",
|
||||
});
|
||||
expect(opts).toEqual({
|
||||
sessionsDir: path.resolve("/tmp/custom/agent-store"),
|
||||
agentId: "ops",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps custom per-agent store roots when agentId is provided", () => {
|
||||
const opts = resolveSessionFilePathOptions({
|
||||
storePath: "/srv/custom/agents/ops/sessions/sessions.json",
|
||||
agentId: "ops",
|
||||
});
|
||||
expect(opts).toEqual({
|
||||
sessionsDir: path.resolve("/srv/custom/agents/ops/sessions"),
|
||||
agentId: "ops",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -42,11 +42,12 @@ export function resolveSessionFilePathOptions(params: {
|
||||
agentId?: string;
|
||||
storePath?: string;
|
||||
}): SessionFilePathOptions | undefined {
|
||||
const agentId = params.agentId?.trim();
|
||||
const storePath = params.storePath?.trim();
|
||||
if (storePath) {
|
||||
return { sessionsDir: path.dirname(path.resolve(storePath)) };
|
||||
const sessionsDir = path.dirname(path.resolve(storePath));
|
||||
return agentId ? { sessionsDir, agentId } : { sessionsDir };
|
||||
}
|
||||
const agentId = params.agentId?.trim();
|
||||
if (agentId) {
|
||||
return { agentId };
|
||||
}
|
||||
@@ -71,7 +72,51 @@ function resolveSessionsDir(opts?: SessionFilePathOptions): string {
|
||||
return resolveAgentSessionsDir(opts?.agentId);
|
||||
}
|
||||
|
||||
function resolvePathWithinSessionsDir(sessionsDir: string, candidate: string): string {
|
||||
function resolvePathFromAgentSessionsDir(
|
||||
agentSessionsDir: string,
|
||||
candidateAbsPath: string,
|
||||
): string | undefined {
|
||||
const agentBase = path.resolve(agentSessionsDir);
|
||||
const relative = path.relative(agentBase, candidateAbsPath);
|
||||
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
return undefined;
|
||||
}
|
||||
return path.resolve(agentBase, relative);
|
||||
}
|
||||
|
||||
function resolveSiblingAgentSessionsDir(
|
||||
baseSessionsDir: string,
|
||||
agentId: string,
|
||||
): string | undefined {
|
||||
const resolvedBase = path.resolve(baseSessionsDir);
|
||||
if (path.basename(resolvedBase) !== "sessions") {
|
||||
return undefined;
|
||||
}
|
||||
const baseAgentDir = path.dirname(resolvedBase);
|
||||
const baseAgentsDir = path.dirname(baseAgentDir);
|
||||
if (path.basename(baseAgentsDir) !== "agents") {
|
||||
return undefined;
|
||||
}
|
||||
const rootDir = path.dirname(baseAgentsDir);
|
||||
return path.join(rootDir, "agents", normalizeAgentId(agentId), "sessions");
|
||||
}
|
||||
|
||||
function extractAgentIdFromAbsoluteSessionPath(candidateAbsPath: string): string | undefined {
|
||||
const normalized = path.normalize(path.resolve(candidateAbsPath));
|
||||
const parts = normalized.split(path.sep).filter(Boolean);
|
||||
const sessionsIndex = parts.lastIndexOf("sessions");
|
||||
if (sessionsIndex < 2 || parts[sessionsIndex - 2] !== "agents") {
|
||||
return undefined;
|
||||
}
|
||||
const agentId = parts[sessionsIndex - 1];
|
||||
return agentId || undefined;
|
||||
}
|
||||
|
||||
function resolvePathWithinSessionsDir(
|
||||
sessionsDir: string,
|
||||
candidate: string,
|
||||
opts?: { agentId?: string },
|
||||
): string {
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Session file path must not be empty");
|
||||
@@ -81,6 +126,34 @@ function resolvePathWithinSessionsDir(sessionsDir: string, candidate: string): s
|
||||
// Older versions stored absolute sessionFile paths in sessions.json;
|
||||
// convert them to relative so the containment check passes.
|
||||
const normalized = path.isAbsolute(trimmed) ? path.relative(resolvedBase, trimmed) : trimmed;
|
||||
if (normalized.startsWith("..") && path.isAbsolute(trimmed)) {
|
||||
const tryAgentFallback = (agentId: string): string | undefined => {
|
||||
const normalizedAgentId = normalizeAgentId(agentId);
|
||||
const siblingSessionsDir = resolveSiblingAgentSessionsDir(resolvedBase, normalizedAgentId);
|
||||
if (siblingSessionsDir) {
|
||||
const siblingResolved = resolvePathFromAgentSessionsDir(siblingSessionsDir, trimmed);
|
||||
if (siblingResolved) {
|
||||
return siblingResolved;
|
||||
}
|
||||
}
|
||||
return resolvePathFromAgentSessionsDir(resolveAgentSessionsDir(normalizedAgentId), trimmed);
|
||||
};
|
||||
|
||||
const explicitAgentId = opts?.agentId?.trim();
|
||||
if (explicitAgentId) {
|
||||
const resolvedFromAgent = tryAgentFallback(explicitAgentId);
|
||||
if (resolvedFromAgent) {
|
||||
return resolvedFromAgent;
|
||||
}
|
||||
}
|
||||
const extractedAgentId = extractAgentIdFromAbsoluteSessionPath(trimmed);
|
||||
if (extractedAgentId) {
|
||||
const resolvedFromPath = tryAgentFallback(extractedAgentId);
|
||||
if (resolvedFromPath) {
|
||||
return resolvedFromPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!normalized || normalized.startsWith("..") || path.isAbsolute(normalized)) {
|
||||
throw new Error("Session file path must be within sessions directory");
|
||||
}
|
||||
@@ -122,7 +195,7 @@ export function resolveSessionFilePath(
|
||||
const sessionsDir = resolveSessionsDir(opts);
|
||||
const candidate = entry?.sessionFile?.trim();
|
||||
if (candidate) {
|
||||
return resolvePathWithinSessionsDir(sessionsDir, candidate);
|
||||
return resolvePathWithinSessionsDir(sessionsDir, candidate, { agentId: opts?.agentId });
|
||||
}
|
||||
return resolveSessionTranscriptPathInDir(sessionId, sessionsDir);
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ export async function appendAssistantMessageToSessionTranscript(params: {
|
||||
let sessionFile: string;
|
||||
try {
|
||||
sessionFile = resolveSessionFilePath(entry.sessionId, entry, {
|
||||
agentId: params.agentId,
|
||||
sessionsDir: path.dirname(storePath),
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user