fix: harden session transcript path resolution
This commit is contained in:
@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Sandbox: confine mirrored skill sync destinations to the sandbox `skills/` root and stop using frontmatter-controlled skill names as filesystem destination paths. Thanks @1seal.
|
||||
- Security/Web tools: treat browser/web content as untrusted by default (wrapped outputs for browser snapshot/tabs/console and structured external-content metadata for web tools), and strip `toolResult.details` from model-facing transcript/compaction inputs to reduce prompt-injection replay risk.
|
||||
- Security/Hooks: harden webhook and device token verification with shared constant-time secret comparison, and add per-client auth-failure throttling for hook endpoints (`429` + `Retry-After`). Thanks @akhmittra.
|
||||
- Sessions/Gateway: harden transcript path resolution and reject unsafe session IDs/file paths so session operations stay within agent sessions directories. Thanks @akhmittra.
|
||||
- Gateway: raise WS payload/buffer limits so 5,000,000-byte image attachments work reliably. (#14486) Thanks @0xRaini.
|
||||
- Logging/CLI: use local timezone timestamps for console prefixing, and include `±HH:MM` offsets when using `openclaw logs --local-time` to avoid ambiguity. (#14771) Thanks @0xRaini.
|
||||
- Gateway: drain active turns before restart to prevent message loss. (#13931) Thanks @0xRaini.
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
loadSessionStore,
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveMainSessionKey,
|
||||
resolveSessionFilePath,
|
||||
resolveStorePath,
|
||||
} from "../config/sessions.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
@@ -229,8 +230,16 @@ async function buildSubagentStatsLine(params: {
|
||||
});
|
||||
|
||||
const sessionId = entry?.sessionId;
|
||||
const transcriptPath =
|
||||
sessionId && storePath ? path.join(path.dirname(storePath), `${sessionId}.jsonl`) : undefined;
|
||||
let transcriptPath: string | undefined;
|
||||
if (sessionId && storePath) {
|
||||
try {
|
||||
transcriptPath = resolveSessionFilePath(sessionId, entry, {
|
||||
sessionsDir: path.dirname(storePath),
|
||||
});
|
||||
} catch {
|
||||
transcriptPath = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const input = entry?.inputTokens;
|
||||
const output = entry?.outputTokens;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox";
|
||||
import path from "node:path";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { resolveSessionFilePath } from "../../config/sessions.js";
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
import { isSubagentSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||
import { jsonResult, readStringArrayParam } from "./common.js";
|
||||
@@ -152,10 +153,20 @@ export function createSessionsListTool(opts?: {
|
||||
});
|
||||
|
||||
const sessionId = typeof entry.sessionId === "string" ? entry.sessionId : undefined;
|
||||
const transcriptPath =
|
||||
sessionId && storePath
|
||||
? path.join(path.dirname(storePath), `${sessionId}.jsonl`)
|
||||
: undefined;
|
||||
const sessionFileRaw = (entry as { sessionFile?: unknown }).sessionFile;
|
||||
const sessionFile = typeof sessionFileRaw === "string" ? sessionFileRaw : undefined;
|
||||
let transcriptPath: string | undefined;
|
||||
if (sessionId && storePath) {
|
||||
try {
|
||||
transcriptPath = resolveSessionFilePath(
|
||||
sessionId,
|
||||
sessionFile ? { sessionFile } : undefined,
|
||||
{ sessionsDir: path.dirname(storePath) },
|
||||
);
|
||||
} catch {
|
||||
transcriptPath = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const row: SessionListRow = {
|
||||
key: displayKey,
|
||||
|
||||
@@ -55,10 +55,12 @@ export type SessionInitResult = {
|
||||
|
||||
function forkSessionFromParent(params: {
|
||||
parentEntry: SessionEntry;
|
||||
sessionsDir: string;
|
||||
}): { sessionId: string; sessionFile: string } | null {
|
||||
const parentSessionFile = resolveSessionFilePath(
|
||||
params.parentEntry.sessionId,
|
||||
params.parentEntry,
|
||||
{ sessionsDir: params.sessionsDir },
|
||||
);
|
||||
if (!parentSessionFile || !fs.existsSync(parentSessionFile)) {
|
||||
return null;
|
||||
@@ -320,6 +322,7 @@ export async function initSessionState(params: {
|
||||
);
|
||||
const forked = forkSessionFromParent({
|
||||
parentEntry: sessionStore[parentSessionKey],
|
||||
sessionsDir: path.dirname(storePath),
|
||||
});
|
||||
if (forked) {
|
||||
sessionId = forked.sessionId;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveStorePath } from "./paths.js";
|
||||
import {
|
||||
resolveSessionFilePath,
|
||||
resolveSessionTranscriptPath,
|
||||
resolveSessionTranscriptPathInDir,
|
||||
resolveStorePath,
|
||||
validateSessionId,
|
||||
} from "./paths.js";
|
||||
|
||||
describe("resolveStorePath", () => {
|
||||
afterEach(() => {
|
||||
@@ -20,3 +26,53 @@ describe("resolveStorePath", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session path safety", () => {
|
||||
it("validates safe session IDs", () => {
|
||||
expect(validateSessionId("sess-1")).toBe("sess-1");
|
||||
expect(validateSessionId("ABC_123.hello")).toBe("ABC_123.hello");
|
||||
});
|
||||
|
||||
it("rejects unsafe session IDs", () => {
|
||||
expect(() => validateSessionId("../etc/passwd")).toThrow(/Invalid session ID/);
|
||||
expect(() => validateSessionId("a/b")).toThrow(/Invalid session ID/);
|
||||
expect(() => validateSessionId("a\\b")).toThrow(/Invalid session ID/);
|
||||
expect(() => validateSessionId("/abs")).toThrow(/Invalid session ID/);
|
||||
});
|
||||
|
||||
it("resolves transcript path inside an explicit sessions dir", () => {
|
||||
const sessionsDir = "/tmp/openclaw/agents/main/sessions";
|
||||
const resolved = resolveSessionTranscriptPathInDir("sess-1", sessionsDir, "topic/a+b");
|
||||
|
||||
expect(resolved).toBe(path.resolve(sessionsDir, "sess-1-topic-topic%2Fa%2Bb.jsonl"));
|
||||
});
|
||||
|
||||
it("rejects unsafe sessionFile candidates that escape the sessions dir", () => {
|
||||
const sessionsDir = "/tmp/openclaw/agents/main/sessions";
|
||||
|
||||
expect(() =>
|
||||
resolveSessionFilePath("sess-1", { sessionFile: "../../etc/passwd" }, { sessionsDir }),
|
||||
).toThrow(/within sessions directory/);
|
||||
|
||||
expect(() =>
|
||||
resolveSessionFilePath("sess-1", { sessionFile: "/etc/passwd" }, { sessionsDir }),
|
||||
).toThrow(/within sessions directory/);
|
||||
});
|
||||
|
||||
it("accepts sessionFile candidates within the sessions dir", () => {
|
||||
const sessionsDir = "/tmp/openclaw/agents/main/sessions";
|
||||
|
||||
const resolved = resolveSessionFilePath(
|
||||
"sess-1",
|
||||
{ sessionFile: "subdir/threaded-session.jsonl" },
|
||||
{ sessionsDir },
|
||||
);
|
||||
|
||||
expect(resolved).toBe(path.resolve(sessionsDir, "subdir/threaded-session.jsonl"));
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
import { expandHomePrefix, resolveRequiredHomeDir } from "../../infra/home-dir.js";
|
||||
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { resolveStateDir } from "../paths.js";
|
||||
@@ -34,11 +33,44 @@ export function resolveDefaultSessionStorePath(agentId?: string): string {
|
||||
return path.join(resolveAgentSessionsDir(agentId), "sessions.json");
|
||||
}
|
||||
|
||||
export function resolveSessionTranscriptPath(
|
||||
export const SAFE_SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
|
||||
|
||||
export function validateSessionId(sessionId: string): string {
|
||||
const trimmed = sessionId.trim();
|
||||
if (!SAFE_SESSION_ID_RE.test(trimmed)) {
|
||||
throw new Error(`Invalid session ID: ${sessionId}`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function resolveSessionsDir(opts?: { agentId?: string; sessionsDir?: string }): string {
|
||||
const sessionsDir = opts?.sessionsDir?.trim();
|
||||
if (sessionsDir) {
|
||||
return path.resolve(sessionsDir);
|
||||
}
|
||||
return resolveAgentSessionsDir(opts?.agentId);
|
||||
}
|
||||
|
||||
function resolvePathWithinSessionsDir(sessionsDir: string, candidate: string): string {
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Session file path must not be empty");
|
||||
}
|
||||
const resolvedBase = path.resolve(sessionsDir);
|
||||
const resolvedCandidate = path.resolve(resolvedBase, trimmed);
|
||||
const relative = path.relative(resolvedBase, resolvedCandidate);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
throw new Error("Session file path must be within sessions directory");
|
||||
}
|
||||
return resolvedCandidate;
|
||||
}
|
||||
|
||||
export function resolveSessionTranscriptPathInDir(
|
||||
sessionId: string,
|
||||
agentId?: string,
|
||||
sessionsDir: string,
|
||||
topicId?: string | number,
|
||||
): string {
|
||||
const safeSessionId = validateSessionId(sessionId);
|
||||
const safeTopicId =
|
||||
typeof topicId === "string"
|
||||
? encodeURIComponent(topicId)
|
||||
@@ -46,17 +78,31 @@ export function resolveSessionTranscriptPath(
|
||||
? String(topicId)
|
||||
: undefined;
|
||||
const fileName =
|
||||
safeTopicId !== undefined ? `${sessionId}-topic-${safeTopicId}.jsonl` : `${sessionId}.jsonl`;
|
||||
return path.join(resolveAgentSessionsDir(agentId), fileName);
|
||||
safeTopicId !== undefined
|
||||
? `${safeSessionId}-topic-${safeTopicId}.jsonl`
|
||||
: `${safeSessionId}.jsonl`;
|
||||
return resolvePathWithinSessionsDir(sessionsDir, fileName);
|
||||
}
|
||||
|
||||
export function resolveSessionTranscriptPath(
|
||||
sessionId: string,
|
||||
agentId?: string,
|
||||
topicId?: string | number,
|
||||
): string {
|
||||
return resolveSessionTranscriptPathInDir(sessionId, resolveAgentSessionsDir(agentId), topicId);
|
||||
}
|
||||
|
||||
export function resolveSessionFilePath(
|
||||
sessionId: string,
|
||||
entry?: SessionEntry,
|
||||
opts?: { agentId?: string },
|
||||
entry?: { sessionFile?: string },
|
||||
opts?: { agentId?: string; sessionsDir?: string },
|
||||
): string {
|
||||
const sessionsDir = resolveSessionsDir(opts);
|
||||
const candidate = entry?.sessionFile?.trim();
|
||||
return candidate ? candidate : resolveSessionTranscriptPath(sessionId, opts?.agentId);
|
||||
if (candidate) {
|
||||
return resolvePathWithinSessionsDir(sessionsDir, candidate);
|
||||
}
|
||||
return resolveSessionTranscriptPathInDir(sessionId, sessionsDir);
|
||||
}
|
||||
|
||||
export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
|
||||
import { resolveDefaultSessionStorePath, resolveSessionTranscriptPath } from "./paths.js";
|
||||
import { resolveDefaultSessionStorePath, resolveSessionFilePath } from "./paths.js";
|
||||
import { loadSessionStore, updateSessionStore } from "./store.js";
|
||||
|
||||
function stripQuery(value: string): string {
|
||||
@@ -103,8 +103,17 @@ export async function appendAssistantMessageToSessionTranscript(params: {
|
||||
return { ok: false, reason: `unknown sessionKey: ${sessionKey}` };
|
||||
}
|
||||
|
||||
const sessionFile =
|
||||
entry.sessionFile?.trim() || resolveSessionTranscriptPath(entry.sessionId, params.agentId);
|
||||
let sessionFile: string;
|
||||
try {
|
||||
sessionFile = resolveSessionFilePath(entry.sessionId, entry, {
|
||||
sessionsDir: path.dirname(storePath),
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
|
||||
await ensureSessionHeader({ sessionFile, sessionId: entry.sessionId });
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("gateway chat.inject transcript writes", () => {
|
||||
return {
|
||||
...original,
|
||||
loadSessionEntry: () => ({
|
||||
storePath: "/tmp/store.json",
|
||||
storePath: path.join(dir, "sessions.json"),
|
||||
entry: {
|
||||
sessionId: "sess-1",
|
||||
sessionFile: transcriptPath,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
||||
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
|
||||
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
|
||||
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
|
||||
import { resolveSessionFilePath } from "../../config/sessions.js";
|
||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
||||
import {
|
||||
@@ -54,13 +55,19 @@ function resolveTranscriptPath(params: {
|
||||
sessionFile?: string;
|
||||
}): string | null {
|
||||
const { sessionId, storePath, sessionFile } = params;
|
||||
if (sessionFile) {
|
||||
return sessionFile;
|
||||
}
|
||||
if (!storePath) {
|
||||
if (!storePath && !sessionFile) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const sessionsDir = storePath ? path.dirname(storePath) : undefined;
|
||||
return resolveSessionFilePath(
|
||||
sessionId,
|
||||
sessionFile ? { sessionFile } : undefined,
|
||||
sessionsDir ? { sessionsDir } : undefined,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return path.join(path.dirname(storePath), `${sessionId}.jsonl`);
|
||||
}
|
||||
|
||||
function ensureTranscriptFile(params: { transcriptPath: string; sessionId: string }): {
|
||||
|
||||
@@ -107,40 +107,73 @@ describe("sessions.usage", () => {
|
||||
|
||||
it("resolves store entries by sessionId when queried via discovered agent-prefixed key", async () => {
|
||||
const storeKey = "agent:opus:slack:dm:u123";
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-usage-test-"));
|
||||
const sessionFile = path.join(tempDir, "s-opus.jsonl");
|
||||
fs.writeFileSync(sessionFile, "", "utf-8");
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-usage-test-"));
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
|
||||
try {
|
||||
const agentSessionsDir = path.join(stateDir, "agents", "opus", "sessions");
|
||||
fs.mkdirSync(agentSessionsDir, { recursive: true });
|
||||
const sessionFile = path.join(agentSessionsDir, "s-opus.jsonl");
|
||||
fs.writeFileSync(sessionFile, "", "utf-8");
|
||||
const respond = vi.fn();
|
||||
|
||||
// Swap the store mock for this test: the canonical key differs from the discovered key
|
||||
// but points at the same sessionId.
|
||||
vi.mocked(loadCombinedSessionStoreForGateway).mockReturnValue({
|
||||
storePath: "(multiple)",
|
||||
store: {
|
||||
[storeKey]: {
|
||||
sessionId: "s-opus",
|
||||
sessionFile: "s-opus.jsonl",
|
||||
label: "Named session",
|
||||
updatedAt: 999,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Query via discovered key: agent:<id>:<sessionId>
|
||||
await usageHandlers["sessions.usage"]({
|
||||
respond,
|
||||
params: {
|
||||
startDate: "2026-02-01",
|
||||
endDate: "2026-02-02",
|
||||
key: "agent:opus:s-opus",
|
||||
limit: 10,
|
||||
},
|
||||
} as unknown as Parameters<(typeof usageHandlers)["sessions.usage"]>[0]);
|
||||
|
||||
expect(respond).toHaveBeenCalledTimes(1);
|
||||
expect(respond.mock.calls[0]?.[0]).toBe(true);
|
||||
const result = respond.mock.calls[0]?.[1] as unknown as { sessions: Array<{ key: string }> };
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0]?.key).toBe(storeKey);
|
||||
} finally {
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects traversal-style keys in specific session usage lookups", async () => {
|
||||
const respond = vi.fn();
|
||||
|
||||
// Swap the store mock for this test: the canonical key differs from the discovered key
|
||||
// but points at the same sessionId.
|
||||
vi.mocked(loadCombinedSessionStoreForGateway).mockReturnValue({
|
||||
storePath: "(multiple)",
|
||||
store: {
|
||||
[storeKey]: {
|
||||
sessionId: "s-opus",
|
||||
sessionFile,
|
||||
label: "Named session",
|
||||
updatedAt: 999,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Query via discovered key: agent:<id>:<sessionId>
|
||||
await usageHandlers["sessions.usage"]({
|
||||
respond,
|
||||
params: {
|
||||
startDate: "2026-02-01",
|
||||
endDate: "2026-02-02",
|
||||
key: "agent:opus:s-opus",
|
||||
key: "agent:opus:../../etc/passwd",
|
||||
limit: 10,
|
||||
},
|
||||
} as unknown as Parameters<(typeof usageHandlers)["sessions.usage"]>[0]);
|
||||
|
||||
expect(respond).toHaveBeenCalledTimes(1);
|
||||
expect(respond.mock.calls[0]?.[0]).toBe(true);
|
||||
const result = respond.mock.calls[0]?.[1] as unknown as { sessions: Array<{ key: string }> };
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0]?.key).toBe(storeKey);
|
||||
expect(respond.mock.calls[0]?.[0]).toBe(false);
|
||||
const error = respond.mock.calls[0]?.[2] as { message?: string } | undefined;
|
||||
expect(error?.message).toContain("Invalid session reference");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { SessionEntry, SessionSystemPromptReport } from "../../config/sessions/types.js";
|
||||
import type {
|
||||
CostUsageSummary,
|
||||
@@ -291,7 +292,7 @@ export const usageHandlers: GatewayRequestHandlers = {
|
||||
const specificKey = typeof p.key === "string" ? p.key.trim() : null;
|
||||
|
||||
// Load session store for named sessions
|
||||
const { store } = loadCombinedSessionStoreForGateway(config);
|
||||
const { storePath, store } = loadCombinedSessionStoreForGateway(config);
|
||||
const now = Date.now();
|
||||
|
||||
// Merge discovered sessions with store entries
|
||||
@@ -331,9 +332,21 @@ export const usageHandlers: GatewayRequestHandlers = {
|
||||
const sessionId = storeEntry?.sessionId ?? keyRest;
|
||||
|
||||
// Resolve the session file path
|
||||
const sessionFile = resolveSessionFilePath(sessionId, storeEntry, {
|
||||
agentId: agentIdFromKey,
|
||||
});
|
||||
let sessionFile: string;
|
||||
try {
|
||||
const pathOpts =
|
||||
storePath && storePath !== "(multiple)"
|
||||
? { sessionsDir: path.dirname(storePath) }
|
||||
: { agentId: agentIdFromKey };
|
||||
sessionFile = resolveSessionFilePath(sessionId, storeEntry, pathOpts);
|
||||
} catch {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `Invalid session reference: ${specificKey}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(sessionFile);
|
||||
@@ -756,15 +769,25 @@ export const usageHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const { entry } = loadSessionEntry(key);
|
||||
const { entry, storePath } = loadSessionEntry(key);
|
||||
|
||||
// For discovered sessions (not in store), try using key as sessionId directly
|
||||
const parsed = parseAgentSessionKey(key);
|
||||
const agentId = parsed?.agentId;
|
||||
const rawSessionId = parsed?.rest ?? key;
|
||||
const sessionId = entry?.sessionId ?? rawSessionId;
|
||||
const sessionFile =
|
||||
entry?.sessionFile ?? resolveSessionFilePath(rawSessionId, entry, { agentId });
|
||||
let sessionFile: string;
|
||||
try {
|
||||
const pathOpts = storePath ? { sessionsDir: path.dirname(storePath) } : { agentId };
|
||||
sessionFile = resolveSessionFilePath(sessionId, entry, pathOpts);
|
||||
} catch {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `Invalid session key: ${key}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeseries = await loadSessionUsageTimeSeries({
|
||||
sessionId,
|
||||
@@ -798,15 +821,25 @@ export const usageHandlers: GatewayRequestHandlers = {
|
||||
: 200;
|
||||
|
||||
const config = loadConfig();
|
||||
const { entry } = loadSessionEntry(key);
|
||||
const { entry, storePath } = loadSessionEntry(key);
|
||||
|
||||
// For discovered sessions (not in store), try using key as sessionId directly
|
||||
const parsed = parseAgentSessionKey(key);
|
||||
const agentId = parsed?.agentId;
|
||||
const rawSessionId = parsed?.rest ?? key;
|
||||
const sessionId = entry?.sessionId ?? rawSessionId;
|
||||
const sessionFile =
|
||||
entry?.sessionFile ?? resolveSessionFilePath(rawSessionId, entry, { agentId });
|
||||
let sessionFile: string;
|
||||
try {
|
||||
const pathOpts = storePath ? { sessionsDir: path.dirname(storePath) } : { agentId };
|
||||
sessionFile = resolveSessionFilePath(sessionId, entry, pathOpts);
|
||||
} catch {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `Invalid session key: ${key}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { loadSessionLogs } = await import("../../infra/session-cost-usage.js");
|
||||
const logs = await loadSessionLogs({
|
||||
|
||||
@@ -507,3 +507,26 @@ describe("resolveSessionTranscriptCandidates", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSessionTranscriptCandidates safety", () => {
|
||||
test("drops unsafe session IDs instead of producing traversal paths", () => {
|
||||
const candidates = resolveSessionTranscriptCandidates(
|
||||
"../etc/passwd",
|
||||
"/tmp/openclaw/agents/main/sessions/sessions.json",
|
||||
);
|
||||
|
||||
expect(candidates).toEqual([]);
|
||||
});
|
||||
|
||||
test("drops unsafe sessionFile candidates and keeps safe fallbacks", () => {
|
||||
const storePath = "/tmp/openclaw/agents/main/sessions/sessions.json";
|
||||
const candidates = resolveSessionTranscriptCandidates(
|
||||
"sess-safe",
|
||||
storePath,
|
||||
"../../etc/passwd",
|
||||
);
|
||||
|
||||
expect(candidates.some((value) => value.includes("etc/passwd"))).toBe(false);
|
||||
expect(candidates).toContain(path.join(path.dirname(storePath), "sess-safe.jsonl"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,11 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { SessionPreviewItem } from "./session-utils.types.js";
|
||||
import { resolveSessionTranscriptPath } from "../config/sessions.js";
|
||||
import {
|
||||
resolveSessionFilePath,
|
||||
resolveSessionTranscriptPath,
|
||||
resolveSessionTranscriptPathInDir,
|
||||
} from "../config/sessions.js";
|
||||
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
|
||||
import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js";
|
||||
import { stripEnvelope } from "./chat-sanitize.js";
|
||||
@@ -61,19 +65,40 @@ export function resolveSessionTranscriptCandidates(
|
||||
agentId?: string,
|
||||
): string[] {
|
||||
const candidates: string[] = [];
|
||||
if (sessionFile) {
|
||||
candidates.push(sessionFile);
|
||||
}
|
||||
const pushCandidate = (resolve: () => string): void => {
|
||||
try {
|
||||
candidates.push(resolve());
|
||||
} catch {
|
||||
// Ignore invalid paths/IDs and keep scanning other safe candidates.
|
||||
}
|
||||
};
|
||||
|
||||
if (storePath) {
|
||||
const dir = path.dirname(storePath);
|
||||
candidates.push(path.join(dir, `${sessionId}.jsonl`));
|
||||
const sessionsDir = path.dirname(storePath);
|
||||
if (sessionFile) {
|
||||
pushCandidate(() => resolveSessionFilePath(sessionId, { sessionFile }, { sessionsDir }));
|
||||
}
|
||||
pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, sessionsDir));
|
||||
} else if (sessionFile) {
|
||||
if (agentId) {
|
||||
pushCandidate(() => resolveSessionFilePath(sessionId, { sessionFile }, { agentId }));
|
||||
} else {
|
||||
const trimmed = sessionFile.trim();
|
||||
if (trimmed) {
|
||||
candidates.push(path.resolve(trimmed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (agentId) {
|
||||
candidates.push(resolveSessionTranscriptPath(sessionId, agentId));
|
||||
pushCandidate(() => resolveSessionTranscriptPath(sessionId, agentId));
|
||||
}
|
||||
|
||||
const home = resolveRequiredHomeDir(process.env, os.homedir);
|
||||
candidates.push(path.join(home, ".openclaw", "sessions", `${sessionId}.jsonl`));
|
||||
return candidates;
|
||||
const legacyDir = path.join(home, ".openclaw", "sessions");
|
||||
pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, legacyDir));
|
||||
|
||||
return Array.from(new Set(candidates));
|
||||
}
|
||||
|
||||
export function archiveFileOnDisk(filePath: string, reason: string): string {
|
||||
|
||||
Reference in New Issue
Block a user